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, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react'; import toast from 'react-hot-toast'; import { AdminLayout } from '../components/AdminLayout'; import { SectionCard, SectionHeader } from '../components/tenant'; import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; 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'; const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024; const EMOTICON_GRID: string[] = [ '✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻', '😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏', '🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍', '⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌', '🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩', '🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩', '☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾', '📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇', ]; 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; } function coerceEventName(value: unknown): string { if (typeof value === 'string') { return value; } if (value && typeof value === 'object') { const record = value as Record; const preferred = record.de ?? record.en ?? Object.values(record)[0]; if (typeof preferred === 'string') { return preferred; } } return ''; } function coerceEventDate(value: unknown): string | null { if (typeof value === 'string' && value.trim()) { const raw = value.trim(); // If ISO with timezone, convert to local date (validation uses server local date) const parsed = new Date(raw); if (!Number.isNaN(parsed.valueOf())) { const year = parsed.getFullYear(); const month = String(parsed.getMonth() + 1).padStart(2, '0'); const day = String(parsed.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } if (raw.length >= 10) { return raw.slice(0, 10); } return raw; } return null; } 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 [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false); 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 context'); // Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten. const latest = await getEvent(slug); const eventTypeId = latest.event_type_id ?? latest.event_type?.id; const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date); const eventName = coerceEventName(latest.name ?? loadedEvent?.name); if (!eventTypeId || !eventName || !eventDate) { throw new Error('Missing required event fields'); } const mergedSettings = { ...(latest.settings ?? {}), branding: buildPayload(payload), } as Record; const response = await updateEvent(slug, { name: eventName, slug: latest.slug, event_type_id: eventTypeId, event_date: eventDate, status: latest.status, is_active: latest.is_active, package_id: latest.package?.id, settings: mergedSettings, }); 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); if ((error as { meta?: { errors?: Record } })?.meta?.errors) { const errors = (error as { meta?: { errors?: Record } }).meta?.errors ?? {}; const flat = Object.entries(errors) .map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`) .join('\n'); toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); return; } toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); }, }); const { mutate, isPending } = mutation; 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 handleFontPreview = (family: string) => { const font = availableFonts.find((entry) => entry.family === family); if (font) { void ensureFontLoaded(font); } }; const handleLogoUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; if (file.size > MAX_LOGO_UPLOAD_BYTES) { toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.')); event.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => { const dataUrl = typeof reader.result === 'string' ? reader.result : ''; setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } })); }; reader.readAsDataURL(file); }; const handleEmoticonSelect = (value: string) => { setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } })); }; const previewBranding = resolvePreviewBranding(form, tenantBranding); const fabActions = React.useMemo(() => { if (!slug) return []; return [ { key: 'save', label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'), icon: Save, onClick: () => mutate(form), loading: isPending, disabled: isPending || eventLoading, tone: 'primary', }, { key: 'reset', label: t('branding.reset', 'Auf Standard zurücksetzen'), icon: RotateCcw, onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }), disabled: isPending, tone: 'secondary', }, ]; }, [slug, mutate, form, isPending, eventLoading, t, 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" style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined} />
setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))} disabled={form.useDefault} placeholder="z. B. Inter, sans-serif" style={form.typography.body ? { fontFamily: form.typography.body } : undefined} />
setForm((prev) => ({ ...prev, logo: { ...prev.logo, value: e.target.value } }))} disabled={form.useDefault} placeholder="✨ oder https://..." />
{form.logo.mode === 'emoticon' && (
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
{EMOTICON_GRID.map((emoji) => { const isActive = form.logo.value === emoji; return ( ); })}
)} {form.logo.mode === 'upload' && (
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
)}
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')}
); } 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 logoVisual = useMemo(() => { const looksLikeImage = branding.logo.mode === 'upload' && branding.logo.value && /^(data:|https?:)/i.test(branding.logo.value); if (looksLikeImage) { return Logo; } return {branding.logo.value || '✨'}; }, [branding.logo.mode, branding.logo.value]); 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 (
{logoVisual}
{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')}
); }