import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; type BrandingForm = { primary: string; accent: string; headingFont: string; bodyFont: string; logoDataUrl: string; }; export default function MobileBrandingPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const slug = slugParam ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); const [event, setEvent] = React.useState(null); const [form, setForm] = React.useState({ primary: '#007AFF', accent: '#5AD2F4', headingFont: '', bodyFont: '', logoDataUrl: '', }); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const [showFontsSheet, setShowFontsSheet] = React.useState(false); const [fontField, setFontField] = React.useState<'heading' | 'body'>('heading'); const [fonts, setFonts] = React.useState([]); const [fontsLoading, setFontsLoading] = React.useState(false); const [fontsLoaded, setFontsLoaded] = React.useState(false); React.useEffect(() => { if (!slug) return; (async () => { setLoading(true); try { const data = await getEvent(slug); setEvent(data); setForm(extractBranding(data)); setError(null); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.'))); } } finally { setLoading(false); } })(); }, [slug, t]); React.useEffect(() => { if (!showFontsSheet || fontsLoaded) return; setFontsLoading(true); getTenantFonts() .then((data) => setFonts(data ?? [])) .catch(() => undefined) .finally(() => { setFontsLoading(false); setFontsLoaded(true); }); }, [showFontsSheet, fontsLoaded]); const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const previewHeadingFont = form.headingFont || 'Montserrat'; const previewBodyFont = form.bodyFont || 'Montserrat'; async function handleSave() { if (!event?.slug) return; const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null; if (!eventTypeId) { const msg = t('events.errors.missingType', 'Event type fehlt. Speichere das Event erneut im Admin.'); setError(msg); toast.error(msg); return; } setSaving(true); setError(null); try { const payload = { name: typeof event.name === 'string' ? event.name : renderName(event.name), slug: event.slug, event_type_id: eventTypeId, event_date: event.event_date ?? undefined, status: event.status ?? 'draft', is_active: event.is_active ?? undefined, }; const settings = { ...(event.settings ?? {}) }; settings.branding = { ...(typeof settings.branding === 'object' ? (settings.branding as Record) : {}), primary_color: form.primary, accent_color: form.accent, heading_font: form.headingFont, body_font: form.bodyFont, typography: { ...(typeof (settings.branding as Record | undefined)?.typography === 'object' ? ((settings.branding as Record).typography as Record) : {}), heading: form.headingFont, body: form.bodyFont, }, palette: { ...(typeof (settings.branding as Record | undefined)?.palette === 'object' ? ((settings.branding as Record).palette as Record) : {}), primary: form.primary, secondary: form.accent, }, logo_data_url: form.logoDataUrl || null, logo: form.logoDataUrl ? { mode: 'upload', value: form.logoDataUrl, position: 'center', size: 'm', } : null, }; const updated = await updateEvent(event.slug, { ...payload, settings, }); setEvent(updated); toast.success(t('events.branding.saveSuccess', 'Branding gespeichert')); } catch (err) { if (!isAuthError(err)) { let message = getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')); if (err instanceof ApiError && err.meta?.errors && typeof err.meta.errors === 'object') { const allErrors = Object.values(err.meta.errors as Record) .flat() .filter((val): val is string => typeof val === 'string'); if (allErrors.length) { message = allErrors.join('\n'); } } setError(message); toast.error(message, { duration: 5000 }); } } finally { setSaving(false); } } function handleReset() { if (event) { setForm(extractBranding(event)); } } return ( navigate(-1)} headerActions={ handleSave()}> } > {error ? ( {error} ) : null} {t('events.branding.previewTitle', 'Guest App Preview')} {previewTitle} {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} {t('events.branding.colors', 'Colors')} setForm((prev) => ({ ...prev, primary: value }))} /> setForm((prev) => ({ ...prev, accent: value }))} /> {t('events.branding.fonts', 'Fonts')} setForm((prev) => ({ ...prev, headingFont: value }))} onPicker={() => { setFontField('heading'); setShowFontsSheet(true); }} /> setForm((prev) => ({ ...prev, bodyFont: value }))} onPicker={() => { setFontField('body'); setShowFontsSheet(true); }} /> {t('events.branding.logo', 'Logo')} {form.logoDataUrl ? ( <> Logo document.getElementById('branding-logo-input')?.click()} /> setForm((prev) => ({ ...prev, logoDataUrl: '' }))}> {t('events.branding.removeLogo', 'Remove')} ) : ( <> {t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')} document.getElementById('branding-logo-input')?.click()}> {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} )} { const file = event.target.files?.[0]; if (!file) return; if (file.size > 1024 * 1024) { setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.')); return; } const reader = new FileReader(); reader.onload = () => { const nextLogo = typeof reader.result === 'string' ? reader.result : typeof reader.result === 'object' && reader.result !== null ? String(reader.result) : ''; setForm((prev) => ({ ...prev, logoDataUrl: nextLogo })); setError(null); }; reader.readAsDataURL(file); }} /> handleSave()} /> {t('events.branding.reset', 'Reset to Defaults')} setShowFontsSheet(false)} title={t('events.branding.fontPicker', 'Select font')} footer={null} bottomOffsetPx={120} > {fontsLoading ? ( Array.from({ length: 4 }).map((_, idx) => ) ) : fonts.length === 0 ? ( {t('events.branding.noFonts', 'Keine Schriftarten gefunden.')} ) : ( fonts.map((font) => ( { setForm((prev) => ({ ...prev, [fontField === 'heading' ? 'headingFont' : 'bodyFont']: font.family, })); setShowFontsSheet(false); }} > {font.family} {font.variants?.length ? ( {font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')} ) : null} {form[fontField === 'heading' ? 'headingFont' : 'bodyFont'] === font.family ? ( {t('common.active', 'Active')} ) : null} )) )} ); } function extractBranding(event: TenantEvent): BrandingForm { const source = (event.settings as Record) ?? {}; const branding = (source.branding as Record) ?? source; const readColor = (key: string, fallback: string) => { const value = branding[key]; return typeof value === 'string' && value.startsWith('#') ? value : fallback; }; const readText = (key: string) => { const value = branding[key]; return typeof value === 'string' ? value : ''; }; return { primary: readColor('primary_color', '#007AFF'), accent: readColor('accent_color', '#5AD2F4'), headingFont: readText('heading_font'), bodyFont: readText('body_font'), logoDataUrl: readText('logo_data_url'), }; } function renderName(name: TenantEvent['name']): string { if (typeof name === 'string') return name; if (name && typeof name === 'object') { return name.de ?? name.en ?? Object.values(name)[0] ?? ''; } return ''; } function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) { return ( {label} onChange(event.target.value)} style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }} /> {value} ); } function ColorSwatch({ color, label }: { color: string; label: string }) { return ( {label} ); } function InputField({ label, value, placeholder, onChange, onPicker, }: { label: string; value: string; placeholder?: string; onChange: (next: string) => void; onPicker?: () => void; }) { return ( {label} onChange(event.target.value)} style={{ flex: 1, height: '100%', border: 'none', outline: 'none', fontSize: 14, background: 'transparent', }} onFocus={onPicker} /> {onPicker ? ( ) : null} ); }