hellth-hub/apps/admin/app/(authed)/einstellungen/page.tsx
Sebastian Mayer 9ab6db9c0e Feature: Community-Profil-Card im Einstellungen-Profil-Tab
Erganzt den Profil-Tab um eine Community-Profil-Karte (UI-Teil zum
Backend-Fundament 634e67c):
- Benutzername-Feld mit Hinweis auf den vergebenen Handle (Username#XXXX).
- Bio-Textarea (max 280 Zeichen) mit Zeichenzaehler.
- Sichtbarkeits-Checkbox (Privacy-by-Default: aus) mit Datenschutzhinweis,
  dass Gewicht, Kalorien und Ziele immer privat bleiben.
- Eigener Submit, der setMyHandle (nur bei eingegebenem Username) und
  updateMyCommunityProfile aufruft und myProfile invalidiert.

Typecheck gruen (db/api/admin), Admin-Tests 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:59:59 +02:00

1091 lines
41 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
BeakerIcon,
BellIcon,
ChevronRightIcon,
ClockIcon,
EyeIcon,
EyeSlashIcon,
FireIcon,
KeyIcon,
MoonIcon,
ScaleIcon,
ShieldCheckIcon,
SunIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { DataPageLayout } from "@/app/_components/data-page-layout";
import { useTheme } from "@/app/_components/theme-provider";
import { authClient } from "@/lib/auth-client";
import { trpc } from "@/lib/trpc";
import { SectionCard } from "./_components/section-card";
import { SettingsTabs, useSettingsTab } from "./_components/settings-tabs";
import { StatusBanner } from "./_components/status-banner";
const PASSWORD_CHARS =
"ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*";
const dietPreferenceOptions = [
{ id: "vegan", label: "Vegan" },
{ id: "vegetarian", label: "Vegetarisch" },
{ id: "meat", label: "Fleisch" },
{ id: "fish", label: "Fisch" },
] as const;
type DietPreference = (typeof dietPreferenceOptions)[number]["id"];
type GoalsForm = {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
drinkMl: number;
weightKg: number;
fastingHours: number;
sportAddsCalories: boolean;
};
const defaultGoalsForm: GoalsForm = {
calories: 2000,
proteinG: 150,
carbsG: 200,
fatG: 67,
drinkMl: 2500,
weightKg: 120,
fastingHours: 12,
sportAddsCalories: true,
};
const goalsFields: Array<{
key: keyof Omit<GoalsForm, "sportAddsCalories">;
label: string;
unit: string;
icon: typeof FireIcon;
}> = [
{ key: "calories", label: "Kalorien", unit: "kcal", icon: FireIcon },
{ key: "proteinG", label: "Protein", unit: "g", icon: ScaleIcon },
{ key: "carbsG", label: "Kohlenhydrate", unit: "g", icon: ScaleIcon },
{ key: "fatG", label: "Fett", unit: "g", icon: ScaleIcon },
{ key: "drinkMl", label: "Trinkziel", unit: "ml", icon: BeakerIcon },
{ key: "weightKg", label: "Gewichtsziel", unit: "kg", icon: ScaleIcon },
{ key: "fastingHours", label: "Fasten", unit: "h", icon: ClockIcon },
];
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function generatePassword(length = 16) {
const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (byte) => PASSWORD_CHARS[byte % PASSWORD_CHARS.length]!).join("");
}
async function toOptimizedAvatarDataUrl(file: File) {
const bitmap = await createImageBitmap(file);
const maxEdge = 320;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas nicht verfügbar");
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const dataUrl = canvas.toDataURL("image/webp", 0.82);
if (!dataUrl.startsWith("data:image/")) {
throw new Error("Profilbild konnte nicht verarbeitet werden.");
}
if (dataUrl.length > 120_000) {
throw new Error("Profilbild ist nach Komprimierung noch zu groß.");
}
return dataUrl;
}
const inputClass =
"w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-[#0F766E] focus:outline-none focus:ring-2 focus:ring-[#0F766E]/20";
const labelClass = "mb-1.5 block text-sm font-medium text-gray-700";
const buttonPrimary =
"inline-flex items-center justify-center rounded-lg bg-[#0F766E] px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#115E59] disabled:cursor-not-allowed disabled:opacity-50";
const buttonSecondary =
"inline-flex items-center justify-center rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50";
export default function EinstellungenPage() {
const { isDark, toggleDark } = useTheme();
const { active, setActive } = useSettingsTab();
const utils = trpc.useUtils();
const profileQuery = trpc.users.myProfile.useQuery();
const securityQuery = trpc.security.status.useQuery();
const healthOverviewQuery = trpc.health.overview.useQuery({ date: todayKey() });
const passkeysQuery = authClient.useListPasskeys();
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [mobile, setMobile] = useState("");
const [street, setStreet] = useState("");
const [postalCode, setPostalCode] = useState("");
const [city, setCity] = useState("");
const [image, setImage] = useState<string | null>(null);
// Community-Profil (Handle, Bio, Sichtbarkeit)
const [username, setUsername] = useState("");
const [bio, setBio] = useState("");
const [profileVisible, setProfileVisible] = useState(false);
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>([
"vegan",
"vegetarian",
"meat",
"fish",
]);
const [mealsPerDay, setMealsPerDay] = useState("3");
const [cooksPerDay, setCooksPerDay] = useState("1");
const [preferredMealPrepDays, setPreferredMealPrepDays] = useState("0");
const [meatFishFrequency, setMeatFishFrequency] = useState<
"none" | "once_per_day" | "flexible"
>("flexible");
const [goalsForm, setGoalsForm] = useState<GoalsForm>(defaultGoalsForm);
const isPlatformAdmin = profileQuery.data?.role === "admin";
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
useEffect(() => {
if (profileQuery.data) {
setFirstName(profileQuery.data.firstName ?? profileQuery.data.name ?? "");
setLastName(profileQuery.data.lastName ?? profileQuery.data.name ?? "");
setPhone(profileQuery.data.phone ?? "");
setMobile(profileQuery.data.mobile ?? "");
setStreet(profileQuery.data.street ?? "");
setPostalCode(profileQuery.data.postalCode ?? "");
setCity(profileQuery.data.city ?? "");
setImage(profileQuery.data.image ?? null);
setUsername(profileQuery.data.username ?? "");
setBio(profileQuery.data.bio ?? "");
setProfileVisible(profileQuery.data.profileVisible ?? false);
}
}, [profileQuery.data]);
const updateMyProfile = trpc.users.updateMyProfile.useMutation({
onSuccess: async () => {
await Promise.all([
utils.users.myProfile.invalidate(),
utils.security.status.invalidate(),
]);
setStatusMessage("Profil gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const setMyHandle = trpc.users.setMyHandle.useMutation();
const updateMyCommunityProfile = trpc.users.updateMyCommunityProfile.useMutation();
const communityPending = setMyHandle.isPending || updateMyCommunityProfile.isPending;
const onSubmitCommunity = async (event: React.FormEvent) => {
event.preventDefault();
setStatusMessage(null);
setErrorMessage(null);
try {
// Handle nur setzen, wenn ein Username eingegeben wurde. Den Discriminator
// vergibt der Server stabil bei der Erst-Vergabe.
const trimmedUsername = username.trim();
if (trimmedUsername) {
await setMyHandle.mutateAsync({ username: trimmedUsername });
}
await updateMyCommunityProfile.mutateAsync({
bio: bio.trim() || null,
profileVisible,
});
await utils.users.myProfile.invalidate();
setStatusMessage("Community-Profil gespeichert.");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "Speichern fehlgeschlagen.",
);
}
};
const changeMyPassword = trpc.users.changeMyPassword.useMutation({
onSuccess: () => {
setCurrentPassword("");
setNewPassword("");
setConfirmNewPassword("");
setStatusMessage("Passwort erfolgreich aktualisiert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const updateHealthProfile = trpc.health.upsertProfile.useMutation({
onSuccess: async () => {
await healthOverviewQuery.refetch();
await utils.health.recipeRecommendations.invalidate();
setStatusMessage("Essenspräferenzen gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
const upsertGoals = trpc.health.upsertGoals.useMutation({
onSuccess: async () => {
await healthOverviewQuery.refetch();
setStatusMessage("Ziele gespeichert.");
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error.message);
setStatusMessage(null);
},
});
useEffect(() => {
const profile = healthOverviewQuery.data?.profile;
if (!profile) return;
setDietPreferences(
(profile.dietPreferences?.length
? profile.dietPreferences
: ["vegan", "vegetarian", "meat", "fish"]) as DietPreference[],
);
setMealsPerDay(String(profile.mealsPerDay ?? 3));
setCooksPerDay(String(profile.cooksPerDay ?? 1));
setPreferredMealPrepDays(String(profile.preferredMealPrepDays ?? 0));
setMeatFishFrequency(
(profile.meatFishFrequency as "none" | "once_per_day" | "flexible") ?? "flexible",
);
}, [healthOverviewQuery.data?.profile]);
useEffect(() => {
const apiGoals = healthOverviewQuery.data?.goals;
if (!apiGoals) return;
setGoalsForm({
calories: apiGoals.calories ?? defaultGoalsForm.calories,
proteinG: apiGoals.proteinG ?? defaultGoalsForm.proteinG,
carbsG: apiGoals.carbsG ?? defaultGoalsForm.carbsG,
fatG: apiGoals.fatG ?? defaultGoalsForm.fatG,
drinkMl: apiGoals.drinkMl ?? defaultGoalsForm.drinkMl,
weightKg: Number(apiGoals.weightKg ?? defaultGoalsForm.weightKg),
fastingHours: apiGoals.fastingHours ?? defaultGoalsForm.fastingHours,
sportAddsCalories: apiGoals.sportAddsCalories ?? true,
});
}, [healthOverviewQuery.data?.goals]);
function toggleDietPreference(preference: DietPreference) {
setDietPreferences((current) => {
const next = current.includes(preference)
? current.filter((item) => item !== preference)
: [...current, preference];
return next.length ? next : current;
});
}
function saveEatingPreferences() {
const profile = healthOverviewQuery.data?.profile;
// Groesse/Gewicht sind optional und muessen > 0 sein. Alte Profile koennen
// hier noch 0 gespeichert haben; solche Werte als null behandeln, damit das
// Speichern der Essenspraeferenzen nicht an der Zod-Validierung scheitert.
const positiveOrNull = (value: number | string | null | undefined) => {
const num = value == null ? null : Number(value);
return num != null && num > 0 ? num : null;
};
updateHealthProfile.mutate({
goal: profile?.goal ?? "healthier",
age: profile?.age ?? null,
heightCm: positiveOrNull(profile?.heightCm),
currentWeightKg: positiveOrNull(profile?.currentWeightKg),
targetWeightKg: positiveOrNull(profile?.targetWeightKg),
activityLevel: profile?.activityLevel ?? "moderate",
dietStyle: profile?.dietStyle ?? "flexible",
dietPreferences,
// An die Zod-Grenzen klemmen, damit eine zu grosse Eingabe nicht die
// ganze Mutation abweist (mealsPerDay 1-8, cooksPerDay 1-4, Prep 0-7).
mealsPerDay: clamp(Number(mealsPerDay) || 3, 1, 8),
cooksPerDay: clamp(Number(cooksPerDay) || 1, 1, 4),
preferredMealPrepDays: clamp(Number(preferredMealPrepDays) || 0, 0, 7),
meatFishFrequency,
cookingTime:
(profile?.cookingTime as "low" | "medium" | "high" | undefined) ?? "medium",
budgetFocus: profile?.budgetFocus ?? false,
excludedFoods: profile?.excludedFoods ?? null,
biggestChallenge: profile?.biggestChallenge ?? null,
medicalNote: profile?.medicalNote ?? false,
completed: Boolean(profile?.completedAt),
});
}
function saveGoals() {
upsertGoals.mutate({
drinkMl: goalsForm.drinkMl,
calories: goalsForm.calories,
carbsG: goalsForm.carbsG,
fatG: goalsForm.fatG,
proteinG: goalsForm.proteinG,
weightKg: goalsForm.weightKg || null,
fastingHours: goalsForm.fastingHours,
// Wochenbudget wird automatisch aus dem Tagesziel abgeleitet (kein eigenes Feld mehr).
weeklyCalories: goalsForm.calories * 7,
sportAddsCalories: goalsForm.sportAddsCalories,
});
}
const isBusy = updateMyProfile.isPending || changeMyPassword.isPending;
const passwordMismatch =
confirmNewPassword.length > 0 && newPassword !== confirmNewPassword;
const canSaveProfile = useMemo(
() =>
!!firstName.trim() &&
firstName.trim().length >= 2 &&
(firstName.trim() !==
(profileQuery.data?.firstName ?? profileQuery.data?.name ?? "") ||
(lastName.trim() || null) !== (profileQuery.data?.lastName ?? null) ||
(phone.trim() || null) !== (profileQuery.data?.phone ?? null) ||
(mobile.trim() || null) !== (profileQuery.data?.mobile ?? null) ||
(street.trim() || null) !== (profileQuery.data?.street ?? null) ||
(postalCode.trim() || null) !== (profileQuery.data?.postalCode ?? null) ||
(city.trim() || null) !== (profileQuery.data?.city ?? null) ||
(image ?? null) !== (profileQuery.data?.image ?? null)),
[
firstName,
lastName,
phone,
mobile,
street,
postalCode,
city,
image,
profileQuery.data?.firstName,
profileQuery.data?.name,
profileQuery.data?.lastName,
profileQuery.data?.phone,
profileQuery.data?.mobile,
profileQuery.data?.street,
profileQuery.data?.postalCode,
profileQuery.data?.city,
profileQuery.data?.image,
],
);
async function handleAddPasskey() {
setErrorMessage(null);
setStatusMessage(null);
const result = await authClient.passkey.addPasskey({
name: newPasskeyName.trim() || undefined,
});
if (result.error) {
setErrorMessage(result.error.message ?? "Passkey konnte nicht hinzugefuegt werden.");
return;
}
setStatusMessage("Passkey erfolgreich hinzugefuegt.");
setNewPasskeyName("");
await passkeysQuery.refetch();
}
async function handleImageUpload(file: File) {
if (!file.type.startsWith("image/")) {
setErrorMessage("Bitte eine Bilddatei auswählen.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setErrorMessage("Profilbild ist zu groß. Maximal 5 MB.");
return;
}
try {
const dataUrl = await toOptimizedAvatarDataUrl(file);
setImage(dataUrl);
setErrorMessage(null);
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Profilbild konnte nicht verarbeitet werden.",
);
}
}
function handleSaveProfile() {
setStatusMessage(null);
setErrorMessage(null);
updateMyProfile.mutate({
firstName: firstName.trim(),
lastName: lastName.trim() || null,
phone: phone.trim() || null,
mobile: mobile.trim() || null,
street: street.trim() || null,
postalCode: postalCode.trim() || null,
city: city.trim() || null,
image,
});
}
function handleChangePassword() {
setStatusMessage(null);
setErrorMessage(null);
changeMyPassword.mutate({ currentPassword, newPassword });
}
return (
<DataPageLayout
title="Einstellungen"
description="Profil, Sicherheit und persönliche Vorlieben"
>
<div className="space-y-6">
<SettingsTabs active={active} onChange={setActive} />
<StatusBanner status={statusMessage} error={errorMessage} />
{profileQuery.isLoading ? (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500">
Profil wird geladen...
</div>
) : null}
{active === "profil" ? (
<SectionCard
title="Mein Profil"
description="Persönliche Daten und Kontakt. E-Mail-Änderung erfolgt über das Konto."
footer={
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveProfile}
disabled={isBusy || !canSaveProfile}
className={buttonPrimary}
>
Profil speichern
</button>
</div>
}
>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<div className="flex flex-col items-center gap-3 sm:items-start">
<div className="h-24 w-24 overflow-hidden rounded-full border border-gray-200 bg-gray-100 shadow-inner">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="Profilbild" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-2xl font-semibold text-gray-400">
{(firstName || profileQuery.data?.email || "?").slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="flex flex-col gap-2 text-center sm:text-left">
<label className={`${buttonSecondary} cursor-pointer text-xs`}>
Bild hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleImageUpload(file);
event.currentTarget.value = "";
}}
/>
</label>
{image ? (
<button
type="button"
onClick={() => setImage(null)}
className="text-xs font-medium text-red-600 hover:text-red-700"
>
Bild entfernen
</button>
) : null}
</div>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Vorname</label>
<input
className={inputClass}
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
placeholder="Vorname"
/>
</div>
<div>
<label className={labelClass}>Nachname</label>
<input
className={inputClass}
value={lastName}
onChange={(event) => setLastName(event.target.value)}
placeholder="Nachname"
/>
</div>
<div className="sm:col-span-2">
<label className={labelClass}>E-Mail</label>
<input
className={`${inputClass} cursor-not-allowed bg-gray-50 text-gray-500`}
value={profileQuery.data?.email ?? ""}
disabled
/>
</div>
<div>
<label className={labelClass}>Telefon</label>
<input
className={inputClass}
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="Telefon"
/>
</div>
<div>
<label className={labelClass}>Mobil</label>
<input
className={inputClass}
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="Mobil"
/>
</div>
<div className="sm:col-span-2">
<label className={labelClass}>Straße und Hausnummer</label>
<input
className={inputClass}
value={street}
onChange={(event) => setStreet(event.target.value)}
placeholder="Straße und Hausnummer"
/>
</div>
<div>
<label className={labelClass}>PLZ</label>
<input
className={inputClass}
value={postalCode}
onChange={(event) => setPostalCode(event.target.value)}
placeholder="PLZ"
/>
</div>
<div>
<label className={labelClass}>Ort</label>
<input
className={inputClass}
value={city}
onChange={(event) => setCity(event.target.value)}
placeholder="Ort"
/>
</div>
</div>
</div>
</SectionCard>
) : null}
{active === "profil" ? (
<form onSubmit={onSubmitCommunity}>
<SectionCard
title="Community-Profil"
description="Dein öffentlicher Auftritt. Gewicht, Kalorien und Ziele bleiben immer privat."
>
<div className="space-y-4">
<div>
<label className={labelClass} htmlFor="community-username">
Benutzername
</label>
<input
id="community-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="z. B. KochHeld"
className={inputClass}
/>
<p className="mt-1.5 text-xs text-gray-500">
{profileQuery.data?.handle
? `Dein Handle: ${profileQuery.data.handle}`
: "Buchstaben, Ziffern, _ und -. Eine vierstellige Kennung (#XXXX) wird automatisch und dauerhaft vergeben."}
</p>
</div>
<div>
<label className={labelClass} htmlFor="community-bio">
Über mich
</label>
<textarea
id="community-bio"
value={bio}
maxLength={280}
rows={3}
onChange={(event) => setBio(event.target.value)}
placeholder="Kurz über dich – optional."
className={inputClass}
/>
<p className="mt-1.5 text-right text-xs text-gray-500">
{bio.length}/280
</p>
</div>
<label className="flex items-start gap-3 rounded-lg bg-gray-50 p-3 text-sm">
<input
type="checkbox"
checked={profileVisible}
onChange={(event) => setProfileVisible(event.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0"
/>
<span className="text-gray-700">
<span className="font-semibold">Profil öffentlich sichtbar machen</span>
<span className="mt-0.5 block text-gray-500">
Andere können dann deinen Benutzernamen und deine Bio sehen.
Gewicht, Kalorien und Ziele bleiben immer privat.
</span>
</span>
</label>
<div className="flex justify-end">
<button type="submit" disabled={communityPending} className={buttonPrimary}>
{communityPending ? "Speichert..." : "Community-Profil speichern"}
</button>
</div>
</div>
</SectionCard>
</form>
) : null}
{active === "ernaehrung" ? (
<SectionCard
title="Rezept- und Mahlzeiten-Präferenzen"
description="Beeinflusst Vorschlaege und automatische Wochenplanung."
footer={
<div className="flex justify-end">
<button
type="button"
onClick={saveEatingPreferences}
disabled={updateHealthProfile.isPending}
className={buttonPrimary}
>
Präferenzen speichern
</button>
</div>
}
>
<div>
<p className={labelClass}>Ernährung</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{dietPreferenceOptions.map((option) => {
const selected = dietPreferences.includes(option.id);
return (
<button
key={option.id}
type="button"
onClick={() => toggleDietPreference(option.id)}
className={`rounded-full px-3 py-2 text-sm font-semibold transition-colors ${
selected
? "bg-[#0F766E] text-white shadow-sm"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<label className="block">
<span className={labelClass}>Mahlzeiten/Tag</span>
<input
className={inputClass}
inputMode="numeric"
value={mealsPerDay}
onChange={(event) => setMealsPerDay(event.target.value)}
/>
</label>
<label className="block">
<span className={labelClass}>Kochvorgänge/Tag</span>
<input
className={inputClass}
inputMode="numeric"
value={cooksPerDay}
onChange={(event) => setCooksPerDay(event.target.value)}
/>
</label>
<label className="block">
<span className={labelClass}>Mealprep-Tage</span>
<input
className={inputClass}
inputMode="numeric"
value={preferredMealPrepDays}
onChange={(event) => setPreferredMealPrepDays(event.target.value)}
/>
</label>
</div>
<label className="block">
<span className={labelClass}>Fleisch / Fisch</span>
<select
value={meatFishFrequency}
onChange={(event) =>
setMeatFishFrequency(
event.target.value as "none" | "once_per_day" | "flexible",
)
}
className={inputClass}
>
<option value="none">nie</option>
<option value="once_per_day">maximal 1x am Tag</option>
<option value="flexible">flexibel</option>
</select>
</label>
</SectionCard>
) : null}
{active === "ziele" ? (
<SectionCard
title="Ziele"
description="Alltagstaugliche Zielwerte für Kalorien, Makros, Wasser, Fasten und Gewicht. Das Wochenbudget wird automatisch als Tagesziel × 7 berechnet."
footer={
<div className="flex justify-end">
<button
type="button"
onClick={saveGoals}
disabled={upsertGoals.isPending}
className={buttonPrimary}
>
{upsertGoals.isPending ? "Speichere…" : "Ziele speichern"}
</button>
</div>
}
>
<div className="space-y-4">
{goalsFields.map((field) => (
<label key={field.key} className="flex items-center gap-3">
<field.icon className="h-6 w-6 shrink-0 text-[#0F766E]" />
<span className="flex-1 text-sm font-medium text-gray-700">
{field.label}
</span>
<div className="flex items-center gap-2">
<input
type="number"
inputMode="numeric"
value={goalsForm[field.key]}
onChange={(event) =>
setGoalsForm((current) => ({
...current,
[field.key]: Number(event.target.value) || 0,
}))
}
className={`${inputClass} w-28 text-right`}
/>
<span className="w-10 text-sm font-medium text-gray-400">
{field.unit}
</span>
</div>
</label>
))}
</div>
{goalsForm.calories < 1500 ? (
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800">
<span className="text-base" aria-hidden>⚠️</span>
<span>
Ein Tagesziel unter 1.500 kcal liegt am unteren Rand des Grundbedarfs.
Sehr niedrige Ziele bitte nur in ärztlicher Absprache wählen. Das Minimum
beträgt 1.200 kcal.
</span>
</div>
) : null}
<label className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700">
Sportkalorien erhöhen das Tagesbudget
<input
type="checkbox"
checked={goalsForm.sportAddsCalories}
onChange={(event) =>
setGoalsForm((current) => ({
...current,
sportAddsCalories: event.target.checked,
}))
}
/>
</label>
{isPlatformAdmin ? (
<div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 text-sm text-gray-600">
Als Admin kannst du die{" "}
<Link
href="/einstellungen/punkte"
className="font-semibold text-[#0F766E] underline"
>
Punkte-Konfiguration
</Link>{" "}
global anpassen.
</div>
) : null}
</SectionCard>
) : null}
{active === "sicherheit" ? (
<div className="space-y-6">
<SectionCard
title="Zwei-Faktor-Authentifizierung"
description="Schuetzt deinen Account zusaetzlich beim Login."
>
<div className="flex items-start gap-3 rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
<ShieldCheckIcon
className={`h-5 w-5 shrink-0 ${
securityQuery.data?.twoFactorEnabled
? "text-emerald-500"
: "text-gray-400"
}`}
/>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">
Status:{" "}
{securityQuery.data?.twoFactorEnabled ? "aktiv" : "nicht aktiv"}
</p>
{securityQuery.data?.twoFactorRecoveryRequired ? (
<p className="mt-1 text-xs text-amber-700">
Recovery aktiv: Bitte OTP neu einrichten.
</p>
) : (
<p className="mt-1 text-xs text-gray-500">
Empfohlen, besonders für Admin-Accounts.
</p>
)}
</div>
<Link href="/sicherheit/2fa-setup" className={buttonSecondary}>
2FA verwalten
</Link>
</div>
</SectionCard>
<SectionCard
title="Passkeys"
description="Anmeldung per Geraet (Windows Hello, Touch ID, Passwortmanager)."
>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={newPasskeyName}
onChange={(event) => setNewPasskeyName(event.target.value)}
placeholder="Name (optional), z. B. Laptop"
className={inputClass}
/>
<button
type="button"
onClick={() => void handleAddPasskey()}
disabled={isBusy}
className={buttonPrimary}
>
<KeyIcon className="mr-1.5 h-4 w-4" />
Hinzufuegen
</button>
</div>
<div className="space-y-2">
{passkeysQuery.isPending ? (
<p className="text-xs text-gray-500">Passkeys werden geladen...</p>
) : null}
{passkeysQuery.error ? (
<p className="text-xs text-red-600">
{passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."}
</p>
) : null}
{!passkeysQuery.isPending &&
!passkeysQuery.error &&
(passkeysQuery.data?.length ?? 0) === 0 ? (
<p className="text-xs text-gray-500">Noch keine Passkeys hinterlegt.</p>
) : null}
{(passkeysQuery.data ?? []).map((item) => (
<div
key={item.id}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700"
>
<p className="font-semibold text-gray-800">
{item.name || "Unbenannter Passkey"}
</p>
<p className="text-gray-500">
Erstellt: {new Date(item.createdAt).toLocaleString("de-DE")}
</p>
</div>
))}
</div>
</SectionCard>
<SectionCard
title="Passwort"
description="Mindestens 16 Zeichen, gerne ein Generator."
footer={
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => {
const generated = generatePassword(16);
setNewPassword(generated);
setConfirmNewPassword(generated);
setShowNewPassword(true);
setShowConfirmPassword(true);
}}
className={buttonSecondary}
>
Sicheres Passwort generieren
</button>
<button
type="button"
onClick={handleChangePassword}
disabled={
isBusy ||
!currentPassword ||
newPassword.length < 16 ||
confirmNewPassword.length < 16 ||
passwordMismatch
}
className={buttonPrimary}
>
Passwort aktualisieren
</button>
</div>
}
>
<PasswordInput
value={currentPassword}
onChange={setCurrentPassword}
show={showCurrentPassword}
onToggle={() => setShowCurrentPassword((value) => !value)}
placeholder="Aktuelles Passwort"
/>
<PasswordInput
value={newPassword}
onChange={setNewPassword}
show={showNewPassword}
onToggle={() => setShowNewPassword((value) => !value)}
placeholder="Neues Passwort (mind. 16 Zeichen)"
/>
<PasswordInput
value={confirmNewPassword}
onChange={setConfirmNewPassword}
show={showConfirmPassword}
onToggle={() => setShowConfirmPassword((value) => !value)}
placeholder="Neues Passwort bestaetigen"
invalid={passwordMismatch}
/>
{passwordMismatch ? (
<p className="text-xs text-red-600">
Die neuen Passwörter stimmen nicht überein.
</p>
) : null}
</SectionCard>
</div>
) : null}
{active === "anwendung" ? (
<div className="space-y-6">
<SectionCard
title="Erscheinungsbild"
description="Hell oder dunkel. Wird pro Browser gespeichert."
>
<div className="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 px-4 py-3">
<div className="flex items-center gap-3">
{isDark ? (
<MoonIcon className="h-5 w-5 text-indigo-400" />
) : (
<SunIcon className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="text-sm font-semibold text-gray-900">Dunkles Design</p>
<p className="text-xs text-gray-500">
{isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleDark}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors ${
isDark ? "bg-[#0F766E]" : "bg-gray-200"
}`}
role="switch"
aria-checked={isDark}
aria-label="Dunkles Design umschalten"
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition ${
isDark ? "translate-x-5" : "translate-x-0.5"
} translate-y-0.5`}
/>
</button>
</div>
</SectionCard>
<SectionCard
title="Erinnerungen"
description="Tracking-, Plan- und Wasser-Erinnerungen verwalten."
>
<Link
href="/erinnerungen"
className="flex items-center gap-3 rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100"
>
<BellIcon className="h-5 w-5 text-emerald-500" />
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">
Taegliche Erinnerungen einrichten
</p>
<p className="text-xs text-gray-500">
Push, E-Mail und Browser-Notifications.
</p>
</div>
<ChevronRightIcon className="h-4 w-4 text-gray-300" />
</Link>
</SectionCard>
</div>
) : null}
</div>
</DataPageLayout>
);
}
function PasswordInput({
value,
onChange,
show,
onToggle,
placeholder,
invalid,
}: {
value: string;
onChange: (v: string) => void;
show: boolean;
onToggle: () => void;
placeholder: string;
invalid?: boolean;
}) {
return (
<div className="relative">
<input
type={show ? "text" : "password"}
className={`${inputClass} pr-11 ${invalid ? "border-red-300" : ""}`}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
<button
type="button"
onClick={onToggle}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
>
{show ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
);
}