Add the setup flow's welcome screen and shell

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:10:50 +02:00
1 parent 13f11ed06c
commit 1c2e7c29de
19 files changed
+743 -1527

No files matched your search

@@ -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;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
import { SourceCardGrid } from "../source-card-grid";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SourceCardGrid />
</I18nProvider>,
);
}
describe("<SourceCardGrid> 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</a>");
});
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("<SourceCardGrid> 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"');
});
});
@@ -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<GoalOption> = [
{
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<Set<OnboardingGoalSlug>>(
() => 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 (
<section aria-labelledby="onboarding-goals-title" className="space-y-6">
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-goals-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.goals.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.goals.body")}
</p>
<p className="text-muted-foreground text-xs">
{t("onboarding.goals.helpHint")}
</p>
</header>
<fieldset
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
aria-describedby="onboarding-goals-title"
>
<legend className="sr-only">{t("onboarding.goals.title")}</legend>
{GOAL_OPTIONS.map((option) => {
const checked = selected.has(option.slug);
const Icon = option.Icon;
return (
<label
key={option.slug}
className={cn(
"border-border bg-card flex min-h-[88px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border p-3 text-center transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:border-primary/50",
checked && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
name="onboarding-goal"
value={option.slug}
checked={checked}
onChange={() => toggle(option.slug)}
className="sr-only"
/>
<span
aria-hidden="true"
className={cn(
"flex size-9 items-center justify-center rounded-full transition-colors",
checked
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-4" />
</span>
<span className="text-foreground text-sm leading-tight font-medium">
{t(option.labelKey)}
</span>
</label>
);
})}
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/0">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
</div>
</section>
);
}
+60 -122
View File
@@ -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
* `<Link>`s wrapping `<Button>`s; the W14b-Content agent will replace
* those with form submissions / mutations on the step pages that need
* them, while the chrome itself stays static.
*
* Locale resolution mirrors the rest of the server tree
* (`resolveServerLocale` → cookie → User.locale → Accept-Language).
* Server component: nothing here holds state. Each screen owns its own
* action row (`StepActions`), because what "next" persists differs per
* screen and the shell should not know.
*/
export interface OnboardingShellProps {
/** 0..4 — see step encoding above. Out-of-range values clamp to 0. */
step: number;
/**
* Step body slot. Step pages render their step-specific UI here; the
* shell handles every surrounding pixel.
*/
screen: OnboardingScreenId;
state: OnboardingStateDto;
userLocale: string | null;
children: React.ReactNode;
/** Href for the back button. Hidden when undefined (e.g. step 0). */
backHref?: string;
/** Href for the "Skip" link. Hidden when undefined. */
skipHref?: string;
/** Href for the primary forward CTA. Hidden when undefined. */
nextHref?: string;
/**
* Override the primary CTA label. Defaults to `onboarding.shell.next`
* (or `onboarding.shell.done.returnCta` at step 4).
*/
nextLabel?: string;
/** Override the userLocale used for translator resolution (test hook). */
userLocale?: string | null;
className?: string;
}
const TOTAL_STEPS = 4;
export async function OnboardingShell({
step,
children,
backHref,
skipHref,
nextHref,
nextLabel,
screen,
state,
userLocale,
className,
children,
}: OnboardingShellProps) {
const clamped = clampStep(step);
const locale = await resolveServerLocale({ userLocale: userLocale ?? null });
const locale = await resolveServerLocale({ userLocale });
const { t } = getServerTranslator(locale);
// Progress label only appears for the four real steps; the welcome
// intro (step 0) and the done screen (step 4) both reuse the
// ordinal keys (1/4 and 4/4 respectively) so the strip never goes
// blank between transitions.
const progressLabel =
clamped === 0
? t("onboarding.shell.step1of4")
: t(`onboarding.shell.step${clamped}of4`);
const primaryLabel =
nextLabel ??
(clamped === 4
? t("onboarding.done.returnCta")
: t("onboarding.shell.next"));
const counter = stepCounter(state, screen);
const steps = screenOrder(state).filter(
(id): id is OnboardingStepId => id !== "welcome",
);
const currentIndex = (steps as readonly string[]).indexOf(screen);
return (
<div
className={cn(
"mx-auto flex min-h-[100svh] w-full max-w-xl flex-col",
// Safe-area-respecting bottom padding for iOS PWA standalone.
// Mirrors the pattern called out in the W14b research file
// (Section 4.4) — the home-bar overlaps the primary CTA
// otherwise.
// Safe-area-respecting bottom padding for the iOS PWA — the home
// bar overlaps the primary action otherwise.
"px-4 pt-6 pb-[max(env(safe-area-inset-bottom),1rem)]",
className,
)}
data-slot="onboarding-shell"
data-screen={screen}
>
<header className="mb-6 flex items-center justify-between gap-3">
<Logo size={32} />
<p
className="text-muted-foreground text-sm font-medium"
aria-live="polite"
data-slot="onboarding-step-counter"
>
{progressLabel}
{counter
? t("onboarding.shell.stepOf", {
current: counter.current,
total: counter.total,
})
: null}
</p>
</header>
<div
role="progressbar"
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
aria-valuenow={clamped === 0 ? 1 : clamped}
aria-label={progressLabel}
<ol
aria-label={t("onboarding.shell.stepsLabel")}
className="mb-8 flex items-center gap-2"
data-slot="onboarding-step-list"
>
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const dotStep = i + 1;
const active = clamped >= dotStep || (clamped === 0 && dotStep === 1);
{steps.map((id, index) => {
const reached = currentIndex >= index;
return (
<span
key={dotStep}
data-active={active}
aria-current={
(clamped === 0 ? 1 : clamped) === dotStep ? "step" : undefined
}
<li
key={id}
aria-current={id === screen ? "step" : undefined}
data-reached={reached}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
active ? "bg-primary" : "bg-muted",
reached ? "bg-primary" : "bg-muted",
)}
/>
>
<span className="sr-only">{t(`onboarding.steps.${id}`)}</span>
</li>
);
})}
</div>
</ol>
<main className="flex-1">{children}</main>
<footer className="mt-8 flex items-center justify-between gap-2">
{backHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={backHref}>{t("onboarding.shell.back")}</Link>
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex items-center gap-2">
{skipHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={skipHref}>{t("onboarding.shell.skip")}</Link>
</Button>
) : null}
{nextHref ? (
<Button asChild className="min-h-11 min-w-11">
<Link href={nextHref}>{primaryLabel}</Link>
</Button>
) : null}
</div>
</footer>
</div>
);
}
function clampStep(step: number): 0 | 1 | 2 | 3 | 4 {
if (!Number.isFinite(step)) return 0;
const rounded = Math.floor(step);
if (rounded < 0) return 0;
if (rounded > 4) return 4;
return rounded as 0 | 1 | 2 | 3 | 4;
}
@@ -1,425 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
CheckCircle2,
ClipboardCheck,
FileUp,
Plug,
Watch,
Wifi,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
pickStatus,
useIntegrationStatuses,
} from "@/components/settings/integrations/shared";
import { useAuth } from "@/hooks/use-auth";
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";
/**
* Onboarding step 2 (source).
*
* Presents the data sources HealthLog ships today. The headline cards
* cover the two paths most people start on:
* 1. Manual entry (enabled, recommended — works on any device)
* 2. Withings — opens OAuth `/api/withings/connect` in a new tab once
* the account has its own Withings developer app configured; the
* callback flips the connection server-side. Before that, the card
* is honest about the prerequisite (v1.29.x, UX audit H1/H2): the
* CTA reads "Set up in Settings" and points at the credentials form
* instead of launching an OAuth handshake that can only 400.
*
* Below them a calm "more sources" row lists the remaining shipped
* integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit),
* each linking to Settings → Integrations where it is actually
* connected. We don't try to run six OAuth flows inside the wizard;
* the row sets the expectation and hands off. A discreet import link
* points at the Apple Health export.zip path — the cold-start escape
* hatch for users with existing history.
*
* Garmin is omitted: not shipped, so a card would over-claim.
*
* Selection is non-binding — Manual is the implicit default and the
* Next CTA always advances. Nothing here is auto-configured; the step
* only signposts where each source is connected.
*/
interface SourceCard {
slug: "manual" | "withings";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
/**
* One of:
* - "enabled-select": tap selects the card (Manual)
* - "enabled-oauth": tap opens OAuth in a new tab (Withings)
*/
state: "enabled-select" | "enabled-oauth";
/** External href for `enabled-oauth`. */
href?: string;
/** Optional recommended badge. */
recommendedKey?: string;
}
const SOURCE_CARDS: ReadonlyArray<SourceCard> = [
{
slug: "manual",
Icon: ClipboardCheck,
titleKey: "onboarding.source.manual.title",
bodyKey: "onboarding.source.manual.body",
state: "enabled-select",
recommendedKey: "onboarding.source.recommended",
},
{
slug: "withings",
Icon: Watch,
titleKey: "onboarding.source.withings.title",
bodyKey: "onboarding.source.withings.body",
state: "enabled-oauth",
href: "/api/withings/connect",
},
];
/**
* The remaining shipped integrations. Listed compactly so the step
* stays calm — the full connect flow for each lives in Settings.
*/
const MORE_SOURCES: ReadonlyArray<{ slug: string; labelKey: string }> = [
{ slug: "apple-health", labelKey: "onboarding.source.more.appleHealth" },
{ slug: "whoop", labelKey: "onboarding.source.more.whoop" },
{ slug: "oura", labelKey: "onboarding.source.more.oura" },
{ slug: "polar", labelKey: "onboarding.source.more.polar" },
{ slug: "nightscout", labelKey: "onboarding.source.more.nightscout" },
{ slug: "fitbit", labelKey: "onboarding.source.more.fitbit" },
];
export function SourceCardGrid() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const [selected, setSelected] = useState<SourceCard["slug"]>("manual");
const [advancing, setAdvancing] = useState(false);
// v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth
// handshake that can only fail: Withings credentials are per-user BYO with
// no env fallback, and a brand-new account never has them yet. Read the
// same consolidated envelope Settings → Integrations uses so the card
// knows whether the handshake can actually succeed.
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const withingsConfigured =
pickStatus(integrationStatus, "withings")?.configured ?? false;
// 2026-07-17 UX-onboarding audit M6 — the connect CTA opens the OAuth
// handshake in a new tab; the callback lands THAT tab on
// Settings → Integrations while this wizard tab never learned the
// connection succeeded. `useIntegrationStatuses` already refetches on
// window focus, so returning to this tab after the OAuth round trip
// picks up the fresh `connected` flag for free — the only missing piece
// was rendering it as a badge instead of leaving the button looking
// untouched.
const withingsConnected =
pickStatus(integrationStatus, "withings")?.connected ?? false;
async function advance() {
if (advancing) return;
setAdvancing(true);
try {
await apiPost("/api/onboarding/step", { step: 3 });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/3");
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setAdvancing(false);
}
}
return (
<section aria-labelledby="onboarding-source-title" className="space-y-6">
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-source-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.source.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.source.body")}
</p>
</header>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{SOURCE_CARDS.map((card) => (
<SourceCardItem
key={card.slug}
card={card}
selected={selected === card.slug}
onSelect={() => setSelected(card.slug)}
titleLabel={t(card.titleKey)}
bodyLabel={t(card.bodyKey)}
recommendedLabel={
card.recommendedKey ? t(card.recommendedKey) : null
}
withingsCtaLabel={t("onboarding.source.withings.cta")}
withingsConfigured={withingsConfigured}
withingsConnected={withingsConnected}
withingsConnectedLabel={t("settings.integrationPill.connected")}
withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")}
withingsSetupHint={t("onboarding.source.withings.setupHint")}
/>
))}
</div>
<div className="border-border space-y-3 rounded-lg border border-dashed p-4">
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-md"
>
<Plug className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<h2 className="text-base font-semibold tracking-tight">
{t("onboarding.source.more.title")}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.source.more.body")}
</p>
</div>
</div>
<ul className="flex flex-wrap gap-2" data-testid="source-more-list">
{MORE_SOURCES.map((item) => (
<li key={item.slug}>
<Button asChild variant="outline" size="sm" className="min-h-9">
<Link
// Anchor each chip to its provider card so the tap lands on
// the card, not the top of the generic Integrations page.
// Apple Health has no web card (it syncs from the iOS app),
// so its chip stays on the page root.
href={
item.slug === "apple-health"
? "/settings/integrations"
: `/settings/integrations#${item.slug}`
}
data-testid={`source-more-${item.slug}`}
>
{t(item.labelKey)}
</Link>
</Button>
</li>
))}
</ul>
{/* #778 — Apple Health looked like one more web connector here, but
there is nothing to connect on the web: the sync is set up in the
iOS app after onboarding. Say so, so nobody hunts Settings for an
OAuth flow that does not exist. The chip stays tappable (it lands
on the Integrations page, whose Apple Health card walks through
the iPhone-app setup) and Next / Skip advance as always. */}
<p
className="text-muted-foreground text-xs leading-relaxed"
data-testid="source-more-apple-health-note"
>
{t("onboarding.source.more.appleHealthHint")}
</p>
</div>
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
data-testid="source-import-link"
>
<FileUp className="size-3.5" />
{t("onboarding.source.import")}
</Link>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={advance}
disabled={advancing}
className="min-h-11 min-w-11"
>
{t("onboarding.shell.next")}
</Button>
</div>
</div>
</section>
);
}
interface SourceCardItemProps {
card: SourceCard;
selected: boolean;
onSelect: () => void;
titleLabel: string;
bodyLabel: string;
recommendedLabel: string | null;
withingsCtaLabel: string;
/** Whether the account already has its own Withings developer-app
* credentials saved — i.e. whether `/api/withings/connect` can
* actually succeed right now. */
withingsConfigured: boolean;
/** Whether Withings is already connected (OAuth completed in the tab the
* connect CTA opened). Replaces the connect button with a badge so
* returning to this wizard tab shows the outcome instead of an
* unchanged card. */
withingsConnected: boolean;
/** Localised "Connected" label for the badge above. */
withingsConnectedLabel: string;
/** CTA label shown instead of "Connect Withings" when unconfigured. */
withingsSetupCtaLabel: string;
/** One-sentence explanation of the BYO credential prerequisite. */
withingsSetupHint: string;
}
function SourceCardItem({
card,
selected,
onSelect,
titleLabel,
bodyLabel,
recommendedLabel,
withingsCtaLabel,
withingsConfigured,
withingsConnected,
withingsConnectedLabel,
withingsSetupCtaLabel,
withingsSetupHint,
}: SourceCardItemProps) {
const Icon = card.Icon;
const baseClasses = cn(
"relative flex flex-col gap-3 rounded-lg border p-4 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"bg-card border-border",
);
const headerRow = (
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-md",
selected
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
{recommendedLabel ? (
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tracking-wide uppercase">
{recommendedLabel}
</span>
) : null}
</div>
</div>
);
const body = (
<p className="text-muted-foreground text-sm leading-relaxed">{bodyLabel}</p>
);
if (card.state === "enabled-oauth") {
return (
<div
className={cn(
baseClasses,
selected && "border-primary bg-primary/5",
"hover:border-primary/50 focus-within:border-primary/50",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
{withingsConnected ? (
<div
className="text-primary flex items-center gap-1.5 pt-1 text-sm font-medium"
data-testid="source-card-withings-connected"
>
<CheckCircle2 className="size-3.5" aria-hidden="true" />
{withingsConnectedLabel}
</div>
) : withingsConfigured ? (
<div className="flex items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<a
href={card.href}
target="_blank"
rel="noopener"
onClick={onSelect}
className="inline-flex items-center gap-1.5"
>
<Wifi className="size-3.5" />
{withingsCtaLabel}
</a>
</Button>
</div>
) : (
// No per-user Withings credentials yet — an OAuth handshake here
// can only 400 (Withings has no env fallback). Point at the
// credentials form instead of launching a doomed connect flow.
<div className="space-y-2 pt-1">
<p className="text-muted-foreground text-xs">{withingsSetupHint}</p>
<Button asChild variant="outline" size="sm">
<Link
href="/settings/integrations#withings"
onClick={onSelect}
className="inline-flex items-center gap-1.5"
data-testid="source-card-withings-setup"
>
<Wifi className="size-3.5" />
{withingsSetupCtaLabel}
</Link>
</Button>
</div>
)}
</div>
);
}
// enabled-select (Manual)
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
baseClasses,
"hover:border-primary/50 cursor-pointer",
selected && "border-primary bg-primary/5",
)}
data-testid={`source-card-${card.slug}`}
>
{headerRow}
{body}
</button>
);
}
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* The action row every setup screen ends on: back on the left, skip and the
* primary on the right. One shape, so the buttons sit in the same place on
* every screen and a keyboard user's tab order never changes between them.
*
* Back is a link (a plain navigation to a screen that already has its
* answer), skip and next are buttons (they persist something first). Each
* one is optional: the welcome screen has no back, the confirm screen has no
* skip, the done screen has only the primary.
*/
export function StepActions({
backHref,
onSkip,
onNext,
nextLabel,
nextDisabled = false,
pending = false,
}: {
backHref?: string;
onSkip?: () => void;
onNext?: () => void;
nextLabel?: string;
nextDisabled?: boolean;
pending?: boolean;
}) {
const { t } = useTranslations();
return (
<div
className="flex items-center justify-between gap-2 pt-2"
data-slot="onboarding-step-actions"
>
{backHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={backHref} data-slot="onboarding-back">
{t("onboarding.shell.back")}
</Link>
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="flex items-center gap-2">
{onSkip ? (
<Button
type="button"
variant="ghost"
onClick={onSkip}
disabled={pending}
className="min-h-11 min-w-11"
data-slot="onboarding-skip"
>
{t("onboarding.shell.skip")}
</Button>
) : null}
{onNext ? (
<Button
type="button"
onClick={onNext}
disabled={pending || nextDisabled}
className="min-h-11 min-w-11"
data-slot="onboarding-next"
>
{pending ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
{nextLabel ?? t("onboarding.shell.next")}
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
/**
* The `h1` of every setup screen, and the one place focus goes when a screen
* arrives.
*
* Moving focus to the heading on each step is what lets a screen-reader user
* follow the flow: without it, a client-side navigation leaves focus on the
* button that was just pressed, which no longer exists, and the new question
* is never announced. `preventScroll` keeps the viewport where the layout
* put it — the heading is at the top of the column anyway.
*
* Semibold rather than the app-wide bold PageHeader H1: the onboarding hero
* is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold.
*/
export function StepHeading({
id,
title,
description,
}: {
id: string;
title: ReactNode;
description?: ReactNode;
}) {
const ref = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
ref.current?.focus({ preventScroll: true });
}, []);
return (
<header className="space-y-2">
<h1
ref={ref}
id={id}
tabIndex={-1}
className="text-2xl font-semibold tracking-tight outline-none"
>
{title}
</h1>
{description ? (
<p className="text-muted-foreground text-base leading-relaxed">
{description}
</p>
) : null}
</header>
);
}
@@ -0,0 +1,86 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import { queryKeys } from "@/lib/query-keys";
import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs";
interface OnboardingStateEnvelope {
onboarding: OnboardingStateDto;
}
/**
* The three writes the setup screens make, as mutations that hand back the
* published state.
*
* Every one of them returns the flow's state after the write, and every
* screen navigates from THAT rather than from what it thinks it just did —
* the step machine reads the same ledger the server wrote, so "where next"
* is never a client-side guess. `authMe` is invalidated because the account
* payload carries the same state and the checklist reads it from there.
*/
export function useOnboardingAnswer() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.onboardingAnswerMutation(),
mutationFn: async (input: OnboardingAnswerInput) => {
const { onboarding } = await apiPatch<OnboardingStateEnvelope>(
"/api/onboarding/answers",
input,
);
return onboarding;
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
}
interface CompleteEnvelope {
completed: true;
onboarding?: OnboardingStateDto;
}
/**
* `POST /api/onboarding/complete` — the confirm screen, and "skip for now".
*
* The body is always empty: profile values go through `PUT /api/auth/profile`
* (the canonical write, with its per-field refusals), never through the
* completion stamp's optional fields.
*/
export function useOnboardingComplete() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.onboardingCompleteMutation(),
mutationFn: async () =>
apiPost<CompleteEnvelope>("/api/onboarding/complete", {}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
}
/** `POST /api/onboarding/restart` — "Set up again". */
export function useOnboardingRestart() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.onboardingRestartMutation(),
mutationFn: async () => {
const { onboarding } = await apiPost<OnboardingStateEnvelope>(
"/api/onboarding/restart",
{},
);
return onboarding;
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
}
/** The route of one setup screen. */
export function screenHref(screen: string): string {
return screen === "welcome" ? "/onboarding" : `/onboarding/${screen}`;
}
@@ -1,340 +0,0 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
HeartPulse,
Plug,
Sparkles,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
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";
import {
DISCLAIMER_VERSION,
isDisclaimerAcknowledgmentCurrent,
} from "@/lib/onboarding/disclaimer";
/**
* v1.4.25 W14b-Content — onboarding welcome carousel.
*
* Three value-prop slides shown on `/onboarding/0` before any data is
* asked of the user. Mirrors the Withings / Strava pattern surfaced in
* the W14b research file (§2.1, §2.5): pitch the product, then ask.
*
* Slides:
* 1. Tagline — "Your health data, owned by you."
* 2. Multi-source — Apple Health + Withings + manual, one view.
* 3. AI Coach + Insights — evidence-grounded, your data only.
*
* The carousel is a CSS scroll-snap rail; arrow buttons + dot pager
* mirror the active slide. `prefers-reduced-motion` disables the smooth
* scroll behaviour (per WCAG 2.3.3 / research §4.6).
*
* The primary CTA ("Get started") POSTs `/api/onboarding/step` with
* `{ step: 1 }`. On success it invalidates the `auth` query (so the
* user-shape's `onboardingStep` re-reads) and pushes to `/onboarding/1`.
*/
interface CarouselSlide {
id: "tagline" | "sources" | "coach";
Icon: LucideIcon;
titleKey: string;
bodyKey: string;
}
const SLIDES: ReadonlyArray<CarouselSlide> = [
{
id: "tagline",
Icon: HeartPulse,
titleKey: "onboarding.welcome.slide1.title",
bodyKey: "onboarding.welcome.slide1.body",
},
{
id: "sources",
Icon: Plug,
titleKey: "onboarding.welcome.slide2.title",
bodyKey: "onboarding.welcome.slide2.body",
},
{
id: "coach",
Icon: Sparkles,
titleKey: "onboarding.welcome.slide3.title",
bodyKey: "onboarding.welcome.slide3.body",
},
];
export function WelcomeCarousel() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { user } = useAuth();
const railRef = useRef<HTMLDivElement | null>(null);
const liveRegionId = useId();
const disclaimerId = useId();
const [active, setActive] = useState(0);
const [advancing, setAdvancing] = useState(false);
// v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates
// "Get started". Pre-checked only when the account acknowledged the
// CURRENT disclaimer version (a re-walk of step 0), so a returning user is
// not re-asked — but a revised disclaimer re-prompts instead of riding an
// acknowledgment given for older text.
const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent(
user?.disclaimerAcknowledgedAt,
user?.disclaimerAcknowledgedVersion,
);
const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent);
// Track which slide is centred in the rail. We observe each slide
// with an IntersectionObserver — simpler and more reliable than
// computing scroll-positions, and it handles touch flings naturally.
useEffect(() => {
const rail = railRef.current;
if (!rail) return;
const slides = Array.from(
rail.querySelectorAll<HTMLElement>("[data-slide-index]"),
);
if (slides.length === 0) return;
const io = new IntersectionObserver(
(entries) => {
// Pick the entry whose intersection ratio is highest — that's
// the slide currently snapped into view.
let best: { idx: number; ratio: number } | null = null;
for (const e of entries) {
const idx = Number((e.target as HTMLElement).dataset.slideIndex);
if (!Number.isFinite(idx)) continue;
if (!best || e.intersectionRatio > best.ratio) {
best = { idx, ratio: e.intersectionRatio };
}
}
if (best && best.ratio > 0.5) {
setActive(best.idx);
}
},
{ root: rail, threshold: [0.5, 0.75, 1] },
);
for (const s of slides) io.observe(s);
return () => io.disconnect();
}, []);
const scrollToSlide = useCallback((idx: number) => {
const rail = railRef.current;
if (!rail) return;
const target = rail.querySelector<HTMLElement>(
`[data-slide-index="${idx}"]`,
);
if (!target) return;
target.scrollIntoView({
behavior: scrollBehaviorForUser(),
inline: "start",
block: "nearest",
});
}, []);
async function handleGetStarted() {
if (advancing || !acknowledged) return;
setAdvancing(true);
try {
// v1.18.6 (DISC-02) — record the acknowledgment before advancing.
// Skipped only when the account already acknowledged the CURRENT
// version (re-walk); an older-version acknowledgment re-records.
if (!disclaimerCurrent) {
await apiPost("/api/onboarding/disclaimer", {
version: DISCLAIMER_VERSION,
});
}
await apiPost("/api/onboarding/step", { step: 1 });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/1");
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setAdvancing(false);
}
}
return (
<section aria-labelledby="onboarding-welcome-title" className="space-y-6">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-welcome-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.welcome.title")}
</h1>
<div className="relative">
<div
ref={railRef}
role="region"
aria-roledescription="carousel"
aria-label={t("onboarding.welcome.carouselLabel")}
className={cn(
"flex w-full snap-x snap-mandatory gap-4 overflow-x-auto",
// Hide native scrollbar; the dot pager is the visible affordance.
"[scrollbar-width:none] scrollbar-none [-ms-overflow-style:none]",
"[&::-webkit-scrollbar]:hidden",
"scroll-smooth motion-reduce:scroll-auto",
)}
>
{SLIDES.map((slide, idx) => (
<article
key={slide.id}
data-slide-index={idx}
role="group"
aria-roledescription="slide"
aria-labelledby={`onboarding-welcome-slide-${slide.id}-title`}
className={cn(
"bg-card border-border flex w-full shrink-0 snap-start flex-col items-center gap-4",
"rounded-xl border p-6 text-center sm:p-8",
)}
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-16 items-center justify-center rounded-full"
>
<slide.Icon className="size-8" />
</span>
<h2
id={`onboarding-welcome-slide-${slide.id}-title`}
className="text-lg font-semibold tracking-tight"
>
{t(slide.titleKey)}
</h2>
<p className="text-foreground text-sm leading-relaxed">
{t(slide.bodyKey)}
</p>
</article>
))}
</div>
{/* Single live region announces slide changes to AT users —
slides themselves carry `aria-label="slide N of total"` so a
second sr-only mirror would double-announce on every move.
The carousel-level region wins because it's tied to the
chevron + dot pager via `aria-controls`. */}
<p id={liveRegionId} aria-live="polite" className="sr-only">
{t("onboarding.welcome.slideOf", {
current: active + 1,
total: SLIDES.length,
})}
</p>
</div>
<div className="flex items-center justify-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => scrollToSlide(Math.max(0, active - 1))}
disabled={active === 0}
aria-label={t("onboarding.welcome.prevSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronLeft className="size-4" />
</Button>
<div
role="tablist"
aria-label={t("onboarding.welcome.carouselLabel")}
className="flex items-center"
>
{SLIDES.map((slide, idx) => (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={active === idx}
aria-label={t("onboarding.welcome.gotoSlide", {
index: idx + 1,
})}
onClick={() => scrollToSlide(idx)}
className="inline-flex size-11 items-center justify-center"
>
<span
aria-hidden="true"
className={cn(
"size-2.5 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
active === idx
? "bg-primary"
: "bg-muted hover:bg-muted-foreground/30",
)}
/>
</button>
))}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => scrollToSlide(Math.min(SLIDES.length - 1, active + 1))}
disabled={active === SLIDES.length - 1}
aria-label={t("onboarding.welcome.nextSlide")}
aria-controls={liveRegionId}
className="min-h-11 min-w-11"
>
<ChevronRight className="size-4" />
</Button>
</div>
{/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This
replaces the per-page / per-chart disclaimer banners removed app-wide
in the same release; the full legal text stays reachable on the
public privacy page (link below). */}
<div className="border-border/60 bg-muted/30 flex items-start gap-3 rounded-lg border p-4">
<Checkbox
id={disclaimerId}
checked={acknowledged}
onCheckedChange={(next) => setAcknowledged(next === true)}
className="mt-0.5"
/>
<label
htmlFor={disclaimerId}
className="text-foreground text-sm leading-relaxed"
>
{t("onboarding.disclaimer.acknowledge")}{" "}
<Link
href="/privacy#medical-boundary"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{t("onboarding.disclaimer.learnMore")}
</Link>
</label>
</div>
<div className="flex justify-end pt-2">
<Button
type="button"
size="lg"
onClick={handleGetStarted}
disabled={advancing || !acknowledged}
className="min-h-11"
>
{t("onboarding.welcome.cta")}
</Button>
</div>
</section>
);
}
@@ -0,0 +1,193 @@
"use client";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { StepHeading } from "@/components/onboarding/step-heading";
import {
screenHref,
useOnboardingComplete,
useOnboardingRestart,
} from "@/components/onboarding/use-onboarding-flow";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import {
DISCLAIMER_VERSION,
isDisclaimerAcknowledgmentCurrent,
} from "@/lib/onboarding/disclaimer";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.39 (C2) — the screen before the first question.
*
* One sentence on what happens and how long it takes, "Set up", and "Skip for
* now" (design spec §The screens, 1). The skip is a real exit: it stamps the
* account as past the first-run redirect and lands on the dashboard with the
* checklist open, and the questions stay one click away in Settings.
*
* The one-time medical disclaimer gates BOTH buttons. It used to gate only
* "Get started", which was fine while the wizard was the only way past this
* screen; a skip that reached the dashboard without it would be a way round
* the acknowledgment. Pre-checked only when the account acknowledged the
* CURRENT version, so a revised disclaimer re-prompts.
*
* `variant="again"` is the same screen for an account whose setup is
* finished: it offers the questions again (through the restart route, which
* resets the ledger and keeps the answers as prefill) or the dashboard.
*/
export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) {
const { t } = useTranslations();
const router = useRouter();
const { user } = useAuth();
const disclaimerId = useId();
const disclaimerCurrent = isDisclaimerAcknowledgmentCurrent(
user?.disclaimerAcknowledgedAt,
user?.disclaimerAcknowledgedVersion,
);
const [acknowledged, setAcknowledged] = useState(() => disclaimerCurrent);
const acknowledge = useMutation({
mutationKey: queryKeys.onboardingDisclaimerMutation(),
mutationFn: async () => {
if (disclaimerCurrent) return;
await apiPost("/api/onboarding/disclaimer", {
version: DISCLAIMER_VERSION,
});
},
});
const complete = useOnboardingComplete();
const restart = useOnboardingRestart();
const pending =
acknowledge.isPending || complete.isPending || restart.isPending;
const fail = (err: unknown) =>
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
async function setUp() {
if (pending || !acknowledged) return;
try {
await acknowledge.mutateAsync();
router.push(screenHref("who"));
} catch (err) {
fail(err);
}
}
async function skipForNow() {
if (pending || !acknowledged) return;
try {
await acknowledge.mutateAsync();
await complete.mutateAsync();
markChecklistExpanded();
router.push("/");
} catch (err) {
fail(err);
}
}
async function setUpAgain() {
if (pending) return;
try {
await restart.mutateAsync();
router.push(screenHref("who"));
} catch (err) {
fail(err);
}
}
if (variant === "again") {
return (
<section aria-labelledby="onboarding-welcome-title" className="space-y-6">
<StepHeading
id="onboarding-welcome-title"
title={t("onboarding.welcomeBack.title")}
description={t("onboarding.flow.welcome.againBody")}
/>
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
<Button asChild variant="outline" className="min-h-11">
<Link href="/">{t("onboarding.welcomeBack.cta")}</Link>
</Button>
<Button
type="button"
onClick={setUpAgain}
disabled={pending}
className="min-h-11"
data-slot="onboarding-set-up-again"
>
{t("onboarding.flow.welcome.setUpAgain")}
</Button>
</div>
</section>
);
}
return (
<section aria-labelledby="onboarding-welcome-title" className="space-y-6">
<StepHeading
id="onboarding-welcome-title"
title={t("onboarding.flow.welcome.title")}
description={t("onboarding.flow.welcome.body")}
/>
<p className="text-sm">{t("onboarding.flow.welcome.changeLater")}</p>
{/* One-time medical-disclaimer acknowledgment; the full text stays
reachable on the public privacy page. */}
<div className="border-border/60 bg-muted/30 flex items-start gap-3 rounded-lg border p-4">
<Checkbox
id={disclaimerId}
checked={acknowledged}
onCheckedChange={(next) => setAcknowledged(next === true)}
className="mt-0.5"
data-slot="onboarding-disclaimer"
/>
<label
htmlFor={disclaimerId}
className="text-foreground text-sm leading-relaxed"
>
{t("onboarding.disclaimer.acknowledge")}{" "}
<Link
href="/privacy#medical-boundary"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{t("onboarding.disclaimer.learnMore")}
</Link>
</label>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
onClick={skipForNow}
disabled={pending || !acknowledged}
className="min-h-11"
data-slot="onboarding-skip-for-now"
>
{t("onboarding.flow.welcome.skip")}
</Button>
<Button
type="button"
size="lg"
onClick={setUp}
disabled={pending || !acknowledged}
className="min-h-11"
data-slot="onboarding-set-up"
>
{t("onboarding.flow.welcome.setUp")}
</Button>
</div>
</section>
);
}