"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 { HellthGoals, loadHellthData } from "@/lib/hellth-data"; 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"]; function todayKey() { return new Date().toISOString().slice(0, 10); } 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 userKey = profileQuery.data?.id ?? profileQuery.data?.email ?? null; const [statusMessage, setStatusMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [healthGoals, setHealthGoals] = 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 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]); useEffect(() => { if (!userKey) return; setHealthGoals(loadHellthData(userKey).goals); }, [userKey]); 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); }, }); 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]); 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; updateHealthProfile.mutate({ goal: profile?.goal ?? "healthier", age: profile?.age ?? null, heightCm: profile?.heightCm == null ? null : Number(profile.heightCm), currentWeightKg: profile?.currentWeightKg == null ? null : Number(profile.currentWeightKg), targetWeightKg: profile?.targetWeightKg == null ? null : Number(profile.targetWeightKg), activityLevel: profile?.activityLevel ?? "moderate", dietStyle: profile?.dietStyle ?? "flexible", dietPreferences, mealsPerDay: Number(mealsPerDay) || 3, cooksPerDay: Number(cooksPerDay) || 1, preferredMealPrepDays: Number(preferredMealPrepDays) || 0, 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), }); } 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" ? (
{[ { icon: FireIcon, label: "Kalorien", value: `${healthGoals?.calories ?? 2000} kcal`, color: "text-orange-500", }, { icon: ScaleIcon, label: "Makros (KH / Fett / Eiweiß)", value: `${healthGoals?.carbs ?? 200} / ${healthGoals?.fat ?? 67} / ${ healthGoals?.protein ?? 150 } g`, color: "text-blue-500", }, { icon: ClockIcon, label: "Fasten", value: `${healthGoals?.fastingHours ?? 12} h`, color: "text-rose-500", }, { icon: BeakerIcon, label: "Wasser", value: `${healthGoals?.drinkMl ?? 2500} ml`, color: "text-cyan-500", }, { icon: ScaleIcon, label: "Gewicht", value: `${healthGoals?.weightKg ?? 120} kg`, color: "text-emerald-500", }, ].map((item) => ( {item.label} {item.value} ))}
{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)} />
); }