hellth-hub/apps/admin/app/(authed)/onboarding/page.tsx

317 lines
16 KiB
TypeScript
Raw Permalink Normal View History

"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>
);
}