feat(onboarding): goals chip-picker on step 1

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:00:19 +02:00
1 parent e2fd11ed9c
commit c7b1fdb022
8 files changed
+364 -12

No files matched your search

+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+11 -2
View File
@@ -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",
+14
View File
@@ -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 (
<OnboardingShell step={1} userLocale={user.locale ?? null}>
<GoalsChipPicker userId={user.id} />
</OnboardingShell>
);
}
// 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-
@@ -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<GoalOption> = [
{
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<OnboardingGoalSlug> {
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<string>(GOAL_OPTIONS.map((o) => o.slug));
const out = new Set<OnboardingGoalSlug>();
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<Set<OnboardingGoalSlug>>(() =>
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 (
<section
aria-labelledby="onboarding-goals-title"
className="space-y-6"
>
<header className="space-y-2">
<h1
id="onboarding-goals-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.goals.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.goals.body")}
</p>
<p className="text-muted-foreground text-xs">
{t("onboarding.goals.helpHint")}
</p>
</header>
<fieldset
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
aria-describedby="onboarding-goals-title"
>
<legend className="sr-only">{t("onboarding.goals.title")}</legend>
{GOAL_OPTIONS.map((option) => {
const checked = selected.has(option.slug);
const Icon = option.Icon;
return (
<label
key={option.slug}
className={cn(
"border-border bg-card flex min-h-[88px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border p-3 text-center transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:border-primary/50",
checked && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
name="onboarding-goal"
value={option.slug}
checked={checked}
onChange={() => toggle(option.slug)}
className="sr-only"
/>
<span
aria-hidden="true"
className={cn(
"flex size-9 items-center justify-center rounded-full transition-colors",
checked
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-4" />
</span>
<span className="text-foreground text-sm font-medium leading-tight">
{t(option.labelKey)}
</span>
</label>
);
})}
</fieldset>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" size="sm">
<Link href="/onboarding/0">{t("onboarding.shell.back")}</Link>
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={advance}
disabled={advancing}
>
{t("onboarding.shell.skip")}
</Button>
<Button type="button" onClick={advance} disabled={advancing}>
{t("onboarding.shell.next")}
</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})`;
}