diff --git a/messages/de.json b/messages/de.json
index 2afa2b4f1..435a84881 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "Deine erste Messung",
- "body": "Eine Messung erweckt das Dashboard zum Leben. Jetzt eintragen oder später nachholen.",
- "placeholder": "Eingabe der Erstmessung folgt."
+ "title": "Kurz zu dir",
+ "body": "Vier optionale Angaben verbessern Zielwerte, BMI und Erinnerungen. Du kannst alles später in den Einstellungen anpassen.",
+ "placeholder": "Eingabe der Erstmessung folgt.",
+ "displayNameLabel": "Anzeigename",
+ "displayNamePlaceholder": "Wie soll dich HealthLog ansprechen?",
+ "displayNameHint": "Erscheint im Dashboard-Gruß und in Berichten.",
+ "heightLabel": "Größe (cm)",
+ "genderLabel": "Geschlecht",
+ "genderNone": "Keine Angabe",
+ "genderMale": "Männlich",
+ "genderFemale": "Weiblich",
+ "dateOfBirthLabel": "Geburtsdatum",
+ "dateOfBirthHint": "Wird ausschließlich für altersbezogene Auswertungen genutzt.",
+ "saveCta": "Speichern und weiter"
},
"done": {
"title": "Alles bereit",
"body": "Willkommen an Bord. Dein Dashboard wartet.",
"returnCta": "Zum Dashboard"
+ },
+ "welcomeBack": {
+ "title": "Willkommen zurück",
+ "body": "Das Onboarding hast du bereits abgeschlossen. Möchtest du etwas anpassen, findest du alles in den Einstellungen.",
+ "cta": "Zum Dashboard"
}
},
"gettingStarted": {
diff --git a/messages/en.json b/messages/en.json
index aa6d4670f..2fa876bd5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "Your first reading",
- "body": "One reading turns the dashboard on. Add it now or skip and log later.",
- "placeholder": "Baseline entry coming soon."
+ "title": "A bit about you",
+ "body": "Four optional fields sharpen target ranges, BMI and reminders. You can adjust any of them later in Settings.",
+ "placeholder": "Baseline entry coming soon.",
+ "displayNameLabel": "Display name",
+ "displayNamePlaceholder": "How should HealthLog address you?",
+ "displayNameHint": "Shown on the dashboard greeting and in reports.",
+ "heightLabel": "Height (cm)",
+ "genderLabel": "Gender",
+ "genderNone": "Prefer not to say",
+ "genderMale": "Male",
+ "genderFemale": "Female",
+ "dateOfBirthLabel": "Date of birth",
+ "dateOfBirthHint": "Used only for age-aware analytics.",
+ "saveCta": "Save and continue"
},
"done": {
"title": "You're all set",
"body": "Welcome aboard. Your dashboard is ready.",
"returnCta": "Open dashboard"
+ },
+ "welcomeBack": {
+ "title": "Welcome back",
+ "body": "You've already finished onboarding. To adjust anything, head to Settings — everything you set up is editable from there.",
+ "cta": "Open dashboard"
}
},
"gettingStarted": {
diff --git a/messages/es.json b/messages/es.json
index 6f7ad5582..03f54c854 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "Tu primera medición",
- "body": "Una medición activa el panel. Añádela ahora o regístrala más adelante.",
- "placeholder": "Entrada inicial próximamente."
+ "title": "Cuéntanos sobre ti",
+ "body": "Cuatro campos opcionales afinan los valores objetivo, el IMC y los recordatorios. Podrás ajustarlo todo más tarde desde Ajustes.",
+ "placeholder": "Entrada inicial próximamente.",
+ "displayNameLabel": "Nombre para mostrar",
+ "displayNamePlaceholder": "¿Cómo debería llamarte HealthLog?",
+ "displayNameHint": "Aparece en el saludo del panel y en los informes.",
+ "heightLabel": "Altura (cm)",
+ "genderLabel": "Sexo",
+ "genderNone": "Prefiero no decirlo",
+ "genderMale": "Hombre",
+ "genderFemale": "Mujer",
+ "dateOfBirthLabel": "Fecha de nacimiento",
+ "dateOfBirthHint": "Se utiliza solo para análisis sensibles a la edad.",
+ "saveCta": "Guardar y continuar"
},
"done": {
"title": "Todo listo",
"body": "Bienvenido a bordo. Tu panel ya está preparado.",
"returnCta": "Abrir el panel"
+ },
+ "welcomeBack": {
+ "title": "Bienvenido de nuevo",
+ "body": "Ya completaste la configuración inicial. Para cambiar algo, abre Ajustes — todo lo que configuraste se puede editar desde ahí.",
+ "cta": "Abrir el panel"
}
},
"gettingStarted": {
diff --git a/messages/fr.json b/messages/fr.json
index fee40d516..ef71cfcb3 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "Votre première mesure",
- "body": "Une seule mesure active le tableau de bord. Ajoutez-la maintenant ou plus tard.",
- "placeholder": "Saisie initiale à venir."
+ "title": "Quelques informations",
+ "body": "Quatre champs facultatifs affinent vos seuils cibles, l'IMC et les rappels. Tout reste modifiable plus tard dans les paramètres.",
+ "placeholder": "Saisie initiale à venir.",
+ "displayNameLabel": "Nom d'affichage",
+ "displayNamePlaceholder": "Comment HealthLog doit-il vous appeler ?",
+ "displayNameHint": "Apparaît dans le message d'accueil du tableau de bord et dans les rapports.",
+ "heightLabel": "Taille (cm)",
+ "genderLabel": "Genre",
+ "genderNone": "Préfère ne pas le préciser",
+ "genderMale": "Homme",
+ "genderFemale": "Femme",
+ "dateOfBirthLabel": "Date de naissance",
+ "dateOfBirthHint": "Utilisée uniquement pour les analyses qui tiennent compte de l'âge.",
+ "saveCta": "Enregistrer et continuer"
},
"done": {
"title": "Tout est prêt",
"body": "Bienvenue à bord. Votre tableau de bord vous attend.",
"returnCta": "Ouvrir le tableau de bord"
+ },
+ "welcomeBack": {
+ "title": "Bon retour",
+ "body": "Vous avez déjà terminé la configuration. Pour modifier un élément, ouvrez les paramètres — tout reste éditable.",
+ "cta": "Ouvrir le tableau de bord"
}
},
"gettingStarted": {
diff --git a/messages/it.json b/messages/it.json
index 6efa7e6af..eb1c10a57 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "La tua prima misurazione",
- "body": "Una sola misurazione accende la dashboard. Aggiungila ora o registrala più tardi.",
- "placeholder": "Inserimento iniziale in arrivo."
+ "title": "Qualcosa su di te",
+ "body": "Quattro campi facoltativi affinano gli intervalli, l'IMC e i promemoria. Puoi modificarli tutti in qualsiasi momento dalle impostazioni.",
+ "placeholder": "Inserimento iniziale in arrivo.",
+ "displayNameLabel": "Nome visualizzato",
+ "displayNamePlaceholder": "Come deve chiamarti HealthLog?",
+ "displayNameHint": "Compare nel saluto della dashboard e nei report.",
+ "heightLabel": "Altezza (cm)",
+ "genderLabel": "Sesso",
+ "genderNone": "Preferisco non specificare",
+ "genderMale": "Maschile",
+ "genderFemale": "Femminile",
+ "dateOfBirthLabel": "Data di nascita",
+ "dateOfBirthHint": "Usata solo per le analisi che tengono conto dell'età.",
+ "saveCta": "Salva e continua"
},
"done": {
"title": "Tutto pronto",
"body": "Benvenuto a bordo. La tua dashboard è pronta.",
"returnCta": "Apri la dashboard"
+ },
+ "welcomeBack": {
+ "title": "Bentornato",
+ "body": "Hai già completato l'onboarding. Per modificare qualcosa apri le impostazioni — tutto ciò che hai configurato è ancora modificabile.",
+ "cta": "Apri la dashboard"
}
},
"gettingStarted": {
diff --git a/messages/pl.json b/messages/pl.json
index 89c0f41ad..980acbe70 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1487,14 +1487,30 @@
}
},
"baseline": {
- "title": "Twój pierwszy pomiar",
- "body": "Jeden pomiar uruchamia pulpit. Dodaj go teraz albo zapisz później.",
- "placeholder": "Wprowadzenie pierwszego wpisu wkrótce."
+ "title": "Kilka słów o Tobie",
+ "body": "Cztery opcjonalne pola doprecyzują wartości docelowe, BMI i przypomnienia. Wszystko możesz później zmienić w ustawieniach.",
+ "placeholder": "Wprowadzenie pierwszego wpisu wkrótce.",
+ "displayNameLabel": "Nazwa wyświetlana",
+ "displayNamePlaceholder": "Jak HealthLog ma się do Ciebie zwracać?",
+ "displayNameHint": "Pojawia się w powitaniu na pulpicie i w raportach.",
+ "heightLabel": "Wzrost (cm)",
+ "genderLabel": "Płeć",
+ "genderNone": "Wolę nie podawać",
+ "genderMale": "Mężczyzna",
+ "genderFemale": "Kobieta",
+ "dateOfBirthLabel": "Data urodzenia",
+ "dateOfBirthHint": "Używana wyłącznie do analiz zależnych od wieku.",
+ "saveCta": "Zapisz i kontynuuj"
},
"done": {
"title": "Wszystko gotowe",
"body": "Witaj na pokładzie. Pulpit czeka na Ciebie.",
"returnCta": "Otwórz pulpit"
+ },
+ "welcomeBack": {
+ "title": "Witaj ponownie",
+ "body": "Wstępne kroki masz już za sobą. Aby coś zmienić, otwórz ustawienia — wszystko, co skonfigurowałeś, da się tam edytować.",
+ "cta": "Otwórz pulpit"
}
},
"gettingStarted": {
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index 2308a2586..cdf330960 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -1,9 +1,13 @@
import { redirect, notFound } from "next/navigation";
+import Link from "next/link";
import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel";
import { GoalsChipPicker } from "@/components/onboarding/GoalsChipPicker";
import { SourceCardGrid } from "@/components/onboarding/SourceCardGrid";
+import { BaselineForm } from "@/components/onboarding/BaselineForm";
+import { DoneScreen } from "@/components/onboarding/DoneScreen";
+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";
@@ -63,25 +67,58 @@ export default async function OnboardingStepPage({ params }: PageProps) {
}
const { user } = session;
- if (user.onboardingCompletedAt) {
+ 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 (requested > current) {
+ if (!completed && requested > current) {
redirect(`/onboarding/${current}`);
}
const locale = await resolveServerLocale({ userLocale: user.locale });
const { t } = getServerTranslator(locale);
- // Welcome (step 0) renders the client-side value-prop carousel.
- // The carousel owns its own primary CTA (POST /api/onboarding/step
- // with step:1) so the shell drops `nextHref` to avoid a double CTA.
+ // Welcome (step 0)
+ // * Fresh user → value-prop carousel.
+ // * Returning user (onboarding already completed) → welcome-back
+ // banner with "Open dashboard" CTA. Marc 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 (
-
+ {completed ? (
+
+
+ {t("onboarding.welcomeBack.title")}
+
+
+ {t("onboarding.welcomeBack.body")}
+
+
+
+
+
+ ) : (
+
+ )}
);
}
@@ -111,51 +148,23 @@ export default async function OnboardingStepPage({ params }: PageProps) {
);
}
- // Foundation placeholder branches — replaced one step at a time by
- // the W14b-Content agent. Each step's body resolves to the real i18n
- // copy already, so the page reads end-to-end even before the per-
- // step component lands.
- const bodyTitle = t(`onboarding.${stepKey(requested)}.title`);
- const bodyText = t(`onboarding.${stepKey(requested)}.body`);
-
- // Wire shell navigation by step. Step 0 has no Back; the wizard
- // never lets the user navigate forward past their current step, but
- // they can advance from the current step's "Next" button — the
- // POST /api/onboarding/step mutation flips `onboardingStep` and the
- // Content agent will redirect to the new step on success. The
- // Foundation scaffold uses plain hrefs so the shell renders end-to-
- // end immediately; the Content agent will replace the next link
- // with an action button bound to the API.
- const backHref = requested > 0 ? `/onboarding/${requested - 1}` : undefined;
- const skipHref = requested > 0 && requested < 4 ? "/" : undefined;
- const nextHref =
- requested < 4 ? `/onboarding/${requested + 1}` : "/";
+ // Baseline (step 3) — profile form. PUT /api/auth/profile saves the
+ // four fields, then POST step:4 advances + flips
+ // `onboardingCompletedAt`.
+ if (requested === 3) {
+ return (
+
+
+
+ );
+ }
+ // 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 (
-
-
-
- {bodyTitle}
-
-
- {bodyText}
-
-
-
+
+
);
}
@@ -167,18 +176,3 @@ function clampCurrentStep(value: number | null | undefined): Step {
if (floor >= 4) return 4;
return floor as Step;
}
-
-function stepKey(step: Step): "welcome" | "goals" | "source" | "baseline" | "done" {
- switch (step) {
- case 0:
- return "welcome";
- case 1:
- return "goals";
- case 2:
- return "source";
- case 3:
- return "baseline";
- case 4:
- return "done";
- }
-}
diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx
index 52ad27e70..1a43f2256 100644
--- a/src/app/onboarding/__tests__/page.test.tsx
+++ b/src/app/onboarding/__tests__/page.test.tsx
@@ -1,100 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-// Stub Next.js navigation — we never actually navigate in these tests.
+/**
+ * v1.4.25 W14b-Content — onboarding root redirect tests.
+ *
+ * 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.
+ */
+
+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;
+});
+
vi.mock("next/navigation", () => ({
- useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
+ redirect: (href: string) => redirectMock(href),
}));
-// Stub TanStack Query — the onboarding page only invalidates after
-// finish, which we don't trigger here.
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
- useQuery: () => ({ data: null, isLoading: false }),
+const getSessionMock = vi.fn();
+vi.mock("@/lib/auth/session", () => ({
+ getSession: () => getSessionMock(),
}));
-// Stub the toaster — fires post-finish only.
-vi.mock("sonner", () => ({
- toast: { success: vi.fn(), error: vi.fn() },
-}));
-
-// The Logo pulls in an SVG icon set we don't need to assert on.
-vi.mock("@/components/ui/logo", () => ({
- Logo: () => null,
-}));
-
-// MeasurementForm is independently tested. Stub it so step 2's render
-// doesn't drag the entire form's dependencies into this SSR test.
-vi.mock("@/components/measurements/measurement-form", () => ({
- MeasurementForm: () => null,
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import OnboardingPage from "../page";
-
-function render(locale: "en" | "de" = "en") {
- return renderToStaticMarkup(
-
-
- ,
- );
-}
+import OnboardingRootPage from "../page";
beforeEach(() => {
- vi.clearAllMocks();
+ redirectMock.mockClear();
+ getSessionMock.mockReset();
});
-describe(" v2", () => {
- it("renders three progress dots, not four", () => {
- const html = render();
- // The progress row holds exactly three dots — count `w-10` segments.
- const dots = html.match(/h-1\.5 w-10 rounded-full/g) ?? [];
- expect(dots.length).toBe(3);
+function makeSession(opts: {
+ onboardingStep?: number | null;
+ onboardingCompletedAt?: Date | null;
+}) {
+ return {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 86400000) },
+ user: {
+ id: "user-1",
+ onboardingStep: opts.onboardingStep ?? null,
+ onboardingCompletedAt: opts.onboardingCompletedAt ?? null,
+ },
+ };
+}
+
+async function runRedirect(): Promise {
+ try {
+ await OnboardingRootPage();
+ } catch (e) {
+ const tagged = e as Error & { __redirect__?: string };
+ if (tagged.__redirect__) return tagged.__redirect__;
+ throw e;
+ }
+ throw new Error("expected redirect, none thrown");
+}
+
+describe(" root redirect", () => {
+ it("redirects to /auth/login when no session", async () => {
+ getSessionMock.mockResolvedValueOnce(null);
+ const href = await runRedirect();
+ expect(href).toBe("/auth/login");
});
- it("shows the v2 welcome copy and step 1 of 3 indicator", () => {
- const html = render();
- expect(html).toContain("Welcome to HealthLog");
- expect(html).toContain("Step 1 of 3");
- expect(html).toContain("About you");
+ it("redirects fresh user (onboardingStep null) to /onboarding/0", async () => {
+ getSessionMock.mockResolvedValueOnce(makeSession({}));
+ const href = await runRedirect();
+ expect(href).toBe("/onboarding/0");
});
- it("renders the German title when locale is de", () => {
- const html = render("de");
- expect(html).toContain("Willkommen bei HealthLog");
- expect(html).toContain("Schritt 1 von 3");
+ it("redirects mid-flow user to /onboarding/", async () => {
+ getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 2 }));
+ const href = await runRedirect();
+ expect(href).toBe("/onboarding/2");
});
- it("includes a single skip link with the descriptive label", () => {
- const html = render();
- const skipMatches =
- html.match(
- /Skip this step — you can finish setup later from Settings./g,
- ) ?? [];
- // Exactly one skip control on step 1; second skip from the legacy
- // wizard is gone.
- expect(skipMatches.length).toBe(1);
+ 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("does not show a Back button on step 1 (first step)", () => {
- const html = render();
- expect(html).not.toContain(">Back<");
- });
-
- it("provides every step-1 form field via accessible labels", () => {
- const html = render();
- expect(html).toContain('for="ob-display-name"');
- expect(html).toContain('for="ob-language"');
- expect(html).toContain('for="ob-height"');
- expect(html).toContain('for="ob-gender"');
- expect(html).toContain('for="ob-dob"');
- });
-
- it("step-1 progressbar has correct aria attributes", () => {
- const html = render();
- expect(html).toContain('role="progressbar"');
- expect(html).toContain('aria-valuemin="1"');
- expect(html).toContain('aria-valuemax="3"');
- expect(html).toContain('aria-valuenow="1"');
+ it("redirects a completed user to /onboarding/", 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");
});
});
diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx
index 19547818c..680293ecc 100644
--- a/src/app/onboarding/page.tsx
+++ b/src/app/onboarding/page.tsx
@@ -1,471 +1,44 @@
-"use client";
+import { redirect } from "next/navigation";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import {
- ArrowLeft,
- ArrowRight,
- Bell,
- CheckCircle2,
- Loader2,
- MessageCircle,
- Smartphone,
-} from "lucide-react";
+import { getSession } from "@/lib/auth/session";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { DateInput } from "@/components/ui/date-input";
-import { Label } from "@/components/ui/label";
-import { Logo } from "@/components/ui/logo";
-import { MeasurementForm } from "@/components/measurements/measurement-form";
-import { tourReferrerKey } from "@/components/onboarding/tour-launcher";
-import { useAuth } from "@/hooks/use-auth";
-import { useTranslations } from "@/lib/i18n/context";
-import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
-import { cn } from "@/lib/utils";
-
-const TOTAL_STEPS = 3;
-
-type ChannelChoice = "TELEGRAM" | "WEB_PUSH" | "NTFY" | "NONE";
-
-const CHANNEL_OPTIONS: ReadonlyArray<{
- id: ChannelChoice;
- Icon: typeof MessageCircle;
- titleKey: string;
- hintKey: string;
- /**
- * Hash anchor on the notifications settings page to scroll to. NONE = skip,
- * no hash. The wizard pushes the user to `/settings/notifications#`
- * after onboarding finishes — see the v1.4 settings split (PR A2-shell)
- * for the new route shape.
- */
- hash: string | null;
-}> = [
- {
- id: "TELEGRAM",
- Icon: MessageCircle,
- titleKey: "onboarding.v2.step3.channelTelegramTitle",
- hintKey: "onboarding.v2.step3.channelTelegramHint",
- hash: "telegram",
- },
- {
- id: "WEB_PUSH",
- Icon: Bell,
- titleKey: "onboarding.v2.step3.channelWebPushTitle",
- hintKey: "onboarding.v2.step3.channelWebPushHint",
- hash: "web-push",
- },
- {
- id: "NTFY",
- Icon: Smartphone,
- titleKey: "onboarding.v2.step3.channelNtfyTitle",
- hintKey: "onboarding.v2.step3.channelNtfyHint",
- hash: "ntfy",
- },
- {
- id: "NONE",
- Icon: CheckCircle2,
- titleKey: "onboarding.v2.step3.channelNoneTitle",
- hintKey: "onboarding.v2.step3.channelNoneHint",
- hash: null,
- },
-];
-
-export default function OnboardingPage() {
- const router = useRouter();
- const queryClient = useQueryClient();
- const { t, locale, setLocale } = useTranslations();
- const { user } = useAuth();
-
- const [step, setStep] = useState<1 | 2 | 3>(1);
- const [saving, setSaving] = useState(false);
-
- // Step 1 — about you
- const [displayName, setDisplayName] = useState("");
- const [heightCm, setHeightCm] = useState("");
- const [dateOfBirth, setDateOfBirth] = useState("");
- const [gender, setGender] = useState("");
-
- // Step 2 — first measurement is captured by ``
- // directly; we just track whether the user has already submitted one
- // so the UI can switch to a "got it" state.
- const [measurementLogged, setMeasurementLogged] = useState(false);
-
- // Step 3 — preferred notification channel.
- const [channelChoice, setChannelChoice] = useState("NONE");
-
- function goBack() {
- setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2) : 1));
+/**
+ * v1.4.25 W14b — onboarding root redirect.
+ *
+ * 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:
+ *
+ * - 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/` where
+ * `current = user.onboardingStep ?? 0`.
+ *
+ * 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.
+ */
+export default async function OnboardingRootPage() {
+ const session = await getSession();
+ if (!session) {
+ redirect("/auth/login");
}
+ const { user } = session;
- /**
- * Persist the profile + complete onboarding. Always called once at
- * the end of the wizard — independent of which steps were skipped —
- * so `onboardingCompletedAt` is set even if the user skips
- * everything except the wizard click-through.
- */
- async function persistAndExit(opts?: { gotoSettingsHash?: string | null }) {
- setSaving(true);
- try {
- const body: Record = {};
- if (displayName.trim()) body.displayName = displayName.trim();
- if (heightCm) body.heightCm = parseFloat(heightCm);
- if (dateOfBirth) body.dateOfBirth = dateOfBirth;
- if (gender) body.gender = gender;
-
- const res = await fetch("/api/onboarding/complete", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) throw new Error(t("onboarding.errorProfile"));
-
- await queryClient.invalidateQueries({ queryKey: ["auth"] });
- toast.success(t("onboarding.v2.doneToast"));
-
- // v1.4.15 Phase B5: leave a presence flag so the dashboard's
- // tour launcher knows the user JUST came from the wizard and
- // delays the spotlight by ~1.5s. Avoids the doneToast and
- // tour overlay stacking on the same z-layer. We use a
- // presence-only `"1"` (not a timestamp) so the launcher's
- // render-phase decision can stay pure under
- // `react-hooks/purity` — the launcher consumes-and-clears the
- // key on first read.
- //
- // v1.4.15 H4: key is scoped by user id so a shared browser
- // (admin impersonation, family laptop) keeps each user's
- // wizard-referrer state independent. Skip the write entirely
- // if the auth payload hasn't loaded — the worst-case is the
- // tour pops up immediately instead of after the 1.5s grace
- // window, which is harmless.
- if (user?.id) {
- try {
- window.sessionStorage.setItem(tourReferrerKey(user.id), "1");
- } catch {
- /* ignore — storage disabled or full */
- }
- }
-
- if (opts?.gotoSettingsHash) {
- router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
- } else {
- router.replace("/");
- }
- } catch (err) {
- const message =
- err instanceof Error ? err.message : t("onboarding.errorGeneric");
- toast.error(message);
- setSaving(false);
- }
- }
-
- function nextStep() {
- if (step < TOTAL_STEPS) {
- setStep((step + 1) as 1 | 2 | 3);
- }
- }
-
- function finish() {
- const target =
- channelChoice !== "NONE"
- ? (CHANNEL_OPTIONS.find((c) => c.id === channelChoice)?.hash ?? null)
- : null;
- void persistAndExit({ gotoSettingsHash: target });
- }
-
- return (
-
-
- {/* Single Skip per step. Step 3's "NONE" choice already covers
- skipping notifications, so we hide the underline on step 3. */}
- {step < TOTAL_STEPS ? (
-
- ) : null}
-
- );
+ const current = clampCurrentStep(user.onboardingStep);
+ redirect(`/onboarding/${current}`);
+}
+
+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;
}
diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/BaselineForm.tsx
new file mode 100644
index 000000000..27815b38f
--- /dev/null
+++ b/src/components/onboarding/BaselineForm.tsx
@@ -0,0 +1,237 @@
+"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 { Loader2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { DateInput } from "@/components/ui/date-input";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.4.25 W14b-Content — onboarding step 3 (baseline).
+ *
+ * Captures the four profile fields the original v1.4.20 wizard
+ * collected on its "About you" screen — display name, height, date of
+ * birth, gender — but spread across a single mobile-friendly card
+ * instead of the older grid layout. The legacy `/api/onboarding/complete`
+ * endpoint is *not* used here: completion now flips on the new
+ * `POST /api/onboarding/step` with `{ step: 4 }`. Profile fields are
+ * persisted via `PUT /api/auth/profile`, the existing canonical write
+ * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`).
+ *
+ * Submit flow on "Save and continue":
+ * 1. PUT profile (best-effort — empty fields are skipped).
+ * 2. POST step:4 — flips `onboardingCompletedAt` server-side and
+ * clears the proxy cookie.
+ * 3. router.push("/onboarding/4") — the done screen.
+ *
+ * "Skip" advances without writing profile data; the wizard still
+ * completes onboarding (the user can fill profile later from
+ * Settings).
+ */
+
+interface BaselineFormState {
+ displayName: string;
+ heightCm: string;
+ dateOfBirth: string;
+ gender: string;
+}
+
+const EMPTY_FORM: BaselineFormState = {
+ displayName: "",
+ heightCm: "",
+ dateOfBirth: "",
+ gender: "",
+};
+
+export function BaselineForm() {
+ const { t } = useTranslations();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [saving, setSaving] = useState(false);
+
+ function patch(
+ key: K,
+ value: BaselineFormState[K],
+ ) {
+ setForm((prev) => ({ ...prev, [key]: value }));
+ }
+
+ async function advance(opts: { saveProfile: boolean }) {
+ if (saving) return;
+ setSaving(true);
+ try {
+ if (opts.saveProfile) {
+ const profileBody: Record = {};
+ if (form.displayName.trim())
+ profileBody.displayName = form.displayName.trim();
+ if (form.heightCm) {
+ const n = Number.parseFloat(form.heightCm);
+ if (Number.isFinite(n)) profileBody.heightCm = n;
+ }
+ if (form.dateOfBirth) profileBody.dateOfBirth = form.dateOfBirth;
+ if (form.gender === "MALE" || form.gender === "FEMALE") {
+ profileBody.gender = form.gender;
+ }
+ if (Object.keys(profileBody).length > 0) {
+ const profileRes = await fetch("/api/auth/profile", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(profileBody),
+ });
+ if (!profileRes.ok) throw new Error(await readError(profileRes));
+ }
+ }
+ const stepRes = await fetch("/api/onboarding/step", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ step: 4 }),
+ });
+ if (!stepRes.ok) throw new Error(await readError(stepRes));
+ await queryClient.invalidateQueries({ queryKey: ["auth"] });
+ router.push("/onboarding/4");
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : t("onboarding.errorGeneric");
+ toast.error(message);
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ {t("onboarding.baseline.title")}
+
+
+ {t("onboarding.baseline.body")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+async function readError(res: Response): Promise {
+ try {
+ const json = (await res.json()) as { error?: string };
+ if (typeof json.error === "string" && json.error.length > 0) {
+ return json.error;
+ }
+ } catch {
+ /* fall through */
+ }
+ return `Request failed (${res.status})`;
+}
diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx
new file mode 100644
index 000000000..ec3bbe864
--- /dev/null
+++ b/src/components/onboarding/DoneScreen.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import Link from "next/link";
+import { CheckCircle2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.4.25 W14b-Content — onboarding step 4 (done).
+ *
+ * Confirms the user has finished onboarding. `onboardingCompletedAt`
+ * was flipped server-side by the baseline form's `POST step:4` write,
+ * so this page is purely presentational — no further mutation, no
+ * cookie flips, no API calls.
+ *
+ * The single CTA returns the user to the dashboard. The link goes
+ * through `next/link` so the regular client-side navigation kicks in
+ * (the proxy redirect was cleared with the same step-API write that
+ * landed the user here).
+ */
+
+export function DoneScreen() {
+ const { t } = useTranslations();
+
+ return (
+
+
+
+
+
+
+