From 1c2e7c29de2913cc888d141f1b54cfe1ea87dfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 15:10:50 +0200 Subject: [PATCH] Add the setup flow's welcome screen and shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The front door reads the same ledger every screen reads: a record that never entered the flow sees the welcome, one mid-way is sent to the step it owes, a finished one is offered the questions again. The shell prints the counter and an ordered step list off the step machine, so it shows exactly the screens this flow has, and every screen moves focus to its heading on arrival. "Skip for now" is a real exit — it stamps the account past the first-run redirect and opens the checklist on the dashboard — and the one-time medical disclaimer now gates it as well as "Set up", because a skip that reached the dashboard without it would be a way round the acknowledgment. The five-step wizard's welcome carousel, goal picker and source grid go with it; the numbered step route follows in the next commit. --- messages/en.json | 26 +- .../success-affordance-guard.test.ts | 1 - src/app/onboarding/[step]/page.tsx | 182 -------- src/app/onboarding/__tests__/page.test.tsx | 157 ++++--- src/app/onboarding/layout.tsx | 11 +- src/app/onboarding/page.tsx | 65 +-- .../__tests__/source-card-grid.test.tsx | 134 ------ .../onboarding/goals-chip-picker.tsx | 228 ---------- .../onboarding/onboarding-shell.tsx | 182 +++----- .../onboarding/source-card-grid.tsx | 425 ------------------ src/components/onboarding/step-actions.tsx | 83 ++++ src/components/onboarding/step-heading.tsx | 50 +++ .../onboarding/use-onboarding-flow.ts | 86 ++++ .../onboarding/welcome-carousel.tsx | 340 -------------- src/components/onboarding/welcome-screen.tsx | 193 ++++++++ .../onboarding/__tests__/wizard-steps.test.ts | 5 + src/lib/onboarding/checklist-storage.ts | 56 +++ src/lib/onboarding/load-flow-state.ts | 40 ++ src/lib/onboarding/wizard-steps.ts | 6 +- 19 files changed, 743 insertions(+), 1527 deletions(-) delete mode 100644 src/app/onboarding/[step]/page.tsx delete mode 100644 src/components/onboarding/__tests__/source-card-grid.test.tsx delete mode 100644 src/components/onboarding/goals-chip-picker.tsx delete mode 100644 src/components/onboarding/source-card-grid.tsx create mode 100644 src/components/onboarding/step-actions.tsx create mode 100644 src/components/onboarding/step-heading.tsx create mode 100644 src/components/onboarding/use-onboarding-flow.ts delete mode 100644 src/components/onboarding/welcome-carousel.tsx create mode 100644 src/components/onboarding/welcome-screen.tsx create mode 100644 src/lib/onboarding/checklist-storage.ts create mode 100644 src/lib/onboarding/load-flow-state.ts 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 ` - ) : ( -