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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:28:16 +02:00
1 parent 5a805e8d17
commit b446911d7d
4 files changed
+159 -26

No files matched your search

+5
View File
@@ -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"
}
}
},
+2 -1
View File
@@ -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 <FirstResultScreen state={state} />;
}
notFound();
return <DoneScreen state={state} />;
}
@@ -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(
<I18nProvider initialLocale="en">
<DoneScreen />
<DoneScreen state={state(recordTarget)} />
</I18nProvider>,
);
}
@@ -71,7 +97,18 @@ describe("<DoneScreen> 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", () => {
+112 -22
View File
@@ -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 `<TourLauncher>` 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 `<TourLauncher>` 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() {
</section>
<div className="flex w-full max-w-xs flex-col gap-2">
<Button asChild size="lg">
<Button
type="button"
size="lg"
onClick={openDashboard}
className="min-h-11"
data-slot="onboarding-open-dashboard"
>
{t("onboarding.done.returnCta")}
</Button>
<Button
type="button"
size="lg"
variant="outline"
onClick={takeTour}
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-take-tour"
>
<Compass className="size-4" />
{t("onboarding.flow.done.tourCta")}
</Button>
{managedRecord ? (
<Button
type="button"
size="lg"
variant="outline"
onClick={() => accountSwitch.mutate(managedRecord.accountId)}
disabled={accountSwitch.isPending}
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-open-managed-record"
>
<UserPlus className="size-4" />
{t("onboarding.flow.done.openRecord", {
name: accountLabel(managedRecord),
})}
</Button>
) : null}
{recordTarget === "both" ? (
<Button asChild size="lg" variant="outline">
<Link
href="/settings/access"
className="inline-flex min-h-11 items-center gap-2"
data-slot="onboarding-create-managed-profile"
>
<UserPlus className="size-4" />
{t("onboarding.flow.done.createProfile")}
</Link>
</Button>
) : null}
<Button asChild variant="ghost">
<Link
href="/settings/integrations"
className="inline-flex items-center gap-2"
@@ -217,15 +310,12 @@ export function DoneScreen() {
{t("onboarding.done.connectCta")}
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Button asChild variant="ghost">
<Link href="/measurements" className="inline-flex items-center gap-2">
<PlusCircle className="size-4" />
{t("onboarding.done.logCta")}
</Link>
</Button>
<Button asChild variant="ghost">
<Link href="/">{t("onboarding.done.returnCta")}</Link>
</Button>
</div>
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on