feat(intake): interview action and client-facing wizard

This commit is contained in:
Dave 2026-09-16 17:56:06 +02:00
parent a74f3e79b5
commit f05a2a2ed2
3 changed files with 180 additions and 0 deletions

39
src/app/actions/intake.ts Normal file
View file

@ -0,0 +1,39 @@
"use server";
import { requireSession } from "@/lib/auth/session";
import { getAdminPb } from "@/lib/pb/admin";
import { canReadClient } from "@/lib/auth/access";
import { nextStep, type InterviewStep } from "@/lib/intake/interview";
import type { Turn } from "@/lib/intake/turn";
import type { ProjectRecord } from "@/lib/pb/types";
/**
* Advance the interview.
*
* Available to a client manager for their own client's projects — this is a
* client-facing intake. The owner creates cards directly and has no use for it.
*
* A Server Action is a public endpoint: the project check below is what stops
* one client interviewing against another's project and learning its name and
* its areas of work.
*/
export async function intakeStepAction(input: {
projectId: string;
turns: Turn[];
finishNow: boolean;
}): Promise<InterviewStep> {
const session = await requireSession();
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(input.projectId)
.catch(() => null);
// Same answer for "does not exist" and "not yours".
if (!project || !canReadClient(session, project.client)) {
return { kind: "unavailable", message: "Project not found." };
}
return nextStep(input);
}

View file

@ -0,0 +1,20 @@
import type { Brief } from "@/lib/intake/brief";
import type { SizeBand } from "@/lib/intake/size";
import type { Turn } from "@/lib/intake/turn";
/**
* Stub. Replaced by Phase 4b when the brief-preview UI lands.
*
* The wizard imports this so Task 6 can build standalone; Plan 4b replaces
* this file with the real preview that submits the brief to Plan 4b's
* server action.
*/
export function BriefPreview(_: {
brief: Brief & { sizeBand: SizeBand };
turns: Turn[];
projectId: string;
clientSlug: string;
projectSlug: string;
}): null {
return null;
}

View file

@ -0,0 +1,121 @@
"use client";
import { useState, useTransition } from "react";
import { Sparkles, ArrowRight } from "lucide-react";
import { intakeStepAction } from "@/app/actions/intake";
import { BriefPreview } from "@/components/intake/brief-preview";
import { MAX_QUESTIONS, type Turn } from "@/lib/intake/turn";
import type { InterviewStep } from "@/lib/intake/interview";
export function IntakeWizard({
projectId,
clientSlug,
projectSlug,
onFallback,
}: {
projectId: string;
clientSlug: string;
projectSlug: string;
/** Shown when the assistant is unavailable — the plain request box. */
onFallback: () => void;
}) {
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();
function advance(nextTurns: Turn[], finishNow: boolean) {
startTransition(async () => {
const result = await intakeStepAction({ projectId, turns: nextTurns, finishNow });
setStep(result);
if (result.kind === "question") {
setQuestion(result.question ?? null);
setAnswer("");
}
if (result.kind === "unavailable") onFallback();
});
}
if (step?.kind === "brief" && step.brief) {
return (
<BriefPreview
brief={step.brief}
turns={turns}
projectId={projectId}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
);
}
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…" : "Request a feature"}
</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-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
<ArrowRight size={14} strokeWidth={2} />
{pending ? "…" : "Next"}
</button>
{/* Always available. Someone who said what they need in the first
answer should not be interrogated through all seven. */}
<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>
);
}