Files
HealthLog/src/components/onboarding/question-screen.tsx
T
Marc-André Bombeck 8c7cdcfc2b Tighten the setup flow after a review of the whole diff
The done screen moves focus to its heading like every other screen; the
confirm screen's preference list holds only dt/dd pairs and the settings
link stands beside it; Back from Q1 is not offered once the flow has
been entered, because the front door would resume past the welcome and
bounce straight back; the medication task records no target on the
ledger, as the contract says, and keeps the id on the screen; and the
two success affordances go through the outcome module rather than a
hand-rolled tick. The error-code catalogue lists the answers and restart
codes and no longer the retired step ones, and the session-surface guard
learns the one new deriving helper the onboarding pages read through.
2026-09-10 16:09:18 +02:00

175 lines
5.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Activity,
Brain,
CalendarHeart,
Droplets,
FlaskConical,
HeartPulse,
Moon,
Scale,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { ChoiceChip } from "@/components/onboarding/choice-chip";
import { StepActions } from "@/components/onboarding/step-actions";
import { StepHeading } from "@/components/onboarding/step-heading";
import {
screenHref,
useOnboardingAnswer,
} from "@/components/onboarding/use-onboarding-flow";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
hasEnteredOnboardingFlow,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import {
QUESTIONS,
questionAnswerBody,
questionOptionLabelKey,
questionPrefill,
questionSkipBody,
type QuestionStepId,
} from "@/lib/onboarding/question-config";
import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps";
/**
* v1.39 (C2) — one question per screen, Q1 to Q5.
*
* The chip set is the whole screen (design spec §Principles 3: one screen,
* one decision). The selection lives in component state while the screen is
* open, pre-filled from the stored answers so a returning person sees what
* they said; "Next" persists it through the answers route and navigates from
* the state the server hands back, so "where next" is the step machine's
* reading of the ledger the server wrote, not this screen's guess.
*
* Q1 is required and has no skip; every other question may be passed, and a
* pass records the pass without touching the stored answer.
*/
const AREA_ICONS: Readonly<Record<string, LucideIcon>> = {
"blood-pressure": HeartPulse,
"weight-body": Scale,
glucose: Droplets,
sleep: Moon,
mood: Brain,
cycle: CalendarHeart,
activity: Activity,
labs: FlaskConical,
illness: Thermometer,
};
/** Answers that carry a second line under the label. */
const OPTION_HINTS: Readonly<Record<string, string>> = {
"who.someone-else": "onboarding.flow.who.hints.someone-else",
"who.both": "onboarding.flow.who.hints.both",
"sources.apple-health": "onboarding.flow.sources.hints.apple-health",
"sources.file": "onboarding.flow.sources.hints.file",
};
export function QuestionScreen({
step,
state,
}: {
step: QuestionStepId;
state: OnboardingStateDto;
}) {
const { t } = useTranslations();
const router = useRouter();
const question = QUESTIONS[step];
const answer = useOnboardingAnswer();
const [selected, setSelected] = useState<string[]>(() =>
questionPrefill(step, state.needs),
);
const choose = (value: string) => {
setSelected((prev) => {
if (question.kind === "single") return [value];
return prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value];
});
};
const body = questionAnswerBody(step, selected);
// Back from Q1 leads to the welcome screen only while the flow has not
// been entered: once Q1 is answered the front door resumes at the step
// owed, so a Back link there would bounce straight back here.
const previous = previousScreen(state, step);
const back =
previous === "welcome" && hasEnteredOnboardingFlow(state) ? null : previous;
async function persist(input: NonNullable<typeof body>) {
try {
const written = await answer.mutateAsync(input);
router.push(screenHref(nextScreen(written, step) ?? "confirm"));
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
}
}
const titleId = `onboarding-${step}-title`;
return (
<section aria-labelledby={titleId} className="space-y-6">
<StepHeading
id={titleId}
title={t(`onboarding.flow.${step}.title`)}
description={t(`onboarding.flow.${step}.body`)}
/>
<fieldset
className={
step === "areas"
? "grid grid-cols-1 gap-3 sm:grid-cols-2"
: "grid grid-cols-1 gap-3"
}
data-slot={`onboarding-question-${step}`}
>
<legend className="sr-only">
{t(`onboarding.flow.${step}.title`)}
</legend>
{question.options.map((option) => {
const hintKey = OPTION_HINTS[`${step}.${option}`];
return (
<ChoiceChip
key={option}
name={`onboarding-${step}`}
value={option}
kind={question.kind}
checked={selected.includes(option)}
onChange={choose}
label={t(questionOptionLabelKey(step, option))}
hint={hintKey ? t(hintKey) : undefined}
Icon={step === "areas" ? AREA_ICONS[option] : undefined}
/>
);
})}
</fieldset>
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={
question.skippable
? () =>
void persist(
questionSkipBody(step as Exclude<QuestionStepId, "who">),
)
: undefined
}
onNext={() => {
if (body) void persist(body);
}}
nextDisabled={body === null}
pending={answer.isPending}
/>
</section>
);
}