Files
fotospiel-app/resources/js/admin/pages/EventBrandingPage.tsx
Codex Agent 9bde8f3f32 Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.
- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
    exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
  - Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
    custom override) that auto-load selected fonts.
  - Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
  - New tests cover font sync command and font manifest API.

  Tests run: php artisan test --filter=Fonts --testsuite=Feature.
  Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
  untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
2025-11-25 19:31:52 +01:00

746 lines
32 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, 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>
);
}