@@ -0,0 +1,745 @@
import React , { useEffect , useMemo , useState } from 'react' ;
import { useParams , useNavigate } from 'react-router-dom' ;
import { useTranslation } from 'react-i18next' ;
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query' ;
import { ArrowLeft , Loader2 , Moon , Sparkles , Sun } from 'lucide-react' ;
import toast from 'react-hot-toast' ;
import { AdminLayout } from '../components/AdminLayout' ;
import { SectionCard , SectionHeader } from '../components/tenant' ;
import { Button } from '@/components/ui/button' ;
import { Input } from '@/components/ui/input' ;
import { Label } from '@/components/ui/label' ;
import { Switch } from '@/components/ui/switch' ;
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from '@/components/ui/select' ;
import { Card , CardContent , CardHeader , CardTitle } from '@/components/ui/card' ;
import { Separator } from '@/components/ui/separator' ;
import { ADMIN_EVENT_VIEW_PATH } from '../constants' ;
import { getEvent , getTenantSettings , updateEvent , type TenantEvent } from '../api' ;
import { cn } from '@/lib/utils' ;
import { getContrastingTextColor } from '../../guest/lib/color' ;
import { buildEventTabs } from '../lib/eventTabs' ;
import { ensureFontLoaded , useTenantFonts } from '../lib/fonts' ;
type BrandingForm = {
useDefault : boolean ;
palette : {
primary : string ;
secondary : string ;
background : string ;
surface : string ;
} ;
typography : {
heading : string ;
body : string ;
size : 's' | 'm' | 'l' ;
} ;
logo : {
mode : 'emoticon' | 'upload' ;
value : string ;
position : 'left' | 'right' | 'center' ;
size : 's' | 'm' | 'l' ;
} ;
buttons : {
style : 'filled' | 'outline' ;
radius : number ;
primary : string ;
secondary : string ;
linkColor : string ;
} ;
mode : 'light' | 'dark' | 'auto' ;
} ;
type BrandingSource = Record < string , unknown > | null | undefined ;
const DEFAULT_BRANDING_FORM : BrandingForm = {
useDefault : false ,
palette : {
primary : '#f43f5e' ,
secondary : '#fb7185' ,
background : '#ffffff' ,
surface : '#ffffff' ,
} ,
typography : {
heading : '' ,
body : '' ,
size : 'm' ,
} ,
logo : {
mode : 'emoticon' ,
value : '✨' ,
position : 'left' ,
size : 'm' ,
} ,
buttons : {
style : 'filled' ,
radius : 12 ,
primary : '#f43f5e' ,
secondary : '#fb7185' ,
linkColor : '#fb7185' ,
} ,
mode : 'auto' ,
} ;
function asString ( value : unknown , fallback = '' ) : string {
return typeof value === 'string' ? value : fallback ;
}
function asHex ( value : unknown , fallback : string ) : string {
if ( typeof value !== 'string' ) return fallback ;
const trimmed = value . trim ( ) ;
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ . test ( trimmed ) ? trimmed : fallback ;
}
function asNumber ( value : unknown , fallback : number ) : number {
if ( typeof value === 'number' && Number . isFinite ( value ) ) {
return value ;
}
if ( typeof value === 'string' && value . trim ( ) !== '' ) {
const parsed = Number ( value ) ;
return Number . isFinite ( parsed ) ? parsed : fallback ;
}
return fallback ;
}
function coerceSize ( value : unknown , fallback : 's' | 'm' | 'l' ) : 's' | 'm' | 'l' {
return value === 's' || value === 'm' || value === 'l' ? value : fallback ;
}
function coerceLogoMode ( value : unknown , fallback : 'emoticon' | 'upload' ) : 'emoticon' | 'upload' {
return value === 'upload' || value === 'emoticon' ? value : fallback ;
}
function coercePosition ( value : unknown , fallback : 'left' | 'right' | 'center' ) : 'left' | 'right' | 'center' {
return value === 'left' || value === 'right' || value === 'center' ? value : fallback ;
}
function coerceButtonStyle ( value : unknown , fallback : 'filled' | 'outline' ) : 'filled' | 'outline' {
return value === 'outline' || value === 'filled' ? value : fallback ;
}
function mapBranding ( source : BrandingSource , fallback : BrandingForm = DEFAULT_BRANDING_FORM ) : BrandingForm {
const paletteSource = ( source ? . palette as Record < string , unknown > | undefined ) ? ? { } ;
const typographySource = ( source ? . typography as Record < string , unknown > | undefined ) ? ? { } ;
const logoSource = ( source ? . logo as Record < string , unknown > | undefined ) ? ? { } ;
const buttonSource = ( source ? . buttons as Record < string , unknown > | undefined ) ? ? { } ;
const palette = {
primary : asHex ( paletteSource . primary ? ? source ? . primary_color , fallback . palette . primary ) ,
secondary : asHex ( paletteSource . secondary ? ? source ? . secondary_color , fallback . palette . secondary ) ,
background : asHex ( paletteSource . background ? ? source ? . background_color , fallback . palette . background ) ,
surface : asHex ( paletteSource . surface ? ? source ? . surface_color ? ? paletteSource . background ? ? source ? . background_color , fallback . palette . surface ? ? fallback . palette . background ) ,
} ;
const typography = {
heading : asString ( typographySource . heading ? ? source ? . heading_font ? ? source ? . font_family , fallback . typography . heading ) ,
body : asString ( typographySource . body ? ? source ? . body_font ? ? source ? . font_family , fallback . typography . body ) ,
size : coerceSize ( typographySource . size ? ? source ? . font_size , fallback . typography . size ) ,
} ;
const logoMode = coerceLogoMode ( logoSource . mode ? ? source ? . logo_mode , fallback . logo . mode ) ;
const logoValue = asString ( logoSource . value ? ? source ? . logo_value ? ? source ? . logo_url ? ? fallback . logo . value , fallback . logo . value ) ;
const logo = {
mode : logoMode ,
value : logoValue ,
position : coercePosition ( logoSource . position ? ? source ? . logo_position , fallback . logo . position ) ,
size : coerceSize ( logoSource . size ? ? source ? . logo_size , fallback . logo . size ) ,
} ;
const buttons = {
style : coerceButtonStyle ( buttonSource . style ? ? source ? . button_style , fallback . buttons . style ) ,
radius : asNumber ( buttonSource . radius ? ? source ? . button_radius , fallback . buttons . radius ) ,
primary : asHex ( buttonSource . primary ? ? source ? . button_primary_color , fallback . buttons . primary ? ? palette . primary ) ,
secondary : asHex ( buttonSource . secondary ? ? source ? . button_secondary_color , fallback . buttons . secondary ? ? palette . secondary ) ,
linkColor : asHex ( buttonSource . link_color ? ? source ? . link_color , fallback . buttons . linkColor ? ? palette . secondary ) ,
} ;
return {
useDefault : Boolean ( source ? . use_default_branding ? ? source ? . use_default ? ? fallback . useDefault ) ,
palette ,
typography ,
logo ,
buttons ,
mode : ( source ? . mode as BrandingForm [ 'mode' ] ) ? ? fallback . mode ,
} ;
}
function buildPayload ( form : BrandingForm ) {
return {
use_default_branding : form.useDefault ,
primary_color : form.palette.primary ,
secondary_color : form.palette.secondary ,
background_color : form.palette.background ,
surface_color : form.palette.surface ,
heading_font : form.typography.heading || null ,
body_font : form.typography.body || null ,
font_size : form.typography.size ,
logo_mode : form.logo.mode ,
logo_value : form.logo.value || null ,
logo_position : form.logo.position ,
logo_size : form.logo.size ,
button_style : form.buttons.style ,
button_radius : form.buttons.radius ,
button_primary_color : form.buttons.primary || null ,
button_secondary_color : form.buttons.secondary || null ,
link_color : form.buttons.linkColor || null ,
mode : form.mode ,
palette : {
primary : form.palette.primary ,
secondary : form.palette.secondary ,
background : form.palette.background ,
surface : form.palette.surface ,
} ,
typography : {
heading : form.typography.heading || null ,
body : form.typography.body || null ,
size : form.typography.size ,
} ,
logo : {
mode : form.logo.mode ,
value : form.logo.value || null ,
position : form.logo.position ,
size : form.logo.size ,
} ,
buttons : {
style : form.buttons.style ,
radius : form.buttons.radius ,
primary : form.buttons.primary || null ,
secondary : form.buttons.secondary || null ,
link_color : form.buttons.linkColor || null ,
} ,
} ;
}
function resolvePreviewBranding ( form : BrandingForm , tenantBranding : BrandingForm | null ) : BrandingForm {
if ( form . useDefault && tenantBranding ) {
return { . . . tenantBranding , useDefault : true } ;
}
if ( form . useDefault ) {
return { . . . DEFAULT_BRANDING_FORM , useDefault : true } ;
}
return form ;
}
export default function EventBrandingPage ( ) : React . ReactElement {
const { slug } = useParams < { slug? : string } > ( ) ;
const navigate = useNavigate ( ) ;
const { t } = useTranslation ( 'management' ) ;
const queryClient = useQueryClient ( ) ;
const [ form , setForm ] = useState < BrandingForm > ( DEFAULT_BRANDING_FORM ) ;
const [ previewTheme , setPreviewTheme ] = useState < 'light' | 'dark' > ( 'light' ) ;
const { fonts : availableFonts , isLoading : fontsLoading } = useTenantFonts ( ) ;
const title = t ( 'branding.title' , 'Branding & Fonts' ) ;
const subtitle = t ( 'branding.subtitle' , 'Farben, Typografie, Logo/Emoticon und Schaltflächen für die Gäste-App anpassen.' ) ;
const { data : tenantSettings } = useQuery ( {
queryKey : [ 'tenant' , 'settings' , 'branding' ] ,
queryFn : getTenantSettings ,
staleTime : 60_000 ,
} ) ;
const tenantBranding = useMemo (
( ) = > mapBranding ( ( tenantSettings ? . settings as Record < string , unknown > | undefined ) ? . branding as BrandingSource , DEFAULT_BRANDING_FORM ) ,
[ tenantSettings ] ,
) ;
const {
data : loadedEvent ,
isLoading : eventLoading ,
} = useQuery < TenantEvent > ( {
queryKey : [ 'tenant' , 'events' , slug ] ,
queryFn : ( ) = > getEvent ( slug ! ) ,
enabled : Boolean ( slug ) ,
staleTime : 30_000 ,
} ) ;
const eventTabs = useMemo ( ( ) = > {
if ( ! loadedEvent ) return [ ] ;
const translateMenu = ( key : string , fallback : string ) = > t ( key , { defaultValue : fallback } ) ;
return buildEventTabs ( loadedEvent , translateMenu ) ;
} , [ loadedEvent , t ] ) ;
useEffect ( ( ) = > {
if ( ! loadedEvent ) return ;
const brandingSource = ( loadedEvent . settings as Record < string , unknown > | undefined ) ? . branding as BrandingSource ;
const mapped = mapBranding ( brandingSource , tenantBranding ? ? DEFAULT_BRANDING_FORM ) ;
setForm ( mapped ) ;
setPreviewTheme ( mapped . mode === 'dark' ? 'dark' : 'light' ) ;
} , [ loadedEvent , tenantBranding ] ) ;
useEffect ( ( ) = > {
const resolved = resolvePreviewBranding ( form , tenantBranding ) ;
setPreviewTheme ( resolved . mode === 'dark' ? 'dark' : 'light' ) ;
} , [ form . mode , form . useDefault , tenantBranding ] ) ;
useEffect ( ( ) = > {
const families = [ form . typography . heading , form . typography . body ] . filter ( Boolean ) as string [ ] ;
families . forEach ( ( family ) = > {
const font = availableFonts . find ( ( entry ) = > entry . family === family ) ;
if ( font ) {
void ensureFontLoaded ( font ) ;
}
} ) ;
} , [ availableFonts , form . typography . body , form . typography . heading ] ) ;
const mutation = useMutation ( {
mutationFn : async ( payload : BrandingForm ) = > {
if ( ! slug ) throw new Error ( 'Missing event slug' ) ;
const response = await updateEvent ( slug , {
settings : {
branding : buildPayload ( payload ) ,
} ,
} ) ;
return response ;
} ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( { queryKey : [ 'tenant' , 'events' , slug ] } ) ;
toast . success ( t ( 'branding.saved' , 'Branding gespeichert.' ) ) ;
} ,
onError : ( error : unknown ) = > {
console . error ( '[branding] save failed' , error ) ;
toast . error ( t ( 'branding.saveError' , 'Branding konnte nicht gespeichert werden.' ) ) ;
} ,
} ) ;
if ( ! slug ) {
return (
< AdminLayout title = { title } subtitle = { subtitle } tabs = { [ ] } currentTabKey = "branding" >
< SectionCard >
< p className = "text-sm text-slate-600 dark:text-slate-300" >
{ t ( 'branding.errors.missingSlug' , 'Kein Event ausgewählt – bitte über die Eventliste öffnen.' ) }
< / p >
< / SectionCard >
< / AdminLayout >
) ;
}
const resolveFontSelectValue = ( current : string ) : string = > {
if ( ! current ) return '' ;
return availableFonts . some ( ( font ) = > font . family === current ) ? current : '__custom' ;
} ;
const handleFontSelect = ( key : 'heading' | 'body' , value : string ) = > {
const resolved = value === '__custom' ? '' : value ;
setForm ( ( prev ) = > ( { . . . prev , typography : { . . . prev . typography , [ key ] : resolved } } ) ) ;
const font = availableFonts . find ( ( entry ) = > entry . family === resolved ) ;
if ( font ) {
void ensureFontLoaded ( font ) ;
}
} ;
const previewBranding = resolvePreviewBranding ( form , tenantBranding ) ;
return (
< AdminLayout
title = { title }
subtitle = { subtitle }
tabs = { eventTabs }
currentTabKey = "branding"
actions = { (
< Button variant = "outline" size = "sm" onClick = { ( ) = > navigate ( ADMIN_EVENT_VIEW_PATH ( slug ) ) } >
< ArrowLeft className = "mr-2 h-4 w-4" / >
{ t ( 'branding.actions.back' , 'Zurück zum Event' ) }
< / Button >
) }
>
< div className = "space-y-4" >
< SectionCard className = "space-y-3" >
< SectionHeader
eyebrow = { t ( 'branding.sections.mode' , 'Standard vs. Event-spezifisch' ) }
title = { t ( 'branding.sections.toggleTitle' , 'Branding-Quelle wählen' ) }
description = { t ( 'branding.sections.toggleDescription' , 'Nutze das Standard-Branding oder überschreibe es nur für dieses Event.' ) }
/ >
< div className = "flex items-center justify-between rounded-2xl border border-slate-200 bg-white/80 p-4 dark:border-white/10 dark:bg-slate-900/40" >
< div >
< p className = "text-sm font-semibold text-slate-900 dark:text-white" >
{ form . useDefault
? t ( 'branding.useDefault' , 'Standard nutzen' )
: t ( 'branding.useCustom' , 'Event-spezifisch' ) }
< / p >
< p className = "text-xs text-slate-600 dark:text-slate-300" >
{ t ( 'branding.toggleHint' , 'Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.' ) }
< / p >
< / div >
< div className = "flex items-center gap-3" >
< span className = "text-xs text-slate-500 dark:text-slate-300" > { t ( 'branding.standard' , 'Standard' ) } < / span >
< Switch
checked = { ! form . useDefault }
onCheckedChange = { ( checked ) = > setForm ( ( prev ) = > ( { . . . prev , useDefault : ! checked ? true : false } ) ) }
aria-label = { t ( 'branding.toggleAria' , 'Event-spezifisches Branding aktivieren' ) }
/ >
< span className = "text-xs text-slate-500 dark:text-slate-300" > { t ( 'branding.custom' , 'Event' ) } < / span >
< / div >
< / div >
< / SectionCard >
< div className = "grid gap-4 lg:grid-cols-2" >
< SectionCard className = "space-y-4" >
< SectionHeader
eyebrow = { t ( 'branding.sections.palette' , 'Palette & Modus' ) }
title = { t ( 'branding.sections.colorsTitle' , 'Farben & Light/Dark' ) }
description = { t ( 'branding.sections.colorsDescription' , 'Primär-, Sekundär-, Hintergrund- und Surface-Farbe festlegen.' ) }
/ >
< div className = "grid grid-cols-2 gap-3" >
{ ( [ 'primary' , 'secondary' , 'background' , 'surface' ] as const ) . map ( ( key ) = > (
< div key = { key } className = "space-y-2" >
< Label htmlFor = { ` color- ${ key } ` } > { key === 'primary' ? 'Primary' : key === 'secondary' ? 'Secondary' : key === 'background' ? 'Background' : 'Surface' } < / Label >
< div className = "flex items-center gap-3" >
< Input
id = { ` color- ${ key } ` }
type = "color"
value = { form . palette [ key ] }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , palette : { . . . prev . palette , [ key ] : e . target . value } } ) ) }
disabled = { form . useDefault }
className = "h-10 w-16 p-1"
/ >
< Input
type = "text"
value = { form . palette [ key ] }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , palette : { . . . prev . palette , [ key ] : e . target . value } } ) ) }
disabled = { form . useDefault }
/ >
< / div >
< / div >
) ) }
< div className = "space-y-2" >
< Label > { t ( 'branding.mode' , 'Modus' ) } < / Label >
< Select
value = { form . mode }
onValueChange = { ( value ) = > {
setForm ( ( prev ) = > ( { . . . prev , mode : value as BrandingForm [ 'mode' ] } ) ) ;
setPreviewTheme ( value === 'dark' ? 'dark' : 'light' ) ;
} }
disabled = { form . useDefault }
>
< SelectTrigger >
< SelectValue placeholder = "auto" / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "auto" > { t ( 'branding.modeAuto' , 'Auto' ) } < / SelectItem >
< SelectItem value = "light" > { t ( 'branding.modeLight' , 'Hell' ) } < / SelectItem >
< SelectItem value = "dark" > { t ( 'branding.modeDark' , 'Dunkel' ) } < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< / div >
< / SectionCard >
< SectionCard className = "space-y-4" >
< SectionHeader
eyebrow = { t ( 'branding.sections.typography' , 'Typografie & Logo' ) }
title = { t ( 'branding.sections.fonts' , 'Schriften & Logo/Emoticon' ) }
description = { t ( 'branding.sections.fontDescription' , 'Heading- und Body-Font sowie Logo/Emoji und Ausrichtung festlegen.' ) }
/ >
< div className = "grid grid-cols-2 gap-3" >
< div className = "space-y-2" >
< Label > { t ( 'branding.headingFont' , 'Heading Font' ) } < / Label >
< Select
value = { resolveFontSelectValue ( form . typography . heading ) }
onValueChange = { ( value ) = > handleFontSelect ( 'heading' , value ) }
disabled = { form . useDefault || fontsLoading }
>
< SelectTrigger >
< SelectValue placeholder = { t ( 'branding.fontDefault' , 'Standard (Tenant)' ) } / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "" > { t ( 'branding.fontDefault' , 'Standard (Tenant)' ) } < / SelectItem >
{ availableFonts . map ( ( font ) = > (
< SelectItem key = { font . family } value = { font . family } > { font . family } < / SelectItem >
) ) }
< SelectItem value = "__custom" > { t ( 'branding.fontCustom' , 'Eigene Schrift eingeben' ) } < / SelectItem >
< / SelectContent >
< / Select >
< Input
value = { form . typography . heading }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , typography : { . . . prev . typography , heading : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = "z. B. Playfair Display"
/ >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.bodyFont' , 'Body Font' ) } < / Label >
< Select
value = { resolveFontSelectValue ( form . typography . body ) }
onValueChange = { ( value ) = > handleFontSelect ( 'body' , value ) }
disabled = { form . useDefault || fontsLoading }
>
< SelectTrigger >
< SelectValue placeholder = { t ( 'branding.fontDefault' , 'Standard (Tenant)' ) } / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "" > { t ( 'branding.fontDefault' , 'Standard (Tenant)' ) } < / SelectItem >
{ availableFonts . map ( ( font ) = > (
< SelectItem key = { font . family } value = { font . family } > { font . family } < / SelectItem >
) ) }
< SelectItem value = "__custom" > { t ( 'branding.fontCustom' , 'Eigene Schrift eingeben' ) } < / SelectItem >
< / SelectContent >
< / Select >
< Input
value = { form . typography . body }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , typography : { . . . prev . typography , body : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = "z. B. Inter, sans-serif"
/ >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.size' , 'Schriftgröße' ) } < / Label >
< Select
value = { form . typography . size }
onValueChange = { ( value ) = > setForm ( ( prev ) = > ( { . . . prev , typography : { . . . prev . typography , size : value as BrandingForm [ 'typography' ] [ 'size' ] } } ) ) }
disabled = { form . useDefault }
>
< SelectTrigger >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "s" > S < / SelectItem >
< SelectItem value = "m" > M < / SelectItem >
< SelectItem value = "l" > L < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.logoValue' , 'Emoticon/Logo-URL' ) } < / Label >
< Input
value = { form . logo . value }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , logo : { . . . prev . logo , value : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = "✨ oder https://..."
/ >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.logoMode' , 'Logo-Modus' ) } < / Label >
< Select
value = { form . logo . mode }
onValueChange = { ( value ) = > setForm ( ( prev ) = > ( { . . . prev , logo : { . . . prev . logo , mode : value as BrandingForm [ 'logo' ] [ 'mode' ] } } ) ) }
disabled = { form . useDefault }
>
< SelectTrigger > < SelectValue / > < / SelectTrigger >
< SelectContent >
< SelectItem value = "emoticon" > { t ( 'branding.emoticon' , 'Emoticon/Text' ) } < / SelectItem >
< SelectItem value = "upload" > { t ( 'branding.upload' , 'Upload/URL' ) } < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.logoPosition' , 'Position' ) } < / Label >
< Select
value = { form . logo . position }
onValueChange = { ( value ) = > setForm ( ( prev ) = > ( { . . . prev , logo : { . . . prev . logo , position : value as BrandingForm [ 'logo' ] [ 'position' ] } } ) ) }
disabled = { form . useDefault }
>
< SelectTrigger > < SelectValue / > < / SelectTrigger >
< SelectContent >
< SelectItem value = "left" > { t ( 'branding.left' , 'Links' ) } < / SelectItem >
< SelectItem value = "center" > { t ( 'branding.center' , 'Zentriert' ) } < / SelectItem >
< SelectItem value = "right" > { t ( 'branding.right' , 'Rechts' ) } < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< / div >
< / SectionCard >
< / div >
< SectionCard className = "space-y-4" >
< SectionHeader
eyebrow = { t ( 'branding.sections.buttons' , 'Buttons & Links' ) }
title = { t ( 'branding.sections.buttonsTitle' , 'Buttons, Links & Radius' ) }
description = { t ( 'branding.sections.buttonsDescription' , 'Stil, Radius und optionale Link-Farbe festlegen.' ) }
/ >
< div className = "grid gap-4 md:grid-cols-3" >
< div className = "space-y-2" >
< Label > { t ( 'branding.buttonStyle' , 'Stil' ) } < / Label >
< Select
value = { form . buttons . style }
onValueChange = { ( value ) = > setForm ( ( prev ) = > ( { . . . prev , buttons : { . . . prev . buttons , style : value as BrandingForm [ 'buttons' ] [ 'style' ] } } ) ) }
disabled = { form . useDefault }
>
< SelectTrigger > < SelectValue / > < / SelectTrigger >
< SelectContent >
< SelectItem value = "filled" > { t ( 'branding.filled' , 'Filled' ) } < / SelectItem >
< SelectItem value = "outline" > { t ( 'branding.outline' , 'Outline' ) } < / SelectItem >
< / SelectContent >
< / Select >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.radius' , 'Radius' ) } < / Label >
< div className = "flex items-center gap-3" >
< input
type = "range"
min = { 0 }
max = { 32 }
value = { form . buttons . radius }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , buttons : { . . . prev . buttons , radius : Number ( e . target . value ) } } ) ) }
disabled = { form . useDefault }
className = "w-full"
/ >
< span className = "w-10 text-right text-sm text-slate-600 dark:text-slate-200" > { form . buttons . radius } px < / span >
< / div >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.linkColor' , 'Link-Farbe' ) } < / Label >
< Input
value = { form . buttons . linkColor }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , buttons : { . . . prev . buttons , linkColor : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = "#fb7185"
/ >
< / div >
< / div >
< div className = "grid gap-4 md:grid-cols-3" >
< div className = "space-y-2" >
< Label > { t ( 'branding.buttonPrimary' , 'Button Primary' ) } < / Label >
< Input
value = { form . buttons . primary }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , buttons : { . . . prev . buttons , primary : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = { form . palette . primary }
/ >
< / div >
< div className = "space-y-2" >
< Label > { t ( 'branding.buttonSecondary' , 'Button Secondary' ) } < / Label >
< Input
value = { form . buttons . secondary }
onChange = { ( e ) = > setForm ( ( prev ) = > ( { . . . prev , buttons : { . . . prev . buttons , secondary : e.target.value } } ) ) }
disabled = { form . useDefault }
placeholder = { form . palette . secondary }
/ >
< / div >
< / div >
< / SectionCard >
< SectionCard className = "space-y-4" >
< SectionHeader
eyebrow = { t ( 'branding.sections.preview' , 'Preview' ) }
title = { t ( 'branding.sections.previewTitle' , 'Mini-Gastansicht' ) }
description = { t ( 'branding.sections.previewCopy' , 'Header, CTA und Bottom-Navigation nach Branding visualisiert.' ) }
/ >
< div className = "flex items-center justify-between" >
< div className = "flex items-center gap-2 text-sm text-slate-600 dark:text-slate-200" >
< Sparkles className = "h-4 w-4" / >
< span > { form . useDefault ? t ( 'branding.usingDefault' , 'Standard-Branding aktiv' ) : t ( 'branding.usingCustom' , 'Event-Branding aktiv' ) } < / span >
< / div >
< div className = "flex items-center gap-2" >
< Button
size = "icon"
variant = { previewTheme === 'light' ? 'secondary' : 'ghost' }
onClick = { ( ) = > setPreviewTheme ( 'light' ) }
aria-label = "Light Preview"
>
< Sun className = "h-4 w-4" / >
< / Button >
< Button
size = "icon"
variant = { previewTheme === 'dark' ? 'secondary' : 'ghost' }
onClick = { ( ) = > setPreviewTheme ( 'dark' ) }
aria-label = "Dark Preview"
>
< Moon className = "h-4 w-4" / >
< / Button >
< / div >
< / div >
< BrandingPreview branding = { previewBranding } theme = { previewTheme } / >
< / SectionCard >
< / div >
< div className = "sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60" >
< div className = "mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0" >
< div className = "text-sm text-slate-600 dark:text-slate-200" >
{ form . useDefault
? t ( 'branding.footer.default' , 'Standard-Farben des Tenants aktiv.' )
: t ( 'branding.footer.custom' , 'Event-spezifisches Branding aktiv.' ) }
< / div >
< div className = "flex items-center gap-2" >
< Button
variant = "ghost"
onClick = { ( ) = > setForm ( { . . . ( tenantBranding ? ? DEFAULT_BRANDING_FORM ) , useDefault : true } ) }
>
{ t ( 'branding.reset' , 'Auf Standard zurücksetzen' ) }
< / Button >
< Button onClick = { ( ) = > mutation . mutate ( form ) } disabled = { mutation . isPending || eventLoading } >
{ mutation . isPending ? (
< >
< Loader2 className = "mr-2 h-4 w-4 animate-spin" / >
{ t ( 'branding.saving' , 'Speichern...' ) }
< / >
) : (
t ( 'branding.save' , 'Branding speichern' )
) }
< / Button >
< / div >
< / div >
< / div >
< / AdminLayout >
) ;
}
function BrandingPreview ( { branding , theme } : { branding : BrandingForm ; theme : 'light' | 'dark' } ) {
const textColor = getContrastingTextColor ( branding . palette . primary , '#0f172a' , '#ffffff' ) ;
const headerStyle : React.CSSProperties = {
background : ` linear-gradient(135deg, ${ branding . palette . primary } , ${ branding . palette . secondary } ) ` ,
color : textColor ,
} ;
const buttonStyle : React.CSSProperties = branding . buttons . style === 'outline'
? {
border : ` 2px solid ${ branding . buttons . primary || branding . palette . primary } ` ,
color : branding.buttons.primary || branding . palette . primary ,
background : 'transparent' ,
}
: {
background : branding.buttons.primary || branding . palette . primary ,
color : getContrastingTextColor ( branding . buttons . primary || branding . palette . primary , '#0f172a' , '#ffffff' ) ,
border : 'none' ,
} ;
return (
< Card className = { cn ( 'overflow-hidden' , theme === 'dark' ? 'bg-slate-900 text-white' : 'bg-white text-slate-900' ) } >
< CardHeader className = "p-0" >
< div className = "px-4 py-3" style = { headerStyle } >
< div className = "flex items-center gap-3" >
< div className = "flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl" >
{ branding . logo . value || '✨' }
< / div >
< div className = "flex flex-col" >
< CardTitle className = "text-base font-semibold" style = { { fontFamily : branding.typography.heading || undefined } } >
Demo Event
< / CardTitle >
< span className = "text-xs opacity-80" > Gastansicht · { branding . mode } < / span >
< / div >
< / div >
< / div >
< / CardHeader >
< CardContent className = "space-y-4 bg-[var(--surface)] px-4 py-5" style = { { [ '--surface' as string ] : branding . palette . surface } } >
< div className = "space-y-2" >
< p className = "text-sm text-slate-600 dark:text-slate-200" style = { { fontFamily : branding.typography.body || undefined } } >
CTA & Buttons spiegeln den gewählten Stil wider .
< / p >
< Button
className = "shadow-md transition"
style = { {
. . . buttonStyle ,
borderRadius : branding.buttons.radius ,
paddingInline : '18px' ,
paddingBlock : '10px' ,
} }
>
Jetzt Fotos hochladen
< / Button >
< / div >
< Separator / >
< div className = "flex items-center justify-between rounded-2xl border border-dashed border-slate-200 p-3 text-sm dark:border-slate-700" style = { { borderRadius : branding.buttons.radius } } >
< span style = { { color : branding.buttons.linkColor || branding . palette . secondary } } > Bottom Navigation < / span >
< div className = "flex items-center gap-2" >
< div className = "h-1.5 w-10 rounded-full" style = { { background : branding.palette.primary } } / >
< div className = "h-1.5 w-10 rounded-full" style = { { background : branding.palette.secondary } } / >
< div className = "h-1.5 w-10 rounded-full" style = { { background : branding.palette.surface } } / >
< / div >
< / div >
< / CardContent >
< / Card >
) ;
}