"use client"; import { BeakerIcon, BellIcon, ChevronRightIcon, ClockIcon, EyeIcon, EyeSlashIcon, FireIcon, MoonIcon, ScaleIcon, 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"; 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; } export default function EinstellungenPage() { const { isDark, toggleDark } = useTheme(); 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 hinzugefügt werden."); return; } setStatusMessage("Passkey erfolgreich hinzugefügt."); 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.", ); } } return (

Ziele

{[ { icon: FireIcon, label: "Kalorien", value: `${healthGoals?.calories ?? 2000} kcal`, color: "text-orange-500" }, { icon: ScaleIcon, label: "Makros", 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-blue-500" }, { icon: ScaleIcon, label: "Gewicht", value: `${healthGoals?.weightKg ?? 120} kg`, color: "text-orange-500" }, ].map((item) => ( {item.label} {item.value} ))}

Rezept-Präferenzen

Ernährung

{dietPreferenceOptions.map((option) => ( ))}

Anwendung

Tägliche Erinnerung

Tracking und Planung

{isDark ? : }

Dunkles Design

{isDark ? "aktiv" : "inaktiv"}

Mein Profil

Du kannst dein eigenes Profil bearbeiten. Höhere Admins können dich zusätzlich in der Benutzerverwaltung bearbeiten.

setFirstName(event.target.value)} placeholder="Vorname" />
setLastName(event.target.value)} placeholder="Nachname" />
{image ? ( // eslint-disable-next-line @next/next/no-img-element Profilbild ) : (
-
)}

Bild wird automatisch skaliert und komprimiert.

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" />

Sicherheit

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

{securityQuery.data?.twoFactorRecoveryRequired && (

2FA Recovery aktiv: Bitte OTP neu einrichten.

)}
2FA verwalten

Passkeys

Passkeys erlauben die Anmeldung per Gerät (z. B. Windows Hello, Touch ID oder Passwortmanager).

setNewPasskeyName(event.target.value)} placeholder="Name (optional), z. B. Laptop" className="flex-1 border border-gray-200 rounded-lg px-3 py-2.5 text-sm" />
{passkeysQuery.isPending && (

Passkeys werden geladen...

)} {passkeysQuery.error && (

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

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

Noch keine Passkeys hinterlegt.

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

{item.name || "Unbenannter Passkey"}

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

))}

Passwort zurücksetzen

setCurrentPassword(event.target.value)} />
setNewPassword(event.target.value)} />
setConfirmNewPassword(event.target.value)} />
{passwordMismatch && (

Die neuen Passwörter stimmen nicht überein.

)}

Darstellung

Erscheinungsbild der Anwendung anpassen.

{isDark ? ( ) : ( )}

Dunkles Design

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

{profileQuery.isLoading && (
Profil wird geladen...
)} {(profileQuery.error || securityQuery.error) && (
{profileQuery.error?.message ?? securityQuery.error?.message}
)} {statusMessage && (
{statusMessage}
)} {errorMessage && (
{errorMessage}
)}
); }