diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index dd822043e..855b7c650 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -129,11 +129,15 @@ describe("S1 — the direct session-resolution set is frozen", () => { "RSC prefetch of the session user's mood list, skipped under a switch", "app/page.tsx": "RSC prefetch of the session user's dashboard snapshot, skipped under a switch", - // Onboarding routing. Reads the actor's own onboarding step to redirect; - // renders no record data. + // Onboarding routing. Reads the actor's OWN setup state through + // `loadOnboardingFlowState` (a `getSession` wrapper, listed in the regex + // below) to guard the step URL and to render the screens; the three + // writes behind them refuse under a switch, so the pages read the same + // record the routes write. "app/onboarding/[step]/page.tsx": - "redirects on the actor's onboarding step", - "app/onboarding/page.tsx": "redirects on the actor's onboarding step", + "guards the step URL on the actor's own setup state", + "app/onboarding/page.tsx": + "resumes or welcomes on the actor's own setup state", }; /** @@ -147,10 +151,12 @@ describe("S1 — the direct session-resolution set is frozen", () => { * `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the * reason a wrapper is the easiest way to disappear from a guard: it returns * `getSession()`'s answer or null, so a caller of it derives an identity - * exactly as much as a caller of the thing it wraps. + * exactly as much as a caller of the thing it wraps. v1.39 (C2) — + * `loadOnboardingFlowState` (`lib/onboarding/load-flow-state.ts`) is the + * same shape: `getSession()` plus the caller's own setup row. */ const SESSION_DERIVING_HELPERS = - /\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/; + /\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/; /** * Reaching for the cookie by name is the way around the helpers. Both the diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 6734638d4..5f3338f96 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -133,6 +133,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { className="bg-card border-border grid grid-cols-1 gap-x-3 gap-y-2 rounded-xl border p-4 text-sm sm:grid-cols-[auto_1fr] md:p-6" data-slot="onboarding-confirm-preferences" > + {/* Grouped dt/dd pairs only: a stray child breaks the list's semantics. */} {glucoseUnit ? ( <>
@@ -157,15 +158,15 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
{user.timezone}
) : null} -
- - {t("onboarding.flow.confirm.changeInSettings")} - -
+

+ + {t("onboarding.flow.confirm.changeInSettings")} + +

{forSomeoneElse ? (
diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index c4a2c43e2..14fe62f84 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -18,6 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card"; +import { StepHeading } from "@/components/onboarding/step-heading"; import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow"; import { useAuth } from "@/hooks/use-auth"; import { useAccountSwitch } from "@/hooks/use-account-switch"; @@ -125,22 +126,16 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { -
- {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */} -

+ - {t("onboarding.done.title")} -

-

- {t("onboarding.done.body")} -

-

+ title={t("onboarding.done.title")} + description={t("onboarding.done.body")} + /> +

{t("onboarding.done.learning")}

-
+
{/* v1.28 — the flagship AI value, made reachable at the one screen every fresh user passes through. The daily briefing / Coach need diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index f818654a3..9e4a22ed7 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -5,11 +5,12 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; -import { CheckCircle2, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { StepActions } from "@/components/onboarding/step-actions"; import { StepHeading } from "@/components/onboarding/step-heading"; import { @@ -74,9 +75,6 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { state.firstResult?.task === offer?.task && state.firstResult?.completedAt !== null, ); - const [resultTarget, setResultTarget] = useState( - state.firstResult?.target ?? null, - ); async function complete(target: string | null) { if (!offer) return; @@ -85,7 +83,6 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { step: "first-result", firstResult: { task: offer.task, target, completed: true }, }); - setResultTarget(target); setCompleted(true); } catch (err) { toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); @@ -123,12 +120,7 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { description={t(`onboarding.flow.first-result.${offer.task}.body`)} /> - + Promise; }) { switch (offer.task) { @@ -173,11 +163,7 @@ function TaskBody({ ) : null; case "add-medication": return ( - + ); case "log-reading": return offer.target ? ( @@ -267,16 +253,18 @@ interface MedicationDetail { function AddMedicationTask({ completed, - medicationId, onComplete, }: { completed: boolean; - medicationId: string | null; onComplete: (target: string | null) => Promise; }) { const { t } = useTranslations(); const fmt = useFormatters(); const [open, setOpen] = useState(false); + // The id stays on this screen: the ledger's `target` is a source or area + // key and is null for the medication task (see `OnboardingFirstResult`), + // so a revisit after completion shows the unnamed result. + const [medicationId, setMedicationId] = useState(null); const detail = useQuery({ queryKey: queryKeys.medicationDetail(medicationId ?? ""), @@ -328,7 +316,8 @@ function AddMedicationTask({ navigateOnCreate={false} onSuccess={(id) => { setOpen(false); - void onComplete(id); + setMedicationId(id); + void onComplete(null); }} /> @@ -507,23 +496,24 @@ function LatestReadingTile({ /* ── the result ────────────────────────────────────────────────────────── */ +/** + * What the task produced. The written-outcome line is the one success + * affordance the codebase renders (`src/components/outcome/`); the value + * itself sits under it in content colour, because it is the reading, the + * medication or the connection the person came for. + */ function ResultTile({ title, detail }: { title: string; detail?: string }) { return (
- -
-

{title}

- {detail ?

{detail}

: null} -
+ + {detail ?

{detail}

: null}
); } diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index 336794c2c..a151437ee 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -25,7 +25,10 @@ import { } from "@/components/onboarding/use-onboarding-flow"; import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; -import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + hasEnteredOnboardingFlow, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; import { QUESTIONS, questionAnswerBody, @@ -95,7 +98,12 @@ export function QuestionScreen({ }; const body = questionAnswerBody(step, selected); - const back = previousScreen(state, step); + // 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) { try { diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 1979e2582..47d19d28d 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -4,6 +4,8 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { ListChecks, Loader2 } from "lucide-react"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; + import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -40,12 +42,13 @@ export function SetupCard() { } function showChecklist() { - const hadState = resetChecklistDismissals(); - toast.success( - hadState - ? t("settings.setup.checklistShown") - : t("settings.setup.checklistNothingHidden"), - ); + // Something was hidden and is back: a written outcome. Nothing was + // hidden: an empty one — there was nothing to bring back. + if (resetChecklistDismissals()) { + toastWrittenOutcome("success", t("settings.setup.checklistShown")); + } else { + toastWrittenOutcome("empty", t("settings.setup.checklistNothingHidden")); + } } return ( diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts index d53b2210c..ee4ae3f09 100644 --- a/src/lib/onboarding/wizard-steps.ts +++ b/src/lib/onboarding/wizard-steps.ts @@ -14,7 +14,6 @@ import type { OnboardingSourceKey } from "./needs"; import { hasEnteredOnboardingFlow, - ONBOARDING_STEP_IDS, type OnboardingFirstResultTask, type OnboardingNeeds, type OnboardingStateDto, @@ -23,17 +22,6 @@ import { export type OnboardingScreenId = "welcome" | OnboardingStepId; -export const ONBOARDING_SCREEN_IDS: readonly OnboardingScreenId[] = [ - "welcome", - ...ONBOARDING_STEP_IDS, -]; - -export function isOnboardingScreenId( - value: string, -): value is OnboardingScreenId { - return (ONBOARDING_SCREEN_IDS as readonly string[]).includes(value); -} - /** * The Q4 sources whose connection the browser can finish on its own — an * OAuth handshake or a URL-plus-secret form on Settings → Integrations. diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 5f8c6037b..7a9b7d909 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -234,15 +234,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "nutrient.water.invalid_day", ], onboarding: [ + "onboarding.answers.invalid", + "onboarding.answers.rateLimited", "onboarding.complete.invalid", "onboarding.disclaimer.invalid", - "onboarding.step.completed", - "onboarding.step.concurrent", - "onboarding.step.invalid", - "onboarding.step.outOfOrder", - "onboarding.step.rateLimited", + "onboarding.restart.invalid", + "onboarding.restart.rateLimited", "onboarding.tour.invalid", - "onboarding.user.notFound", ], personal_records: ["personal_records.invalid_query"], practitioner: ["practitioner.invalid"], diff --git a/src/lib/query-keys/onboarding.ts b/src/lib/query-keys/onboarding.ts index 27579b56f..9dedec5e8 100644 --- a/src/lib/query-keys/onboarding.ts +++ b/src/lib/query-keys/onboarding.ts @@ -18,12 +18,12 @@ export const onboardingKeys = { /** `POST /api/onboarding/disclaimer` — the one-time acknowledgment. */ onboardingDisclaimerMutation: () => ["onboarding", "disclaimer"] as const, /** - * The latest reading of one type, read back after the first-result step + * The latest reading of one area, read back after the first-result step * logged it so the screen can show the value on its tile. Rides under the * `["measurements"]` prefix so `measurementDependentKeys` evicts it. */ - onboardingLatestReading: (type: string) => - ["measurements", "onboarding-latest", type] as const, + onboardingLatestReading: (area: string) => + ["measurements", "onboarding-latest", area] as const, /** * The upcoming-visit count the checklist's "prepare the visit" row reads * off the visits list's meta. Its own key under the `["encounters"]`