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() {
))}
-