- Served the guest PWA at /g/{token} and introduced a mobile-friendly gallery page with lazy-loaded thumbnails, themed colors, lightbox, and download links plus new gallery data client (resources/js/guest/pages/PublicGalleryPage.tsx:1, resources/js/guest/services/galleryApi.ts:1, resources/js/guest/router.tsx:1). Added i18n strings for the public gallery experience (resources/js/guest/i18n/messages.ts:1).
- Ensured checkout step changes snap back to the progress bar on mobile via smooth scroll anchoring (resources/ js/pages/marketing/checkout/CheckoutWizard.tsx:1).
- Enabled tenant admins to export all approved event photos through a new download action that streams a ZIP archive, with translations and routing in place (app/Http/Controllers/Tenant/EventPhotoArchiveController.php:1, app/Filament/Resources/EventResource.php:1, routes/web.php:1, resources/lang/de/admin.php:1, resources/lang/en/admin.php:1).
223 lines
7.5 KiB
TypeScript
223 lines
7.5 KiB
TypeScript
import React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { createBrowserRouter, Outlet, useParams, Link } 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 type { FetchEventErrorCode } from './services/eventApi';
|
|
import { EventStatsProvider } from './context/EventStatsContext';
|
|
import { GuestIdentityProvider } from './context/GuestIdentityContext';
|
|
import LandingPage from './pages/LandingPage';
|
|
import ProfileSetupPage from './pages/ProfileSetupPage';
|
|
import HomePage from './pages/HomePage';
|
|
import TaskPickerPage from './pages/TaskPickerPage';
|
|
import TaskDetailPage from './pages/TaskDetailPage';
|
|
import UploadPage from './pages/UploadPage';
|
|
import UploadQueuePage from './pages/UploadQueuePage';
|
|
import GalleryPage from './pages/GalleryPage';
|
|
import PhotoLightbox from './pages/PhotoLightbox';
|
|
import AchievementsPage from './pages/AchievementsPage';
|
|
import SlideshowPage from './pages/SlideshowPage';
|
|
import SettingsPage from './pages/SettingsPage';
|
|
import LegalPage from './pages/LegalPage';
|
|
import PublicGalleryPage from './pages/PublicGalleryPage';
|
|
import NotFoundPage from './pages/NotFoundPage';
|
|
import { LocaleProvider } from './i18n/LocaleContext';
|
|
import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages';
|
|
import { useTranslation, type TranslateFn } from './i18n/useTranslation';
|
|
|
|
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: '/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: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> },
|
|
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> },
|
|
{ path: '*', element: <NotFoundPage /> },
|
|
]);
|
|
|
|
function EventBoundary({ token }: { token: string }) {
|
|
const { event, status, error, errorCode } = useEventData();
|
|
|
|
if (status === 'loading') {
|
|
return <EventLoadingView />;
|
|
}
|
|
|
|
if (status === 'error' || !event) {
|
|
return <EventErrorView code={errorCode} message={error} />;
|
|
}
|
|
|
|
const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
|
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
|
|
|
|
return (
|
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
|
<EventStatsProvider eventKey={token}>
|
|
<div className="pb-16">
|
|
<Header slug={token} />
|
|
<div className="px-4 py-3">
|
|
<Outlet />
|
|
</div>
|
|
<BottomNav />
|
|
</div>
|
|
</EventStatsProvider>
|
|
</LocaleProvider>
|
|
);
|
|
}
|
|
|
|
function SetupLayout() {
|
|
const { token } = useParams<{ token: string }>();
|
|
if (!token) return null;
|
|
const { event } = useEventData();
|
|
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
|
|
const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`;
|
|
return (
|
|
<GuestIdentityProvider eventKey={token}>
|
|
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
|
|
<EventStatsProvider eventKey={token}>
|
|
<div className="pb-0">
|
|
<Header slug={token} />
|
|
<Outlet />
|
|
</div>
|
|
</EventStatsProvider>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 'event_not_public':
|
|
return build('event_not_public');
|
|
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 (
|
|
<div className="pb-16">
|
|
<Header title={title} />
|
|
<div className="px-4 py-3">
|
|
{children}
|
|
</div>
|
|
<BottomNav />
|
|
</div>
|
|
);
|
|
}
|
|
|