From b896622d2262cc0acdb31c118fe2f29234a167ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 20:08:19 +0200 Subject: [PATCH] feat(onboarding): baseline + done steps + welcome-back banner + entry-point swap --- messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/pl.json | 22 +- src/app/onboarding/[step]/page.tsx | 122 +++-- src/app/onboarding/__tests__/page.test.tsx | 159 +++---- src/app/onboarding/page.tsx | 505 ++------------------- src/components/onboarding/BaselineForm.tsx | 237 ++++++++++ src/components/onboarding/DoneScreen.tsx | 56 +++ 11 files changed, 584 insertions(+), 627 deletions(-) create mode 100644 src/components/onboarding/BaselineForm.tsx create mode 100644 src/components/onboarding/DoneScreen.tsx 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 ( -
-
- -

- {t("onboarding.v2.title")} -

-

- {t("onboarding.v2.subtitle")} -

-
- - {/* Progress indicator — labelled for screen readers */} -
- {Array.from({ length: TOTAL_STEPS }, (_, i) => ( -
- ))} -
-

- {t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })} -

- - {/* ───── Step 1 — about you ───── */} - {step === 1 && ( -
-
-

- {t("onboarding.v2.step1.title")} -

-

- {t("onboarding.v2.step1.description")} -

-
- -
- - setDisplayName(e.target.value)} - autoComplete="nickname" - placeholder={t("settings.username")} - maxLength={50} - /> -

- {t("onboarding.v2.step1.displayNameHint")} -

-
- -
- - -

- {t("onboarding.v2.step1.languageHint")} -

-
- -
-
- - setHeightCm(e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} - autoComplete="off" - /> -
-
- - -
-
- -
- - setDateOfBirth(e.target.value)} - max={new Date().toISOString().slice(0, 10)} - autoComplete="bday" - /> -

- {t("settings.dateOfBirthHint")} -

-
-
- )} - - {/* ───── Step 2 — first measurement ───── */} - {step === 2 && ( -
-
-

- {t("onboarding.v2.step2.title")} -

-

- {t("onboarding.v2.step2.description")} -

-
- - {measurementLogged ? ( -
- -

{t("onboarding.v2.step2.added")}

-
- ) : ( - setMeasurementLogged(true)} /> - )} - -

- {t("onboarding.v2.step2.skipHint")} -

-
- )} - - {/* ───── Step 3 — pick a channel ───── */} - {step === 3 && ( -
-
-

- {t("onboarding.v2.step3.title")} -

-

- {t("onboarding.v2.step3.description")} -

-
- -
- - {t("onboarding.v2.step3.title")} - - {CHANNEL_OPTIONS.map((option) => { - const checked = channelChoice === option.id; - const Icon = option.Icon; - return ( - - ); - })} -
-
- )} - - {/* ───── Navigation row ───── */} -
- {step > 1 ? ( - - ) : ( - - )} - - {step < TOTAL_STEPS ? ( - - ) : ( - - )} -
- - {/* 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")} +

+
+ +
+ {t("onboarding.baseline.title")} + +
+ + patch("displayName", e.target.value)} + autoComplete="nickname" + maxLength={50} + placeholder={t("onboarding.baseline.displayNamePlaceholder")} + /> +

+ {t("onboarding.baseline.displayNameHint")} +

+
+ +
+
+ + patch("heightCm", e.target.value)} + placeholder="175" + min={50} + max={300} + step={0.1} + autoComplete="off" + /> +
+
+ + +
+
+ +
+ + patch("dateOfBirth", e.target.value)} + max={new Date().toISOString().slice(0, 10)} + autoComplete="bday" + /> +

+ {t("onboarding.baseline.dateOfBirthHint")} +

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

+ {t("onboarding.done.title")} +

+

+ {t("onboarding.done.body")} +

+
+ + +
+ ); +}