"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; 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(null); const [errorMessage, setErrorMessage] = useState(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(null); const [dietPreferences, setDietPreferences] = useState([ "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(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); } }, [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 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 (
{profileQuery.isLoading ? (
Profil wird geladen...
) : null} {active === "profil" ? (
} >
{image ? ( // eslint-disable-next-line @next/next/no-img-element Profilbild ) : (
{(firstName || profileQuery.data?.email || "?").slice(0, 1).toUpperCase()}
)}
{image ? ( ) : null}
setFirstName(event.target.value)} placeholder="Vorname" />
setLastName(event.target.value)} placeholder="Nachname" />
setPhone(event.target.value)} placeholder="Telefon" />
setMobile(event.target.value)} placeholder="Mobil" />
setStreet(event.target.value)} placeholder="Straße und Hausnummer" />
setPostalCode(event.target.value)} placeholder="PLZ" />
setCity(event.target.value)} placeholder="Ort" />
) : null} {active === "ernaehrung" ? ( } >

Ernährung

{dietPreferenceOptions.map((option) => { const selected = dietPreferences.includes(option.id); return ( ); })}
) : null} {active === "ziele" ? ( } >
{goalsFields.map((field) => ( ))}
{isPlatformAdmin ? (
Als Admin kannst du die{" "} Punkte-Konfiguration {" "} global anpassen.
) : null}
) : null} {active === "sicherheit" ? (

Status:{" "} {securityQuery.data?.twoFactorEnabled ? "aktiv" : "nicht aktiv"}

{securityQuery.data?.twoFactorRecoveryRequired ? (

Recovery aktiv: Bitte OTP neu einrichten.

) : (

Empfohlen, besonders für Admin-Accounts.

)}
2FA verwalten
setNewPasskeyName(event.target.value)} placeholder="Name (optional), z. B. Laptop" className={inputClass} />
{passkeysQuery.isPending ? (

Passkeys werden geladen...

) : null} {passkeysQuery.error ? (

{passkeysQuery.error.message ?? "Passkeys konnten nicht geladen werden."}

) : null} {!passkeysQuery.isPending && !passkeysQuery.error && (passkeysQuery.data?.length ?? 0) === 0 ? (

Noch keine Passkeys hinterlegt.

) : null} {(passkeysQuery.data ?? []).map((item) => (

{item.name || "Unbenannter Passkey"}

Erstellt: {new Date(item.createdAt).toLocaleString("de-DE")}

))}
} > setShowCurrentPassword((value) => !value)} placeholder="Aktuelles Passwort" /> setShowNewPassword((value) => !value)} placeholder="Neues Passwort (mind. 16 Zeichen)" /> setShowConfirmPassword((value) => !value)} placeholder="Neues Passwort bestaetigen" invalid={passwordMismatch} /> {passwordMismatch ? (

Die neuen Passwörter stimmen nicht überein.

) : null} ) : null} {active === "anwendung" ? (
{isDark ? ( ) : ( )}

Dunkles Design

{isDark ? "Dunkles Design ist aktiv" : "Helles Design ist aktiv"}

Taegliche Erinnerungen einrichten

Push, E-Mail und Browser-Notifications.

) : null}
); } function PasswordInput({ value, onChange, show, onToggle, placeholder, invalid, }: { value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void; placeholder: string; invalid?: boolean; }) { return (
onChange(event.target.value)} />
); }