317 lines
16 KiB
TypeScript
317 lines
16 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import {
|
||
|
|
ArrowRightIcon,
|
||
|
|
CheckCircleIcon,
|
||
|
|
ExclamationTriangleIcon,
|
||
|
|
SparklesIcon,
|
||
|
|
} from "@heroicons/react/24/outline";
|
||
|
|
import Link from "next/link";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useSession } from "@/lib/auth-client";
|
||
|
|
import {
|
||
|
|
ActivityLevel,
|
||
|
|
HealthGoal,
|
||
|
|
HealthProfile,
|
||
|
|
WeightLossPace,
|
||
|
|
buildCoachRecommendations,
|
||
|
|
buildGoalsFromProfile,
|
||
|
|
loadHellthData,
|
||
|
|
saveHellthData,
|
||
|
|
} from "@/lib/hellth-data";
|
||
|
|
|
||
|
|
type FormState = {
|
||
|
|
goal: HealthGoal;
|
||
|
|
age: string;
|
||
|
|
heightCm: string;
|
||
|
|
currentWeightKg: string;
|
||
|
|
targetWeightKg: string;
|
||
|
|
activityLevel: ActivityLevel;
|
||
|
|
pace: WeightLossPace;
|
||
|
|
mealsPerDay: string;
|
||
|
|
dietStyle: HealthProfile["dietStyle"];
|
||
|
|
cookingTime: HealthProfile["cookingTime"];
|
||
|
|
budgetFocus: boolean;
|
||
|
|
excludedFoods: string;
|
||
|
|
biggestChallenge: string;
|
||
|
|
medicalNote: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
const initialForm: FormState = {
|
||
|
|
goal: "lose_weight",
|
||
|
|
age: "",
|
||
|
|
heightCm: "",
|
||
|
|
currentWeightKg: "",
|
||
|
|
targetWeightKg: "",
|
||
|
|
activityLevel: "moderate",
|
||
|
|
pace: "normal",
|
||
|
|
mealsPerDay: "3",
|
||
|
|
dietStyle: "mixed",
|
||
|
|
cookingTime: "medium",
|
||
|
|
budgetFocus: false,
|
||
|
|
excludedFoods: "",
|
||
|
|
biggestChallenge: "",
|
||
|
|
medicalNote: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
function numberFrom(value: string) {
|
||
|
|
return Number(value.replace(",", ".")) || 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function OptionButton({
|
||
|
|
active,
|
||
|
|
label,
|
||
|
|
description,
|
||
|
|
onClick,
|
||
|
|
}: {
|
||
|
|
active: boolean;
|
||
|
|
label: string;
|
||
|
|
description: string;
|
||
|
|
onClick: () => void;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClick}
|
||
|
|
className={`rounded-3xl border p-4 text-left transition ${
|
||
|
|
active ? "border-[#0F766E] bg-emerald-50 ring-2 ring-emerald-100" : "border-gray-100 bg-white hover:bg-gray-50"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<p className="text-base font-black text-gray-950">{label}</p>
|
||
|
|
<p className="mt-1 text-sm font-semibold text-gray-400">{description}</p>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function OnboardingPage() {
|
||
|
|
const router = useRouter();
|
||
|
|
const { data: session } = useSession();
|
||
|
|
const userKey = session?.user?.id ?? session?.user?.email ?? null;
|
||
|
|
const [form, setForm] = useState<FormState>(initialForm);
|
||
|
|
const [savedProfile, setSavedProfile] = useState<HealthProfile | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!userKey) return;
|
||
|
|
const profile = loadHellthData(userKey).healthProfile;
|
||
|
|
if (profile) {
|
||
|
|
setSavedProfile(profile);
|
||
|
|
setForm({
|
||
|
|
goal: profile.goal,
|
||
|
|
age: String(profile.age),
|
||
|
|
heightCm: String(profile.heightCm),
|
||
|
|
currentWeightKg: String(profile.currentWeightKg),
|
||
|
|
targetWeightKg: profile.targetWeightKg ? String(profile.targetWeightKg) : "",
|
||
|
|
activityLevel: profile.activityLevel,
|
||
|
|
pace: profile.pace,
|
||
|
|
mealsPerDay: String(profile.mealsPerDay),
|
||
|
|
dietStyle: profile.dietStyle,
|
||
|
|
cookingTime: profile.cookingTime,
|
||
|
|
budgetFocus: profile.budgetFocus,
|
||
|
|
excludedFoods: profile.excludedFoods,
|
||
|
|
biggestChallenge: profile.biggestChallenge,
|
||
|
|
medicalNote: profile.medicalNote,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}, [userKey]);
|
||
|
|
|
||
|
|
function saveProfile() {
|
||
|
|
if (!userKey) return;
|
||
|
|
const age = numberFrom(form.age);
|
||
|
|
const heightCm = numberFrom(form.heightCm);
|
||
|
|
const currentWeightKg = numberFrom(form.currentWeightKg);
|
||
|
|
const targetWeightKg = numberFrom(form.targetWeightKg);
|
||
|
|
|
||
|
|
if (age < 16 || heightCm < 120 || currentWeightKg < 35) {
|
||
|
|
setError("Bitte Alter, Größe und aktuelles Gewicht plausibel ausfüllen.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (form.goal === "lose_weight" && targetWeightKg > 0 && targetWeightKg >= currentWeightKg) {
|
||
|
|
setError("Für Abnehmen sollte das Zielgewicht unter dem aktuellen Gewicht liegen.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const profile: HealthProfile = {
|
||
|
|
completedAt: new Date().toISOString(),
|
||
|
|
goal: form.goal,
|
||
|
|
age,
|
||
|
|
heightCm,
|
||
|
|
currentWeightKg,
|
||
|
|
targetWeightKg: targetWeightKg || undefined,
|
||
|
|
activityLevel: form.activityLevel,
|
||
|
|
pace: form.pace,
|
||
|
|
mealsPerDay: Math.max(1, Math.min(6, numberFrom(form.mealsPerDay) || 3)),
|
||
|
|
dietStyle: form.dietStyle,
|
||
|
|
cookingTime: form.cookingTime,
|
||
|
|
budgetFocus: form.budgetFocus,
|
||
|
|
excludedFoods: form.excludedFoods.trim(),
|
||
|
|
biggestChallenge: form.biggestChallenge.trim(),
|
||
|
|
medicalNote: form.medicalNote,
|
||
|
|
};
|
||
|
|
const goals = buildGoalsFromProfile(profile);
|
||
|
|
const coachRecommendations = buildCoachRecommendations(profile, goals);
|
||
|
|
const data = loadHellthData(userKey);
|
||
|
|
saveHellthData({ ...data, healthProfile: profile, goals, coachRecommendations }, userKey);
|
||
|
|
setSavedProfile(profile);
|
||
|
|
setError(null);
|
||
|
|
}
|
||
|
|
|
||
|
|
const previewProfile: HealthProfile | null =
|
||
|
|
numberFrom(form.age) >= 16 && numberFrom(form.heightCm) >= 120 && numberFrom(form.currentWeightKg) >= 35
|
||
|
|
? {
|
||
|
|
completedAt: new Date().toISOString(),
|
||
|
|
goal: form.goal,
|
||
|
|
age: numberFrom(form.age),
|
||
|
|
heightCm: numberFrom(form.heightCm),
|
||
|
|
currentWeightKg: numberFrom(form.currentWeightKg),
|
||
|
|
targetWeightKg: numberFrom(form.targetWeightKg) || undefined,
|
||
|
|
activityLevel: form.activityLevel,
|
||
|
|
pace: form.pace,
|
||
|
|
mealsPerDay: numberFrom(form.mealsPerDay) || 3,
|
||
|
|
dietStyle: form.dietStyle,
|
||
|
|
cookingTime: form.cookingTime,
|
||
|
|
budgetFocus: form.budgetFocus,
|
||
|
|
excludedFoods: form.excludedFoods,
|
||
|
|
biggestChallenge: form.biggestChallenge,
|
||
|
|
medicalNote: form.medicalNote,
|
||
|
|
}
|
||
|
|
: null;
|
||
|
|
const previewGoals = previewProfile ? buildGoalsFromProfile(previewProfile) : null;
|
||
|
|
const previewRecommendations = previewProfile && previewGoals ? buildCoachRecommendations(previewProfile, previewGoals) : [];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
||
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-[#0F766E] text-white">
|
||
|
|
<SparklesIcon className="h-7 w-7" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Erster Start</p>
|
||
|
|
<h1 className="text-4xl font-black tracking-normal text-gray-950">Dein Health-Profil</h1>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p className="mt-4 max-w-3xl text-sm font-semibold text-gray-500">
|
||
|
|
Hellth Hub nutzt diese Antworten, um alltagstaugliche Ziele und konkrete Vorschläge zu erzeugen. Die Werte sind Startpunkte und werden später über deinen echten Verlauf verbessert.
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section className="grid gap-5 xl:grid-cols-[1fr_0.8fr]">
|
||
|
|
<div className="space-y-5">
|
||
|
|
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<h2 className="text-xl font-black text-gray-950">1. Ziel</h2>
|
||
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||
|
|
<OptionButton active={form.goal === "lose_weight"} label="Abnehmen" description="Fokus auf moderates Defizit und Sättigung." onClick={() => setForm((current) => ({ ...current, goal: "lose_weight" }))} />
|
||
|
|
<OptionButton active={form.goal === "maintain"} label="Gewicht halten" description="Routine, Energie und Stabilität verbessern." onClick={() => setForm((current) => ({ ...current, goal: "maintain" }))} />
|
||
|
|
<OptionButton active={form.goal === "healthier"} label="Gesünder leben" description="Mehr Struktur, bessere Lebensmittelauswahl." onClick={() => setForm((current) => ({ ...current, goal: "healthier" }))} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<h2 className="text-xl font-black text-gray-950">2. Ausgangslage</h2>
|
||
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
|
|
<input value={form.age} onChange={(event) => setForm((current) => ({ ...current, age: event.target.value }))} placeholder="Alter" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
<input value={form.heightCm} onChange={(event) => setForm((current) => ({ ...current, heightCm: event.target.value }))} placeholder="Größe cm" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
<input value={form.currentWeightKg} onChange={(event) => setForm((current) => ({ ...current, currentWeightKg: event.target.value }))} placeholder="Aktuelles Gewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
<input value={form.targetWeightKg} onChange={(event) => setForm((current) => ({ ...current, targetWeightKg: event.target.value }))} placeholder="Zielgewicht kg" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
</div>
|
||
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||
|
|
<OptionButton active={form.activityLevel === "low"} label="Wenig aktiv" description="Viel Sitzen, wenig Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "low" }))} />
|
||
|
|
<OptionButton active={form.activityLevel === "moderate"} label="Moderat" description="Alltag mit etwas Bewegung." onClick={() => setForm((current) => ({ ...current, activityLevel: "moderate" }))} />
|
||
|
|
<OptionButton active={form.activityLevel === "active"} label="Aktiv" description="Regelmäßiges Training oder aktiver Job." onClick={() => setForm((current) => ({ ...current, activityLevel: "active" }))} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<h2 className="text-xl font-black text-gray-950">3. Alltag</h2>
|
||
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||
|
|
<OptionButton active={form.pace === "gentle"} label="Sanft" description="Kleines Defizit, leichter Alltag." onClick={() => setForm((current) => ({ ...current, pace: "gentle" }))} />
|
||
|
|
<OptionButton active={form.pace === "normal"} label="Normal" description="Guter Startpunkt für Abnehmen." onClick={() => setForm((current) => ({ ...current, pace: "normal" }))} />
|
||
|
|
<OptionButton active={form.pace === "ambitious"} label="Ambitioniert" description="Mehr Struktur, genauer beobachten." onClick={() => setForm((current) => ({ ...current, pace: "ambitious" }))} />
|
||
|
|
</div>
|
||
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
|
|
<input value={form.mealsPerDay} onChange={(event) => setForm((current) => ({ ...current, mealsPerDay: event.target.value }))} placeholder="Mahlzeiten pro Tag" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
<select value={form.dietStyle} onChange={(event) => setForm((current) => ({ ...current, dietStyle: event.target.value as FormState["dietStyle"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||
|
|
<option value="mixed">Mischkost</option>
|
||
|
|
<option value="flexible">Flexibel</option>
|
||
|
|
<option value="vegetarian">Vegetarisch</option>
|
||
|
|
<option value="vegan">Vegan</option>
|
||
|
|
<option value="low_carb">Low Carb</option>
|
||
|
|
</select>
|
||
|
|
<select value={form.cookingTime} onChange={(event) => setForm((current) => ({ ...current, cookingTime: event.target.value as FormState["cookingTime"] }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
||
|
|
<option value="low">Wenig Kochzeit</option>
|
||
|
|
<option value="medium">Mittlere Kochzeit</option>
|
||
|
|
<option value="high">Koche gern</option>
|
||
|
|
</select>
|
||
|
|
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3 text-sm font-black">
|
||
|
|
<input type="checkbox" checked={form.budgetFocus} onChange={(event) => setForm((current) => ({ ...current, budgetFocus: event.target.checked }))} />
|
||
|
|
Budget wichtig
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||
|
|
<textarea value={form.excludedFoods} onChange={(event) => setForm((current) => ({ ...current, excludedFoods: event.target.value }))} placeholder="Allergien, Ausschlüsse, No-Gos" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
<textarea value={form.biggestChallenge} onChange={(event) => setForm((current) => ({ ...current, biggestChallenge: event.target.value }))} placeholder="Was macht Abnehmen oder gesünder essen aktuell schwer?" className="min-h-28 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
||
|
|
</div>
|
||
|
|
<label className="mt-4 flex items-start gap-3 rounded-3xl bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-900">
|
||
|
|
<input type="checkbox" checked={form.medicalNote} onChange={(event) => setForm((current) => ({ ...current, medicalNote: event.target.checked }))} className="mt-1" />
|
||
|
|
Es gibt Diagnosen, Medikamente, Schwangerschaft, Essstörungs-Vorgeschichte oder andere gesundheitliche Themen, die beachtet werden sollten.
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<aside className="space-y-4">
|
||
|
|
<section className="sticky top-6 rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||
|
|
<h2 className="text-xl font-black text-gray-950">Auswertung</h2>
|
||
|
|
{previewGoals ? (
|
||
|
|
<>
|
||
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||
|
|
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.calories}</p><p className="text-xs font-bold text-gray-400">kcal/Tag</p></div>
|
||
|
|
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
||
|
|
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.drinkMl}</p><p className="text-xs font-bold text-gray-400">ml Wasser</p></div>
|
||
|
|
<div className="rounded-3xl bg-gray-50 p-4"><p className="text-2xl font-black">{previewGoals.weightKg}</p><p className="text-xs font-bold text-gray-400">kg Ziel</p></div>
|
||
|
|
</div>
|
||
|
|
<div className="mt-5 space-y-3">
|
||
|
|
{previewRecommendations.slice(0, 4).map((item) => (
|
||
|
|
<div key={item.id} className="rounded-3xl bg-emerald-50 p-4">
|
||
|
|
<p className="font-black text-emerald-950">{item.title}</p>
|
||
|
|
<p className="mt-1 text-sm font-semibold text-emerald-800">{item.body}</p>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<p className="mt-3 text-sm font-semibold text-gray-400">Fülle Alter, Größe und Gewicht aus, dann siehst du hier die ersten Vorschläge.</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error ? (
|
||
|
|
<div className="mt-4 flex gap-2 rounded-3xl bg-red-50 p-4 text-sm font-semibold text-red-700">
|
||
|
|
<ExclamationTriangleIcon className="h-5 w-5 shrink-0" />
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{savedProfile ? (
|
||
|
|
<div className="mt-4 flex gap-2 rounded-3xl bg-green-50 p-4 text-sm font-semibold text-green-700">
|
||
|
|
<CheckCircleIcon className="h-5 w-5 shrink-0" />
|
||
|
|
Profil gespeichert. Deine Ziele und Coach-Vorschläge sind jetzt userbezogen aktiv.
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<button type="button" onClick={saveProfile} className="mt-5 flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-5 py-4 text-sm font-black text-white">
|
||
|
|
Profil speichern
|
||
|
|
<ArrowRightIcon className="h-5 w-5" />
|
||
|
|
</button>
|
||
|
|
{savedProfile ? (
|
||
|
|
<Link href="/" className="mt-3 flex w-full items-center justify-center rounded-full bg-emerald-600 px-5 py-4 text-sm font-black text-white">
|
||
|
|
Zur Tagesansicht
|
||
|
|
</Link>
|
||
|
|
) : null}
|
||
|
|
</section>
|
||
|
|
</aside>
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|