From c7b1fdb022636568f24d88553534d6c3e70b7bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 20:00:19 +0200 Subject: [PATCH] feat(onboarding): goals chip-picker on step 1 --- messages/de.json | 13 +- messages/en.json | 13 +- messages/es.json | 13 +- messages/fr.json | 13 +- messages/it.json | 13 +- messages/pl.json | 13 +- src/app/onboarding/[step]/page.tsx | 14 + src/components/onboarding/GoalsChipPicker.tsx | 284 ++++++++++++++++++ 8 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 src/components/onboarding/GoalsChipPicker.tsx diff --git a/messages/de.json b/messages/de.json index 3d0681aa0..157eda20b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "Was möchtest du erfassen?", - "body": "Wähle die Werte, die dir wichtig sind. Dashboard und Erinnerungen richten sich danach.", - "placeholder": "Auswahl der Ziele folgt." + "body": "Wähle die Bereiche, die dir wichtig sind. Dashboard und Erinnerungen richten sich danach.", + "helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.", + "placeholder": "Auswahl der Ziele folgt.", + "options": { + "weightManagement": "Gewicht im Griff", + "bpTracking": "Blutdruck", + "glucoseTracking": "Blutzucker", + "sleepImprovement": "Besser schlafen", + "medicationCompliance": "Medikamente", + "generalWellness": "Allgemeine Form" + } }, "source": { "title": "Quelle verbinden", diff --git a/messages/en.json b/messages/en.json index dff0df6dc..3092a524f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "What would you like to track?", - "body": "Pick the metrics you care about. Your dashboard and the default reminders follow your choice.", - "placeholder": "Goal selection coming soon." + "body": "Pick the areas you care about. Your dashboard and the default reminders follow your choice.", + "helpHint": "One to three priorities tend to work best — you can always add more later.", + "placeholder": "Goal selection coming soon.", + "options": { + "weightManagement": "Weight management", + "bpTracking": "Blood pressure", + "glucoseTracking": "Glucose", + "sleepImprovement": "Better sleep", + "medicationCompliance": "Medications", + "generalWellness": "General wellness" + } }, "source": { "title": "Connect a source", diff --git a/messages/es.json b/messages/es.json index 7047899a2..1ed59bf88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "¿Qué te gustaría registrar?", - "body": "Elige las métricas que te interesan. El panel y los recordatorios se ajustarán a tu elección.", - "placeholder": "Selección de objetivos próximamente." + "body": "Elige las áreas que te interesan. El panel y los recordatorios se ajustarán a tu elección.", + "helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.", + "placeholder": "Selección de objetivos próximamente.", + "options": { + "weightManagement": "Control del peso", + "bpTracking": "Presión arterial", + "glucoseTracking": "Glucosa", + "sleepImprovement": "Mejor sueño", + "medicationCompliance": "Medicamentos", + "generalWellness": "Bienestar general" + } }, "source": { "title": "Conectar una fuente", diff --git a/messages/fr.json b/messages/fr.json index 903822987..1a06a05de 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "Que souhaitez-vous suivre ?", - "body": "Choisissez les indicateurs qui comptent pour vous. Le tableau de bord et les rappels suivront.", - "placeholder": "Sélection des objectifs à venir." + "body": "Sélectionnez les domaines qui comptent pour vous. Le tableau de bord et les rappels s'y adapteront.", + "helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.", + "placeholder": "Sélection des objectifs à venir.", + "options": { + "weightManagement": "Gestion du poids", + "bpTracking": "Tension artérielle", + "glucoseTracking": "Glycémie", + "sleepImprovement": "Mieux dormir", + "medicationCompliance": "Médicaments", + "generalWellness": "Bien-être général" + } }, "source": { "title": "Connecter une source", diff --git a/messages/it.json b/messages/it.json index e16223e5e..8c2c5a921 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "Cosa vuoi monitorare?", - "body": "Scegli le metriche che ti interessano. Dashboard e promemoria si adatteranno alle tue scelte.", - "placeholder": "Selezione degli obiettivi in arrivo." + "body": "Scegli gli ambiti che ti interessano. Dashboard e promemoria si adatteranno alle tue scelte.", + "helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.", + "placeholder": "Selezione degli obiettivi in arrivo.", + "options": { + "weightManagement": "Gestione del peso", + "bpTracking": "Pressione arteriosa", + "glucoseTracking": "Glicemia", + "sleepImprovement": "Sonno migliore", + "medicationCompliance": "Farmaci", + "generalWellness": "Benessere generale" + } }, "source": { "title": "Connetti una sorgente", diff --git a/messages/pl.json b/messages/pl.json index 0135e7081..0395eafdd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1453,8 +1453,17 @@ }, "goals": { "title": "Co chcesz śledzić?", - "body": "Wybierz wskaźniki, które są dla Ciebie ważne. Pulpit i przypomnienia dostosują się do Twojego wyboru.", - "placeholder": "Wybór celów wkrótce." + "body": "Wybierz obszary, które są dla Ciebie ważne. Pulpit i przypomnienia dostosują się do Twojego wyboru.", + "helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.", + "placeholder": "Wybór celów wkrótce.", + "options": { + "weightManagement": "Kontrola wagi", + "bpTracking": "Ciśnienie", + "glucoseTracking": "Glukoza", + "sleepImprovement": "Lepszy sen", + "medicationCompliance": "Leki", + "generalWellness": "Ogólna forma" + } }, "source": { "title": "Połącz źródło", diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index e22e2d55b..6adc3cca3 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -2,6 +2,7 @@ import { redirect, notFound } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/OnboardingShell"; import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel"; +import { GoalsChipPicker } from "@/components/onboarding/GoalsChipPicker"; import { getSession } from "@/lib/auth/session"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -84,6 +85,19 @@ export default async function OnboardingStepPage({ params }: PageProps) { ); } + // Goals (step 1) — multi-select chip grid. Component owns its own + // Back/Skip/Next row so the shell drops every footer href to avoid + // duplicate controls. The user id is threaded as a prop so the + // client hydration reads localStorage synchronously in its state + // initializer (avoids the setState-in-effect anti-pattern). + if (requested === 1) { + return ( + + + + ); + } + // 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- diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx new file mode 100644 index 000000000..e47286103 --- /dev/null +++ b/src/components/onboarding/GoalsChipPicker.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Brain, + Droplet, + HeartPulse, + Moon, + Pill, + Scale, + type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.25 W14b-Content — onboarding step 1 (goals). + * + * Multi-select chip picker for the metrics the user wants to track. + * The goal slugs match a stable enum (used by the eventual + * `User.onboardingGoals` column in v1.4.26 — for now we hold the + * selection in client state and bundle it into the final-step submit). + * + * Persistence — per the W14b-Content brief — happens in localStorage + * scoped by `user.id` (mirrors the family-shared-device pattern from + * `tour-launcher.tsx:59–65`). The Welcome carousel doesn't write here, + * so a user who closes the tab on step 1 sees their previous choice + * pre-selected when they resume. + * + * CTAs: + * - Back → `/onboarding/0` + * - Skip → advance with empty goal set + * - Next → advance with chosen goal set + * + * Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }` + * and `router.push("/onboarding/2")`. + */ + +export const ONBOARDING_GOALS_STORAGE_PREFIX = "healthlog.onboarding.goals"; + +export type OnboardingGoalSlug = + | "weight-management" + | "bp-tracking" + | "glucose-tracking" + | "sleep-improvement" + | "medication-compliance" + | "general-wellness"; + +interface GoalOption { + slug: OnboardingGoalSlug; + Icon: LucideIcon; + labelKey: string; +} + +const GOAL_OPTIONS: ReadonlyArray = [ + { + slug: "weight-management", + Icon: Scale, + labelKey: "onboarding.goals.options.weightManagement", + }, + { + slug: "bp-tracking", + Icon: HeartPulse, + labelKey: "onboarding.goals.options.bpTracking", + }, + { + slug: "glucose-tracking", + Icon: Droplet, + labelKey: "onboarding.goals.options.glucoseTracking", + }, + { + slug: "sleep-improvement", + Icon: Moon, + labelKey: "onboarding.goals.options.sleepImprovement", + }, + { + slug: "medication-compliance", + Icon: Pill, + labelKey: "onboarding.goals.options.medicationCompliance", + }, + { + slug: "general-wellness", + Icon: Brain, + labelKey: "onboarding.goals.options.generalWellness", + }, +]; + +export function goalsStorageKey(userId: string): string { + return `${ONBOARDING_GOALS_STORAGE_PREFIX}:${userId}`; +} + +function readGoalsFromStorage( + storageKey: string, +): Set { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return new Set(); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + const allowed = new Set(GOAL_OPTIONS.map((o) => o.slug)); + const out = new Set(); + for (const v of parsed) { + if (typeof v === "string" && allowed.has(v)) { + out.add(v as OnboardingGoalSlug); + } + } + return out; + } catch { + /* corrupt entry — ignore */ + return new Set(); + } +} + +export interface GoalsChipPickerProps { + /** + * The current user's id. Threaded in from the server step page + * (`getSession().user.id`) so the localStorage hydration can run + * synchronously in the state initializer and avoid the + * `react-hooks/set-state-in-effect` cascading-render anti-pattern. + */ + userId: string; +} + +export function GoalsChipPicker({ userId }: GoalsChipPickerProps) { + const { t } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const storageKey = goalsStorageKey(userId); + + const [selected, setSelected] = useState>(() => + readGoalsFromStorage(storageKey), + ); + const [advancing, setAdvancing] = useState(false); + + const toggle = useCallback((slug: OnboardingGoalSlug) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + }, []); + + // Persist on every change. localStorage is cheap and the goal set is + // tiny — no need for a debounced effect here. + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey, + JSON.stringify(Array.from(selected)), + ); + } catch { + /* quota or disabled — best-effort */ + } + }, [storageKey, selected]); + + async function advance() { + if (advancing) return; + setAdvancing(true); + try { + const res = await fetch("/api/onboarding/step", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: 2 }), + }); + if (!res.ok) throw new Error(await readError(res)); + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.push("/onboarding/2"); + } catch (err) { + const message = + err instanceof Error ? err.message : t("onboarding.errorGeneric"); + toast.error(message); + setAdvancing(false); + } + } + + return ( +
+
+

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

+

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

+

+ {t("onboarding.goals.helpHint")} +

+
+ +
+ {t("onboarding.goals.title")} + {GOAL_OPTIONS.map((option) => { + const checked = selected.has(option.slug); + const Icon = option.Icon; + return ( + + ); + })} +
+ +
+ +
+ + +
+
+
+ ); +} + +async function readError(res: Response): Promise { + try { + const json = (await res.json()) as { error?: string }; + if (typeof json.error === "string" && json.error.length > 0) { + return json.error; + } + } catch { + /* fall through */ + } + return `Request failed (${res.status})`; +}