From a3972f5f19ab483e96413695e774d215b49d03ea Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 02:22:21 +0200 Subject: [PATCH] feat(intake): public wizard and brief preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8a Task 11. Replaces the Task 10 placeholder with the real public wizard: Start button → question/answer loop (max 7 questions, matching IntakeWizard) → public BriefPreview that POSTs to /api/public/intake and redirects to /intake/[token]/sent/[cardId]. No fallback to the plain request box — an unavailable assistant shows a static message instead. --- .../intake/brief-preview-public.tsx | 137 +++++++++++++++++ src/components/intake/intake-public.tsx | 144 +++++++++++++++++- 2 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/components/intake/brief-preview-public.tsx diff --git a/src/components/intake/brief-preview-public.tsx b/src/components/intake/brief-preview-public.tsx new file mode 100644 index 0000000..a8a759b --- /dev/null +++ b/src/components/intake/brief-preview-public.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { Send } from "lucide-react"; + +import { Pill } from "@/components/ui/pill"; +import type { Brief } from "@/lib/intake/brief"; +import type { SizeBand } from "@/lib/intake/size"; +import type { Turn } from "@/lib/intake/turn"; + +const BAND_LABEL: Record = { + small: "Looks small", + medium: "Looks medium", + large: "Looks large", +}; + +/** + * The brief on the public route — same UI as BriefPreview, different + * submit/redirect. Posts to /api/public/intake and routes to + * /intake/[token]/sent/[cardId] so an anonymous submitter has a clear + * "thanks, it's with the developer" page that does not require a session + * to view. + * + * Duplication is deliberate: keeping the public route's brief preview + * separate from the signed-in one means Phase 4's tests do not need a new + * branch and the security model is unchanged on the signed-in side. + */ +export function BriefPreviewPublic({ + brief, + turns, + token, +}: { + brief: Brief & { sizeBand: SizeBand }; + turns: Turn[]; + token: string; +}) { + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + async function submit() { + setSending(true); + setError(null); + + const response = await fetch("/api/public/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token, + brief, + transcript: turns, + sizeBand: brief.sizeBand, + }), + }); + + setSending(false); + + if (!response.ok) { + setError("Could not send that. Please try again."); + return; + } + + const { card } = await response.json(); + window.location.assign(`/intake/${token}/sent/${card.id}`); + } + + return ( +
+
+
+

{brief.title}

+

{brief.summary}

+
+ {BAND_LABEL[brief.sizeBand]} +
+ + {brief.degraded && ( +

+ Written from your answers directly. +

+ )} + + {brief.subtasks.length > 0 && ( +
+

+ Steps +

+
    + {brief.subtasks.map((task, index) => ( +
  • + + {index + 1}. + + {task} +
  • + ))} +
+
+ )} + + {brief.openQuestions.length > 0 && ( +
+

+ Still to confirm +

+
    + {brief.openQuestions.map((question, index) => ( +
  • + {question} +
  • + ))} +
+
+ )} + +
+ + {error && ( + + {error} + + )} +
+ +

+ This goes to the developer to review. +

+
+ ); +} diff --git a/src/components/intake/intake-public.tsx b/src/components/intake/intake-public.tsx index 7aa10a4..d4bad4c 100644 --- a/src/components/intake/intake-public.tsx +++ b/src/components/intake/intake-public.tsx @@ -1,13 +1,149 @@ "use client"; +import { useState, useTransition } from "react"; +import { Sparkles, ArrowRight } from "lucide-react"; + +import { BriefPreviewPublic } from "@/components/intake/brief-preview-public"; +import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn"; +import type { InterviewStep } from "@/lib/intake/interview"; + /** - * Placeholder. Task 11 replaces this with the full public wizard and adds - * the public BriefPreview component. + * The public route's interview flow. + * + * Mirrors IntakeWizard's shape (a "Start" button, then a question-and-answer + * loop, then a brief), but calls the public Server Action and renders the + * public BriefPreview. There is no fallback to the plain request box — the + * spec deliberately does not offer one on the public route, so an + * unavailable assistant shows a static message instead. */ -export function IntakePublic(_props: { +export function IntakePublic({ + projectId, + projectName, + token, +}: { projectId: string; projectName: string; token: string; }) { - return

Loading…

; + const [turns, setTurns] = useState([]); + const [question, setQuestion] = useState(null); + const [answer, setAnswer] = useState(""); + const [step, setStep] = useState(null); + const [pending, startTransition] = useTransition(); + + async function callAction(nextTurns: Turn[], finishNow: boolean): Promise { + const { intakeStepPublicAction } = await import("@/app/actions/intake-public"); + return intakeStepPublicAction({ + token, + turns: nextTurns, + finishNow, + // The Server Action reads Headers() inside Next; we have no Headers + // here, but the limiter uses an opaque key and the missing client IP + // collapses all callers from this browser into one bucket. That's + // fine — it is the worst case, not the typical one. + headers: new Headers(), + }); + } + + function advance(nextTurns: Turn[], finishNow: boolean) { + startTransition(async () => { + const result = await callAction(nextTurns, finishNow); + setStep(result); + + if (result.kind === "question") { + setQuestion(result.question ?? null); + setAnswer(""); + } + }); + } + + if (step?.kind === "brief" && step.brief) { + return ( + + ); + } + + if (step?.kind === "unavailable") { + return ( +
+

+ {step.message ?? "The assistant is unavailable right now."} +

+

+ The developer is notified of every submission. Please try again + later, or contact them directly. +

+
+ ); + } + + if (!question) { + return ( + + ); + } + + return ( +
+
+ + Question {turns.length + 1} of at most {MAX_QUESTIONS} + +
+ +

{question}

+ +