feat(intake): interview action and client-facing wizard
This commit is contained in:
parent
a74f3e79b5
commit
f05a2a2ed2
3 changed files with 180 additions and 0 deletions
39
src/app/actions/intake.ts
Normal file
39
src/app/actions/intake.ts
Normal 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);
|
||||
}
|
||||
20
src/components/intake/brief-preview.tsx
Normal file
20
src/components/intake/brief-preview.tsx
Normal 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;
|
||||
}
|
||||
121
src/components/intake/intake-wizard.tsx
Normal file
121
src/components/intake/intake-wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue