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

+25 -1
View File
@@ -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": {
@@ -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 },
-182
View File
@@ -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 (
<OnboardingShell step={0} userLocale={user.locale ?? null}>
{completed ? (
<section
aria-labelledby="onboarding-welcomeback-title"
className="space-y-4"
>
{/* 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-welcomeback-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.welcomeBack.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.welcomeBack.body")}
</p>
<div className="flex justify-end">
<Button asChild size="lg">
<Link href="/">{t("onboarding.welcomeBack.cta")}</Link>
</Button>
</div>
</section>
) : (
<WelcomeCarousel />
)}
</OnboardingShell>
);
}
// 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 (
<OnboardingShell step={1} userLocale={user.locale ?? null}>
<GoalsChipPicker userId={user.id} />
</OnboardingShell>
);
}
// 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 (
<OnboardingShell step={2} userLocale={user.locale ?? null}>
<SourceCardGrid />
</OnboardingShell>
);
}
// Baseline (step 3) — profile form. PUT /api/auth/profile saves the
// four fields, then POST step:4 advances + flips
// `onboardingCompletedAt`.
if (requested === 3) {
return (
<OnboardingShell step={3} userLocale={user.locale ?? null}>
<BaselineForm />
</OnboardingShell>
);
}
// 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 (
<OnboardingShell step={4} userLocale={user.locale ?? null}>
<DoneScreen />
</OnboardingShell>
);
}
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;
}
+101 -56
View File
@@ -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<Record<OnboardingStepId, OnboardingStepStatus>> = {},
needs: Partial<OnboardingStateDto["needs"]> = {},
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<string> {
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("<OnboardingRootPage> 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("<OnboardingRootPage>", () => {
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/<current>", 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/<current>", 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<OnboardingStepId, OnboardingStepStatus>;
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");
});
});
+4 -7
View File
@@ -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,
+35 -30
View File
@@ -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/<current>` 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 (
<OnboardingShell screen="welcome" state={state} userLocale={userLocale}>
<WelcomeScreen variant="again" />
</OnboardingShell>
);
}
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 (
<OnboardingShell screen="welcome" state={state} userLocale={userLocale}>
<WelcomeScreen variant="fresh" />
</OnboardingShell>
);
}
@@ -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>
);
}
@@ -360,6 +360,11 @@ describe("canVisitScreen", () => {
expect(canVisitScreen(midway, "done")).toBe(false);
});
it("always admits the first question, which is where the welcome screen goes", () => {
expect(canVisitScreen(state(), "who")).toBe(true);
expect(canVisitScreen(state(), "areas")).toBe(false);
});
it("refuses a screen the order does not contain", () => {
const noUnits = state(QUESTIONS_DONE, {
recordTarget: "me",
+56
View File
@@ -0,0 +1,56 @@
/**
* The getting-started checklist's per-browser state.
*
* Three keys, all of them conveniences rather than record: which rows this
* browser hid, whether the whole card was hidden, and whether it is open.
* They were private to the card until the setup flow needed two of them —
* the done screen and "skip for now" open the list, and Settings brings a
* hidden list back — and a second copy of a storage key is how two surfaces
* end up writing different ones.
*
* Every access is wrapped: storage can be full, disabled, or absent, and the
* card renders correctly with no stored value.
*/
export const CHECKLIST_DISMISSED_ITEMS_KEY =
"healthlog-getting-started-dismissed";
export const CHECKLIST_DISMISSED_ALL_KEY = "healthlog-getting-started-hidden";
export const CHECKLIST_EXPANDED_KEY = "healthlog-getting-started-expanded";
function storage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
/** Open the checklist on the next dashboard visit. */
export function markChecklistExpanded(): void {
try {
storage()?.setItem(CHECKLIST_EXPANDED_KEY, "1");
} catch {
/* storage may be full or disabled */
}
}
/**
* "Show the checklist again": forget every dismissal and open the list.
* Returns whether anything was stored to forget, so the caller can say
* "it is back" rather than "done" to somebody who never hid it.
*/
export function resetChecklistDismissals(): boolean {
const store = storage();
if (!store) return false;
try {
const hadState =
store.getItem(CHECKLIST_DISMISSED_ALL_KEY) === "1" ||
(store.getItem(CHECKLIST_DISMISSED_ITEMS_KEY) ?? "[]") !== "[]";
store.removeItem(CHECKLIST_DISMISSED_ITEMS_KEY);
store.removeItem(CHECKLIST_DISMISSED_ALL_KEY);
store.setItem(CHECKLIST_EXPANDED_KEY, "1");
return hadState;
} catch {
return false;
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.39 (C2) — the setup flow's state, for the server-rendered pages.
*
* One read per request: the page that guards a URL and the metadata that
* titles it both ask for the same row, and `cache` makes the second ask free.
* The DTO is the same one `GET /api/auth/me` publishes (`toOnboardingStateDto`
* with the account's own unit columns), so the screen the server paints and
* the screen the client reasons about after hydration are built from one
* shape.
*
* The caller's OWN record, always. The flow's three writes refuse under an
* acting-account switch, so a page that read the switched record's state would
* show questions nobody could answer.
*/
import { cache } from "react";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { readHeldUnitPreferences, type OnboardingStateDto } from "./needs";
import { loadOnboardingRecordRow, toOnboardingStateDto } from "./needs-store";
export interface OnboardingFlowPageState {
userId: string;
userLocale: string | null;
state: OnboardingStateDto;
}
export const loadOnboardingFlowState = cache(
async (): Promise<OnboardingFlowPageState | null> => {
const session = await getSession();
if (!session) return null;
const { user } = session;
const row = await loadOnboardingRecordRow(prisma, user.id);
return {
userId: user.id,
userLocale: user.locale ?? null,
state: toOnboardingStateDto(row, readHeldUnitPreferences(user)),
};
},
);
+5 -1
View File
@@ -211,6 +211,10 @@ export function previousScreen(
* actually is. A screen the order does not contain (units for someone who
* ticked no unit-bearing area) is refused too, so a stale link cannot show
* a question whose answer would mean nothing.
*
* The first question is the one exception: it is where the welcome screen's
* "Set up" goes, and a record that has not entered the flow resumes on the
* welcome, so without this arm nobody could ever start.
*/
export function canVisitScreen(
state: FlowState,
@@ -219,7 +223,7 @@ export function canVisitScreen(
const order = screenOrder(state);
const index = order.indexOf(screen);
if (index === -1) return false;
return index <= order.indexOf(resumeScreen(state));
return index <= Math.max(order.indexOf(resumeScreen(state)), 1);
}
/**