mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(onboarding): goals chip-picker on step 1
This commit is contained in:
1 parent
e2fd11ed9c
commit
c7b1fdb022
8 files changed
+364
-12
No files matched your search
+11
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
Reference in new issue
Block a user