Files
fotospiel-app/resources/js/guest/router.tsx
Codex Agent 9bde8f3f32 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.
2025-11-25 19:31:52 +01:00

334 lines
12 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { createBrowserRouter, Outlet, useParams, Link, Navigate } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import { useEventData } from './hooks/useEventData';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { EventStatsProvider } from './context/EventStatsContext';
import { GuestIdentityProvider, useOptionalGuestIdentity } from './context/GuestIdentityContext';
import { EventBrandingProvider } from './context/EventBrandingContext';
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';
import { NotificationCenterProvider } from './context/NotificationCenterContext';
const LandingPage = React.lazy(() => import('./pages/LandingPage'));
const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage'));
const HomePage = React.lazy(() => import('./pages/HomePage'));
const TaskPickerPage = React.lazy(() => import('./pages/TaskPickerPage'));
const TaskDetailPage = React.lazy(() => import('./pages/TaskDetailPage'));
const UploadPage = React.lazy(() => import('./pages/UploadPage'));
const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage'));
const GalleryPage = React.lazy(() => import('./pages/GalleryPage'));
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
const SharedPhotoPage = React.lazy(() => import('./pages/SharedPhotoPage'));
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
function HomeLayout() {
const { token } = useParams();
if (!token) {
return (
<div className="pb-16">
<Header title="Event" />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
);
}
return (
<GuestIdentityProvider eventKey={token}>
<EventBoundary token={token} />
</GuestIdentityProvider>
);
}
export const router = createBrowserRouter([
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
{ path: '/share/:slug', element: <SharedPhotoPage /> },
{
path: '/setup/:token',
element: <SetupLayout />,
children: [
{ index: true, element: <ProfileSetupPage /> },
],
},
{ path: '/g/:token', element: <PublicGalleryPage /> },
{
path: '/e/:token',
element: <HomeLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'tasks', element: <TaskPickerPage /> },
{ path: 'tasks/:taskId', element: <TaskDetailPage /> },
{ path: 'upload', element: <UploadPage /> },
{ path: 'queue', element: <UploadQueuePage /> },
{ path: 'gallery', element: <GalleryPage /> },
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
{ path: 'achievements', element: <AchievementsPage /> },
{ path: 'slideshow', element: <SlideshowPage /> },
{ path: 'help', element: <HelpCenterPage /> },
{ path: 'help/:slug', element: <HelpArticlePage /> },
],
},
{ path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> },
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> },
{ path: '/help', element: <HelpStandalone /> },
{ path: '/help/:slug', element: <HelpArticleStandalone /> },
{ path: '*', element: <NotFoundPage /> },
]);
function EventBoundary({ token }: { token: string }) {
const identity = useOptionalGuestIdentity();
const { event, status, error, errorCode } = useEventData();
if (status === 'loading') {
return <EventLoadingView />;
}
if (status === 'error' || !event) {
return <EventErrorView code={errorCode} message={error} />;
}
if (identity?.hydrated && !identity.name) {
return <Navigate to={`/setup/${encodeURIComponent(token)}`} replace />;
}
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
const branding = mapEventBranding(event.branding);
return (
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventBrandingProvider branding={branding}>
<EventStatsProvider eventKey={token}>
<NotificationCenterProvider eventToken={token}>
<div className="pb-16">
<Header eventToken={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</NotificationCenterProvider>
</EventStatsProvider>
</EventBrandingProvider>
</LocaleProvider>
);
}
function SetupLayout() {
const { token } = useParams<{ token: string }>();
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 (
<GuestIdentityProvider eventKey={token}>
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventBrandingProvider branding={branding}>
<EventStatsProvider eventKey={token}>
<NotificationCenterProvider eventToken={token}>
<div className="pb-0">
<Header eventToken={token} />
<Outlet />
</div>
</NotificationCenterProvider>
</EventStatsProvider>
</EventBrandingProvider>
</LocaleProvider>
</GuestIdentityProvider>
);
}
function EventLoadingView() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
<div className="space-y-1">
<p className="text-lg font-semibold text-foreground">{t('eventAccess.loading.title')}</p>
<p className="text-sm text-muted-foreground">{t('eventAccess.loading.subtitle')}</p>
</div>
</div>
);
}
function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
if (!raw) {
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 {
primaryColor: primary ?? '',
secondaryColor: secondary ?? '',
backgroundColor: background ?? '',
fontFamily: bodyFont,
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,
};
}
interface EventErrorViewProps {
code: FetchEventErrorCode | null;
message: string | null;
}
function EventErrorView({ code, message }: EventErrorViewProps) {
const { t } = useTranslation();
const content = getErrorContent(t, code, message);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-8 w-8" aria-hidden />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">{content.title}</h2>
<p className="text-sm text-muted-foreground">{content.description}</p>
{content.hint && (
<p className="text-xs text-muted-foreground">{content.hint}</p>
)}
</div>
{content.ctaHref && content.ctaLabel && (
<Button asChild>
<Link to={content.ctaHref}>{content.ctaLabel}</Link>
</Button>
)}
</div>
);
}
function getErrorContent(
t: TranslateFn,
code: FetchEventErrorCode | null,
message: string | null,
) {
const build = (key: string, options?: { ctaHref?: string }) => {
const ctaLabel = t(`eventAccess.error.${key}.ctaLabel`, '');
const hint = t(`eventAccess.error.${key}.hint`, '');
return {
title: t(`eventAccess.error.${key}.title`),
description: message ?? t(`eventAccess.error.${key}.description`),
ctaLabel: ctaLabel.trim().length > 0 ? ctaLabel : undefined,
ctaHref: options?.ctaHref,
hint: hint.trim().length > 0 ? hint : null,
};
};
switch (code) {
case 'invalid_token':
return build('invalid_token', { ctaHref: '/event' });
case 'token_revoked':
return build('token_revoked', { ctaHref: '/event' });
case 'token_expired':
return build('token_expired', { ctaHref: '/event' });
case 'token_rate_limited':
return build('token_rate_limited');
case 'access_rate_limited':
return build('access_rate_limited');
case 'event_not_public':
return build('event_not_public');
case 'gallery_expired':
return build('gallery_expired', { ctaHref: '/event' });
case 'network_error':
return build('network_error');
case 'server_error':
return build('server_error');
default:
return build('default', { ctaHref: '/event' });
}
}
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<EventBrandingProvider>
<div className="pb-16">
<Header title={title} />
<div className="px-4 py-3">
{children}
</div>
<BottomNav />
</div>
</EventBrandingProvider>
);
}
function HelpStandalone() {
const { t } = useTranslation();
return (
<SimpleLayout title={t('help.center.title')}>
<HelpCenterPage />
</SimpleLayout>
);
}
function HelpArticleStandalone() {
const { t } = useTranslation();
return (
<SimpleLayout title={t('help.center.title')}>
<HelpArticlePage />
</SimpleLayout>
);
}