Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.

- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
    exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
  - Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
    custom override) that auto-load selected fonts.
  - Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
  - New tests cover font sync command and font manifest API.

  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.
This commit is contained in:
Codex Agent
2025-11-25 19:31:52 +01:00
parent 4d31eb4d42
commit 9bde8f3f32
38 changed files with 2420 additions and 104 deletions

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class SyncGoogleFonts extends Command
{
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
private const API_ENDPOINT = 'https://www.googleapis.com/webfonts/v1/webfonts';
public function handle(): int
{
$apiKey = config('services.google_fonts.key');
if (! $apiKey) {
$this->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<int, int>
*/
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<string, mixed> $font
* @return array<string, string>
*/
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',
};
}
}

View File

@@ -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<int, array|mixed> $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<int, array|mixed> $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');
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
class FontController extends Controller
{
public function index(): JsonResponse
{
$fonts = Cache::remember('fonts:manifest', 60, function () {
$path = public_path('fonts/google/manifest.json');
if (! File::exists($path)) {
return $this->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;
}
}

View File

@@ -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'],

View File

@@ -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