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

-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>
);
}