121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
"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>
|
|
);
|
|
}
|