diff --git a/messages/en.json b/messages/en.json index 2b0fd25c8..20c28a1a5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4490,7 +4490,9 @@ "step1of4": "Step 1 of 4", "step2of4": "Step 2 of 4", "step3of4": "Step 3 of 4", - "step4of4": "Step 4 of 4" + "step4of4": "Step 4 of 4", + "stepOf": "Step {current} of {total}", + "stepsLabel": "Setup steps" }, "goals": { "title": "What would you like to track?", @@ -4620,6 +4622,28 @@ "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed." + }, + "steps": { + "who": "Who this record is for", + "areas": "What to keep an eye on", + "medication": "Medication", + "sources": "Where readings come from", + "visit": "Doctor's visit", + "units": "Units", + "confirm": "Confirm", + "first-result": "First result", + "done": "Done" + }, + "flow": { + "welcome": { + "title": "Welcome to HealthLog", + "body": "Five short questions decide what HealthLog shows you first. It takes about two minutes.", + "changeLater": "Nothing is final: every choice can be changed later under Settings.", + "setUp": "Set up", + "skip": "Skip for now", + "againBody": "Your setup is finished. Answer the questions again to change what HealthLog shows first, or go to the dashboard.", + "setUpAgain": "Set up again" + } } }, "gettingStarted": { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 6df0bd37e..f1c58ba81 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -275,7 +275,6 @@ const PINNED_AFFORDANCES: Record< "src/components/mood/mood-form.tsx": { "toast.success": 1 }, "src/components/mood/mood-list.tsx": { "toast.success": 3 }, "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, - "src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 }, "src/components/records/allergy-form.tsx": { "toast.success": 1 }, "src/components/records/allergy-manager.tsx": { "toast.success": 1 }, "src/components/records/conditions-manager.tsx": { "toast.success": 1 }, diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx deleted file mode 100644 index 14b825d5d..000000000 --- a/src/app/onboarding/[step]/page.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { redirect, notFound } from "next/navigation"; -import Link from "next/link"; - -import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; -import { WelcomeCarousel } from "@/components/onboarding/welcome-carousel"; -import { GoalsChipPicker } from "@/components/onboarding/goals-chip-picker"; -import { SourceCardGrid } from "@/components/onboarding/source-card-grid"; -import { BaselineForm } from "@/components/onboarding/baseline-form"; -import { DoneScreen } from "@/components/onboarding/done-screen"; -import { Button } from "@/components/ui/button"; -import { getSession } from "@/lib/auth/session"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { resolveServerLocale } from "@/lib/i18n/server-locale"; - -/** - * Onboarding wizard step page. - * - * Routes: - * /onboarding/0 welcome → carousel / value-prop intro - * /onboarding/1 goals → "what do you want to track?" - * /onboarding/2 source → device/import source cards (see - * source-card-grid.tsx for the shipped set) - * /onboarding/3 baseline → first measurement or sync confirmation - * /onboarding/4 done → success screen + return to dashboard - * - * Step gating: - * - Unauthenticated → `/auth/login` (the proxy also enforces this, - * but the server-side check keeps the contract explicit). - * - `onboardingCompletedAt != null` → `/` (no replaying the wizard - * by URL once the user has finished). - * - Out-of-order requests (e.g. user lands on `/onboarding/3` while - * `onboardingStep == 1`) redirect to the user's current step. The - * "current step" is `User.onboardingStep ?? 0`. - * - * The user MAY navigate backwards (e.g. `/onboarding/0` while on step - * 2). Backwards navigation is non-destructive — the shell's "Back" - * button uses it. Only forward jumps are blocked. - */ - -const VALID_STEPS = [0, 1, 2, 3, 4] as const; -type Step = (typeof VALID_STEPS)[number]; - -export const dynamicParams = false; - -export function generateStaticParams() { - return VALID_STEPS.map((step) => ({ step: String(step) })); -} - -interface PageProps { - params: Promise<{ step: string }>; -} - -export default async function OnboardingStepPage({ params }: PageProps) { - const { step: stepParam } = await params; - const parsedStep = Number.parseInt(stepParam, 10); - - if ( - !Number.isFinite(parsedStep) || - !VALID_STEPS.includes(parsedStep as Step) - ) { - notFound(); - } - const requested = parsedStep as Step; - - const session = await getSession(); - if (!session) { - redirect("/auth/login"); - } - const { user } = session; - - const completed = user.onboardingCompletedAt != null; - - // Completed users hitting any mid-flow step (1, 2, 3) are bounced - // back to the dashboard — replaying half the wizard is never useful. - // Step 0 stays accessible as the welcome-back surface (banner instead - // of carousel) and step 4 stays accessible as the success screen so - // a re-landing user has a clean "Open dashboard" exit. - if (completed && requested > 0 && requested < 4) { - redirect("/"); - } - - const current = clampCurrentStep(user.onboardingStep); - if (!completed && requested > current) { - redirect(`/onboarding/${current}`); - } - - const locale = await resolveServerLocale({ userLocale: user.locale }); - const { t } = getServerTranslator(locale); - - // Welcome (step 0) - // * Fresh user → value-prop carousel. - // * Returning user (onboarding already completed) → welcome-back - // banner with "Open dashboard" CTA. The maintainer note 2026-05-14: the - // "restart onboarding" affordance lives in Settings → Account in - // v1.4.26; for now the banner is informational only. - if (requested === 0) { - return ( - - {completed ? ( -
- {/* 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.welcomeBack.title")} -

-

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

-
- -
-
- ) : ( - - )} -
- ); - } - - // Goals (step 1) — multi-select chip grid. Component owns its own - // Back/Skip/Next row so the shell drops every footer href to avoid - // duplicate controls. The user id is threaded as a prop so the - // client hydration reads localStorage synchronously in its state - // initializer (avoids the setState-in-effect anti-pattern). - if (requested === 1) { - return ( - - - - ); - } - - // Source (step 2) — card grid. Manual is the implicit default; the - // connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout, - // Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s - // SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the - // current shipped set rather than duplicating it here. The selection - // is non-binding: nothing here auto-configures a sync. - if (requested === 2) { - return ( - - - - ); - } - - // Baseline (step 3) — profile form. PUT /api/auth/profile saves the - // four fields, then POST step:4 advances + flips - // `onboardingCompletedAt`. - if (requested === 3) { - return ( - - - - ); - } - - // Done (step 4) — success screen + dashboard CTA. Reachable both - // after first-completion (POST step:4) and again if a completed - // user lands here by URL. - return ( - - - - ); -} - -function clampCurrentStep(value: number | null | undefined): Step { - if (value == null || !Number.isFinite(value)) return 0; - const floor = Math.floor(value); - if (floor <= 0) return 0; - if (floor >= 4) return 4; - return floor as Step; -} diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx index 1a43f2256..64e546bd2 100644 --- a/src/app/onboarding/__tests__/page.test.tsx +++ b/src/app/onboarding/__tests__/page.test.tsx @@ -1,19 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactElement } from "react"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, + type OnboardingStepId, + type OnboardingStepStatus, +} from "@/lib/onboarding/needs"; /** - * v1.4.25 W14b-Content — onboarding root redirect tests. + * v1.39 (C2) — the setup flow's front door. * - * The v1.4.20 single-file wizard was deleted in this commit; the root - * `/onboarding` page is now a server-side redirect into the new - * `/onboarding/[step]` flow. These tests cover the three redirect - * branches: missing session, mid-flow user, completed user. + * Three ways in, decided by the step machine: a record that never entered + * the flow sees the welcome screen, a record mid-way is sent to the step it + * owes, and a finished record sees the welcome's "again" variant. No session + * mirrors the proxy's gate. */ const redirectMock = vi.fn((href: string) => { - // next/navigation `redirect()` throws a special sentinel inside the - // server-component renderer to short-circuit rendering. Mimic that - // by throwing an error tagged with the href so each test can - // unambiguously assert the redirect target. const err = new Error(`__redirect__:${href}`); (err as Error & { __redirect__: string }).__redirect__ = href; throw err; @@ -23,79 +28,119 @@ vi.mock("next/navigation", () => ({ redirect: (href: string) => redirectMock(href), })); -const getSessionMock = vi.fn(); -vi.mock("@/lib/auth/session", () => ({ - getSession: () => getSessionMock(), +const loadMock = vi.fn(); +vi.mock("@/lib/onboarding/load-flow-state", () => ({ + loadOnboardingFlowState: () => loadMock(), +})); + +// The shell resolves a locale from cookies; the tree is inspected, not +// rendered, so a plain passthrough is all the test needs. +vi.mock("@/components/onboarding/onboarding-shell", () => ({ + OnboardingShell: ({ children }: { children: ReactElement }) => children, })); import OnboardingRootPage from "../page"; +import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; beforeEach(() => { redirectMock.mockClear(); - getSessionMock.mockReset(); + loadMock.mockReset(); }); -function makeSession(opts: { - onboardingStep?: number | null; - onboardingCompletedAt?: Date | null; -}) { +function state( + statuses: Partial> = {}, + needs: Partial = {}, + completedAt: string | null = null, +): OnboardingStateDto { return { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 86400000) }, - user: { - id: "user-1", - onboardingStep: opts.onboardingStep ?? null, - onboardingCompletedAt: opts.onboardingCompletedAt ?? null, - }, + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: statuses[step.id] ?? step.status, + })), + needs: { ...emptyOnboardingNeeds(), ...needs }, + completedAt, + firstResult: null, }; } -async function runRedirect(): Promise { +type ShellElement = ReactElement<{ + children: ReactElement<{ variant: string }>; +}>; + +async function run(): Promise< + { redirect: string } | { element: ShellElement } +> { try { - await OnboardingRootPage(); + const element = (await OnboardingRootPage()) as ShellElement; + return { element }; } catch (e) { const tagged = e as Error & { __redirect__?: string }; - if (tagged.__redirect__) return tagged.__redirect__; + if (tagged.__redirect__) return { redirect: tagged.__redirect__ }; throw e; } - throw new Error("expected redirect, none thrown"); } -describe(" root redirect", () => { - it("redirects to /auth/login when no session", async () => { - getSessionMock.mockResolvedValueOnce(null); - const href = await runRedirect(); - expect(href).toBe("/auth/login"); +function welcomeVariant(element: ShellElement): string { + const child = element.props.children; + expect(child.type).toBe(WelcomeScreen); + return child.props.variant; +} + +describe("", () => { + it("redirects to /auth/login when there is no session", async () => { + loadMock.mockResolvedValueOnce(null); + expect(await run()).toEqual({ redirect: "/auth/login" }); }); - it("redirects fresh user (onboardingStep null) to /onboarding/0", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({})); - const href = await runRedirect(); - expect(href).toBe("/onboarding/0"); + it("shows the welcome screen to a record that never entered the flow", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(), + }); + const result = await run(); + expect("element" in result && welcomeVariant(result.element)).toBe("fresh"); }); - it("redirects mid-flow user to /onboarding/", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 2 })); - const href = await runRedirect(); - expect(href).toBe("/onboarding/2"); + it("sends a record mid-way to the step it owes", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state({ who: "done", areas: "done" }, { recordTarget: "me" }), + }); + expect(await run()).toEqual({ redirect: "/onboarding/medication" }); }); - it("clamps an out-of-range onboardingStep into the 0..4 window", async () => { - getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 99 })); - const href = await runRedirect(); - expect(href).toBe("/onboarding/4"); + it("sends a confirmed flow with an open first result to that screen", async () => { + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state( + { + who: "done", + areas: "done", + medication: "done", + sources: "done", + visit: "done", + confirm: "done", + }, + { recordTarget: "me", areas: ["blood-pressure"] }, + "2026-09-10T08:00:00.000Z", + ), + }); + expect(await run()).toEqual({ redirect: "/onboarding/first-result" }); }); - it("redirects a completed user to /onboarding/", async () => { - getSessionMock.mockResolvedValueOnce( - makeSession({ - onboardingStep: 4, - onboardingCompletedAt: new Date("2026-05-01"), - }), - ); - const href = await runRedirect(); - // The step page handles the welcome-back banner at step 0 and the - // done-screen at step 4 — the root page just bounces into the - // current step regardless of completion state. - expect(href).toBe("/onboarding/4"); + it("offers the questions again to a finished record", async () => { + const done = Object.fromEntries( + defaultOnboardingSteps().map((s) => [s.id, "done" as const]), + ) as Record; + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(done, { recordTarget: "me" }, "2026-09-10T08:00:00.000Z"), + }); + const result = await run(); + expect("element" in result && welcomeVariant(result.element)).toBe("again"); }); }); diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index 5a233e2b0..1f4ae68a5 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -1,14 +1,11 @@ import type { ReactNode } from "react"; /** - * v1.4.25 W14b — onboarding route-segment layout. + * The setup flow's route-segment layout. * - * Intentionally pass-through. The `OnboardingShell` chrome lives inside - * each `[step]/page.tsx` so the existing `/onboarding` root page - * (`page.tsx` — the legacy v1.4.20 single-file wizard) keeps rendering - * unchanged while the rebuilt multi-step flow ships beside it. The - * Content agent that follows W14b-Foundation will swap the root page's - * redirect target once every step has real content. + * Pass-through on purpose: the `OnboardingShell` chrome is rendered by each + * page, because the shell's counter and step list depend on the flow state + * the page loads, and a layout cannot read what its page decided. */ export default function OnboardingLayout({ children, diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 680293ecc..7acc07fe8 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,44 +1,49 @@ import { redirect } from "next/navigation"; -import { getSession } from "@/lib/auth/session"; +import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; +import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; +import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state"; +import { isOnboardingSettled } from "@/lib/onboarding/needs"; +import { resumeScreen } from "@/lib/onboarding/wizard-steps"; /** - * v1.4.25 W14b — onboarding root redirect. + * v1.39 (C2) — the setup flow's front door. * - * The proxy redirect (`src/proxy.ts:179`) lands every still-pending user - * on `/onboarding`. Until W14b shipped, this file rendered the v1.4.20 - * single-file 3-step wizard inline. The new flow lives under - * `/onboarding/[step]/page.tsx`, so this root page now resolves to a - * server-side redirect into the right step: + * The proxy lands every account still pending its first run here, and + * Settings → "Set up again" does too. What is shown depends on where the + * flow stands, read off the same ledger every screen reads: * - * - No session → `/auth/login` (the proxy enforces - * this too; we mirror it to keep the contract explicit). - * - `onboardingCompletedAt != null` (returning user) - * → `/onboarding/0` so the welcome-back - * banner can show; the dashboard is - * one click away from there. - * - Otherwise → `/onboarding/` where - * `current = user.onboardingStep ?? 0`. + * - never entered → the welcome screen ("Set up" / "Skip for now") + * - a step still owed → redirect to that step + * - finished → the welcome screen's "again" variant, which + * offers the questions once more or the dashboard * - * The previous wizard remained the entry point through the - * W14b-Foundation phase to keep an unbroken flow; this commit swaps it - * out now that every step page renders real UI. + * No session mirrors the proxy's own gate (`/onboarding` is a public path, + * so the page has to say it too). */ export default async function OnboardingRootPage() { - const session = await getSession(); - if (!session) { + const flow = await loadOnboardingFlowState(); + if (!flow) { redirect("/auth/login"); } - const { user } = session; + const { state, userLocale } = flow; - const current = clampCurrentStep(user.onboardingStep); - redirect(`/onboarding/${current}`); -} + const resume = resumeScreen(state); + if (resume !== "welcome") { + const finished = state.completedAt !== null && isOnboardingSettled(state); + if (!finished) { + redirect(`/onboarding/${resume}`); + } + return ( + + + + ); + } -function clampCurrentStep(value: number | null | undefined): 0 | 1 | 2 | 3 | 4 { - if (value == null || !Number.isFinite(value)) return 0; - const floor = Math.floor(value); - if (floor <= 0) return 0; - if (floor >= 4) return 4; - return floor as 0 | 1 | 2 | 3 | 4; + return ( + + + + ); } diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx deleted file mode 100644 index f419960c4..000000000 --- a/src/components/onboarding/__tests__/source-card-grid.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.29.x — UX audit H1: the wizard's Withings card must not launch an - * OAuth handshake that can only 400 for a brand-new account (Withings - * credentials are per-user BYO with no env fallback). The card reads the - * consolidated integrations-status envelope and swaps its CTA + href - * depending on whether the account already has its own credentials saved. - */ - -const integrationStatusState = vi.hoisted(() => ({ - data: undefined as - | { - threshold: number; - integrations: Array<{ - integration: string; - configured?: boolean; - connected?: boolean; - }>; - } - | undefined, -})); - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: integrationStatusState.data }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), -})); - -vi.mock("next/link", () => ({ - default: ({ - href, - children, - ...rest - }: { - href: string; - children: React.ReactNode; - }) => ( - - {children} - - ), -})); - -import { SourceCardGrid } from "../source-card-grid"; - -function render() { - return renderToStaticMarkup( - - - , - ); -} - -describe(" Withings card", () => { - it("points at Settings instead of launching OAuth when unconfigured", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [{ integration: "withings", configured: false }], - }; - const html = render(); - expect(html).toContain('data-testid="source-card-withings-setup"'); - expect(html).toContain('href="/settings/integrations#withings"'); - expect(html).not.toContain('href="/api/withings/connect"'); - expect(html).toContain("Set up"); - }); - - it("also redirects to Settings when the status envelope hasn't loaded yet", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('href="/settings/integrations#withings"'); - expect(html).not.toContain('href="/api/withings/connect"'); - }); - - it("opens the real OAuth connect flow once credentials are configured", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [{ integration: "withings", configured: true }], - }; - const html = render(); - expect(html).toContain('href="/api/withings/connect"'); - expect(html).not.toContain('data-testid="source-card-withings-setup"'); - expect(html).toContain("Connect Withings"); - }); - - // 2026-07-17 UX-onboarding audit M6 — the connect CTA opens OAuth in a - // new tab; the callback flips the connection in THAT tab while this - // wizard tab used to render the same unchanged button forever. Once - // `useIntegrationStatuses` (refetches on window focus) reports - // `connected: true`, the card must swap to a badge instead of a dead - // "Connect Withings" button that would just reopen a redundant flow. - it("shows a connected badge instead of the connect button once Withings is linked", () => { - integrationStatusState.data = { - threshold: 3, - integrations: [ - { integration: "withings", configured: true, connected: true }, - ], - }; - const html = render(); - expect(html).toContain('data-testid="source-card-withings-connected"'); - expect(html).not.toContain('href="/api/withings/connect"'); - expect(html).not.toContain("Connect Withings"); - }); -}); - -// #778 — the step listed Apple Health next to the web connectors with nothing -// saying its setup happens in the iOS app afterwards, so it read like one more -// OAuth flow to hunt for. -describe(" Apple Health note", () => { - it("says Apple Health setup completes in the iPhone app, not on the web", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('data-testid="source-more-apple-health-note"'); - expect(html).toContain("open the HealthLog app on your iPhone"); - expect(html).toContain("no web connection"); - }); - - it("keeps the Apple Health chip tappable so the note is not a dead end", () => { - integrationStatusState.data = undefined; - const html = render(); - expect(html).toContain('data-testid="source-more-apple-health"'); - expect(html).toContain('href="/settings/integrations"'); - }); -}); diff --git a/src/components/onboarding/goals-chip-picker.tsx b/src/components/onboarding/goals-chip-picker.tsx deleted file mode 100644 index c83877eba..000000000 --- a/src/components/onboarding/goals-chip-picker.tsx +++ /dev/null @@ -1,228 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - Brain, - Droplet, - HeartPulse, - Moon, - Pill, - Scale, - type LucideIcon, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiPost } from "@/lib/api/api-fetch"; -import { localizedApiError } from "@/lib/api/localized-error"; -import { queryKeys } from "@/lib/query-keys"; - -/** - * v1.4.25 W14b-Content — onboarding step 1 (goals). - * - * Multi-select chip picker for the metrics the user wants to track. - * The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS` - * (`src/lib/onboarding/goals.ts`). Selection lives in component state - * while the step is open; on advance it is persisted server-side to - * `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile - * layout on completion and feeds reminder suggestions. - * - * CTAs: - * - Back → `/onboarding/0` - * - Skip → advance with empty goal set (explicit "no preference") - * - Next → advance with chosen goal set - * - * Both Skip and Next call POST `/api/onboarding/step` with - * `{ step: 2, goals }` and `router.push("/onboarding/2")`. - */ - -export type OnboardingGoalSlug = - | "weight-management" - | "bp-tracking" - | "glucose-tracking" - | "sleep-improvement" - | "medication-compliance" - | "general-wellness"; - -interface GoalOption { - slug: OnboardingGoalSlug; - Icon: LucideIcon; - labelKey: string; -} - -const GOAL_OPTIONS: ReadonlyArray = [ - { - slug: "weight-management", - Icon: Scale, - labelKey: "onboarding.goals.options.weightManagement", - }, - { - slug: "bp-tracking", - Icon: HeartPulse, - labelKey: "onboarding.goals.options.bpTracking", - }, - { - slug: "glucose-tracking", - Icon: Droplet, - labelKey: "onboarding.goals.options.glucoseTracking", - }, - { - slug: "sleep-improvement", - Icon: Moon, - labelKey: "onboarding.goals.options.sleepImprovement", - }, - { - slug: "medication-compliance", - Icon: Pill, - labelKey: "onboarding.goals.options.medicationCompliance", - }, - { - slug: "general-wellness", - Icon: Brain, - labelKey: "onboarding.goals.options.generalWellness", - }, -]; - -export interface GoalsChipPickerProps { - /** - * The current user's id. Threaded in from the server step page - * (`getSession().user.id`) so the v1.4.26 column write can reuse - * the same call site without a prop reshuffle. - */ - userId: string; -} - -export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) { - const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); - - const [selected, setSelected] = useState>( - () => new Set(), - ); - const [advancing, setAdvancing] = useState(false); - - const toggle = useCallback((slug: OnboardingGoalSlug) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); - return next; - }); - }, []); - - async function advance() { - if (advancing) return; - setAdvancing(true); - try { - // v1.17.1 — persist the chosen goal slugs alongside the step - // write. Skip sends an empty array (explicit "no preference"), - // not omission, so a resume can read the deliberate empty choice. - // The server seeds the dashboard from these on completion. - await apiPost("/api/onboarding/step", { - step: 2, - goals: Array.from(selected), - }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/2"); - } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); - setAdvancing(false); - } - } - - return ( -
-
- {/* 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.goals.title")} -

-

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

-

- {t("onboarding.goals.helpHint")} -

-
- -
- {t("onboarding.goals.title")} - {GOAL_OPTIONS.map((option) => { - const checked = selected.has(option.slug); - const Icon = option.Icon; - return ( - - ); - })} -
- -
- -
- - -
-
-
- ); -} diff --git a/src/components/onboarding/onboarding-shell.tsx b/src/components/onboarding/onboarding-shell.tsx index 1084ba693..3ce3c4b0f 100644 --- a/src/components/onboarding/onboarding-shell.tsx +++ b/src/components/onboarding/onboarding-shell.tsx @@ -1,171 +1,109 @@ -import Link from "next/link"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; -import { Button } from "@/components/ui/button"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import type { + OnboardingStateDto, + OnboardingStepId, +} from "@/lib/onboarding/needs"; +import { + screenOrder, + stepCounter, + type OnboardingScreenId, +} from "@/lib/onboarding/wizard-steps"; /** - * v1.4.25 W14b — onboarding wizard chrome. + * v1.39 (C2) — the chrome around every setup screen. * - * Renders the shared frame around every step in the rebuilt - * `/onboarding/[step]` flow: logo, "Step N of 4" label, four-dot - * progress strip, the step body slot, and the back / skip / next - * action row pinned to the bottom safe-area. + * Logo, the "Step 2 of 6" counter, the step list, and the body slot. The + * counter and the list are both read off the step machine, so the shell + * shows exactly the screens THIS flow has — six questions for somebody who + * ticked glucose, five for somebody who did not, a first-result screen only + * when there is a task to offer. * - * Step encoding (mirrors `User.onboardingStep`): - * 0 = welcome (no progress dot; intro screen, primary action is "next") - * 1 = goals (dot 1 active) - * 2 = source (dot 2 active) - * 3 = baseline (dot 3 active) - * 4 = done (dot 4 active; "done" copy; primary CTA = return to dashboard) + * The step list is a real ordered list (design spec §Principles 6), each + * entry with its name for a screen reader and `aria-current="step"` on the + * one in view; the dots are the visual rendering of that list, not a + * separate progress bar. The counter is `aria-live` so a step change is + * announced without the heading having to carry the number. * - * Server component — every step page is also a server component and - * the shell carries no client state. Back / skip / next are plain - * ``s wrapping ` - ) : ( -