feat(intake): public wizard and brief preview

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.
This commit is contained in:
Dave 2026-09-21 02:22:21 +02:00
parent a591f352c8
commit a3972f5f19
2 changed files with 277 additions and 4 deletions

View file

@ -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<SizeBand, string> = {
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<string | null>(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 (
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-text-primary">{brief.title}</h2>
<p className="mt-1 text-sm text-text-secondary">{brief.summary}</p>
</div>
<Pill tone="neutral">{BAND_LABEL[brief.sizeBand]}</Pill>
</div>
{brief.degraded && (
<p className="text-xs text-text-muted">
Written from your answers directly.
</p>
)}
{brief.subtasks.length > 0 && (
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Steps
</h3>
<ul className="space-y-1">
{brief.subtasks.map((task, index) => (
<li key={index} className="flex gap-2 text-sm text-text-secondary">
<span aria-hidden className="text-text-muted">
{index + 1}.
</span>
{task}
</li>
))}
</ul>
</div>
)}
{brief.openQuestions.length > 0 && (
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Still to confirm
</h3>
<ul className="space-y-1">
{brief.openQuestions.map((question, index) => (
<li key={index} className="text-sm text-text-muted">
{question}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center gap-3 border-t border-border-subtle pt-3">
<button
type="button"
disabled={sending}
onClick={submit}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
<Send size={14} strokeWidth={2} />
{sending ? "Sending…" : "Send this"}
</button>
{error && (
<span role="alert" className="text-xs text-accent">
{error}
</span>
)}
</div>
<p className="text-[11px] text-text-muted">
This goes to the developer to review.
</p>
</section>
);
}

View file

@ -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 <p className="text-sm text-text-muted">Loading…</p>;
const [turns, setTurns] = useState<Turn[]>([]);
const [question, setQuestion] = useState<string | null>(null);
const [answer, setAnswer] = useState("");
const [step, setStep] = useState<InterviewStep | null>(null);
const [pending, startTransition] = useTransition();
async function callAction(nextTurns: Turn[], finishNow: boolean): Promise<InterviewStep> {
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 (
<BriefPreviewPublic
brief={step.brief}
turns={turns}
token={token}
/>
);
}
if (step?.kind === "unavailable") {
return (
<section className="space-y-2 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<p className="text-sm text-text-secondary">
{step.message ?? "The assistant is unavailable right now."}
</p>
<p className="text-[11px] text-text-muted">
The developer is notified of every submission. Please try again
later, or contact them directly.
</p>
</section>
);
}
if (!question) {
return (
<button
type="button"
disabled={pending}
onClick={() => advance([], false)}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
<Sparkles size={14} strokeWidth={2} />
{pending ? "Starting…" : `Tell ${projectName} what's not working`}
</button>
);
}
return (
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<div className="flex items-baseline gap-2">
<span className="text-[11px] uppercase tracking-wide text-text-muted">
Question {turns.length + 1} of at most {MAX_QUESTIONS}
</span>
</div>
<p className="text-sm font-medium text-text-primary">{question}</p>
<textarea
autoFocus
rows={3}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
placeholder="In your own words"
className="w-full rounded-lg border border-border-subtle bg-panel p-3 text-sm text-text-primary outline-none focus:border-brand"
/>
<div className="flex items-center gap-3">
<button
type="button"
disabled={pending || !answer.trim()}
onClick={() => {
const next = [...turns, { question, answer: answer.trim() }];
setTurns(next);
advance(next, false);
}}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-text-fg hover:bg-brand-hover disabled:opacity-50"
>
<ArrowRight size={14} strokeWidth={2} />
{pending ? "…" : "Next"}
</button>
<button
type="button"
disabled={pending || (turns.length === 0 && !answer.trim())}
onClick={() => {
const next = answer.trim()
? [...turns, { question, answer: answer.trim() }]
: turns;
setTurns(next);
advance(next, true);
}}
className="text-xs text-text-muted hover:text-text-secondary disabled:opacity-50"
>
That&apos;s enough — write it up
</button>
</div>
</section>
);
}