From b446911d7d6ede338567d8fbbe2d6d823331d96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 15:28:16 +0200 Subject: [PATCH] Add the setup flow's done screen The dashboard is one click away, already ordered, and the checklist opens on arrival. The tour is an offer here rather than an automatic launch: pressing it sets the referrer the shell's launcher reads, and nothing else does. Two record-shaped exits follow the answer to Q1: an account that said "both" is offered the profile for the person it looks after, and one that set up a record for somebody else is offered that record through the same switch the account menu uses, so the first entry lands where it belongs. The step is marked done on arrival, once. --- messages/en.json | 5 + src/app/onboarding/[step]/page.tsx | 3 +- .../onboarding/__tests__/done-screen.test.tsx | 43 +++++- src/components/onboarding/done-screen.tsx | 134 +++++++++++++++--- 4 files changed, 159 insertions(+), 26 deletions(-) diff --git a/messages/en.json b/messages/en.json index 68091e25d..5534da01c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4771,6 +4771,11 @@ "open": "Open {target}", "markDone": "I have made the entry" } + }, + "done": { + "tourCta": "Take a short tour", + "openRecord": "Open the record of {name}", + "createProfile": "Create the profile for the person you look after" } } }, diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 4dd9f08df..73f4e8efe 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; import { ConfirmScreen } from "@/components/onboarding/confirm-screen"; +import { DoneScreen } from "@/components/onboarding/done-screen"; import { FirstResultScreen } from "@/components/onboarding/first-result-screen"; import { QuestionScreen } from "@/components/onboarding/question-screen"; import { UnitsScreen } from "@/components/onboarding/units-screen"; @@ -106,5 +107,5 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { if (screen === "first-result") { return ; } - notFound(); + return ; } diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 72038c37b..628d87837 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -17,12 +17,22 @@ const aiProviderState = vi.hoisted(() => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: aiProviderState.data }), + useMutation: () => ({ mutate: () => {}, isPending: false }), + useQueryClient: () => ({ invalidateQueries: async () => {} }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: () => {}, replace: () => {} }), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "u1" } }), })); +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: () => {}, isPending: false }), +})); + vi.mock("@/components/onboarding/tour-launcher", () => ({ setTourReferrer: () => {}, })); @@ -42,12 +52,28 @@ vi.mock("next/link", () => ({ ), })); +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingStateDto, +} from "@/lib/onboarding/needs"; import { DoneScreen } from "../done-screen"; -function render() { +function state( + recordTarget: OnboardingStateDto["needs"]["recordTarget"] = "me", +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), recordTarget }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: null, + }; +} + +function render(recordTarget?: OnboardingStateDto["needs"]["recordTarget"]) { return renderToStaticMarkup( - + , ); } @@ -71,7 +97,18 @@ describe(" AI panel", () => { const html = render(); expect(html).toContain('href="/settings/integrations"'); expect(html).toContain('href="/measurements"'); - expect(html).toContain('href="/"'); + expect(html).toContain('data-slot="onboarding-open-dashboard"'); + }); + + it("offers the tour rather than launching it", () => { + aiProviderState.data = undefined; + expect(render()).toContain('data-slot="onboarding-take-tour"'); + }); + + it("offers the managed profile only to an answer that asked for it later", () => { + aiProviderState.data = undefined; + expect(render("both")).toContain('href="/settings/access"'); + expect(render("me")).not.toContain('href="/settings/access"'); }); it("shows the shared-key note only when the operator key serves the user", () => { diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index bb8c56689..c4a2c43e2 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -1,25 +1,33 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, + Compass, FileUp, PlusCircle, Plug, Settings2, Sparkles, + UserPlus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card"; +import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow"; import { useAuth } from "@/hooks/use-auth"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { accountLabel } from "@/lib/sharing/account-access-view"; interface AiProviderStatus { /** Origin of the provider that would serve this user, if any. */ @@ -27,33 +35,70 @@ interface AiProviderStatus { } /** - * v1.4.25 W14b-Content — onboarding step 4 (done). + * v1.39 (C2) — the setup flow's exit. * - * Confirms the user has finished onboarding. `onboardingCompletedAt` - * was flipped server-side by the baseline form's `POST step:4` write, - * so this page is purely presentational — no further mutation, no - * cookie flips, no API calls. + * The dashboard is one click away, already ordered, and the checklist opens + * on arrival (design spec §The screens, 5). The tour is an OFFER here rather + * than an automatic launch: pressing "Take a short tour" sets the referrer + * the shell-level `` reads, and nothing else does, so somebody + * who wants the dashboard gets the dashboard. * - * The single CTA returns the user to the dashboard. The link goes - * through `next/link` so the regular client-side navigation kicks in - * (the proxy redirect was cleared with the same step-API write that - * landed the user here). + * Two record-shaped exits ride the answer to Q1: "both" is offered the + * profile for the person they look after, and "someone I look after" — whose + * profile was created on the confirm screen — is offered that record, through + * the same switch the account menu uses, so their first entry lands in the + * right record. * - * v1.18.6 — on mount we write the per-user wizard-return marker so the - * shell-level `` auto-opens the module tour immediately - * on the next dashboard arrival (sequenced after the post-wizard - * grace), instead of waiting out the 24 h fallback. Whichever exit the - * user picks from this screen, the next `/` mount finds the marker. + * The step is marked done on arrival, once. Everything else on this screen + * is presentational: the module map was derived on confirm, the cookie + * cleared there too. + * + * The AI panel leads with the payoff (a static, clearly-labelled SAMPLE + * briefing — no model call, no egress, no consent), then the honest + * local-first ladder and the "useful without AI" release valve. Value-first, + * never a gate: every exit below stays, and setup is a single optional + * deep-link. */ -export function DoneScreen() { +export function DoneScreen({ state }: { state: OnboardingStateDto }) { const { t } = useTranslations(); + const router = useRouter(); const { user } = useAuth(); const [sampleOpen, setSampleOpen] = useState(false); + const answer = useOnboardingAnswer(); + const accountSwitch = useAccountSwitch(); + // Mark the step once. The write is idempotent, but a strict-mode double + // mount would still send it twice; the ref keeps it to one. + const marked = useRef(false); + const donePending = + state.steps.find((step) => step.id === "done")?.status === "pending"; useEffect(() => { + if (!donePending || marked.current) return; + marked.current = true; + answer.mutate({ step: "done" }); + // `answer` is a fresh object every render; the mutation it wraps is not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [donePending]); + + const recordTarget = state.needs.recordTarget; + const managedRecord = + recordTarget === "someone-else" + ? (user?.accountAccess?.accounts.find( + (entry) => entry.recordKind === "managed", + ) ?? null) + : null; + + function openDashboard() { + markChecklistExpanded(); + router.push("/"); + } + + function takeTour() { if (user?.id) setTourReferrer(user.id); - }, [user?.id]); + markChecklistExpanded(); + router.push("/"); + } // The shared-key note is the ONLY honest divergence in the panel: on a // deployment that ships an operator key, insights already work for the @@ -208,7 +253,55 @@ export function DoneScreen() {
- + + {managedRecord ? ( + + ) : null} + {recordTarget === "both" ? ( + + ) : null} + - -
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on