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