diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 5c10775..058f588 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -349,9 +349,14 @@ class EventController extends Controller $validated['settings']['watermark_allowed'] = $watermarkAllowed; $settings = $validated['settings']; + $branding = Arr::get($settings, 'branding', []); $watermark = Arr::get($settings, 'watermark', []); $existingWatermark = is_array($watermark) ? $watermark : []; + if (is_array($branding)) { + $settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed); + } + if (is_array($watermark)) { $mode = $watermark['mode'] ?? 'base'; $policy = $watermarkAllowed ? 'basic' : 'none'; @@ -442,6 +447,68 @@ class EventController extends Controller ]); } + /** + * @param array $branding + * @return array + */ + private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array + { + $logoDataUrl = $branding['logo_data_url'] ?? null; + + if (! $brandingAllowed) { + unset($branding['logo_data_url']); + + return $branding; + } + + if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') { + unset($branding['logo_data_url']); + + return $branding; + } + + if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) { + throw ValidationException::withMessages([ + 'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'), + ]); + } + + $decoded = base64_decode($matches[2], true); + + if ($decoded === false) { + throw ValidationException::withMessages([ + 'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'), + ]); + } + + if (strlen($decoded) > 1024 * 1024) { // 1 MB + throw ValidationException::withMessages([ + 'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'), + ]); + } + + $extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]); + $path = sprintf('branding/logos/event-%s.%s', $event->id, $extension); + Storage::disk('public')->put($path, $decoded); + + $branding['logo_url'] = $path; + $branding['logo_mode'] = 'upload'; + $branding['logo_value'] = $path; + + $logo = $branding['logo'] ?? []; + if (! is_array($logo)) { + $logo = []; + } + + $logo['mode'] = 'upload'; + $logo['value'] = $path; + $branding['logo'] = $logo; + + unset($branding['logo_data_url']); + + return $branding; + } + public function destroy(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); diff --git a/resources/css/app.css b/resources/css/app.css index efc1407..afb56c1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -121,6 +121,7 @@ --guest-radius: 14px; --guest-button-style: filled; --guest-link: #007aff; + --guest-font-scale: 1; --guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; @@ -516,6 +517,7 @@ html.guest-theme { --card: var(--guest-surface); --popover: var(--guest-surface); background-color: var(--guest-background); + font-size: calc(16px * var(--guest-font-scale, 1)); } html.guest-theme.dark { diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index b07e7c3..c460474 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1918,10 +1918,18 @@ "titleShort": "Branding", "previewTitle": "Guest-App-Vorschau", "previewSubtitle": "Aktuelle Farben & Schriften", + "previewCta": "Fotos hochladen", "primary": "Primärfarbe", "accent": "Akzentfarbe", "background": "Hintergrund", "surface": "Fläche", + "lockedBranding": "Branding ist in diesem Paket gesperrt.", + "source": "Branding-Quelle", + "sourceHint": "Nutze das Tenant-Branding oder überschreibe es für dieses Event.", + "useDefault": "Tenant", + "useCustom": "Event", + "usingDefault": "Tenant-Branding aktiv", + "usingCustom": "Event-Branding aktiv", "mode": "Theme", "modeLight": "Hell", "modeAuto": "Auto", @@ -1936,13 +1944,37 @@ "headingFontPlaceholder": "SF Pro Display", "bodyFont": "Fließtext-Schrift", "bodyFontPlaceholder": "SF Pro Text", + "fontSize": "Schriftgröße", + "fontSizeSmall": "S", + "fontSizeMedium": "M", + "fontSizeLarge": "L", "logo": "Logo", "logoAlt": "Logo", + "logoModeUpload": "Upload", + "logoModeEmoticon": "Emoticon", + "logoValue": "Emoticon", + "logoValuePlaceholder": "🎉", + "logoPosition": "Position", + "positionLeft": "Links", + "positionCenter": "Zentriert", + "positionRight": "Rechts", + "logoSize": "Größe", + "logoSizeSmall": "S", + "logoSizeMedium": "M", + "logoSizeLarge": "L", "replaceLogo": "Logo ersetzen", "removeLogo": "Entfernen", - "logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.", + "logoHint": "Logo hochladen oder Emoji für den Guest-Header nutzen.", "uploadLogo": "Logo hochladen (max. 1 MB)", "logoTooLarge": "Logo muss unter 1 MB sein.", + "buttons": "Buttons & Links", + "buttonsHint": "Stil, Radius und Link-Farbe für CTAs.", + "buttonFilled": "Gefüllt", + "buttonOutline": "Outline", + "buttonRadius": "Radius", + "buttonPrimary": "Button Primär", + "buttonSecondary": "Button Sekundär", + "linkColor": "Link-Farbe", "save": "Branding speichern", "saving": "Speichere...", "saveSuccess": "Branding gespeichert.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 29b3349..dd88a5a 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1922,10 +1922,18 @@ "titleShort": "Branding", "previewTitle": "Guest app preview", "previewSubtitle": "Current colors & fonts", + "previewCta": "Upload photos", "primary": "Primary", "accent": "Accent", "background": "Background", "surface": "Surface", + "lockedBranding": "Branding is locked for this package.", + "source": "Branding source", + "sourceHint": "Use tenant branding or override for this event.", + "useDefault": "Tenant", + "useCustom": "Event", + "usingDefault": "Tenant branding active", + "usingCustom": "Event branding active", "mode": "Theme", "modeLight": "Light", "modeAuto": "Auto", @@ -1940,13 +1948,37 @@ "headingFontPlaceholder": "SF Pro Display", "bodyFont": "Body font", "bodyFontPlaceholder": "SF Pro Text", + "fontSize": "Font size", + "fontSizeSmall": "S", + "fontSizeMedium": "M", + "fontSizeLarge": "L", "logo": "Logo", "logoAlt": "Logo", + "logoModeUpload": "Upload", + "logoModeEmoticon": "Emoticon", + "logoValue": "Emoticon", + "logoValuePlaceholder": "🎉", + "logoPosition": "Position", + "positionLeft": "Left", + "positionCenter": "Center", + "positionRight": "Right", + "logoSize": "Size", + "logoSizeSmall": "S", + "logoSizeMedium": "M", + "logoSizeLarge": "L", "replaceLogo": "Replace logo", "removeLogo": "Remove", - "logoHint": "Upload a logo to brand guest invites and QR posters.", + "logoHint": "Upload a logo or use an emoji for the guest header.", "uploadLogo": "Upload logo (max. 1 MB)", "logoTooLarge": "Logo must be under 1 MB.", + "buttons": "Buttons & links", + "buttonsHint": "Style, radius, and link color for CTA buttons.", + "buttonFilled": "Filled", + "buttonOutline": "Outline", + "buttonRadius": "Radius", + "buttonPrimary": "Button primary", + "buttonSecondary": "Button secondary", + "linkColor": "Link color", "save": "Save branding", "saving": "Saving...", "saveSuccess": "Branding saved.", diff --git a/resources/js/admin/lib/__tests__/brandingForm.test.ts b/resources/js/admin/lib/__tests__/brandingForm.test.ts index 3854114..4015c1b 100644 --- a/resources/js/admin/lib/__tests__/brandingForm.test.ts +++ b/resources/js/admin/lib/__tests__/brandingForm.test.ts @@ -7,6 +7,15 @@ const defaults = { background: '#ffffff', surface: '#f0f0f0', mode: 'auto' as const, + buttonStyle: 'filled' as const, + buttonRadius: 12, + buttonPrimary: '#111111', + buttonSecondary: '#222222', + linkColor: '#222222', + fontSize: 'm' as const, + logoMode: 'upload' as const, + logoPosition: 'left' as const, + logoSize: 'm' as const, }; describe('extractBrandingForm', () => { @@ -34,6 +43,7 @@ describe('extractBrandingForm', () => { expect(result.background).toBe('#000000'); expect(result.surface).toBe('#111111'); expect(result.mode).toBe('dark'); + expect(result.fontSize).toBe('m'); }); it('falls back to legacy keys and defaults', () => { @@ -52,5 +62,62 @@ describe('extractBrandingForm', () => { expect(result.background).toBe('#abcdef'); expect(result.surface).toBe('#abcdef'); expect(result.mode).toBe('light'); + expect(result.buttonStyle).toBe(defaults.buttonStyle); + expect(result.buttonRadius).toBe(defaults.buttonRadius); + }); + + it('extracts buttons, logo, and typography settings', () => { + const settings = { + branding: { + typography: { + heading: 'Display Font', + body: 'Body Font', + size: 'l', + }, + buttons: { + style: 'outline', + radius: 24, + primary: '#333333', + secondary: '#444444', + link_color: '#555555', + }, + logo: { + mode: 'emoticon', + value: '🎉', + position: 'center', + size: 'l', + }, + use_default_branding: true, + }, + }; + + const result = extractBrandingForm(settings, defaults); + + expect(result.headingFont).toBe('Display Font'); + expect(result.bodyFont).toBe('Body Font'); + expect(result.fontSize).toBe('l'); + expect(result.buttonStyle).toBe('outline'); + expect(result.buttonRadius).toBe(24); + expect(result.buttonPrimary).toBe('#333333'); + expect(result.buttonSecondary).toBe('#444444'); + expect(result.linkColor).toBe('#555555'); + expect(result.logoMode).toBe('emoticon'); + expect(result.logoValue).toBe('🎉'); + expect(result.logoPosition).toBe('center'); + expect(result.logoSize).toBe('l'); + expect(result.useDefaultBranding).toBe(true); + }); + + it('normalizes stored logo paths for previews', () => { + const settings = { + branding: { + logo_url: 'branding/logos/event-123.png', + logo_mode: 'upload', + }, + }; + + const result = extractBrandingForm(settings, defaults); + + expect(result.logoDataUrl).toBe('/storage/branding/logos/event-123.png'); }); }); diff --git a/resources/js/admin/lib/brandingForm.ts b/resources/js/admin/lib/brandingForm.ts index 6658fbf..747c1d1 100644 --- a/resources/js/admin/lib/brandingForm.ts +++ b/resources/js/admin/lib/brandingForm.ts @@ -5,11 +5,38 @@ export type BrandingFormValues = { surface: string; headingFont: string; bodyFont: string; + fontSize: 's' | 'm' | 'l'; logoDataUrl: string; + logoValue: string; + logoMode: 'upload' | 'emoticon'; + logoPosition: 'left' | 'center' | 'right'; + logoSize: 's' | 'm' | 'l'; mode: 'light' | 'dark' | 'auto'; + buttonStyle: 'filled' | 'outline'; + buttonRadius: number; + buttonPrimary: string; + buttonSecondary: string; + linkColor: string; + useDefaultBranding: boolean; }; -export type BrandingFormDefaults = Pick; +export type BrandingFormDefaults = Pick< + BrandingFormValues, + | 'primary' + | 'accent' + | 'background' + | 'surface' + | 'mode' + | 'buttonStyle' + | 'buttonRadius' + | 'buttonPrimary' + | 'buttonSecondary' + | 'linkColor' + | 'fontSize' + | 'logoMode' + | 'logoPosition' + | 'logoSize' +>; type BrandingRecord = Record; @@ -23,11 +50,48 @@ const readHexColor = (value: unknown, fallback: string): string => { return fallback; }; +const readEnum = (value: unknown, allowed: readonly T[], fallback: T): T => ( + allowed.includes(value as T) ? (value as T) : fallback +); + +const readNumber = (value: unknown, fallback: number): number => ( + typeof value === 'number' && !Number.isNaN(value) ? value : fallback +); + +const resolveAssetPreviewUrl = (value: string | null | undefined): string => { + if (typeof value !== 'string') { + return ''; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + + const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed; + + if (normalized.startsWith('storage/')) { + return `/${normalized}`; + } + + if (normalized.startsWith('branding/') || normalized.startsWith('tenant-branding/')) { + return `/storage/${normalized}`; + } + + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +}; + export function extractBrandingForm(settings: unknown, defaults: BrandingFormDefaults): BrandingFormValues { const settingsRecord = isRecord(settings) ? settings : {}; const branding = isRecord(settingsRecord.branding) ? (settingsRecord.branding as BrandingRecord) : settingsRecord; const palette = isRecord(branding.palette) ? (branding.palette as BrandingRecord) : {}; const typography = isRecord(branding.typography) ? (branding.typography as BrandingRecord) : {}; + const buttons = isRecord(branding.buttons) ? (branding.buttons as BrandingRecord) : {}; + const logo = isRecord(branding.logo) ? (branding.logo as BrandingRecord) : {}; const primary = readHexColor(palette.primary, readHexColor(branding.primary_color, defaults.primary)); const accent = readHexColor( @@ -39,9 +103,27 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef const headingFont = typeof typography.heading === 'string' ? typography.heading : (branding.heading_font as string | undefined); const bodyFont = typeof typography.body === 'string' ? typography.body : (branding.body_font as string | undefined); - const mode = branding.mode === 'light' || branding.mode === 'dark' || branding.mode === 'auto' - ? branding.mode - : defaults.mode; + const mode = readEnum(branding.mode, ['light', 'dark', 'auto'], defaults.mode); + const fontSize = readEnum(typography.size ?? branding.font_size, ['s', 'm', 'l'], defaults.fontSize); + + const logoMode = readEnum(logo.mode ?? branding.logo_mode, ['upload', 'emoticon'], defaults.logoMode); + const logoValue = logoMode === 'emoticon' + ? (typeof logo.value === 'string' ? logo.value : (branding.logo_value as string | undefined) ?? '') + : ''; + const logoPosition = readEnum(logo.position ?? branding.logo_position, ['left', 'center', 'right'], defaults.logoPosition); + const logoSize = readEnum(logo.size ?? branding.logo_size, ['s', 'm', 'l'], defaults.logoSize); + const logoUploadValue = + typeof branding.logo_data_url === 'string' + ? branding.logo_data_url + : logoMode === 'upload' + ? (typeof logo.value === 'string' ? logo.value : (branding.logo_url as string | undefined)) + : undefined; + + const buttonStyle = readEnum(buttons.style ?? branding.button_style, ['filled', 'outline'], defaults.buttonStyle); + const buttonRadius = readNumber(buttons.radius ?? branding.button_radius, defaults.buttonRadius); + const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary)); + const buttonSecondary = readHexColor(buttons.secondary, readHexColor(branding.button_secondary_color, accent)); + const linkColor = readHexColor(buttons.link_color ?? buttons.linkColor, readHexColor(branding.link_color, accent)); return { primary, @@ -50,7 +132,18 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef surface, headingFont: headingFont ?? '', bodyFont: bodyFont ?? '', - logoDataUrl: typeof branding.logo_data_url === 'string' ? branding.logo_data_url : '', + fontSize, + logoDataUrl: resolveAssetPreviewUrl(logoUploadValue), + logoValue, + logoMode, + logoPosition, + logoSize, mode, + buttonStyle, + buttonRadius, + buttonPrimary, + buttonSecondary, + linkColor, + useDefaultBranding: branding.use_default_branding === true, }; } diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 2e9cdf4..5400f0c 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -7,7 +7,7 @@ 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 { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { isBrandingAllowed } from '../lib/events'; @@ -25,6 +25,15 @@ const BRANDING_FORM_DEFAULTS = { background: '#ffffff', surface: '#ffffff', mode: 'auto' as const, + buttonStyle: 'filled' as const, + buttonRadius: 12, + buttonPrimary: ADMIN_COLORS.primary, + buttonSecondary: ADMIN_COLORS.accent, + linkColor: ADMIN_COLORS.accent, + fontSize: 'm' as const, + logoMode: 'upload' as const, + logoPosition: 'left' as const, + logoSize: 'm' as const, }; const BRANDING_FORM_BASE: BrandingFormValues = { @@ -32,6 +41,20 @@ const BRANDING_FORM_BASE: BrandingFormValues = { 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 = @@ -89,6 +112,8 @@ export default function MobileBrandingPage() { 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; @@ -122,13 +147,42 @@ export default function MobileBrandingPage() { }); }, [showFontsSheet, fontsLoaded]); + React.useEffect(() => { + if (tenantBrandingLoaded) return; + let active = true; + getTenantSettings() + .then((payload) => { + if (!active) 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 previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); - const previewHeadingFont = form.headingFont || 'Fraunces'; - const previewBodyFont = form.bodyFont || 'Manrope'; - const previewSurfaceText = getContrastingTextColor(form.surface, '#ffffff', '#0f172a'); + const previewHeadingFont = previewForm.headingFont || 'Fraunces'; + const previewBodyFont = previewForm.bodyFont || 'Manrope'; + const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a'); + 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 = event?.package?.watermark_allowed !== false; const brandingAllowed = isBrandingAllowed(event ?? null); const watermarkLocked = watermarkAllowed && !brandingAllowed; + const brandingDisabled = !brandingAllowed || form.useDefaultBranding; async function handleSave() { if (!event?.slug) return; @@ -159,22 +213,38 @@ export default function MobileBrandingPage() { is_active: event.is_active ?? undefined, }; 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' @@ -185,15 +255,28 @@ export default function MobileBrandingPage() { background: form.background, surface: form.surface, }, - logo_data_url: form.logoDataUrl || null, - logo: form.logoDataUrl - ? { - mode: 'upload', - value: form.logoDataUrl, - position: 'center', - size: 'm', - } - : null, + 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); if (watermarkPayload) { @@ -445,27 +528,117 @@ export default function MobileBrandingPage() { {t('events.branding.previewTitle', 'Guest App Preview')} - - - - - - {previewTitle} - - - {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} - + + + + + + + {previewLogoUrl ? ( + {t('events.branding.logoAlt', + ) : ( + + {previewLogoValue || previewInitials} + + )} + + + + {previewTitle} + + + {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} + + + - - - - + + + + + + +
+ {t('events.branding.previewCta', 'Fotos hochladen')} +
+ {!brandingAllowed ? ( + } + text={t('events.branding.lockedBranding', 'Branding ist in diesem Paket gesperrt.')} + tone="danger" + /> + ) : null} + + + + {t('events.branding.source', 'Branding Source')} + + + {t('events.branding.sourceHint', 'Nutze das Tenant-Branding oder überschreibe es für dieses Event.')} + + + setForm((prev) => ({ ...prev, useDefaultBranding: true }))} + disabled={!brandingAllowed} + /> + setForm((prev) => ({ ...prev, useDefaultBranding: false }))} + disabled={!brandingAllowed} + /> + + + {form.useDefaultBranding + ? t('events.branding.usingDefault', 'Tenant-Branding aktiv') + : t('events.branding.usingCustom', 'Event-Branding aktiv')} + + + {t('events.branding.mode', 'Theme')} @@ -475,16 +648,19 @@ export default function MobileBrandingPage() { label={t('events.branding.modeLight', 'Light')} active={form.mode === 'light'} onPress={() => setForm((prev) => ({ ...prev, mode: 'light' }))} + disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, mode: 'auto' }))} + disabled={brandingDisabled} /> setForm((prev) => ({ ...prev, mode: 'dark' }))} + disabled={brandingDisabled} /> @@ -497,21 +673,25 @@ export default function MobileBrandingPage() { label={t('events.branding.primary', 'Primary Color')} value={form.primary} onChange={(value) => 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} /> @@ -528,6 +708,7 @@ export default function MobileBrandingPage() { setFontField('heading'); 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')} - - {form.logoDataUrl ? ( - <> - {t('events.branding.logoAlt', - - document.getElementById('branding-logo-input')?.click()} + + {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', - setForm((prev) => ({ ...prev, logoDataUrl: '' }))}> + + 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.removeLogo', 'Remove')} + + + {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} - - - ) : ( - <> - - - {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); - }} + + )} + { + 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} + /> ) : ( @@ -787,10 +1122,47 @@ function renderName(name: TenantEvent['name']): string { return ''; } -function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) { +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, border, surface } = useAdminTheme(); return ( - + {label} @@ -799,6 +1171,7 @@ function ColorField({ label, value, onChange }: { label: string; value: string; type="color" value={value} onChange={(event) => onChange(event.target.value)} + disabled={disabled} style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }} /> @@ -828,6 +1201,7 @@ function InputField({ onChange, onPicker, children, + disabled, }: { label: string; value: string; @@ -835,10 +1209,11 @@ function InputField({ onChange: (next: string) => void; onPicker?: () => void; children?: React.ReactNode; + disabled?: boolean; }) { const { textStrong, border, surface, primary } = useAdminTheme(); return ( - + {label} @@ -859,6 +1234,7 @@ function InputField({ value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} + disabled={disabled} style={{ flex: 1, height: '100%', @@ -871,7 +1247,7 @@ function InputField({ /> )} {onPicker ? ( - + ) : null} @@ -1104,10 +1480,20 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean; ); } -function ModeButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { +function ModeButton({ + label, + active, + onPress, + disabled, +}: { + label: string; + active: boolean; + onPress: () => void; + disabled?: boolean; +}) { const { backdrop, surfaceMuted, border, surface } = useAdminTheme(); return ( - + = { + s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4' }, + m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5' }, + l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6' }, +}; + +function getLogoClasses(size?: LogoSize) { + return LOGO_SIZE_CLASSES[size ?? 'm']; +} + const NOTIFICATION_ICON_MAP: Record> = { broadcast: MessageSquare, feedback_request: MessageSquare, @@ -69,18 +81,25 @@ function getInitials(name: string): string { return name.substring(0, 2).toUpperCase(); } -function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string, logo?: { mode: 'emoticon' | 'upload'; value: string | null }) { +function renderEventAvatar( + name: string, + icon: unknown, + accentColor: string, + textColor: string, + logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize } +) { + const sizes = getLogoClasses(logo?.size); if (logo?.mode === 'upload' && logo.value) { return ( -
- {name} +
+ {name}
); } if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) { return (
{logo.value} @@ -94,21 +113,21 @@ function renderEventAvatar(name: string, icon: unknown, accentColor: string, tex if (trimmed) { const normalized = trimmed.toLowerCase(); const IconComponent = EVENT_ICON_COMPONENTS[normalized]; - if (IconComponent) { - return ( -
- -
- ); - } + if (IconComponent) { + return ( +
+ +
+ ); + } if (isLikelyEmoji(trimmed)) { return (
{trimmed} @@ -188,6 +207,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const logoPosition = branding.logo?.position ?? 'left'; const headerStyle: React.CSSProperties = { background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, color: headerTextColor, @@ -219,9 +239,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string className="guest-header z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur" style={headerStyle} > -
+
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)} -
+
{event.name}
{stats && tasksEnabled && ( diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx index 5b82eb2..7fde74d 100644 --- a/resources/js/guest/context/EventBrandingContext.tsx +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -38,6 +38,11 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = { const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase(); const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase(); const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase(); +const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = { + s: 0.94, + m: 1, + l: 1.08, +}; const EventBrandingContext = createContext(undefined); @@ -62,7 +67,8 @@ function resolveBranding(input?: EventBranding | null): EventBranding { const headingFont = input.typography?.heading ?? input.fontFamily ?? null; const bodyFont = input.typography?.body ?? input.fontFamily ?? null; - const sizePreset = input.typography?.sizePreset ?? 'm'; + const rawSize = input.typography?.sizePreset ?? 'm'; + const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm'; const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon'); const logoValue = input.logo?.value ?? input.logoUrl ?? null; @@ -116,6 +122,7 @@ function applyCssVariables(branding: EventBranding) { root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor); root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled'); + root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1)); const headingFont = branding.typography?.heading ?? branding.fontFamily; const bodyFont = branding.typography?.body ?? branding.fontFamily; @@ -149,6 +156,7 @@ function resetCssVariables() { root.style.removeProperty('--guest-radius'); root.style.removeProperty('--guest-link'); root.style.removeProperty('--guest-button-style'); + root.style.removeProperty('--guest-font-scale'); root.style.removeProperty('--guest-font-family'); root.style.removeProperty('--guest-body-font'); root.style.removeProperty('--guest-heading-font'); diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx index 17be86c..7e3d73c 100644 --- a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx +++ b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx @@ -9,6 +9,11 @@ const sampleBranding: EventBranding = { backgroundColor: '#fef2f2', fontFamily: 'Montserrat, sans-serif', logoUrl: null, + typography: { + heading: null, + body: null, + sizePreset: 'l', + }, mode: 'dark', }; @@ -17,6 +22,7 @@ describe('EventBrandingProvider', () => { document.documentElement.classList.remove('guest-theme', 'dark'); document.documentElement.style.removeProperty('color-scheme'); document.documentElement.style.removeProperty('--guest-background'); + document.documentElement.style.removeProperty('--guest-font-scale'); }); it('applies guest theme classes and variables', async () => { @@ -31,6 +37,7 @@ describe('EventBrandingProvider', () => { expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.style.colorScheme).toBe('dark'); expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe(sampleBranding.backgroundColor); + expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08'); }); unmount(); diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 2bccc50..c54c727 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -232,6 +232,48 @@ class EventControllerTest extends TenantTestCase $this->assertSame('blur_last', data_get($settings, 'live_show.background_mode')); } + public function test_update_event_uploads_branding_logo_data_url(): void + { + Storage::fake('public'); + + $eventType = EventType::factory()->create(); + $event = Event::factory()->for($this->tenant)->create([ + 'event_type_id' => $eventType->id, + 'name' => 'Branding Event', + 'slug' => 'branding-event', + 'date' => now()->addDays(5), + ]); + + $logoFile = UploadedFile::fake()->image('logo.png', 64, 64); + $logoContents = file_get_contents($logoFile->getRealPath()); + $this->assertIsString($logoContents); + $logoDataUrl = 'data:image/png;base64,'.base64_encode($logoContents); + + $response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [ + 'name' => 'Branding Event', + 'event_date' => now()->addDays(5)->toDateString(), + 'event_type_id' => $eventType->id, + 'settings' => [ + 'branding' => [ + 'logo_data_url' => $logoDataUrl, + 'logo' => [ + 'mode' => 'upload', + 'value' => $logoDataUrl, + ], + ], + ], + ]); + + $response->assertOk(); + + $event->refresh(); + $logoPath = (string) data_get($event->settings, 'branding.logo_url'); + $this->assertNotEmpty($logoPath); + Storage::disk('public')->assertExists($logoPath); + $this->assertSame($logoPath, data_get($event->settings, 'branding.logo.value')); + $this->assertNull(data_get($event->settings, 'branding.logo_data_url')); + } + public function test_upload_exceeds_package_limit_fails(): void { $tenant = $this->tenant;