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'; const DEFAULT_FONT_VALUE = '__default'; const CUSTOM_FONT_VALUE = '__custom'; 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 | 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 | undefined) ?? {}; const typographySource = (source?.typography as Record | undefined) ?? {}; const logoSource = (source?.logo as Record | undefined) ?? {}; const buttonSource = (source?.buttons as Record | 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(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 | undefined)?.branding as BrandingSource, DEFAULT_BRANDING_FORM), [tenantSettings], ); const { data: loadedEvent, isLoading: eventLoading, } = useQuery({ 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 | 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 (

{t('branding.errors.missingSlug', 'Kein Event ausgewählt – bitte über die Eventliste öffnen.')}

); } const resolveFontSelectValue = (current: string): string => { if (!current) return DEFAULT_FONT_VALUE; return availableFonts.some((font) => font.family === current) ? current : CUSTOM_FONT_VALUE; }; const handleFontSelect = (key: 'heading' | 'body', value: string) => { const resolved = value === CUSTOM_FONT_VALUE || value === DEFAULT_FONT_VALUE ? '' : 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 ( navigate(ADMIN_EVENT_VIEW_PATH(slug))}> {t('branding.actions.back', 'Zurück zum Event')} )} >

{form.useDefault ? t('branding.useDefault', 'Standard nutzen') : t('branding.useCustom', 'Event-spezifisch')}

{t('branding.toggleHint', 'Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.')}

{t('branding.standard', 'Standard')} setForm((prev) => ({ ...prev, useDefault: !checked ? true : false }))} aria-label={t('branding.toggleAria', 'Event-spezifisches Branding aktivieren')} /> {t('branding.custom', 'Event')}
{(['primary', 'secondary', 'background', 'surface'] as const).map((key) => (
setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))} disabled={form.useDefault} className="h-10 w-16 p-1" /> setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))} disabled={form.useDefault} />
))}
setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))} disabled={form.useDefault} placeholder="z. B. Playfair Display" />
setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))} disabled={form.useDefault} placeholder="z. B. Inter, sans-serif" />
setForm((prev) => ({ ...prev, logo: { ...prev.logo, value: e.target.value } }))} disabled={form.useDefault} placeholder="✨ oder https://..." />
setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, radius: Number(e.target.value) } }))} disabled={form.useDefault} className="w-full" /> {form.buttons.radius}px
setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, linkColor: e.target.value } }))} disabled={form.useDefault} placeholder="#fb7185" />
setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, primary: e.target.value } }))} disabled={form.useDefault} placeholder={form.palette.primary} />
setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, secondary: e.target.value } }))} disabled={form.useDefault} placeholder={form.palette.secondary} />
{form.useDefault ? t('branding.usingDefault', 'Standard-Branding aktiv') : t('branding.usingCustom', 'Event-Branding aktiv')}
{form.useDefault ? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.') : t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
); } function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) { const { t } = useTranslation('management'); 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 (
{branding.logo.value || '✨'}
{t('branding.preview.demoTitle', 'Demo Event')} {t('branding.preview.guestView', { mode: branding.mode, defaultValue: 'Guest view · {{mode}}' })}

{t('branding.preview.ctaCopy', 'CTA & buttons reflect the chosen style.')}

{t('branding.preview.bottomNav', 'Bottom navigation')}
); }