diff --git a/src/app/actions/intake.ts b/src/app/actions/intake.ts new file mode 100644 index 0000000..f1e25f3 --- /dev/null +++ b/src/app/actions/intake.ts @@ -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 { + const session = await requireSession(); + + const pb = await getAdminPb(); + const project = await pb + .collection("projects") + .getOne(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); +} diff --git a/src/components/intake/brief-preview.tsx b/src/components/intake/brief-preview.tsx new file mode 100644 index 0000000..500079f --- /dev/null +++ b/src/components/intake/brief-preview.tsx @@ -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; +} diff --git a/src/components/intake/intake-wizard.tsx b/src/components/intake/intake-wizard.tsx new file mode 100644 index 0000000..a371376 --- /dev/null +++ b/src/components/intake/intake-wizard.tsx @@ -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([]); + const [question, setQuestion] = useState(null); + const [answer, setAnswer] = useState(""); + const [step, setStep] = useState(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 ( + + ); + } + + if (!question) { + return ( + + ); + } + + return ( +
+
+ + Question {turns.length + 1} of at most {MAX_QUESTIONS} + +
+ +

{question}

+ +