Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 1 addition & 33 deletions app/runs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ function RunWizard({ id }: { id: string }) {
const router = useRouter()
const { state, dispatch, status } = useRun(id)
const [generating, setGenerating] = useState(false)
const [regeneratingPlatform, setRegeneratingPlatform] = useState<PlatformId | null>(null)

function handleAnalyzed(context: ProjectContext) {
// One run per folder: if this path already belongs to another run, go there.
Expand All @@ -54,7 +53,7 @@ function RunWizard({ id }: { id: string }) {
dispatch({ type: 'GENERATED', generation: data.generation })
if (Array.isArray(data.failed) && data.failed.length) {
const names = data.failed.map((f: PlatformId) => PLATFORMS.find((p) => p.id === f)?.name ?? f)
toast.warning(`Couldn't generate: ${names.join(', ')}. You can regenerate them.`)
toast.warning(`Couldn't generate: ${names.join(', ')}.`)
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Generation failed')
Expand All @@ -63,26 +62,6 @@ function RunWizard({ id }: { id: string }) {
}
}

async function regeneratePlatform(platform: PlatformId) {
if (!state.context || !state.generation) return
const body = { context: state.context, refinements: state.refinements, platform, core: state.generation.core }
setRegeneratingPlatform(platform)
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Generation failed')
dispatch({ type: 'REGEN_PLATFORM', platform, content: data.content })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Generation failed')
} finally {
setRegeneratingPlatform(null)
}
}

function markdownFor(platform: PlatformId): string | null {
if (!state.generation) return null
const content = state.generation.platforms[platform]
Expand All @@ -102,14 +81,6 @@ function RunWizard({ id }: { id: string }) {
URL.revokeObjectURL(url)
}

async function copyAll(platform: PlatformId) {
const md = markdownFor(platform)
if (!md) return
await navigator.clipboard.writeText(md)
const name = PLATFORMS.find((p) => p.id === platform)?.name ?? platform
toast.success(`${name} kit copied`)
}

function planMarkdown(): string | null {
if (!state.generation?.plan) return null
return planToMarkdown(state.generation.core, state.generation.plan)
Expand Down Expand Up @@ -187,10 +158,7 @@ function RunWizard({ id }: { id: string }) {
<PostKit
generation={state.generation}
productName={state.context?.name ?? state.generation.core.productName ?? 'Your product'}
onRegeneratePlatform={regeneratePlatform}
regeneratingPlatform={regeneratingPlatform}
onExportMarkdown={exportMarkdown}
onCopyAll={copyAll}
onCopyPlan={copyPlan}
onExportPlan={exportPlan}
onStartOver={() => router.push('/runs')}
Expand Down
47 changes: 47 additions & 0 deletions components/brand/PlatformLogo/PlatformLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PlatformId } from '@/lib/platforms'
import type { PlatformLogoProps } from './PlatformLogo.types'

// Brand marks per platform. The four with official Simple Icons render as the real
// logo in the brand color; AppSumo has no open logo, so it falls back to a monogram.
const MARKS: Record<
PlatformId,
{ color: string; path: string } | { color: string; bg: string; glyph: string }
> = {
'product-hunt': {
color: '#DA552F',
path: 'M13.604 8.4h-3.405V12h3.405c.995 0 1.801-.806 1.801-1.801 0-.993-.805-1.799-1.801-1.799zM12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zm1.604 14.4h-3.405V18H7.801V6h5.804c2.319 0 4.2 1.88 4.2 4.199 0 2.321-1.881 4.201-4.201 4.201z',
},
'hacker-news': {
color: '#FF6600',
path: 'M0 24V0h24v24H0zM6.951 5.896l4.112 7.708v5.064h1.583v-4.972l4.148-7.799h-1.749l-2.457 4.875c-.372.745-.688 1.434-.688 1.434s-.297-.708-.651-1.434L8.831 5.896h-1.88z',
},
reddit: {
color: '#FF4500',
path: 'M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z',
},
social: {
color: '#0f0f0f',
path: 'M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z',
},
appsumo: { color: '#1c1c1c', bg: '#FCCD13', glyph: 'AS' },
}

export function PlatformLogo({ id, className = 'size-5' }: PlatformLogoProps) {
const mark = MARKS[id]
if ('path' in mark) {
return (
<svg viewBox="0 0 24 24" className={className} fill={mark.color} role="img" aria-hidden focusable="false">
<path d={mark.path} />
</svg>
)
}
return (
<span
className={`flex ${className} items-center justify-center rounded text-[9px] font-bold leading-none`}
style={{ background: mark.bg, color: mark.color }}
aria-hidden
>
{mark.glyph}
</span>
)
}
7 changes: 7 additions & 0 deletions components/brand/PlatformLogo/PlatformLogo.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PlatformId } from '@/lib/platforms'

export type PlatformLogoProps = {
id: PlatformId
/** Tailwind size utility (width + height). Defaults to `size-5`. */
className?: string
}
1 change: 1 addition & 0 deletions components/brand/PlatformLogo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PlatformLogo } from './PlatformLogo'
3 changes: 0 additions & 3 deletions components/post-kit/PostKit/PostKit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,7 @@ function renderKit() {
<PostKit
generation={generation}
productName="Acme"
onRegeneratePlatform={vi.fn()}
regeneratingPlatform={null}
onExportMarkdown={vi.fn()}
onCopyAll={vi.fn()}
onCopyPlan={vi.fn()}
onExportPlan={vi.fn()}
onStartOver={vi.fn()}
Expand Down
166 changes: 82 additions & 84 deletions components/post-kit/PostKit/PostKit.tsx
Original file line number Diff line number Diff line change
@@ -1,127 +1,125 @@
'use client'
import { useState } from 'react'
import { CalendarRange, Download, RotateCcw } from 'lucide-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 { PlatformLogo } from '@/components/brand/PlatformLogo'
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<View, string> = { edit: 'Edit', 'launch-ops': 'Launch ops', preview: 'Preview' }

export function PostKit({
generation,
productName,
onRegeneratePlatform,
regeneratingPlatform,
onExportMarkdown,
onCopyAll,
onCopyPlan,
onExportPlan,
onStartOver,
}: PostKitProps) {
export function PostKit({ generation, productName, onExportMarkdown, onCopyPlan, onExportPlan, onStartOver }: PostKitProps) {
const { core, platforms, plan } = generation
const firstReady = PLATFORMS.find((p) => platforms[p.id])?.id ?? 'product-hunt'
const [platform, setPlatform] = useState<PlatformId>(firstReady)
const [view, setView] = useState<View>('edit')
const [planMode, setPlanMode] = useState(false)

const content = platforms[platform]
const Sections = PLATFORM_SECTIONS[platform]
const Preview = PREVIEWS[platform]
const regenerating = regeneratingPlatform === platform
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 (
<div className="mx-auto max-w-5xl px-6">
<div className="reveal reveal-1 mb-6 text-center">
<span className="text-xs font-medium uppercase tracking-wide text-primary">Your launch kit is ready</span>
<h2 className="font-display mx-auto mt-2 max-w-2xl text-balance text-2xl font-bold leading-tight tracking-tight sm:text-3xl">
{core.essence}
</h2>
</div>

{/* Cross-platform launch plan — spans every channel, shown above the per-platform kits */}
{plan && (
<div className="reveal reveal-1 mb-6">
<LaunchPlan plan={plan} onCopy={onCopyPlan} onExport={onExportPlan} />
{/* Header: product name + global actions */}
<div className="reveal reveal-1 mb-4 flex items-center justify-between gap-4">
<h2 className="font-display truncate text-xl font-bold tracking-tight">{productName}</h2>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="sm" onClick={onStartOver}>
<RotateCcw className="size-4" /> Start over
</Button>
<Button size="sm" onClick={() => (planMode ? onExportPlan() : onExportMarkdown(platform))}>
<Download className="size-4" /> Export
</Button>
</div>
)}

{/* Platform selector — drives both edit and preview */}
<div className="reveal reveal-2 mb-4 flex flex-wrap justify-center gap-2">
{PLATFORMS.map((p) => {
const ready = Boolean(platforms[p.id])
const isActive = platform === p.id
return (
<button
key={p.id}
disabled={!ready}
onClick={() => ready && setPlatform(p.id)}
className={`rounded-full border px-3 py-1.5 text-sm transition-colors ${
isActive && ready
? 'border-primary bg-primary/10 font-medium text-primary'
: ready
? 'border-border text-muted-foreground hover:bg-muted'
: 'cursor-not-allowed border-dashed border-border text-muted-foreground/60'
}`}
>
{p.name}
{!ready && <span className="ml-1 text-[10px] uppercase">failed</span>}
</button>
)
})}
</div>

{/* Toolbar: Edit | Launch ops (PH only) | Preview + per-platform actions */}
{/* Toolbar: Plan + platform-logo selector (left), view tabs (right) */}
<div className="reveal reveal-2 mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 shadow-sm">
{views.map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-sm transition-colors ${
activeView === v ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
{VIEW_LABELS[v]}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<RegenerateButton
onClick={() => onRegeneratePlatform(platform)}
regenerating={regenerating}
disabled={!canRegenerate}
title={canRegenerate ? undefined : 'Add OPENROUTER_API_KEY to your .env to regenerate'}
/>
<Button variant="outline" size="sm" onClick={() => onCopyAll(platform)}>Copy all</Button>
<Button size="sm" onClick={() => onExportMarkdown(platform)}>Export Markdown</Button>
<Button variant="ghost" size="sm" onClick={onStartOver}>Start over</Button>
<div className="flex flex-wrap items-center gap-1.5">
{plan && (
<>
<button

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Set explicit type="button" on toolbar buttons.

These plain <button> elements default to submit behavior and can accidentally submit a surrounding form.

Proposed fix
               <button
+                type="button"
                 onClick={() => setPlanMode(true)}
                 aria-label="Launch plan"
@@
               <button
                 key={p.id}
+                type="button"
                 disabled={!ready}
                 aria-label={p.name}
@@
               <button
                 key={v}
+                type="button"
                 onClick={() => setView(v)}
                 className={`whitespace-nowrap rounded-md px-3 py-1.5 text-sm transition-colors ${

Also applies to: 71-71, 98-98

🧰 Tools
🪛 React Doctor (0.5.6)

[warning] 52-52: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 52-52: This JSX crashes because React isn't in scope.

If you're on React 17+ with the new JSX transform, disable this rule. Otherwise import React at the top of the file.

(react-in-jsx-scope)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/post-kit/PostKit/PostKit.tsx` at line 52, Add explicit
`type="button"` attribute to all plain button elements in the PostKit component
to prevent them from defaulting to submit behavior and accidentally submitting
surrounding forms. Locate the three button elements at lines 52, 71, and 98 in
PostKit.tsx and add the type attribute to each one to explicitly set them as
regular buttons rather than submit buttons.

onClick={() => setPlanMode(true)}
aria-label="Launch plan"
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm transition-colors ${
planMode
? 'border-primary bg-primary/10 font-medium text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
>
<CalendarRange className="size-4" />
<span>Plan</span>
</button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden />
</>
)}
{PLATFORMS.map((p) => {
const ready = Boolean(platforms[p.id])
const active = !planMode && platform === p.id
return (
<button
key={p.id}
disabled={!ready}
aria-label={p.name}
title={ready ? p.name : `${p.name} — didn't generate`}
onClick={() => {
if (!ready) return
setPlanMode(false)
setPlatform(p.id)
}}
className={`rounded-lg border p-1.5 transition-colors ${
active
? 'border-primary bg-primary/10'
: ready
? 'border-border hover:bg-muted'
: 'cursor-not-allowed border-dashed border-border opacity-40'
}`}
>
<PlatformLogo id={p.id} className="size-5" />
</button>
)
})}
</div>

{!planMode && content && (
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 shadow-sm">
{views.map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-sm transition-colors ${
activeView === v ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
{VIEW_LABELS[v]}
</button>
))}
</div>
)}
</div>

<div key={`${platform}-${activeView}`} className="reveal reveal-2">
{!content ? (
<div key={planMode ? 'plan' : `${platform}-${activeView}`} className="reveal reveal-2">
{planMode && plan ? (
<LaunchPlan plan={plan} onCopy={onCopyPlan} onExport={onExportPlan} />
) : !content ? (
<p className="text-center text-sm text-muted-foreground">
This platform didn&apos;t generate. Switch to a ready platform, or start over to try again.
This platform didn&apos;t generate. Pick another, or start over to try again.
</p>
) : activeView === 'edit' ? (
Sections ? <Sections core={core} content={content} loading={regenerating} /> : null
Sections ? <Sections core={core} content={content} /> : null
) : activeView === 'launch-ops' ? (
<LaunchOpsSection kit={content as ProductHuntContent} loading={regenerating} />
<LaunchOpsSection kit={content as ProductHuntContent} />
) : Preview ? (
<Preview core={core} content={content} productName={productName} />
) : (
Expand Down
3 changes: 0 additions & 3 deletions components/post-kit/PostKit/PostKit.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import type { PlatformId } from '@/lib/platforms'
export type PostKitProps = {
generation: Generation
productName: string
onRegeneratePlatform: (platform: PlatformId) => void
regeneratingPlatform: PlatformId | null
onExportMarkdown: (platform: PlatformId) => void
onCopyAll: (platform: PlatformId) => void
onCopyPlan: () => void
onExportPlan: () => void
onStartOver: () => void
Expand Down
Loading