Files
fotospiel-app/resources/js/admin/pages/EventBrandingPage.tsx

962 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
}
function coerceEventName(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
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<BrandingForm>(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<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 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<string, unknown>;
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<string, string[]> } })?.meta?.errors) {
const errors = (error as { meta?: { errors?: Record<string, string[]> } }).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 (
<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 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<HTMLInputElement>) => {
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<FloatingAction[]>(() => {
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 (
<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 pb-28">
<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={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{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"
style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined}
/>
</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={DEFAULT_FONT_VALUE}>{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
{availableFonts.map((font) => (
<SelectItem
key={font.family}
value={font.family}
onMouseEnter={() => handleFontPreview(font.family)}
onFocus={() => handleFontPreview(font.family)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate" style={{ fontFamily: font.family }}>{font.family}</span>
<span className="text-xs text-muted-foreground" style={{ fontFamily: font.family }}>AaBb</span>
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM_FONT_VALUE}>{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"
style={form.typography.body ? { fontFamily: form.typography.body } : undefined}
/>
</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>
{form.logo.mode === 'emoticon' && (
<div className="space-y-2">
<Label>{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}</Label>
<Dialog open={emoticonDialogOpen} onOpenChange={setEmoticonDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={form.useDefault}>
{t('branding.openEmoticons', 'Emoticon-Gitter öffnen')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl bg-white/95 p-4 text-left dark:bg-slate-950">
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')}
</DialogTitle>
</DialogHeader>
<div className="mt-3 grid grid-cols-8 gap-2 sm:grid-cols-10">
{EMOTICON_GRID.map((emoji) => {
const isActive = form.logo.value === emoji;
return (
<button
key={emoji}
type="button"
className={cn(
'flex h-12 w-full items-center justify-center rounded-lg text-3xl leading-none transition sm:h-12',
isActive
? 'bg-slate-900 text-white shadow-sm ring-2 ring-slate-900 dark:bg-white/90 dark:text-slate-900 dark:ring-white/90'
: 'bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700',
)}
onClick={() => {
handleEmoticonSelect(emoji);
setEmoticonDialogOpen(false);
}}
disabled={form.useDefault}
title={emoji}
>
<span aria-hidden>{emoji}</span>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
</div>
)}
{form.logo.mode === 'upload' && (
<div className="space-y-2">
<Label>{t('branding.logoUpload', 'Logo hochladen')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" disabled={form.useDefault} onClick={() => document.getElementById('branding-logo-upload')?.click()}>
<UploadCloud className="mr-2 h-4 w-4" />
{t('branding.logoUploadButton', 'Datei auswählen')}
</Button>
<input
id="branding-logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoUpload}
disabled={form.useDefault}
/>
<span className="text-xs text-slate-600 dark:text-slate-300">
{t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')}
</span>
</div>
</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>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
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 <img src={branding.logo.value} alt="Logo" className="h-10 w-10 rounded-full object-cover" />;
}
return <span className="text-xl">{branding.logo.value || '✨'}</span>;
}, [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 (
<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 overflow-hidden rounded-full bg-white/90 text-xl">
{logoVisual}
</div>
<div className="flex flex-col">
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
{t('branding.preview.demoTitle', 'Demo Event')}
</CardTitle>
<span className="text-xs opacity-80">
{t('branding.preview.guestView', { mode: branding.mode, defaultValue: 'Guest view · {{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 }}>
{t('branding.preview.ctaCopy', 'CTA & buttons reflect the chosen style.')}
</p>
<Button
className="shadow-md transition"
style={{
...buttonStyle,
borderRadius: branding.buttons.radius,
paddingInline: '18px',
paddingBlock: '10px',
}}
>
{t('branding.preview.cta', 'Upload photos now')}
</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 }}>
{t('branding.preview.bottomNav', '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>
);
}