2026-05-29 10:07:09 +02:00
"use client" ;
2026-05-27 22:15:16 +02:00
import {
BeakerIcon ,
BellIcon ,
ChevronRightIcon ,
ClockIcon ,
EyeIcon ,
EyeSlashIcon ,
FireIcon ,
2026-05-29 10:07:09 +02:00
KeyIcon ,
2026-05-27 22:15:16 +02:00
MoonIcon ,
ScaleIcon ,
2026-05-29 10:07:09 +02:00
ShieldCheckIcon ,
2026-05-27 22:15:16 +02:00
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" ;
2026-05-29 10:07:09 +02:00
import { SectionCard } from "./_components/section-card" ;
import { SettingsTabs , useSettingsTab } from "./_components/settings-tabs" ;
import { StatusBanner } from "./_components/status-banner" ;
2026-05-27 22:15:16 +02:00
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" ] ;
2026-05-29 11:12:34 +02:00
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 } ,
] ;
2026-05-27 22:15:16 +02:00
function todayKey() {
return new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
}
2026-05-29 11:12:34 +02:00
function clamp ( value : number , min : number , max : number ) {
return Math . min ( Math . max ( value , min ) , max ) ;
}
2026-05-27 22:15:16 +02:00
function generatePassword ( length = 16 ) {
const bytes = crypto . getRandomValues ( new Uint8Array ( length ) ) ;
2026-05-29 10:07:09 +02:00
return Array . from ( bytes , ( byte ) = > PASSWORD_CHARS [ byte % PASSWORD_CHARS . length ] ! ) . join ( "" ) ;
2026-05-27 22:15:16 +02:00
}
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" ) ;
2026-05-29 10:20:24 +02:00
if ( ! ctx ) throw new Error ( "Canvas nicht verfügbar" ) ;
2026-05-27 22:15:16 +02:00
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 ) {
2026-05-29 10:20:24 +02:00
throw new Error ( "Profilbild ist nach Komprimierung noch zu groß." ) ;
2026-05-27 22:15:16 +02:00
}
return dataUrl ;
}
2026-05-29 10:07:09 +02:00
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" ;
2026-05-27 22:15:16 +02:00
export default function EinstellungenPage() {
const { isDark , toggleDark } = useTheme ( ) ;
2026-05-29 10:07:09 +02:00
const { active , setActive } = useSettingsTab ( ) ;
2026-05-27 22:15:16 +02:00
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 ) ;
2026-05-29 10:07:09 +02:00
const [ dietPreferences , setDietPreferences ] = useState < DietPreference [ ] > ( [
"vegan" ,
"vegetarian" ,
"meat" ,
"fish" ,
] ) ;
2026-05-27 22:15:16 +02:00
const [ mealsPerDay , setMealsPerDay ] = useState ( "3" ) ;
const [ cooksPerDay , setCooksPerDay ] = useState ( "1" ) ;
const [ preferredMealPrepDays , setPreferredMealPrepDays ] = useState ( "0" ) ;
2026-05-29 10:07:09 +02:00
const [ meatFishFrequency , setMeatFishFrequency ] = useState <
"none" | "once_per_day" | "flexible"
> ( "flexible" ) ;
2026-05-29 11:12:34 +02:00
const [ goalsForm , setGoalsForm ] = useState < GoalsForm > ( defaultGoalsForm ) ;
2026-05-27 22:15:16 +02:00
const isPlatformAdmin = profileQuery . data ? . role === "admin" ;
2026-05-29 10:07:09 +02:00
2026-05-27 22:15:16 +02:00
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 ( ) = > {
2026-05-29 10:07:09 +02:00
await Promise . all ( [
utils . users . myProfile . invalidate ( ) ,
utils . security . status . invalidate ( ) ,
] ) ;
2026-05-27 22:15:16 +02:00
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 ( ) ;
2026-05-29 10:20:24 +02:00
setStatusMessage ( "Essenspräferenzen gespeichert." ) ;
2026-05-27 22:15:16 +02:00
setErrorMessage ( null ) ;
} ,
onError : ( error ) = > {
setErrorMessage ( error . message ) ;
setStatusMessage ( null ) ;
} ,
} ) ;
2026-05-29 11:12:34 +02:00
const upsertGoals = trpc . health . upsertGoals . useMutation ( {
onSuccess : async ( ) = > {
await healthOverviewQuery . refetch ( ) ;
setStatusMessage ( "Ziele gespeichert." ) ;
setErrorMessage ( null ) ;
} ,
onError : ( error ) = > {
setErrorMessage ( error . message ) ;
setStatusMessage ( null ) ;
} ,
} ) ;
2026-05-27 22:15:16 +02:00
useEffect ( ( ) = > {
const profile = healthOverviewQuery . data ? . profile ;
if ( ! profile ) return ;
2026-05-29 10:07:09 +02:00
setDietPreferences (
( profile . dietPreferences ? . length
? profile . dietPreferences
: [ "vegan" , "vegetarian" , "meat" , "fish" ] ) as DietPreference [ ] ,
) ;
2026-05-27 22:15:16 +02:00
setMealsPerDay ( String ( profile . mealsPerDay ? ? 3 ) ) ;
setCooksPerDay ( String ( profile . cooksPerDay ? ? 1 ) ) ;
setPreferredMealPrepDays ( String ( profile . preferredMealPrepDays ? ? 0 ) ) ;
2026-05-29 10:07:09 +02:00
setMeatFishFrequency (
( profile . meatFishFrequency as "none" | "once_per_day" | "flexible" ) ? ? "flexible" ,
) ;
2026-05-27 22:15:16 +02:00
} , [ healthOverviewQuery . data ? . profile ] ) ;
2026-05-29 11:12:34 +02:00
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 ] ) ;
2026-05-27 22:15:16 +02:00
function toggleDietPreference ( preference : DietPreference ) {
setDietPreferences ( ( current ) = > {
2026-05-29 10:07:09 +02:00
const next = current . includes ( preference )
? current . filter ( ( item ) = > item !== preference )
: [ . . . current , preference ] ;
2026-05-27 22:15:16 +02:00
return next . length ? next : current ;
} ) ;
}
function saveEatingPreferences() {
const profile = healthOverviewQuery . data ? . profile ;
2026-05-29 11:12:34 +02:00
// 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 ;
} ;
2026-05-27 22:15:16 +02:00
updateHealthProfile . mutate ( {
goal : profile?.goal ? ? "healthier" ,
age : profile?.age ? ? null ,
2026-05-29 11:12:34 +02:00
heightCm : positiveOrNull ( profile ? . heightCm ) ,
currentWeightKg : positiveOrNull ( profile ? . currentWeightKg ) ,
targetWeightKg : positiveOrNull ( profile ? . targetWeightKg ) ,
2026-05-27 22:15:16 +02:00
activityLevel : profile?.activityLevel ? ? "moderate" ,
dietStyle : profile?.dietStyle ? ? "flexible" ,
dietPreferences ,
2026-05-29 11:12:34 +02:00
// 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 ) ,
2026-05-27 22:15:16 +02:00
meatFishFrequency ,
2026-05-29 10:07:09 +02:00
cookingTime :
( profile ? . cookingTime as "low" | "medium" | "high" | undefined ) ? ? "medium" ,
2026-05-27 22:15:16 +02:00
budgetFocus : profile?.budgetFocus ? ? false ,
excludedFoods : profile?.excludedFoods ? ? null ,
biggestChallenge : profile?.biggestChallenge ? ? null ,
medicalNote : profile?.medicalNote ? ? false ,
completed : Boolean ( profile ? . completedAt ) ,
} ) ;
}
2026-05-29 11:12:34 +02:00
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 ,
} ) ;
}
2026-05-27 22:15:16 +02:00
const isBusy = updateMyProfile . isPending || changeMyPassword . isPending ;
const passwordMismatch =
confirmNewPassword . length > 0 && newPassword !== confirmNewPassword ;
const canSaveProfile = useMemo (
( ) = >
! ! firstName . trim ( ) &&
firstName . trim ( ) . length >= 2 &&
2026-05-29 10:07:09 +02:00
( firstName . trim ( ) !==
( profileQuery . data ? . firstName ? ? profileQuery . data ? . name ? ? "" ) ||
2026-05-27 22:15:16 +02:00
( 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 ) {
2026-05-29 10:07:09 +02:00
setErrorMessage ( result . error . message ? ? "Passkey konnte nicht hinzugefuegt werden." ) ;
2026-05-27 22:15:16 +02:00
return ;
}
2026-05-29 10:07:09 +02:00
setStatusMessage ( "Passkey erfolgreich hinzugefuegt." ) ;
2026-05-27 22:15:16 +02:00
setNewPasskeyName ( "" ) ;
await passkeysQuery . refetch ( ) ;
}
async function handleImageUpload ( file : File ) {
if ( ! file . type . startsWith ( "image/" ) ) {
2026-05-29 10:20:24 +02:00
setErrorMessage ( "Bitte eine Bilddatei auswählen." ) ;
2026-05-27 22:15:16 +02:00
return ;
}
if ( file . size > 5 * 1024 * 1024 ) {
2026-05-29 10:20:24 +02:00
setErrorMessage ( "Profilbild ist zu groß. Maximal 5 MB." ) ;
2026-05-27 22:15:16 +02:00
return ;
}
try {
const dataUrl = await toOptimizedAvatarDataUrl ( file ) ;
setImage ( dataUrl ) ;
setErrorMessage ( null ) ;
} catch ( error ) {
setErrorMessage (
error instanceof Error
? error . message
: "Profilbild konnte nicht verarbeitet werden." ,
) ;
}
}
2026-05-29 10:07:09 +02:00
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 ,
} ) ;
}
2026-05-27 22:15:16 +02:00
2026-05-29 10:07:09 +02:00
function handleChangePassword() {
setStatusMessage ( null ) ;
setErrorMessage ( null ) ;
changeMyPassword . mutate ( { currentPassword , newPassword } ) ;
}
2026-05-27 22:15:16 +02:00
2026-05-29 10:07:09 +02:00
return (
< DataPageLayout
title = "Einstellungen"
2026-05-29 10:20:24 +02:00
description = "Profil, Sicherheit und persönliche Vorlieben"
2026-05-29 10:07:09 +02:00
>
< 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 . . .
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
) : null }
{ active === "profil" ? (
< SectionCard
title = "Mein Profil"
2026-05-29 10:20:24 +02:00
description = "Persönliche Daten und Kontakt. E-Mail-Änderung erfolgt über das Konto."
2026-05-29 10:07:09 +02:00
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" >
2026-05-27 22:15:16 +02:00
{ image ? (
// eslint-disable-next-line @next/next/no-img-element
2026-05-29 10:07:09 +02:00
< img src = { image } alt = "Profilbild" className = "h-full w-full object-cover" / >
2026-05-27 22:15:16 +02:00
) : (
2026-05-29 10:07:09 +02:00
< 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 ( ) }
2026-05-27 22:15:16 +02:00
< / div >
) }
< / div >
2026-05-29 10:07:09 +02:00
< div className = "flex flex-col gap-2 text-center sm:text-left" >
< label className = { ` ${ buttonSecondary } cursor-pointer text-xs ` } >
2026-05-27 22:15:16 +02:00
Bild hochladen
< input
type = "file"
accept = "image/*"
className = "hidden"
onChange = { ( event ) = > {
const file = event . target . files ? . [ 0 ] ;
2026-05-29 10:07:09 +02:00
if ( file ) void handleImageUpload ( file ) ;
2026-05-27 22:15:16 +02:00
event . currentTarget . value = "" ;
} }
/ >
< / label >
2026-05-29 10:07:09 +02:00
{ 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" >
2026-05-29 10:20:24 +02:00
< label className = { labelClass } > Straße und Hausnummer < / label >
2026-05-29 10:07:09 +02:00
< input
className = { inputClass }
value = { street }
onChange = { ( event ) = > setStreet ( event . target . value ) }
2026-05-29 10:20:24 +02:00
placeholder = "Straße und Hausnummer"
2026-05-29 10:07:09 +02:00
/ >
< / 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"
/ >
2026-05-27 22:15:16 +02:00
< / div >
< / div >
< / div >
2026-05-29 10:07:09 +02:00
< / SectionCard >
) : null }
{ active === "ernaehrung" ? (
< SectionCard
2026-05-29 10:20:24 +02:00
title = "Rezept- und Mahlzeiten-Präferenzen"
2026-05-29 10:07:09 +02:00
description = "Beeinflusst Vorschlaege und automatische Wochenplanung."
footer = {
< div className = "flex justify-end" >
< button
type = "button"
onClick = { saveEatingPreferences }
disabled = { updateHealthProfile . isPending }
className = { buttonPrimary }
>
2026-05-29 10:20:24 +02:00
Präferenzen speichern
2026-05-29 10:07:09 +02:00
< / button >
< / div >
}
>
2026-05-27 22:15:16 +02:00
< div >
2026-05-29 10:20:24 +02:00
< p className = { labelClass } > Ernährung < / p >
2026-05-29 10:07:09 +02:00
< 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 >
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
< 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" >
2026-05-29 10:20:24 +02:00
< span className = { labelClass } > Kochvorgänge / Tag < / span >
2026-05-29 10:07:09 +02:00
< 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 >
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
< 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 1 x am Tag < / option >
< option value = "flexible" > flexibel < / option >
< / select >
< / label >
< / SectionCard >
) : null }
{ active === "ziele" ? (
< SectionCard
title = "Ziele"
2026-05-29 11:12:34 +02:00
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 }
2026-05-29 10:07:09 +02:00
>
2026-05-29 11:12:34 +02:00
{ 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 }
2026-05-29 10:07:09 +02:00
< / span >
2026-05-29 11:12:34 +02:00
< 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 >
2026-05-29 10:07:09 +02:00
) ) }
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 11:41:41 +02:00
{ 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 }
2026-05-29 11:12:34 +02:00
< 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 >
2026-05-29 10:07:09 +02:00
{ 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."
2026-05-27 22:15:16 +02:00
>
2026-05-29 10:07:09 +02:00
< 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" >
2026-05-29 10:20:24 +02:00
Empfohlen , besonders für Admin - Accounts .
2026-05-29 10:07:09 +02:00
< / p >
) }
< / div >
< Link href = "/sicherheit/2fa-setup" className = { buttonSecondary } >
2 FA verwalten
< / Link >
< / div >
< / SectionCard >
2026-05-27 22:15:16 +02:00
2026-05-29 10:07:09 +02:00
< 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 &&
2026-05-27 22:15:16 +02:00
! passkeysQuery . error &&
2026-05-29 10:07:09 +02:00
( passkeysQuery . data ? . length ? ? 0 ) === 0 ? (
2026-05-27 22:15:16 +02:00
< p className = "text-xs text-gray-500" > Noch keine Passkeys hinterlegt . < / p >
2026-05-29 10:07:09 +02:00
) : 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 >
2026-05-27 22:15:16 +02:00
2026-05-29 10:07:09 +02:00
< 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
2026-05-27 22:15:16 +02:00
value = { currentPassword }
2026-05-29 10:07:09 +02:00
onChange = { setCurrentPassword }
show = { showCurrentPassword }
onToggle = { ( ) = > setShowCurrentPassword ( ( value ) = > ! value ) }
placeholder = "Aktuelles Passwort"
2026-05-27 22:15:16 +02:00
/ >
2026-05-29 10:07:09 +02:00
< PasswordInput
2026-05-27 22:15:16 +02:00
value = { newPassword }
2026-05-29 10:07:09 +02:00
onChange = { setNewPassword }
show = { showNewPassword }
onToggle = { ( ) = > setShowNewPassword ( ( value ) = > ! value ) }
placeholder = "Neues Passwort (mind. 16 Zeichen)"
2026-05-27 22:15:16 +02:00
/ >
2026-05-29 10:07:09 +02:00
< PasswordInput
2026-05-27 22:15:16 +02:00
value = { confirmNewPassword }
2026-05-29 10:07:09 +02:00
onChange = { setConfirmNewPassword }
show = { showConfirmPassword }
onToggle = { ( ) = > setShowConfirmPassword ( ( value ) = > ! value ) }
placeholder = "Neues Passwort bestaetigen"
invalid = { passwordMismatch }
2026-05-27 22:15:16 +02:00
/ >
2026-05-29 10:07:09 +02:00
{ passwordMismatch ? (
< p className = "text-xs text-red-600" >
2026-05-29 10:20:24 +02:00
Die neuen Passwörter stimmen nicht ü berein .
2026-05-29 10:07:09 +02:00
< / p >
) : null }
< / SectionCard >
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
) : null }
2026-05-27 22:15:16 +02:00
2026-05-29 10:07:09 +02:00
{ 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 >
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
< / SectionCard >
< SectionCard
title = "Erinnerungen"
description = "Tracking-, Plan- und Wasser-Erinnerungen verwalten."
2026-05-27 22:15:16 +02:00
>
2026-05-29 10:07:09 +02:00
< 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 >
2026-05-27 22:15:16 +02:00
< / div >
2026-05-29 10:07:09 +02:00
) : null }
2026-05-27 22:15:16 +02:00
< / div >
< / DataPageLayout >
) ;
}
2026-05-29 10:07:09 +02:00
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 >
) ;
}