diff --git a/README.md b/README.md index e3e0298..0c35c21 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### Turn your repo into a launch worth upvoting. -Point Launch Kit at a project folder. It reads your README and drafts **launch‑ready posts — written natively for [Product Hunt, Hacker News, Reddit, and AppSumo](#-one-repo-every-audience)**. Runs 100% on your machine. Bring your own [OpenRouter](https://openrouter.ai) key. +Point Launch Kit at a project folder. It reads your README and drafts **launch‑ready posts — written natively for [Product Hunt, Hacker News, Reddit, AppSumo, and X / social](#-one-repo-every-audience)** — plus a full cross‑platform launch plan. Runs 100% on your machine. Bring your own [OpenRouter](https://openrouter.ai) key. [![CI](https://img.shields.io/github/actions/workflow/status/AhmedFr/launch-kit/ci.yml?branch=main&style=for-the-badge&logo=githubactions&logoColor=white&label=CI&color=E2563C&labelColor=1c1c1c)](https://github.com/AhmedFr/launch-kit/actions/workflows/ci.yml) [![GitHub stars](https://img.shields.io/github/stars/AhmedFr/launch-kit?style=for-the-badge&logo=github&color=E2563C&labelColor=1c1c1c)](https://github.com/AhmedFr/launch-kit/stargazers) @@ -20,9 +20,10 @@ Point Launch Kit at a project folder. It reads your README and drafts **launch ## ✨ What you get -- **One core, four native posts.** Launch Kit distills your project once, then rewrites it for each platform's audience — not one blurb copy‑pasted everywhere. -- **A guided launch, not just copy.** Gallery shot lists, a demo‑video storyboard, posting times, and a launch‑day checklist. -- **Live previews.** See each post rendered the way it'll actually look on Product Hunt, HN, Reddit, and AppSumo. +- **One core, five native posts.** Launch Kit distills your project once, then rewrites it for each platform's audience — not one blurb copy‑pasted everywhere. +- **A guided launch, not just copy.** A cross‑platform launch plan (L‑6 weeks → L+4 weeks with 30/7/48h countdowns, SEO/GEO, and post‑launch momentum), plus gallery shot lists, a demo‑video storyboard, posting times, a Product Hunt hour‑by‑hour battle plan, and KOL/UGC outreach templates. +- **Battle‑tested playbook baked in.** Every prompt is shaped by proven launch principles — user value first, content over channel, and concentrated, genuine momentum over vanity reach. +- **Live previews.** See each post rendered the way it'll actually look on Product Hunt, HN, Reddit, AppSumo, and X. - **Your runs, saved.** Every folder you launch is kept locally so you can pick up where you left off. - **Private by design.** Your code and README never leave your machine. No backend, no database, no account. - **Bring your own key.** Use the built‑in mock generator key‑free, or plug in your OpenRouter key for the real thing. @@ -63,10 +64,13 @@ A three‑step wizard, one run per folder: | Platform | What Launch Kit writes | | --- | --- | -| **Product Hunt** | Tagline + alternatives, description, topics, gallery shot list, video storyboard, maker's first comment, launch‑day ops & outreach | -| **Hacker News** | A `Show HN:` title, an honest technical post, your first comment, and posting tips | -| **Reddit** | Subreddit recommendations, a value‑first title + body, and comment etiquette | +| **Product Hunt** | Tagline + alternatives, description, topics, gallery shot list, video storyboard, maker's first comment, and launch‑day ops: hour‑by‑hour battle plan, upvote‑momentum tactics, comment moderation & outreach | +| **Hacker News** | A `Show HN:` title, an honest technical post, your first comment, posting tips, and engagement etiquette | +| **Reddit** | Subreddit recommendations with per‑sub rules, recommended posting timing, a value‑first title + body, and comment etiquette | | **AppSumo** | Deal headline, lifetime‑deal pitch, what's included, who it's for, and an FAQ | +| **Social / KOL** | An X (Twitter) launch thread, cold‑outreach DM templates for X / LinkedIn / Telegram influencers, and a UGC ask to turn early users into creators | + +Plus a **cross‑platform launch plan** spanning every channel: a 10‑week timeline (L‑6 weeks → L+4 weeks), 30/7/48‑hour pre‑launch countdowns, a keyword‑driven SEO/GEO checklist, and a post‑launch momentum cadence. ![Per-platform launch kit](docs/screenshots/kit.png) diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index c7d2a35..6cec54e 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -35,8 +35,14 @@ export async function POST(req: Request) { return NextResponse.json({ platform: body.platform, content }) } - // Full generation: distill the core once, then fan out to every platform in parallel. + // Full generation: distill the core once, then fan out to every platform in + // parallel, plus the cross-platform launch plan. The plan is best-effort — a + // failure there must not sink the whole generation. const core = await provider.generateCore(input) + const planPromise = provider.generatePlan(core, input).catch((err) => { + console.error('[api/generate] plan failed:', err?.message ?? err) + return undefined + }) const results = await Promise.allSettled( PLATFORM_IDS.map((id) => provider.generatePlatform(id, core, input)), ) @@ -60,6 +66,8 @@ export async function POST(req: Request) { } const generation: Generation = { core, platforms } + const plan = await planPromise + if (plan) generation.plan = plan return NextResponse.json({ generation, failed }) } catch (err) { const message = err instanceof Error ? err.message : 'Generation failed.' diff --git a/app/runs/[id]/page.tsx b/app/runs/[id]/page.tsx index 96993b6..c14cf35 100644 --- a/app/runs/[id]/page.tsx +++ b/app/runs/[id]/page.tsx @@ -9,6 +9,7 @@ import { ContextReview } from '@/components/context-review/ContextReview' import { PostKit } from '@/components/post-kit/PostKit' import { GeneratingKit } from '@/components/generating/GeneratingKit' import { platformToMarkdown } from '@/lib/export/to-markdown' +import { planToMarkdown } from '@/lib/export/plan-to-markdown' import { PLATFORMS, type PlatformId } from '@/lib/platforms' import { useRun } from '@/lib/runs/useRun' import { findByPath } from '@/lib/runs/store' @@ -109,6 +110,30 @@ function RunWizard({ id }: { id: string }) { toast.success(`${name} kit copied`) } + function planMarkdown(): string | null { + if (!state.generation?.plan) return null + return planToMarkdown(state.generation.core, state.generation.plan) + } + + function exportPlan() { + const md = planMarkdown() + if (!md) return + const blob = new Blob([md], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'launch-plan.md' + a.click() + URL.revokeObjectURL(url) + } + + async function copyPlan() { + const md = planMarkdown() + if (!md) return + await navigator.clipboard.writeText(md) + toast.success('Launch plan copied') + } + if (status === 'loading') { return

Loading run…

} @@ -166,6 +191,8 @@ function RunWizard({ id }: { id: string }) { regeneratingPlatform={regeneratingPlatform} onExportMarkdown={exportMarkdown} onCopyAll={copyAll} + onCopyPlan={copyPlan} + onExportPlan={exportPlan} onStartOver={() => router.push('/runs')} /> )} diff --git a/components/launch-plan/LaunchPlan/LaunchPlan.test.tsx b/components/launch-plan/LaunchPlan/LaunchPlan.test.tsx new file mode 100644 index 0000000..b1210b2 --- /dev/null +++ b/components/launch-plan/LaunchPlan/LaunchPlan.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LaunchPlan } from './LaunchPlan' +import type { LaunchPlan as LaunchPlanData } from '@/lib/types' + +const plan: LaunchPlanData = { + phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define the ICP'] }], + countdown30: ['Build the landing page'], + countdown7: ['Confirm the hunter'], + countdown48h: ['Final QA'], + seoGeo: ['Configure IndexNow'], + momentum: ['Post a recap'], +} + +describe('LaunchPlan', () => { + it('renders the timeline and every checklist section', () => { + render( {}} onExport={() => {}} />) + expect(screen.getByText('L-6 weeks')).toBeInTheDocument() + expect(screen.getByText('Define the ICP')).toBeInTheDocument() + expect(screen.getByText('Configure IndexNow')).toBeInTheDocument() + expect(screen.getByText('Post a recap')).toBeInTheDocument() + }) + + it('wires the copy and export actions', async () => { + const onCopy = vi.fn() + const onExport = vi.fn() + render() + await userEvent.click(screen.getByRole('button', { name: 'Copy' })) + await userEvent.click(screen.getByRole('button', { name: 'Export' })) + expect(onCopy).toHaveBeenCalledOnce() + expect(onExport).toHaveBeenCalledOnce() + }) +}) diff --git a/components/launch-plan/LaunchPlan/LaunchPlan.tsx b/components/launch-plan/LaunchPlan/LaunchPlan.tsx new file mode 100644 index 0000000..707f02f --- /dev/null +++ b/components/launch-plan/LaunchPlan/LaunchPlan.tsx @@ -0,0 +1,50 @@ +import { SectionCard } from '@/components/common/SectionCard' +import { Button } from '@/components/ui/button' +import type { LaunchPlanProps } from './LaunchPlan.types' + +function Checklist({ title, items }: { title: string; items: string[] }) { + if (items.length === 0) return null + return ( +
+
{title}
+
    {items.map((i) =>
  • {i}
  • )}
+
+ ) +} + +export function LaunchPlan({ plan, onCopy, onExport }: LaunchPlanProps) { + const actions = ( +
+ + +
+ ) + + return ( + +
+
Timeline · L-6 weeks → L+4 weeks
+
    + {plan.phases.map((phase) => ( +
  1. +
    + {phase.window} + — {phase.goal} +
    +
      {phase.tasks.map((t) =>
    • {t}
    • )}
    +
  2. + ))} +
+
+ +
+ + + +
+ + + +
+ ) +} diff --git a/components/launch-plan/LaunchPlan/LaunchPlan.types.ts b/components/launch-plan/LaunchPlan/LaunchPlan.types.ts new file mode 100644 index 0000000..9c0e1cc --- /dev/null +++ b/components/launch-plan/LaunchPlan/LaunchPlan.types.ts @@ -0,0 +1,7 @@ +import type { LaunchPlan as LaunchPlanData } from '@/lib/types' + +export type LaunchPlanProps = { + plan: LaunchPlanData + onCopy: () => void + onExport: () => void +} diff --git a/components/launch-plan/LaunchPlan/index.ts b/components/launch-plan/LaunchPlan/index.ts new file mode 100644 index 0000000..86418e4 --- /dev/null +++ b/components/launch-plan/LaunchPlan/index.ts @@ -0,0 +1 @@ +export { LaunchPlan } from './LaunchPlan' diff --git a/components/post-kit/PostKit/PostKit.test.tsx b/components/post-kit/PostKit/PostKit.test.tsx new file mode 100644 index 0000000..e23ecef --- /dev/null +++ b/components/post-kit/PostKit/PostKit.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PostKit } from './PostKit' +import { RuntimeConfigProvider } from '@/lib/config/RuntimeConfigProvider' +import type { RuntimeConfig } from '@/lib/config/runtime-config' +import { MockProvider } from '@/lib/generation/mock-provider' +import type { GenerateInput, Generation } from '@/lib/types' + +const input: GenerateInput = { + context: { + name: 'Acme', oneLiner: 'Ship faster', summary: 'Acme helps teams ship faster.', + features: ['Fast builds'], techStack: ['Next.js'], differentiators: ['Zero config'], + links: [], readmeExcerpt: '', warnings: [], + }, + refinements: { tone: 'bold', audience: 'developers' }, +} + +const config: RuntimeConfig = { provider: 'mock', hasKey: false, model: 'mock', localFsAvailable: true } + +let generation: Generation + +beforeAll(async () => { + const p = new MockProvider() + const core = await p.generateCore(input) + const [ph, hn] = await Promise.all([ + p.generatePlatform('product-hunt', core, input), + p.generatePlatform('hacker-news', core, input), + ]) + generation = { core, platforms: { 'product-hunt': ph, 'hacker-news': hn } as Generation['platforms'] } +}) + +function renderKit() { + return render( + + + , + ) +} + +describe('PostKit launch-ops tab', () => { + it('shows a Launch ops tab for Product Hunt and keeps ops out of the Edit view', async () => { + renderKit() + // Product Hunt is the default platform; the third tab is present. + expect(screen.getByRole('button', { name: 'Launch ops' })).toBeInTheDocument() + // Edit view (default) no longer renders the launch-day ops. + expect(screen.queryByText('Hour-by-hour (PT)')).not.toBeInTheDocument() + // Switching to the tab reveals the ops content. + await userEvent.click(screen.getByRole('button', { name: 'Launch ops' })) + expect(screen.getByText('Hour-by-hour (PT)')).toBeInTheDocument() + }) + + it('hides the Launch ops tab for platforms without launch-day ops', async () => { + renderKit() + await userEvent.click(screen.getByRole('button', { name: 'Hacker News' })) + expect(screen.queryByRole('button', { name: 'Launch ops' })).not.toBeInTheDocument() + }) +}) diff --git a/components/post-kit/PostKit/PostKit.tsx b/components/post-kit/PostKit/PostKit.tsx index 88245fe..61b56db 100644 --- a/components/post-kit/PostKit/PostKit.tsx +++ b/components/post-kit/PostKit/PostKit.tsx @@ -2,13 +2,20 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { RegenerateButton } from '@/components/common/RegenerateButton' +import { LaunchPlan } from '@/components/launch-plan/LaunchPlan' +import { LaunchOpsSection } from '@/components/post-kit/sections/LaunchOpsSection' import { PLATFORMS, type PlatformId } from '@/lib/platforms' import { PREVIEWS } from '@/lib/preview/registry' import { PLATFORM_SECTIONS } from '@/components/post-kit/platforms/registry' import { useRuntimeConfig } from '@/lib/config/RuntimeConfigProvider' import { canGenerate } from '@/lib/config/runtime-config' +import type { ProductHuntContent } from '@/lib/types' import type { PostKitProps } from './PostKit.types' +// Product Hunt is the only platform with a launch-day ops view; it gets a third tab. +type View = 'edit' | 'launch-ops' | 'preview' +const VIEW_LABELS: Record = { edit: 'Edit', 'launch-ops': 'Launch ops', preview: 'Preview' } + export function PostKit({ generation, productName, @@ -16,12 +23,14 @@ export function PostKit({ regeneratingPlatform, onExportMarkdown, onCopyAll, + onCopyPlan, + onExportPlan, onStartOver, }: PostKitProps) { - const { core, platforms } = generation + const { core, platforms, plan } = generation const firstReady = PLATFORMS.find((p) => platforms[p.id])?.id ?? 'product-hunt' const [platform, setPlatform] = useState(firstReady) - const [view, setView] = useState<'edit' | 'preview'>('edit') + const [view, setView] = useState('edit') const content = platforms[platform] const Sections = PLATFORM_SECTIONS[platform] @@ -30,6 +39,11 @@ export function PostKit({ const config = useRuntimeConfig() const canRegenerate = canGenerate(config) + // Only Product Hunt has launch-day ops; fall back to Edit when its tab can't apply. + const hasLaunchOps = platform === 'product-hunt' && Boolean(content) + const views: View[] = hasLaunchOps ? ['edit', 'launch-ops', 'preview'] : ['edit', 'preview'] + const activeView: View = view === 'launch-ops' && !hasLaunchOps ? 'edit' : view + return (
@@ -39,6 +53,13 @@ export function PostKit({
+ {/* Cross-platform launch plan — spans every channel, shown above the per-platform kits */} + {plan && ( +
+ +
+ )} + {/* Platform selector — drives both edit and preview */}
{PLATFORMS.map((p) => { @@ -64,18 +85,18 @@ export function PostKit({ })}
- {/* Toolbar: Edit | Preview + per-platform actions */} + {/* Toolbar: Edit | Launch ops (PH only) | Preview + per-platform actions */}
- {(['edit', 'preview'] as const).map((v) => ( + {views.map((v) => ( ))}
@@ -92,13 +113,15 @@ export function PostKit({
-
+
{!content ? (

This platform didn't generate. Switch to a ready platform, or start over to try again.

- ) : view === 'edit' ? ( + ) : activeView === 'edit' ? ( Sections ? : null + ) : activeView === 'launch-ops' ? ( + ) : Preview ? ( ) : ( diff --git a/components/post-kit/PostKit/PostKit.types.ts b/components/post-kit/PostKit/PostKit.types.ts index 51620cb..8f4b2fd 100644 --- a/components/post-kit/PostKit/PostKit.types.ts +++ b/components/post-kit/PostKit/PostKit.types.ts @@ -8,5 +8,7 @@ export type PostKitProps = { regeneratingPlatform: PlatformId | null onExportMarkdown: (platform: PlatformId) => void onCopyAll: (platform: PlatformId) => void + onCopyPlan: () => void + onExportPlan: () => void onStartOver: () => void } diff --git a/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx b/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx index b115632..5f8936c 100644 --- a/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx +++ b/components/post-kit/platforms/HackerNewsSections/HackerNewsSections.tsx @@ -39,6 +39,12 @@ export function HackerNewsSections({ content, loading = false }: PlatformSection
Avoid
    {hn.postingTips.avoid.map((a) =>
  • {a}
  • )}
+ {hn.postingTips.etiquette && hn.postingTips.etiquette.length > 0 && ( +
+
Etiquette
+
    {hn.postingTips.etiquette.map((e) =>
  • {e}
  • )}
+
+ )}
) diff --git a/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx b/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx index b3589ec..87ff157 100644 --- a/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx +++ b/components/post-kit/platforms/ProductHuntSections/ProductHuntSections.tsx @@ -2,10 +2,10 @@ import { CopySection } from '@/components/post-kit/sections/CopySection' import { TopicsCommentSection } from '@/components/post-kit/sections/TopicsCommentSection' import { GallerySection } from '@/components/post-kit/sections/GallerySection' import { VideoSection } from '@/components/post-kit/sections/VideoSection' -import { LaunchOpsSection } from '@/components/post-kit/sections/LaunchOpsSection' import type { ProductHuntContent } from '@/lib/types' import type { PlatformSectionsProps } from '../platform-sections.types' +// Launch-day ops live in their own "Launch ops" tab in PostKit, not in this edit view. export function ProductHuntSections({ content, loading }: PlatformSectionsProps) { const kit = content as ProductHuntContent return ( @@ -14,7 +14,6 @@ export function ProductHuntSections({ content, loading }: PlatformSectionsProps) - ) } diff --git a/components/post-kit/platforms/RedditSections/RedditSections.tsx b/components/post-kit/platforms/RedditSections/RedditSections.tsx index 7be0bc6..f53b4b2 100644 --- a/components/post-kit/platforms/RedditSections/RedditSections.tsx +++ b/components/post-kit/platforms/RedditSections/RedditSections.tsx @@ -10,12 +10,21 @@ export function RedditSections({ content, loading = false }: PlatformSectionsPro
    {reddit.subreddits.map((s) => ( -
  • - {s.name} - — {s.why} +
  • + + {s.name} + — {s.why} + + {s.rulesNote && ⚑ {s.rulesNote}}
  • ))}
+ {reddit.postingTiming && ( +
+ 🕐 + Best timing: {reddit.postingTiming} +
+ )}
diff --git a/components/post-kit/platforms/SocialSections/SocialSections.test.tsx b/components/post-kit/platforms/SocialSections/SocialSections.test.tsx new file mode 100644 index 0000000..a21a85b --- /dev/null +++ b/components/post-kit/platforms/SocialSections/SocialSections.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SocialSections } from './SocialSections' +import type { LaunchCore, SocialContent } from '@/lib/types' + +const core: LaunchCore = { + productName: 'Acme', essence: 'Ship faster', audience: 'developers', problem: 'Shipping is slow', features: ['Fast builds'], differentiators: ['Zero config'], +} + +const content: SocialContent = { + thread: { tweets: ['Devs: shipping is slow. I built Acme.', 'It does X, Y, Z.'] }, + kolOutreach: { twitter: 'Hey {name} on X', linkedin: 'Hi {name} on LinkedIn', telegram: 'Hi {name} on Telegram' }, + ugcAsk: 'Would you share how you use Acme?', + postingTips: { bestTimeET: '9:00 AM ET', hashtags: ['#buildinpublic'] }, +} + +describe('SocialSections', () => { + it('renders the thread, all outreach channels, the UGC ask, and posting tips', () => { + render() + expect(screen.getByText('Launch thread')).toBeInTheDocument() + expect(screen.getByText('It does X, Y, Z.')).toBeInTheDocument() + expect(screen.getByText('X / Twitter')).toBeInTheDocument() + expect(screen.getByText('LinkedIn')).toBeInTheDocument() + expect(screen.getByText('Telegram')).toBeInTheDocument() + expect(screen.getByText('Would you share how you use Acme?')).toBeInTheDocument() + expect(screen.getByText('#buildinpublic')).toBeInTheDocument() + }) +}) diff --git a/components/post-kit/platforms/SocialSections/SocialSections.tsx b/components/post-kit/platforms/SocialSections/SocialSections.tsx new file mode 100644 index 0000000..4056013 --- /dev/null +++ b/components/post-kit/platforms/SocialSections/SocialSections.tsx @@ -0,0 +1,70 @@ +import { SectionCard } from '@/components/common/SectionCard' +import { CopyButton } from '@/components/common/CopyButton' +import type { SocialContent } from '@/lib/types' +import type { PlatformSectionsProps } from '../platform-sections.types' + +export function SocialSections({ content, loading = false }: PlatformSectionsProps) { + const social = content as SocialContent + const fullThread = social.thread.tweets.join('\n\n') + const channels: { label: string; value: string }[] = [ + { label: 'X / Twitter', value: social.kolOutreach.twitter }, + { label: 'LinkedIn', value: social.kolOutreach.linkedin }, + { label: 'Telegram', value: social.kolOutreach.telegram }, + ] + + return ( +
+ +
+ {social.thread.tweets.length} tweets + +
+
    + {social.thread.tweets.map((tweet, i) => ( +
  1. +
    + {i + 1}/{social.thread.tweets.length} + +
    +

    {tweet}

    +
  2. + ))} +
+
+ + + {channels.map((c) => ( +
+
+ {c.label} + +
+

{c.value}

+
+ ))} +
+ + +
+ Invite an early user to create + +
+

{social.ugcAsk}

+
+ + +
+ 🕐 + Best time: {social.postingTips.bestTimeET} +
+ {social.postingTips.hashtags.length > 0 && ( +
+ {social.postingTips.hashtags.map((h) => ( + {h} + ))} +
+ )} +
+
+ ) +} diff --git a/components/post-kit/platforms/SocialSections/index.ts b/components/post-kit/platforms/SocialSections/index.ts new file mode 100644 index 0000000..7f5766f --- /dev/null +++ b/components/post-kit/platforms/SocialSections/index.ts @@ -0,0 +1 @@ +export { SocialSections } from './SocialSections' diff --git a/components/post-kit/platforms/registry.ts b/components/post-kit/platforms/registry.ts index bc25c74..c5ec0e3 100644 --- a/components/post-kit/platforms/registry.ts +++ b/components/post-kit/platforms/registry.ts @@ -5,6 +5,7 @@ import { ProductHuntSections } from './ProductHuntSections' import { HackerNewsSections } from './HackerNewsSections' import { RedditSections } from './RedditSections' import { AppSumoSections } from './AppSumoSections' +import { SocialSections } from './SocialSections' // Maps a platform to its edit-view section group. Adding a platform's editor is // a single entry here plus its Sections component folder. @@ -13,4 +14,5 @@ export const PLATFORM_SECTIONS: PartialLaunch day
    {launch.launchDayChecklist.map((c) =>
  • {c}
  • )}
+ {launch.hourByHour && launch.hourByHour.length > 0 && ( +
+
Hour-by-hour (PT)
+
    + {launch.hourByHour.map((step) => ( +
  1. + {step.timePT} + {step.action} +
  2. + ))} +
+
+ )} + {launch.momentumTactics && launch.momentumTactics.length > 0 && ( +
+
Upvote momentum
+
    {launch.momentumTactics.map((t) =>
  • {t}
  • )}
+
+ )} + {launch.commentModeration && launch.commentModeration.length > 0 && ( +
+
Comment moderation
+
    {launch.commentModeration.map((c) =>
  • {c}
  • )}
+
+ )}
Hunter outreach diff --git a/components/preview/SocialPreview/SocialPreview.tsx b/components/preview/SocialPreview/SocialPreview.tsx new file mode 100644 index 0000000..b29f9a9 --- /dev/null +++ b/components/preview/SocialPreview/SocialPreview.tsx @@ -0,0 +1,39 @@ +import type { PreviewProps } from '@/lib/preview/preview.types' +import type { SocialContent } from '@/lib/types' + +export function SocialPreview({ content, productName }: PreviewProps) { + const social = content as SocialContent + const handle = productName.trim().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 15) || 'maker' + + return ( +
+
+ {social.thread.tweets.map((tweet, i) => ( +
+
+ {handle.charAt(0).toUpperCase()} +
+
+
+ {productName} + @{handle} + {i === 0 && thread} +
+

{tweet}

+
+ 💬🔁♥↗ +
+
+
+ ))} +
+ {social.postingTips.hashtags.length > 0 && ( +
+ {social.postingTips.hashtags.map((h) => ( + {h} + ))} +
+ )} +
+ ) +} diff --git a/components/preview/SocialPreview/index.ts b/components/preview/SocialPreview/index.ts new file mode 100644 index 0000000..de79e36 --- /dev/null +++ b/components/preview/SocialPreview/index.ts @@ -0,0 +1 @@ +export { SocialPreview } from './SocialPreview' diff --git a/lib/export/plan-to-markdown.test.ts b/lib/export/plan-to-markdown.test.ts new file mode 100644 index 0000000..fe637ea --- /dev/null +++ b/lib/export/plan-to-markdown.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { planToMarkdown } from './plan-to-markdown' +import type { LaunchCore, LaunchPlan } from '@/lib/types' + +const core: LaunchCore = { + productName: 'Acme', essence: 'Ship faster', audience: 'developers', problem: 'Shipping is slow', features: ['Fast builds'], differentiators: ['Zero config'], +} + +const plan: LaunchPlan = { + phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define ICP'] }], + countdown30: ['Build the landing page'], + countdown7: ['Confirm the hunter'], + countdown48h: ['Final QA'], + seoGeo: ['Configure IndexNow'], + momentum: ['Post a recap'], +} + +describe('planToMarkdown', () => { + it('renders the timeline, countdowns, SEO/GEO and momentum', () => { + const md = planToMarkdown(core, plan) + expect(md).toContain('# Acme — Launch Plan') + expect(md).toContain('### L-6 weeks — Lock strategy') + expect(md).toContain('## 30-Day Countdown') + expect(md).toContain('## 7-Day Countdown') + expect(md).toContain('## Final 48 Hours') + expect(md).toContain('## SEO / GEO') + expect(md).toContain('## Post-Launch Momentum') + }) +}) diff --git a/lib/export/plan-to-markdown.ts b/lib/export/plan-to-markdown.ts new file mode 100644 index 0000000..a0cde3d --- /dev/null +++ b/lib/export/plan-to-markdown.ts @@ -0,0 +1,29 @@ +import type { LaunchCore, LaunchPlan } from '@/lib/types' + +const bullets = (items: string[]) => items.map((i) => `- ${i}`).join('\n') + +// Renders the cross-platform launch plan as Markdown for copy/export. +export function planToMarkdown(core: LaunchCore, plan: LaunchPlan): string { + return `# ${core.productName} — Launch Plan + +## Timeline (L-6 weeks → L+4 weeks) +${plan.phases + .map((p) => `### ${p.window} — ${p.goal}\n${bullets(p.tasks)}`) + .join('\n\n')} + +## 30-Day Countdown +${bullets(plan.countdown30)} + +## 7-Day Countdown +${bullets(plan.countdown7)} + +## Final 48 Hours +${bullets(plan.countdown48h)} + +## SEO / GEO +${bullets(plan.seoGeo)} + +## Post-Launch Momentum +${bullets(plan.momentum)} +` +} diff --git a/lib/export/to-markdown.test.ts b/lib/export/to-markdown.test.ts index 86aabb2..27e66ad 100644 --- a/lib/export/to-markdown.test.ts +++ b/lib/export/to-markdown.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { platformToMarkdown } from './to-markdown' -import type { LaunchCore, ProductHuntContent, HackerNewsContent, RedditContent, AppSumoContent } from '@/lib/types' +import type { LaunchCore, ProductHuntContent, HackerNewsContent, RedditContent, AppSumoContent, SocialContent } from '@/lib/types' const core: LaunchCore = { productName: 'Acme', essence: 'Ship faster', audience: 'developers', problem: 'Shipping is slow', features: ['Fast builds'], differentiators: ['Zero config'], @@ -12,21 +12,28 @@ const ph: ProductHuntContent = { firstComment: 'Hey hunters!', gallery: { shots: [{ title: 'Hero', purpose: 'wow', caption: 'cap', layoutHint: 'full-bleed' }] }, video: { hook: 'Tired?', scenes: [{ timeRange: '0-3s', visual: 'logo', onScreenText: 'Acme' }], lengthSec: 30, cta: 'Try it' }, - launch: { recommendedDay: 'Tuesday', recommendedTimePT: '12:01 AM PT', prelaunchChecklist: ['a'], launchDayChecklist: ['b'], outreach: { hunter: 'hi', supporters: 'yo' } }, + launch: { + recommendedDay: 'Tuesday', recommendedTimePT: '12:01 AM PT', prelaunchChecklist: ['a'], launchDayChecklist: ['b'], + hourByHour: [{ timePT: '12:01 AM PT', action: 'Go live' }], + momentumTactics: ['Ask for honest comments'], + commentModeration: ['Reply fast'], + outreach: { hunter: 'hi', supporters: 'yo' }, + }, } const hn: HackerNewsContent = { title: 'Show HN: Acme – ship faster', postBody: 'I built Acme to make builds fast.', firstComment: 'Happy to answer questions.', - postingTips: { bestTimeET: '8:00 AM ET', avoid: ['hype'] }, + postingTips: { bestTimeET: '8:00 AM ET', avoid: ['hype'], etiquette: ['Answer critique directly'] }, } const reddit: RedditContent = { - subreddits: [{ name: 'r/SideProject', why: 'makers share early projects' }], + subreddits: [{ name: 'r/SideProject', why: 'makers share early projects', rulesNote: 'Lead with value' }], title: 'I built Acme', body: 'Story here.', replyEtiquette: ['Reply to everyone'], + postingTiming: 'Weekday mornings ET', } const appSumo: AppSumoContent = { @@ -37,6 +44,13 @@ const appSumo: AppSumoContent = { faq: [{ q: 'Lifetime?', a: 'Yes.' }], } +const social: SocialContent = { + thread: { tweets: ['Devs: shipping is slow.', 'I built Acme.'] }, + kolOutreach: { twitter: 'Hey {name}', linkedin: 'Hi {name}', telegram: 'Hi {name}!' }, + ugcAsk: 'Share how you use Acme?', + postingTips: { bestTimeET: '9 AM ET', hashtags: ['#buildinpublic'] }, +} + describe('platformToMarkdown', () => { it('renders Product Hunt content with gallery and video', () => { const md = platformToMarkdown('product-hunt', core, ph) @@ -45,6 +59,9 @@ describe('platformToMarkdown', () => { expect(md).toContain('## Gallery') expect(md).toContain('## Demo Video') expect(md).toContain('Tuesday') + expect(md).toContain('### Hour-by-hour (PT)') + expect(md).toContain('### Upvote momentum') + expect(md).toContain('### Comment moderation') }) it('renders Hacker News content without a gallery section', () => { @@ -52,12 +69,15 @@ describe('platformToMarkdown', () => { expect(md).toContain('Show HN: Acme – ship faster') expect(md).toContain('I built Acme to make builds fast.') expect(md).not.toContain('## Gallery') + expect(md).toContain('Etiquette:') }) it('renders Reddit content with subreddits', () => { const md = platformToMarkdown('reddit', core, reddit) expect(md).toContain('r/SideProject') expect(md).toContain('Story here.') + expect(md).toContain('Lead with value') + expect(md).toContain('Best timing:') }) it('renders AppSumo content with the deal headline', () => { @@ -65,4 +85,12 @@ describe('platformToMarkdown', () => { expect(md).toContain('Lifetime access to Acme') expect(md).toContain('Pay once, own forever.') }) + + it('renders Social content with thread, outreach, and UGC ask', () => { + const md = platformToMarkdown('social', core, social) + expect(md).toContain('## X Launch Thread') + expect(md).toContain('## KOL Outreach') + expect(md).toContain('## UGC Ask') + expect(md).toContain('#buildinpublic') + }) }) diff --git a/lib/export/to-markdown.ts b/lib/export/to-markdown.ts index afb98ce..e508fc0 100644 --- a/lib/export/to-markdown.ts +++ b/lib/export/to-markdown.ts @@ -5,6 +5,7 @@ import type { PlatformContent, ProductHuntContent, RedditContent, + SocialContent, } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' @@ -46,7 +47,7 @@ ${bullets(launch.prelaunchChecklist)} ### Launch day ${bullets(launch.launchDayChecklist)} - +${launch.hourByHour?.length ? `\n### Hour-by-hour (PT)\n${launch.hourByHour.map((s) => `- \`${s.timePT}\` — ${s.action}`).join('\n')}\n` : ''}${launch.momentumTactics?.length ? `\n### Upvote momentum\n${bullets(launch.momentumTactics)}\n` : ''}${launch.commentModeration?.length ? `\n### Comment moderation\n${bullets(launch.commentModeration)}\n` : ''} ### Outreach — Hunter ${launch.outreach.hunter} @@ -72,15 +73,15 @@ ${hn.firstComment} Avoid: ${bullets(hn.postingTips.avoid)} -` +${hn.postingTips.etiquette?.length ? `\nEtiquette:\n${bullets(hn.postingTips.etiquette)}\n` : ''}` } function redditMarkdown(core: LaunchCore, reddit: RedditContent): string { return `# ${core.productName} — Reddit Launch Post ## Subreddits -${reddit.subreddits.map((s) => `- **${s.name}** — ${s.why}`).join('\n')} - +${reddit.subreddits.map((s) => `- **${s.name}** — ${s.why}${s.rulesNote ? `\n - ⚑ ${s.rulesNote}` : ''}`).join('\n')} +${reddit.postingTiming ? `\n**Best timing:** ${reddit.postingTiming}\n` : ''} ## Title ${reddit.title} @@ -112,6 +113,32 @@ ${deal.faq.map((f) => `**${f.q}**\n${f.a}`).join('\n\n')} ` } +function socialMarkdown(core: LaunchCore, social: SocialContent): string { + return `# ${core.productName} — Social / KOL Launch Kit + +## X Launch Thread +${social.thread.tweets.map((t, i) => `${i + 1}. ${t}`).join('\n\n')} + +## KOL Outreach +**X / Twitter:** +${social.kolOutreach.twitter} + +**LinkedIn:** +${social.kolOutreach.linkedin} + +**Telegram:** +${social.kolOutreach.telegram} + +## UGC Ask +${social.ugcAsk} + +## Posting Tips +**Best time:** ${social.postingTips.bestTimeET} + +Hashtags: ${social.postingTips.hashtags.join(' ')} +` +} + export function platformToMarkdown(platform: PlatformId, core: LaunchCore, content: PlatformContent): string { switch (platform) { case 'product-hunt': @@ -122,5 +149,7 @@ export function platformToMarkdown(platform: PlatformId, core: LaunchCore, conte return redditMarkdown(core, content as RedditContent) case 'appsumo': return appSumoMarkdown(core, content as AppSumoContent) + case 'social': + return socialMarkdown(core, content as SocialContent) } } diff --git a/lib/generation/core/core.prompt.ts b/lib/generation/core/core.prompt.ts index 1344274..98517b5 100644 --- a/lib/generation/core/core.prompt.ts +++ b/lib/generation/core/core.prompt.ts @@ -1,11 +1,14 @@ import type { GenerateInput } from '@/lib/types' -import { renderFacts } from '../prompt-shared' +import { PLAYBOOK_PRINCIPLES, renderFacts } from '../prompt-shared' export const CORE_SYSTEM_PROMPT = [ + PLAYBOOK_PRINCIPLES, + '', 'You are a product positioning strategist.', 'You distill a product into its platform-neutral essence so launch copy can later be written for any audience.', + 'The essence and value proposition you extract must be defensible and benefit-led — the foundation every channel amplifies.', 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', -].join(' ') +].join('\n') const CORE_SHAPE = `{ "productName": "the product's name", @@ -13,7 +16,10 @@ const CORE_SHAPE = `{ "audience": "who it is for", "problem": "the concrete problem it solves", "features": ["3-6 concrete capabilities"], - "differentiators": ["2-4 reasons it is different or better"] + "differentiators": ["2-4 reasons it is different or better"], + "valueProp": "the single-sentence promise to the user that every channel will amplify", + "icp": "the ideal customer profile — sharper and more specific than the broad audience", + "keywords": ["5-8 search/topic keywords for SEO, Product Hunt topics and community targeting"] }` export function buildCorePrompt(input: GenerateInput): string { diff --git a/lib/generation/core/core.schema.ts b/lib/generation/core/core.schema.ts index 4177623..134a3a7 100644 --- a/lib/generation/core/core.schema.ts +++ b/lib/generation/core/core.schema.ts @@ -1,6 +1,8 @@ import { z } from 'zod' // The shared, platform-agnostic essence every platform's content is written from. +// keywords/icp/valueProp are optional so cores persisted before this field existed +// still validate; new generations always populate them. export const launchCoreSchema = z.object({ productName: z.string(), essence: z.string(), @@ -8,6 +10,9 @@ export const launchCoreSchema = z.object({ problem: z.string(), features: z.array(z.string()), differentiators: z.array(z.string()), + keywords: z.array(z.string()).optional(), + icp: z.string().optional(), + valueProp: z.string().optional(), }) export type LaunchCoreSchema = z.infer diff --git a/lib/generation/mock-provider.test.ts b/lib/generation/mock-provider.test.ts index c005e21..11a901f 100644 --- a/lib/generation/mock-provider.test.ts +++ b/lib/generation/mock-provider.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { MockProvider } from './mock-provider' import { launchCoreSchema } from './core/core.schema' +import { launchPlanSchema } from './plan/plan.schema' import { PLATFORM_GENERATORS } from './platforms/registry' import type { GenerateInput } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' @@ -14,7 +15,7 @@ const input: GenerateInput = { refinements: { tone: 'bold', audience: 'developers' }, } -const PLATFORMS: PlatformId[] = ['product-hunt', 'appsumo', 'hacker-news', 'reddit'] +const PLATFORMS: PlatformId[] = ['product-hunt', 'appsumo', 'hacker-news', 'reddit', 'social'] describe('MockProvider', () => { it('produces a schema-valid core that references the product', async () => { @@ -37,4 +38,13 @@ describe('MockProvider', () => { const content = await provider.generatePlatform('product-hunt', core, input) expect((content as { copy: { tagline: string } }).copy.tagline.length).toBeLessThanOrEqual(60) }) + + it('produces a schema-valid launch plan spanning the timeline', async () => { + const provider = new MockProvider() + const core = await provider.generateCore(input) + const plan = await provider.generatePlan(core, input) + expect(() => launchPlanSchema.parse(plan)).not.toThrow() + expect(plan.phases.length).toBeGreaterThan(0) + expect(plan.seoGeo.length).toBeGreaterThan(0) + }) }) diff --git a/lib/generation/mock-provider.ts b/lib/generation/mock-provider.ts index aaf7877..fe26b78 100644 --- a/lib/generation/mock-provider.ts +++ b/lib/generation/mock-provider.ts @@ -3,9 +3,11 @@ import type { GenerateInput, HackerNewsContent, LaunchCore, + LaunchPlan, PlatformContent, ProductHuntContent, RedditContent, + SocialContent, } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' import type { GenerationProvider } from './provider.types' @@ -16,13 +18,20 @@ function clamp(s: string, n: number): string { function buildCore({ context, refinements }: GenerateInput): LaunchCore { const audience = refinements.audience || context.audience || 'builders' + const essence = context.oneLiner || context.summary || `${context.name} gets the job done` + const keywords = Array.from( + new Set([...context.techStack, ...context.features.map((f) => f.toLowerCase())]), + ).slice(0, 8) return { productName: context.name, - essence: context.oneLiner || context.summary || `${context.name} gets the job done`, + essence, audience, problem: context.problem || `${audience} waste time on a slow, manual process`, features: context.features.length ? context.features : ['Does the core job well', 'Simple to start', 'Built for speed'], differentiators: context.differentiators.length ? context.differentiators : ['Faster than the manual way', 'No setup required'], + valueProp: essence, + icp: refinements.audience || context.audience || `${audience} who want results without the busywork`, + keywords: keywords.length ? keywords : [context.name.toLowerCase(), 'productivity', 'tool'], } } @@ -77,6 +86,28 @@ function buildProductHunt(core: LaunchCore): ProductHuntContent { 'Share to your channels (X, LinkedIn, Slack/Discord)', 'Post a mid-day update with traction', ], + hourByHour: [ + { timePT: '12:01 AM PT', action: 'Go live and pin your first comment' }, + { timePT: '12:05–2:00 AM PT', action: 'Message your first cohort of genuine supporters to take a look' }, + { timePT: '6:00–9:00 AM PT', action: 'US wakes up — reply to every comment within minutes' }, + { timePT: '9:00 AM–12:00 PM PT', action: 'Share to X, LinkedIn and your communities with the live link' }, + { timePT: '12:00–2:00 PM PT', action: 'Post a mid-day traction update to re-engage early supporters' }, + { timePT: '2:00–6:00 PM PT', action: 'Keep answering comments; thank everyone who upvoted or shared' }, + { timePT: '6:00–9:00 PM PT', action: 'Final push to fence-sitters; ask for honest comments, not just upvotes' }, + { timePT: '9:00–11:59 PM PT', action: 'Last-call message and a thank-you to the community' }, + ], + momentumTactics: [ + 'Stagger outreach to real supporters across the day so engagement stays steady, not front-loaded', + 'Ask for an honest comment plus an upvote — comments weigh on ranking and credibility', + 'Reply fast and personally; an active maker thread pulls more visitors in', + 'Never buy or trade votes — Product Hunt detects it and it tanks ranking', + ], + commentModeration: [ + 'Triage every comment within minutes for the first 6 hours', + 'Answer hard questions directly and publicly — defensiveness reads worse than a flaw', + 'Thank supporters by name; turn praise into a follow-up question to keep the thread alive', + 'Pin the most useful exchange so newcomers see the product in use', + ], outreach: { hunter: `Hi {name}, I'm launching ${name} (${benefit}) on Product Hunt and admire your eye for ${audience} tools. Would you be open to hunting it? Assets ready to go.`, supporters: `Hey! ${name} is live on Product Hunt today 🚀 If it looks useful, an upvote and an honest comment would mean a lot: {link}`, @@ -100,6 +131,12 @@ function buildHackerNews(core: LaunchCore): HackerNewsContent { postingTips: { bestTimeET: '8:00–10:00 AM ET, Tuesday–Thursday', avoid: ['Marketing language or superlatives', 'Emoji and exclamation marks', 'Vague claims without specifics', 'Asking directly for upvotes'], + etiquette: [ + 'Answer technical critique directly and without defensiveness', + 'Be candid about trade-offs and what is still rough', + 'Reply in the first person as the maker, with specifics', + 'Let the work speak — never ask for upvotes', + ], }, } } @@ -108,9 +145,9 @@ function buildReddit(core: LaunchCore): RedditContent { const { productName: name, audience, essence, problem, features: feat } = core return { subreddits: [ - { name: 'r/SideProject', why: 'makers share and get feedback on early projects' }, - { name: 'r/startups', why: 'founders discuss building and launching' }, - { name: 'r/Entrepreneur', why: 'a broad audience open to useful tools, if you lead with value' }, + { name: 'r/SideProject', why: 'makers share and get feedback on early projects', rulesNote: 'Sharing is welcome; still lead with what you learned, not a pitch' }, + { name: 'r/startups', why: 'founders discuss building and launching', rulesNote: 'Self-promo is restricted to specific threads — check the rules before posting' }, + { name: 'r/Entrepreneur', why: 'a broad audience open to useful tools, if you lead with value', rulesNote: 'Heavy self-promo filter; comment and contribute before linking' }, ], title: `I built ${name} to help ${audience} — ${essence}`, body: @@ -123,6 +160,7 @@ function buildReddit(core: LaunchCore): RedditContent { "Don't drop links until asked, or only where the sub's rules allow", 'Give value in comments before promoting', ], + postingTiming: 'Weekday mornings (8–10 AM ET) when these subs are most active; avoid weekends', } } @@ -143,6 +181,79 @@ function buildAppSumo(core: LaunchCore): AppSumoContent { } } +function buildSocial(core: LaunchCore): SocialContent { + const { productName: name, audience, essence, problem, features: feat, keywords } = core + const tags = (keywords?.length ? keywords : ['buildinpublic', 'indiehackers']).slice(0, 3).map((k) => `#${k.replace(/[^a-zA-Z0-9]/g, '')}`) + return { + thread: { + tweets: [ + `${audience}: ${problem} I got tired of it, so I built ${name}. 🧵`, + `${name} ${essence.charAt(0).toLowerCase()}${essence.slice(1)}`, + `What it does:\n${feat.slice(0, 3).map((f) => `→ ${f}`).join('\n')}`, + `I built it because the existing tools didn't fit how ${audience} actually work.`, + `It's live today and free to try. If you've felt this pain, I'd love your honest take. 👇`, + ], + }, + kolOutreach: { + twitter: `Hey {name} — love how you cover tools for ${audience}. I just launched ${name} (${essence}). Thought it might resonate with your audience — happy to send free access if you want to take a look, no strings.`, + linkedin: `Hi {name}, I follow your posts on ${audience} workflows. I just shipped ${name} — ${essence}. Would you be open to a quick look? I'd value your perspective and can share full access.`, + telegram: `Hi {name}! Big fan of your community for ${audience}. I just launched ${name} (${essence}). Would it be okay to share it with the group, or get your take first?`, + }, + ugcAsk: `Hey! Thanks for being an early ${name} user. If it's saved you time, would you be up for a short post or 30-sec clip on how you use it? Happy to feature you and send some perks — real stories from ${audience} help far more than any ad.`, + postingTips: { + bestTimeET: '9:00–11:00 AM ET, Tuesday–Thursday', + hashtags: tags, + }, + } +} + +function buildPlan(core: LaunchCore): LaunchPlan { + const { productName: name, audience, keywords } = core + const kw = (keywords?.length ? keywords : [name.toLowerCase()]).slice(0, 3).join(', ') + return { + phases: [ + { window: 'L-6 weeks', goal: 'Lock strategy', tasks: [`Define the ICP and value prop for ${audience}`, `Pick target keywords (${kw}) and a launch goal`, 'Set the budget for the few high-value channels'] }, + { window: 'L-5 to L-4 weeks', goal: 'Build assets', tasks: ['Polish the landing page and demo video', `Write the core story for ${name}`, 'Prepare gallery shots and the feature clip'] }, + { window: 'L-4 to L-3 weeks', goal: 'Lock partnerships', tasks: ['Shortlist and reach out to relevant KOLs', 'Recruit a handful of early UGC creators', 'Line up a credible Product Hunt hunter'] }, + { window: 'L-3 to L-2 weeks', goal: 'Prepare content', tasks: ['Draft the PH, HN, Reddit and X copy', 'Brief KOLs with a content pack', 'Map which subreddits and communities to seed'] }, + { window: 'L-2 to L-1 weeks', goal: 'Final confirmation', tasks: ['Confirm all drafts and the hunter', 'Lock the launch date and time', 'Warm up your network'] }, + { window: 'Launch week (Day 1-5)', goal: 'Execute', tasks: ['Run the Product Hunt day plan', 'Publish the X thread and trigger KOL posts', 'Seed Reddit/HN where you are a real member'] }, + { window: 'L+1 to L+4 weeks', goal: 'Build momentum', tasks: ['Convert early users into UGC', 'Capture backlinks and press', 'Publish follow-up content on traction'] }, + ], + countdown30: [ + 'Finalize positioning and the launch goal', + 'Build the landing page and demo video', + 'Start KOL and hunter outreach', + `Set up analytics and configure IndexNow for ${kw}`, + ], + countdown7: [ + 'Confirm hunter and KOL commitments', + 'Schedule the launch-day comms', + 'Pre-write first comments and the X thread', + 'Warm up your network with a teaser', + ], + countdown48h: [ + 'Final QA of the product and signup flow', + 'Pin assets and double-check links', + 'Brief supporters on timing (12:01 AM PT)', + 'Rest — launch day is long', + ], + seoGeo: [ + `Publish a launch blog post targeting "${kw}"`, + 'Configure IndexNow so new pages hit Bing/AI search in seconds', + 'Add Product/SoftwareApplication schema markup to the site', + 'Capture backlinks from the launch (PH, directories, press)', + 'Structure pages with clear Q&A so AI search engines cite you', + ], + momentum: [ + 'Day 1-7: thank everyone, reply to all feedback, ship a quick win', + 'Day 7: post a "what we learned" recap with metrics', + 'Day 14: publish UGC and case studies from early users', + 'Day 30: roundup post and outreach to newsletters/press', + ], + } +} + export class MockProvider implements GenerationProvider { async generateCore(input: GenerateInput): Promise { return buildCore(input) @@ -159,6 +270,13 @@ export class MockProvider implements GenerationProvider { return buildReddit(core) case 'appsumo': return buildAppSumo(core) + case 'social': + return buildSocial(core) } } + + async generatePlan(core: LaunchCore, _input?: GenerateInput): Promise { + void _input + return buildPlan(core) + } } diff --git a/lib/generation/openrouter-provider.ts b/lib/generation/openrouter-provider.ts index 0d04455..6ad135f 100644 --- a/lib/generation/openrouter-provider.ts +++ b/lib/generation/openrouter-provider.ts @@ -1,9 +1,11 @@ import type { z } from 'zod' -import type { GenerateInput, LaunchCore, PlatformContent } from '@/lib/types' +import type { GenerateInput, LaunchCore, LaunchPlan, PlatformContent } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' import type { GenerationProvider } from './provider.types' import { launchCoreSchema } from './core/core.schema' import { buildCorePrompt, CORE_SYSTEM_PROMPT } from './core/core.prompt' +import { launchPlanSchema } from './plan/plan.schema' +import { buildPlanPrompt, PLAN_SYSTEM } from './plan/plan.prompt' import { PLATFORM_GENERATORS } from './platforms/registry' import { extractJsonObject } from './json' import { DEFAULT_MODEL } from './model' @@ -26,6 +28,10 @@ export class OpenRouterProvider implements GenerationProvider { return this.call(gen.system, gen.buildPrompt(core, input), gen.schema) } + async generatePlan(core: LaunchCore, input: GenerateInput): Promise { + return this.call(PLAN_SYSTEM, buildPlanPrompt(core, input), launchPlanSchema) + } + private async call(system: string, user: string, schema: z.ZodType): Promise { if (!this.apiKey) throw new Error('OPENROUTER_API_KEY is not set.') diff --git a/lib/generation/plan/plan.prompt.ts b/lib/generation/plan/plan.prompt.ts new file mode 100644 index 0000000..2d39786 --- /dev/null +++ b/lib/generation/plan/plan.prompt.ts @@ -0,0 +1,38 @@ +import type { GenerateInput, LaunchCore } from '@/lib/types' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../prompt-shared' + +export const PLAN_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', + 'You are a launch operator who plans multi-channel product launches end to end.', + 'You sequence a launch over ~10 weeks (L-6 weeks to L+4 weeks) so channels amplify each other instead of cannibalizing: strategy, then assets, then partnerships, then content, then launch week, then momentum.', + 'Every task must be concrete and specific to THIS product, its audience, and its keywords — no generic filler.', + 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', +].join('\n') + +const SHAPE = `{ + "phases": [ + { "window": "e.g. L-6 weeks", "goal": "the milestone for this window", "tasks": ["2-4 concrete tasks for this product"] } + ], + "countdown30": ["4-6 tasks for the 30-day pre-launch countdown"], + "countdown7": ["4-6 tasks for the 7-day countdown"], + "countdown48h": ["4-6 tasks for the final 48 hours"], + "seoGeo": ["4-6 SEO/GEO tasks using this product's keywords: IndexNow, schema markup, a launch blog post, backlink capture, and optimizing to be cited by AI search"], + "momentum": ["4-6 post-launch tasks across the 7-day and 30-day follow-up cadence"] +}` + +export function buildPlanPrompt(core: LaunchCore, input: GenerateInput): string { + return [ + 'Write a complete, product-specific launch plan covering the full timeline and the pre-launch countdowns.', + '', + renderCore(core), + renderRefinements(input), + '', + 'Return ONLY a JSON object that matches this exact shape (same keys, no extras):', + SHAPE, + '', + 'Cover the phases from L-6 weeks through L+4 weeks. Make SEO/GEO tasks reference the keywords above. Keep every task actionable.', + ] + .filter(Boolean) + .join('\n') +} diff --git a/lib/generation/plan/plan.schema.test.ts b/lib/generation/plan/plan.schema.test.ts new file mode 100644 index 0000000..46bfed6 --- /dev/null +++ b/lib/generation/plan/plan.schema.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { launchPlanSchema } from './plan.schema' + +const plan = { + phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define ICP'] }], + countdown30: ['Build the landing page'], + countdown7: ['Confirm the hunter'], + countdown48h: ['Final QA'], + seoGeo: ['Configure IndexNow'], + momentum: ['Post a recap'], +} + +describe('launchPlanSchema', () => { + it('accepts a valid plan', () => { + expect(launchPlanSchema.parse(plan)).toEqual(plan) + }) + it('rejects a plan missing phases', () => { + expect(() => launchPlanSchema.parse({ ...plan, phases: undefined })).toThrow() + }) + it('rejects a phase missing its tasks', () => { + expect(() => launchPlanSchema.parse({ ...plan, phases: [{ window: 'L-6 weeks', goal: 'x' }] })).toThrow() + }) +}) diff --git a/lib/generation/plan/plan.schema.ts b/lib/generation/plan/plan.schema.ts new file mode 100644 index 0000000..5d3702d --- /dev/null +++ b/lib/generation/plan/plan.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +// A cross-platform, product-tailored launch plan: the L-6w→L+4w timeline, the +// 30/7/48h countdowns, an SEO/GEO checklist, and the post-launch momentum cadence. +export const launchPlanSchema = z.object({ + phases: z.array(z.object({ window: z.string(), goal: z.string(), tasks: z.array(z.string()) })), + countdown30: z.array(z.string()), + countdown7: z.array(z.string()), + countdown48h: z.array(z.string()), + seoGeo: z.array(z.string()), + momentum: z.array(z.string()), +}) diff --git a/lib/generation/platforms/appsumo/prompt.ts b/lib/generation/platforms/appsumo/prompt.ts index e835b89..c1990d5 100644 --- a/lib/generation/platforms/appsumo/prompt.ts +++ b/lib/generation/platforms/appsumo/prompt.ts @@ -1,11 +1,13 @@ import type { GenerateInput, LaunchCore } from '@/lib/types' -import { renderCore, renderRefinements } from '../../prompt-shared' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared' export const APPSUMO_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', 'You are a lifetime-deal copywriter for AppSumo, writing for a value-hungry marketplace audience.', - 'Emphasize buying once and owning forever, ROI, who it is for, and refund confidence. Concrete benefits over hype.', + 'Emphasize buying once and owning forever, concrete ROI, who it is for, and refund confidence. Real user value over hype.', 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', -].join(' ') +].join('\n') const SHAPE = `{ "dealHeadline": "the lifetime-deal headline", diff --git a/lib/generation/platforms/hacker-news/prompt.ts b/lib/generation/platforms/hacker-news/prompt.ts index 5b9d2eb..ed062c9 100644 --- a/lib/generation/platforms/hacker-news/prompt.ts +++ b/lib/generation/platforms/hacker-news/prompt.ts @@ -1,12 +1,15 @@ import type { GenerateInput, LaunchCore } from '@/lib/types' -import { renderCore, renderRefinements } from '../../prompt-shared' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared' export const HACKER_NEWS_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', 'You are a seasoned Hacker News poster writing a Show HN.', 'HN despises marketing language, hype, superlatives, and emoji. Be technical, humble, specific, and honest about trade-offs.', 'Speak in the first person as the maker. No exclamation marks, no buzzwords.', + 'Engage authentically: answer technical critique head-on and treat tough questions as the point, not a threat.', 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', -].join(' ') +].join('\n') const SHAPE = `{ "title": "a 'Show HN:' title — honest and specific, no hype, ideally <80 chars", @@ -14,7 +17,8 @@ const SHAPE = `{ "firstComment": "your first comment: extra technical context + one specific question inviting feedback", "postingTips": { "bestTimeET": "best day and time to post, in US Eastern Time", - "avoid": ["3-4 things to avoid when posting this on HN"] + "avoid": ["3-4 things to avoid when posting this on HN"], + "etiquette": ["3-4 rules for engaging authentically: answer critique directly, stay humble, no vote-asking, reply technically"] } }` diff --git a/lib/generation/platforms/hacker-news/schema.ts b/lib/generation/platforms/hacker-news/schema.ts index 4296f9e..0ec94a8 100644 --- a/lib/generation/platforms/hacker-news/schema.ts +++ b/lib/generation/platforms/hacker-news/schema.ts @@ -5,5 +5,10 @@ export const hackerNewsSchema = z.object({ title: z.string(), postBody: z.string(), firstComment: z.string(), - postingTips: z.object({ bestTimeET: z.string(), avoid: z.array(z.string()) }), + postingTips: z.object({ + bestTimeET: z.string(), + avoid: z.array(z.string()), + // Optional for back-compat with content persisted before this field existed. + etiquette: z.array(z.string()).optional(), + }), }) diff --git a/lib/generation/platforms/product-hunt/prompt.ts b/lib/generation/platforms/product-hunt/prompt.ts index 963554c..286750b 100644 --- a/lib/generation/platforms/product-hunt/prompt.ts +++ b/lib/generation/platforms/product-hunt/prompt.ts @@ -1,11 +1,14 @@ import type { GenerateInput, LaunchCore } from '@/lib/types' -import { renderCore, renderRefinements } from '../../prompt-shared' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared' export const PRODUCT_HUNT_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', 'You are an expert Product Hunt launch strategist and copywriter.', 'Product Hunt rewards punchy, specific, maker-energy copy with a strong visual story.', + 'A top-5 finish comes from concentrated, genuine supporters posting at 12:01 AM PT and from comments that keep momentum — not from spread-thin, generic asks.', 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', -].join(' ') +].join('\n') const SHAPE = `{ "copy": { @@ -32,6 +35,9 @@ const SHAPE = `{ "recommendedTimePT": "e.g. 12:01 AM PT", "prelaunchChecklist": ["4-5 concrete pre-launch tasks"], "launchDayChecklist": ["4-5 concrete launch-day tasks"], + "hourByHour": [ { "timePT": "e.g. 12:01 AM PT", "action": "what to do in this window to build and hold momentum" } ], + "momentumTactics": ["3-4 upvote-momentum tactics: stagger genuine supporters, ask for an honest comment + upvote, never buy votes"], + "commentModeration": ["3-4 rules for triaging and replying to comments fast to sustain ranking"], "outreach": { "hunter": "a DM asking someone to hunt the product (use {name})", "supporters": "a message rallying supporters (use {link})" @@ -50,6 +56,7 @@ export function buildProductHuntPrompt(core: LaunchCore, input: GenerateInput): SHAPE, '', 'Provide 4 gallery shots and 4-5 video scenes. Keep the tagline at or under 60 characters.', + 'Provide a 6-8 step hour-by-hour launch-day timeline in Pacific Time, starting at 12:01 AM PT and covering the first-comment pin, an early reply blitz, a mid-day traction update, and a final push.', ] .filter(Boolean) .join('\n') diff --git a/lib/generation/platforms/product-hunt/schema.ts b/lib/generation/platforms/product-hunt/schema.ts index b44e0e5..5f7a692 100644 --- a/lib/generation/platforms/product-hunt/schema.ts +++ b/lib/generation/platforms/product-hunt/schema.ts @@ -20,6 +20,10 @@ export const productHuntSchema = z.object({ recommendedTimePT: z.string(), prelaunchChecklist: z.array(z.string()), launchDayChecklist: z.array(z.string()), + // Playbook additions — optional for back-compat with kits persisted before they existed. + hourByHour: z.array(z.object({ timePT: z.string(), action: z.string() })).optional(), + momentumTactics: z.array(z.string()).optional(), + commentModeration: z.array(z.string()).optional(), outreach: z.object({ hunter: z.string(), supporters: z.string() }), }), }) diff --git a/lib/generation/platforms/reddit/prompt.ts b/lib/generation/platforms/reddit/prompt.ts index 408221c..4fb3d08 100644 --- a/lib/generation/platforms/reddit/prompt.ts +++ b/lib/generation/platforms/reddit/prompt.ts @@ -1,17 +1,21 @@ import type { GenerateInput, LaunchCore } from '@/lib/types' -import { renderCore, renderRefinements } from '../../prompt-shared' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared' export const REDDIT_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', 'You are a Reddit-native maker who knows each subreddit has its own culture and self-promotion rules.', 'Redditors punish salesy, promotional posts. Be transparent that you are the maker, lead with value or a story, and never sound like an ad.', + 'Concentrate on the one or two subreddits where you are a genuine member; depth of value beats breadth of posting.', 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', -].join(' ') +].join('\n') const SHAPE = `{ - "subreddits": [ { "name": "r/...", "why": "why this subreddit fits and its self-promo norm" } ], + "subreddits": [ { "name": "r/...", "why": "why this subreddit fits and its self-promo norm", "rulesNote": "its specific rule to respect — self-promo ratio, required flair, or 'comment before you post'" } ], "title": "a non-clickbait, value-first post title that respects subreddit norms", "body": "the post body: tell a story or give value, be transparent you built it; \\n for line breaks", - "replyEtiquette": ["4-5 rules for engaging the comments and respecting self-promo norms"] + "replyEtiquette": ["4-5 rules for engaging the comments and respecting self-promo norms"], + "postingTiming": "the best day/time to post given these subreddits' activity and norms" }` export function buildRedditPrompt(core: LaunchCore, input: GenerateInput): string { diff --git a/lib/generation/platforms/reddit/schema.ts b/lib/generation/platforms/reddit/schema.ts index 4910285..bd81efd 100644 --- a/lib/generation/platforms/reddit/schema.ts +++ b/lib/generation/platforms/reddit/schema.ts @@ -2,8 +2,10 @@ import { z } from 'zod' // Reddit: which subreddits to post in, the post itself, and how to behave in the comments. export const redditSchema = z.object({ - subreddits: z.array(z.object({ name: z.string(), why: z.string() })), + // rulesNote is optional for back-compat with content persisted before it existed. + subreddits: z.array(z.object({ name: z.string(), why: z.string(), rulesNote: z.string().optional() })), title: z.string(), body: z.string(), replyEtiquette: z.array(z.string()), + postingTiming: z.string().optional(), }) diff --git a/lib/generation/platforms/registry.ts b/lib/generation/platforms/registry.ts index 66e36a0..b1a1450 100644 --- a/lib/generation/platforms/registry.ts +++ b/lib/generation/platforms/registry.ts @@ -10,6 +10,8 @@ import { redditSchema } from './reddit/schema' import { buildRedditPrompt, REDDIT_SYSTEM } from './reddit/prompt' import { appSumoSchema } from './appsumo/schema' import { buildAppSumoPrompt, APPSUMO_SYSTEM } from './appsumo/prompt' +import { socialSchema } from './social/schema' +import { buildSocialPrompt, SOCIAL_SYSTEM } from './social/prompt' // Everything needed to generate and validate one platform's content. Adding a // platform is a single entry here plus its schema.ts + prompt.ts. @@ -24,4 +26,5 @@ export const PLATFORM_GENERATORS: Record = { appsumo: { schema: appSumoSchema, system: APPSUMO_SYSTEM, buildPrompt: buildAppSumoPrompt }, 'hacker-news': { schema: hackerNewsSchema, system: HACKER_NEWS_SYSTEM, buildPrompt: buildHackerNewsPrompt }, reddit: { schema: redditSchema, system: REDDIT_SYSTEM, buildPrompt: buildRedditPrompt }, + social: { schema: socialSchema, system: SOCIAL_SYSTEM, buildPrompt: buildSocialPrompt }, } diff --git a/lib/generation/platforms/social/prompt.ts b/lib/generation/platforms/social/prompt.ts new file mode 100644 index 0000000..0d0cade --- /dev/null +++ b/lib/generation/platforms/social/prompt.ts @@ -0,0 +1,43 @@ +import type { GenerateInput, LaunchCore } from '@/lib/types' +import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared' + +export const SOCIAL_SYSTEM = [ + PLAYBOOK_PRINCIPLES, + '', + 'You are a launch-savvy founder writing for X (Twitter) and running KOL/influencer outreach.', + 'Threads earn attention with a sharp hook, one idea per tweet, and a concrete payoff — not hype or thread-bait.', + 'Outreach DMs are short, specific, and personalized; they lead with why this matters to that creator and their audience, never a generic blast. A good cold DM earns roughly a 10-15% reply rate because it respects the reader.', + 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.', +].join('\n') + +const SHAPE = `{ + "thread": { + "tweets": ["5-8 tweets that form one launch thread; tweet 1 is the hook, the last is a clear CTA with the link; one idea per tweet, no hashtag spam"] + }, + "kolOutreach": { + "twitter": "a short cold DM for an X creator (use {name}); lead with why it fits their audience", + "linkedin": "a short cold DM for a LinkedIn creator (use {name}); slightly more professional framing", + "telegram": "a short cold DM for a Telegram group owner or KOL (use {name}); direct and community-aware" + }, + "ugcAsk": "a message inviting an early user to make a short post/video about the product (UGC), framed around what's in it for them", + "postingTips": { + "bestTimeET": "best day and time to post the thread, in US Eastern Time", + "hashtags": ["2-4 relevant, non-spammy hashtags"] + } +}` + +export function buildSocialPrompt(core: LaunchCore, input: GenerateInput): string { + return [ + 'Write a social launch kit for this product: an X (Twitter) launch thread, cold-outreach DMs for KOLs, and a UGC ask.', + '', + renderCore(core), + renderRefinements(input), + '', + 'Return ONLY a JSON object that matches this exact shape (same keys, no extras):', + SHAPE, + '', + 'Write 5-8 tweets. Keep each tweet tight enough to stand on its own. Make every DM feel personally written, not mass-sent.', + ] + .filter(Boolean) + .join('\n') +} diff --git a/lib/generation/platforms/social/schema.ts b/lib/generation/platforms/social/schema.ts new file mode 100644 index 0000000..f00dd54 --- /dev/null +++ b/lib/generation/platforms/social/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +// Social / KOL: an X launch thread, cold-outreach DMs for each network, a UGC ask, and posting tips. +export const socialSchema = z.object({ + thread: z.object({ tweets: z.array(z.string()) }), + kolOutreach: z.object({ twitter: z.string(), linkedin: z.string(), telegram: z.string() }), + ugcAsk: z.string(), + postingTips: z.object({ bestTimeET: z.string(), hashtags: z.array(z.string()) }), +}) diff --git a/lib/generation/prompt-shared.ts b/lib/generation/prompt-shared.ts index 95b53ad..667cd05 100644 --- a/lib/generation/prompt-shared.ts +++ b/lib/generation/prompt-shared.ts @@ -1,5 +1,16 @@ import type { GenerateInput, LaunchCore } from '@/lib/types' +// The Gingiris launch-playbook principles, prepended to every system prompt so all +// generated copy is shaped by the same launch philosophy regardless of platform. +export const PLAYBOOK_PRINCIPLES = [ + 'Launch principles to honor in everything you write:', + '1. User value first — every line must earn attention by promising real value to the target user, never by hyping the maker.', + '2. Content is king, channel is queen — the message must be strong enough to spread on its own; the platform only amplifies it.', + '3. Global brand, local execution — keep one clear, consistent positioning while speaking natively to this specific platform and audience.', + '4. Concentrate on real people, not vanity — favor a few high-intent, genuine asks over broad, hollow ones.', + 'Anchor claims in concrete specifics; use credible numbers only when given, and never invent metrics or lean on empty superlatives.', +].join('\n') + // Raw product facts, used for the core distillation call. export function renderFacts(input: GenerateInput): string { const { context, refinements } = input @@ -20,16 +31,22 @@ export function renderFacts(input: GenerateInput): string { .join('\n') } -// The distilled core, rendered for a platform-content call. +// The distilled core, rendered for a platform-content call. The enrichment lines +// are emitted only when present so cores persisted before they existed still render. export function renderCore(core: LaunchCore): string { return [ `Product: ${core.productName}`, `Essence: ${core.essence}`, + core.valueProp && `Value proposition: ${core.valueProp}`, `Audience: ${core.audience}`, + core.icp && `Ideal customer profile: ${core.icp}`, `Problem solved: ${core.problem}`, `Features: ${core.features.join('; ')}`, `Differentiators: ${core.differentiators.join('; ')}`, - ].join('\n') + core.keywords?.length && `Keywords: ${core.keywords.join(', ')}`, + ] + .filter(Boolean) + .join('\n') } // Author-chosen refinements that shape voice; appended to every platform prompt. diff --git a/lib/generation/prompt.test.ts b/lib/generation/prompt.test.ts index 506c565..c30e533 100644 --- a/lib/generation/prompt.test.ts +++ b/lib/generation/prompt.test.ts @@ -33,8 +33,8 @@ describe('buildCorePrompt', () => { }) describe('PLATFORM_GENERATORS', () => { - it('covers all four platforms with schema, system, and buildPrompt', () => { - for (const id of ['product-hunt', 'appsumo', 'hacker-news', 'reddit'] as const) { + it('covers every platform with schema, system, and buildPrompt', () => { + for (const id of ['product-hunt', 'appsumo', 'hacker-news', 'reddit', 'social'] as const) { const gen = PLATFORM_GENERATORS[id] expect(gen.schema).toBeDefined() expect(gen.system.length).toBeGreaterThan(0) @@ -59,4 +59,8 @@ describe('PLATFORM_GENERATORS', () => { it('builds an AppSumo prompt framed around a lifetime deal', () => { expect(PLATFORM_GENERATORS['appsumo'].buildPrompt(core, input).toLowerCase()).toContain('lifetime') }) + + it('builds a Social prompt that asks for a launch thread', () => { + expect(PLATFORM_GENERATORS['social'].buildPrompt(core, input).toLowerCase()).toContain('thread') + }) }) diff --git a/lib/generation/provider.types.ts b/lib/generation/provider.types.ts index 155f33c..ac4dea3 100644 --- a/lib/generation/provider.types.ts +++ b/lib/generation/provider.types.ts @@ -1,4 +1,4 @@ -import type { GenerateInput, LaunchCore, PlatformContent } from '@/lib/types' +import type { GenerateInput, LaunchCore, LaunchPlan, PlatformContent } from '@/lib/types' import type { PlatformId } from '@/lib/platforms' export interface GenerationProvider { @@ -6,4 +6,6 @@ export interface GenerationProvider { generateCore(input: GenerateInput): Promise // Write one platform's native content from the shared core. generatePlatform(platform: PlatformId, core: LaunchCore, input: GenerateInput): Promise + // Build the cross-platform launch plan from the shared core. + generatePlan(core: LaunchCore, input: GenerateInput): Promise } diff --git a/lib/generation/schema.test.ts b/lib/generation/schema.test.ts index ca9084f..bbb325d 100644 --- a/lib/generation/schema.test.ts +++ b/lib/generation/schema.test.ts @@ -4,6 +4,7 @@ import { productHuntSchema } from './platforms/product-hunt/schema' import { hackerNewsSchema } from './platforms/hacker-news/schema' import { redditSchema } from './platforms/reddit/schema' import { appSumoSchema } from './platforms/appsumo/schema' +import { socialSchema } from './platforms/social/schema' const core = { productName: 'Acme', @@ -12,6 +13,9 @@ const core = { problem: 'Shipping is slow.', features: ['Fast builds'], differentiators: ['Zero config'], + valueProp: 'Ship in minutes, not days.', + icp: 'Seed-stage dev teams shipping weekly', + keywords: ['ci', 'builds', 'developer tools'], } const productHunt = { @@ -45,6 +49,13 @@ const appSumo = { faq: [{ q: 'Is it really lifetime?', a: 'Yes.' }], } +const social = { + thread: { tweets: ['Devs: shipping is slow. I built Acme. 🧵', 'It does X, Y, Z.', 'Live today — try it.'] }, + kolOutreach: { twitter: 'Hey {name}...', linkedin: 'Hi {name}...', telegram: 'Hi {name}!' }, + ugcAsk: 'Would you share how you use Acme?', + postingTips: { bestTimeET: '9:00 AM ET', hashtags: ['#buildinpublic'] }, +} + describe('launchCoreSchema', () => { it('accepts a valid core', () => { expect(launchCoreSchema.parse(core)).toEqual(core) @@ -52,6 +63,11 @@ describe('launchCoreSchema', () => { it('rejects a core missing essence', () => { expect(() => launchCoreSchema.parse({ ...core, essence: undefined })).toThrow() }) + it('accepts a core without the optional enrichment fields (back-compat)', () => { + const { valueProp, icp, keywords, ...legacy } = core + void valueProp; void icp; void keywords + expect(() => launchCoreSchema.parse(legacy)).not.toThrow() + }) }) describe('productHuntSchema', () => { @@ -89,3 +105,15 @@ describe('appSumoSchema', () => { expect(() => appSumoSchema.parse({ ...appSumo, faq: [{ q: 'no answer' }] })).toThrow() }) }) + +describe('socialSchema', () => { + it('accepts valid Social content', () => { + expect(socialSchema.parse(social)).toEqual(social) + }) + it('rejects content missing the thread', () => { + expect(() => socialSchema.parse({ ...social, thread: undefined })).toThrow() + }) + it('rejects outreach missing a channel', () => { + expect(() => socialSchema.parse({ ...social, kolOutreach: { twitter: 'a', linkedin: 'b' } })).toThrow() + }) +}) diff --git a/lib/platforms.ts b/lib/platforms.ts index 52a81ec..7976ece 100644 --- a/lib/platforms.ts +++ b/lib/platforms.ts @@ -1,6 +1,6 @@ // Launch platforms the kit can target. Each gets its own native, audience-tuned // content; adding one is a new entry here plus its generator + preview + sections. -export type PlatformId = 'product-hunt' | 'appsumo' | 'hacker-news' | 'reddit' +export type PlatformId = 'product-hunt' | 'appsumo' | 'hacker-news' | 'reddit' | 'social' export type Platform = { id: PlatformId @@ -13,4 +13,5 @@ export const PLATFORMS: Platform[] = [ { id: 'appsumo', name: 'AppSumo', blurb: 'Lifetime-deal marketplace' }, { id: 'hacker-news', name: 'Hacker News', blurb: 'Show HN' }, { id: 'reddit', name: 'Reddit', blurb: 'Subreddit launch post' }, + { id: 'social', name: 'Social / KOL', blurb: 'X thread + influencer outreach' }, ] diff --git a/lib/preview/registry.ts b/lib/preview/registry.ts index 337901c..b5ea48d 100644 --- a/lib/preview/registry.ts +++ b/lib/preview/registry.ts @@ -5,6 +5,7 @@ import { ProductHuntPreview } from '@/components/preview/ProductHuntPreview' import { AppSumoPreview } from '@/components/preview/AppSumoPreview' import { HackerNewsPreview } from '@/components/preview/HackerNewsPreview' import { RedditPreview } from '@/components/preview/RedditPreview' +import { SocialPreview } from '@/components/preview/SocialPreview' // Maps a platform to its preview renderer. Each platform's preview lives in its // own component folder; adding one here is the only integration step. @@ -13,6 +14,7 @@ export const PREVIEWS: Partial>> appsumo: AppSumoPreview, 'hacker-news': HackerNewsPreview, reddit: RedditPreview, + social: SocialPreview, } export function hasPreview(id: PlatformId): boolean { diff --git a/lib/types.ts b/lib/types.ts index 8ebf4f1..01e9031 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -31,6 +31,10 @@ export type LaunchCore = { problem: string features: string[] differentiators: string[] + // Enrichments (optional for back-compat with cores persisted before they existed). + keywords?: string[] // SEO/GEO + PH topics + subreddit targeting + icp?: string // sharper than free-text `audience` — the ideal customer profile + valueProp?: string // the one-sentence promise channels amplify } // Product Hunt is the richest format: gallery, demo video, maker comment, hunter outreach. @@ -50,6 +54,10 @@ export type ProductHuntContent = { recommendedTimePT: string prelaunchChecklist: string[] launchDayChecklist: string[] + // Playbook additions — optional for back-compat with kits persisted before they existed. + hourByHour?: { timePT: string; action: string }[] + momentumTactics?: string[] + commentModeration?: string[] outreach: { hunter: string; supporters: string } } } @@ -62,15 +70,18 @@ export type HackerNewsContent = { title: string postBody: string firstComment: string - postingTips: { bestTimeET: string; avoid: string[] } + // etiquette is optional for back-compat with content persisted before it existed. + postingTips: { bestTimeET: string; avoid: string[]; etiquette?: string[] } } // Reddit lives in the right subreddit with a value-first, non-salesy post. export type RedditContent = { - subreddits: { name: string; why: string }[] + // rulesNote / postingTiming are optional for back-compat with content persisted before they existed. + subreddits: { name: string; why: string; rulesNote?: string }[] title: string body: string replyEtiquette: string[] + postingTiming?: string } // AppSumo is a lifetime-deal marketplace listing. @@ -82,20 +93,41 @@ export type AppSumoContent = { faq: { q: string; a: string }[] } +// Social / KOL: a launch thread for X plus influencer cold-outreach and a UGC ask. +export type SocialContent = { + thread: { tweets: string[] } + kolOutreach: { twitter: string; linkedin: string; telegram: string } + ugcAsk: string + postingTips: { bestTimeET: string; hashtags: string[] } +} + // Maps each platform id to its own native content shape. export type PlatformContentMap = { 'product-hunt': ProductHuntContent appsumo: AppSumoContent 'hacker-news': HackerNewsContent reddit: RedditContent + social: SocialContent } export type PlatformContent = PlatformContentMap[keyof PlatformContentMap] -// The full generation result: a shared core plus whichever platforms succeeded. +// A cross-platform, product-tailored launch plan: timeline, countdowns, SEO/GEO, momentum. +export type LaunchPlan = { + phases: { window: string; goal: string; tasks: string[] }[] + countdown30: string[] + countdown7: string[] + countdown48h: string[] + seoGeo: string[] + momentum: string[] +} + +// The full generation result: a shared core, whichever platforms succeeded, and an +// optional cross-platform launch plan (optional for back-compat with persisted runs). export type Generation = { core: LaunchCore platforms: Partial + plan?: LaunchPlan } export type GenerateInput = { context: ProjectContext; refinements: Refinements }