feat(onboarding): baseline + done steps + welcome-back banner + entry-point swap

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:08:19 +02:00
1 parent 888efd6cf4
commit b896622d22
11 files changed
+584 -627

No files matched your search

+19 -3
View File
@@ -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": {
+19 -3
View File
@@ -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": {
+19 -3
View File
@@ -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": {
+19 -3
View File
@@ -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": {
+19 -3
View File
@@ -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": {
+19 -3
View File
@@ -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": {
+58 -64
View File
@@ -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 (
<OnboardingShell step={0} userLocale={user.locale ?? null}>
<WelcomeCarousel />
{completed ? (
<section
aria-labelledby="onboarding-welcomeback-title"
className="space-y-5"
>
<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>
);
}
@@ -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 (
<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={requested}
backHref={backHref}
skipHref={skipHref}
nextHref={nextHref}
userLocale={user.locale ?? null}
>
<section className="space-y-4" aria-labelledby="onboarding-step-title">
<h1
id="onboarding-step-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{bodyTitle}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{bodyText}
</p>
<div
data-testid="onboarding-step-body"
aria-label="Step body placeholder — W14b-Content agent fills in real UI."
className="bg-muted/30 min-h-[140px] rounded-lg border border-dashed p-6"
/>
</section>
<OnboardingShell step={4} userLocale={user.locale ?? null}>
<DoneScreen />
</OnboardingShell>
);
}
@@ -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";
}
}
+80 -79
View File
@@ -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(
<I18nProvider initialLocale={locale}>
<OnboardingPage />
</I18nProvider>,
);
}
import OnboardingRootPage from "../page";
beforeEach(() => {
vi.clearAllMocks();
redirectMock.mockClear();
getSessionMock.mockReset();
});
describe("<OnboardingPage> 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<string> {
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("<OnboardingRootPage> 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/<current>", 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/<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");
});
});
+39 -466
View File
@@ -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#<hash>`
* 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 `<MeasurementForm>`
// 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<ChannelChoice>("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/<current>` 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<string, unknown> = {};
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 (
<div className="mx-auto w-full max-w-md space-y-6 py-6">
<div className="text-center">
<Logo className="text-primary mx-auto mb-4" size={48} />
<h1 className="text-2xl font-semibold tracking-tight">
{t("onboarding.v2.title")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("onboarding.v2.subtitle")}
</p>
</div>
{/* Progress indicator — labelled for screen readers */}
<div
role="progressbar"
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
aria-valuenow={step}
aria-label={t("onboarding.v2.stepOf", {
current: step,
total: TOTAL_STEPS,
})}
className="flex items-center justify-center gap-2"
>
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<div
key={i}
className={cn(
"h-1.5 w-10 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
i + 1 <= step ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
<p className="text-muted-foreground -mt-3 text-center text-xs">
{t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })}
</p>
{/* ───── Step 1 — about you ───── */}
{step === 1 && (
<section
aria-labelledby="ob-step1-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step1-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step1.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step1.description")}
</p>
</header>
<div className="space-y-2">
<Label htmlFor="ob-display-name">
{t("onboarding.v2.step1.displayNameLabel")}
</Label>
<Input
id="ob-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="nickname"
placeholder={t("settings.username")}
maxLength={50}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step1.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-language">
{t("onboarding.v2.step1.languageLabel")}
</Label>
<select
id="ob-language"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step1.languageHint")}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-height">{t("settings.height")}</Label>
<Input
id="ob-height"
type="number"
inputMode="decimal"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ob-dob">{t("settings.dateOfBirth")}</Label>
<DateInput
id="ob-dob"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
</section>
)}
{/* ───── Step 2 — first measurement ───── */}
{step === 2 && (
<section
aria-labelledby="ob-step2-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step2-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step2.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step2.description")}
</p>
</header>
{measurementLogged ? (
<div className="border-primary/40 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm">
<CheckCircle2 className="text-primary mt-0.5 size-5 shrink-0" />
<p>{t("onboarding.v2.step2.added")}</p>
</div>
) : (
<MeasurementForm onSuccess={() => setMeasurementLogged(true)} />
)}
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step2.skipHint")}
</p>
</section>
)}
{/* ───── Step 3 — pick a channel ───── */}
{step === 3 && (
<section
aria-labelledby="ob-step3-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step3-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step3.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step3.description")}
</p>
</header>
<fieldset className="space-y-2">
<legend className="sr-only">
{t("onboarding.v2.step3.title")}
</legend>
{CHANNEL_OPTIONS.map((option) => {
const checked = channelChoice === option.id;
const Icon = option.Icon;
return (
<label
key={option.id}
className={cn(
"border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
checked && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="ob-channel"
value={option.id}
checked={checked}
onChange={() => setChannelChoice(option.id)}
className="text-primary focus-visible:ring-ring mt-1 size-4 focus-visible:ring-[3px] focus-visible:outline-none"
/>
<span className="flex min-w-0 flex-1 items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{t(option.titleKey)}
</span>
<span className="text-muted-foreground block text-xs">
{t(option.hintKey)}
</span>
</span>
</span>
</label>
);
})}
</fieldset>
</section>
)}
{/* ───── Navigation row ───── */}
<div className="flex items-center gap-3">
{step > 1 ? (
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={saving}
>
<ArrowLeft className="mr-1 size-4" />
{t("onboarding.v2.back")}
</Button>
) : (
<span className="flex-1" />
)}
{step < TOTAL_STEPS ? (
<Button
type="button"
onClick={nextStep}
className="ml-auto"
disabled={saving}
>
{t("onboarding.v2.continue")}
<ArrowRight className="ml-1 size-4" />
</Button>
) : (
<Button
type="button"
onClick={finish}
className="ml-auto"
disabled={saving}
>
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{channelChoice === "NONE"
? t("onboarding.v2.finish")
: t("onboarding.v2.step3.openSettings")}
</Button>
)}
</div>
{/* Single Skip per step. Step 3's "NONE" choice already covers
skipping notifications, so we hide the underline on step 3. */}
{step < TOTAL_STEPS ? (
<button
type="button"
onClick={nextStep}
disabled={saving}
className="text-muted-foreground hover:text-foreground mx-auto block text-sm underline-offset-4 transition-colors duration-150 ease-out hover:underline motion-reduce:transition-none"
>
{t("onboarding.v2.skipStep")}
</button>
) : null}
</div>
);
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;
}
+237
View File
@@ -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<BaselineFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
function patch<K extends keyof BaselineFormState>(
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<string, unknown> = {};
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 (
<section
aria-labelledby="onboarding-baseline-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-baseline-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.baseline.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.baseline.body")}
</p>
</header>
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<div className="space-y-2">
<Label htmlFor="ob-baseline-display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<Input
id="ob-baseline-display-name"
value={form.displayName}
onChange={(e) => patch("displayName", e.target.value)}
autoComplete="nickname"
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.displayNameHint")}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-baseline-height">
{t("onboarding.baseline.heightLabel")}
</Label>
<Input
id="ob-baseline-height"
type="number"
inputMode="decimal"
value={form.heightCm}
onChange={(e) => patch("heightCm", e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-gender">
{t("onboarding.baseline.genderLabel")}
</Label>
<select
id="ob-baseline-gender"
value={form.gender}
onChange={(e) => patch("gender", e.target.value)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="">{t("onboarding.baseline.genderNone")}</option>
<option value="MALE">{t("onboarding.baseline.genderMale")}</option>
<option value="FEMALE">{t("onboarding.baseline.genderFemale")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ob-baseline-dob">
{t("onboarding.baseline.dateOfBirthLabel")}
</Label>
<DateInput
id="ob-baseline-dob"
value={form.dateOfBirth}
onChange={(e) => patch("dateOfBirth", e.target.value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.baseline.dateOfBirthHint")}
</p>
</div>
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Link href="/onboarding/2">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => advance({ saveProfile: false })}
disabled={saving}
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={() => advance({ saveProfile: true })}
disabled={saving}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{t("onboarding.baseline.saveCta")}
</Button>
</div>
</div>
</section>
);
}
async function readError(res: Response): Promise<string> {
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})`;
}
+56
View File
@@ -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 (
<section
aria-labelledby="onboarding-done-title"
className="flex flex-col items-center gap-6 py-6 text-center"
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-20 items-center justify-center rounded-full"
>
<CheckCircle2 className="size-10" />
</span>
<header className="space-y-2">
<h1
id="onboarding-done-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.done.title")}
</h1>
<p className="text-muted-foreground mx-auto max-w-md text-base leading-relaxed">
{t("onboarding.done.body")}
</p>
</header>
<Button asChild size="lg">
<Link href="/">{t("onboarding.done.returnCta")}</Link>
</Button>
</section>
);
}