diff --git a/apps/web/app/(chat)/layout.tsx b/apps/web/app/(chat)/layout.tsx
index 5fc1452..beb609a 100644
--- a/apps/web/app/(chat)/layout.tsx
+++ b/apps/web/app/(chat)/layout.tsx
@@ -6,7 +6,8 @@ import Layout from "@/components/layout/Layout";
import { ChatModal } from "@/components/chat/modal/ChatModal";
import { MinimizedPill } from "@/components/chat/modal/MinimizedPill";
import { SearchModal } from "@/components/search/SearchModal";
-import { CreateProjectModal } from "@/components/project/CreateProjectModal";
+import { BriefModal } from "@/components/brief/BriefModal";
+import { Toaster } from "@/components/ui/sonner";
export default function ChatLayout({ children }: { children: React.ReactNode }) {
const { status } = useAuth();
@@ -22,7 +23,8 @@ export default function ChatLayout({ children }: { children: React.ReactNode })
-
+
+
);
diff --git a/apps/web/src/components/brief/BriefFieldInput.tsx b/apps/web/src/components/brief/BriefFieldInput.tsx
new file mode 100644
index 0000000..af814eb
--- /dev/null
+++ b/apps/web/src/components/brief/BriefFieldInput.tsx
@@ -0,0 +1,679 @@
+"use client"
+
+import { useId, useState, type ReactNode } from "react"
+import {
+ AUDIENCE_SEGMENTS,
+ AVAILABILITY,
+ BRIEF_OPTION_LABELS,
+ BRIEF_SITE_TYPES,
+ BRIEF_UI_COPY,
+ CTA_TYPES,
+ FIELD_DEFS,
+ LEGAL_MODES,
+ NicheCategoryEnum,
+ PRICE_INTERVALS,
+ PRICING_MODES,
+ PRIMARY_GOALS,
+ PRODUCT_TYPES,
+ SOCIAL_NETWORKS,
+ TECH_LEVELS,
+ TONES,
+ TRAFFIC_SOURCES,
+ type BriefData,
+ type FieldId,
+} from "@repo/schemas"
+import { BUILDER_CAPABILITIES } from "@repo/site-builder"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+
+type Change = (value: unknown) => void
+type Item = NonNullable[number]
+
+export type FieldRenderProps = {
+ id: FieldId
+ data: BriefData
+ onChange: Change
+ onBlur?: () => void
+ disabled?: boolean
+ describedBy: string
+ invalid: boolean
+ logoUrl?: string
+ onUpload?: (file: File) => void
+ uploading?: boolean
+}
+
+const control = "text-[13px]"
+
+function Counter({ value, max }: { value: string; max: number }) {
+ return (
+ max ? "text-red-600 dark:text-red-400" : "text-mut")} aria-hidden="true">
+ {value.length}/{max}
+
+ )
+}
+
+function TextControl(props: {
+ inputId: string
+ value: string | undefined
+ max: number
+ multiline?: boolean
+ placeholder?: string
+ type?: string
+ onChange: (v: string) => void
+ onBlur?: () => void
+ disabled?: boolean
+ describedBy?: string
+ invalid?: boolean
+ label?: string
+ autoComplete?: string
+}) {
+ const value = props.value ?? ""
+ const common = {
+ id: props.inputId,
+ value,
+ placeholder: props.placeholder,
+ disabled: props.disabled,
+ onBlur: props.onBlur,
+ "aria-describedby": props.describedBy,
+ "aria-invalid": props.invalid || undefined,
+ "aria-label": props.label,
+ autoComplete: props.autoComplete ?? "off",
+ }
+ return (
+
+ )
+}
+
+function Chips(props: {
+ name: string
+ options: readonly T[]
+ labels: Record
+ value: T | T[] | undefined
+ multiple?: boolean
+ max?: number
+ onChange: (v: T | T[] | undefined) => void
+ disabled?: boolean
+ describedBy?: string
+}) {
+ const selected = Array.isArray(props.value) ? props.value : props.value ? [props.value] : []
+ return (
+
+ {props.options.map((opt) => {
+ const checked = selected.includes(opt)
+ const atMax = !!props.multiple && !!props.max && selected.length >= props.max && !checked
+ return (
+
+ {
+ if (!props.multiple) return props.onChange(opt)
+ const next = checked ? selected.filter((s) => s !== opt) : [...selected, opt]
+ props.onChange(next.length ? next : undefined)
+ }}
+ />
+
+ {props.labels[opt] ?? opt}
+
+
+ )
+ })}
+
+ )
+}
+
+function SubLabel({ htmlFor, children }: { htmlFor: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+type ListProps = {
+ noun: string
+ items: T[]
+ min?: number
+ max: number
+ make: () => T
+ onChange: (items: T[]) => void
+ render: (item: T, update: (next: T) => void, index: number) => ReactNode
+ disabled?: boolean
+}
+
+function ListEditor({ noun, items, min = 0, max, make, onChange, render, disabled }: ListProps) {
+ const move = (from: number, to: number) => {
+ const next = [...items]
+ const [moved] = next.splice(from, 1)
+ next.splice(to, 0, moved!)
+ onChange(next)
+ }
+ return (
+
+
{min > 0 ? `${min}–${max} ${noun}s` : `Up to ${max} ${noun}s`}
+
+ {items.map((item, i) => (
+
+
+
+ {noun[0]!.toUpperCase() + noun.slice(1)} {i + 1}
+
+
+ move(i, i - 1)} aria-label={`${BRIEF_UI_COPY.moveUp}: ${noun} ${i + 1}`}>
+ ↑ {BRIEF_UI_COPY.moveUp}
+
+ move(i, i + 1)} aria-label={`${BRIEF_UI_COPY.moveDown}: ${noun} ${i + 1}`}>
+ ↓ {BRIEF_UI_COPY.moveDown}
+
+ onChange(items.filter((_, j) => j !== i))} aria-label={`${BRIEF_UI_COPY.remove}: ${noun} ${i + 1}`}>
+ {BRIEF_UI_COPY.remove}
+
+
+
+ {render(item, (next) => onChange(items.map((x, j) => (j === i ? next : x))), i)}
+
+ ))}
+
+ {items.length < max && (
+
onChange([...items, make()])} className="self-start rounded-xl border border-dashed border-em px-3 py-2 text-[12.5px] font-medium text-sec hover:bg-tertiary focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ + {BRIEF_UI_COPY.add} {noun}
+
+ )}
+
+ )
+}
+
+function useSubId(base: string) {
+ const uid = useId()
+ return (part: string | number) => `${base}-${uid}-${part}`
+}
+
+const L = BRIEF_OPTION_LABELS
+const optional = (v: T | "") => (v === "" ? undefined : v)
+
+function StringList({ id, data, onChange, disabled, noun, max, itemMax }: FieldRenderProps & { noun: string; max: number; itemMax: number }) {
+ const sub = useSubId(id)
+ const items = (data[id] as string[] | undefined) ?? []
+ return (
+ ""}
+ disabled={disabled}
+ onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+ )}
+ />
+ )
+}
+
+export function BriefFieldInput(props: FieldRenderProps) {
+ const { id, data, onChange, onBlur, disabled, describedBy, invalid } = props
+ const sub = useSubId(id)
+ const def = FIELD_DEFS[id]
+ const limits = def.limits as Record
+ const textMax = typeof limits.max === "number" ? limits.max : 200
+
+ switch (id) {
+ case "productName":
+ case "companyName":
+ case "fontPreference":
+ case "currentAlternative":
+ case "valueProp":
+ case "campaignMessage":
+ case "companyAddress":
+ return (
+ onChange(v)} onBlur={onBlur} disabled={disabled} describedBy={describedBy} invalid={invalid} autoComplete={id === "companyAddress" ? "street-address" : undefined} />
+ )
+ case "oneLiner":
+ case "audienceDescription":
+ case "problem":
+ case "visualNotes":
+ case "objections":
+ return (
+ onChange(v)} onBlur={onBlur} disabled={disabled} describedBy={describedBy} invalid={invalid} />
+ )
+ case "existingUrl":
+ return onChange(v.trim())} onBlur={onBlur} disabled={disabled} describedBy={describedBy} invalid={invalid} />
+ case "contactEmail":
+ return onChange(v.trim())} onBlur={onBlur} disabled={disabled} describedBy={describedBy} invalid={invalid} autoComplete="email" />
+ case "phone":
+ return onChange(v)} onBlur={onBlur} disabled={disabled} describedBy={describedBy} invalid={invalid} autoComplete="tel" />
+ case "launchDate":
+ return onChange(e.target.value)} onBlur={onBlur} disabled={disabled} aria-describedby={describedBy} aria-invalid={invalid || undefined} />
+
+ case "productType":
+ return
+ case "availability":
+ return
+ case "primaryGoal":
+ return
+ case "audienceSegment":
+ return
+ case "siteType":
+ return
+ case "tone":
+ return
+ case "trafficSources":
+ return
+ case "language":
+ return
+ case "niche":
+ return (
+ onChange(optional(e.target.value))} disabled={disabled} aria-describedby={describedBy}>
+ Let us classify it
+ {NicheCategoryEnum.options.map((n) => (
+
+ {n.replace(/_/g, " ").toLowerCase().replace(/^\w/, (c) => c.toUpperCase())}
+
+ ))}
+
+ )
+
+ case "allowCompetitorComparison":
+ case "showContactPublicly":
+ case "proofAttested": {
+ const fallback = id === "showContactPublicly"
+ const checked = (data[id] as boolean | undefined) ?? fallback
+ return (
+
+ onChange(v)} disabled={disabled} aria-describedby={describedBy} aria-invalid={invalid || undefined} />
+ {checked ? "On" : "Off"}
+
+ )
+ }
+
+ case "features":
+ return (
+ ({ title: "", benefit: "" })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+ Feature
+ update({ ...item, title: v })} disabled={disabled} />
+ Why it matters
+ update({ ...item, benefit: v })} disabled={disabled} />
+
+ )}
+ />
+ )
+ case "howItWorks": {
+ const hiw = data.howItWorks ?? { steps: [] }
+ return (
+
+
({ title: "", description: "" })} onChange={(steps) => onChange(steps.length || hiw.onboardingNote ? { ...hiw, steps } : undefined)}
+ render={(item, update, i) => (
+
+ Step
+ update({ ...item, title: v })} disabled={disabled} />
+ What happens
+ update({ ...item, description: v })} disabled={disabled} />
+
+ )}
+ />
+ Onboarding note (optional)
+ onChange({ ...hiw, onboardingNote: optional(v) })} disabled={disabled} />
+
+ )
+ }
+ case "differentiators":
+ return
+ case "targetKeywords":
+ return
+ case "trustedBy":
+ return
+ case "brandColors":
+ return (
+ "#2563EB"} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+ update(e.target.value)} disabled={disabled} className="h-9 w-12 cursor-pointer rounded-lg border border-base bg-transparent" />
+
+
+ )}
+ />
+ )
+ case "competitors":
+ return (
+ > noun="competitor" items={data.competitors ?? []} max={5} disabled={disabled} make={() => ({ name: "" })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+
+ Name
+ update({ ...item, name: v })} disabled={disabled} />
+
+
+ Website (optional)
+ update({ ...item, url: optional(v.trim()) })} disabled={disabled} />
+
+
+ )}
+ />
+ )
+ case "designReferences":
+ return (
+ > noun="reference" items={data.designReferences ?? []} max={3} disabled={disabled} make={() => ({ url: "", redesignFrom: false })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+ Link
+ update({ ...item, url: v.trim() })} disabled={disabled} />
+ What you like about it (optional)
+ update({ ...item, note: optional(v) })} disabled={disabled} />
+
+ update({ ...item, redesignFrom: e.target.checked })} disabled={disabled} className="size-4 accent-blue-600" />
+ This is the site I'm redesigning
+
+
+ )}
+ />
+ )
+ case "testimonials":
+ return (
+ > noun="testimonial" items={data.testimonials ?? []} max={6} disabled={disabled} make={() => ({ quote: "", personName: "", permissionConfirmed: false })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+
Quote
+
update({ ...item, quote: v })} disabled={disabled} />
+
+
+ Name
+ update({ ...item, personName: v })} disabled={disabled} />
+
+
+ Role (optional)
+ update({ ...item, role: optional(v) })} disabled={disabled} />
+
+
+ Company (optional)
+ update({ ...item, company: optional(v) })} disabled={disabled} />
+
+
+
+ update({ ...item, permissionConfirmed: e.target.checked })} disabled={disabled} className="size-4 accent-blue-600" />
+ I have permission to publish this quote
+
+
+ )}
+ />
+ )
+ case "metrics":
+ return (
+ ({ value: "", label: "", sourceNote: "" })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+
+ Number
+ update({ ...item, value: v })} disabled={disabled} />
+
+
+ What it counts
+ update({ ...item, label: v })} disabled={disabled} />
+
+
+ Where it comes from
+ update({ ...item, sourceNote: v })} disabled={disabled} />
+
+
+ )}
+ />
+ )
+ case "certifications":
+ return (
+ ({ name: "" })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => update({ name: v })} disabled={disabled} />}
+ />
+ )
+ case "faq":
+ return (
+ ({ q: "", a: "" })} onChange={(next) => onChange(next.length ? next : undefined)}
+ render={(item, update, i) => (
+
+ Question
+ update({ ...item, q: v })} disabled={disabled} />
+ Answer
+ update({ ...item, a: v })} disabled={disabled} />
+
+ )}
+ />
+ )
+
+ case "pricing": {
+ const pricing = data.pricing ?? { mode: "HIDE" as const }
+ return (
+
+
onChange({ ...pricing, mode })} disabled={disabled} describedBy={describedBy} />
+ {pricing.mode === "SHOW_PLANS" && (
+ ({ name: "", price: "", currency: "USD", interval: "MONTHLY" as const, features: [], highlighted: false })} onChange={(plans) => onChange({ ...pricing, plans: plans.length ? plans : undefined })}
+ render={(plan, update, i) => (
+
+
+
+ Plan name
+ update({ ...plan, name: v })} disabled={disabled} />
+
+
+ Price
+ update({ ...plan, price: v.trim() })} disabled={disabled} />
+
+
+ Currency
+ update({ ...plan, currency: v.trim().toUpperCase() })} disabled={disabled} />
+
+
+
update({ ...plan, interval: interval as typeof plan.interval })} disabled={disabled} />
+ What's included (one per line, up to 8)
+
+ )}
+ />
+ )}
+ {pricing.mode !== "HIDE" && (
+ <>
+ Trial note (optional)
+ onChange({ ...pricing, trialNote: optional(v) })} disabled={disabled} />
+ >
+ )}
+
+ )
+ }
+
+ case "ctaPrimary":
+ case "ctaSecondary": {
+ const cta = data[id] ?? { label: "", type: "URL" as const }
+ const types = CTA_TYPES.filter((t) => t !== "FORM" || BUILDER_CAPABILITIES.formHandler)
+ return (
+
+
Button text
+
onChange({ ...cta, label: v })} onBlur={onBlur} disabled={disabled} invalid={invalid} />
+ onChange({ ...cta, type: type as typeof cta.type })} disabled={disabled} describedBy={describedBy} />
+ {cta.type === "URL" && (
+ <>
+ Where it goes
+ onChange({ ...cta, url: optional(v.trim()) })} onBlur={onBlur} disabled={disabled} invalid={invalid} />
+ >
+ )}
+ {cta.type === "EMAIL" && Opens an email to your contact address.
}
+
+ )
+ }
+ case "afterConversion": {
+ const ac = data.afterConversion ?? { message: "" }
+ return (
+
+ Thank-you message
+ onChange(v || ac.expectedResponseTime ? { ...ac, message: v } : undefined)} disabled={disabled} />
+ Expected response time (optional)
+ onChange({ ...ac, expectedResponseTime: optional(v) })} disabled={disabled} />
+ Send notifications to (optional, never shown)
+ onChange({ ...ac, notifyEmail: optional(v.trim()) })} disabled={disabled} />
+
+ )
+ }
+ case "audienceDetails": {
+ const ad = data.audienceDetails ?? {}
+ const set = (patch: Partial>) => {
+ const next = { ...ad, ...patch }
+ onChange(Object.values(next).some(Boolean) ? next : undefined)
+ }
+ return (
+
+
+ Industry
+ set({ industry: optional(v) })} disabled={disabled} />
+
+
+ Company size
+ set({ companySize: optional(v) })} disabled={disabled} />
+
+
+ Region
+ set({ region: optional(v) })} disabled={disabled} />
+
+
+ set({ techLevel: v as (typeof TECH_LEVELS)[number] })} disabled={disabled} />
+
+
+ )
+ }
+ case "buyer": {
+ const buyer = data.buyer ?? { sameAsUser: true }
+ return (
+
+
+ onChange({ ...buyer, sameAsUser: e.target.checked })} disabled={disabled} className="size-4 accent-blue-600" />
+ The person who uses it also buys it
+
+ {!buyer.sameAsUser && (
+ <>
+ Who decides to buy?
+ onChange({ ...buyer, description: optional(v) })} disabled={disabled} />
+ >
+ )}
+
+ )
+ }
+ case "socialLinks": {
+ const links = data.socialLinks ?? {}
+ return (
+
+ {SOCIAL_NETWORKS.map((n) => (
+
+ {L.socialNetwork[n]}
+ {
+ const next = { ...links, [n]: optional(v.trim()) }
+ onChange(Object.values(next).some(Boolean) ? next : undefined)
+ }} disabled={disabled} />
+
+ ))}
+
+ )
+ }
+ case "legal": {
+ const legal = data.legal ?? { mode: "GENERATE_TEMPLATE" as const }
+ return (
+
+
onChange({ ...legal, mode })} disabled={disabled} describedBy={describedBy} />
+ {legal.mode === "LINK_OWN" && (
+
+
+ Privacy policy link
+ onChange({ ...legal, privacyUrl: optional(v.trim()) })} disabled={disabled} />
+
+
+ Terms link (optional)
+ onChange({ ...legal, termsUrl: optional(v.trim()) })} disabled={disabled} />
+
+
+ )}
+ {legal.mode !== "NONE" && (
+
+
+ Legal entity name (optional)
+ onChange({ ...legal, legalEntityName: optional(v) })} disabled={disabled} />
+
+
+ Country (optional)
+ onChange({ ...legal, country: optional(v) })} disabled={disabled} />
+
+
+ )}
+
+ )
+ }
+ case "logo":
+ return
+ default:
+ return null
+ }
+}
+
+function LogoInput({ id, data, onUpload, uploading, disabled, describedBy, logoUrl, onChange }: FieldRenderProps) {
+ const [localError, setLocalError] = useState(null)
+ return (
+
+ {data.logo && logoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ Logo
+
+ )}
+
+ {uploading ? "Uploading…" : data.logo ? "Replace logo" : "Upload logo"}
+ {
+ const file = e.target.files?.[0]
+ e.target.value = ""
+ if (!file) return
+ if (file.size > 2 * 1024 * 1024) return setLocalError("Logos can be up to 2 MB")
+ setLocalError(null)
+ onUpload?.(file)
+ }}
+ />
+
+ {data.logo && (
+
onChange(undefined)} disabled={disabled}>
+ {BRIEF_UI_COPY.remove}
+
+ )}
+ {localError &&
{localError} }
+
+ )
+}
diff --git a/apps/web/src/components/brief/BriefModal.tsx b/apps/web/src/components/brief/BriefModal.tsx
new file mode 100644
index 0000000..0326151
--- /dev/null
+++ b/apps/web/src/components/brief/BriefModal.tsx
@@ -0,0 +1,689 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useRouter } from "next/navigation"
+import { useQuery, useQueryClient } from "@tanstack/react-query"
+import { toast } from "sonner"
+import {
+ BRIEF_STEP_COPY,
+ BRIEF_UI_COPY,
+ FIELD_DEFS,
+ QUICK_STEP_IDS,
+ REQUIRED_FIELD_IDS,
+ STEP_IDS,
+ dropUnconfirmedImportedProof,
+ validateFieldValue,
+ validateForSubmit,
+ type BriefDraftSummary,
+ type BriefFieldErrors,
+ type BriefView,
+ type FieldId,
+ type StepId,
+} from "@repo/schemas"
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
+import { cn } from "@/lib/utils"
+import { useAuth } from "@/lib/authContext"
+import { isFreeTierExhausted } from "@/lib/freeTierError"
+import { useProjectModalStore, type BriefModalRequest } from "@/store/projectModalStore"
+import { briefAssetUrl, briefsApi, type BriefApiError } from "@/lib/api/services/briefs.service"
+import { BriefFieldInput } from "./BriefFieldInput"
+import { PrefillPanel } from "./PrefillPanel"
+import { useBriefDraft } from "./useBriefDraft"
+import {
+ errorsForField,
+ fieldsFor,
+ firstStepWithError,
+ firstUnfilledStep,
+ stepHasRequired,
+ stepsFor,
+ type BriefViewMode,
+} from "./briefFields"
+
+const VIEW_KEY = "uf-brief-view"
+const SIMPLE_LABEL_KINDS = new Set(["text", "textarea", "url", "email", "date"])
+
+function readViewPreference(): BriefViewMode {
+ try {
+ return window.localStorage.getItem(VIEW_KEY) === "FULL" ? "FULL" : "QUICK"
+ } catch {
+ return "QUICK"
+ }
+}
+
+function writeViewPreference(view: BriefViewMode): void {
+ try {
+ window.localStorage.setItem(VIEW_KEY, view)
+ } catch {}
+}
+
+function timeAgo(iso: string): string {
+ const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000)
+ if (mins < 1) return "just now"
+ if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`
+ const hours = Math.round(mins / 60)
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`
+ const days = Math.round(hours / 24)
+ return `${days} day${days === 1 ? "" : "s"} ago`
+}
+
+export function BriefModal() {
+ const isOpen = useProjectModalStore((s) => s.isOpen)
+ const request = useProjectModalStore((s) => s.request)
+ const close = useProjectModalStore((s) => s.close)
+ const [session, setSession] = useState(0)
+
+ useEffect(() => {
+ if (isOpen) setSession((s) => s + 1)
+ }, [isOpen])
+
+ if (!isOpen) return null
+ return
+}
+
+type Phase = "loading" | "choose" | "form" | "summary" | "error"
+
+function BriefModalSession({ request, onClose }: { request: BriefModalRequest; onClose: () => void }) {
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const { user } = useAuth()
+ const editing = request.mode === "edit"
+ const projectSlug = request.mode === "edit" ? request.projectSlug : undefined
+ const draft = useBriefDraft(editing ? "edit" : "draft", projectSlug)
+
+ const [phase, setPhase] = useState("loading")
+ const [view, setView] = useState(() => (editing ? "FULL" : readViewPreference()))
+ const [step, setStep] = useState(request.mode === "edit" ? (request.step ?? "about") : "about")
+ const [drafts, setDrafts] = useState([])
+ const [atDraftLimit, setAtDraftLimit] = useState(false)
+ const [clientErrors, setClientErrors] = useState({})
+ const [live, setLive] = useState("")
+ const [banner, setBanner] = useState<{ text: string; upgrade?: boolean } | null>(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [uploading, setUploading] = useState(false)
+ const [autoPrefillText, setAutoPrefillText] = useState()
+ const bodyRef = useRef(null)
+ const startedRef = useRef(false)
+
+ const announce = useCallback((message: string) => {
+ setLive("")
+ requestAnimationFrame(() => setLive(message))
+ }, [])
+
+ const { data: eligibility } = useQuery({
+ queryKey: ["brief-eligibility"],
+ queryFn: briefsApi.eligibility,
+ enabled: !editing,
+ staleTime: 30_000,
+ })
+
+ const openBrief = useCallback(
+ (brief: BriefView) => {
+ draft.load(brief)
+ const pointer = brief.currentStep
+ if (!editing && pointer && pointer !== "start" && pointer !== "summary") setStep(pointer)
+ if (!editing && brief.mode && brief.mode !== view) setView(brief.mode)
+ setPhase("form")
+ },
+ [draft, editing, view],
+ )
+
+ const startNew = useCallback(
+ async (ideaText?: string) => {
+ setPhase("loading")
+ try {
+ const { brief, prefillSuggested } = await briefsApi.create({ ...(ideaText ? { ideaText } : {}), mode: view })
+ openBrief(brief)
+ if (ideaText && prefillSuggested) setAutoPrefillText(ideaText)
+ } catch (err) {
+ const e = err as BriefApiError
+ if (e.code === "BRIEF_DRAFT_LIMIT") {
+ setDrafts((e.data as { drafts?: BriefDraftSummary[] } | undefined)?.drafts ?? [])
+ setAtDraftLimit(true)
+ setPhase("choose")
+ return
+ }
+ setBanner({ text: e.status === 429 ? BRIEF_UI_COPY.rateLimited : e.message })
+ setPhase("error")
+ }
+ },
+ [openBrief, view],
+ )
+
+ useEffect(() => {
+ if (startedRef.current) return
+ startedRef.current = true
+ void (async () => {
+ try {
+ if (request.mode === "edit") {
+ openBrief(await briefsApi.getForProject(request.projectSlug))
+ return
+ }
+ if (request.ideaText) return void (await startNew(request.ideaText))
+ const open = await briefsApi.listOpen()
+ if (open.length > 0) {
+ setDrafts(open)
+ setPhase("choose")
+ return
+ }
+ await startNew()
+ } catch (err) {
+ setBanner({ text: (err as Error).message })
+ setPhase("error")
+ }
+ })()
+ }, [request, openBrief, startNew])
+
+ const steps = stepsFor(view)
+ const stepIndex = Math.max(0, steps.indexOf(step))
+ const isLastStep = stepIndex === steps.length - 1
+ const fields = useMemo(() => fieldsFor(step, view, draft.data), [step, view, draft.data])
+ const errors: BriefFieldErrors = { ...draft.errors, ...clientErrors }
+
+ const goTo = useCallback(
+ (next: StepId | "summary") => {
+ if (next === "summary") {
+ setPhase("summary")
+ draft.setColumns({ currentStep: "summary" })
+ } else {
+ setPhase("form")
+ setStep(next)
+ draft.setColumns({ currentStep: next })
+ }
+ bodyRef.current?.scrollTo({ top: 0 })
+ announce(next === "summary" ? BRIEF_UI_COPY.quickSummaryTitle : BRIEF_STEP_COPY[next].title)
+ },
+ [draft, announce],
+ )
+
+ const switchView = (next: BriefViewMode) => {
+ if (next === view) return
+ setView(next)
+ writeViewPreference(next)
+ draft.setColumns({ mode: next })
+ if (next === "QUICK" && !QUICK_STEP_IDS.includes(step as (typeof QUICK_STEP_IDS)[number])) setStep(QUICK_STEP_IDS[0])
+ if (phase === "summary" && next === "FULL") goTo(firstUnfilledStep(draft.data, null))
+ }
+
+ const validateStep = (): boolean => {
+ const found: BriefFieldErrors = {}
+ for (const id of fields) {
+ const value = draft.data[id]
+ if (REQUIRED_FIELD_IDS.includes(id) && (value === undefined || value === "")) {
+ found[id] = ["Required"]
+ continue
+ }
+ if (value === undefined) continue
+ const result = validateFieldValue(id, value, "submit")
+ if (!result.ok) Object.assign(found, result.errors)
+ }
+ setClientErrors((prev) => {
+ const next = { ...prev }
+ for (const id of fields) for (const k of Object.keys(next)) if (k === id || k.startsWith(`${id}.`)) delete next[k]
+ return { ...next, ...found }
+ })
+ const count = Object.keys(found).length
+ if (count > 0) {
+ announce(`${count} answer${count === 1 ? " needs" : "s need"} attention`)
+ const first = Object.keys(found)[0]!.split(".")[0]
+ requestAnimationFrame(() => document.getElementById(first!)?.focus())
+ }
+ return count === 0
+ }
+
+ const blurField = (id: FieldId) => {
+ const value = draft.data[id]
+ if (value === undefined) return
+ const result = validateFieldValue(id, value, "submit")
+ setClientErrors((prev) => {
+ const next = { ...prev }
+ for (const k of Object.keys(next)) if (k === id || k.startsWith(`${id}.`)) delete next[k]
+ return result.ok ? next : { ...next, ...result.errors }
+ })
+ }
+
+ const onContinue = () => {
+ if (!validateStep()) return
+ if (!isLastStep) return goTo(steps[stepIndex + 1]!)
+ if (view === "QUICK" && !editing) return goTo("summary")
+ void submit()
+ }
+
+ const onBack = () => {
+ if (phase === "summary") return goTo(steps[steps.length - 1]!)
+ if (stepIndex > 0) goTo(steps[stepIndex - 1]!)
+ }
+
+ const submit = async () => {
+ if (editing) return void saveEdit()
+ const cleaned = dropUnconfirmedImportedProof({ data: draft.data, meta: draft.brief?.meta ?? {} })
+ const check = validateForSubmit(cleaned.data)
+ if (!check.ok) {
+ setClientErrors(check.errors)
+ const target = firstStepWithError(check.errors)
+ if (target) {
+ if (!QUICK_STEP_IDS.includes(target as (typeof QUICK_STEP_IDS)[number])) switchView("FULL")
+ goTo(target)
+ }
+ announce(`${Object.keys(check.errors).length} answers need attention`)
+ return
+ }
+ setSubmitting(true)
+ setBanner(null)
+ try {
+ const saved = await draft.flush()
+ if (!saved) throw new Error("Couldn't save your draft")
+ const created = await briefsApi.createProject(saved.id)
+ queryClient.invalidateQueries({ queryKey: ["projects"] })
+ onClose()
+ router.push(`/project/${created.project.slug}`)
+ } catch (err) {
+ const e = err as BriefApiError
+ if (e.status === 422 && e.errors) {
+ setClientErrors(e.errors)
+ const target = firstStepWithError(e.errors)
+ if (target) goTo(target)
+ } else if (isFreeTierExhausted(err) || e.status === 402) {
+ setBanner({ text: BRIEF_UI_COPY.eligibility, upgrade: true })
+ } else {
+ setBanner({ text: e.status === 429 ? BRIEF_UI_COPY.rateLimited : e.message })
+ }
+ announce(e.message)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const saveEdit = async () => {
+ if (!projectSlug) return
+ setSubmitting(true)
+ try {
+ const result = await draft.saveEdit()
+ queryClient.invalidateQueries({ queryKey: ["brief", projectSlug] })
+ onClose()
+ if (result.regenerateNeeded) toast(BRIEF_UI_COPY.editSavedNeedsRegenerate)
+ else toast(BRIEF_UI_COPY.saved)
+ } catch (err) {
+ const e = err as BriefApiError
+ if (e.status === 422 && e.errors) {
+ const target = firstStepWithError(e.errors)
+ if (target) goTo(target)
+ }
+ setBanner({ text: e.status === 409 ? BRIEF_UI_COPY.conflict : e.message })
+ announce(e.message)
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const requestClose = async () => {
+ if (editing) {
+ if (draft.dirty) return void saveEdit()
+ return onClose()
+ }
+ const brief = draft.brief
+ if (brief) {
+ await draft.flush().catch(() => null)
+ if (!draft.hasUserAnswers()) {
+ void briefsApi.remove(brief.id).catch(() => {})
+ } else {
+ toast(BRIEF_UI_COPY.draftSaved)
+ }
+ }
+ onClose()
+ }
+
+ const reloadAfterConflict = async () => {
+ if (!draft.brief) return
+ const fresh = editing && projectSlug ? await briefsApi.getForProject(projectSlug) : await briefsApi.get(draft.brief.id)
+ draft.load(fresh)
+ setBanner(null)
+ }
+
+ const uploadLogo = async (file: File) => {
+ if (!draft.brief) return
+ setUploading(true)
+ try {
+ await draft.flush()
+ const view = editing && projectSlug ? await briefsApi.uploadForProject(projectSlug, file, "logo") : await briefsApi.upload(draft.brief.id, file, "logo")
+ draft.acceptServer(view)
+ announce("Logo uploaded")
+ } catch (err) {
+ setClientErrors((prev) => ({ ...prev, logo: [(err as Error).message] }))
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ const saveLabel =
+ draft.saveState === "saving"
+ ? BRIEF_UI_COPY.saving
+ : draft.saveState === "saved"
+ ? BRIEF_UI_COPY.saved
+ : draft.saveState === "rateLimited"
+ ? BRIEF_UI_COPY.rateLimited
+ : draft.saveState === "error"
+ ? "Not saved yet"
+ : ""
+
+ const canSkip = !stepHasRequired(step) && view === "FULL" && !editing
+ const title = editing ? BRIEF_UI_COPY.editTitle : BRIEF_UI_COPY.modalTitle
+
+ return (
+ !o && void requestClose()}>
+
+
+
+
+ {title}
+
+ {phase === "form" ? BRIEF_STEP_COPY[step].description : BRIEF_UI_COPY.quickSummary}
+
+
+ {!editing && phase !== "choose" && (
+
+ {(["QUICK", "FULL"] as const).map((v) => (
+ switchView(v)}
+ className={cn(
+ "rounded-lg px-3 py-1 text-[12px] font-semibold focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ view === v ? "bg-inv text-inv" : "text-mut hover:text-sec",
+ )}
+ >
+ {v === "QUICK" ? BRIEF_UI_COPY.quickLabel : BRIEF_UI_COPY.fullLabel}
+
+ ))}
+
+ )}
+
+ {(phase === "form" || phase === "summary") && (
+
+
+ {steps.map((s, i) => {
+ const current = phase === "form" && s === step
+ return (
+
+ goTo(s)}
+ aria-current={current ? "step" : undefined}
+ className={cn(
+ "rounded-full px-2.5 py-1 text-[11.5px] font-medium focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ current ? "bg-blue-600 text-white" : i < stepIndex || phase === "summary" ? "bg-bubble text-sec" : "text-mut hover:bg-tertiary",
+ )}
+ >
+ Step {i + 1}:
+ {BRIEF_STEP_COPY[s].title}
+
+
+ )
+ })}
+
+
+ {saveLabel}
+
+
+ )}
+
+
+
+ {live}
+
+
+
+ {!editing && eligibility && !eligibility.eligible && (
+
+ )}
+ {draft.saveState === "conflict" && (
+
+ {BRIEF_UI_COPY.conflict}
+ void reloadAfterConflict()} className="rounded-lg border border-current px-2.5 py-1 font-semibold">
+ {BRIEF_UI_COPY.reload}
+
+
+ )}
+ {banner && (
+
+ )}
+
+ {phase === "loading" && (
+
+
+
+ )}
+
+ {phase === "choose" && (
+
+
{BRIEF_UI_COPY.continueDraft}
+ {atDraftLimit &&
You have 5 drafts open. Continue one, or delete one to start fresh.
}
+
+ {drafts.map((d) => (
+
+
+ {d.productName ?? "Untitled draft"}
+ , edited {timeAgo(d.updatedAt)}
+
+
+ openBrief(await briefsApi.get(d.id))} className="rounded-xl bg-inv px-3 py-1.5 text-[12px] font-semibold text-inv focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ Continue
+
+ {atDraftLimit && (
+ {
+ await briefsApi.remove(d.id)
+ setDrafts((prev) => prev.filter((x) => x.id !== d.id))
+ setAtDraftLimit(false)
+ }}
+ className="rounded-xl border border-base px-3 py-1.5 text-[12px] font-semibold text-sec focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ aria-label={`Delete draft ${d.productName ?? "Untitled draft"}`}
+ >
+ Delete
+
+ )}
+
+
+ ))}
+
+ {!atDraftLimit && (
+
void startNew(request.mode === "create" ? request.ideaText : undefined)} className="self-start rounded-xl border border-base px-4 py-2 text-[12.5px] font-semibold text-sec hover:bg-tertiary focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ {BRIEF_UI_COPY.startNew}
+
+ )}
+
+ )}
+
+ {phase === "summary" && (
+
+
{BRIEF_UI_COPY.quickSummaryTitle}
+
{BRIEF_UI_COPY.quickSummary}
+
switchView("FULL")} className="text-[12.5px] font-semibold text-blue-700 underline underline-offset-2 dark:text-blue-300">
+ {BRIEF_UI_COPY.addMoreDetails}
+
+
+ )}
+
+ {phase === "form" && (
+
+ {!editing && step === steps[0] && (
+
draft.acceptServer(v)}
+ announce={announce}
+ />
+ )}
+ {step === "proof" && (
+ {BRIEF_STEP_COPY.proof.description}
+ )}
+ {fields.map((id) => (
+ blurField(id)}
+ logoUrl={draft.brief?.uploads.logo ? briefAssetUrl(draft.brief.uploads.logo.url) : undefined}
+ onUpload={uploadLogo}
+ uploading={uploading}
+ contactPublic={draft.data.showContactPublicly !== false}
+ accountEmail={user?.email}
+ />
+ ))}
+
+ )}
+
+
+ {(phase === "form" || phase === "summary") && (
+
+
+ {BRIEF_UI_COPY.back}
+
+
+ {phase === "form" && canSkip && !isLastStep && (
+ goTo(steps[stepIndex + 1]!)} className="rounded-xl px-3 py-2 text-[12.5px] font-semibold text-mut hover:bg-tertiary focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ {BRIEF_UI_COPY.skipStep}
+
+ )}
+ void submit() : onContinue}
+ className="rounded-xl bg-inv px-5 py-2.5 text-[13px] font-semibold text-inv hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
+ >
+ {submitting
+ ? BRIEF_UI_COPY.saving
+ : phase === "summary" || (isLastStep && view === "FULL")
+ ? editing
+ ? BRIEF_UI_COPY.saveChanges
+ : BRIEF_UI_COPY.createProject
+ : BRIEF_UI_COPY.continue}
+
+
+
+ )}
+
+
+ )
+}
+
+function FieldBlock({
+ id,
+ draft,
+ errors,
+ onBlur,
+ logoUrl,
+ onUpload,
+ uploading,
+ contactPublic,
+ accountEmail,
+}: {
+ id: FieldId
+ draft: ReturnType
+ errors: string[]
+ onBlur: () => void
+ logoUrl?: string
+ onUpload: (file: File) => void
+ uploading: boolean
+ contactPublic: boolean
+ accountEmail?: string
+}) {
+ const def = FIELD_DEFS[id]
+ const meta = draft.brief?.meta[id]
+ const required = def.tier === "required"
+ const aiAllowed = "aiDecideAllowed" in def && def.aiDecideAllowed === true
+ const aiOn = aiAllowed && draft.isAiDecide(id)
+ const suggested = meta?.source === "imported" && !meta.confirmed && draft.data[id] !== undefined
+ const isProof = "proof" in def && def.proof === true
+ const labelId = `${id}-label`
+ const helpId = `${id}-help`
+ const errorId = `${id}-error`
+ const describedBy = [helpId, errors.length ? errorId : null].filter(Boolean).join(" ")
+ const simple = SIMPLE_LABEL_KINDS.has(def.kind)
+
+ let help: string = def.help
+ if (id === "contactEmail") {
+ help = `${def.help} ${contactPublic ? BRIEF_UI_COPY.publicEmailNote : BRIEF_UI_COPY.privateEmailNote}`
+ if (accountEmail && draft.data.contactEmail === accountEmail && meta?.source === "default") help += " Pre-filled from your account."
+ }
+
+ return (
+
+
+ {simple ? (
+
+ {def.label}
+
+ ) : (
+
+ {def.label}
+
+ )}
+
+ {required ? BRIEF_UI_COPY.required : BRIEF_UI_COPY.optional}
+
+ {suggested && {BRIEF_UI_COPY.suggested} }
+ {aiOn && {BRIEF_UI_COPY.aiWillDecide} }
+
+
+ {help}
+
+ {aiAllowed && (
+
+ draft.setAiDecide(id, e.target.checked)} />
+ {BRIEF_UI_COPY.letAiDecide}
+
+ )}
+ {suggested && meta?.excerpt &&
From your source: “{meta.excerpt}”
}
+
draft.setField(id, v)}
+ onBlur={onBlur}
+ disabled={aiOn}
+ describedBy={describedBy}
+ invalid={errors.length > 0}
+ logoUrl={logoUrl}
+ onUpload={onUpload}
+ uploading={uploading}
+ />
+ {suggested &&
+ (isProof ? (
+
+ e.target.checked && draft.setField(id, draft.data[id])} />
+ {BRIEF_UI_COPY.proofImportedConfirm}
+
+ ) : (
+ draft.setField(id, draft.data[id])} className="self-start rounded-lg border border-base px-2.5 py-1 text-[11.5px] font-semibold text-sec hover:bg-tertiary focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ {BRIEF_UI_COPY.accept}
+
+ ))}
+ {errors.length > 0 && (
+
+ {[...new Set(errors)].join(" ")}
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/brief/BriefPanel.tsx b/apps/web/src/components/brief/BriefPanel.tsx
new file mode 100644
index 0000000..ce07122
--- /dev/null
+++ b/apps/web/src/components/brief/BriefPanel.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ BRIEF_PANEL_COPY,
+ BRIEF_SOURCE_LABELS,
+ BRIEF_STEP_COPY,
+ CLAIM_PATHS,
+ FIELD_DEFS,
+ FIELD_IDS,
+ STEP_IDS,
+ isBlankValue,
+ type BriefView,
+ type FieldId,
+ type Source,
+ type StepId,
+} from "@repo/schemas"
+import { briefsApi, type BriefApiError } from "@/lib/api/services/briefs.service"
+import { useProjectModalStore } from "@/store/projectModalStore"
+import { cn } from "@/lib/utils"
+import { fieldLabel, summarize } from "./briefFields"
+
+const SOURCE_STYLE: Record = {
+ user: "bg-tertiary text-sec",
+ imported: "bg-violet-50 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300",
+ researched: "bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300",
+ assumed: "bg-amber-50 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300",
+ default: "bg-tertiary text-mut",
+}
+
+export function briefQueryKey(slug: string) {
+ return ["brief", slug] as const
+}
+
+// Resolves to null for projects created before intake briefs existed.
+export function useProjectBrief(slug: string | undefined) {
+ return useQuery({
+ queryKey: briefQueryKey(slug ?? ""),
+ enabled: !!slug,
+ queryFn: async (): Promise => {
+ try {
+ return await briefsApi.getForProject(slug!)
+ } catch (err) {
+ if ((err as BriefApiError).status === 404) return null
+ throw err
+ }
+ },
+ refetchInterval: (query) => (query.state.data?.status === "RESOLVING" ? 1500 : false),
+ })
+}
+
+type Props = {
+ slug: string
+ brief: BriefView
+ staleVersion: boolean
+ onRegenerate?: () => void
+ onApproved: () => void
+}
+
+export function BriefPanel({ slug, brief, staleVersion, onRegenerate, onApproved }: Props) {
+ const queryClient = useQueryClient()
+ const openModal = useProjectModalStore((s) => s.open)
+ const [onlyPending, setOnlyPending] = useState(brief.status === "AWAITING_REVIEW")
+ const [confirmed, setConfirmed] = useState>(new Set())
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ const reviewing = brief.status === "AWAITING_REVIEW"
+ const pendingClaims = brief.needsConfirmation
+ const allConfirmed = pendingClaims.every((p) => confirmed.has(p))
+
+ const rows = useMemo(
+ () =>
+ STEP_IDS.map((step) => ({
+ step,
+ fields: FIELD_IDS.filter((id) => {
+ if (isBlankValue(brief.data[id])) return false
+ if (id === "sourceDocument") return false
+ const meta = brief.meta[id]
+ if (onlyPending) return meta?.source === "assumed" || (meta?.source === "imported" && !meta.confirmed)
+ return true
+ }).filter((id) => fieldStep(id) === step),
+ })).filter((g) => g.fields.length > 0),
+ [brief, onlyPending],
+ )
+
+ const refresh = (view?: BriefView) => {
+ if (view) queryClient.setQueryData(briefQueryKey(slug), view)
+ else void queryClient.invalidateQueries({ queryKey: briefQueryKey(slug) })
+ }
+
+ const remove = async (id: FieldId) => {
+ setBusy(true)
+ setError(null)
+ try {
+ const result = await briefsApi.patchForProject(slug, { expectedVersion: brief.version, unset: [id] })
+ setConfirmed((prev) => {
+ const next = new Set(prev)
+ next.delete(id)
+ return next
+ })
+ refresh(result.brief)
+ } catch (err) {
+ setError((err as Error).message)
+ refresh()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const approve = async () => {
+ setBusy(true)
+ setError(null)
+ try {
+ const view = await briefsApi.approveForProject(slug, { expectedVersion: brief.version, confirmedPaths: [...confirmed] })
+ refresh(view)
+ onApproved()
+ } catch (err) {
+ setError((err as Error).message)
+ refresh()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {BRIEF_PANEL_COPY.title}
+
+
+ setOnlyPending((v) => !v)} className={cn("rounded-lg border px-2.5 py-1 text-[11.5px] font-semibold focus-visible:ring-[3px] focus-visible:ring-ring/50", onlyPending ? "border-amber-400 text-amber-800 dark:text-amber-300" : "border-base text-mut")}>
+ {onlyPending ? BRIEF_PANEL_COPY.showAll : BRIEF_PANEL_COPY.needsConfirmation}
+
+ openModal({ mode: "edit", projectSlug: slug })} className="rounded-lg bg-inv px-3 py-1.5 text-[12px] font-semibold text-inv focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40">
+ {BRIEF_PANEL_COPY.editBrief}
+
+
+
+
+ {brief.status === "RESOLVING" && (
+
+
+ {BRIEF_PANEL_COPY.resolving}
+
+ )}
+
+ {reviewing && (
+
+
{BRIEF_PANEL_COPY.reviewBanner}
+
{BRIEF_PANEL_COPY.reviewHelp}
+ {brief.resolution.notice &&
{BRIEF_PANEL_COPY.resolveNotice}
}
+
+ )}
+
+ {staleVersion && (
+
+ {BRIEF_PANEL_COPY.staleBanner}
+ {onRegenerate && (
+
+ Regenerate
+
+ )}
+
+ )}
+
+ {rows.length === 0 && onlyPending && Nothing needs your confirmation.
}
+
+ {rows.map(({ step, fields }) => (
+
+
{BRIEF_STEP_COPY[step].title}
+
+
+ ))}
+
+ {!onlyPending && brief.omitted.length > 0 && (
+
+
{BRIEF_PANEL_COPY.omittedTitle}
+
+ {brief.omitted.map((o) => (
+
+ {o.label}
+ openModal({ mode: "edit", projectSlug: slug, step: o.step })} className="font-semibold text-blue-700 underline underline-offset-2 dark:text-blue-300" aria-label={`Add ${o.label}`}>
+ Add
+
+
+ ))}
+
+
+ )}
+
+ {brief.warnings.length > 0 && (
+
+
{BRIEF_PANEL_COPY.warningsTitle}
+
+ {brief.warnings.map((w) => (
+ {w.message}
+ ))}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {reviewing && (
+
+
void approve()} className="rounded-xl bg-inv px-5 py-2.5 text-[13px] font-semibold text-inv hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40">
+ {BRIEF_PANEL_COPY.approve}
+
+ {!allConfirmed && (
+
+ Confirm or remove: {pendingClaims.filter((p) => !confirmed.has(p)).map(fieldLabel).join(", ")}
+
+ )}
+
+ )}
+
+ )
+}
+
+function fieldStep(id: FieldId): StepId {
+ return FIELD_DEFS[id].step
+}
diff --git a/apps/web/src/components/brief/PrefillPanel.tsx b/apps/web/src/components/brief/PrefillPanel.tsx
new file mode 100644
index 0000000..771bfec
--- /dev/null
+++ b/apps/web/src/components/brief/PrefillPanel.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { BRIEF_UI_COPY, type BriefView, type PrefillBriefInput } from "@repo/schemas"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Textarea } from "@/components/ui/textarea"
+import { Input } from "@/components/ui/input"
+import { briefsApi, type BriefApiError } from "@/lib/api/services/briefs.service"
+
+type Props = {
+ briefId: string | null
+ defaultOpen: boolean
+ autoRunText?: string
+ beforeRun: () => Promise
+ onResult: (view: BriefView, filled: number) => void
+ announce: (message: string) => void
+}
+
+type Tab = "text" | "url" | "doc"
+
+export function PrefillPanel({ briefId, defaultOpen, autoRunText, beforeRun, onResult, announce }: Props) {
+ const [open, setOpen] = useState(defaultOpen || !!autoRunText)
+ const [tab, setTab] = useState("text")
+ const [text, setText] = useState(autoRunText ?? "")
+ const [url, setUrl] = useState("")
+ const [file, setFile] = useState(null)
+ const [running, setRunning] = useState(false)
+ const [error, setError] = useState(null)
+ const controller = useRef(null)
+ const autoRan = useRef(false)
+
+ const run = async (input: { kind: Tab; text?: string; url?: string; file?: File | null }) => {
+ if (!briefId) return
+ setError(null)
+ setRunning(true)
+ announce(BRIEF_UI_COPY.prefillWorking)
+ const ctrl = new AbortController()
+ controller.current = ctrl
+ try {
+ await beforeRun()
+ let payload: PrefillBriefInput
+ if (input.kind === "doc") {
+ if (!input.file) throw new Error("no file")
+ const uploaded = await briefsApi.upload(briefId, input.file, "sourceDocument")
+ const uploadId = uploaded.data.sourceDocument?.uploadId
+ if (!uploadId) throw new Error("upload failed")
+ payload = { kind: "doc", uploadId }
+ } else if (input.kind === "url") {
+ payload = { kind: "url", url: input.url ?? "" }
+ } else {
+ payload = { kind: "text", text: input.text ?? "" }
+ }
+ const result = await briefsApi.prefill(briefId, payload, ctrl.signal)
+ onResult(result.brief, result.filled.length)
+ const message = result.filled.length
+ ? `We filled ${result.filled.length} answer${result.filled.length === 1 ? "" : "s"}. Check the ones marked ${BRIEF_UI_COPY.suggested}.`
+ : "We didn't find anything new to fill in."
+ announce(message)
+ setError(null)
+ } catch (err) {
+ if (ctrl.signal.aborted) {
+ announce("Cancelled")
+ return
+ }
+ const e = err as BriefApiError
+ const message = e.status === 429 ? BRIEF_UI_COPY.rateLimited : e.status === 413 || e.status === 415 ? e.message : BRIEF_UI_COPY.prefillFailed
+ setError(message)
+ announce(message)
+ } finally {
+ setRunning(false)
+ controller.current = null
+ }
+ }
+
+ useEffect(() => {
+ if (autoRunText && briefId && !autoRan.current) {
+ autoRan.current = true
+ void run({ kind: "text", text: autoRunText })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [autoRunText, briefId])
+
+ useEffect(() => () => controller.current?.abort(), [])
+
+ const canRun =
+ !running && !!briefId && (tab === "text" ? text.trim().length > 0 : tab === "url" ? url.trim().length > 0 : !!file)
+
+ return (
+
+
+ setOpen((v) => !v)}
+ className="flex w-full items-center justify-between rounded-2xl px-4 py-3 text-left text-[13px] font-semibold text-pri focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ >
+ {BRIEF_UI_COPY.startFaster}
+
+ {open ? "−" : "+"}
+
+
+
+ {open && (
+
+
{BRIEF_UI_COPY.startFasterHelp}
+
setTab(v as Tab)}>
+
+ {BRIEF_UI_COPY.prefillTabs.text}
+ {BRIEF_UI_COPY.prefillTabs.url}
+ {BRIEF_UI_COPY.prefillTabs.doc}
+
+
+
+ {BRIEF_UI_COPY.prefillTabs.text}
+
+
+
+
+ {BRIEF_UI_COPY.prefillTabs.url}
+
+ setUrl(e.target.value)} disabled={running} />
+
+
+
+ PDF, DOCX, PPTX, TXT or MD up to 10 MB
+
+ {
+ const f = e.target.files?.[0] ?? null
+ if (f && f.size > 10 * 1024 * 1024) {
+ setError("Documents can be up to 10 MB")
+ setFile(null)
+ return
+ }
+ setFile(f)
+ }}
+ />
+
+
+
+ void run({ kind: tab, text, url, file })}
+ className="rounded-xl bg-inv px-4 py-2 text-[12.5px] font-semibold text-inv hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40"
+ >
+ {running ? BRIEF_UI_COPY.prefillWorking : BRIEF_UI_COPY.fillFromThis}
+
+ {running && (
+ controller.current?.abort()} className="rounded-xl border border-base px-3 py-2 text-[12.5px] font-medium text-sec hover:bg-tertiary focus-visible:ring-[3px] focus-visible:ring-ring/50">
+ {BRIEF_UI_COPY.cancel}
+
+ )}
+ {running && }
+
+ {error &&
{error}
}
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/components/brief/briefFields.ts b/apps/web/src/components/brief/briefFields.ts
new file mode 100644
index 0000000..d091ad6
--- /dev/null
+++ b/apps/web/src/components/brief/briefFields.ts
@@ -0,0 +1,136 @@
+import {
+ BRIEF_OPTION_LABELS,
+ FIELD_DEFS,
+ QUICK_STEP_IDS,
+ REQUIRED_FIELD_IDS,
+ STEP_IDS,
+ fieldsForStep,
+ isBlankValue,
+ type BriefData,
+ type BriefFieldErrors,
+ type FieldId,
+ type StepId,
+} from "@repo/schemas"
+import { BUILDER_CAPABILITIES } from "@repo/site-builder"
+
+export type BriefViewMode = "QUICK" | "FULL"
+
+// sourceDocument is only ever used to pre-fill, so it lives in the
+// "Start faster" panel rather than the brand step.
+const PANEL_ONLY: FieldId[] = ["sourceDocument"]
+
+export function isFieldVisible(id: FieldId, data: BriefData): boolean {
+ if (PANEL_ONLY.includes(id)) return false
+ if (id === "language") return BUILDER_CAPABILITIES.languages.length > 1
+ if (id === "launchDate") return data.availability === "LAUNCHING_SOON"
+ return true
+}
+
+export function stepsFor(view: BriefViewMode): StepId[] {
+ return view === "QUICK" ? [...QUICK_STEP_IDS] : [...STEP_IDS]
+}
+
+export function fieldsFor(step: StepId, view: BriefViewMode, data: BriefData): FieldId[] {
+ return fieldsForStep(step).filter(
+ (id) => isFieldVisible(id, data) && (view === "FULL" || REQUIRED_FIELD_IDS.includes(id)),
+ )
+}
+
+export function stepHasRequired(step: StepId): boolean {
+ return fieldsForStep(step).some((id) => REQUIRED_FIELD_IDS.includes(id))
+}
+
+export function fieldOfErrorKey(key: string): FieldId {
+ return key.split(".")[0] as FieldId
+}
+
+export function errorsForField(errors: BriefFieldErrors, id: FieldId): string[] {
+ return Object.entries(errors)
+ .filter(([k]) => fieldOfErrorKey(k) === id)
+ .flatMap(([, msgs]) => msgs)
+}
+
+export function firstStepWithError(errors: BriefFieldErrors): StepId | null {
+ const fields = new Set(Object.keys(errors).map(fieldOfErrorKey))
+ return STEP_IDS.find((s) => fieldsForStep(s).some((f) => fields.has(f))) ?? null
+}
+
+export function firstUnfilledStep(data: BriefData, after: StepId | null): StepId {
+ const start = after ? STEP_IDS.indexOf(after) + 1 : 0
+ return STEP_IDS.slice(start).find((s) => fieldsForStep(s).some((f) => isFieldVisible(f, data) && isBlankValue(data[f]))) ?? STEP_IDS[0]
+}
+
+function label(group: keyof typeof BRIEF_OPTION_LABELS, value: string): string {
+ return (BRIEF_OPTION_LABELS[group] as Record)[value] ?? value
+}
+
+function clip(text: string, max = 90): string {
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
+}
+
+// Short, plain-text summary of an answer for the brief panel.
+export function summarize(id: FieldId, data: BriefData): string {
+ const v = data[id] as unknown
+ if (isBlankValue(v)) return "—"
+ switch (id) {
+ case "productType":
+ return label("productType", v as string)
+ case "availability":
+ return label("availability", v as string)
+ case "primaryGoal":
+ return label("primaryGoal", v as string)
+ case "audienceSegment":
+ return label("audienceSegment", v as string)
+ case "siteType":
+ return label("siteType", v as string)
+ case "tone":
+ return (v as string[]).map((t) => label("tone", t)).join(", ")
+ case "trafficSources":
+ return (v as string[]).map((t) => label("trafficSource", t)).join(", ")
+ case "features":
+ return clip((data.features ?? []).map((f) => f.title).join(", "))
+ case "howItWorks":
+ return clip((data.howItWorks?.steps ?? []).map((s) => s.title).join(" → "))
+ case "competitors":
+ return clip((data.competitors ?? []).map((c) => c.name).join(", "))
+ case "testimonials":
+ return `${data.testimonials?.length ?? 0} quote(s)`
+ case "metrics":
+ return clip((data.metrics ?? []).map((m) => `${m.value} ${m.label}`).join(", "))
+ case "certifications":
+ return clip((data.certifications ?? []).map((c) => c.name).join(", "))
+ case "faq":
+ return `${data.faq?.length ?? 0} question(s)`
+ case "pricing":
+ return data.pricing?.mode === "SHOW_PLANS"
+ ? clip((data.pricing.plans ?? []).map((p) => `${p.name} ${p.currency} ${p.price}`).join(", "))
+ : label("pricingMode", data.pricing?.mode ?? "HIDE")
+ case "ctaPrimary":
+ case "ctaSecondary": {
+ const cta = data[id]!
+ return `${cta.label} → ${cta.type === "URL" ? cta.url ?? "" : label("ctaType", cta.type)}`
+ }
+ case "buyer":
+ return data.buyer?.sameAsUser ? "Same as the user" : clip(data.buyer?.description ?? "")
+ case "legal":
+ return label("legalMode", data.legal?.mode ?? "GENERATE_TEMPLATE")
+ case "logo":
+ return "Uploaded"
+ case "socialLinks":
+ return clip(Object.keys(data.socialLinks ?? {}).map((n) => label("socialNetwork", n)).join(", "))
+ case "afterConversion":
+ return clip(data.afterConversion?.message ?? "")
+ case "audienceDetails":
+ return clip(Object.values(data.audienceDetails ?? {}).filter(Boolean).join(", "))
+ case "designReferences":
+ return clip((data.designReferences ?? []).map((r) => r.url).join(", "))
+ }
+ if (typeof v === "boolean") return v ? "Yes" : "No"
+ if (Array.isArray(v)) return clip(v.map(String).join(", "))
+ if (typeof v === "string") return clip(v)
+ return clip(JSON.stringify(v))
+}
+
+export function fieldLabel(id: FieldId): string {
+ return FIELD_DEFS[id].label
+}
diff --git a/apps/web/src/components/brief/useBriefDraft.ts b/apps/web/src/components/brief/useBriefDraft.ts
new file mode 100644
index 0000000..294d5a9
--- /dev/null
+++ b/apps/web/src/components/brief/useBriefDraft.ts
@@ -0,0 +1,283 @@
+"use client"
+
+import { useCallback, useEffect, useRef, useState } from "react"
+import {
+ isBlankValue,
+ type BriefData,
+ type BriefFieldErrors,
+ type BriefMode,
+ type BriefStepPointer,
+ type BriefView,
+ type FieldId,
+ type PatchBriefInput,
+} from "@repo/schemas"
+import { briefsApi, type BriefApiError } from "@/lib/api/services/briefs.service"
+
+const AUTOSAVE_DELAY_MS = 800
+
+export type SaveState = "idle" | "saving" | "saved" | "error" | "conflict" | "rateLimited"
+
+type Pending = {
+ set: Partial>
+ unset: Set
+ aiDecide: Partial>
+}
+
+const emptyPending = (): Pending => ({ set: {}, unset: new Set(), aiDecide: {} })
+
+function isEmpty(p: Pending): boolean {
+ return Object.keys(p.set).length === 0 && p.unset.size === 0 && Object.keys(p.aiDecide).length === 0
+}
+
+function merge(into: Pending, from: Pending): void {
+ for (const [k, v] of Object.entries(from.set)) if (!(k in into.set) && !into.unset.has(k as FieldId)) into.set[k as FieldId] = v
+ for (const k of from.unset) if (!(k in into.set)) into.unset.add(k)
+ for (const [k, v] of Object.entries(from.aiDecide)) if (!(k in into.aiDecide)) into.aiDecide[k as FieldId] = v
+}
+
+function overlay(data: BriefData, p: Pending): BriefData {
+ const next: Record = { ...data, ...p.set }
+ for (const k of p.unset) delete next[k]
+ for (const [k, on] of Object.entries(p.aiDecide)) if (on) delete next[k]
+ return next as BriefData
+}
+
+function toBody(version: number, p: Pending, columns: { mode?: BriefMode; currentStep?: BriefStepPointer }): PatchBriefInput {
+ return {
+ expectedVersion: version,
+ ...(Object.keys(p.set).length ? { set: p.set as Record } : {}),
+ ...(p.unset.size ? { unset: [...p.unset] } : {}),
+ ...(Object.keys(p.aiDecide).length ? { aiDecide: p.aiDecide as Record } : {}),
+ ...columns,
+ }
+}
+
+// Owns the working copy of a brief. In "draft" mode every change is
+// autosaved (debounced, optimistic-concurrency); in "edit" mode changes are
+// held until save() so an attached brief gets one revision per save.
+export function useBriefDraft(kind: "draft" | "edit", projectSlug?: string) {
+ const [brief, setBriefState] = useState(null)
+ const [data, setData] = useState({})
+ const [errors, setErrors] = useState({})
+ const [saveState, setSaveState] = useState("idle")
+ const [dirty, setDirty] = useState(false)
+
+ const briefRef = useRef(null)
+ const pending = useRef(emptyPending())
+ const columns = useRef<{ mode?: BriefMode; currentStep?: BriefStepPointer }>({})
+ const inflight = useRef | null>(null)
+ const timer = useRef | null>(null)
+ const flushRef = useRef<() => Promise>(async () => null)
+
+ const setBrief = useCallback((view: BriefView | null) => {
+ briefRef.current = view
+ setBriefState(view)
+ }, [])
+
+ const load = useCallback(
+ (view: BriefView) => {
+ pending.current = emptyPending()
+ columns.current = {}
+ setBrief(view)
+ setData(view.data)
+ setErrors({})
+ setDirty(false)
+ setSaveState("idle")
+ },
+ [setBrief],
+ )
+
+ const acceptServer = useCallback(
+ (view: BriefView) => {
+ setBrief(view)
+ setData(overlay(view.data, pending.current))
+ },
+ [setBrief],
+ )
+
+ const schedule = useCallback(
+ (delay = AUTOSAVE_DELAY_MS) => {
+ if (kind === "edit") {
+ setDirty(true)
+ return
+ }
+ if (timer.current) clearTimeout(timer.current)
+ timer.current = setTimeout(() => void flushRef.current(), delay)
+ },
+ [kind],
+ )
+
+ const flush = useCallback(async (): Promise => {
+ if (kind === "edit") return briefRef.current
+ if (timer.current) clearTimeout(timer.current)
+ while (inflight.current) await inflight.current.catch(() => {})
+ const current = briefRef.current
+ if (!current || (isEmpty(pending.current) && Object.keys(columns.current).length === 0)) return current
+
+ const take = pending.current
+ const cols = columns.current
+ pending.current = emptyPending()
+ columns.current = {}
+ setSaveState("saving")
+
+ const request = briefsApi
+ .patch(current.id, toBody(current.version, take, cols))
+ .then((view) => {
+ acceptServer(view)
+ setSaveState("saved")
+ })
+ .catch((err: BriefApiError) => {
+ if (err.status === 409) {
+ merge(pending.current, take)
+ setSaveState("conflict")
+ return
+ }
+ if (err.status === 422 && err.errors) {
+ setErrors((prev) => ({ ...prev, ...err.errors }))
+ const failed = new Set(Object.keys(err.errors).map((k) => k.split(".")[0]))
+ const retry: Pending = emptyPending()
+ for (const [k, v] of Object.entries(take.set)) if (!failed.has(k)) retry.set[k as FieldId] = v
+ for (const k of take.unset) if (!failed.has(k)) retry.unset.add(k)
+ for (const [k, v] of Object.entries(take.aiDecide)) if (!failed.has(k)) retry.aiDecide[k as FieldId] = v
+ merge(pending.current, retry)
+ columns.current = { ...cols, ...columns.current }
+ setSaveState("error")
+ if (!isEmpty(pending.current)) schedule()
+ return
+ }
+ merge(pending.current, take)
+ columns.current = { ...cols, ...columns.current }
+ if (err.status === 429) {
+ setSaveState("rateLimited")
+ schedule(((err.retryAfter ?? 10) + 1) * 1000)
+ return
+ }
+ setSaveState("error")
+ schedule(5000)
+ })
+ inflight.current = request
+ await request
+ inflight.current = null
+ if (!isEmpty(pending.current)) schedule()
+ return briefRef.current
+ }, [kind, acceptServer, schedule])
+ flushRef.current = flush
+
+ useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current)
+ },
+ [],
+ )
+
+ const clearErrorsFor = useCallback((id: FieldId) => {
+ setErrors((prev) => {
+ const next = { ...prev }
+ for (const k of Object.keys(next)) if (k === id || k.startsWith(`${id}.`)) delete next[k]
+ return next
+ })
+ }, [])
+
+ const setField = useCallback(
+ (id: FieldId, value: unknown) => {
+ const blank = isBlankValue(value) && typeof value !== "boolean"
+ setData((prev) => {
+ const next: Record = { ...prev }
+ if (blank) delete next[id]
+ else next[id] = value
+ return next as BriefData
+ })
+ if (blank) {
+ delete pending.current.set[id]
+ pending.current.unset.add(id)
+ } else {
+ pending.current.set[id] = value
+ pending.current.unset.delete(id)
+ }
+ if (pending.current.aiDecide[id]) delete pending.current.aiDecide[id]
+ clearErrorsFor(id)
+ schedule()
+ },
+ [schedule, clearErrorsFor],
+ )
+
+ const setAiDecide = useCallback(
+ (id: FieldId, on: boolean) => {
+ pending.current.aiDecide[id] = on
+ if (on) {
+ delete pending.current.set[id]
+ pending.current.unset.delete(id)
+ setData((prev) => {
+ const next: Record = { ...prev }
+ delete next[id]
+ return next as BriefData
+ })
+ }
+ setBriefState((b) =>
+ b ? { ...b, meta: { ...b.meta, [id]: { ...(b.meta[id] ?? { source: "user", confirmed: true, updatedAt: "" }), aiDecide: on } } } : b,
+ )
+ clearErrorsFor(id)
+ schedule()
+ },
+ [schedule, clearErrorsFor],
+ )
+
+ const setColumns = useCallback(
+ (cols: { mode?: BriefMode; currentStep?: BriefStepPointer }) => {
+ if (kind === "edit") return
+ columns.current = { ...columns.current, ...cols }
+ schedule()
+ },
+ [kind, schedule],
+ )
+
+ const isAiDecide = useCallback(
+ (id: FieldId) => pending.current.aiDecide[id] ?? brief?.meta[id]?.aiDecide === true,
+ [brief],
+ )
+
+ // Edit mode: one PATCH for everything changed since the modal opened.
+ const saveEdit = useCallback(async (): Promise<{ brief: BriefView; regenerateNeeded: boolean }> => {
+ const current = briefRef.current
+ if (!current || !projectSlug) throw new Error("Nothing to save")
+ const take = pending.current
+ try {
+ const result = await briefsApi.patchForProject(projectSlug, toBody(current.version, take, {}))
+ pending.current = emptyPending()
+ setBrief(result.brief)
+ setData(result.brief.data)
+ setDirty(false)
+ return result
+ } catch (err) {
+ const e = err as BriefApiError
+ if (e.status === 422 && e.errors) setErrors(e.errors)
+ if (e.status === 409) setSaveState("conflict")
+ throw err
+ }
+ }, [projectSlug, setBrief])
+
+ const hasUserAnswers = useCallback(() => {
+ const meta = briefRef.current?.meta ?? {}
+ return !isEmpty(pending.current) || Object.values(meta).some((m) => m?.source === "user")
+ }, [])
+
+ return {
+ brief,
+ data,
+ errors,
+ setErrors,
+ saveState,
+ dirty,
+ load,
+ acceptServer,
+ flush,
+ setField,
+ setAiDecide,
+ setColumns,
+ isAiDecide,
+ saveEdit,
+ hasUserAnswers,
+ }
+}
+
+export type BriefDraft = ReturnType
diff --git a/apps/web/src/components/chat/ChatHomeView.tsx b/apps/web/src/components/chat/ChatHomeView.tsx
index b9c3124..1cf04be 100644
--- a/apps/web/src/components/chat/ChatHomeView.tsx
+++ b/apps/web/src/components/chat/ChatHomeView.tsx
@@ -14,7 +14,7 @@ import {
PipelineToggle,
type PipelineToggleStep,
} from "@/components/workspace-tabs/PipelineToggle";
-import { ExtractFlowPanel } from "@/components/chat/ExtractFlowPanel";
+import { useProjectModalStore } from "@/store/projectModalStore";
import { usePendingPromptStore } from "@/stores/pendingPromptStore";
import { WorkspaceShell } from "@/components/workspace/WorkspaceShell";
import WorkspaceLoading from "../../../app/(chat)/project/[slug]/loading";
@@ -83,10 +83,7 @@ export default function ChatHomeView() {
const [greeting, setGreeting] = useState("Hi");
const [showSkeleton, setShowSkeleton] = useState(true);
const [activeTab, setActiveTab] = useState("RESEARCH");
- // New, additive guided-setup flow (/extract -> dynamic questions -> minimal
- // create form -> /plan SSE). Entirely separate from the existing
- // useClarifyStore turn-by-turn chat flow below, which is left untouched.
- const [useGuidedSetup, setUseGuidedSetup] = useState(false);
+ const openBriefModal = useProjectModalStore((s) => s.open);
// Once a project exists — either from typing an idea here, or from the
// create-project modal's "Start generation" — the home page renders the
// same 4-step human-in-the-loop pipeline (WorkspaceShell) that
@@ -144,7 +141,6 @@ export default function ChatHomeView() {
inputType,
sourceUrl,
error,
- startIdea,
setQuestions,
answerQuestion,
setGenerating,
@@ -220,36 +216,10 @@ export default function ChatHomeView() {
const handleSubmit = useCallback(
async (message: string) => {
+ // A new idea goes through the intake modal (BriefModal), which replaces
+ // the old chip-question clarify round.
if (phase === "idle") {
- startIdea(message);
- setIsThinking(true);
- try {
- const result = await clarify(message);
- setIsThinking(false);
- setQuestions(result.ready ? [] : (result.questions ?? []), {
- name: result.name,
- niche: result.niche,
- targetAudience: result.targetAudience,
- brandPersonality: result.brandPersonality,
- pricePositioning: result.pricePositioning,
- businessModel: result.businessModel,
- differentiator: result.differentiator,
- });
- if (result.ready) {
- runGeneration(
- result.niche ?? "OTHER",
- result.targetAudience ?? "General audience",
- result.name ?? message.slice(0, 40),
- result.brandPersonality,
- result.pricePositioning,
- result.businessModel,
- result.differentiator,
- );
- }
- } catch (err) {
- setIsThinking(false);
- setError(err instanceof Error ? err.message : "Something went wrong");
- }
+ openBriefModal({ mode: "create", ideaText: message });
return;
}
@@ -294,7 +264,7 @@ export default function ChatHomeView() {
},
[
phase,
- startIdea,
+ openBriefModal,
clarify,
runGeneration,
setQuestions,
@@ -319,8 +289,8 @@ export default function ChatHomeView() {
// A visitor typed an idea into the marketing homepage's hero chatbox
// while signed out, then landed here after completing sign-in. Pick up
- // that stored prompt once and kick off the normal clarify flow with it,
- // exactly as if they'd typed it into ChatInput themselves.
+ // that stored prompt once and open the intake modal with it; nothing
+ // reaches a model before sign-in.
const pendingPrompt = usePendingPromptStore((s) => s.prompt);
const clearPendingPrompt = usePendingPromptStore((s) => s.clear);
useEffect(() => {
@@ -410,15 +380,6 @@ export default function ChatHomeView() {
onSelectStep={(id) => setActiveTab(id as WelcomeTab)}
/>
- setUseGuidedSetup((v) => !v)}
- className="self-center text-[12px] font-medium text-mut hover:text-sec transition-colors cursor-pointer underline underline-offset-2"
- >
- {useGuidedSetup
- ? "Use chat instead"
- : "Try the new guided setup"}
-
>
) : (
)}
- {isIdle && useGuidedSetup ? (
-
- ) : (
- phase !== "generating" && (
- <>
-
- >
- )
+ {phase !== "generating" && (
+
)}
diff --git a/apps/web/src/components/chat/ExtractFlowPanel.tsx b/apps/web/src/components/chat/ExtractFlowPanel.tsx
deleted file mode 100644
index d22a651..0000000
--- a/apps/web/src/components/chat/ExtractFlowPanel.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-"use client"
-
-import { useCallback, useState } from "react"
-import { useExtractStore } from "@/stores/extractStore"
-import { extractApi } from "@/lib/api/services/extract.service"
-import { MinimalCreateProjectModal } from "@/components/project/MinimalCreateProjectModal"
-
-// New, standalone home-page flow: describe your idea -> dynamic
-// (fully server-driven, not hardcoded to 3) clarifying questions ->
-// minimal create form -> project + version created -> /plan SSE stream.
-// Deliberately independent of ChatHomeView's existing turn-by-turn
-// useClarifyStore/ChatInput wiring, which stays untouched.
-export function ExtractFlowPanel() {
- const {
- phase,
- ideaText,
- extracted,
- questions,
- answers,
- error,
- start,
- answerQuestion,
- finish,
- setError,
- reset,
- } = useExtractStore()
-
- const [draftIdea, setDraftIdea] = useState("")
- const [isLoading, setIsLoading] = useState(false)
- const [createOpen, setCreateOpen] = useState(false)
-
- const handleStart = useCallback(async () => {
- if (!draftIdea.trim()) return
- setIsLoading(true)
- setError(null)
- try {
- const result = await extractApi.extract(draftIdea.trim(), "free")
- start(draftIdea.trim(), result.extracted, result.questions)
- if (result.questions.length === 0) {
- setCreateOpen(true)
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Something went wrong")
- } finally {
- setIsLoading(false)
- }
- }, [draftIdea, start, setError])
-
- const handleAnswerChange = useCallback(
- (questionId: string, value: string) => {
- answerQuestion(questionId, value)
- },
- [answerQuestion],
- )
-
- const handleAnswersSubmit = useCallback(async () => {
- setIsLoading(true)
- setError(null)
- try {
- const result = await extractApi.answer(extracted, answers)
- finish(result.extracted)
- setCreateOpen(true)
- } catch (err) {
- setError(err instanceof Error ? err.message : "Something went wrong")
- } finally {
- setIsLoading(false)
- }
- }, [extracted, answers, finish, setError])
-
- const handleClose = useCallback(() => {
- reset()
- setDraftIdea("")
- }, [reset])
-
- return (
-
- {phase === "idle" && (
-
-
- )}
-
- {phase === "questions" && (
-
- {questions.map((q) => (
-
-
{q.question}
- {q.options && q.options.length > 0 ? (
-
- {q.options.map((opt) => (
- handleAnswerChange(q.id, opt)}
- className={`rounded-lg border px-3 py-1.5 text-[12.5px] font-medium transition-colors cursor-pointer ${
- answers[q.id] === opt
- ? "border-em bg-bubble text-pri"
- : "border-base text-sec hover:border-em"
- }`}
- >
- {opt}
-
- ))}
-
- ) : (
-
handleAnswerChange(q.id, e.target.value)}
- placeholder="Type your answer..."
- className="w-full rounded-lg border border-base bg-transparent px-3 py-2 text-[13px] text-pri placeholder:text-mut outline-none"
- />
- )}
-
- ))}
-
-
- {isLoading ? "Saving..." : "Continue"}
-
-
- )}
-
- {error && (
-
- {error}
-
- )}
-
-
{
- setCreateOpen(v)
- if (!v) handleClose()
- }}
- />
-
- )
-}
diff --git a/apps/web/src/components/project/CreateProjectModal.tsx b/apps/web/src/components/project/CreateProjectModal.tsx
deleted file mode 100644
index fa4fec9..0000000
--- a/apps/web/src/components/project/CreateProjectModal.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-"use client"
-
-import { useState } from "react"
-import { useRouter } from "next/navigation"
-import { useMutation } from "@tanstack/react-query"
-import {
- Dialog,
- DialogContent,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { ProjectForm } from "./ProjectForm"
-import { projectsApi } from "@/lib/api/services/projects.service"
-import { useProjectStore } from "@/stores/projectStore"
-import { useProjectModalStore } from "@/store/projectModalStore"
-import type { CreateProjectInput, ProjectInputType } from "@repo/schemas"
-
-type SiteType = CreateProjectInput["siteType"]
-
-const SITE_TYPES: { value: SiteType; label: string }[] = [
- { value: "SINGLE_PAGE", label: "Single page" },
- { value: "MULTI_PAGE", label: "Multi page" },
- { value: undefined, label: "Let AI decide" },
-]
-
-const ENTRY_OPTIONS: { value: ProjectInputType; label: string }[] = [
- { value: "FROM_SCRATCH", label: "From idea" },
- { value: "FROM_OWN_SITE", label: "Redesign my site" },
-]
-
-const STATS = [
- { value: "100,000+", label: "Pages generated" },
- { value: "120+", label: "Research-backed rules" },
- { value: "4.8/5", label: "Rated by builders" },
-]
-
-export function CreateProjectModal() {
- const router = useRouter()
- const { isOpen, close } = useProjectModalStore()
- const [step, setStep] = useState<1 | 2>(1)
- const [inputType, setInputType] = useState("FROM_SCRATCH")
- const [name, setName] = useState("")
- const [siteType, setSiteType] = useState(undefined)
- // Set once the project row exists; swaps the modal to the "start generation?"
- // confirmation instead of navigating away immediately.
- const [createdSlug, setCreatedSlug] = useState(null)
- const { setProject } = useProjectStore()
-
- const createMutation = useMutation({
- mutationFn: projectsApi.create,
- onSuccess: (result) => {
- setProject(result.project, result.version)
- setCreatedSlug(result.project.slug)
- },
- })
-
- const handleSubmit = async (data: CreateProjectInput) => {
- await createMutation.mutateAsync({ ...data, inputType })
- }
-
- const handleClose = (v: boolean) => {
- if (v) return
- setStep(1)
- setName("")
- setSiteType(undefined)
- setCreatedSlug(null)
- createMutation.reset()
- close()
- }
-
- // "Later" — project already exists and shows on /projects; just dismiss.
- const handleDismissAfterCreate = () => {
- handleClose(false)
- router.refresh()
- }
-
- // "Start generation" — hand off to the home page, which drives the 4-step
- // human-in-the-loop pipeline for this project.
- const handleStartGeneration = () => {
- const slug = createdSlug
- handleClose(false)
- router.push(`/?project=${slug}&start=1`)
- }
-
- return (
-
-
- {/* Fixed height so step 1 and step 2 render at exactly the same size —
- no jump when advancing between them. */}
-
- {/* ---------- Left: visual panel ---------- */}
-
-
-
-
-
-
- UseFrame
-
-
-
- {STATS.map((s) => (
-
-
- {s.value}
-
-
{s.label}
-
- ))}
-
-
-
- {/* ---------- Right: steps ---------- */}
-
-
-
-
-
-
-
-
-
setStep(1)}
- disabled={step === 1 || createMutation.isPending}
- aria-label="Previous step"
- className="flex h-7 w-7 items-center justify-center rounded-lg border border-base text-mut transition-colors hover:border-em hover:text-sec disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
- >
-
-
-
-
-
setStep(2)}
- disabled={step === 2}
- aria-label="Next step"
- className="flex h-7 w-7 items-center justify-center rounded-lg border border-base text-mut transition-colors hover:border-em hover:text-sec disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
- >
-
-
-
-
-
-
-
- {createdSlug ? (
- <>
-
-
- Project created
-
-
- It's saved and visible on your Projects page. Start
- generation now to run research, website, SEO and deploy —
- approving each step as you go.
-
-
-
-
-
- Start generation
-
-
- Later
-
-
- >
- ) : step === 1 ? (
- <>
-
-
- How would you like to start?
-
-
- Choose how you want to begin your project.
-
-
-
-
- {ENTRY_OPTIONS.map((opt) => {
- const active = inputType === opt.value
- return (
- setInputType(opt.value)}
- className={`rounded-full border px-4 py-2.5 text-[13px] font-medium transition-all duration-150 cursor-pointer ${
- active
- ? "border-blue-400 bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400"
- : "border-base text-sec hover:border-em hover:bg-tertiary"
- }`}
- >
- {opt.label}
-
- )
- })}
-
-
-
- Project name
- setName(e.target.value)}
- />
-
-
-
-
Site type
-
- {SITE_TYPES.map((t) => {
- const active = siteType === t.value
- return (
- setSiteType(t.value)}
- className={`rounded-full border px-4 py-2 text-[12.5px] font-medium transition-all duration-150 cursor-pointer ${
- active
- ? "border-blue-400 bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400"
- : "border-base text-sec hover:border-em hover:bg-tertiary"
- }`}
- >
- {t.label}
-
- )
- })}
-
-
-
-
- setStep(2)}
- disabled={name.trim().length < 2}
- className="w-full rounded-2xl bg-inv py-3.5 text-[13.5px] font-semibold text-inv transition-opacity duration-150 hover:opacity-90 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
- >
- Continue
-
-
- >
- ) : (
- <>
-
-
- Tell us about your product
-
-
- Project details used to generate your page.
-
-
-
-
- >
- )}
-
- {createMutation.isError && (
-
- {createMutation.error?.message ?? "Something went wrong"}
-
- )}
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/project/MinimalCreateProjectModal.tsx b/apps/web/src/components/project/MinimalCreateProjectModal.tsx
deleted file mode 100644
index 4fd1738..0000000
--- a/apps/web/src/components/project/MinimalCreateProjectModal.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-"use client"
-
-import { useEffect, useState } from "react"
-import { useRouter } from "next/navigation"
-import { useMutation } from "@tanstack/react-query"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { projectsApi } from "@/lib/api/services/projects.service"
-import { useExtractStore } from "@/stores/extractStore"
-import { usePlanStream } from "@/hooks/usePlanStream"
-import { isFreeTierExhausted } from "@/lib/freeTierError"
-import type { NicheCategory } from "@repo/schemas"
-
-const ORCHESTRATOR_URL =
- process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:4001"
-
-const NICHES = [
- "EDTECH",
- "HEALTH_WELLNESS",
- "FINTECH",
- "SAAS_B2B",
- "ECOMMERCE",
- "FOOD_LIFESTYLE",
- "FITNESS",
- "LUXURY",
- "MEDITATION",
- "KIDS",
- "OTHER",
-] as const
-
-const NICHE_LABELS: Record = {
- EDTECH: "EdTech",
- HEALTH_WELLNESS: "Health & Wellness",
- FINTECH: "FinTech",
- SAAS_B2B: "SaaS / B2B",
- ECOMMERCE: "E-Commerce",
- FOOD_LIFESTYLE: "Food & Lifestyle",
- FITNESS: "Fitness",
- LUXURY: "Luxury",
- MEDITATION: "Meditation",
- KIDS: "Kids",
- OTHER: "Other",
-}
-
-type Props = {
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-// The minimal, post-extract create form: name, industry, target audience,
-// optional "your current site" URL. Distinct from CreateProjectModal (kept
-// untouched, still used from ProjectsView's "New project" entry point) — this
-// one is only reachable from the new home-page extract flow, pre-filled with
-// whatever the extraction + Q&A pass already inferred.
-export function MinimalCreateProjectModal({ open, onOpenChange }: Props) {
- const router = useRouter()
- const { ideaText, extracted, reset } = useExtractStore()
- const { startPlan } = usePlanStream()
-
- const [name, setName] = useState("")
- const [niche, setNiche] = useState("")
- const [targetAudience, setTargetAudience] = useState("")
- const [sourceUrl, setSourceUrl] = useState("")
-
- useEffect(() => {
- if (!open) return
- setName(extracted.name ?? "")
- setNiche((extracted.niche as NicheCategory) ?? "")
- setTargetAudience(extracted.targetAudience ?? "")
- setSourceUrl("")
- }, [open, extracted])
-
- const createMutation = useMutation({
- mutationFn: projectsApi.create,
- onSuccess: (result) => {
- onOpenChange(false)
- reset()
-
- startPlan(ORCHESTRATOR_URL, {
- projectId: result.project.id,
- versionId: result.version.id,
- tier: result.tier,
- ideaText,
- niche: niche || "OTHER",
- targetAudience,
- sourceUrl: sourceUrl || undefined,
- extracted,
- })
-
- router.push(`/project/${result.project.slug}?status=planning`)
- },
- onError: (err) => {
- if (isFreeTierExhausted(err)) {
- onOpenChange(false)
- router.push("/billing")
- }
- },
- })
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!name || !niche || !targetAudience) return
-
- await createMutation.mutateAsync({
- name,
- startupIdea: ideaText.slice(0, 2000),
- niche,
- targetAudience,
- inputType: sourceUrl ? "FROM_OWN_SITE" : "FROM_SCRATCH",
- sourceUrl: sourceUrl || undefined,
- ideaText,
- extracted,
- })
- }
-
- const handleClose = (v: boolean) => {
- if (v) return
- createMutation.reset()
- onOpenChange(false)
- }
-
- return (
-
-
-
-
- A few final details
-
-
- We'll use this to research competitors and build your design brief.
-
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/project/ProjectForm.tsx b/apps/web/src/components/project/ProjectForm.tsx
deleted file mode 100644
index 965b389..0000000
--- a/apps/web/src/components/project/ProjectForm.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-"use client"
-
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CreateProjectSchema } from "@repo/schemas"
-import type { CreateProjectInput, ProjectInputType } from "@repo/schemas"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-
-const NICHES = [
- "EDTECH",
- "HEALTH_WELLNESS",
- "FINTECH",
- "SAAS_B2B",
- "ECOMMERCE",
- "FOOD_LIFESTYLE",
- "FITNESS",
- "LUXURY",
- "MEDITATION",
- "KIDS",
- "OTHER",
-] as const
-
-const NICHE_LABELS: Record = {
- EDTECH: "EdTech",
- HEALTH_WELLNESS: "Health & Wellness",
- FINTECH: "FinTech",
- SAAS_B2B: "SaaS / B2B",
- ECOMMERCE: "E-Commerce",
- FOOD_LIFESTYLE: "Food & Lifestyle",
- FITNESS: "Fitness",
- LUXURY: "Luxury",
- MEDITATION: "Meditation",
- KIDS: "Kids",
- OTHER: "Other",
-}
-
-type Props = {
- inputType: ProjectInputType
- /** Collected in step 1 of the modal, not re-asked here. */
- name: string
- siteType: CreateProjectInput["siteType"]
- onSubmit: (data: CreateProjectInput) => Promise
- isLoading: boolean
-}
-
-export function ProjectForm({ inputType, name, siteType, onSubmit, isLoading }: Props) {
- const {
- register,
- handleSubmit,
- setValue,
- formState: { errors },
- } = useForm({
- resolver: zodResolver(CreateProjectSchema),
- defaultValues: { inputType, name, siteType },
- })
-
- // flex-1 + mt-auto on the button mirrors step 1's layout, so Submit sits
- // pinned at the bottom in exactly the same place Continue does.
- return (
-
- )
-}
diff --git a/apps/web/src/components/projects/ProjectsView.tsx b/apps/web/src/components/projects/ProjectsView.tsx
index bb2b5d8..edf6bba 100644
--- a/apps/web/src/components/projects/ProjectsView.tsx
+++ b/apps/web/src/components/projects/ProjectsView.tsx
@@ -88,7 +88,7 @@ export default function ProjectsView({ initialProjects }: { initialProjects: Pro
All your UseFrame projects in one place.
openProjectModal()}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-inv text-inv text-[13px] font-semibold hover:opacity-90 transition-opacity duration-150 cursor-pointer"
>
@@ -108,7 +108,7 @@ export default function ProjectsView({ initialProjects }: { initialProjects: Pro
No projects yet
openProjectModal()}
className="text-[13px] font-semibold text-pri underline underline-offset-2 cursor-pointer"
>
Create your first project
diff --git a/apps/web/src/components/workspace-tabs/ResearchTab.tsx b/apps/web/src/components/workspace-tabs/ResearchTab.tsx
index cc45d9b..583d833 100644
--- a/apps/web/src/components/workspace-tabs/ResearchTab.tsx
+++ b/apps/web/src/components/workspace-tabs/ResearchTab.tsx
@@ -12,6 +12,7 @@ import { CandidatePicker } from "./CandidatePicker"
import { StepApprovalBar } from "./StepApprovalBar"
import { BriefFieldEditor } from "./BriefFieldEditor"
import type { PipelineStepStatus } from "@/lib/api/services/pipeline.service"
+import { BriefPanel, useProjectBrief } from "@/components/brief/BriefPanel"
type LoadState =
| { status: "loading" }
@@ -99,9 +100,24 @@ type Props = {
pipelineStatus?: PipelineStepStatus
locked?: boolean
onApproved?: () => void
+ onRegenerate?: () => void
}
-export function ResearchTab({ project, pipelineStatus, locked, onApproved }: Props) {
+function hasGeneratedSnapshot(snapshot: unknown): boolean {
+ return !!snapshot && typeof snapshot === "object" && Array.isArray((snapshot as { pages?: unknown }).pages)
+}
+
+export function ResearchTab({ project, pipelineStatus, locked, onApproved, onRegenerate }: Props) {
+ const { data: brief, isLoading: briefLoading } = useProjectBrief(project?.slug)
+ // Projects created before intake briefs have none and research as before;
+ // otherwise research waits until the brief is approved.
+ const briefReady = !briefLoading && (brief === null || brief?.status === "APPROVED")
+ const currentVersion = project?.versions.find((v) => v.id === project.currentVersionId)
+ const staleVersion =
+ !!brief?.latestRevision &&
+ !!currentVersion?.briefRevisionId &&
+ hasGeneratedSnapshot(currentVersion.snapshot) &&
+ currentVersion.briefRevisionId !== brief.latestRevision.id
const [state, setState] = useState({ status: "loading" })
const [isGenerating, setIsGenerating] = useState(false)
const [isApproving, setIsApproving] = useState(false)
@@ -153,11 +169,11 @@ export function ResearchTab({ project, pipelineStatus, locked, onApproved }: Pro
// exists yet — the user still approves/rejects the result, this just saves
// them a manual "generate" click on a step that has to happen either way.
useEffect(() => {
- if (state.status === "empty" && project && pipelineStatus === "PENDING" && !isGenerating) {
+ if (state.status === "empty" && project && pipelineStatus === "PENDING" && !isGenerating && briefReady) {
generate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [state.status, project, pipelineStatus])
+ }, [state.status, project, pipelineStatus, briefReady])
const handleApprove = useCallback(() => {
if (!project) return
@@ -224,6 +240,18 @@ export function ResearchTab({ project, pipelineStatus, locked, onApproved }: Pro
+ {project && brief && (
+
+ setState({ status: "empty" })}
+ />
+
+ )}
+
{(state.status === "loading" || isGenerating) && (
@@ -236,7 +264,9 @@ export function ResearchTab({ project, pipelineStatus, locked, onApproved }: Pro
title={project ? "No design brief yet" : "Start a project to see research"}
subtitle={
project
- ? "This project hasn't generated a design brief yet."
+ ? briefReady
+ ? "This project hasn't generated a design brief yet."
+ : "Research starts once you approve your brief above."
: "Design and copy rationale will appear here once you generate a page."
}
/>
diff --git a/apps/web/src/components/workspace/WorkspaceShell.tsx b/apps/web/src/components/workspace/WorkspaceShell.tsx
index f6e2800..190addd 100644
--- a/apps/web/src/components/workspace/WorkspaceShell.tsx
+++ b/apps/web/src/components/workspace/WorkspaceShell.tsx
@@ -483,6 +483,7 @@ export function WorkspaceShell({ project }: Props) {
invalidatePipeline();
setViewingStep(null);
}}
+ onRegenerate={() => createVersionMutation.mutate()}
/>
)}
diff --git a/apps/web/src/lib/api/axios.ts b/apps/web/src/lib/api/axios.ts
index 1040954..c9195a4 100644
--- a/apps/web/src/lib/api/axios.ts
+++ b/apps/web/src/lib/api/axios.ts
@@ -45,8 +45,19 @@ api.interceptors.response.use(
error?.message ??
"An unexpected error occurred";
- const rejected = new Error(message) as Error & { code?: string };
+ const rejected = new Error(message) as Error & {
+ code?: string;
+ status?: number;
+ errors?: Record
;
+ data?: unknown;
+ retryAfter?: number;
+ };
rejected.code = error?.response?.data?.code;
+ rejected.status = status;
+ rejected.errors = error?.response?.data?.errors;
+ rejected.data = error?.response?.data?.data;
+ const retryAfter = Number(error?.response?.headers?.["retry-after"]);
+ if (Number.isFinite(retryAfter)) rejected.retryAfter = retryAfter;
return Promise.reject(rejected);
}
);
diff --git a/apps/web/src/lib/api/services/briefs.service.ts b/apps/web/src/lib/api/services/briefs.service.ts
new file mode 100644
index 0000000..2f56297
--- /dev/null
+++ b/apps/web/src/lib/api/services/briefs.service.ts
@@ -0,0 +1,101 @@
+import { api } from "../axios"
+import type {
+ ApproveBriefInput,
+ BriefDraftSummary,
+ BriefUploadField,
+ BriefView,
+ CreateBriefInput,
+ PatchBriefInput,
+ PrefillBriefInput,
+} from "@repo/schemas"
+import type { CreateProjectResponse } from "./projects.service"
+
+export type BriefApiError = Error & {
+ code?: string
+ status?: number
+ errors?: Record
+ data?: unknown
+ retryAfter?: number
+}
+
+type Envelope = { success: true; data: T }
+
+async function upload(path: string, file: File, field: BriefUploadField): Promise {
+ const { data } = await api.post>(path, file, {
+ params: { field, name: file.name },
+ headers: { "Content-Type": file.type || "application/octet-stream" },
+ transformRequest: [(body: unknown) => body],
+ })
+ return data.data.brief
+}
+
+export const briefsApi = {
+ create: async (input: CreateBriefInput): Promise<{ brief: BriefView; prefillSuggested: boolean }> => {
+ const { data } = await api.post>("/briefs", input)
+ return data.data
+ },
+
+ listOpen: async (): Promise => {
+ const { data } = await api.get>("/briefs", { params: { open: "true" } })
+ return data.data.drafts
+ },
+
+ get: async (id: string): Promise => {
+ const { data } = await api.get>(`/briefs/${id}`)
+ return data.data.brief
+ },
+
+ patch: async (id: string, input: PatchBriefInput): Promise => {
+ const { data } = await api.patch>(`/briefs/${id}`, input)
+ return data.data.brief
+ },
+
+ remove: async (id: string): Promise => {
+ await api.delete(`/briefs/${id}`)
+ },
+
+ prefill: async (id: string, input: PrefillBriefInput, signal: AbortSignal): Promise<{ brief: BriefView; filled: string[] }> => {
+ const { data } = await api.post>(`/briefs/${id}/prefill`, input, {
+ signal,
+ timeout: 40_000,
+ })
+ return data.data
+ },
+
+ upload: (id: string, file: File, field: BriefUploadField): Promise => upload(`/briefs/${id}/uploads`, file, field),
+
+ createProject: async (briefId: string): Promise => {
+ const { data } = await api.post>("/projects", { briefId })
+ return data.data
+ },
+
+ getForProject: async (slug: string): Promise => {
+ const { data } = await api.get>(`/projects/${slug}/brief`)
+ return data.data.brief
+ },
+
+ patchForProject: async (slug: string, input: PatchBriefInput): Promise<{ brief: BriefView; regenerateNeeded: boolean }> => {
+ const { data } = await api.patch>(`/projects/${slug}/brief`, input)
+ return data.data
+ },
+
+ approveForProject: async (slug: string, input: ApproveBriefInput): Promise => {
+ const { data } = await api.post>(`/projects/${slug}/brief/approve`, input)
+ return data.data.brief
+ },
+
+ uploadForProject: (slug: string, file: File, field: BriefUploadField): Promise =>
+ upload(`/projects/${slug}/brief/uploads`, file, field),
+
+ eligibility: async (): Promise<{ eligible: boolean }> => {
+ const { data } = await api.get>("/generate/eligibility")
+ return data.data
+ },
+}
+
+const API_ORIGIN = process.env.NEXT_PUBLIC_API_SERVICE_URL ?? "http://localhost:4000"
+
+// Upload URLs come back as signed API paths.
+export function briefAssetUrl(path: string): string {
+ return `${API_ORIGIN}${path}`
+}
diff --git a/apps/web/src/lib/api/services/projects.service.ts b/apps/web/src/lib/api/services/projects.service.ts
index 9db8a31..1605d03 100644
--- a/apps/web/src/lib/api/services/projects.service.ts
+++ b/apps/web/src/lib/api/services/projects.service.ts
@@ -38,6 +38,7 @@ export type ProjectDetail = ProjectListItem & {
label: string | null
siteType: string
snapshot: Record
+ briefRevisionId?: string | null
createdAt: string
}[]
competitorScans: {
diff --git a/apps/web/src/store/projectModalStore.ts b/apps/web/src/store/projectModalStore.ts
index e0e3295..a7d2ae8 100644
--- a/apps/web/src/store/projectModalStore.ts
+++ b/apps/web/src/store/projectModalStore.ts
@@ -1,13 +1,26 @@
import { create } from "zustand";
+import type { StepId } from "@repo/schemas";
+
+// One modal (BriefModal) serves every way into a project: the Create
+// Project button, the home prompt box, and "Edit brief" on a project.
+export type BriefModalRequest =
+ | { mode: "create"; ideaText?: string }
+ | { mode: "edit"; projectSlug: string; step?: StepId };
interface ProjectModalState {
isOpen: boolean;
- open: () => void;
+ request: BriefModalRequest;
+ open: (request?: BriefModalRequest) => void;
close: () => void;
}
+function isRequest(value: unknown): value is BriefModalRequest {
+ return !!value && typeof value === "object" && "mode" in value;
+}
+
export const useProjectModalStore = create((set) => ({
isOpen: false,
- open: () => set({ isOpen: true }),
+ request: { mode: "create" },
+ open: (request) => set({ isOpen: true, request: isRequest(request) ? request : { mode: "create" } }),
close: () => set({ isOpen: false }),
}));
diff --git a/docs/intake-brief-pr.md b/docs/intake-brief-pr.md
new file mode 100644
index 0000000..e33d8cf
--- /dev/null
+++ b/docs/intake-brief-pr.md
@@ -0,0 +1,64 @@
+# Intake modal + Project Brief
+
+One shared `BriefModal` (Create Project, home prompt box, Edit brief) writes a server-side **Project Brief**: every answer plus its source (`user`, `imported`, `researched`, `assumed`, `default`). A resolver fills gaps honestly, the Research tab shows and approves the brief, and the research/generation pipeline reads it only through `toPipelineInput`.
+
+## §3 findings (inspected before building)
+
+| # | Area | What exists | Effect on the plan |
+|---|---|---|---|
+| 1 | Create flow | `CreateProjectModal` (2-step name/site type → `ProjectForm`), `MinimalCreateProjectModal` (home "guided setup", `/extract` → `/plan` SSE). `POST /api/projects` takes the legacy field body; its only other callers were `ChatHomeView`'s clarify chat. No component named `EntryPathSelector`; the "How would you like to start?" step played that role. | Both modals, `ProjectForm` and `ExtractFlowPanel` are replaced by `BriefModal`; the legacy body still works and gets a minimal brief. |
+| 2 | Home idea box | Logged out: `WorkspacePreview` stores the idea in `pendingPromptStore` (localStorage) and redirects to `/signin` (a redirect, not a popup). Logged in: `ChatHomeView` runs `/clarify`. `/extract` **requires login** (`verifyToken`). | Idea → `BriefModal` with `ideaText`; no model call before sign-in. `/extract`, `/extract/answer`, `extractStore`, `extract.service`, `usePlanStream` and `planStore` are left in place and now unused; a follow-up can delete them. **Existing bug:** `/extract` trusts `tier` from the request body, so any caller can request the paid model. |
+| 3 | Project columns | `startupIdea`, `niche`, `targetAudience` feed `PlanService.generate` → orchestrator `/plan/sync` and the website `/generate` request. `inputType`/`sourceUrl` trigger a scan in `createProject`. `docsKey` and `logoUrl` are unused. | `syncProjectFromBrief` keeps them derived. `logoUrl` stays unset: there is no public asset storage. |
+| 4 | "Phase 1" | `ResearchIntake` table exists but nothing writes it. `retrieveForProject` exists (rag/research-service) but no route calls it. No Call 1/Call 2, closed section registry or niche classifier as described. RESEARCH starts when `ResearchTab` sees `researchStatus = PENDING` and calls `POST /research/generate` → `/plan/sync` (planner = layout decision). Website = `/generate` → structure/copy/design/critique agents. | The brief hooks into those real stages (see "How the pipeline reads the brief"). |
+| 5 | Uploads | **None.** No signed URLs, GCS or S3 in the API; R2 only inside deploy-service for site artifacts. | Uploads are stored in a new `brief_uploads` table (bytes, private) and logos are served through HMAC-signed 10-minute URLs. One direct upload request replaces "start + complete". |
+| 6 | Rate limiter | Redis counters: `dailyRateLimit`, `hourlyRateLimit` (per user), `dailyRateLimitByIp`. | Added `minuteRateLimit` and a `Retry-After` header on every 429. |
+| 7 | URL capture | Playwright scans (`worker/scan.processor.ts`) have **no SSRF protection**: no private/metadata IP blocking and no redirect re-checks. | New `safeFetch` for pre-fill only (https, DNS-pinned connection, blocks private/link-local/metadata/ULA/multicast v4+v6, re-checks each of ≤3 redirects, 443 only, 2 MB, 15 s, no cookies). **The existing scan path still needs the same fix.** |
+| 8 | Model routing | `getModelForTier` (free → DeepSeek flash with high reasoning, paid → Claude). No cheap-call helper. | Added `getCheapModel()` (DeepSeek flash without reasoning, Haiku fallback). DeepSeek doesn't reliably honour the AI SDK's JSON-object mode, so the brief agents use `generateText` plus JSON extraction, like the other agents. |
+| 9 | site-builder | No section registry: sections are the `SectionSchema` enum. **Text was interpolated raw into generated JSX** (`${headline} `), so any `{...}` or markup in copy became code. No form handler. English only. | All text is emitted as JS string literals, hrefs go through `safeHref`, ``/CSS inputs are sanitized. `BUILDER_CAPABILITIES = { formHandler: false, legalPages: false, languages: ["en"] }`. |
+| 10 | Web | Radix/shadcn Dialog, Input, Textarea, Switch, Tabs; react-hook-form present but forms are hand-rolled; TanStack Query; `useSSE`. `sonner` Toaster existed but wasn't mounted. | Reused all of these; mounted ` ` in the authenticated layout. |
+| 11 | Gate | `GenerateService.authorize()` runs inside `POST /api/projects` and **consumes the free generation or deducts credits at project creation**. Research approval deducts `RESEARCH_CREDIT_COST`. | Unchanged. See conflict A. |
+| 12 | Service calls | API → orchestrator with the user's access token (`Authorization: Bearer`, re-signed by the API); research-service uses `x-internal-secret`. | The brief resolve/pre-fill calls follow the orchestrator pattern. |
+
+## Conflicts flagged (existing behaviour kept)
+
+- **A. Gate at approve.** The gate already charges at project creation. Running it again at approve would double-charge, so approve does not call it. It flips the brief to `APPROVED`, and research then starts through the existing entry point (`ResearchTab` → `/research/generate`). `PlanService.generate` refuses with `409 BRIEF_NOT_APPROVED` until then. If the gate refuses at create, the draft stays a `DRAFT`.
+- **B. Proof attestation on drafts.** Permission/attestation are enforced on submit and on every write to an attached brief, but not during draft autosave. Otherwise a half-entered testimonial couldn't be saved.
+- **C. Routes use `:slug`.** `/api/projects/:slug/brief…` instead of `:id`, matching every other project route.
+- **D. Stale detection.** `ProjectVersion.briefRevisionId` is stamped with the latest revision (`APPROVED` or `EDITED`) when website generation starts, so regenerating after an edit clears the banner.
+- **E. Competitor scanning / researched write-back.** Only the SSE `/plan` route scans competitors and produces `seoKeywords`, and it was used only by the removed `MinimalCreateProjectModal`. User competitors are scanned first there and results are written back to blank `competitors`/`targetKeywords`. The active `/plan/sync` path has no scanning, so no write-back happens in today's UI. Adding scans there would change research cost and wasn't done.
+- **F. Legacy create body.** It builds an `APPROVED` brief marked `resolution.origin = "legacy"`. The pipeline keeps reading those projects' own columns until the brief is edited.
+- **G. Pre-fill never imports `trustedBy`.** A company named on a page isn't evidence it is a customer. Testimonials need the quote and name verbatim in the source, metrics need value and label verbatim, and plans need name and price verbatim. All imported proof needs the user's tick.
+- **H. `CONTACT` sections** require public contact details; `TEAM` is always excluded.
+
+## How the pipeline reads the brief
+
+`loadBriefContext` (orchestrator) loads an `APPROVED` brief owned by the caller and calls `toPipelineInput`.
+
+- **Planner (`/plan/sync`, `/plan`):** brief facts plus "only facts in the brief" rule, plus a section menu filtered by `SECTION_REQUIRES_BRIEF`; `layout.sections` is filtered again deterministically.
+- **Structure:** same menu and filter; honours `siteType`.
+- **Copy:** facts plus rule. `TESTIMONIALS`/`PRICING` content is built from the user's data verbatim, with no model call.
+- **After critique:** `applyBriefToSpec` sets the user's CTA label/href (through `safeHref`), mailto for public contact, and strips sentences naming competitors unless comparison is allowed.
+
+Sections excluded when unmet: `TESTIMONIALS` (no attested, permitted quotes), `PRICING` (no `SHOW_PLANS` plans), `CONTACT` (no public contact), `TEAM` (no data source yet). Projects without a brief generate exactly as before, including the copy agent writing testimonials.
+
+## Data model (migration `20261008000000_project_brief`, additive only)
+
+`ProjectBrief`, `BriefRevision`, `BriefUpload` (new: upload bytes, nullable `expiresAt`), `ProjectVersion.briefRevisionId` (FK, `SET NULL`), enums `BriefStatus`/`BriefMode`. `ProjectBrief.resolution` (new JSON) holds the resolver notice and dropped fields. `ResearchIntake` is untouched.
+
+The migration was applied only to a throwaway local pgvector container. **It has not been applied to the Neon database the services use.** That database is also missing `20261005000000_custom_domains`. `prisma migrate diff` also shows pre-existing drift that this migration deliberately excludes (HNSW/search-vector indexes, `users_phone_key`, `users_status_idx`, a renamed index).
+
+## Not built / gaps
+
+- **Product analytics events:** no analytics mechanism exists in the app.
+- **`/internal/briefs/stats`:** no admin surface exists.
+- **Langfuse:** spans come from AI SDK telemetry (`brief.prefill` records no inputs/outputs; `brief.resolve` sees only the LLM-safe view). Langfuse itself isn't wired yet.
+- **Web e2e (Playwright + axe):** no e2e setup exists in `apps/web`. Accessibility is built in (Radix focus trap/return, labelled fields, `aria-describedby` help/errors, live region, radio/checkbox chips, Move up/Move down, mobile full-screen sheet) but is not automatically audited.
+- **URL pre-fill fetches raw HTML** (no JS rendering), so single-page-app sites yield little text.
+- **ESLint can't run:** web's config imports a missing `@repo/eslint-config`, and the server has no flat config for ESLint 9.
+
+## Tests
+
+- `packages/schemas` (23): catalog integrity, validators/schemes, provenance and merge policy, proof-source enforcement, claim filter, LLM-safe view, `toPipelineInput` keys, section requirements.
+- `apps/orchestrator-service` (23): SSRF fixtures (loopback, metadata, private-resolving host, redirect to private IP, `file://`, port 22, IPv6/mapped), prompt-injection fixture, document extraction including a zip bomb, resolver (assume-only, never proof, retry then drop, failure → review, nothing assumed → approved, IDOR), generation pass.
+- `apps/server`: upload sniffing and signed URLs (always run), plus 13 integration tests against a real local DB, run with `BRIEF_IT_DATABASE_URL=` (create/patch/409, field errors, IDOR 404s, pre-fill merge, logo re-encode/EXIF strip/signed URL, disguised files, submit 422, resolve → gate → approve, edit revision and proof strictness, gate refusal keeps draft, 5-draft cap, legacy body, URL rate limit with Retry-After).
+- `apps/web`: generated site rendered to HTML with React: hostile text in every field produces no executable markup and no `javascript:` hrefs. This fails against the pre-change builder.