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 { Slider } from 'tamagui'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { isBrandingAllowed, isWatermarkAllowed, isWatermarkRemovalAllowed } 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_GRADIENTS, useAdminTheme } from './theme'; import { ContextHelpLink } from './components/ContextHelpLink'; import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm'; import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext'; import { getContrastingTextColor, relativeLuminance } from '@/guest/lib/color'; const BRANDING_FORM_DEFAULTS = { primary: DEFAULT_EVENT_BRANDING.primaryColor, accent: DEFAULT_EVENT_BRANDING.secondaryColor, background: DEFAULT_EVENT_BRANDING.backgroundColor, surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor, mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto', buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled', buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12, buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor, buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm', }; const BRANDING_FORM_BASE: BrandingFormValues = { ...BRANDING_FORM_DEFAULTS, headingFont: '', bodyFont: '', logoDataUrl: '', logoValue: '', useDefaultBranding: false, }; const FONT_SIZE_SCALE: Record = { s: 0.94, m: 1, l: 1.08, }; const LOGO_SIZE_PREVIEW: Record = { s: 28, m: 36, l: 44, }; 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; assetPreviewUrl: 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(BRANDING_FORM_BASE); const [watermarkForm, setWatermarkForm] = React.useState({ mode: 'base', assetPath: '', assetDataUrl: '', assetPreviewUrl: '', 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); const [tenantBranding, setTenantBranding] = React.useState(null); const [tenantBrandingLoaded, setTenantBrandingLoaded] = React.useState(false); React.useEffect(() => { if (!slug) return; (async () => { setLoading(true); try { const data = await getEvent(slug); if (!data) { setLoading(false); return; } setEvent(data); setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS)); 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]); React.useEffect(() => { if (tenantBrandingLoaded) return; let active = true; getTenantSettings() .then((payload) => { if (!active || !payload) return; setTenantBranding(extractBrandingForm(payload.settings ?? {}, BRANDING_FORM_DEFAULTS)); }) .catch(() => undefined) .finally(() => { if (active) { setTenantBrandingLoaded(true); } }); return () => { active = false; }; }, [tenantBrandingLoaded]); const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form; const previewBackground = previewForm.background; const previewSurfaceCandidate = previewForm.surface || previewBackground; const backgroundLuminance = relativeLuminance(previewBackground); const surfaceLuminance = relativeLuminance(previewSurfaceCandidate); const previewSurface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06 ? backgroundLuminance >= 0.6 ? '#ffffff' : '#0f172a' : previewSurfaceCandidate; const previewForeground = backgroundLuminance >= 0.6 ? '#1f2937' : '#f8fafc'; const previewMutedForeground = backgroundLuminance >= 0.6 ? '#6b7280' : '#cbd5e1'; const previewBorder = backgroundLuminance >= 0.6 ? '#e6d9d6' : '#334155'; const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const previewHeadingFont = previewForm.headingFont || DEFAULT_EVENT_BRANDING.typography?.heading || DEFAULT_EVENT_BRANDING.fontFamily; const previewBodyFont = previewForm.bodyFont || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily; const previewSurfaceText = previewForeground; const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1; const previewButtonColor = previewForm.buttonPrimary || previewForm.primary; const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a'); const previewLogoSize = LOGO_SIZE_PREVIEW[previewForm.logoSize] ?? 36; const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : ''; const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewInitials = getInitials(previewTitle); const watermarkAllowed = isWatermarkAllowed(event ?? null); const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null); const customWatermarkAllowed = watermarkAllowed && brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed; const brandingDisabled = !brandingAllowed || form.useDefaultBranding; React.useEffect(() => { setWatermarkForm((prev) => { if (prev.mode === 'custom' && !customWatermarkAllowed) { return { ...prev, mode: 'base' }; } if (prev.mode === 'off' && !watermarkRemovalAllowed) { return { ...prev, mode: 'base' }; } return prev; }); }, [customWatermarkAllowed, watermarkRemovalAllowed]); async function handleSave() { if (!event?.slug) 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 settings = { ...(event.settings ?? {}) }; const logoUploadValue = form.logoMode === 'upload' ? form.logoDataUrl.trim() : ''; const logoIsDataUrl = logoUploadValue.startsWith('data:image/'); const normalizedLogoPath = logoIsDataUrl ? '' : normalizeBrandingPath(logoUploadValue); const logoValue = form.logoMode === 'upload' ? (logoIsDataUrl ? logoUploadValue : normalizedLogoPath || null) : (form.logoValue.trim() || null); settings.branding = { ...(typeof settings.branding === 'object' ? (settings.branding as Record) : {}), use_default_branding: form.useDefaultBranding, primary_color: form.primary, secondary_color: form.accent, accent_color: form.accent, background_color: form.background, surface_color: form.surface, font_family: form.bodyFont, heading_font: form.headingFont, body_font: form.bodyFont, font_size: form.fontSize, mode: form.mode, button_style: form.buttonStyle, button_radius: form.buttonRadius, button_primary_color: form.buttonPrimary, button_secondary_color: form.buttonSecondary, link_color: form.linkColor, typography: { ...(typeof (settings.branding as Record | undefined)?.typography === 'object' ? ((settings.branding as Record).typography as Record) : {}), heading: form.headingFont, body: form.bodyFont, size: form.fontSize, }, palette: { ...(typeof (settings.branding as Record | undefined)?.palette === 'object' ? ((settings.branding as Record).palette as Record) : {}), primary: form.primary, secondary: form.accent, background: form.background, surface: form.surface, }, buttons: { ...(typeof (settings.branding as Record | undefined)?.buttons === 'object' ? ((settings.branding as Record).buttons as Record) : {}), style: form.buttonStyle, radius: form.buttonRadius, primary: form.buttonPrimary, secondary: form.buttonSecondary, link_color: form.linkColor, }, logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null, logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null, logo_mode: form.logoMode, logo_value: logoValue, logo_position: form.logoPosition, logo_size: form.logoSize, logo: { mode: form.logoMode, value: logoValue, position: form.logoPosition, size: form.logoSize, }, }; const watermarkPayload = buildWatermarkPayload( watermarkForm, watermarkAllowed, brandingAllowed, watermarkRemovalAllowed ); if (watermarkPayload) { settings.watermark = watermarkPayload; } const updated = await updateEvent(event.slug, { settings }); setEvent(updated); setWatermarkForm(extractWatermark(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(extractBrandingForm(event.settings ?? {}, BRANDING_FORM_DEFAULTS)); setWatermarkForm(extractWatermark(event)); } } function renderWatermarkTab() { const controlsLocked = watermarkLocked; const mode = controlsLocked ? 'base' : watermarkForm.mode; const resolvedMode = mode === 'custom' && !customWatermarkAllowed ? 'base' : mode === 'off' && !watermarkRemovalAllowed ? 'base' : mode; const customizationDisabled = controlsLocked || resolvedMode !== 'custom'; return ( <> {t('events.watermark.previewTitle', 'Watermark Preview')} {!watermarkAllowed ? ( navigate(adminPath('/mobile/billing/shop?feature=watermark_allowed'))} /> ) : null} {watermarkLocked ? ( } text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')} /> ) : null} {t('events.watermark.title', 'Wasserzeichen')} { const value = event.target.value; if (controlsLocked) return; if (value === 'custom' || value === 'base' || value === 'off') { setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] })); } }} > {resolvedMode === 'custom' && !controlsLocked ? ( {t('events.watermark.upload', 'Wasserzeichen hochladen')} document.getElementById('watermark-upload-input')?.click()}> {watermarkForm.assetPath || watermarkForm.assetDataUrl ? t('events.watermark.replace', 'Wasserzeichen ersetzen') : t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')} {watermarkForm.assetDataUrl ? ( {t('events.watermark.previewAlt', ) : null} { 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, assetPreviewUrl: 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={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))} disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))} disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, padding: value }))} disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, offsetX: value }))} disabled={customizationDisabled} suffix={t('events.watermark.offsetX', 'X-Achse')} /> setWatermarkForm((prev) => ({ ...prev, offsetY: value }))} disabled={customizationDisabled} /> ); } return ( handleSave()} ariaLabel={t('common.save', 'Save')}> } > {error ? ( {error} ) : null} setActiveTab('branding')} /> setActiveTab('watermark')} /> {activeTab === 'branding' ? ( <> {t('events.branding.previewTitle', 'Guest App Preview')} {previewLogoUrl ? ( {t('events.branding.logoAlt', ) : ( {previewLogoValue || previewInitials} )} {previewTitle} {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
{t('events.branding.previewCta', 'Fotos hochladen')}
{!brandingAllowed ? ( navigate(adminPath('/mobile/billing/shop?feature=custom_branding'))} /> ) : null} {t('events.branding.source', 'Branding Source')} {t('events.branding.sourceHint', 'Use the default branding or customize this event only.')} setForm((prev) => ({ ...prev, useDefaultBranding: true }))} disabled={!brandingAllowed} /> setForm((prev) => ({ ...prev, useDefaultBranding: false }))} disabled={!brandingAllowed} /> {form.useDefaultBranding ? t('events.branding.usingDefault', 'Account-Branding aktiv') : t('events.branding.usingCustom', 'Event-Branding aktiv')} {form.useDefaultBranding ? null : ( <> {t('events.branding.mode', 'Theme')} setForm((prev) => ({ ...prev, mode: 'light' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, mode: 'auto' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, mode: 'dark' }))} disabled={brandingDisabled} /> {t('events.branding.colors', 'Colors')} setForm((prev) => ({ ...prev, primary: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, accent: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, background: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, surface: value }))} disabled={brandingDisabled} /> {t('events.branding.fonts', 'Fonts')} setForm((prev) => ({ ...prev, headingFont: value }))} onPicker={() => { setFontField('heading'); setShowFontsSheet(true); }} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, bodyFont: value }))} onPicker={() => { setFontField('body'); setShowFontsSheet(true); }} disabled={brandingDisabled} /> {t('events.branding.fontSize', 'Font Size')} setForm((prev) => ({ ...prev, fontSize: 's' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, fontSize: 'm' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, fontSize: 'l' }))} disabled={brandingDisabled} /> {t('events.branding.logo', 'Logo')} {t('events.branding.logoHint', 'Upload a logo or use an emoji for the guest header.')} setForm((prev) => ({ ...prev, logoMode: 'upload' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoMode: 'emoticon' }))} disabled={brandingDisabled} /> {form.logoMode === 'emoticon' ? ( setForm((prev) => ({ ...prev, logoValue: value }))} disabled={brandingDisabled} /> ) : ( {form.logoDataUrl ? ( <> {t('events.branding.logoAlt', document.getElementById('branding-logo-input')?.click()} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoDataUrl: '' }))} > {t('events.branding.removeLogo', 'Remove')} ) : ( <> 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); }} /> )} {t('events.branding.logoPosition', 'Position')} setForm((prev) => ({ ...prev, logoPosition: 'left' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoPosition: 'center' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoPosition: 'right' }))} disabled={brandingDisabled} /> {t('events.branding.logoSize', 'Size')} setForm((prev) => ({ ...prev, logoSize: 's' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoSize: 'm' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, logoSize: 'l' }))} disabled={brandingDisabled} /> {t('events.branding.buttons', 'Buttons & Links')} {t('events.branding.buttonsHint', 'Style, radius, and link color for CTA buttons.')} setForm((prev) => ({ ...prev, buttonStyle: 'filled' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, buttonStyle: 'outline' }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, buttonRadius: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, buttonPrimary: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, buttonSecondary: value }))} disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, linkColor: value }))} disabled={brandingDisabled} /> )} ) : ( 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 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: '', assetPreviewUrl: readString('asset_url', ''), 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, removalAllowed: boolean ): WatermarkSettings | null { const customAllowed = watermarkAllowed && brandingAllowed; let mode = form.mode; if (mode === 'custom' && !customAllowed) { mode = 'base'; } if (mode === 'off' && !removalAllowed) { mode = 'base'; } 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' && customAllowed) { 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 normalizeBrandingPath(value: string): string { const trimmed = value.trim(); if (!trimmed) { return ''; } if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('data:')) { return trimmed; } const normalized = trimmed.replace(/^\/+/, ''); if (normalized.startsWith('storage/')) { return normalized.slice('storage/'.length); } return normalized; } function getInitials(name: string): string { const words = name.split(' ').filter(Boolean); if (words.length >= 2) { return `${words[0][0]}${words[1][0]}`.toUpperCase(); } return name.substring(0, 2).toUpperCase(); } function ColorField({ label, value, onChange, disabled, }: { label: string; value: string; onChange: (next: string) => void; disabled?: boolean; }) { const { textStrong, muted } = useAdminTheme(); return ( {label} onChange(event.target.value)} disabled={disabled} /> {value} ); } function ColorSwatch({ color, label, borderColor }: { color: string; label: string; borderColor?: string }) { const { border, muted } = useAdminTheme(); return ( {label} ); } function InputField({ label, value, placeholder, onChange, onPicker, disabled, }: { label: string; value: string; placeholder?: string; onChange: (next: string) => void; onPicker?: () => void; disabled?: boolean; }) { const { primary } = useAdminTheme(); return ( onChange(event.target.value)} onFocus={onPicker} disabled={disabled} style={{ flex: 1 }} /> {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, primary, border, surface } = useAdminTheme(); return ( {label} {value} {suffix ? ` ${suffix}` : ''} onChange(next[0] ?? value)} disabled={disabled} > ); } 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, previewUrl, previewAlt, }: { position: WatermarkPosition; scale: number; opacity: number; padding: number; offsetX: number; offsetY: number; previewUrl?: string; previewAlt?: string; }) { 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 (
{previewUrl ? ( {previewAlt ) : ( )}
); } 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 UpgradeCard({ title, body, actionLabel, onPress, }: { title: string; body: string; actionLabel: string; onPress: () => void; }) { const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme(); return ( {title} {body} ); } function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme(); return ( {label} ); } function ModeButton({ label, active, onPress, disabled, }: { label: string; active: boolean; onPress: () => void; disabled?: boolean; }) { const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme(); return ( {label} ); }