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:
255
app/Console/Commands/SyncGoogleFonts.php
Normal file
255
app/Console/Commands/SyncGoogleFonts.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -847,7 +847,7 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...')
|
// 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);
|
return Storage::url($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,42 +895,232 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
private function buildGalleryBranding(Event $event): array
|
private function buildGalleryBranding(Event $event): array
|
||||||
{
|
{
|
||||||
$defaultPrimary = '#f43f5e';
|
return $this->resolveBrandingPayload($event);
|
||||||
$defaultSecondary = '#fb7185';
|
}
|
||||||
$defaultBackground = '#ffffff';
|
|
||||||
|
|
||||||
$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);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||||
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||||
|
|
||||||
return [
|
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||||
'primary_color' => Arr::get($eventBranding, 'primary_color')
|
$sources = $brandingAllowed
|
||||||
?? Arr::get($tenantBranding, 'primary_color')
|
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||||
?? $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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveFontFamily(Event $event): ?string
|
$primary = $this->normalizeHexColor(
|
||||||
{
|
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||||
$fontFamily = Arr::get($event->settings, 'branding.font_family')
|
$defaults['primary']
|
||||||
?? Arr::get($event->tenant?->settings, 'branding.font_family');
|
);
|
||||||
|
$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)) {
|
$headingFont = $this->firstStringFromSources($sources, ['typography.heading', 'heading_font', 'font_family']);
|
||||||
return null;
|
$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));
|
$logoRawValue = $this->firstStringFromSources($sources, ['logo.value', 'logo_url', 'icon'])
|
||||||
$defaultInter = strtolower('Inter, sans-serif');
|
?? ($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
|
private function encodeGalleryCursor(Photo $photo): string
|
||||||
@@ -1393,12 +1583,6 @@ class EventPublicController extends BaseController
|
|||||||
];
|
];
|
||||||
|
|
||||||
$branding = $this->buildGalleryBranding($event);
|
$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) {
|
if ($joinToken) {
|
||||||
$this->joinTokenService->incrementUsage($joinToken);
|
$this->joinTokenService->incrementUsage($joinToken);
|
||||||
@@ -1414,13 +1598,7 @@ class EventPublicController extends BaseController
|
|||||||
'type' => $eventTypeData,
|
'type' => $eventTypeData,
|
||||||
'join_token' => $joinToken?->token,
|
'join_token' => $joinToken?->token,
|
||||||
'photobooth_enabled' => (bool) $event->photobooth_enabled,
|
'photobooth_enabled' => (bool) $event->photobooth_enabled,
|
||||||
'branding' => [
|
'branding' => $branding,
|
||||||
'primary_color' => $branding['primary_color'],
|
|
||||||
'secondary_color' => $branding['secondary_color'],
|
|
||||||
'background_color' => $branding['background_color'],
|
|
||||||
'font_family' => $fontFamily,
|
|
||||||
'logo_url' => $this->toPublicUrl($logoUrl),
|
|
||||||
],
|
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
app/Http/Controllers/Api/Tenant/FontController.php
Normal file
115
app/Http/Controllers/Api/Tenant/FontController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests\Tenant;
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class SettingsStoreRequest extends FormRequest
|
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.primary_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||||
'settings.branding.secondary_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.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' => ['sometimes', 'array'],
|
||||||
'settings.features.photo_likes_enabled' => ['nullable', 'boolean'],
|
'settings.features.photo_likes_enabled' => ['nullable', 'boolean'],
|
||||||
'settings.features.event_checklist' => ['nullable', 'boolean'],
|
'settings.features.event_checklist' => ['nullable', 'boolean'],
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ class WatermarkConfigResolver
|
|||||||
{
|
{
|
||||||
public static function determineBrandingAllowed(Event $event): bool
|
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
|
public static function determinePolicy(Event $event): string
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\App\Console\Commands\PurgeExpiredDataExports::class,
|
\App\Console\Commands\PurgeExpiredDataExports::class,
|
||||||
\App\Console\Commands\ProcessTenantRetention::class,
|
\App\Console\Commands\ProcessTenantRetention::class,
|
||||||
\App\Console\Commands\SendGuestFeedbackReminders::class,
|
\App\Console\Commands\SendGuestFeedbackReminders::class,
|
||||||
|
\App\Console\Commands\SyncGoogleFonts::class,
|
||||||
\App\Console\Commands\SeedDemoSwitcherTenants::class,
|
\App\Console\Commands\SeedDemoSwitcherTenants::class,
|
||||||
])
|
])
|
||||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ return [
|
|||||||
'token' => env('POSTMARK_TOKEN'),
|
'token' => env('POSTMARK_TOKEN'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'google_fonts' => [
|
||||||
|
'key' => env('GOOGLE_FONTS_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
'pexels' => [
|
'pexels' => [
|
||||||
'key' => env('PEXELS_API_KEY'),
|
'key' => env('PEXELS_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
|
@import '/fonts/google/fonts.css';
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
|
||||||
@@ -115,7 +117,12 @@
|
|||||||
--guest-primary: #f43f5e;
|
--guest-primary: #f43f5e;
|
||||||
--guest-secondary: #fb7185;
|
--guest-secondary: #fb7185;
|
||||||
--guest-background: #ffffff;
|
--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-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-heading-font: 'Playfair Display', serif;
|
||||||
--guest-serif-font: 'Lora', serif;
|
--guest-serif-font: 'Lora', serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,19 @@ export type EventAddonCatalogItem = {
|
|||||||
increments?: Record<string, number>;
|
increments?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
export type EventAddonSummary = {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -1266,6 +1279,18 @@ export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
|
|||||||
return data.data ?? [];
|
return data.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTenantFonts(): Promise<TenantFont[]> {
|
||||||
|
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<TenantEventType[]> {
|
export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load 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<string, unknown>;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTenantSettings(): Promise<TenantSettingsPayload> {
|
||||||
|
const response = await authorizedFetch('/api/v1/tenant/settings');
|
||||||
|
const data = await jsonOrThrow<{ data?: { id?: number; settings?: Record<string, unknown>; updated_at?: string | null } }>(
|
||||||
|
response,
|
||||||
|
'Failed to load tenant settings',
|
||||||
|
);
|
||||||
|
const payload = (data.data ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(payload.id ?? 0),
|
||||||
|
settings: (payload.settings ?? {}) as Record<string, unknown>,
|
||||||
|
updated_at: (payload.updated_at ?? null) as string | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{
|
||||||
packages: TenantPackageSummary[];
|
packages: TenantPackageSummary[];
|
||||||
activePackage: TenantPackageSummary | null;
|
activePackage: TenantPackageSummary | null;
|
||||||
@@ -2236,6 +2282,7 @@ const CacheKeys = {
|
|||||||
dashboard: 'tenant:dashboard',
|
dashboard: 'tenant:dashboard',
|
||||||
events: 'tenant:events',
|
events: 'tenant:events',
|
||||||
packages: 'tenant:packages',
|
packages: 'tenant:packages',
|
||||||
|
fonts: 'tenant:fonts',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function cachedFetch<T>(
|
function cachedFetch<T>(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
ADMIN_EVENT_VIEW_PATH,
|
||||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||||
|
ADMIN_EVENT_BRANDING_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
|
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
|
||||||
@@ -36,6 +37,7 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
|
|||||||
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
{ 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: '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: '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) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`);
|
||||||
|
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/branding`);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null
|
|||||||
if (settings && typeof settings === 'object') {
|
if (settings && typeof settings === 'object') {
|
||||||
const brand = (settings as Record<string, unknown>).branding;
|
const brand = (settings as Record<string, unknown>).branding;
|
||||||
if (brand && typeof brand === 'object') {
|
if (brand && typeof brand === 'object') {
|
||||||
const colorPalette = (brand as Record<string, unknown>).colors;
|
const palette = (brand as Record<string, unknown>).palette as Record<string, unknown> | undefined;
|
||||||
|
const colorPalette = palette ?? (brand as Record<string, unknown>).colors;
|
||||||
if (colorPalette && typeof colorPalette === 'object') {
|
if (colorPalette && typeof colorPalette === 'object') {
|
||||||
const paletteRecord = colorPalette as Record<string, unknown>;
|
const paletteRecord = colorPalette as Record<string, unknown>;
|
||||||
for (const key of Object.keys(paletteRecord)) {
|
for (const key of Object.keys(paletteRecord)) {
|
||||||
@@ -22,7 +23,22 @@ export function extractBrandingPalette(settings: TenantEvent['settings'] | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fontValue = (brand as Record<string, unknown>).font_family;
|
|
||||||
|
const directColors = [
|
||||||
|
(brand as Record<string, unknown>).primary_color,
|
||||||
|
(brand as Record<string, unknown>).secondary_color,
|
||||||
|
(brand as Record<string, unknown>).background_color,
|
||||||
|
];
|
||||||
|
directColors.forEach((value) => {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
colors.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const typography = (brand as Record<string, unknown>).typography as Record<string, unknown> | undefined;
|
||||||
|
const fontValue = (brand as Record<string, unknown>).font_family
|
||||||
|
?? (typography?.body as string | undefined)
|
||||||
|
?? (typography?.heading as string | undefined);
|
||||||
if (typeof fontValue === 'string' && fontValue.trim()) {
|
if (typeof fontValue === 'string' && fontValue.trim()) {
|
||||||
font = fontValue.trim();
|
font = fontValue.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
ADMIN_EVENT_VIEW_PATH,
|
||||||
ADMIN_EVENT_RECAP_PATH,
|
ADMIN_EVENT_RECAP_PATH,
|
||||||
|
ADMIN_EVENT_BRANDING_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
export type EventTabCounts = Partial<{
|
export type EventTabCounts = Partial<{
|
||||||
@@ -52,6 +53,11 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
|
|||||||
href: ADMIN_EVENT_INVITES_PATH(event.slug),
|
href: ADMIN_EVENT_INVITES_PATH(event.slug),
|
||||||
badge: formatBadge(counts.invites ?? event.active_invites_count ?? event.total_invites_count ?? null),
|
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',
|
key: 'photobooth',
|
||||||
label: translate('eventMenu.photobooth', 'Photobooth'),
|
label: translate('eventMenu.photobooth', 'Photobooth'),
|
||||||
|
|||||||
77
resources/js/admin/lib/fonts.ts
Normal file
77
resources/js/admin/lib/fonts.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { getTenantFonts, type TenantFont, type TenantFontVariant } from '../api';
|
||||||
|
|
||||||
|
const fontLoaders = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
745
resources/js/admin/pages/EventBrandingPage.tsx
Normal file
745
resources/js/admin/pages/EventBrandingPage.tsx
Normal file
@@ -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<string, unknown> | 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<string, unknown> | undefined) ?? {};
|
||||||
|
const typographySource = (source?.typography as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const logoSource = (source?.logo as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const buttonSource = (source?.buttons as Record<string, unknown> | 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<BrandingForm>(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<string, unknown> | undefined)?.branding as BrandingSource, DEFAULT_BRANDING_FORM),
|
||||||
|
[tenantSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: loadedEvent,
|
||||||
|
isLoading: eventLoading,
|
||||||
|
} = useQuery<TenantEvent>({
|
||||||
|
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<string, unknown> | 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 (
|
||||||
|
<AdminLayout title={title} subtitle={subtitle} tabs={[]} currentTabKey="branding">
|
||||||
|
<SectionCard>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{t('branding.errors.missingSlug', 'Kein Event ausgewählt – bitte über die Eventliste öffnen.')}
|
||||||
|
</p>
|
||||||
|
</SectionCard>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AdminLayout
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
tabs={eventTabs}
|
||||||
|
currentTabKey="branding"
|
||||||
|
actions={(
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t('branding.actions.back', 'Zurück zum Event')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionCard className="space-y-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('branding.sections.mode', 'Standard vs. Event-spezifisch')}
|
||||||
|
title={t('branding.sections.toggleTitle', 'Branding-Quelle wählen')}
|
||||||
|
description={t('branding.sections.toggleDescription', 'Nutze das Standard-Branding oder überschreibe es nur für dieses Event.')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-slate-200 bg-white/80 p-4 dark:border-white/10 dark:bg-slate-900/40">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
|
{form.useDefault
|
||||||
|
? t('branding.useDefault', 'Standard nutzen')
|
||||||
|
: t('branding.useCustom', 'Event-spezifisch')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{t('branding.toggleHint', 'Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-300">{t('branding.standard', 'Standard')}</span>
|
||||||
|
<Switch
|
||||||
|
checked={!form.useDefault}
|
||||||
|
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, useDefault: !checked ? true : false }))}
|
||||||
|
aria-label={t('branding.toggleAria', 'Event-spezifisches Branding aktivieren')}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-300">{t('branding.custom', 'Event')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<SectionCard className="space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('branding.sections.palette', 'Palette & Modus')}
|
||||||
|
title={t('branding.sections.colorsTitle', 'Farben & Light/Dark')}
|
||||||
|
description={t('branding.sections.colorsDescription', 'Primär-, Sekundär-, Hintergrund- und Surface-Farbe festlegen.')}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{(['primary', 'secondary', 'background', 'surface'] as const).map((key) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={`color-${key}`}>{key === 'primary' ? 'Primary' : key === 'secondary' ? 'Secondary' : key === 'background' ? 'Background' : 'Surface'}</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id={`color-${key}`}
|
||||||
|
type="color"
|
||||||
|
value={form.palette[key]}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
className="h-10 w-16 p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.palette[key]}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, palette: { ...prev.palette, [key]: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.mode', 'Modus')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.mode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setForm((prev) => ({ ...prev, mode: value as BrandingForm['mode'] }));
|
||||||
|
setPreviewTheme(value === 'dark' ? 'dark' : 'light');
|
||||||
|
}}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="auto" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">{t('branding.modeAuto', 'Auto')}</SelectItem>
|
||||||
|
<SelectItem value="light">{t('branding.modeLight', 'Hell')}</SelectItem>
|
||||||
|
<SelectItem value="dark">{t('branding.modeDark', 'Dunkel')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard className="space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('branding.sections.typography', 'Typografie & Logo')}
|
||||||
|
title={t('branding.sections.fonts', 'Schriften & Logo/Emoticon')}
|
||||||
|
description={t('branding.sections.fontDescription', 'Heading- und Body-Font sowie Logo/Emoji und Ausrichtung festlegen.')}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.headingFont', 'Heading Font')}</Label>
|
||||||
|
<Select
|
||||||
|
value={resolveFontSelectValue(form.typography.heading)}
|
||||||
|
onValueChange={(value) => handleFontSelect('heading', value)}
|
||||||
|
disabled={form.useDefault || fontsLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('branding.fontDefault', 'Standard (Tenant)')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||||
|
{availableFonts.map((font) => (
|
||||||
|
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__custom">{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={form.typography.heading}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder="z. B. Playfair Display"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.bodyFont', 'Body Font')}</Label>
|
||||||
|
<Select
|
||||||
|
value={resolveFontSelectValue(form.typography.body)}
|
||||||
|
onValueChange={(value) => handleFontSelect('body', value)}
|
||||||
|
disabled={form.useDefault || fontsLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('branding.fontDefault', 'Standard (Tenant)')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t('branding.fontDefault', 'Standard (Tenant)')}</SelectItem>
|
||||||
|
{availableFonts.map((font) => (
|
||||||
|
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__custom">{t('branding.fontCustom', 'Eigene Schrift eingeben')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={form.typography.body}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder="z. B. Inter, sans-serif"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.size', 'Schriftgröße')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.typography.size}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, size: value as BrandingForm['typography']['size'] } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="s">S</SelectItem>
|
||||||
|
<SelectItem value="m">M</SelectItem>
|
||||||
|
<SelectItem value="l">L</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.logoValue', 'Emoticon/Logo-URL')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.logo.value}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, value: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder="✨ oder https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.logoMode', 'Logo-Modus')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.logo.mode}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: value as BrandingForm['logo']['mode'] } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="emoticon">{t('branding.emoticon', 'Emoticon/Text')}</SelectItem>
|
||||||
|
<SelectItem value="upload">{t('branding.upload', 'Upload/URL')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.logoPosition', 'Position')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.logo.position}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, logo: { ...prev.logo, position: value as BrandingForm['logo']['position'] } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">{t('branding.left', 'Links')}</SelectItem>
|
||||||
|
<SelectItem value="center">{t('branding.center', 'Zentriert')}</SelectItem>
|
||||||
|
<SelectItem value="right">{t('branding.right', 'Rechts')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionCard className="space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('branding.sections.buttons', 'Buttons & Links')}
|
||||||
|
title={t('branding.sections.buttonsTitle', 'Buttons, Links & Radius')}
|
||||||
|
description={t('branding.sections.buttonsDescription', 'Stil, Radius und optionale Link-Farbe festlegen.')}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.buttonStyle', 'Stil')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.buttons.style}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, style: value as BrandingForm['buttons']['style'] } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="filled">{t('branding.filled', 'Filled')}</SelectItem>
|
||||||
|
<SelectItem value="outline">{t('branding.outline', 'Outline')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.radius', 'Radius')}</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
value={form.buttons.radius}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, radius: Number(e.target.value) } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<span className="w-10 text-right text-sm text-slate-600 dark:text-slate-200">{form.buttons.radius}px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.linkColor', 'Link-Farbe')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.buttons.linkColor}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, linkColor: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder="#fb7185"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.buttonPrimary', 'Button Primary')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.buttons.primary}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, primary: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder={form.palette.primary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('branding.buttonSecondary', 'Button Secondary')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.buttons.secondary}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, buttons: { ...prev.buttons, secondary: e.target.value } }))}
|
||||||
|
disabled={form.useDefault}
|
||||||
|
placeholder={form.palette.secondary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard className="space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('branding.sections.preview', 'Preview')}
|
||||||
|
title={t('branding.sections.previewTitle', 'Mini-Gastansicht')}
|
||||||
|
description={t('branding.sections.previewCopy', 'Header, CTA und Bottom-Navigation nach Branding visualisiert.')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-200">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span>{form.useDefault ? t('branding.usingDefault', 'Standard-Branding aktiv') : t('branding.usingCustom', 'Event-Branding aktiv')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant={previewTheme === 'light' ? 'secondary' : 'ghost'}
|
||||||
|
onClick={() => setPreviewTheme('light')}
|
||||||
|
aria-label="Light Preview"
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant={previewTheme === 'dark' ? 'secondary' : 'ghost'}
|
||||||
|
onClick={() => setPreviewTheme('dark')}
|
||||||
|
aria-label="Dark Preview"
|
||||||
|
>
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BrandingPreview branding={previewBranding} theme={previewTheme} />
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-40 mt-6 bg-gradient-to-t from-white via-white to-white/70 py-3 backdrop-blur dark:from-slate-900 dark:via-slate-900 dark:to-slate-900/60">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-2 sm:px-0">
|
||||||
|
<div className="text-sm text-slate-600 dark:text-slate-200">
|
||||||
|
{form.useDefault
|
||||||
|
? t('branding.footer.default', 'Standard-Farben des Tenants aktiv.')
|
||||||
|
: t('branding.footer.custom', 'Event-spezifisches Branding aktiv.')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true })}
|
||||||
|
>
|
||||||
|
{t('branding.reset', 'Auf Standard zurücksetzen')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => mutation.mutate(form)} disabled={mutation.isPending || eventLoading}>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t('branding.saving', 'Speichern...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('branding.save', 'Branding speichern')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className={cn('overflow-hidden', theme === 'dark' ? 'bg-slate-900 text-white' : 'bg-white text-slate-900')}>
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<div className="px-4 py-3" style={headerStyle}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-xl">
|
||||||
|
{branding.logo.value || '✨'}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<CardTitle className="text-base font-semibold" style={{ fontFamily: branding.typography.heading || undefined }}>
|
||||||
|
Demo Event
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-xs opacity-80">Gastansicht · {branding.mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 bg-[var(--surface)] px-4 py-5" style={{ ['--surface' as string]: branding.palette.surface }}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-200" style={{ fontFamily: branding.typography.body || undefined }}>
|
||||||
|
CTA & Buttons spiegeln den gewählten Stil wider.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="shadow-md transition"
|
||||||
|
style={{
|
||||||
|
...buttonStyle,
|
||||||
|
borderRadius: branding.buttons.radius,
|
||||||
|
paddingInline: '18px',
|
||||||
|
paddingBlock: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Jetzt Fotos hochladen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-dashed border-slate-200 p-3 text-sm dark:border-slate-700" style={{ borderRadius: branding.buttons.radius }}>
|
||||||
|
<span style={{ color: branding.buttons.linkColor || branding.palette.secondary }}>Bottom Navigation</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.primary }} />
|
||||||
|
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.secondary }} />
|
||||||
|
<div className="h-1.5 w-10 rounded-full" style={{ background: branding.palette.surface }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||||
ADMIN_EVENT_PHOTOS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
|
ADMIN_EVENT_BRANDING_PATH,
|
||||||
buildEngagementTabPath,
|
buildEngagementTabPath,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import {
|
import {
|
||||||
@@ -491,7 +492,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
event={event}
|
event={event}
|
||||||
invites={toolkitData?.invites}
|
invites={toolkitData?.invites}
|
||||||
emotions={emotions}
|
emotions={emotions}
|
||||||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
onOpenBranding={() => navigate(ADMIN_EVENT_BRANDING_PATH(event.slug))}
|
||||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
TenantEmotion,
|
TenantEmotion,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
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 { extractBrandingPalette } from '../lib/branding';
|
||||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||||
import { buildEventTabs } from '../lib/eventTabs';
|
import { buildEventTabs } from '../lib/eventTabs';
|
||||||
@@ -506,7 +506,7 @@ export default function EventTasksPage() {
|
|||||||
collections={collections}
|
collections={collections}
|
||||||
onOpenBranding={() => {
|
onOpenBranding={() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`);
|
navigate(ADMIN_EVENT_BRANDING_PATH(slug));
|
||||||
}}
|
}}
|
||||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
ADMIN_EVENT_INVITES_PATH,
|
ADMIN_EVENT_INVITES_PATH,
|
||||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||||
|
ADMIN_EVENT_BRANDING_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
import { useTranslation } from 'react-i18next';
|
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: '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: '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: '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: '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) },
|
{ key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_VIEW_PATH(slug) },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts';
|
||||||
|
|
||||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||||
import { authorizedFetch } from '../../auth/tokens';
|
import { authorizedFetch } from '../../auth/tokens';
|
||||||
@@ -214,6 +215,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
onDraftChange,
|
onDraftChange,
|
||||||
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
|
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
|
||||||
|
|
||||||
const inviteUrl = invite?.url ?? '';
|
const inviteUrl = invite?.url ?? '';
|
||||||
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
const qrCodeDataUrl = invite?.qr_code_data_url ?? null;
|
||||||
@@ -269,6 +271,40 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
const appliedLayoutRef = React.useRef<string | null>(null);
|
const appliedLayoutRef = React.useRef<string | null>(null);
|
||||||
const appliedInviteRef = React.useRef<number | string | null>(null);
|
const appliedInviteRef = React.useRef<number | string | null>(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(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
setIsCompact(false);
|
setIsCompact(false);
|
||||||
@@ -1269,7 +1305,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
|
|
||||||
if (element.type !== 'qr') {
|
if (element.type !== 'qr') {
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<div className="grid gap-4 sm:grid-cols-2" key={`${element.id}-appearance`}>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3" key={`${element.id}-appearance`}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('invites.customizer.elements.align', 'Ausrichtung')}</Label>
|
<Label>{t('invites.customizer.elements.align', 'Ausrichtung')}</Label>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
@@ -1301,6 +1337,29 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('invites.customizer.elements.fontFamily', 'Schriftart')}</Label>
|
||||||
|
<Select
|
||||||
|
value={availableFonts.some((font) => font.family === element.fontFamily) ? element.fontFamily ?? '' : ''}
|
||||||
|
onValueChange={(value) => handleElementFontChange(element.id, value)}
|
||||||
|
disabled={fontsLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('invites.customizer.elements.fontPlaceholder', 'Standard')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t('invites.customizer.elements.fontPlaceholder', 'Standard')}</SelectItem>
|
||||||
|
{availableFonts.map((font) => (
|
||||||
|
<SelectItem key={font.family} value={font.family}>{font.family}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={element.fontFamily ?? ''}
|
||||||
|
onChange={(event) => handleElementFontChange(element.id, event.target.value)}
|
||||||
|
placeholder="z. B. Playfair Display"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1322,7 +1381,7 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[elementBindings, form, t, updateElement, updateElementAlign, updateElementContent, updateForm]
|
[availableFonts, elementBindings, form, handleElementFontChange, t, updateElement, updateElementAlign, updateElementContent, updateForm]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderActionButtons = (mode: 'inline' | 'floating') => (
|
const renderActionButtons = (mode: 'inline' | 'floating') => (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
|
|||||||
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||||
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
||||||
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
||||||
|
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
|
||||||
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
|
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
|
||||||
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
||||||
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
||||||
@@ -105,6 +106,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||||
|
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> },
|
||||||
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
||||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||||
{ path: 'engagement', element: <EngagementPage /> },
|
{ path: 'engagement', element: <EngagementPage /> },
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ function TabLink({
|
|||||||
children,
|
children,
|
||||||
isActive,
|
isActive,
|
||||||
accentColor,
|
accentColor,
|
||||||
|
radius,
|
||||||
|
style,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
|
radius: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const activeStyle = isActive
|
const activeStyle = isActive
|
||||||
@@ -23,8 +27,10 @@ function TabLink({
|
|||||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
|
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
boxShadow: `0 12px 30px ${accentColor}33`,
|
boxShadow: `0 12px 30px ${accentColor}33`,
|
||||||
|
borderRadius: radius,
|
||||||
|
...style,
|
||||||
}
|
}
|
||||||
: undefined;
|
: { borderRadius: radius, ...style };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -47,6 +53,10 @@ export default function BottomNav() {
|
|||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { branding } = useEventBranding();
|
const { branding } = useEventBranding();
|
||||||
|
const radius = branding.buttons?.radius ?? 12;
|
||||||
|
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||||
|
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||||
|
const surface = branding.palette?.surface ?? branding.backgroundColor;
|
||||||
|
|
||||||
const isReady = status === 'ready' && !!event;
|
const isReady = status === 'ready' && !!event;
|
||||||
|
|
||||||
@@ -79,13 +89,27 @@ export default function BottomNav() {
|
|||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-lg items-center gap-3">
|
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||||
<div className="flex flex-1 justify-evenly gap-2">
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor} compact={compact}>
|
<TabLink
|
||||||
|
to={`${base}`}
|
||||||
|
isActive={isHomeActive}
|
||||||
|
accentColor={branding.primaryColor}
|
||||||
|
radius={radius}
|
||||||
|
compact={compact}
|
||||||
|
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Home className="h-5 w-5" aria-hidden />
|
<Home className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.home}</span>
|
<span>{labels.home}</span>
|
||||||
</div>
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor} compact={compact}>
|
<TabLink
|
||||||
|
to={`${base}/tasks`}
|
||||||
|
isActive={isTasksActive}
|
||||||
|
accentColor={branding.primaryColor}
|
||||||
|
radius={radius}
|
||||||
|
compact={compact}
|
||||||
|
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.tasks}</span>
|
<span>{labels.tasks}</span>
|
||||||
@@ -102,6 +126,7 @@ export default function BottomNav() {
|
|||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
||||||
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
|
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
|
||||||
|
borderRadius: radius,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Camera className="h-6 w-6" aria-hidden />
|
<Camera className="h-6 w-6" aria-hidden />
|
||||||
@@ -112,6 +137,8 @@ export default function BottomNav() {
|
|||||||
to={`${base}/achievements`}
|
to={`${base}/achievements`}
|
||||||
isActive={isAchievementsActive}
|
isActive={isAchievementsActive}
|
||||||
accentColor={branding.primaryColor}
|
accentColor={branding.primaryColor}
|
||||||
|
radius={radius}
|
||||||
|
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
@@ -123,6 +150,8 @@ export default function BottomNav() {
|
|||||||
to={`${base}/gallery`}
|
to={`${base}/gallery`}
|
||||||
isActive={isGalleryActive}
|
isActive={isGalleryActive}
|
||||||
accentColor={branding.primaryColor}
|
accentColor={branding.primaryColor}
|
||||||
|
radius={radius}
|
||||||
|
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ export default function FiltersBar({
|
|||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
showPhotobooth = true,
|
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 { t } = useTranslation();
|
||||||
const filters: FilterConfig = React.useMemo(
|
const filters: FilterConfig = React.useMemo(
|
||||||
() => (showPhotobooth
|
() => (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',
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={styleOverride}
|
||||||
>
|
>
|
||||||
{filters.map((filter) => (
|
{filters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/Eve
|
|||||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||||
|
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||||
|
|
||||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
heart: Heart,
|
heart: Heart,
|
||||||
@@ -66,7 +67,26 @@ function getInitials(name: string): string {
|
|||||||
return name.substring(0, 2).toUpperCase();
|
return name.substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) {
|
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string, logo?: { mode: 'emoticon' | 'upload'; value: string | null }) {
|
||||||
|
if (logo?.mode === 'upload' && logo.value) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
|
||||||
|
<img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
|
||||||
|
style={{ backgroundColor: accentColor, color: textColor }}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{logo.value}</span>
|
||||||
|
<span className="sr-only">{name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof icon === 'string') {
|
if (typeof icon === 'string') {
|
||||||
const trimmed = icon.trim();
|
const trimmed = icon.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
@@ -113,7 +133,17 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const brandingContext = useOptionalEventBranding();
|
const brandingContext = useOptionalEventBranding();
|
||||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||||
const primaryForeground = '#ffffff';
|
const headerTextColor = React.useMemo(() => {
|
||||||
|
const primaryLum = relativeLuminance(branding.primaryColor);
|
||||||
|
const secondaryLum = relativeLuminance(branding.secondaryColor);
|
||||||
|
const avgLum = (primaryLum + secondaryLum) / 2;
|
||||||
|
|
||||||
|
if (avgLum > 0.55) {
|
||||||
|
return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#ffffff';
|
||||||
|
}, [branding.primaryColor, branding.secondaryColor]);
|
||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const notificationCenter = useOptionalNotificationCenter();
|
const notificationCenter = useOptionalNotificationCenter();
|
||||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||||
@@ -169,7 +199,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
|
|
||||||
const headerStyle: React.CSSProperties = {
|
const headerStyle: React.CSSProperties = {
|
||||||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||||
color: primaryForeground,
|
color: headerTextColor,
|
||||||
fontFamily: branding.fontFamily ?? undefined,
|
fontFamily: branding.fontFamily ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,7 +229,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
style={headerStyle}
|
style={headerStyle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)}
|
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
|
||||||
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
||||||
<div className="font-semibold text-base">{event.name}</div>
|
<div className="font-semibold text-base">{event.name}</div>
|
||||||
{guestName && (
|
{guestName && (
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
|
palette: {
|
||||||
|
primary: '#f43f5e',
|
||||||
|
secondary: '#fb7185',
|
||||||
|
background: '#ffffff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
heading: null,
|
||||||
|
body: null,
|
||||||
|
sizePreset: 'm',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
mode: 'emoticon',
|
||||||
|
value: null,
|
||||||
|
position: 'left',
|
||||||
|
size: 'm',
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
style: 'filled',
|
||||||
|
radius: 12,
|
||||||
|
},
|
||||||
|
mode: 'auto',
|
||||||
};
|
};
|
||||||
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
||||||
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
|
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
|
||||||
@@ -33,12 +55,50 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
|
|||||||
return DEFAULT_EVENT_BRANDING;
|
return DEFAULT_EVENT_BRANDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const palettePrimary = input.palette?.primary ?? input.primaryColor;
|
||||||
|
const paletteSecondary = input.palette?.secondary ?? input.secondaryColor;
|
||||||
|
const paletteBackground = input.palette?.background ?? input.backgroundColor;
|
||||||
|
const paletteSurface = input.palette?.surface ?? input.backgroundColor;
|
||||||
|
|
||||||
|
const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
|
||||||
|
const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
|
||||||
|
const sizePreset = input.typography?.sizePreset ?? 'm';
|
||||||
|
|
||||||
|
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
|
||||||
|
const logoValue = input.logo?.value ?? input.logoUrl ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primaryColor: normaliseHexColor(input.primaryColor, DEFAULT_EVENT_BRANDING.primaryColor),
|
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||||
secondaryColor: normaliseHexColor(input.secondaryColor, DEFAULT_EVENT_BRANDING.secondaryColor),
|
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||||
backgroundColor: normaliseHexColor(input.backgroundColor, DEFAULT_EVENT_BRANDING.backgroundColor),
|
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||||
fontFamily: input.fontFamily?.trim() || null,
|
fontFamily: bodyFont?.trim() || null,
|
||||||
logoUrl: input.logoUrl?.trim() || null,
|
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
|
||||||
|
palette: {
|
||||||
|
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||||
|
secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||||
|
background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||||
|
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
heading: headingFont?.trim() || null,
|
||||||
|
body: bodyFont?.trim() || null,
|
||||||
|
sizePreset,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
mode: logoMode,
|
||||||
|
value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null),
|
||||||
|
position: input.logo?.position ?? 'left',
|
||||||
|
size: input.logo?.size ?? 'm',
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
style: input.buttons?.style ?? 'filled',
|
||||||
|
radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12,
|
||||||
|
primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||||
|
secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||||
|
linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||||
|
},
|
||||||
|
mode: input.mode ?? 'auto',
|
||||||
|
useDefaultBranding: input.useDefaultBranding ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +111,26 @@ function applyCssVariables(branding: EventBranding) {
|
|||||||
root.style.setProperty('--guest-primary', branding.primaryColor);
|
root.style.setProperty('--guest-primary', branding.primaryColor);
|
||||||
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
||||||
root.style.setProperty('--guest-background', branding.backgroundColor);
|
root.style.setProperty('--guest-background', branding.backgroundColor);
|
||||||
|
root.style.setProperty('--guest-surface', branding.palette?.surface ?? branding.backgroundColor);
|
||||||
|
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||||
|
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||||
|
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
|
||||||
|
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
|
||||||
|
|
||||||
if (branding.fontFamily) {
|
const headingFont = branding.typography?.heading ?? branding.fontFamily;
|
||||||
root.style.setProperty('--guest-font-family', branding.fontFamily);
|
const bodyFont = branding.typography?.body ?? branding.fontFamily;
|
||||||
root.style.setProperty('--guest-heading-font', branding.fontFamily);
|
|
||||||
|
if (bodyFont) {
|
||||||
|
root.style.setProperty('--guest-font-family', bodyFont);
|
||||||
|
root.style.setProperty('--guest-body-font', bodyFont);
|
||||||
} else {
|
} else {
|
||||||
root.style.removeProperty('--guest-font-family');
|
root.style.removeProperty('--guest-font-family');
|
||||||
|
root.style.removeProperty('--guest-body-font');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headingFont) {
|
||||||
|
root.style.setProperty('--guest-heading-font', headingFont);
|
||||||
|
} else {
|
||||||
root.style.removeProperty('--guest-heading-font');
|
root.style.removeProperty('--guest-heading-font');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,10 +144,66 @@ function resetCssVariables() {
|
|||||||
root.style.removeProperty('--guest-primary');
|
root.style.removeProperty('--guest-primary');
|
||||||
root.style.removeProperty('--guest-secondary');
|
root.style.removeProperty('--guest-secondary');
|
||||||
root.style.removeProperty('--guest-background');
|
root.style.removeProperty('--guest-background');
|
||||||
|
root.style.removeProperty('--guest-surface');
|
||||||
|
root.style.removeProperty('--guest-button-radius');
|
||||||
|
root.style.removeProperty('--guest-radius');
|
||||||
|
root.style.removeProperty('--guest-link');
|
||||||
|
root.style.removeProperty('--guest-button-style');
|
||||||
root.style.removeProperty('--guest-font-family');
|
root.style.removeProperty('--guest-font-family');
|
||||||
|
root.style.removeProperty('--guest-body-font');
|
||||||
root.style.removeProperty('--guest-heading-font');
|
root.style.removeProperty('--guest-heading-font');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyThemeMode(mode: EventBranding['mode']) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const prefersDark = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: false;
|
||||||
|
|
||||||
|
let storedTheme: 'light' | 'dark' | 'system' | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('theme');
|
||||||
|
storedTheme = raw === 'light' || raw === 'dark' || raw === 'system' ? raw : null;
|
||||||
|
} catch {
|
||||||
|
storedTheme = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDark = () => root.classList.add('dark');
|
||||||
|
const applyLight = () => root.classList.remove('dark');
|
||||||
|
|
||||||
|
if (mode === 'dark') {
|
||||||
|
applyDark();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'light') {
|
||||||
|
applyLight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedTheme === 'dark') {
|
||||||
|
applyDark();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedTheme === 'light') {
|
||||||
|
applyLight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersDark) {
|
||||||
|
applyDark();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLight();
|
||||||
|
}
|
||||||
|
|
||||||
export function EventBrandingProvider({
|
export function EventBrandingProvider({
|
||||||
branding,
|
branding,
|
||||||
children,
|
children,
|
||||||
@@ -85,10 +215,20 @@ export function EventBrandingProvider({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyCssVariables(resolved);
|
applyCssVariables(resolved);
|
||||||
|
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||||
|
applyThemeMode(resolved.mode ?? 'auto');
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (previousDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
resetCssVariables();
|
resetCssVariables();
|
||||||
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
||||||
|
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto');
|
||||||
};
|
};
|
||||||
}, [resolved]);
|
}, [resolved]);
|
||||||
|
|
||||||
@@ -98,6 +238,7 @@ export function EventBrandingProvider({
|
|||||||
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|
||||||
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|
||||||
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
|
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
|
||||||
|
// legacy surface check omitted by intent
|
||||||
}), [resolved]);
|
}), [resolved]);
|
||||||
|
|
||||||
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
|
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
|
||||||
|
|||||||
50
resources/js/guest/lib/color.ts
Normal file
50
resources/js/guest/lib/color.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
|
const normalized = hex.trim();
|
||||||
|
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let r: number;
|
||||||
|
let g: number;
|
||||||
|
let b: number;
|
||||||
|
|
||||||
|
if (normalized.length === 4) {
|
||||||
|
r = parseInt(normalized[1] + normalized[1], 16);
|
||||||
|
g = parseInt(normalized[2] + normalized[2], 16);
|
||||||
|
b = parseInt(normalized[3] + normalized[3], 16);
|
||||||
|
} else {
|
||||||
|
r = parseInt(normalized.slice(1, 3), 16);
|
||||||
|
g = parseInt(normalized.slice(3, 5), 16);
|
||||||
|
b = parseInt(normalized.slice(5, 7), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeLuminance(hex: string): number {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
if (!rgb) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (channel: number) => {
|
||||||
|
const c = channel / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = normalize(rgb.r);
|
||||||
|
const g = normalize(rgb.g);
|
||||||
|
const b = normalize(rgb.b);
|
||||||
|
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContrastingTextColor(
|
||||||
|
backgroundHex: string,
|
||||||
|
lightColor = '#ffffff',
|
||||||
|
darkColor = '#0f172a',
|
||||||
|
): string {
|
||||||
|
const luminance = relativeLuminance(backgroundHex);
|
||||||
|
|
||||||
|
return luminance > 0.5 ? darkColor : lightColor;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { useToast } from '../components/ToastHost';
|
|||||||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||||||
import { createPhotoShareLink } from '../services/photosApi';
|
import { createPhotoShareLink } from '../services/photosApi';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
|
|
||||||
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||||
type GalleryPhoto = {
|
type GalleryPhoto = {
|
||||||
@@ -285,36 +286,41 @@ export default function GalleryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="">
|
<Page title="">
|
||||||
|
<div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
|
||||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPage.title')}</h1>
|
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{newCount > 0 ? (
|
{newCount > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={acknowledgeNew}
|
onClick={acknowledgeNew}
|
||||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
|
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
>
|
>
|
||||||
{newPhotosBadgeText}
|
{newPhotosBadgeText}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`}>
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
|
||||||
{newPhotosBadgeText}
|
{newPhotosBadgeText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FiltersBar value={filter} onChange={setFilter} className="mt-2" showPhotobooth={showPhotoboothFilter} />
|
<FiltersBar
|
||||||
{loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>}
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
className="mt-2"
|
||||||
|
showPhotobooth={showPhotoboothFilter}
|
||||||
|
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||||
|
/>
|
||||||
|
{loading && <p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>{t('galleryPage.loading', 'Lade…')}</p>}
|
||||||
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{list.map((p: GalleryPhoto) => {
|
{list.map((p: GalleryPhoto) => {
|
||||||
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||||
@@ -344,7 +350,8 @@ export default function GalleryPage() {
|
|||||||
openPhoto();
|
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 }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
@@ -356,16 +363,16 @@ export default function GalleryPage() {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
|
||||||
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
|
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white">{localizedTaskTitle}</p>}
|
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white" style={headingFont ? { fontFamily: headingFont } : undefined}>{localizedTaskTitle}</p>}
|
||||||
<div className="flex items-center justify-between text-xs text-white/90">
|
<div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
<span className="truncate">{createdLabel}</span>
|
<span className="truncate">{createdLabel}</span>
|
||||||
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-3 top-3 z-10 flex flex-col items-start gap-2">
|
<div className="absolute left-3 top-3 z-10 flex flex-col items-start gap-2">
|
||||||
{localizedTaskTitle && (
|
{localizedTaskTitle && (
|
||||||
<span className="rounded-full bg-white/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white shadow">
|
<span className="rounded-full bg-white/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white shadow" style={{ borderRadius: radius }}>
|
||||||
{localizedTaskTitle}
|
{localizedTaskTitle}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -378,11 +385,17 @@ export default function GalleryPage() {
|
|||||||
onShare(p);
|
onShare(p);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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'
|
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
|
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
|
||||||
disabled={shareTargetId === p.id}
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Share2 className="h-4 w-4" aria-hidden />
|
<Share2 className="h-4 w-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
@@ -393,10 +406,16 @@ export default function GalleryPage() {
|
|||||||
onLike(p.id);
|
onLike(p.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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'
|
liked.has(p.id) ? 'text-pink-300' : 'text-white'
|
||||||
)}
|
)}
|
||||||
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
|
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,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||||
{likeCount}
|
{likeCount}
|
||||||
@@ -491,3 +510,9 @@ export default function GalleryPage() {
|
|||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default function HomePage() {
|
|||||||
const { completedCount } = useGuestTaskProgress(token ?? '');
|
const { completedCount } = useGuestTaskProgress(token ?? '');
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const { branding } = useEventBranding();
|
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 heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
|
||||||
const [heroVisible, setHeroVisible] = React.useState(() => {
|
const [heroVisible, setHeroVisible] = React.useState(() => {
|
||||||
@@ -133,7 +136,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-32">
|
<div className="space-y-6 pb-32" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
{heroVisible && (
|
{heroVisible && (
|
||||||
<HeroCard
|
<HeroCard
|
||||||
name={displayName}
|
name={displayName}
|
||||||
@@ -147,7 +150,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
|
<section className="space-y-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
|
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
|
||||||
@@ -163,7 +166,7 @@ export default function HomePage() {
|
|||||||
onShuffle={shuffleMissionPreview}
|
onShuffle={shuffleMissionPreview}
|
||||||
/>
|
/>
|
||||||
<EmotionActionCard />
|
<EmotionActionCard />
|
||||||
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} />
|
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -328,15 +331,23 @@ function UploadActionCard({
|
|||||||
token,
|
token,
|
||||||
accentColor,
|
accentColor,
|
||||||
secondaryAccent,
|
secondaryAccent,
|
||||||
|
radius,
|
||||||
|
bodyFont,
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
token: string;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
secondaryAccent: string;
|
secondaryAccent: string;
|
||||||
|
radius: number;
|
||||||
|
bodyFont?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="overflow-hidden border-0 text-white shadow-sm"
|
className="overflow-hidden border-0 text-white shadow-sm"
|
||||||
style={{ background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})` }}
|
style={{
|
||||||
|
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
|
||||||
|
borderRadius: `${radius}px`,
|
||||||
|
fontFamily: bodyFont,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className="flex flex-col gap-3 py-5">
|
<CardContent className="flex flex-col gap-3 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -348,7 +359,11 @@ function UploadActionCard({
|
|||||||
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild className="bg-white/90 text-slate-900 hover:bg-white">
|
<Button
|
||||||
|
asChild
|
||||||
|
className="bg-white/90 text-slate-900 hover:bg-white"
|
||||||
|
style={{ borderRadius: `${radius}px` }}
|
||||||
|
>
|
||||||
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type Ga
|
|||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
||||||
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
|
||||||
|
import { getContrastingTextColor } from '../lib/color';
|
||||||
|
|
||||||
interface GalleryState {
|
interface GalleryState {
|
||||||
meta: GalleryMetaResponse | null;
|
meta: GalleryMetaResponse | null;
|
||||||
@@ -90,6 +91,29 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
loadInitial();
|
loadInitial();
|
||||||
}, [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 () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!token || !state.cursor || state.loadingMore) {
|
if (!token || !state.cursor || state.loadingMore) {
|
||||||
return;
|
return;
|
||||||
@@ -140,10 +164,17 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
return {} as React.CSSProperties;
|
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 {
|
return {
|
||||||
'--gallery-primary': state.meta.branding.primary_color,
|
'--gallery-primary': primary,
|
||||||
'--gallery-secondary': state.meta.branding.secondary_color,
|
'--gallery-secondary': secondary,
|
||||||
'--gallery-background': state.meta.branding.background_color,
|
'--gallery-background': background,
|
||||||
|
'--gallery-surface': surface,
|
||||||
} as React.CSSProperties & Record<string, string>;
|
} as React.CSSProperties & Record<string, string>;
|
||||||
}, [state.meta]);
|
}, [state.meta]);
|
||||||
|
|
||||||
@@ -151,9 +182,13 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
if (!state.meta) {
|
if (!state.meta) {
|
||||||
return {};
|
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 {
|
return {
|
||||||
background: state.meta.branding.primary_color,
|
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
|
||||||
color: '#ffffff',
|
color: textColor,
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [state.meta]);
|
||||||
|
|
||||||
@@ -162,7 +197,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
color: state.meta.branding.primary_color,
|
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [state.meta]);
|
||||||
|
|
||||||
@@ -171,7 +206,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
backgroundColor: state.meta.branding.background_color,
|
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
|
||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
}, [state.meta]);
|
}, [state.meta]);
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export default function TaskPickerPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { branding } = useEventBranding();
|
const { branding } = useEventBranding();
|
||||||
const { t, locale } = useTranslation();
|
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);
|
const { isCompleted } = useGuestTaskProgress(eventKey);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
|||||||
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
||||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||||
import { useEventStats } from '../context/EventStatsContext';
|
import { useEventStats } from '../context/EventStatsContext';
|
||||||
|
import { useEventBranding } from '../context/EventBrandingContext';
|
||||||
import { compressPhoto, formatBytes } from '../lib/image';
|
import { compressPhoto, formatBytes } from '../lib/image';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -113,6 +114,11 @@ export default function UploadPage() {
|
|||||||
const { markCompleted } = useGuestTaskProgress(token);
|
const { markCompleted } = useGuestTaskProgress(token);
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const stats = useEventStats();
|
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 taskIdParam = searchParams.get('task');
|
||||||
const emotionSlug = searchParams.get('emotion') || '';
|
const emotionSlug = searchParams.get('emotion') || '';
|
||||||
@@ -936,7 +942,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
const canRetryCamera = permissionState !== 'unsupported';
|
const canRetryCamera = permissionState !== 'unsupported';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white">
|
<div
|
||||||
|
className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||||
|
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
|
||||||
<Camera className="h-6 w-6" />
|
<Camera className="h-6 w-6" />
|
||||||
@@ -948,11 +957,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
{canRetryCamera && (
|
{canRetryCamera && (
|
||||||
<Button onClick={startCamera} size="sm">
|
<Button
|
||||||
|
onClick={startCamera}
|
||||||
|
size="sm"
|
||||||
|
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
|
||||||
|
>
|
||||||
{t('upload.buttons.startCamera')}
|
{t('upload.buttons.startCamera')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
|
||||||
|
>
|
||||||
{t('upload.galleryButton')}
|
{t('upload.galleryButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -962,9 +980,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
|
|
||||||
return renderWithDialog(
|
return renderWithDialog(
|
||||||
<>
|
<>
|
||||||
<div className="relative pt-8">
|
<div className="relative pt-8" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||||
{taskFloatingCard}
|
{taskFloatingCard}
|
||||||
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
|
<section
|
||||||
|
className="relative overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
|
>
|
||||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -1028,7 +1049,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
|
<div
|
||||||
|
className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4"
|
||||||
|
style={{ fontFamily: bodyFont }}
|
||||||
|
>
|
||||||
{uploadWarning && (
|
{uploadWarning && (
|
||||||
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
|
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
|
||||||
<AlertDescription className="text-xs">
|
<AlertDescription className="text-xs">
|
||||||
|
|||||||
@@ -173,12 +173,59 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const palette = raw.palette ?? {};
|
||||||
|
const typography = raw.typography ?? {};
|
||||||
|
const buttons = raw.buttons ?? {};
|
||||||
|
const logo = raw.logo ?? {};
|
||||||
|
const primary = palette.primary ?? raw.primary_color ?? '';
|
||||||
|
const secondary = palette.secondary ?? raw.secondary_color ?? '';
|
||||||
|
const background = palette.background ?? raw.background_color ?? '';
|
||||||
|
const surface = palette.surface ?? raw.surface_color ?? background;
|
||||||
|
const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null;
|
||||||
|
const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null;
|
||||||
|
const sizePreset = (typography.size as 's' | 'm' | 'l' | undefined) ?? (raw.font_size as 's' | 'm' | 'l' | undefined) ?? 'm';
|
||||||
|
const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon');
|
||||||
|
const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null;
|
||||||
|
const logoPosition = logo.position ?? raw.logo_position ?? 'left';
|
||||||
|
const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm';
|
||||||
|
const buttonStyle = (buttons.style as 'filled' | 'outline' | undefined) ?? (raw.button_style as 'filled' | 'outline' | undefined) ?? 'filled';
|
||||||
|
const buttonRadius = typeof buttons.radius === 'number' ? buttons.radius : (typeof raw.button_radius === 'number' ? raw.button_radius : 12);
|
||||||
|
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
|
||||||
|
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
|
||||||
|
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primaryColor: raw.primary_color ?? '',
|
primaryColor: primary ?? '',
|
||||||
secondaryColor: raw.secondary_color ?? '',
|
secondaryColor: secondary ?? '',
|
||||||
backgroundColor: raw.background_color ?? '',
|
backgroundColor: background ?? '',
|
||||||
fontFamily: raw.font_family ?? null,
|
fontFamily: bodyFont,
|
||||||
logoUrl: raw.logo_url ?? null,
|
logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null,
|
||||||
|
palette: {
|
||||||
|
primary: primary ?? '',
|
||||||
|
secondary: secondary ?? '',
|
||||||
|
background: background ?? '',
|
||||||
|
surface: surface ?? background ?? '',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
heading: headingFont,
|
||||||
|
body: bodyFont,
|
||||||
|
sizePreset,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
mode: logoMode,
|
||||||
|
value: logoValue,
|
||||||
|
position: logoPosition,
|
||||||
|
size: logoSize,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
style: buttonStyle,
|
||||||
|
radius: buttonRadius,
|
||||||
|
primary: buttonPrimary,
|
||||||
|
secondary: buttonSecondary,
|
||||||
|
linkColor,
|
||||||
|
},
|
||||||
|
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
|
||||||
|
useDefaultBranding: raw.use_default_branding ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,46 @@ export interface EventBrandingPayload {
|
|||||||
background_color?: string | null;
|
background_color?: string | null;
|
||||||
font_family?: string | null;
|
font_family?: string | null;
|
||||||
logo_url?: string | null;
|
logo_url?: string | null;
|
||||||
|
surface_color?: string | null;
|
||||||
|
heading_font?: string | null;
|
||||||
|
body_font?: string | null;
|
||||||
|
font_size?: 's' | 'm' | 'l' | null;
|
||||||
|
icon?: string | null;
|
||||||
|
logo_mode?: 'emoticon' | 'upload' | null;
|
||||||
|
logo_value?: string | null;
|
||||||
|
logo_position?: 'left' | 'right' | 'center' | null;
|
||||||
|
logo_size?: 's' | 'm' | 'l' | null;
|
||||||
|
button_style?: 'filled' | 'outline' | null;
|
||||||
|
button_radius?: number | null;
|
||||||
|
button_primary_color?: string | null;
|
||||||
|
button_secondary_color?: string | null;
|
||||||
|
link_color?: string | null;
|
||||||
|
mode?: 'light' | 'dark' | 'auto' | null;
|
||||||
|
use_default_branding?: boolean | null;
|
||||||
|
palette?: {
|
||||||
|
primary?: string | null;
|
||||||
|
secondary?: string | null;
|
||||||
|
background?: string | null;
|
||||||
|
surface?: string | null;
|
||||||
|
} | null;
|
||||||
|
typography?: {
|
||||||
|
heading?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
size?: 's' | 'm' | 'l' | null;
|
||||||
|
} | null;
|
||||||
|
logo?: {
|
||||||
|
mode?: 'emoticon' | 'upload';
|
||||||
|
value?: string | null;
|
||||||
|
position?: 'left' | 'right' | 'center';
|
||||||
|
size?: 's' | 'm' | 'l';
|
||||||
|
} | null;
|
||||||
|
buttons?: {
|
||||||
|
style?: 'filled' | 'outline';
|
||||||
|
radius?: number | null;
|
||||||
|
primary?: string | null;
|
||||||
|
secondary?: string | null;
|
||||||
|
link_color?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventData {
|
export interface EventData {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ export interface GalleryBranding {
|
|||||||
primary_color: string;
|
primary_color: string;
|
||||||
secondary_color: string;
|
secondary_color: string;
|
||||||
background_color: string;
|
background_color: string;
|
||||||
|
surface_color?: string;
|
||||||
|
mode?: 'light' | 'dark' | 'auto';
|
||||||
|
palette?: {
|
||||||
|
primary?: string | null;
|
||||||
|
secondary?: string | null;
|
||||||
|
background?: string | null;
|
||||||
|
surface?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryMetaResponse {
|
export interface GalleryMetaResponse {
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
export interface EventBranding {
|
export interface EventBranding {
|
||||||
|
// Legacy/compat fields used across components
|
||||||
primaryColor: string;
|
primaryColor: string;
|
||||||
secondaryColor: string;
|
secondaryColor: string;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
fontFamily: string | null;
|
fontFamily: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
}
|
|
||||||
|
|
||||||
|
// Extended branding shape
|
||||||
|
useDefaultBranding?: boolean;
|
||||||
|
palette?: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
background: string;
|
||||||
|
surface?: string;
|
||||||
|
};
|
||||||
|
typography?: {
|
||||||
|
heading: string | null;
|
||||||
|
body: string | null;
|
||||||
|
sizePreset?: 's' | 'm' | 'l';
|
||||||
|
};
|
||||||
|
logo?: {
|
||||||
|
mode: 'emoticon' | 'upload';
|
||||||
|
value: string | null;
|
||||||
|
position?: 'left' | 'right' | 'center';
|
||||||
|
size?: 's' | 'm' | 'l';
|
||||||
|
};
|
||||||
|
buttons?: {
|
||||||
|
style?: 'filled' | 'outline';
|
||||||
|
radius?: number; // px
|
||||||
|
primary?: string;
|
||||||
|
secondary?: string;
|
||||||
|
linkColor?: string;
|
||||||
|
};
|
||||||
|
mode?: 'light' | 'dark' | 'auto';
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
|||||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\FontController;
|
||||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
||||||
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
||||||
@@ -116,6 +117,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::put('profile', [ProfileController::class, 'update'])
|
Route::put('profile', [ProfileController::class, 'update'])
|
||||||
->middleware('tenant.admin')
|
->middleware('tenant.admin')
|
||||||
->name('tenant.profile.update');
|
->name('tenant.profile.update');
|
||||||
|
Route::get('fonts', [FontController::class, 'index'])->name('tenant.fonts.index');
|
||||||
Route::get('onboarding', [OnboardingController::class, 'show'])
|
Route::get('onboarding', [OnboardingController::class, 'show'])
|
||||||
->middleware('tenant.admin')
|
->middleware('tenant.admin')
|
||||||
->name('tenant.onboarding.show');
|
->name('tenant.onboarding.show');
|
||||||
|
|||||||
134
tests/Feature/Api/Event/EventBrandingResponseTest.php
Normal file
134
tests/Feature/Api/Event/EventBrandingResponseTest.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventPackage;
|
||||||
|
use App\Models\EventType;
|
||||||
|
use App\Models\Package;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EventBrandingResponseTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_returns_extended_branding_shape_with_logo_and_buttons(): void
|
||||||
|
{
|
||||||
|
$package = Package::factory()->create([
|
||||||
|
'branding_allowed' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eventType = EventType::factory()->create([
|
||||||
|
'icon' => 'party',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'status' => 'published',
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
|
'settings' => [
|
||||||
|
'branding' => [
|
||||||
|
'palette' => [
|
||||||
|
'primary' => '#123456',
|
||||||
|
'secondary' => '#654321',
|
||||||
|
'background' => '#f0f0f0',
|
||||||
|
'surface' => '#ffffff',
|
||||||
|
],
|
||||||
|
'typography' => [
|
||||||
|
'heading' => 'Playfair Display',
|
||||||
|
'body' => 'Inter, sans-serif',
|
||||||
|
'size' => 'l',
|
||||||
|
],
|
||||||
|
'logo' => [
|
||||||
|
'mode' => 'upload',
|
||||||
|
'value' => 'branding/test.png',
|
||||||
|
'position' => 'center',
|
||||||
|
'size' => 'l',
|
||||||
|
],
|
||||||
|
'buttons' => [
|
||||||
|
'style' => 'outline',
|
||||||
|
'radius' => 18,
|
||||||
|
'primary' => '#ff0000',
|
||||||
|
'secondary' => '#00ff00',
|
||||||
|
'link_color' => '#111111',
|
||||||
|
],
|
||||||
|
'mode' => 'dark',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
EventPackage::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => 0,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'gallery_expires_at' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'branding-check']);
|
||||||
|
$response = $this->getJson('/api/v1/events/'.$token->plain_token);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('branding.palette.primary', '#123456');
|
||||||
|
$response->assertJsonPath('branding.palette.surface', '#ffffff');
|
||||||
|
$response->assertJsonPath('branding.typography.heading', 'Playfair Display');
|
||||||
|
$response->assertJsonPath('branding.typography.size', 'l');
|
||||||
|
$response->assertJsonPath('branding.logo.mode', 'upload');
|
||||||
|
$this->assertStringContainsString('/storage/', (string) $response->json('branding.logo.value'));
|
||||||
|
$response->assertJsonPath('branding.logo.position', 'center');
|
||||||
|
$response->assertJsonPath('branding.buttons.style', 'outline');
|
||||||
|
$response->assertJsonPath('branding.buttons.radius', 18);
|
||||||
|
$response->assertJsonPath('branding.mode', 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_uses_tenant_branding_when_use_default_flag_is_enabled(): void
|
||||||
|
{
|
||||||
|
$package = Package::factory()->create([
|
||||||
|
'branding_allowed' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'status' => 'published',
|
||||||
|
'settings' => [
|
||||||
|
'branding' => [
|
||||||
|
'use_default_branding' => true,
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#111111',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->tenant->update([
|
||||||
|
'settings' => [
|
||||||
|
'branding' => [
|
||||||
|
'primary_color' => '#abcdef',
|
||||||
|
'secondary_color' => '#fedcba',
|
||||||
|
'background_color' => '#ffffff',
|
||||||
|
'buttons' => [
|
||||||
|
'style' => 'filled',
|
||||||
|
'radius' => 8,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
EventPackage::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => 0,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'gallery_expires_at' => now()->addDays(14),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'branding-default']);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/events/'.$token->plain_token);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('branding.use_default_branding', true);
|
||||||
|
$response->assertJsonPath('branding.primary_color', '#abcdef');
|
||||||
|
$response->assertJsonPath('branding.secondary_color', '#fedcba');
|
||||||
|
$response->assertJsonPath('branding.buttons.radius', 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/Feature/Api/TenantFontsTest.php
Normal file
71
tests/Feature/Api/TenantFontsTest.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TenantFontsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private ?string $manifestBackup = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$manifestPath = public_path('fonts/google/manifest.json');
|
||||||
|
if (File::exists($manifestPath)) {
|
||||||
|
$this->manifestBackup = File::get($manifestPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$manifestPath = public_path('fonts/google/manifest.json');
|
||||||
|
if ($this->manifestBackup !== null) {
|
||||||
|
File::put($manifestPath, $this->manifestBackup);
|
||||||
|
} else {
|
||||||
|
File::delete($manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tenant_can_fetch_font_manifest(): void
|
||||||
|
{
|
||||||
|
$manifestPath = public_path('fonts/google/manifest.json');
|
||||||
|
File::ensureDirectoryExists(dirname($manifestPath));
|
||||||
|
|
||||||
|
File::put($manifestPath, json_encode([
|
||||||
|
'fonts' => [
|
||||||
|
[
|
||||||
|
'family' => 'Manifest Font',
|
||||||
|
'category' => 'sans-serif',
|
||||||
|
'variants' => [
|
||||||
|
['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/google/manifest-font/regular.woff2'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'role' => 'tenant_admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = $user->createToken('test')->plainTextToken;
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/tenant/fonts');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonStructure(['data']);
|
||||||
|
$this->assertTrue(collect($response->json('data'))->pluck('family')->contains('Manifest Font'));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/Feature/Console/SyncGoogleFontsTest.php
Normal file
76
tests/Feature/Console/SyncGoogleFontsTest.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Console;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SyncGoogleFontsTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config()->set('services.google_fonts.key', 'test-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_fonts_and_writes_manifest(): void
|
||||||
|
{
|
||||||
|
$targetPath = storage_path('app/test-fonts');
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'family' => 'Alpha Sans',
|
||||||
|
'category' => 'sans-serif',
|
||||||
|
'variants' => ['regular', '700'],
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
|
||||||
|
'700' => 'https://fonts.gstatic.com/s/alpha-700.woff2',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'family' => 'Beta Serif',
|
||||||
|
'category' => 'serif',
|
||||||
|
'variants' => ['regular'],
|
||||||
|
'files' => [
|
||||||
|
'regular' => 'https://fonts.gstatic.com/s/beta-regular.ttf',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::call('fonts:sync-google', [
|
||||||
|
'--count' => 2,
|
||||||
|
'--weights' => '400,700',
|
||||||
|
'--path' => 'storage/app/test-fonts',
|
||||||
|
'--force' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manifestPath = $targetPath.'/manifest.json';
|
||||||
|
$cssPath = $targetPath.'/fonts.css';
|
||||||
|
|
||||||
|
$this->assertFileExists($manifestPath);
|
||||||
|
$this->assertFileExists($cssPath);
|
||||||
|
|
||||||
|
$manifest = json_decode(File::get($manifestPath), true);
|
||||||
|
$this->assertSame(2, $manifest['count']);
|
||||||
|
$this->assertCount(2, $manifest['fonts']);
|
||||||
|
|
||||||
|
$family = collect($manifest['fonts']);
|
||||||
|
$this->assertTrue($family->pluck('family')->contains('Alpha Sans'));
|
||||||
|
$this->assertTrue($family->pluck('family')->contains('Beta Serif'));
|
||||||
|
|
||||||
|
$this->assertTrue(str_contains(File::get($cssPath), "font-family: 'Alpha Sans';"));
|
||||||
|
$this->assertTrue(str_contains(File::get($cssPath), "font-family: 'Beta Serif';"));
|
||||||
|
|
||||||
|
File::deleteDirectory($targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user