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:
parent
a591f352c8
commit
a3972f5f19
2 changed files with 277 additions and 4 deletions
137
src/components/intake/brief-preview-public.tsx
Normal file
137
src/components/intake/brief-preview-public.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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's enough — write it up
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue