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, Droplets, Lock } 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, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { isBrandingAllowed } from '../lib/events'; import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme'; type BrandingForm = { primary: string; accent: string; headingFont: string; bodyFont: string; logoDataUrl: string; }; type WatermarkPosition = | 'top-left' | 'top-center' | 'top-right' | 'middle-left' | 'center' | 'middle-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; type WatermarkForm = { mode: 'base' | 'custom' | 'off'; assetPath: string; assetDataUrl: string; position: WatermarkPosition; opacity: number; scale: number; padding: number; offsetX: number; offsetY: number; }; type TabKey = 'branding' | 'watermark'; export default function MobileBrandingPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const slug = slugParam ?? null; const navigate = useNavigate(); const { t } = useTranslation('management'); const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme(); const [event, setEvent] = React.useState(null); const [form, setForm] = React.useState({ primary: ADMIN_COLORS.primary, accent: ADMIN_COLORS.accent, headingFont: '', bodyFont: '', logoDataUrl: '', }); const [watermarkForm, setWatermarkForm] = React.useState({ mode: 'base', assetPath: '', assetDataUrl: '', position: 'bottom-right', opacity: 0.25, scale: 0.2, padding: 16, offsetX: 0, offsetY: 0, }); const [activeTab, setActiveTab] = React.useState('branding'); 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 back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); 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)); setWatermarkForm(extractWatermark(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 || 'Fraunces'; const previewBodyFont = form.bodyFont || 'Manrope'; const watermarkAllowed = event?.package?.watermark_allowed !== false; const brandingAllowed = isBrandingAllowed(event ?? null); const watermarkLocked = watermarkAllowed && !brandingAllowed; 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 { if (watermarkAllowed && brandingAllowed && watermarkForm.mode === 'custom' && !watermarkForm.assetDataUrl && !watermarkForm.assetPath) { const msg = t('events.watermark.errors.noAsset', 'Bitte lade zuerst ein Wasserzeichen hoch.'); setError(msg); setActiveTab('watermark'); setSaving(false); return; } 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 watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed); if (watermarkPayload) { settings.watermark = watermarkPayload; } const updated = await updateEvent(event.slug, { ...payload, settings, }); setEvent(updated); void trackOnboarding('branding_configured', { event_id: updated.id }); 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)); setWatermarkForm(extractWatermark(event)); } } function renderWatermarkTab() { const policyLabel = watermarkAllowed ? 'basic' : 'none'; const disabled = !watermarkAllowed; const controlsLocked = watermarkLocked || disabled; const mode = controlsLocked ? 'base' : watermarkForm.mode; return ( <> {t('events.watermark.previewTitle', 'Watermark Preview')} {disabled ? ( } text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')} tone="danger" /> ) : null} {watermarkLocked ? ( } text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')} /> ) : null} {t('events.watermark.title', 'Wasserzeichen')} { if (controlsLocked) return; if (value === 'custom' || value === 'base' || value === 'off') { setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] })); } }} onPicker={() => undefined} > {mode === 'custom' && !controlsLocked ? ( {t('events.watermark.upload', 'Wasserzeichen hochladen')} document.getElementById('watermark-upload-input')?.click()}> {watermarkForm.assetPath ? t('events.watermark.replace', 'Wasserzeichen ersetzen') : t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')} { const file = event.target.files?.[0]; if (!file) return; if (file.size > 3 * 1024 * 1024) { setError(t('events.watermark.errors.fileTooLarge', 'Wasserzeichen muss kleiner als 3 MB sein.')); return; } const reader = new FileReader(); reader.onload = () => { const result = typeof reader.result === 'string' ? reader.result : ''; setWatermarkForm((prev) => ({ ...prev, assetDataUrl: result, assetPath: '' })); setError(null); }; reader.readAsDataURL(file); }} /> {t('events.watermark.uploadHint', 'PNG mit transparenter Fläche empfohlen.')} ) : null} {t('events.watermark.placement', 'Position & Größe')} setWatermarkForm((prev) => ({ ...prev, position: next }))} disabled={controlsLocked} /> setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))} disabled={controlsLocked} /> setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))} disabled={controlsLocked} /> setWatermarkForm((prev) => ({ ...prev, padding: value }))} disabled={controlsLocked} /> setWatermarkForm((prev) => ({ ...prev, offsetX: value }))} disabled={controlsLocked} suffix={t('events.watermark.offsetX', 'X-Achse')} /> setWatermarkForm((prev) => ({ ...prev, offsetY: value }))} disabled={controlsLocked} /> ); } return ( handleSave()} ariaLabel={t('common.save', 'Save')}> } > {error ? ( {error} ) : null} setActiveTab('branding')} /> setActiveTab('watermark')} /> {activeTab === 'branding' ? ( <> {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 ? ( <> {t('events.branding.logoAlt', 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); }} /> ) : ( renderWatermarkTab() )} 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', ADMIN_COLORS.primary), accent: readColor('accent_color', ADMIN_COLORS.accent), headingFont: readText('heading_font'), bodyFont: readText('body_font'), logoDataUrl: readText('logo_data_url'), }; } function extractWatermark(event: TenantEvent): WatermarkForm { const settings = (event.settings as Record) ?? {}; const wm = (settings.watermark as Record) ?? {}; const readNumber = (key: string, fallback: number) => { const value = wm[key]; return typeof value === 'number' && !Number.isNaN(value) ? value : fallback; }; const readString = (key: string, fallback: string) => { const value = wm[key]; return typeof value === 'string' && value.trim() ? value : fallback; }; const mode = (wm.mode === 'custom' || wm.mode === 'off') ? wm.mode : 'base'; const position = readString('position', 'bottom-right') as WatermarkPosition; return { mode, assetPath: readString('asset', ''), assetDataUrl: '', position, opacity: readNumber('opacity', 0.25), scale: readNumber('scale', 0.2), padding: readNumber('padding', 16), offsetX: readNumber('offset_x', 0), offsetY: readNumber('offset_y', 0), }; } function buildWatermarkPayload( form: WatermarkForm, watermarkAllowed: boolean, brandingAllowed: boolean ): WatermarkSettings | null { if (!watermarkAllowed) { return { mode: 'off' }; } const policy = watermarkAllowed ? 'basic' : 'none'; const desiredMode = brandingAllowed ? form.mode : 'base'; const mode = desiredMode === 'off' && policy === 'basic' ? 'base' : desiredMode; const payload: WatermarkSettings = { mode, position: form.position, opacity: Math.min(1, Math.max(0, form.opacity)), scale: Math.min(1, Math.max(0.05, form.scale)), padding: Math.max(0, Math.round(form.padding)), offset_x: Math.max(-500, Math.min(500, Math.round(form.offsetX))), offset_y: Math.max(-500, Math.min(500, Math.round(form.offsetY))), }; if (mode === 'custom' && brandingAllowed) { if (form.assetDataUrl) { payload.asset_data_url = form.assetDataUrl; } if (form.assetPath) { payload.asset = form.assetPath; } } return payload; } 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 }) { const { textStrong, muted, border, surface } = useAdminTheme(); return ( {label} onChange(event.target.value)} style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }} /> {value} ); } function ColorSwatch({ color, label }: { color: string; label: string }) { const { border, muted } = useAdminTheme(); return ( {label} ); } function InputField({ label, value, placeholder, onChange, onPicker, children, }: { label: string; value: string; placeholder?: string; onChange: (next: string) => void; onPicker?: () => void; children?: React.ReactNode; }) { const { textStrong, border, surface, primary } = useAdminTheme(); return ( {label} {children ?? ( onChange(event.target.value)} style={{ flex: 1, height: '100%', border: 'none', outline: 'none', fontSize: 14, background: 'transparent', }} onFocus={onPicker} /> )} {onPicker ? ( ) : null} ); } function LabeledSlider({ label, value, min, max, step, onChange, disabled, suffix, }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void; disabled?: boolean; suffix?: string; }) { const { textStrong, muted } = useAdminTheme(); return ( {label} {value} {suffix ? ` ${suffix}` : ''} onChange(Number(event.target.value))} style={{ width: '100%' }} /> ); } function PositionGrid({ value, onChange, disabled, }: { value: WatermarkPosition; onChange: (value: WatermarkPosition) => void; disabled?: boolean; }) { const { textStrong, primary, border, accentSoft, surface } = useAdminTheme(); const positions: WatermarkPosition[] = [ 'top-left', 'top-center', 'top-right', 'middle-left', 'center', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right', ]; return ( Position
{positions.map((pos) => ( ))}
); } function WatermarkPreview({ position, scale, opacity, padding, offsetX, offsetY, }: { position: WatermarkPosition; scale: number; opacity: number; padding: number; offsetX: number; offsetY: number; }) { const { border, muted, textStrong, overlay } = useAdminTheme(); const width = 280; const height = 180; const wmWidth = Math.max(24, Math.round(width * Math.min(1, Math.max(0.05, scale)))); const wmHeight = Math.round(wmWidth * 0.4); const baseX = (() => { switch (position) { case 'top-center': case 'center': case 'bottom-center': return (width - wmWidth) / 2; case 'top-right': case 'middle-right': case 'bottom-right': return width - wmWidth - padding; default: return padding; } })(); const baseY = (() => { switch (position) { case 'middle-left': case 'center': case 'middle-right': return (height - wmHeight) / 2; case 'bottom-left': case 'bottom-center': case 'bottom-right': return height - wmHeight - padding; default: return padding; } })(); const x = Math.max(0, Math.min(width - wmWidth, baseX + offsetX)); const y = Math.max(0, Math.min(height - wmHeight, baseY + offsetY)); return (
); } function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text: string; tone?: 'info' | 'danger' }) { const { dangerBg, dangerText, surfaceMuted, textStrong, border } = useAdminTheme(); const background = tone === 'danger' ? dangerBg : surfaceMuted; const color = tone === 'danger' ? dangerText : textStrong; return ( {icon} {text} ); } function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { const { backdrop, surfaceMuted, border, surface } = useAdminTheme(); return ( {label} ); }