mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(onboarding): baseline + done steps + welcome-back banner + entry-point swap
This commit is contained in:
1 parent
888efd6cf4
commit
b896622d22
11 files changed
+584
-627
No files matched your search
+19
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user