From 92e64c361a9764fb663dac9c290b7f63f7d6f104 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 4 Nov 2025 16:14:07 +0100 Subject: [PATCH] die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert. --- .../Controllers/Api/EventPublicController.php | 46 ++++-- resources/css/app.css | 7 + resources/js/guest/components/BottomNav.tsx | 50 ++++-- resources/js/guest/components/Header.tsx | 53 +++++-- .../js/guest/context/EventBrandingContext.tsx | 115 ++++++++++++++ resources/js/guest/pages/GalleryPage.tsx | 55 +------ resources/js/guest/pages/HomePage.tsx | 145 ++++++++++++++---- resources/js/guest/pages/UploadPage.tsx | 19 +-- resources/js/guest/router.tsx | 54 +++++-- resources/js/guest/services/eventApi.ts | 11 +- resources/js/guest/types/event-branding.ts | 8 + 11 files changed, 407 insertions(+), 156 deletions(-) create mode 100644 resources/js/guest/context/EventBrandingContext.tsx create mode 100644 resources/js/guest/types/event-branding.ts diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 9d49a58..51b2187 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -200,7 +200,7 @@ class EventPublicController extends BaseController [$eventRecord, $joinToken] = $result; - $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id); + $event = Event::with(['tenant', 'eventPackage.package', 'eventType'])->find($eventRecord->id); if (! $event) { return ApiError::response( @@ -763,29 +763,40 @@ class EventPublicController extends BaseController return $result; } - [$event, $joinToken] = $result; + [$eventRecord, $joinToken] = $result; - $locale = $request->query('locale', $event->default_locale ?? 'de'); - $nameData = json_decode($event->name, true); - $localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name; + $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id); - $eventType = null; - if ($event->event_type_id) { - $eventType = DB::table('event_types') - ->where('id', $event->event_type_id) - ->first(['slug as type_slug', 'name as type_name']); + if (! $event) { + return ApiError::response( + 'event_not_found', + 'Event Not Found', + 'Das Event konnte nicht gefunden werden.', + Response::HTTP_NOT_FOUND, + ['token' => Str::limit($token, 12)] + ); } + $locale = $request->query('locale', $event->default_locale ?? 'de'); + $localizedName = $this->translateLocalized($event->name, $locale, 'Fotospiel Event'); + + $eventType = $event->eventType; $eventTypeData = $eventType ? [ - 'slug' => $eventType->type_slug, - 'name' => $this->getLocalized($eventType->type_name, $locale, 'Event'), - 'icon' => $eventType->type_slug === 'wedding' ? 'heart' : 'guests', + 'slug' => $eventType->slug, + 'name' => $this->translateLocalized($eventType->name, $locale, 'Event'), + 'icon' => $eventType->icon ?? null, ] : [ 'slug' => 'general', 'name' => $this->getLocalized('Event', $locale, 'Event'), - 'icon' => 'guests', + 'icon' => null, ]; + $branding = $this->buildGalleryBranding($event); + $fontFamily = Arr::get($event->settings, 'branding.font_family') + ?? Arr::get($event->tenant?->settings, 'branding.font_family'); + $logoUrl = Arr::get($event->settings, 'branding.logo_url') + ?? Arr::get($event->tenant?->settings, 'branding.logo_url'); + if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); } @@ -799,6 +810,13 @@ class EventPublicController extends BaseController 'updated_at' => $event->updated_at, 'type' => $eventTypeData, 'join_token' => $joinToken?->token, + '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'); } diff --git a/resources/css/app.css b/resources/css/app.css index 3a03199..7d4582b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -111,6 +111,13 @@ font-display: swap; } +:root { + --guest-primary: #f43f5e; + --guest-secondary: #fb7185; + --guest-background: #ffffff; + --guest-font-family: inherit; +} + @font-face { font-family: 'Great Vibes'; src: url('/fonts/GreatVibes-Regular.ttf') format('truetype'); diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 78b2534..e130f2c 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -3,25 +3,36 @@ import { NavLink, useParams, useLocation } from 'react-router-dom'; import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react'; import { useEventData } from '../hooks/useEventData'; import { useTranslation } from '../i18n/useTranslation'; +import { useEventBranding } from '../context/EventBrandingContext'; function TabLink({ to, children, isActive, + accentColor, }: { to: string; children: React.ReactNode; isActive: boolean; + accentColor: string; }) { + const activeStyle = isActive + ? { + background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`, + color: '#ffffff', + boxShadow: `0 12px 30px ${accentColor}33`, + } + : undefined; + return ( {children} @@ -33,10 +44,12 @@ export default function BottomNav() { const location = useLocation(); const { event, status } = useEventData(); const { t } = useTranslation(); + const { branding } = useEventBranding(); const isReady = status === 'ready' && !!event; - if (!token || !isReady) return null; // Only show bottom nav within event context + if (!token || !isReady) return null; + const base = `/e/${encodeURIComponent(token)}`; const currentPath = location.pathname; @@ -47,33 +60,36 @@ export default function BottomNav() { gallery: t('navigation.gallery'), }; - // Improved active state logic const isHomeActive = currentPath === base || currentPath === `/${token}`; const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`; const isAchievementsActive = currentPath.startsWith(`${base}/achievements`); const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`); return ( -
-
- +
+
+
- {labels.home} + + {labels.home}
- +
- {labels.tasks} + + {labels.tasks}
- +
- {labels.achievements} + + {labels.achievements}
- +
- {labels.gallery} + + {labels.gallery}
diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 6c985b0..cd6f38f 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -6,6 +6,7 @@ import { useOptionalEventStats } from '../context/EventStatsContext'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { SettingsSheet } from './settings-sheet'; import { useTranslation } from '../i18n/useTranslation'; +import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; const EVENT_ICON_COMPONENTS: Record> = { heart: Heart, @@ -36,7 +37,7 @@ function getInitials(name: string): string { return name.substring(0, 2).toUpperCase(); } -function renderEventAvatar(name: string, icon: unknown) { +function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) { if (typeof icon === 'string') { const trimmed = icon.trim(); if (trimmed) { @@ -44,7 +45,10 @@ function renderEventAvatar(name: string, icon: unknown) { const IconComponent = EVENT_ICON_COMPONENTS[normalized]; if (IconComponent) { return ( -
+
); @@ -52,7 +56,10 @@ function renderEventAvatar(name: string, icon: unknown) { if (isLikelyEmoji(trimmed)) { return ( -
+
{trimmed} {name}
@@ -62,7 +69,10 @@ function renderEventAvatar(name: string, icon: unknown) { } return ( -
+
{getInitials(name)}
); @@ -72,11 +82,18 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const statsContext = useOptionalEventStats(); const identity = useOptionalGuestIdentity(); const { t } = useTranslation(); + const brandingContext = useOptionalEventBranding(); + const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING; + const primaryForeground = '#ffffff'; + const { event, status } = useEventData(); if (!eventToken) { const guestName = identity?.name && identity?.hydrated ? identity.name : null; return ( -
+
{title}
{guestName && ( @@ -93,14 +110,21 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string ); } - const { event, status } = useEventData(); const guestName = identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null; + const headerStyle: React.CSSProperties = { + background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, + color: primaryForeground, + fontFamily: branding.fontFamily ?? undefined, + }; + + const accentColor = branding.secondaryColor; + if (status === 'loading') { return ( -
-
{t('header.loading')}
+
+
{t('header.loading')}
@@ -117,17 +141,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; return ( -
+
- {renderEventAvatar(event.name, event.type?.icon)} -
+ {renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)} +
{event.name}
{guestName && ( - + {`${t('common.hi')} ${guestName}`} )} -
+
{stats && ( <> diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx new file mode 100644 index 0000000..38ece29 --- /dev/null +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -0,0 +1,115 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import type { EventBranding } from '../types/event-branding'; + +type EventBrandingContextValue = { + branding: EventBranding; + isCustom: boolean; +}; + +export const DEFAULT_EVENT_BRANDING: EventBranding = { + primaryColor: '#f43f5e', + secondaryColor: '#fb7185', + backgroundColor: '#ffffff', + fontFamily: null, + logoUrl: null, +}; +const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase(); +const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase(); +const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase(); + +const EventBrandingContext = createContext(undefined); + +function normaliseHexColor(value: string | null | undefined, 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 resolveBranding(input?: EventBranding | null): EventBranding { + if (!input) { + return DEFAULT_EVENT_BRANDING; + } + + return { + primaryColor: normaliseHexColor(input.primaryColor, DEFAULT_EVENT_BRANDING.primaryColor), + secondaryColor: normaliseHexColor(input.secondaryColor, DEFAULT_EVENT_BRANDING.secondaryColor), + backgroundColor: normaliseHexColor(input.backgroundColor, DEFAULT_EVENT_BRANDING.backgroundColor), + fontFamily: input.fontFamily?.trim() || null, + logoUrl: input.logoUrl?.trim() || null, + }; +} + +function applyCssVariables(branding: EventBranding) { + if (typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + root.style.setProperty('--guest-primary', branding.primaryColor); + root.style.setProperty('--guest-secondary', branding.secondaryColor); + root.style.setProperty('--guest-background', branding.backgroundColor); + + if (branding.fontFamily) { + root.style.setProperty('--guest-font-family', branding.fontFamily); + } else { + root.style.removeProperty('--guest-font-family'); + } +} + +function resetCssVariables() { + if (typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + root.style.removeProperty('--guest-primary'); + root.style.removeProperty('--guest-secondary'); + root.style.removeProperty('--guest-background'); + root.style.removeProperty('--guest-font-family'); +} + +export function EventBrandingProvider({ + branding, + children, +}: { + branding?: EventBranding | null; + children: React.ReactNode; +}) { + const resolved = useMemo(() => resolveBranding(branding), [branding]); + + useEffect(() => { + applyCssVariables(resolved); + + return () => { + resetCssVariables(); + applyCssVariables(DEFAULT_EVENT_BRANDING); + }; + }, [resolved]); + + const value = useMemo(() => ({ + branding: resolved, + isCustom: + resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY + || resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY + || resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND, + }), [resolved]); + + return {children}; +} + +export function useEventBranding(): EventBrandingContextValue { + const context = useContext(EventBrandingContext); + + if (!context) { + throw new Error('useEventBranding must be used within an EventBrandingProvider'); + } + + return context; +} + +export function useOptionalEventBranding(): EventBrandingContextValue | undefined { + return useContext(EventBrandingContext); +} diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 32ebb05..e397858 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -92,37 +92,16 @@ export default function GalleryPage() { const galleryLimits = eventPackage?.limits?.gallery ?? null; const galleryCountdown = React.useMemo(() => { - if (!galleryLimits) { + if (!galleryLimits || galleryLimits.state !== 'expired') { return null; } - if (galleryLimits.state === 'expired') { - return { - tone: 'danger' as const, - label: t('galleryCountdown.expired'), - description: t('galleryCountdown.expiredDescription'), - cta: null, - }; - } - - if (galleryLimits.state === 'warning') { - const days = Math.max(0, galleryLimits.days_remaining ?? 0); - const label = days <= 1 - ? t('galleryCountdown.expiresToday') - : t('galleryCountdown.expiresIn').replace('{days}', `${days}`); - - return { - tone: days <= 1 ? ('danger' as const) : ('warning' as const), - label, - description: t('galleryCountdown.description'), - cta: { - type: 'upload' as const, - label: t('galleryCountdown.ctaUpload'), - }, - }; - } - - return null; + return { + tone: 'danger' as const, + label: t('galleryCountdown.expired'), + description: t('galleryCountdown.expiredDescription'), + cta: null, + }; }, [galleryLimits, t]); const handleCountdownCta = React.useCallback(() => { @@ -146,18 +125,6 @@ export default function GalleryPage() { .replace('{used}', `${photoLimits.used}`) .replace('{max}', `${photoLimits.limit}`), }); - } else if ( - photoLimits?.state === 'warning' - && typeof photoLimits.remaining === 'number' - && typeof photoLimits.limit === 'number' - ) { - warnings.push({ - id: 'photos-warning', - tone: 'warning', - message: t('upload.limitWarning') - .replace('{remaining}', `${photoLimits.remaining}`) - .replace('{max}', `${photoLimits.limit}`), - }); } if (galleryLimits?.state === 'expired') { @@ -166,14 +133,6 @@ export default function GalleryPage() { tone: 'danger', message: t('upload.errors.galleryExpired'), }); - } else if (galleryLimits?.state === 'warning') { - const days = Math.max(0, galleryLimits.days_remaining ?? 0); - const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays'; - warnings.push({ - id: 'gallery-warning', - tone: 'warning', - message: t(key).replace('{days}', `${days}`), - }); } return warnings; diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 51915d7..06da8c1 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -9,8 +9,10 @@ import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useEventStats } from '../context/EventStatsContext'; import { useEventData } from '../hooks/useEventData'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react'; +import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; +import { useEventBranding } from '../context/EventBrandingContext'; +import type { EventBranding } from '../types/event-branding'; export default function HomePage() { const { token } = useParams<{ token: string }>(); @@ -19,12 +21,51 @@ export default function HomePage() { const { event } = useEventData(); const { completedCount } = useGuestTaskProgress(token); const { t } = useTranslation(); + const { branding } = useEventBranding(); - if (!token) return null; + const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed'; + const [heroVisible, setHeroVisible] = React.useState(() => { + if (typeof window === 'undefined') { + return true; + } + + try { + return window.sessionStorage.getItem(heroStorageKey) !== '1'; + } catch { + return true; + } + }); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + try { + setHeroVisible(window.sessionStorage.getItem(heroStorageKey) !== '1'); + } catch { + setHeroVisible(true); + } + }, [heroStorageKey]); + + const dismissHero = React.useCallback(() => { + setHeroVisible(false); + if (typeof window === 'undefined') { + return; + } + + try { + window.sessionStorage.setItem(heroStorageKey, '1'); + } catch { + // ignore storage exceptions (e.g. private mode) + } + }, [heroStorageKey]); const displayName = hydrated && name ? name : t('home.fallbackGuestName'); const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName'); const latestUploadText = formatLatestUpload(stats.latestPhotoAt, t); + const accentColor = branding.primaryColor; + const secondaryAccent = branding.secondaryColor; const primaryActions = React.useMemo( () => [ @@ -32,19 +73,19 @@ export default function HomePage() { to: 'tasks', label: t('home.actions.items.tasks.label'), description: t('home.actions.items.tasks.description'), - icon: , + icon: , }, { to: 'upload', label: t('home.actions.items.upload.label'), description: t('home.actions.items.upload.description'), - icon: , + icon: , }, { to: 'gallery', label: t('home.actions.items.gallery.label'), description: t('home.actions.items.gallery.description'), - icon: , + icon: , }, ], [t], @@ -59,42 +100,54 @@ export default function HomePage() { [t], ); + if (!token) { + return null; + } + return (
- + {heroVisible && ( + + )} - + } + icon={} label={t('home.stats.online')} value={`${stats.onlineGuests}`} + accentColor={accentColor} /> } + icon={} label={t('home.stats.tasksSolved')} value={`${stats.tasksSolved}`} + accentColor={accentColor} /> } + icon={} label={t('home.stats.lastUpload')} value={latestUploadText} + accentColor={accentColor} /> } + icon={} label={t('home.stats.completedTasks')} value={`${completedCount}`} + accentColor={accentColor} />
-
+

{t('home.actions.title')}

@@ -102,14 +155,20 @@ export default function HomePage() {
{primaryActions.map((action) => ( - + -
+
{action.icon}
- {action.label} + {action.label} {action.description}
@@ -117,20 +176,25 @@ export default function HomePage() { ))}
-
- + {t('home.checklist.title')} {t('home.checklist.description')} {checklistItems.map((item) => (
- + {item}
))} @@ -151,11 +215,15 @@ function HeroCard({ eventName, tasksCompleted, t, + branding, + onDismiss, }: { name: string; eventName: string; tasksCompleted: number; t: TranslateFn; + branding: EventBranding; + onDismiss: () => void; }) { const heroTitle = t('home.hero.title').replace('{name}', name); const heroDescription = t('home.hero.description').replace('{eventName}', eventName); @@ -163,22 +231,41 @@ function HeroCard({ ? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`) : t('home.hero.progress.none'); + const style = React.useMemo(() => ({ + background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, + color: '#ffffff', + fontFamily: branding.fontFamily ?? undefined, + }), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]); + return ( - - + + + {t('home.hero.subtitle')} - {heroTitle} -

{heroDescription}

+ {heroTitle} +

{heroDescription}

{progressMessage}

); } -function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { +function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) { return (
-
+
{icon}
diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index d507d20..2cbe983 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -283,7 +283,6 @@ const [canUpload, setCanUpload] = useState(true); let canUploadCurrent = pkg.limits?.can_upload_photos ?? true; let errorMessage: string | null = null; - const warnings: string[] = []; if (photoLimits?.state === 'limit_reached') { canUploadCurrent = false; @@ -294,32 +293,16 @@ const [canUpload, setCanUpload] = useState(true); } else { errorMessage = t('upload.errors.photoLimit'); } - } else if ( - photoLimits?.state === 'warning' - && typeof photoLimits.remaining === 'number' - && typeof photoLimits.limit === 'number' - ) { - warnings.push( - t('upload.limitWarning') - .replace('{remaining}', `${photoLimits.remaining}`) - .replace('{max}', `${photoLimits.limit}`) - ); } if (galleryLimits?.state === 'expired') { canUploadCurrent = false; errorMessage = t('upload.errors.galleryExpired'); - } else if (galleryLimits?.state === 'warning') { - const daysLeft = Math.max(0, galleryLimits.days_remaining ?? 0); - const key = daysLeft === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays'; - warnings.push( - t(key).replace('{days}', `${daysLeft}`) - ); } setCanUpload(canUploadCurrent); setUploadError(errorMessage); - setUploadWarning(errorMessage ? null : (warnings.length > 0 ? warnings.join(' ยท ') : null)); + setUploadWarning(null); } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index e5240e8..cafd0bd 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -5,9 +5,9 @@ import Header from './components/Header'; import BottomNav from './components/BottomNav'; import { useEventData } from './hooks/useEventData'; import { AlertTriangle, Loader2 } from 'lucide-react'; -import type { FetchEventErrorCode } from './services/eventApi'; import { EventStatsProvider } from './context/EventStatsContext'; import { GuestIdentityProvider } from './context/GuestIdentityContext'; +import { EventBrandingProvider } from './context/EventBrandingContext'; import LandingPage from './pages/LandingPage'; import ProfileSetupPage from './pages/ProfileSetupPage'; import HomePage from './pages/HomePage'; @@ -26,6 +26,8 @@ import NotFoundPage from './pages/NotFoundPage'; import { LocaleProvider } from './i18n/LocaleContext'; import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages'; import { useTranslation, type TranslateFn } from './i18n/useTranslation'; +import type { EventBranding } from './types/event-branding'; +import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi'; function HomeLayout() { const { token } = useParams(); @@ -92,37 +94,43 @@ function EventBoundary({ token }: { token: string }) { const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; const localeStorageKey = `guestLocale_event_${event.id ?? token}`; + const branding = mapEventBranding(event.branding); return ( - -
-
-
- + + +
+
+
+ +
+
- -
- + + ); } function SetupLayout() { const { token } = useParams<{ token: string }>(); - if (!token) return null; const { event } = useEventData(); + if (!token) return null; const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`; + const branding = event ? mapEventBranding(event.branding) : null; return ( - -
-
- -
-
+ + +
+
+ +
+
+
); @@ -141,6 +149,20 @@ function EventLoadingView() { ); } +function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null { + if (!raw) { + return null; + } + + return { + primaryColor: raw.primary_color ?? '', + secondaryColor: raw.secondary_color ?? '', + backgroundColor: raw.background_color ?? '', + fontFamily: raw.font_family ?? null, + logoUrl: raw.logo_url ?? null, + }; +} + interface EventErrorViewProps { code: FetchEventErrorCode | null; message: string | null; diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index de894eb..e6ff18e 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -1,5 +1,13 @@ import { getDeviceId } from '../lib/device'; +export interface EventBrandingPayload { + primary_color?: string | null; + secondary_color?: string | null; + background_color?: string | null; + font_family?: string | null; + logo_url?: string | null; +} + export interface EventData { id: number; slug: string; @@ -11,8 +19,9 @@ export interface EventData { type?: { slug: string; name: string; - icon: string; + icon: string | null; }; + branding?: EventBrandingPayload | null; } export interface PackageData { diff --git a/resources/js/guest/types/event-branding.ts b/resources/js/guest/types/event-branding.ts new file mode 100644 index 0000000..50e65c1 --- /dev/null +++ b/resources/js/guest/types/event-branding.ts @@ -0,0 +1,8 @@ +export interface EventBranding { + primaryColor: string; + secondaryColor: string; + backgroundColor: string; + fontFamily: string | null; + logoUrl: string | null; +} +