From 9bde8f3f327ce03db82246528438960cc9e49aac Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 25 Nov 2025 19:31:52 +0100 Subject: [PATCH] =?UTF-8?q?Neue=20Branding-Page=20und=20G=C3=A4ste-PWA=20r?= =?UTF-8?q?eagiert=20nun=20auf=20Branding-Einstellungen=20vom=20event-admi?= =?UTF-8?q?n.=20Implemented=20local=20Google=20Fonts=20pipeline=20and=20ad?= =?UTF-8?q?min=20UI=20selects=20for=20branding=20and=20invites.=20=20=20-?= =?UTF-8?q?=20Added=20fonts:sync-google=20command=20(uses=20GOOGLE=5FFONTS?= =?UTF-8?q?=5FAPI=5FKEY,=20generates=20/public/fonts/google=20files,=20man?= =?UTF-8?q?ifest,=20CSS,=20cache=20flush)=20and=20=20=20=20=20exposed=20ma?= =?UTF-8?q?nifest=20via=20new=20GET=20/api/v1/tenant/fonts=20endpoint=20wi?= =?UTF-8?q?th=20fallbacks=20for=20existing=20local=20fonts.=20=20=20-=20Im?= =?UTF-8?q?ported=20generated=20fonts=20CSS,=20added=20API=20client=20+=20?= =?UTF-8?q?font=20loader=20hook,=20and=20wired=20branding=20page=20font=20?= =?UTF-8?q?fields=20to=20searchable=20selects=20(with=20=20=20=20=20custom?= =?UTF-8?q?=20override)=20that=20auto-load=20selected=20fonts.=20=20=20-?= =?UTF-8?q?=20Invites=20layout=20editor=20now=20offers=20font=20selection?= =?UTF-8?q?=20per=20element=20with=20runtime=20font=20loading=20for=20prev?= =?UTF-8?q?iews/export=20alignment.=20=20=20-=20New=20tests=20cover=20font?= =?UTF-8?q?=20sync=20command=20and=20font=20manifest=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests run: php artisan test --filter=Fonts --testsuite=Feature. Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google. --- app/Console/Commands/SyncGoogleFonts.php | 255 ++++++ .../Controllers/Api/EventPublicController.php | 256 +++++- .../Controllers/Api/Tenant/FontController.php | 115 +++ .../Requests/Tenant/SettingsStoreRequest.php | 24 + app/Support/WatermarkConfigResolver.php | 10 +- bootstrap/app.php | 1 + config/services.php | 4 + resources/css/app.css | 7 + resources/js/admin/api.ts | 47 ++ resources/js/admin/components/EventNav.tsx | 2 + resources/js/admin/constants.ts | 1 + resources/js/admin/lib/branding.ts | 20 +- resources/js/admin/lib/eventTabs.ts | 6 + resources/js/admin/lib/fonts.ts | 77 ++ .../js/admin/pages/EventBrandingPage.tsx | 745 ++++++++++++++++++ resources/js/admin/pages/EventDetailPage.tsx | 3 +- resources/js/admin/pages/EventTasksPage.tsx | 4 +- resources/js/admin/pages/EventsPage.tsx | 2 + .../InviteLayoutCustomizerPanel.tsx | 63 +- resources/js/admin/router.tsx | 2 + resources/js/guest/components/BottomNav.tsx | 35 +- resources/js/guest/components/FiltersBar.tsx | 10 +- resources/js/guest/components/Header.tsx | 38 +- .../js/guest/context/EventBrandingContext.tsx | 157 +++- resources/js/guest/lib/color.ts | 50 ++ resources/js/guest/pages/GalleryPage.tsx | 57 +- resources/js/guest/pages/HomePage.tsx | 25 +- .../js/guest/pages/PublicGalleryPage.tsx | 49 +- resources/js/guest/pages/TaskPickerPage.tsx | 5 + resources/js/guest/pages/UploadPage.tsx | 36 +- resources/js/guest/router.tsx | 57 +- resources/js/guest/services/eventApi.ts | 40 + resources/js/guest/services/galleryApi.ts | 8 + resources/js/guest/types/event-branding.ts | 30 +- routes/api.php | 2 + .../Api/Event/EventBrandingResponseTest.php | 134 ++++ tests/Feature/Api/TenantFontsTest.php | 71 ++ tests/Feature/Console/SyncGoogleFontsTest.php | 76 ++ 38 files changed, 2420 insertions(+), 104 deletions(-) create mode 100644 app/Console/Commands/SyncGoogleFonts.php create mode 100644 app/Http/Controllers/Api/Tenant/FontController.php create mode 100644 resources/js/admin/lib/fonts.ts create mode 100644 resources/js/admin/pages/EventBrandingPage.tsx create mode 100644 resources/js/guest/lib/color.ts create mode 100644 tests/Feature/Api/Event/EventBrandingResponseTest.php create mode 100644 tests/Feature/Api/TenantFontsTest.php create mode 100644 tests/Feature/Console/SyncGoogleFontsTest.php diff --git a/app/Console/Commands/SyncGoogleFonts.php b/app/Console/Commands/SyncGoogleFonts.php new file mode 100644 index 0000000..2d7ffa1 --- /dev/null +++ b/app/Console/Commands/SyncGoogleFonts.php @@ -0,0 +1,255 @@ +error('GOOGLE_FONTS_API_KEY is missing in the environment.'); + + return self::FAILURE; + } + + $count = max(1, (int) $this->option('count')); + $weights = $this->prepareWeights($this->option('weights')); + $includeItalic = (bool) $this->option('italic'); + $force = (bool) $this->option('force'); + + $pathOption = $this->option('path'); + $basePath = $pathOption + ? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption)) + : public_path('fonts/google'); + + $this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no')); + + $response = Http::retry(2, 200) + ->timeout(30) + ->get(self::API_ENDPOINT, [ + 'key' => $apiKey, + 'sort' => 'popularity', + ]); + + if (! $response->ok()) { + $this->error(sprintf('Google Fonts API failed: %s', $response->body())); + + return self::FAILURE; + } + + $items = Arr::get($response->json(), 'items', []); + if (! is_array($items) || ! count($items)) { + $this->warn('Google Fonts API returned no items.'); + + return self::SUCCESS; + } + + $selected = array_slice($items, 0, $count); + $manifestFonts = []; + $filesystem = new Filesystem(); + File::ensureDirectoryExists($basePath); + + foreach ($selected as $index => $font) { + if (! is_array($font) || ! isset($font['family'])) { + continue; + } + + $family = (string) $font['family']; + $slug = Str::slug($family); + $familyDir = $basePath.DIRECTORY_SEPARATOR.$slug; + File::ensureDirectoryExists($familyDir); + + $variantMap = $this->buildVariantMap($font, $weights, $includeItalic); + if (! count($variantMap)) { + $this->warn("Skipping {$family} (no matching variants)"); + continue; + } + + $variants = []; + foreach ($variantMap as $variantKey => $fileUrl) { + $style = str_contains($variantKey, 'italic') ? 'italic' : 'normal'; + $weight = (int) preg_replace('/[^0-9]/', '', $variantKey) ?: 400; + $extension = pathinfo(parse_url($fileUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'woff2'; + $filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension); + $targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename; + + if (! $force && $filesystem->exists($targetPath)) { + $this->line("✔ {$family} {$variantKey} already exists"); + } else { + $this->line("↓ Downloading {$family} {$variantKey}"); + $fileResponse = Http::retry(2, 200)->timeout(30)->get($fileUrl); + + if (! $fileResponse->ok()) { + $this->warn(" Skipped {$family} {$variantKey} (download failed)"); + continue; + } + + $filesystem->put($targetPath, $fileResponse->body()); + } + + $relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename); + $variants[] = [ + 'variant' => $variantKey, + 'weight' => $weight, + 'style' => $style, + 'url' => $relativePath, + ]; + } + + if (! count($variants)) { + continue; + } + + $manifestFonts[] = [ + 'family' => $family, + 'slug' => $slug, + 'category' => $font['category'] ?? null, + 'variants' => $variants, + ]; + } + + $this->pruneStaleFamilies($basePath, $manifestFonts); + $this->writeManifest($basePath, $manifestFonts); + $this->writeCss($basePath, $manifestFonts); + Cache::forget('fonts:manifest'); + + $this->info(sprintf('Synced %d font families to %s', count($manifestFonts), $basePath)); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function prepareWeights(string $weights): array + { + $parts = array_filter(array_map('trim', explode(',', $weights))); + $numeric = array_map(fn ($weight) => (int) $weight, $parts); + $numeric = array_filter($numeric, fn ($weight) => $weight >= 100 && $weight <= 900); + + return $numeric ? array_values(array_unique($numeric)) : [400, 700]; + } + + /** + * @param array $font + * @return array + */ + private function buildVariantMap(array $font, array $weights, bool $includeItalic): array + { + $files = $font['files'] ?? []; + if (! is_array($files)) { + return []; + } + + $variants = []; + foreach ($weights as $weight) { + $normalKey = $weight === 400 ? 'regular' : (string) $weight; + $italicKey = $weight === 400 ? 'italic' : $weight.'italic'; + + if (isset($files[$normalKey])) { + $variants[$normalKey] = (string) $files[$normalKey]; + } + + if ($includeItalic && isset($files[$italicKey])) { + $variants[$italicKey] = (string) $files[$italicKey]; + } + } + + // Fallback: if no requested weights present, try any provided + if (! count($variants)) { + foreach ($files as $key => $url) { + if (is_string($key) && is_string($url)) { + $variants[$key] = $url; + } + } + } + + return $variants; + } + + private function pruneStaleFamilies(string $basePath, array $manifestFonts): void + { + $keep = collect($manifestFonts)->pluck('slug')->filter()->all(); + $directories = File::directories($basePath); + + foreach ($directories as $dir) { + $name = basename($dir); + if (! in_array($name, $keep, true)) { + File::deleteDirectory($dir); + $this->line("🗑 Removed stale font directory {$name}"); + } + } + } + + private function writeManifest(string $basePath, array $fonts): void + { + $manifest = [ + 'generated_at' => now()->toIso8601String(), + 'source' => 'google-webfonts', + 'count' => count($fonts), + 'fonts' => $fonts, + ]; + + File::put($basePath.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + private function writeCss(string $basePath, array $fonts): void + { + $lines = [ + '/* Auto-generated by fonts:sync-google */', + ]; + + foreach ($fonts as $font) { + $family = $font['family'] ?? null; + $variants = $font['variants'] ?? []; + if (! $family || ! is_array($variants)) { + continue; + } + + foreach ($variants as $variant) { + if (! isset($variant['url'])) { + continue; + } + + $lines[] = sprintf( + "@font-face {\n font-family: '%s';\n font-style: %s;\n font-weight: %s;\n font-display: swap;\n src: url('%s') format('%s');\n}\n", + $family, + $variant['style'] ?? 'normal', + $variant['weight'] ?? 400, + $variant['url'], + $this->guessFormat((string) ($variant['url'] ?? '')) + ); + } + } + + File::put($basePath.'/fonts.css', implode("\n", $lines)); + } + + private function guessFormat(string $url): string + { + $extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION)); + + return match ($extension) { + 'ttf' => 'truetype', + 'otf' => 'opentype', + 'woff' => 'woff', + default => 'woff2', + }; + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 7e7bd89..24211da 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -847,7 +847,7 @@ class EventPublicController extends BaseController } // Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...') - if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/')) { + if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/') || str_starts_with($path, 'branding/')) { return Storage::url($path); } @@ -895,42 +895,232 @@ class EventPublicController extends BaseController private function buildGalleryBranding(Event $event): array { - $defaultPrimary = '#f43f5e'; - $defaultSecondary = '#fb7185'; - $defaultBackground = '#ffffff'; + return $this->resolveBrandingPayload($event); + } - $event->loadMissing('eventPackage.package', 'tenant'); + private function normalizeHexColor(?string $value, string $fallback): string + { + if (is_string($value)) { + $trimmed = trim($value); + + if (preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $trimmed) === 1) { + return $trimmed; + } + } + + return $fallback; + } + + /** + * @param array $sources + */ + private function firstStringFromSources(array $sources, array $keys): ?string + { + foreach ($sources as $source) { + foreach ($keys as $key) { + $value = Arr::get($source ?? [], $key); + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + + return null; + } + + /** + * @param array $sources + */ + private function firstNumberFromSources(array $sources, array $keys): ?float + { + foreach ($sources as $source) { + foreach ($keys as $key) { + $value = Arr::get($source ?? [], $key); + if (is_numeric($value)) { + return (float) $value; + } + } + } + + return null; + } + + /** + * @return array{ + * primary_color: string, + * secondary_color: string, + * background_color: string, + * surface_color: string, + * font_family: ?string, + * heading_font: ?string, + * body_font: ?string, + * font_size: string, + * logo_url: ?string, + * logo_mode: string, + * logo_value: ?string, + * logo_position: string, + * logo_size: string, + * button_style: string, + * button_radius: int, + * button_primary_color: string, + * button_secondary_color: string, + * link_color: string, + * mode: string, + * use_default_branding: bool, + * palette: array{primary:string, secondary:string, background:string, surface:string}, + * typography: array{heading:?string, body:?string, size:string}, + * logo: array{mode:string, value:?string, position:string, size:string}, + * buttons: array{style:string, radius:int, primary:string, secondary:string, link_color:string}, + * icon: ?string + * } + */ + private function resolveBrandingPayload(Event $event): array + { + $defaults = [ + 'primary' => '#f43f5e', + 'secondary' => '#fb7185', + 'background' => '#ffffff', + 'surface' => '#ffffff', + 'font' => null, + 'size' => 'm', + 'logo_position' => 'left', + 'logo_size' => 'm', + 'button_style' => 'filled', + 'button_radius' => 12, + 'mode' => 'auto', + ]; + + $event->loadMissing('eventPackage.package', 'tenant', 'eventType'); $brandingAllowed = $this->determineBrandingAllowed($event); $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : []; $tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : []; - return [ - 'primary_color' => Arr::get($eventBranding, 'primary_color') - ?? Arr::get($tenantBranding, 'primary_color') - ?? $defaultPrimary, - 'secondary_color' => Arr::get($eventBranding, 'secondary_color') - ?? Arr::get($tenantBranding, 'secondary_color') - ?? $defaultSecondary, - 'background_color' => Arr::get($eventBranding, 'background_color') - ?? Arr::get($tenantBranding, 'background_color') - ?? $defaultBackground, - ]; - } + $useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false)); + $sources = $brandingAllowed + ? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding]) + : [[]]; - private function resolveFontFamily(Event $event): ?string - { - $fontFamily = Arr::get($event->settings, 'branding.font_family') - ?? Arr::get($event->tenant?->settings, 'branding.font_family'); + $primary = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']), + $defaults['primary'] + ); + $secondary = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['palette.secondary', 'secondary_color']), + $defaults['secondary'] + ); + $background = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['palette.background', 'background_color']), + $defaults['background'] + ); + $surface = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['palette.surface', 'surface_color']), + $background ?: $defaults['surface'] + ); - if (! is_string($fontFamily)) { - return null; + $headingFont = $this->firstStringFromSources($sources, ['typography.heading', 'heading_font', 'font_family']); + $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']); + $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size']; + $fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size']; + + $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']); + if (! in_array($logoMode, ['emoticon', 'upload'], true)) { + $logoMode = null; + } + $logoPosition = $this->firstStringFromSources($sources, ['logo.position', 'logo_position']) ?? $defaults['logo_position']; + if (! in_array($logoPosition, ['left', 'right', 'center'], true)) { + $logoPosition = $defaults['logo_position']; + } + $logoSize = $this->firstStringFromSources($sources, ['logo.size', 'logo_size']) ?? $defaults['logo_size']; + if (! in_array($logoSize, ['s', 'm', 'l'], true)) { + $logoSize = $defaults['logo_size']; } - $normalized = strtolower(trim($fontFamily)); - $defaultInter = strtolower('Inter, sans-serif'); + $logoRawValue = $this->firstStringFromSources($sources, ['logo.value', 'logo_url', 'icon']) + ?? ($event->eventType?->icon ?? null); + if (! $logoMode) { + $logoMode = $logoRawValue && (preg_match('/^https?:\/\//', $logoRawValue) === 1 + || str_starts_with($logoRawValue, '/storage/') + || str_starts_with($logoRawValue, 'storage/')) + ? 'upload' + : 'emoticon'; + } - return $normalized === $defaultInter ? null : $fontFamily; + $logoValue = $logoMode === 'upload' ? $this->toPublicUrl($logoRawValue) : $logoRawValue; + + $buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style']; + if (! in_array($buttonStyle, ['filled', 'outline'], true)) { + $buttonStyle = $defaults['button_style']; + } + + $buttonRadius = (int) ($this->firstNumberFromSources($sources, ['buttons.radius', 'button_radius']) ?? $defaults['button_radius']); + $buttonRadius = $buttonRadius > 0 ? $buttonRadius : $defaults['button_radius']; + + $buttonPrimary = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['buttons.primary', 'button_primary_color']), + $primary + ); + $buttonSecondary = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['buttons.secondary', 'button_secondary_color']), + $secondary + ); + $linkColor = $this->normalizeHexColor( + $this->firstStringFromSources($sources, ['buttons.link_color', 'link_color']), + $secondary + ); + + $mode = $this->firstStringFromSources($sources, ['mode']) ?? $defaults['mode']; + if (! in_array($mode, ['light', 'dark', 'auto'], true)) { + $mode = $defaults['mode']; + } + + return [ + 'primary_color' => $primary, + 'secondary_color' => $secondary, + 'background_color' => $background, + 'surface_color' => $surface, + 'font_family' => $bodyFont, + 'heading_font' => $headingFont, + 'body_font' => $bodyFont, + 'font_size' => $fontSize, + 'logo_url' => $logoMode === 'upload' ? $logoValue : null, + 'logo_mode' => $logoMode, + 'logo_value' => $logoValue, + 'logo_position' => $logoPosition, + 'logo_size' => $logoSize, + 'button_style' => $buttonStyle, + 'button_radius' => $buttonRadius, + 'button_primary_color' => $buttonPrimary, + 'button_secondary_color' => $buttonSecondary, + 'link_color' => $linkColor, + 'mode' => $mode, + 'use_default_branding' => $useDefault, + 'palette' => [ + 'primary' => $primary, + 'secondary' => $secondary, + 'background' => $background, + 'surface' => $surface, + ], + 'typography' => [ + 'heading' => $headingFont, + 'body' => $bodyFont, + 'size' => $fontSize, + ], + 'logo' => [ + 'mode' => $logoMode, + 'value' => $logoValue, + 'position' => $logoPosition, + 'size' => $logoSize, + ], + 'buttons' => [ + 'style' => $buttonStyle, + 'radius' => $buttonRadius, + 'primary' => $buttonPrimary, + 'secondary' => $buttonSecondary, + 'link_color' => $linkColor, + ], + 'icon' => $logoMode === 'emoticon' ? $logoValue : ($event->eventType?->icon ?? null), + ]; } private function encodeGalleryCursor(Photo $photo): string @@ -1393,12 +1583,6 @@ class EventPublicController extends BaseController ]; $branding = $this->buildGalleryBranding($event); - $fontFamily = $this->resolveFontFamily($event); - $brandingAllowed = $this->determineBrandingAllowed($event); - $logoUrl = $brandingAllowed - ? (Arr::get($event->settings, 'branding.logo_url') - ?? Arr::get($event->tenant?->settings, 'branding.logo_url')) - : null; if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); @@ -1414,13 +1598,7 @@ class EventPublicController extends BaseController 'type' => $eventTypeData, 'join_token' => $joinToken?->token, 'photobooth_enabled' => (bool) $event->photobooth_enabled, - 'branding' => [ - 'primary_color' => $branding['primary_color'], - 'secondary_color' => $branding['secondary_color'], - 'background_color' => $branding['background_color'], - 'font_family' => $fontFamily, - 'logo_url' => $this->toPublicUrl($logoUrl), - ], + 'branding' => $branding, ])->header('Cache-Control', 'no-store'); } diff --git a/app/Http/Controllers/Api/Tenant/FontController.php b/app/Http/Controllers/Api/Tenant/FontController.php new file mode 100644 index 0000000..3359815 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/FontController.php @@ -0,0 +1,115 @@ +fallbackFonts(); + } + + $raw = json_decode(File::get($path), true); + $entries = Arr::get($raw, 'fonts', []); + if (! is_array($entries)) { + return $this->fallbackFonts(); + } + + $fonts = array_values(array_filter(array_map(function ($font) { + if (! is_array($font) || ! isset($font['family'])) { + return null; + } + + $variants = array_values(array_filter(array_map(function ($variant) { + if (! is_array($variant) || ! isset($variant['url'])) { + return null; + } + + return [ + 'variant' => $variant['variant'] ?? null, + 'weight' => isset($variant['weight']) ? (int) $variant['weight'] : 400, + 'style' => $variant['style'] ?? 'normal', + 'url' => $variant['url'], + ]; + }, $font['variants'] ?? []))); + + if (! count($variants)) { + return null; + } + + return [ + 'family' => (string) $font['family'], + 'category' => $font['category'] ?? null, + 'variants' => $variants, + ]; + }, $entries))); + + $merged = $this->mergeFallbackFonts($fonts); + + return $merged; + }); + + return response()->json(['data' => $fonts]); + } + + private function fallbackFonts(): array + { + return [ + [ + 'family' => 'Montserrat', + 'category' => 'sans-serif', + 'variants' => [ + ['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/Montserrat-Regular.ttf'], + ['variant' => '700', 'weight' => 700, 'style' => 'normal', 'url' => '/fonts/Montserrat-Bold.ttf'], + ], + ], + [ + 'family' => 'Lora', + 'category' => 'serif', + 'variants' => [ + ['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/Lora-Regular.ttf'], + ['variant' => '700', 'weight' => 700, 'style' => 'normal', 'url' => '/fonts/Lora-Bold.ttf'], + ], + ], + [ + 'family' => 'Playfair Display', + 'category' => 'serif', + 'variants' => [ + ['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/PlayfairDisplay-Regular.ttf'], + ['variant' => '700', 'weight' => 700, 'style' => 'normal', 'url' => '/fonts/PlayfairDisplay-Bold.ttf'], + ], + ], + [ + 'family' => 'Great Vibes', + 'category' => 'script', + 'variants' => [ + ['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/GreatVibes-Regular.ttf'], + ], + ], + ]; + } + + private function mergeFallbackFonts(array $fonts): array + { + $existingFamilies = collect($fonts)->pluck('family')->all(); + $fallbacks = $this->fallbackFonts(); + + foreach ($fallbacks as $font) { + if (! in_array($font['family'], $existingFamilies, true)) { + $fonts[] = $font; + } + } + + return $fonts; + } +} + diff --git a/app/Http/Requests/Tenant/SettingsStoreRequest.php b/app/Http/Requests/Tenant/SettingsStoreRequest.php index 211a918..dafc294 100644 --- a/app/Http/Requests/Tenant/SettingsStoreRequest.php +++ b/app/Http/Requests/Tenant/SettingsStoreRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Tenant; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class SettingsStoreRequest extends FormRequest { @@ -28,6 +29,29 @@ class SettingsStoreRequest extends FormRequest 'settings.branding.primary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], 'settings.branding.secondary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], 'settings.branding.font_family' => ['nullable', 'string', 'max:100'], + 'settings.branding.surface_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.mode' => ['nullable', Rule::in(['light', 'dark', 'auto'])], + 'settings.branding.use_default_branding' => ['nullable', 'boolean'], + 'settings.branding.palette' => ['nullable', 'array'], + 'settings.branding.palette.primary' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.palette.secondary' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.palette.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.palette.surface' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.typography' => ['nullable', 'array'], + 'settings.branding.typography.heading' => ['nullable', 'string', 'max:150'], + 'settings.branding.typography.body' => ['nullable', 'string', 'max:150'], + 'settings.branding.typography.size' => ['nullable', Rule::in(['s', 'm', 'l'])], + 'settings.branding.logo' => ['nullable', 'array'], + 'settings.branding.logo.mode' => ['nullable', Rule::in(['emoticon', 'upload'])], + 'settings.branding.logo.value' => ['nullable', 'string', 'max:500'], + 'settings.branding.logo.position' => ['nullable', Rule::in(['left', 'right', 'center'])], + 'settings.branding.logo.size' => ['nullable', Rule::in(['s', 'm', 'l'])], + 'settings.branding.buttons' => ['nullable', 'array'], + 'settings.branding.buttons.style' => ['nullable', Rule::in(['filled', 'outline'])], + 'settings.branding.buttons.radius' => ['nullable', 'integer', 'min:0', 'max:64'], + 'settings.branding.buttons.primary' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.buttons.secondary' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'settings.branding.buttons.link_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'], 'settings.features' => ['sometimes', 'array'], 'settings.features.photo_likes_enabled' => ['nullable', 'boolean'], 'settings.features.event_checklist' => ['nullable', 'boolean'], diff --git a/app/Support/WatermarkConfigResolver.php b/app/Support/WatermarkConfigResolver.php index 68ef17d..368a1c8 100644 --- a/app/Support/WatermarkConfigResolver.php +++ b/app/Support/WatermarkConfigResolver.php @@ -10,9 +10,15 @@ class WatermarkConfigResolver { public static function determineBrandingAllowed(Event $event): bool { - $event->loadMissing('eventPackage.package'); + $event->loadMissing('eventPackage.package', 'eventPackages.package'); - return $event->eventPackage?->package?->branding_allowed === true; + $package = $event->eventPackage?->package; + + if (! $package && $event->relationLoaded('eventPackages')) { + $package = $event->eventPackages->first()?->package; + } + + return $package?->branding_allowed === true; } public static function determinePolicy(Event $event): string diff --git a/bootstrap/app.php b/bootstrap/app.php index d7402e8..8f8c3b2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -31,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\PurgeExpiredDataExports::class, \App\Console\Commands\ProcessTenantRetention::class, \App\Console\Commands\SendGuestFeedbackReminders::class, + \App\Console\Commands\SyncGoogleFonts::class, \App\Console\Commands\SeedDemoSwitcherTenants::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { diff --git a/config/services.php b/config/services.php index 4aed8e0..92e4337 100644 --- a/config/services.php +++ b/config/services.php @@ -25,6 +25,10 @@ return [ 'token' => env('POSTMARK_TOKEN'), ], + 'google_fonts' => [ + 'key' => env('GOOGLE_FONTS_API_KEY'), + ], + 'pexels' => [ 'key' => env('PEXELS_API_KEY'), ], diff --git a/resources/css/app.css b/resources/css/app.css index 1f10fc8..a98f9aa 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,6 +2,8 @@ @plugin 'tailwindcss-animate'; +@import '/fonts/google/fonts.css'; + @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @@ -115,7 +117,12 @@ --guest-primary: #f43f5e; --guest-secondary: #fb7185; --guest-background: #ffffff; + --guest-surface: #ffffff; + --guest-radius: 12px; + --guest-button-style: filled; + --guest-link: #fb7185; --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', serif; --guest-serif-font: 'Lora', serif; } diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index d3504ef..c5f1c00 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -181,6 +181,19 @@ export type EventAddonCatalogItem = { increments?: Record; }; +export type TenantFontVariant = { + variant: string | null; + weight: number; + style: string; + url: string; +}; + +export type TenantFont = { + family: string; + category?: string | null; + variants: TenantFontVariant[]; +}; + export type EventAddonSummary = { id: number; key: string; @@ -1266,6 +1279,18 @@ export async function getAddonCatalog(): Promise { return data.data ?? []; } +export async function getTenantFonts(): Promise { + return cachedFetch( + CacheKeys.fonts, + async () => { + const response = await authorizedFetch('/api/v1/tenant/fonts'); + const data = await jsonOrThrow<{ data?: TenantFont[] }>(response, 'Failed to load fonts'); + return data.data ?? []; + }, + 6 * 60 * 60 * 1000, + ); +} + export async function getEventTypes(): Promise { const response = await authorizedFetch('/api/v1/tenant/event-types'); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types'); @@ -1672,6 +1697,27 @@ export async function getDashboardSummary(options?: { force?: boolean }): Promis ); } +export type TenantSettingsPayload = { + id: number; + settings: Record; + updated_at: string | null; +}; + +export async function getTenantSettings(): Promise { + const response = await authorizedFetch('/api/v1/tenant/settings'); + const data = await jsonOrThrow<{ data?: { id?: number; settings?: Record; updated_at?: string | null } }>( + response, + 'Failed to load tenant settings', + ); + const payload = (data.data ?? {}) as Record; + + return { + id: Number(payload.id ?? 0), + settings: (payload.settings ?? {}) as Record, + updated_at: (payload.updated_at ?? null) as string | null, + }; +} + export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{ packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null; @@ -2236,6 +2282,7 @@ const CacheKeys = { dashboard: 'tenant:dashboard', events: 'tenant:events', packages: 'tenant:packages', + fonts: 'tenant:fonts', } as const; function cachedFetch( diff --git a/resources/js/admin/components/EventNav.tsx b/resources/js/admin/components/EventNav.tsx index 19b1582..39c7125 100644 --- a/resources/js/admin/components/EventNav.tsx +++ b/resources/js/admin/components/EventNav.tsx @@ -24,6 +24,7 @@ import { ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, + ADMIN_EVENT_BRANDING_PATH, } from '../constants'; import { cn } from '@/lib/utils'; import { resolveEventDisplayName, formatEventDate } from '../lib/events'; @@ -36,6 +37,7 @@ function buildEventLinks(slug: string, t: ReturnType['t'] { key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) }, { key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) }, { key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) }, + { key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) }, ]; } diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index d5ad5e5..198e55f 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -32,3 +32,4 @@ export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/even export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`); export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`); export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`); +export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/branding`); diff --git a/resources/js/admin/lib/branding.ts b/resources/js/admin/lib/branding.ts index ed19e96..c938866 100644 --- a/resources/js/admin/lib/branding.ts +++ b/resources/js/admin/lib/branding.ts @@ -12,7 +12,8 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null if (settings && typeof settings === 'object') { const brand = (settings as Record).branding; if (brand && typeof brand === 'object') { - const colorPalette = (brand as Record).colors; + const palette = (brand as Record).palette as Record | undefined; + const colorPalette = palette ?? (brand as Record).colors; if (colorPalette && typeof colorPalette === 'object') { const paletteRecord = colorPalette as Record; for (const key of Object.keys(paletteRecord)) { @@ -22,7 +23,22 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null } } } - const fontValue = (brand as Record).font_family; + + const directColors = [ + (brand as Record).primary_color, + (brand as Record).secondary_color, + (brand as Record).background_color, + ]; + directColors.forEach((value) => { + if (typeof value === 'string' && value.trim()) { + colors.push(value); + } + }); + + const typography = (brand as Record).typography as Record | undefined; + const fontValue = (brand as Record).font_family + ?? (typography?.body as string | undefined) + ?? (typography?.heading as string | undefined); if (typeof fontValue === 'string' && fontValue.trim()) { font = fontValue.trim(); } diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index 667e49b..0b54893 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -6,6 +6,7 @@ import { ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_RECAP_PATH, + ADMIN_EVENT_BRANDING_PATH, } from '../constants'; export type EventTabCounts = Partial<{ @@ -52,6 +53,11 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts href: ADMIN_EVENT_INVITES_PATH(event.slug), badge: formatBadge(counts.invites ?? event.active_invites_count ?? event.total_invites_count ?? null), }, + { + key: 'branding', + label: translate('eventMenu.branding', 'Branding'), + href: ADMIN_EVENT_BRANDING_PATH(event.slug), + }, { key: 'photobooth', label: translate('eventMenu.photobooth', 'Photobooth'), diff --git a/resources/js/admin/lib/fonts.ts b/resources/js/admin/lib/fonts.ts new file mode 100644 index 0000000..e3c5e3e --- /dev/null +++ b/resources/js/admin/lib/fonts.ts @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getTenantFonts, type TenantFont, type TenantFontVariant } from '../api'; + +const fontLoaders = new Map>(); + +export function useTenantFonts() { + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: ['tenant', 'fonts'], + queryFn: getTenantFonts, + staleTime: 6 * 60 * 60 * 1000, + }); + + return { + fonts: data ?? [], + isLoading: isLoading || isFetching, + refetch, + }; +} + +export function ensureFontLoaded(font: TenantFont, preferred?: { weight?: number; style?: string }): Promise { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return Promise.resolve(); + } + + const variant = pickVariant(font, preferred); + if (!variant) { + return Promise.resolve(); + } + + const key = `${font.family}-${variant.weight}-${variant.style}`; + + if (document.fonts?.check(`1rem "${font.family}"`)) { + return Promise.resolve(); + } + + if (! fontLoaders.has(key)) { + const loader = new FontFace(font.family, `url(${variant.url})`, { + weight: String(variant.weight ?? ''), + style: variant.style ?? 'normal', + display: 'swap', + }) + .load() + .then((fontFace) => { + document.fonts.add(fontFace); + }) + .catch((error) => { + console.warn('[fonts] failed to load font', font.family, variant, error); + fontLoaders.delete(key); + }); + + fontLoaders.set(key, loader); + } + + return fontLoaders.get(key) ?? Promise.resolve(); +} + +function pickVariant(font: TenantFont, preferred?: { weight?: number; style?: string }): TenantFontVariant | null { + const variants = font.variants ?? []; + if (! variants.length) { + return null; + } + + if (preferred?.weight || preferred?.style) { + const found = variants.find((variant) => { + const matchesWeight = preferred.weight ? Number(variant.weight) === Number(preferred.weight) : true; + const matchesStyle = preferred.style ? variant.style === preferred.style : true; + return matchesWeight && matchesStyle; + }); + if (found) { + return found; + } + } + + return variants[0]; +} + diff --git a/resources/js/admin/pages/EventBrandingPage.tsx b/resources/js/admin/pages/EventBrandingPage.tsx new file mode 100644 index 0000000..afae40b --- /dev/null +++ b/resources/js/admin/pages/EventBrandingPage.tsx @@ -0,0 +1,745 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react'; +import toast from 'react-hot-toast'; + +import { AdminLayout } from '../components/AdminLayout'; +import { SectionCard, SectionHeader } from '../components/tenant'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ADMIN_EVENT_VIEW_PATH } from '../constants'; +import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api'; +import { cn } from '@/lib/utils'; +import { getContrastingTextColor } from '../../guest/lib/color'; +import { buildEventTabs } from '../lib/eventTabs'; +import { ensureFontLoaded, useTenantFonts } from '../lib/fonts'; + +type BrandingForm = { + useDefault: boolean; + palette: { + primary: string; + secondary: string; + background: string; + surface: string; + }; + typography: { + heading: string; + body: string; + size: 's' | 'm' | 'l'; + }; + logo: { + mode: 'emoticon' | 'upload'; + value: string; + position: 'left' | 'right' | 'center'; + size: 's' | 'm' | 'l'; + }; + buttons: { + style: 'filled' | 'outline'; + radius: number; + primary: string; + secondary: string; + linkColor: string; + }; + mode: 'light' | 'dark' | 'auto'; +}; + +type BrandingSource = Record | null | undefined; + +const DEFAULT_BRANDING_FORM: BrandingForm = { + useDefault: false, + palette: { + primary: '#f43f5e', + secondary: '#fb7185', + background: '#ffffff', + surface: '#ffffff', + }, + typography: { + heading: '', + body: '', + size: 'm', + }, + logo: { + mode: 'emoticon', + value: '✨', + position: 'left', + size: 'm', + }, + buttons: { + style: 'filled', + radius: 12, + primary: '#f43f5e', + secondary: '#fb7185', + linkColor: '#fb7185', + }, + mode: 'auto', +}; + +function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function asHex(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : fallback; +} + +function asNumber(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +function coerceSize(value: unknown, fallback: 's' | 'm' | 'l'): 's' | 'm' | 'l' { + return value === 's' || value === 'm' || value === 'l' ? value : fallback; +} + +function coerceLogoMode(value: unknown, fallback: 'emoticon' | 'upload'): 'emoticon' | 'upload' { + return value === 'upload' || value === 'emoticon' ? value : fallback; +} + +function coercePosition(value: unknown, fallback: 'left' | 'right' | 'center'): 'left' | 'right' | 'center' { + return value === 'left' || value === 'right' || value === 'center' ? value : fallback; +} + +function coerceButtonStyle(value: unknown, fallback: 'filled' | 'outline'): 'filled' | 'outline' { + return value === 'outline' || value === 'filled' ? value : fallback; +} + +function mapBranding(source: BrandingSource, fallback: BrandingForm = DEFAULT_BRANDING_FORM): BrandingForm { + const paletteSource = (source?.palette as Record | undefined) ?? {}; + const typographySource = (source?.typography as Record | undefined) ?? {}; + const logoSource = (source?.logo as Record | undefined) ?? {}; + const buttonSource = (source?.buttons as Record | undefined) ?? {}; + + const palette = { + primary: asHex(paletteSource.primary ?? source?.primary_color, fallback.palette.primary), + secondary: asHex(paletteSource.secondary ?? source?.secondary_color, fallback.palette.secondary), + background: asHex(paletteSource.background ?? source?.background_color, fallback.palette.background), + surface: asHex(paletteSource.surface ?? source?.surface_color ?? paletteSource.background ?? source?.background_color, fallback.palette.surface ?? fallback.palette.background), + }; + + const typography = { + heading: asString(typographySource.heading ?? source?.heading_font ?? source?.font_family, fallback.typography.heading), + body: asString(typographySource.body ?? source?.body_font ?? source?.font_family, fallback.typography.body), + size: coerceSize(typographySource.size ?? source?.font_size, fallback.typography.size), + }; + + const logoMode = coerceLogoMode(logoSource.mode ?? source?.logo_mode, fallback.logo.mode); + const logoValue = asString(logoSource.value ?? source?.logo_value ?? source?.logo_url ?? fallback.logo.value, fallback.logo.value); + + const logo = { + mode: logoMode, + value: logoValue, + position: coercePosition(logoSource.position ?? source?.logo_position, fallback.logo.position), + size: coerceSize(logoSource.size ?? source?.logo_size, fallback.logo.size), + }; + + const buttons = { + style: coerceButtonStyle(buttonSource.style ?? source?.button_style, fallback.buttons.style), + radius: asNumber(buttonSource.radius ?? source?.button_radius, fallback.buttons.radius), + primary: asHex(buttonSource.primary ?? source?.button_primary_color, fallback.buttons.primary ?? palette.primary), + secondary: asHex(buttonSource.secondary ?? source?.button_secondary_color, fallback.buttons.secondary ?? palette.secondary), + linkColor: asHex(buttonSource.link_color ?? source?.link_color, fallback.buttons.linkColor ?? palette.secondary), + }; + + return { + useDefault: Boolean(source?.use_default_branding ?? source?.use_default ?? fallback.useDefault), + palette, + typography, + logo, + buttons, + mode: (source?.mode as BrandingForm['mode']) ?? fallback.mode, + }; +} + +function buildPayload(form: BrandingForm) { + return { + use_default_branding: form.useDefault, + primary_color: form.palette.primary, + secondary_color: form.palette.secondary, + background_color: form.palette.background, + surface_color: form.palette.surface, + heading_font: form.typography.heading || null, + body_font: form.typography.body || null, + font_size: form.typography.size, + logo_mode: form.logo.mode, + logo_value: form.logo.value || null, + logo_position: form.logo.position, + logo_size: form.logo.size, + button_style: form.buttons.style, + button_radius: form.buttons.radius, + button_primary_color: form.buttons.primary || null, + button_secondary_color: form.buttons.secondary || null, + link_color: form.buttons.linkColor || null, + mode: form.mode, + palette: { + primary: form.palette.primary, + secondary: form.palette.secondary, + background: form.palette.background, + surface: form.palette.surface, + }, + typography: { + heading: form.typography.heading || null, + body: form.typography.body || null, + size: form.typography.size, + }, + logo: { + mode: form.logo.mode, + value: form.logo.value || null, + position: form.logo.position, + size: form.logo.size, + }, + buttons: { + style: form.buttons.style, + radius: form.buttons.radius, + primary: form.buttons.primary || null, + secondary: form.buttons.secondary || null, + link_color: form.buttons.linkColor || null, + }, + }; +} + +function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm | null): BrandingForm { + if (form.useDefault && tenantBranding) { + return { ...tenantBranding, useDefault: true }; + } + if (form.useDefault) { + return { ...DEFAULT_BRANDING_FORM, useDefault: true }; + } + return form; +} + +export default function EventBrandingPage(): React.ReactElement { + const { slug } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation('management'); + const queryClient = useQueryClient(); + const [form, setForm] = useState(DEFAULT_BRANDING_FORM); + const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light'); + const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); + + const title = t('branding.title', 'Branding & Fonts'); + const subtitle = t('branding.subtitle', 'Farben, Typografie, Logo/Emoticon und Schaltflächen für die Gäste-App anpassen.'); + + const { data: tenantSettings } = useQuery({ + queryKey: ['tenant', 'settings', 'branding'], + queryFn: getTenantSettings, + staleTime: 60_000, + }); + + const tenantBranding = useMemo( + () => mapBranding((tenantSettings?.settings as Record | undefined)?.branding as BrandingSource, DEFAULT_BRANDING_FORM), + [tenantSettings], + ); + + const { + data: loadedEvent, + isLoading: eventLoading, + } = useQuery({ + queryKey: ['tenant', 'events', slug], + queryFn: () => getEvent(slug!), + enabled: Boolean(slug), + staleTime: 30_000, + }); + + const eventTabs = useMemo(() => { + if (!loadedEvent) return []; + const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + return buildEventTabs(loadedEvent, translateMenu); + }, [loadedEvent, t]); + + useEffect(() => { + if (!loadedEvent) return; + + const brandingSource = (loadedEvent.settings as Record | undefined)?.branding as BrandingSource; + const mapped = mapBranding(brandingSource, tenantBranding ?? DEFAULT_BRANDING_FORM); + setForm(mapped); + setPreviewTheme(mapped.mode === 'dark' ? 'dark' : 'light'); + }, [loadedEvent, tenantBranding]); + + useEffect(() => { + const resolved = resolvePreviewBranding(form, tenantBranding); + setPreviewTheme(resolved.mode === 'dark' ? 'dark' : 'light'); + }, [form.mode, form.useDefault, tenantBranding]); + + useEffect(() => { + const families = [form.typography.heading, form.typography.body].filter(Boolean) as string[]; + families.forEach((family) => { + const font = availableFonts.find((entry) => entry.family === family); + if (font) { + void ensureFontLoaded(font); + } + }); + }, [availableFonts, form.typography.body, form.typography.heading]); + + const mutation = useMutation({ + mutationFn: async (payload: BrandingForm) => { + if (!slug) throw new Error('Missing event slug'); + const response = await updateEvent(slug, { + settings: { + branding: buildPayload(payload), + }, + }); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tenant', 'events', slug] }); + toast.success(t('branding.saved', 'Branding gespeichert.')); + }, + onError: (error: unknown) => { + console.error('[branding] save failed', error); + toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); + }, + }); + + if (!slug) { + return ( + + +

+ {t('branding.errors.missingSlug', 'Kein Event ausgewählt – bitte über die Eventliste öffnen.')} +

+
+
+ ); + } + + const resolveFontSelectValue = (current: string): string => { + if (!current) return ''; + return availableFonts.some((font) => font.family === current) ? current : '__custom'; + }; + + const handleFontSelect = (key: 'heading' | 'body', value: string) => { + const resolved = value === '__custom' ? '' : value; + setForm((prev) => ({ ...prev, typography: { ...prev.typography, [key]: resolved } })); + const font = availableFonts.find((entry) => entry.family === resolved); + if (font) { + void ensureFontLoaded(font); + } + }; + + const previewBranding = resolvePreviewBranding(form, tenantBranding); + + return ( + navigate(ADMIN_EVENT_VIEW_PATH(slug))}> + + {t('branding.actions.back', 'Zurück zum Event')} + + )} + > +
+ + +
+
+

+ {form.useDefault + ? t('branding.useDefault', 'Standard nutzen') + : t('branding.useCustom', 'Event-spezifisch')} +

+

+ {t('branding.toggleHint', 'Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.')} +

+
+
+ {t('branding.standard', 'Standard')} + setForm((prev) => ({ ...prev, useDefault: !checked ? true : false }))} + aria-label={t('branding.toggleAria', 'Event-spezifisches Branding aktivieren')} + /> + {t('branding.custom', 'Event')} +
+
+
+ +
+ + +
+ {(['primary', 'secondary', 'background', 'surface'] as const).map((key) => ( +
+ +
+ setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))} + disabled={form.useDefault} + className="h-10 w-16 p-1" + /> + setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))} + disabled={form.useDefault} + /> +
+
+ ))} +
+ + +
+
+
+ + + +
+
+ + + setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))} + disabled={form.useDefault} + placeholder="z. B. Playfair Display" + /> +
+
+ + + setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))} + disabled={form.useDefault} + placeholder="z. B. Inter, sans-serif" + /> +
+
+ + +
+
+ + setForm((prev) => ({ ...prev, logo: { ...prev.logo, value: e.target.value } }))} + disabled={form.useDefault} + placeholder="✨ oder https://..." + /> +
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+ + +
+
+ +
+ setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, radius: Number(e.target.value) } }))} + disabled={form.useDefault} + className="w-full" + /> + {form.buttons.radius}px +
+
+
+ + setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, linkColor: e.target.value } }))} + disabled={form.useDefault} + placeholder="#fb7185" + /> +
+
+
+
+ + setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, primary: e.target.value } }))} + disabled={form.useDefault} + placeholder={form.palette.primary} + /> +
+
+ + setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, secondary: e.target.value } }))} + disabled={form.useDefault} + placeholder={form.palette.secondary} + /> +
+
+
+ + + +
+
+ + {form.useDefault ? t('branding.usingDefault', 'Standard-Branding aktiv') : t('branding.usingCustom', 'Event-Branding aktiv')} +
+
+ + +
+
+ +
+
+ +
+
+
+ {form.useDefault + ? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.') + : t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')} +
+
+ + +
+
+
+
+ ); +} + +function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) { + const textColor = getContrastingTextColor(branding.palette.primary, '#0f172a', '#ffffff'); + const headerStyle: React.CSSProperties = { + background: `linear-gradient(135deg, ${branding.palette.primary}, ${branding.palette.secondary})`, + color: textColor, + }; + + const buttonStyle: React.CSSProperties = branding.buttons.style === 'outline' + ? { + border: `2px solid ${branding.buttons.primary || branding.palette.primary}`, + color: branding.buttons.primary || branding.palette.primary, + background: 'transparent', + } + : { + background: branding.buttons.primary || branding.palette.primary, + color: getContrastingTextColor(branding.buttons.primary || branding.palette.primary, '#0f172a', '#ffffff'), + border: 'none', + }; + + return ( + + +
+
+
+ {branding.logo.value || '✨'} +
+
+ + Demo Event + + Gastansicht · {branding.mode} +
+
+
+
+ +
+

+ CTA & Buttons spiegeln den gewählten Stil wider. +

+ +
+ +
+ Bottom Navigation +
+
+
+
+
+
+ + + ); +} diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index aed1690..87e81bd 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -56,6 +56,7 @@ import { ADMIN_EVENT_PHOTOBOOTH_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH, + ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath, } from '../constants'; import { @@ -491,7 +492,7 @@ const shownWarningToasts = React.useRef>(new Set()); event={event} invites={toolkitData?.invites} emotions={emotions} - onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} + onOpenBranding={() => navigate(ADMIN_EVENT_BRANDING_PATH(event.slug))} onOpenCollections={() => navigate(buildEngagementTabPath('collections'))} onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))} diff --git a/resources/js/admin/pages/EventTasksPage.tsx b/resources/js/admin/pages/EventTasksPage.tsx index 6ea6f4b..0f07620 100644 --- a/resources/js/admin/pages/EventTasksPage.tsx +++ b/resources/js/admin/pages/EventTasksPage.tsx @@ -30,7 +30,7 @@ import { TenantEmotion, } from '../api'; import { isAuthError } from '../auth/tokens'; -import { ADMIN_EVENTS_PATH, ADMIN_EVENT_INVITES_PATH, buildEngagementTabPath } from '../constants'; +import { ADMIN_EVENTS_PATH, ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath } from '../constants'; import { extractBrandingPalette } from '../lib/branding'; import { filterEmotionsByEventType } from '../lib/emotions'; import { buildEventTabs } from '../lib/eventTabs'; @@ -506,7 +506,7 @@ export default function EventTasksPage() { collections={collections} onOpenBranding={() => { if (!slug) return; - navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`); + navigate(ADMIN_EVENT_BRANDING_PATH(slug)); }} onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))} onOpenCollections={() => navigate(buildEngagementTabPath('collections'))} diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index f709e17..b452f5d 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -21,6 +21,7 @@ import { ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, + ADMIN_EVENT_BRANDING_PATH, } from '../constants'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { useTranslation } from 'react-i18next'; @@ -182,6 +183,7 @@ function EventCard({ { key: 'members', label: translate('events.list.actions.members', 'Mitglieder'), to: ADMIN_EVENT_MEMBERS_PATH(slug) }, { key: 'tasks', label: translate('events.list.actions.tasks', 'Tasks'), to: ADMIN_EVENT_TASKS_PATH(slug) }, { key: 'invites', label: translate('events.list.actions.invites', 'QR-Einladungen'), to: ADMIN_EVENT_INVITES_PATH(slug) }, + { key: 'branding', label: translate('events.list.actions.branding', 'Branding'), to: ADMIN_EVENT_BRANDING_PATH(slug) }, { key: 'photobooth', label: translate('events.list.actions.photobooth', 'Photobooth'), to: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) }, { key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_VIEW_PATH(slug) }, ]; diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 06c1be9..2703861 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -31,6 +31,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; +import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts'; import type { EventQrInvite, EventQrInviteLayout } from '../../api'; import { authorizedFetch } from '../../auth/tokens'; @@ -214,6 +215,7 @@ export function InviteLayoutCustomizerPanel({ onDraftChange, }: InviteLayoutCustomizerPanelProps): React.JSX.Element { const { t } = useTranslation('management'); + const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); const inviteUrl = invite?.url ?? ''; const qrCodeDataUrl = invite?.qr_code_data_url ?? null; @@ -269,6 +271,40 @@ export function InviteLayoutCustomizerPanel({ const appliedLayoutRef = React.useRef(null); const appliedInviteRef = React.useRef(null); + const handleElementFontChange = React.useCallback( + (id: string, family: string) => { + updateElement(id, { fontFamily: family || null }); + const font = availableFonts.find((entry) => entry.family === family); + if (font) { + void ensureFontLoaded(font).then(() => { + fabricCanvasRef.current?.requestRenderAll(); + }); + } + }, + [availableFonts, updateElement] + ); + + React.useEffect(() => { + if (!availableFonts.length || !elements.length) { + return; + } + + const families = Array.from( + new Set( + elements + .map((element) => element.fontFamily) + .filter((value): value is string => Boolean(value)), + ), + ); + + families.forEach((family) => { + const font = availableFonts.find((entry) => entry.family === family); + if (font) { + void ensureFontLoaded(font); + } + }); + }, [availableFonts, elements]); + React.useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { setIsCompact(false); @@ -1269,7 +1305,7 @@ export function InviteLayoutCustomizerPanel({ if (element.type !== 'qr') { blocks.push( -
+
+
+ + + handleElementFontChange(element.id, event.target.value)} + placeholder="z. B. Playfair Display" + /> +
); } @@ -1322,7 +1381,7 @@ export function InviteLayoutCustomizerPanel({
); }, - [elementBindings, form, t, updateElement, updateElementAlign, updateElementContent, updateForm] + [availableFonts, elementBindings, form, handleElementFontChange, t, updateElement, updateElementAlign, updateElementContent, updateForm] ); const renderActionButtons = (mode: 'inline' | 'floating') => ( diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index b8dad1f..26b660c 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -20,6 +20,7 @@ const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage')); const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage')); const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage')); const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage')); +const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage')); const EngagementPage = React.lazy(() => import('./pages/EngagementPage')); const BillingPage = React.lazy(() => import('./pages/BillingPage')); const TasksPage = React.lazy(() => import('./pages/TasksPage')); @@ -105,6 +106,7 @@ export const router = createBrowserRouter([ { path: 'events/:slug/members', element: }, { path: 'events/:slug/tasks', element: }, { path: 'events/:slug/invites', element: }, + { path: 'events/:slug/branding', element: }, { path: 'events/:slug/photobooth', element: }, { path: 'events/:slug/toolkit', element: }, { path: 'engagement', element: }, diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 41a1f6f..355c265 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -10,12 +10,16 @@ function TabLink({ children, isActive, accentColor, + radius, + style, compact = false, }: { to: string; children: React.ReactNode; isActive: boolean; accentColor: string; + radius: number; + style?: React.CSSProperties; compact?: boolean; }) { const activeStyle = isActive @@ -23,8 +27,10 @@ function TabLink({ background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`, color: '#ffffff', boxShadow: `0 12px 30px ${accentColor}33`, + borderRadius: radius, + ...style, } - : undefined; + : { borderRadius: radius, ...style }; return (
- +
{labels.home}
- +
{labels.tasks} @@ -102,6 +126,7 @@ export default function BottomNav() { style={{ background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`, boxShadow: `0 20px 35px ${branding.primaryColor}44`, + borderRadius: radius, }} > @@ -112,6 +137,8 @@ export default function BottomNav() { to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor} + radius={radius} + style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined} compact={compact} >
@@ -123,6 +150,8 @@ export default function BottomNav() { to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor} + radius={radius} + style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined} compact={compact} >
diff --git a/resources/js/guest/components/FiltersBar.tsx b/resources/js/guest/components/FiltersBar.tsx index 7dbe471..85aaf61 100644 --- a/resources/js/guest/components/FiltersBar.tsx +++ b/resources/js/guest/components/FiltersBar.tsx @@ -18,7 +18,14 @@ export default function FiltersBar({ onChange, className, showPhotobooth = true, -}: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string; showPhotobooth?: boolean }) { + styleOverride, +}: { + value: GalleryFilter; + onChange: (v: GalleryFilter) => void; + className?: string; + showPhotobooth?: boolean; + styleOverride?: React.CSSProperties; +}) { const { t } = useTranslation(); const filters: FilterConfig = React.useMemo( () => (showPhotobooth @@ -33,6 +40,7 @@ export default function FiltersBar({ 'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', className, )} + style={styleOverride} > {filters.map((filter) => ( ) : ( - + {newPhotosBadgeText} )}
-
- - {loading &&

{t('galleryPage.loading', 'Lade…')}

} + + {loading &&

{t('galleryPage.loading', 'Lade…')}

}
{list.map((p: GalleryPhoto) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); @@ -344,7 +350,8 @@ export default function GalleryPage() { openPhoto(); } }} - className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400" + className="group relative overflow-hidden border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400" + style={{ borderRadius: radius }} >
-
- {localizedTaskTitle &&

{localizedTaskTitle}

} -
+
+ {localizedTaskTitle &&

{localizedTaskTitle}

} +
{createdLabel} {p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}
{localizedTaskTitle && ( - + {localizedTaskTitle} )} @@ -378,11 +385,17 @@ export default function GalleryPage() { onShare(p); }} className={cn( - 'flex h-9 w-9 items-center justify-center rounded-full border border-white/40 bg-black/40 text-white transition backdrop-blur', + 'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur', shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10' )} aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')} disabled={shareTargetId === p.id} + style={{ + borderRadius: radius, + background: buttonStyle === 'outline' ? 'transparent' : '#00000066', + border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)', + color: buttonStyle === 'outline' ? linkColor : undefined, + }} > @@ -393,10 +406,16 @@ export default function GalleryPage() { onLike(p.id); }} className={cn( - 'flex items-center gap-1 rounded-full border border-white/40 bg-black/40 px-3 py-1 text-sm font-medium text-white transition backdrop-blur', + 'flex items-center gap-1 px-3 py-1 text-sm font-medium transition backdrop-blur', liked.has(p.id) ? 'text-pink-300' : 'text-white' )} aria-label={t('galleryPage.photo.likeAria', 'Foto liken')} + style={{ + borderRadius: radius, + background: buttonStyle === 'outline' ? 'transparent' : '#00000066', + border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)', + color: buttonStyle === 'outline' ? linkColor : undefined, + }} > {likeCount} @@ -491,3 +510,9 @@ export default function GalleryPage() { ); } + const { branding } = useEventBranding(); + const radius = branding.buttons?.radius ?? 12; + const buttonStyle = branding.buttons?.style ?? 'filled'; + const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index f65e22b..ecd3e0a 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -20,6 +20,9 @@ export default function HomePage() { const { completedCount } = useGuestTaskProgress(token ?? ''); const { t, locale } = useTranslation(); const { branding } = useEventBranding(); + const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const radius = branding.buttons?.radius ?? 12; const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed'; const [heroVisible, setHeroVisible] = React.useState(() => { @@ -133,7 +136,7 @@ export default function HomePage() { } return ( -
+
{heroVisible && ( )} -
+

Starte dein Fotospiel

@@ -163,7 +166,7 @@ export default function HomePage() { onShuffle={shuffleMissionPreview} /> - +
@@ -328,15 +331,23 @@ function UploadActionCard({ token, accentColor, secondaryAccent, + radius, + bodyFont, }: { token: string; accentColor: string; secondaryAccent: string; + radius: number; + bodyFont?: string; }) { return (
@@ -348,7 +359,11 @@ function UploadActionCard({

Kamera öffnen oder ein Foto aus deiner Galerie wählen.

- diff --git a/resources/js/guest/pages/PublicGalleryPage.tsx b/resources/js/guest/pages/PublicGalleryPage.tsx index a84dec5..6460250 100644 --- a/resources/js/guest/pages/PublicGalleryPage.tsx +++ b/resources/js/guest/pages/PublicGalleryPage.tsx @@ -8,6 +8,7 @@ import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type Ga import { useTranslation } from '../i18n/useTranslation'; import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages'; import { AlertTriangle, Download, Loader2, X } from 'lucide-react'; +import { getContrastingTextColor } from '../lib/color'; interface GalleryState { meta: GalleryMetaResponse | null; @@ -90,6 +91,29 @@ export default function PublicGalleryPage(): React.ReactElement | null { loadInitial(); }, [loadInitial]); + useEffect(() => { + const mode = state.meta?.branding.mode; + if (!mode || typeof document === 'undefined') { + return; + } + + const wasDark = document.documentElement.classList.contains('dark'); + + if (mode === 'dark') { + document.documentElement.classList.add('dark'); + } else if (mode === 'light') { + document.documentElement.classList.remove('dark'); + } + + return () => { + if (wasDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }; + }, [state.meta?.branding.mode]); + const loadMore = useCallback(async () => { if (!token || !state.cursor || state.loadingMore) { return; @@ -140,10 +164,17 @@ export default function PublicGalleryPage(): React.ReactElement | null { return {} as React.CSSProperties; } + const palette = state.meta.branding.palette ?? {}; + const primary = palette.primary ?? state.meta.branding.primary_color; + const secondary = palette.secondary ?? state.meta.branding.secondary_color; + const background = palette.background ?? state.meta.branding.background_color; + const surface = palette.surface ?? state.meta.branding.surface_color ?? background; + return { - '--gallery-primary': state.meta.branding.primary_color, - '--gallery-secondary': state.meta.branding.secondary_color, - '--gallery-background': state.meta.branding.background_color, + '--gallery-primary': primary, + '--gallery-secondary': secondary, + '--gallery-background': background, + '--gallery-surface': surface, } as React.CSSProperties & Record; }, [state.meta]); @@ -151,9 +182,13 @@ export default function PublicGalleryPage(): React.ReactElement | null { if (!state.meta) { return {}; } + const palette = state.meta.branding.palette ?? {}; + const primary = palette.primary ?? state.meta.branding.primary_color; + const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary; + const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff'); return { - background: state.meta.branding.primary_color, - color: '#ffffff', + background: `linear-gradient(135deg, ${primary}, ${secondary})`, + color: textColor, } satisfies React.CSSProperties; }, [state.meta]); @@ -162,7 +197,7 @@ export default function PublicGalleryPage(): React.ReactElement | null { return {}; } return { - color: state.meta.branding.primary_color, + color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color), } satisfies React.CSSProperties; }, [state.meta]); @@ -171,7 +206,7 @@ export default function PublicGalleryPage(): React.ReactElement | null { return {}; } return { - backgroundColor: state.meta.branding.background_color, + backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color, } satisfies React.CSSProperties; }, [state.meta]); diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index 921483d..98ec6cd 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -169,6 +169,11 @@ export default function TaskPickerPage() { const [searchParams, setSearchParams] = useSearchParams(); const { branding } = useEventBranding(); const { t, locale } = useTranslation(); + const radius = branding.buttons?.radius ?? 12; + const buttonStyle = branding.buttons?.style ?? 'filled'; + const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const { isCompleted } = useGuestTaskProgress(eventKey); diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 306a019..edbd8b9 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -33,6 +33,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; import { useEventStats } from '../context/EventStatsContext'; +import { useEventBranding } from '../context/EventBrandingContext'; import { compressPhoto, formatBytes } from '../lib/image'; interface Task { @@ -113,6 +114,11 @@ export default function UploadPage() { const { markCompleted } = useGuestTaskProgress(token); const { t, locale } = useTranslation(); const stats = useEventStats(); + const { branding } = useEventBranding(); + const radius = branding.buttons?.radius ?? 12; + const buttonStyle = branding.buttons?.style ?? 'filled'; + const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const taskIdParam = searchParams.get('task'); const emotionSlug = searchParams.get('emotion') || ''; @@ -936,7 +942,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ const canRetryCamera = permissionState !== 'unsupported'; return ( -
+
@@ -948,11 +957,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
{canRetryCamera && ( - )} -
@@ -962,9 +980,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ return renderWithDialog( <> -
+
{taskFloatingCard} -
+