refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -21,8 +21,8 @@ import { useBackNavigation } from './hooks/useBackNavigation';
import { ADMIN_GRADIENTS, useAdminTheme } from './theme';
import { ContextHelpLink } from './components/ContextHelpLink';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
import { getContrastingTextColor, relativeLuminance } from '@/guest/lib/color';
import { DEFAULT_EVENT_BRANDING } from '@/shared/guest/context/EventBrandingContext';
import { getContrastingTextColor, relativeLuminance } from '@/shared/guest/lib/color';
const BRANDING_FORM_DEFAULTS = {
primary: DEFAULT_EVENT_BRANDING.primaryColor,

View File

@@ -22,7 +22,7 @@ import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import i18n from '../i18n';
import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm';
import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext';
import { DEFAULT_EVENT_BRANDING } from '@/shared/guest/context/EventBrandingContext';
type ProfileFormState = {
name: string;

View File

@@ -7,6 +7,7 @@ import { ConsentProvider } from '@/contexts/consent';
import { AppearanceProvider } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
import ToastHost from './components/ToastHost';
import PwaManager from './components/PwaManager';
export default function App() {
return (
@@ -27,6 +28,7 @@ function AppThemeRouter() {
return (
<Theme name={themeName}>
<RouterProvider router={router} />
<PwaManager />
<ToastHost />
</Theme>
);

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
@@ -23,7 +24,7 @@ vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/components/PullToRefresh', () => ({
vi.mock('@/shared/guest/components/PullToRefresh', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
@@ -35,7 +36,7 @@ vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, options?: unknown, fallback?: string) => {
if (typeof fallback === 'string') return fallback;
@@ -45,12 +46,12 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('../lib/guestTheme', () => ({
useGuestThemeVariant: () => ({ isDark: false }),
useGuestThemeVariant: vi.fn(() => ({ isDark: false })),
}));
vi.mock('../lib/bento', () => ({
@@ -70,7 +71,7 @@ vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@/guest/lib/localizeTaskLabel', () => ({
vi.mock('@/shared/guest/lib/localizeTaskLabel', () => ({
localizeTaskLabel: (value: string | null) => value,
}));
@@ -116,6 +117,7 @@ vi.mock('../services/achievementsApi', () => ({
}));
import AchievementsScreen from '../screens/AchievementsScreen';
import { useGuestThemeVariant } from '../lib/guestTheme';
describe('AchievementsScreen', () => {
it('renders personal achievements content', async () => {
@@ -123,6 +125,16 @@ describe('AchievementsScreen', () => {
expect(await screen.findByText('Badges')).toBeInTheDocument();
expect(screen.getByText('First upload')).toBeInTheDocument();
expect(screen.getByText('Upload photo')).toBeInTheDocument();
expect(screen.getByText('Upload one photo')).toBeInTheDocument();
});
it('uses dark-mode feed row and placeholder styles when dark theme is active', async () => {
vi.mocked(useGuestThemeVariant).mockReturnValue({ isDark: true });
const { container } = render(<AchievementsScreen />);
await userEvent.click(await screen.findByRole('button', { name: 'Feed' }));
expect(container.querySelector('[backgroundcolor="rgba(255, 255, 255, 0.08)"]')).toBeTruthy();
expect(container.querySelector('[backgroundcolor="rgba(255, 255, 255, 0.12)"]')).toBeTruthy();
});
});

View File

@@ -11,7 +11,7 @@ vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),

View File

@@ -15,17 +15,17 @@ vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ event: null }),
}));
vi.mock('@/guest/context/EventBrandingContext', () => ({
vi.mock('@/shared/guest/context/EventBrandingContext', () => ({
EventBrandingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
LocaleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DEFAULT_LOCALE: 'de',
isLocaleCode: () => true,
}));
vi.mock('@/guest/context/NotificationCenterContext', () => ({
vi.mock('@/shared/guest/context/NotificationCenterContext', () => ({
NotificationCenterProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import EventLogo from '../components/EventLogo';
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
import type { EventBranding } from '@/guest/types/event-branding';
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
import type { EventBranding } from '@/shared/guest/types/event-branding';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -22,7 +22,7 @@ vi.mock('../hooks/usePollStats', () => ({
usePollStats: () => ({ stats: { onlineGuests: 0, guestCount: 0, likesCount: 0 } }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, options?: unknown, fallback?: string) => {
if (typeof fallback === 'string') return fallback;
@@ -32,7 +32,7 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));

View File

@@ -35,19 +35,19 @@ vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/components/PullToRefresh', () => ({
vi.mock('@/shared/guest/components/PullToRefresh', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/services/helpApi', () => ({
vi.mock('@/shared/guest/services/helpApi', () => ({
getHelpArticles: () => Promise.resolve({
servedFromCache: false,
articles: [{ slug: 'intro', title: 'Intro', summary: 'Summary', updated_at: null }],

View File

@@ -57,7 +57,7 @@ vi.mock('../components/PhotoFrameTile', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/context/EventBrandingContext', () => ({
vi.mock('@/shared/guest/context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: { welcomeMessage: '' },
isCustom: false,
@@ -97,11 +97,11 @@ const translate = (key: string, options?: unknown, fallback?: string) => {
return key;
};
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: translate, locale: 'de' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
@@ -119,7 +119,7 @@ vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ isCompleted: () => false }),
}));

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { LocaleProvider } from '@/shared/guest/i18n/LocaleContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),

View File

@@ -10,7 +10,7 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),

View File

@@ -47,14 +47,14 @@ vi.mock('../services/photosApi', () => ({
createPhotoShareLink: vi.fn().mockResolvedValue({ url: 'http://example.com' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));

View File

@@ -73,7 +73,7 @@ vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
@@ -89,7 +89,7 @@ vi.mock('../hooks/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({ data: { photos: [], latestPhotoAt: null, nextCursor: null }, loading: false, error: null }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
@@ -97,7 +97,7 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
@@ -129,7 +129,7 @@ vi.mock('../services/qrApi', () => ({
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: null }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ completedCount: 0 }),
}));

View File

@@ -8,11 +8,11 @@ vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ appearance: 'dark', updateAppearance }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
@@ -24,7 +24,7 @@ vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ hydrated: false, name: '', setName: vi.fn(), clearName: vi.fn() }),
}));
vi.mock('@/guest/hooks/useHapticsPreference', () => ({
vi.mock('@/shared/guest/hooks/useHapticsPreference', () => ({
useHapticsPreference: () => ({ enabled: false, setEnabled: vi.fn(), supported: true }),
}));

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
@@ -10,11 +10,11 @@ vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'dark' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/components/legal-markdown', () => ({
vi.mock('@/shared/guest/components/legal-markdown', () => ({
LegalMarkdown: () => <div>Legal markdown</div>,
}));

View File

@@ -56,7 +56,7 @@ vi.mock('../lib/toast', () => ({
pushGuestToast: vi.fn(),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, options?: unknown, fallback?: string) => {
if (typeof fallback === 'string') return fallback;

View File

@@ -12,7 +12,7 @@ vi.mock('../services/photosApi', () => ({
fetchGallery: () => Promise.resolve({ data: [] }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));

View File

@@ -37,7 +37,7 @@ vi.mock('../services/tasksApi', () => ({
fetchTasks: () => Promise.resolve([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));

View File

@@ -42,11 +42,11 @@ vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
@@ -54,7 +54,7 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));

View File

@@ -38,7 +38,7 @@ vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
@@ -46,11 +46,11 @@ vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ markCompleted: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? _key) : (arg3 ?? _key),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { resolveGuestThemeName } from '../lib/brandingTheme';
import type { EventBranding } from '@/guest/types/event-branding';
import type { EventBranding } from '@/shared/guest/types/event-branding';
const baseBranding: EventBranding = {
primaryColor: '#FF5A5F',

View File

@@ -12,8 +12,8 @@ import SettingsSheet from './SettingsSheet';
import GuestAnalyticsNudge from './GuestAnalyticsNudge';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useOptionalNotificationCenter } from '@/shared/guest/context/NotificationCenterContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
type AppShellProps = {

View File

@@ -6,7 +6,7 @@ import { Button } from '@tamagui/button';
import { Home, Image, Share2 } from 'lucide-react';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
export default function BottomDock() {

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Camera, Heart, PartyPopper, Users } from 'lucide-react';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
import { getContrastingTextColor } from '@/guest/lib/color';
import type { EventBranding } from '@/guest/types/event-branding';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/shared/guest/context/EventBrandingContext';
import { getContrastingTextColor } from '@/shared/guest/lib/color';
import type { EventBranding } from '@/shared/guest/types/event-branding';
type LogoSize = 's' | 'm' | 'l';

View File

@@ -3,8 +3,8 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '@/shared/guest/lib/analyticsConsent';
import { useGuestThemeVariant } from '../lib/guestTheme';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';

View File

@@ -4,8 +4,8 @@ import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ScrollView } from '@tamagui/scroll-view';
import { X } from 'lucide-react';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useOptionalNotificationCenter } from '@/shared/guest/context/NotificationCenterContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
type NotificationSheetProps = {

View File

@@ -1,23 +1,10 @@
import React from 'react';
import { registerSW } from 'virtual:pwa-register';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from './ToastHost';
import { pushGuestToast } from '../lib/toast';
export default function PwaManager() {
const toast = useToast();
const { t } = useTranslation();
const toastRef = React.useRef(toast);
const tRef = React.useRef(t);
const updatePromptedRef = React.useRef(false);
React.useEffect(() => {
toastRef.current = toast;
}, [toast]);
React.useEffect(() => {
tRef.current = t;
}, [t]);
React.useEffect(() => {
if (!('serviceWorker' in navigator)) {
return;
@@ -30,30 +17,30 @@ export default function PwaManager() {
return;
}
updatePromptedRef.current = true;
toastRef.current.push({
text: tRef.current('common.updateAvailable'),
pushGuestToast({
text: 'Update available',
type: 'info',
durationMs: 0,
action: {
label: tRef.current('common.updateAction'),
label: 'Reload',
onClick: () => updateSW(true),
},
});
},
onOfflineReady() {
toastRef.current.push({
text: tRef.current('common.offlineReady'),
pushGuestToast({
text: 'Offline mode ready',
type: 'success',
});
},
onRegisterError(error) {
console.warn('Guest PWA registration failed', error);
console.warn('Guest v2 PWA registration failed', error);
},
});
const runQueue = () => {
void import('../queue/queue')
.then((m) => m.processQueue().catch(() => {}))
void import('@/shared/guest/queue/queue')
.then((module) => module.processQueue().catch(() => {}))
.catch(() => {});
};

View File

@@ -7,11 +7,11 @@ import { Input } from '@tamagui/input';
import { Card } from '@tamagui/card';
import { Switch } from '@tamagui/switch';
import { Check, Moon, RotateCcw, Sun, Languages, FileText, LifeBuoy } from 'lucide-react';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { useHapticsPreference } from '@/guest/hooks/useHapticsPreference';
import { triggerHaptic } from '@/guest/lib/haptics';
import { useHapticsPreference } from '@/shared/guest/hooks/useHapticsPreference';
import { triggerHaptic } from '@/shared/guest/lib/haptics';
import { useConsent } from '@/contexts/consent';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventData } from '../context/EventDataContext';

View File

@@ -6,10 +6,10 @@ import { Button } from '@tamagui/button';
import { ArrowLeft, X } from 'lucide-react';
import SettingsContent from './SettingsContent';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/guest/components/legal-markdown';
import type { LocaleCode } from '@/guest/i18n/messages';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/shared/guest/components/legal-markdown';
import type { LocaleCode } from '@/shared/guest/i18n/messages';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },

View File

@@ -4,7 +4,7 @@ import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Share2, MessageSquare, Copy, X } from 'lucide-react';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
type ShareSheetProps = {

View File

@@ -4,8 +4,8 @@ import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon } from 'lucide-react';
import PhotoFrameTile from './PhotoFrameTile';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/shared/guest/lib/emotionTheme';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { getBentoSurfaceTokens } from '../lib/bento';

View File

@@ -3,7 +3,7 @@ import { XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Bell, Settings } from 'lucide-react';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/shared/guest/context/EventBrandingContext';
import EventLogo from './EventLogo';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { fetchEvent, type EventData, FetchEventError } from '../services/eventApi';
import { isTaskModeEnabled } from '@/guest/lib/engagement';
import { isTaskModeEnabled } from '@/shared/guest/lib/engagement';
type EventDataStatus = 'idle' | 'loading' | 'ready' | 'error';

View File

@@ -1,4 +1,3 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
@@ -10,7 +9,7 @@ import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategi
import { shouldCacheResponse } from './lib/cachePolicy';
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<any>;
__WB_MANIFEST: Array<unknown>;
};
clientsClaim();
@@ -27,6 +26,12 @@ const isGuestNavigation = (pathname: string) => {
if (pathname.startsWith('/g/')) {
return true;
}
if (pathname.startsWith('/show/')) {
return true;
}
if (pathname.startsWith('/setup/')) {
return true;
}
if (pathname.startsWith('/share/')) {
return true;
}
@@ -52,7 +57,7 @@ registerRoute(
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 7 }),
],
})
}),
);
registerRoute(
@@ -70,7 +75,7 @@ registerRoute(
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 }),
],
})
}),
);
registerRoute(
@@ -81,7 +86,7 @@ registerRoute(
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
],
})
}),
);
registerRoute(
@@ -92,7 +97,7 @@ registerRoute(
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 }),
],
})
}),
);
self.addEventListener('message', (event) => {
@@ -101,13 +106,14 @@ self.addEventListener('message', (event) => {
}
});
self.addEventListener('sync', (event: any) => {
if (event.tag === 'upload-queue') {
event.waitUntil(
self.addEventListener('sync', (event: unknown) => {
const syncEvent = event as { tag?: string; waitUntil: (promise: Promise<unknown>) => void };
if (syncEvent.tag === 'upload-queue') {
syncEvent.waitUntil(
(async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
clients.forEach((client) => client.postMessage({ type: 'sync-queue' }));
})()
})(),
);
}
});
@@ -129,7 +135,7 @@ self.addEventListener('push', (event) => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach((client) => client.postMessage({ type: 'guest-notification-refresh' }));
})()
})(),
);
});
@@ -148,7 +154,8 @@ self.addEventListener('notificationclick', (event) => {
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
return undefined;
}),
);
});
@@ -156,6 +163,6 @@ self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' }));
})
}),
);
});

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE, isLocaleCode } from '@/guest/i18n/messages';
import { NotificationCenterProvider } from '@/guest/context/NotificationCenterContext';
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
import { LocaleProvider } from '@/shared/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE, isLocaleCode } from '@/shared/guest/i18n/messages';
import { NotificationCenterProvider } from '@/shared/guest/context/NotificationCenterContext';
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
import { EventDataProvider, useEventData } from '../context/EventDataContext';
import { GuestIdentityProvider, useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { mapEventBranding } from '../lib/eventBranding';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE } from '@/guest/i18n/messages';
import { LocaleProvider } from '@/shared/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE } from '@/shared/guest/i18n/messages';
export default function GuestLocaleLayout() {
return (

View File

@@ -2,9 +2,9 @@ import { Theme } from '@tamagui/core';
import React from 'react';
import type { Appearance } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventBranding } from '@/guest/context/EventBrandingContext';
import { relativeLuminance } from '@/guest/lib/color';
import type { EventBranding } from '@/guest/types/event-branding';
import { useEventBranding } from '@/shared/guest/context/EventBrandingContext';
import { relativeLuminance } from '@/shared/guest/lib/color';
import type { EventBranding } from '@/shared/guest/types/event-branding';
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
const DARK_LUMINANCE_THRESHOLD = 0.35;

View File

@@ -1,5 +1,5 @@
import type { EventBranding } from '@/guest/types/event-branding';
import type { EventBrandingPayload } from '@/guest/services/eventApi';
import type { EventBranding } from '@/shared/guest/types/event-branding';
import type { EventBrandingPayload } from '@/shared/guest/services/eventApi';
export function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
if (!raw) {

View File

@@ -1,5 +1,5 @@
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext';
import type { EventBranding } from '@/guest/types/event-branding';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/shared/guest/context/EventBrandingContext';
import type { EventBranding } from '@/shared/guest/types/event-branding';
import { useAppearance, type Appearance } from '@/hooks/use-appearance';
import { resolveGuestThemeName } from './brandingTheme';

View File

@@ -4,7 +4,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Award, BarChart2, Camera, Flame, Sparkles, Trophy, Users } from 'lucide-react';
import AppShell from '../components/AppShell';
import PullToRefresh from '@/guest/components/PullToRefresh';
import PullToRefresh from '@/shared/guest/components/PullToRefresh';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import {
@@ -17,11 +17,11 @@ import {
type TopPhotoHighlight,
type TrendingEmotionHighlight,
} from '../services/achievementsApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { getBentoSurfaceTokens } from '../lib/bento';
import { localizeTaskLabel } from '@/guest/lib/localizeTaskLabel';
import { localizeTaskLabel } from '@/shared/guest/lib/localizeTaskLabel';
import { buildEventPath } from '../lib/routes';
import { useNavigate } from 'react-router-dom';
@@ -76,9 +76,10 @@ type LeaderboardProps = {
emptyCopy: string;
formatNumber: (value: number) => string;
guestFallback: string;
isDark: boolean;
};
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback }: LeaderboardProps) {
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback, isDark }: LeaderboardProps) {
return (
<YStack gap="$2">
<XStack alignItems="center" gap="$2">
@@ -114,9 +115,9 @@ function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, forma
justifyContent="space-between"
padding="$2"
borderRadius="$card"
backgroundColor="rgba(15, 23, 42, 0.05)"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
borderWidth={1}
borderColor="rgba(15, 23, 42, 0.08)"
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$1" fontWeight="$7" color="$color" opacity={0.7}>
@@ -146,9 +147,10 @@ type BadgesGridProps = {
badges: AchievementBadge[];
emptyCopy: string;
completeCopy: string;
isDark: boolean;
};
function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
function BadgesGrid({ badges, emptyCopy, completeCopy, isDark }: BadgesGridProps) {
if (badges.length === 0) {
return (
<Text fontSize="$2" color="$color" opacity={0.7}>
@@ -172,8 +174,8 @@ function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
padding="$2"
borderRadius="$card"
borderWidth={1}
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : 'rgba(15, 23, 42, 0.1)'}
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : 'rgba(255, 255, 255, 0.85)'}
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.1)'}
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.85)'}
gap="$1"
>
<Text fontSize="$2" fontWeight="$7">
@@ -190,7 +192,7 @@ function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
{badge.earned ? completeCopy : `${progress}/${target}`}
</Text>
</XStack>
<YStack height={6} borderRadius={999} backgroundColor="rgba(15, 23, 42, 0.08)">
<YStack height={6} borderRadius={999} backgroundColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}>
<YStack
height={6}
borderRadius={999}
@@ -209,9 +211,10 @@ type TimelineProps = {
points: TimelinePoint[];
formatNumber: (value: number) => string;
emptyCopy: string;
isDark: boolean;
};
function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
function Timeline({ points, formatNumber, emptyCopy, isDark }: TimelineProps) {
if (points.length === 0) {
return (
<Text fontSize="$2" color="$color" opacity={0.7}>
@@ -229,9 +232,9 @@ function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
justifyContent="space-between"
padding="$2"
borderRadius="$card"
backgroundColor="rgba(15, 23, 42, 0.05)"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
borderWidth={1}
borderColor="rgba(15, 23, 42, 0.08)"
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
>
<Text fontSize="$2" fontWeight="$6">
{point.date}
@@ -257,6 +260,7 @@ type HighlightsProps = {
topPhotoFallbackGuest: string;
trendingTitle: string;
trendingCountLabel: string;
isDark: boolean;
};
function Highlights({
@@ -271,6 +275,7 @@ function Highlights({
topPhotoFallbackGuest,
trendingTitle,
trendingCountLabel,
isDark,
}: HighlightsProps) {
if (!topPhoto && !trendingEmotion) {
return (
@@ -298,7 +303,13 @@ function Highlights({
style={{ width: '100%', height: 180, objectFit: 'cover', borderRadius: 16 }}
/>
) : (
<YStack height={180} borderRadius={16} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
<YStack
height={180}
borderRadius={16}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
alignItems="center"
justifyContent="center"
>
<Text fontSize="$2" color="$color" opacity={0.7}>
{topPhotoNoPreview}
</Text>
@@ -347,9 +358,10 @@ type FeedProps = {
emptyCopy: string;
guestFallback: string;
likesLabel: string;
isDark: boolean;
};
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel }: FeedProps) {
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel, isDark }: FeedProps) {
if (feed.length === 0) {
return (
<Text fontSize="$2" color="$color" opacity={0.7}>
@@ -368,9 +380,9 @@ function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guest
gap="$2"
padding="$2"
borderRadius="$card"
backgroundColor="rgba(15, 23, 42, 0.05)"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
borderWidth={1}
borderColor="rgba(15, 23, 42, 0.08)"
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
>
{item.thumbnail ? (
<img
@@ -379,8 +391,15 @@ function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guest
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<YStack width={64} height={64} borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
<Camera size={18} color="#0F172A" />
<YStack
width={64}
height={64}
borderRadius={12}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
alignItems="center"
justifyContent="center"
>
<Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</YStack>
)}
<YStack flex={1} gap="$1">
@@ -415,7 +434,6 @@ export default function AchievementsScreen() {
const { locale } = useLocale();
const { isDark } = useGuestThemeVariant();
const navigate = useNavigate();
const surface = getBentoSurfaceTokens(isDark);
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
@@ -576,6 +594,7 @@ export default function AchievementsScreen() {
badges={personal?.badges ?? []}
emptyCopy={t('achievements.badges.empty', 'No badges yet.')}
completeCopy={t('achievements.badges.complete', 'Complete')}
isDark={isDark}
/>
</YStack>
</BentoCard>
@@ -597,6 +616,7 @@ export default function AchievementsScreen() {
topPhotoFallbackGuest={t('achievements.leaderboard.guestFallback', 'Guest')}
trendingTitle={t('achievements.highlights.trendingTitle', 'Trending emotion')}
trendingCountLabel={t('achievements.highlights.trendingCount', { count: '{count}' }, '{count} photos')}
isDark={isDark}
/>
</BentoCard>
@@ -613,6 +633,7 @@ export default function AchievementsScreen() {
points={highlights?.timeline ?? []}
formatNumber={formatNumber}
emptyCopy={t('achievements.timeline.empty', 'No timeline data yet.')}
isDark={isDark}
/>
</BentoCard>
</YStack>
@@ -626,6 +647,7 @@ export default function AchievementsScreen() {
emptyCopy={t('achievements.leaderboard.uploadsEmpty', 'No uploads yet.')}
formatNumber={formatNumber}
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
isDark={isDark}
/>
</BentoCard>
<BentoCard isDark={isDark}>
@@ -637,6 +659,7 @@ export default function AchievementsScreen() {
emptyCopy={t('achievements.leaderboard.likesEmpty', 'No likes yet.')}
formatNumber={formatNumber}
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
isDark={isDark}
/>
</BentoCard>
</YStack>
@@ -654,6 +677,7 @@ export default function AchievementsScreen() {
emptyCopy={t('achievements.feed.empty', 'The feed is quiet for now.')}
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
likesLabel={t('achievements.feed.likesLabel', { count: '{count}' }, '{count} likes')}
isDark={isDark}
/>
</BentoCard>
) : null}

View File

@@ -10,8 +10,8 @@ import { useEventData } from '../context/EventDataContext';
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { buildEventPath } from '../lib/routes';
import { getBentoSurfaceTokens } from '../lib/bento';

View File

@@ -7,10 +7,10 @@ import { ArrowLeft, Loader2 } from 'lucide-react';
import AppShell from '../components/AppShell';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import PullToRefresh from '@/guest/components/PullToRefresh';
import { getHelpArticle, type HelpArticleDetail } from '@/guest/services/helpApi';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import PullToRefresh from '@/shared/guest/components/PullToRefresh';
import { getHelpArticle, type HelpArticleDetail } from '@/shared/guest/services/helpApi';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import EventLogo from '../components/EventLogo';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -8,10 +8,10 @@ import { Loader2, RefreshCcw, Search } from 'lucide-react';
import AppShell from '../components/AppShell';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import PullToRefresh from '@/guest/components/PullToRefresh';
import { getHelpArticles, type HelpArticleSummary } from '@/guest/services/helpApi';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import PullToRefresh from '@/shared/guest/components/PullToRefresh';
import { getHelpArticles, type HelpArticleSummary } from '@/shared/guest/services/helpApi';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import EventLogo from '../components/EventLogo';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -12,14 +12,14 @@ import { buildEventPath } from '../lib/routes';
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
import { usePollStats } from '../hooks/usePollStats';
import { fetchGallery } from '../services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { useGuestTaskProgress } from '@/shared/guest/hooks/useGuestTaskProgress';
import { fetchEmotions } from '../services/emotionsApi';
import { getBentoSurfaceTokens } from '../lib/bento';
import { useEventBranding } from '@/guest/context/EventBrandingContext';
import { useEventBranding } from '@/shared/guest/context/EventBrandingContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
type ActionTileProps = {

View File

@@ -7,7 +7,7 @@ import { Input } from '@tamagui/input';
import { Card } from '@tamagui/card';
import { Html5Qrcode } from 'html5-qrcode';
import { QrCode, ArrowRight } from 'lucide-react';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { fetchEvent } from '../services/eventApi';
import { readGuestName } from '../context/GuestIdentityContext';
import EventLogo from '../components/EventLogo';

View File

@@ -3,9 +3,9 @@ import { useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Card } from '@tamagui/card';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/guest/components/legal-markdown';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/shared/guest/components/legal-markdown';
import EventLogo from '../components/EventLogo';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -2,13 +2,13 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import { Loader2, Maximize2, Minimize2, Pause, Play } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { useLiveShowState } from '@/guest/hooks/useLiveShowState';
import { useLiveShowPlayback } from '@/guest/hooks/useLiveShowPlayback';
import LiveShowStage from '@/guest/components/LiveShowStage';
import LiveShowBackdrop from '@/guest/components/LiveShowBackdrop';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { prefersReducedMotion } from '@/guest/lib/motion';
import { resolveLiveShowEffect } from '@/guest/lib/liveShowEffects';
import { useLiveShowState } from '@/shared/guest/hooks/useLiveShowState';
import { useLiveShowPlayback } from '@/shared/guest/hooks/useLiveShowPlayback';
import LiveShowStage from '@/shared/guest/components/LiveShowStage';
import LiveShowBackdrop from '@/shared/guest/components/LiveShowBackdrop';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { prefersReducedMotion } from '@/shared/guest/lib/motion';
import { resolveLiveShowEffect } from '@/shared/guest/lib/liveShowEffects';
import EventLogo from '../components/EventLogo';
export default function LiveShowScreen() {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
export default function NotFoundScreen() {
const { t } = useTranslation();

View File

@@ -11,8 +11,8 @@ import SurfaceCard from '../components/SurfaceCard';
import ShareSheet from '../components/ShareSheet';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { buildEventPath } from '../lib/routes';
import { pushGuestToast } from '../lib/toast';

View File

@@ -5,7 +5,7 @@ import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Input } from '@tamagui/input';
import { Card } from '@tamagui/card';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useEventData } from '../context/EventDataContext';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import EventLogo from '../components/EventLogo';

View File

@@ -8,10 +8,10 @@ import { Sheet } from '@tamagui/sheet';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import EventLogo from '../components/EventLogo';
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/guest/services/galleryApi';
import { createPhotoShareLink } from '@/guest/services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/shared/guest/services/galleryApi';
import { createPhotoShareLink } from '@/shared/guest/services/photosApi';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
import { mapEventBranding } from '../lib/eventBranding';
import { BrandingTheme } from '../lib/brandingTheme';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -9,7 +9,7 @@ import { buildEventShareLink } from '../services/eventLink';
import { usePollStats } from '../hooks/usePollStats';
import { fetchEventQrCode } from '../services/qrApi';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { getBentoSurfaceTokens } from '../lib/bento';
import { pushGuestToast } from '../lib/toast';

View File

@@ -7,10 +7,10 @@ import { AlertCircle, Download, Maximize2, X } from 'lucide-react';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import EventLogo from '../components/EventLogo';
import { fetchPhotoShare } from '@/guest/services/photosApi';
import type { EventBrandingPayload } from '@/guest/services/eventApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
import { fetchPhotoShare } from '@/shared/guest/services/photosApi';
import type { EventBrandingPayload } from '@/shared/guest/services/eventApi';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
import { mapEventBranding } from '../lib/eventBranding';
import { BrandingTheme } from '../lib/brandingTheme';
import { useGuestThemeVariant } from '../lib/guestTheme';

View File

@@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lu
import { useEventData } from '../context/EventDataContext';
import EventLogo from '../components/EventLogo';
import { fetchGallery, type GalleryPhoto } from '../services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
function normalizeImageUrl(src?: string | null) {
if (!src) return '';

View File

@@ -9,7 +9,7 @@ import SurfaceCard from '../components/SurfaceCard';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
function getTaskValue(task: TaskItem, key: string): string | undefined {

View File

@@ -6,13 +6,13 @@ import { Trophy, Play } from 'lucide-react';
import AppShell from '../components/AppShell';
import TaskHeroCard, { type TaskHero } from '../components/TaskHeroCard';
import { useEventData } from '../context/EventDataContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { fetchTasks } from '../services/tasksApi';
import { fetchEmotions } from '../services/emotionsApi';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate } from 'react-router-dom';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { useGuestTaskProgress } from '@/shared/guest/hooks/useGuestTaskProgress';
import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';

View File

@@ -6,11 +6,11 @@ import { RefreshCcw, Trash2, UploadCloud } from 'lucide-react';
import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi';
type ProgressMap = Record<number, number>;

View File

@@ -9,15 +9,15 @@ import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
import { useGuestTaskProgress } from '@/shared/guest/hooks/useGuestTaskProgress';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/shared/guest/lib/uploadErrorDialog';
import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { pushGuestToast } from '../lib/toast';
import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
import { compressPhoto, formatBytes } from '@/guest/lib/image';
import { compressPhoto, formatBytes } from '@/shared/guest/lib/image';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];

View File

@@ -3,4 +3,4 @@ export {
type AchievementsPayload,
type AchievementBadge,
type LeaderboardEntry,
} from '@/guest/services/achievementApi';
} from '@/shared/guest/services/achievementApi';

View File

@@ -5,4 +5,4 @@ export {
type EventStats,
FetchEventError,
type FetchEventErrorCode,
} from '@/guest/services/eventApi';
} from '@/shared/guest/services/eventApi';

View File

@@ -3,4 +3,4 @@ export {
fetchGalleryPhotos,
type GalleryMetaResponse,
type GalleryPhotoResource,
} from '@/guest/services/galleryApi';
} from '@/shared/guest/services/galleryApi';

View File

@@ -3,4 +3,4 @@ export {
markGuestNotificationRead,
dismissGuestNotification,
type GuestNotificationItem,
} from '@/guest/services/notificationApi';
} from '@/shared/guest/services/notificationApi';

View File

@@ -1,6 +1,6 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/guest/services/photosApi';
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/shared/guest/services/photosApi';
export type GalleryPhoto = Record<string, unknown>;

View File

@@ -1 +1,4 @@
export { registerGuestPushSubscription, unregisterGuestPushSubscription } from '@/guest/services/pushApi';
export {
registerPushSubscription as registerGuestPushSubscription,
unregisterPushSubscription as unregisterGuestPushSubscription,
} from '@/shared/guest/services/pushApi';

View File

@@ -1,3 +1,3 @@
export { uploadPhoto } from '@/guest/services/photosApi';
export { enqueue } from '@/guest/queue/queue';
export { useUploadQueue } from '@/guest/queue/hooks';
export { uploadPhoto } from '@/shared/guest/services/photosApi';
export { enqueue } from '@/shared/guest/queue/queue';
export { useUploadQueue } from '@/shared/guest/queue/hooks';

View File

@@ -1,215 +0,0 @@
import React from 'react';
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import { isTaskModeEnabled } from '../lib/engagement';
function TabLink({
to,
children,
isActive,
accentColor,
radius,
style,
compact = false,
}: {
to: string;
children: React.ReactNode;
isActive: boolean;
accentColor: string;
radius: number;
style?: React.CSSProperties;
compact?: boolean;
}) {
const activeStyle = isActive
? {
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
color: '#ffffff',
boxShadow: `0 12px 30px ${accentColor}33`,
borderRadius: radius,
...style,
}
: { borderRadius: radius, ...style };
return (
<NavLink
to={to}
className={`
flex ${compact ? 'h-10 text-[10px]' : 'h-14 text-xs'} flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 font-medium transition-all duration-200 ease-out
touch-manipulation backdrop-blur-md
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
`}
style={activeStyle}
>
{children}
</NavLink>
);
}
export default function BottomNav() {
const { token } = useParams();
const location = useLocation();
const { event, status } = useEventData();
const { t } = useTranslation();
const { branding } = useEventBranding();
const navRef = React.useRef<HTMLDivElement | null>(null);
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;
if (!token || !isReady) return null;
const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname;
const tasksEnabled = isTaskModeEnabled(event);
const labels = {
home: t('navigation.home'),
tasks: t('navigation.tasks'),
achievements: t('navigation.achievements'),
gallery: t('navigation.gallery'),
upload: t('home.actions.items.upload.label'),
};
const isHomeActive = currentPath === base || currentPath === `/${token}`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`);
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
const isUploadActive = currentPath.startsWith(`${base}/upload`);
const compact = isUploadActive;
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
const setBottomOffset = React.useCallback(() => {
if (typeof document === 'undefined' || !navRef.current) {
return;
}
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
document.documentElement.style.setProperty('--guest-bottom-nav-offset', `${height}px`);
}, []);
React.useLayoutEffect(() => {
if (typeof window === 'undefined') {
return;
}
setBottomOffset();
const handleResize = () => setBottomOffset();
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
const observer = new ResizeObserver(() => setBottomOffset());
observer.observe(navRef.current);
window.addEventListener('resize', handleResize);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleResize);
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
};
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
};
}, [setBottomOffset, compact]);
return (
<div
ref={navRef}
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
compact ? 'pt-1' : 'pt-2 pb-1'
}`}
style={{ paddingBottom: navPaddingBottom }}
>
<div className="pointer-events-none absolute -top-7 inset-x-0 h-7 bg-gradient-to-b from-black/0 via-black/30 to-black/60 dark:via-black/40 dark:to-black/70" aria-hidden />
<div className="mx-auto flex max-w-lg items-center gap-3">
<div className="flex flex-1 justify-evenly gap-2">
<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">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</TabLink>
{tasksEnabled ? (
<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">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
</div>
</TabLink>
) : null}
</div>
<Link
to={`${base}/upload`}
aria-label={labels.upload}
className={`relative flex ${compact ? 'h-12 w-12' : 'h-16 w-16'} items-center justify-center rounded-full text-white shadow-2xl transition-all duration-300 ${
isUploadActive
? 'translate-y-6 scale-75 opacity-0 pointer-events-none'
: 'hover:scale-105'
}`}
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
borderRadius: radius,
}}
tabIndex={isUploadActive ? -1 : 0}
aria-hidden={isUploadActive}
>
<Camera className="h-6 w-6" aria-hidden />
</Link>
<div className="flex flex-1 justify-evenly gap-2">
<TabLink
to={`${base}/achievements`}
isActive={isAchievementsActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">
<Trophy className="h-5 w-5" aria-hidden />
<span>{labels.achievements}</span>
</div>
</TabLink>
<TabLink
to={`${base}/gallery`}
isActive={isGalleryActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">
<GalleryHorizontal className="h-5 w-5" aria-hidden />
<span>{labels.gallery}</span>
</div>
</TabLink>
</div>
</div>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import React from 'react';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { ZapOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
export type DemoReadOnlyNoticeProps = {
title: string;
copy: string;
hint?: string;
ctaLabel?: string;
onCta?: () => void;
radius?: number;
bodyFont?: string;
motionProps?: HTMLMotionProps<'div'>;
};
export default function DemoReadOnlyNotice({
title,
copy,
hint,
ctaLabel,
onCta,
radius,
bodyFont,
motionProps,
}: DemoReadOnlyNoticeProps) {
return (
<motion.div
className="rounded-[28px] border border-white/15 bg-black/70 p-5 text-white shadow-2xl backdrop-blur"
style={{ borderRadius: radius, fontFamily: bodyFont }}
{...motionProps}
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10">
<ZapOff className="h-5 w-5 text-amber-200" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs text-white/80">{copy}</p>
{hint ? <p className="text-[11px] text-white/60">{hint}</p> : null}
</div>
</div>
{ctaLabel && onCta ? (
<div className="mt-4 flex flex-wrap gap-3">
<Button
size="sm"
variant="secondary"
className="rounded-full bg-white/90 text-slate-900 hover:bg-white"
onClick={onCta}
>
{ctaLabel}
</Button>
</div>
) : null}
</motion.div>
);
}

View File

@@ -1,199 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '../i18n/useTranslation';
interface Emotion {
id: number;
slug: string;
name: string;
emoji: string;
description?: string;
}
interface EmotionPickerProps {
onSelect?: (emotion: Emotion) => void;
variant?: 'standalone' | 'embedded';
title?: string;
subtitle?: string;
showSkip?: boolean;
}
export default function EmotionPicker({
onSelect,
variant = 'standalone',
title,
subtitle,
showSkip,
}: EmotionPickerProps) {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { locale } = useTranslation();
// Fallback emotions (when API not available yet)
const fallbackEmotions = React.useMemo<Emotion[]>(
() => [
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
],
[]
);
useEffect(() => {
if (!eventKey) return;
async function fetchEmotions() {
try {
setLoading(true);
setError(null);
// Try API first
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions?locale=${encodeURIComponent(locale)}`, {
headers: {
Accept: 'application/json',
'X-Locale': locale,
},
});
if (response.ok) {
const data = await response.json();
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
} else {
// Fallback to predefined emotions
console.warn('Emotions API not available, using fallback');
setEmotions(fallbackEmotions);
}
} catch (err) {
console.error('Failed to fetch emotions:', err);
setError('Emotions konnten nicht geladen werden');
setEmotions(fallbackEmotions);
} finally {
setLoading(false);
}
}
fetchEmotions();
}, [eventKey, locale, fallbackEmotions]);
const handleEmotionSelect = (emotion: Emotion) => {
if (onSelect) {
onSelect(emotion);
} else {
// Default: Navigate to tasks with emotion filter
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`);
}
};
const headingTitle = title ?? 'Wie fühlst du dich?';
const headingSubtitle = subtitle ?? '(optional)';
const shouldShowSkip = showSkip ?? variant === 'standalone';
const content = (
<div className="space-y-4">
{(variant === 'standalone' || title) && (
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
{headingTitle}
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground dark:text-white/70">{headingSubtitle}</span>}
</h3>
{loading && <span className="text-xs text-muted-foreground dark:text-white/70">Lade Emotionen</span>}
</div>
)}
<div className="relative">
<div
className={cn(
'grid grid-rows-2 grid-flow-col auto-cols-[170px] sm:auto-cols-[190px] gap-3 overflow-x-auto pb-2 pr-12',
'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent'
)}
aria-label="Emotions"
>
{emotions.map((emotion) => {
// Localize name and description if they are JSON
const localize = (value: string | object, defaultValue: string = ''): string => {
if (typeof value === 'string' && value.startsWith('{')) {
try {
const data = JSON.parse(value as string);
return data.de || data.en || defaultValue || '';
} catch {
return value as string;
}
}
return value as string;
};
const localizedName = localize(emotion.name, emotion.name);
const localizedDescription = localize(emotion.description || '', '');
return (
<button
key={emotion.id}
type="button"
onClick={() => handleEmotionSelect(emotion)}
className="group relative flex flex-col gap-2 rounded-2xl p-[1px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.98]"
style={{
backgroundImage: 'linear-gradient(135deg, color-mix(in oklch, var(--guest-primary) 45%, white), color-mix(in oklch, var(--guest-secondary) 40%, white))',
}}
>
<div className="relative flex flex-col gap-2 rounded-[0.95rem] border border-white/50 bg-white/80 px-4 py-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-center gap-3">
<span className="text-2xl" aria-hidden>
{emotion.emoji}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground dark:text-white">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
</div>
</div>
</button>
);
})}
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--guest-background)] via-[var(--guest-background)]/90 to-transparent dark:from-black dark:via-black/80" aria-hidden />
</div>
{/* Skip option */}
{shouldShowSkip && (
<div className="mt-4">
<Button
variant="ghost"
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
onClick={() => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
}}
>
Überspringen und Aufgabe wählen
</Button>
</div>
)}
</div>
);
if (error) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
);
}
if (variant === 'embedded') {
return content;
}
return <div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">{content}</div>;
}

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: LucideIcon }>;
const baseFilters: FilterConfig = [
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: Sparkles },
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: Flame },
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: UserRound },
];
export default function FiltersBar({
value,
onChange,
className,
showPhotobooth = true,
styleOverride,
}: {
value: GalleryFilter;
onChange: (v: GalleryFilter) => void;
className?: string;
showPhotobooth?: boolean;
styleOverride?: React.CSSProperties;
}) {
const { t } = useTranslation();
const filters: FilterConfig = React.useMemo(
() => (showPhotobooth
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: Camera }]
: baseFilters),
[showPhotobooth],
);
return (
<div
className={cn(
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
style={styleOverride}
>
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
const isActive = value === filter.value;
const Icon = filter.icon;
return (
<div key={filter.value} className="flex items-center">
<button
type="button"
onClick={() => onChange(filter.value)}
className={cn(
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
isActive
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden />
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
</button>
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,195 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { getDeviceId } from '../lib/device';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Heart } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
type Props = { token: string };
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type PreviewPhoto = {
id: number;
session_id?: string | null;
ingest_source?: string | null;
likes_count?: number | null;
created_at?: string | null;
task_id?: number | null;
task_title?: string | null;
emotion_id?: number | null;
emotion_name?: string | null;
thumbnail_path?: string | null;
file_path?: string | null;
title?: string | null;
};
export default function GalleryPreview({ token }: Props) {
const { locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading } = usePollGalleryDelta(token, locale);
const [mode, setMode] = React.useState<PreviewFilter>('latest');
const typedPhotos = React.useMemo(() => photos as PreviewPhoto[], [photos]);
const hasPhotobooth = React.useMemo(() => typedPhotos.some((p) => p.ingest_source === 'photobooth'), [typedPhotos]);
const radius = branding.buttons?.radius ?? 12;
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const items = React.useMemo(() => {
let arr = typedPhotos.slice();
// MyPhotos filter (requires session_id matching)
if (mode === 'mine') {
const deviceId = getDeviceId();
arr = arr.filter((photo) => photo.session_id === deviceId);
} else if (mode === 'photobooth') {
arr = arr.filter((photo) => photo.ingest_source === 'photobooth');
}
// Sorting
if (mode === 'popular') {
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
} else {
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
}
return arr.slice(0, 9); // up to 3x3 preview
}, [typedPhotos, mode]);
React.useEffect(() => {
if (mode === 'photobooth' && !hasPhotobooth) {
setMode('latest');
}
}, [mode, hasPhotobooth]);
// Helper function to generate photo title (must be before return)
function getPhotoTitle(photo: PreviewPhoto): string {
if (photo.task_id) {
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
}
if (photo.emotion_id) {
return `Emotion: ${photo.emotion_name || 'Gefühl'}`;
}
// Fallback based on creation time or placeholder
const now = new Date();
const created = new Date(photo.created_at || now);
const hours = created.getHours();
if (hours < 12) return 'Morgenmoment';
if (hours < 18) return 'Nachmittagslicht';
return 'Abendstimmung';
}
const filters: { value: PreviewFilter; label: string }[] = [
{ value: 'latest', label: 'Newest' },
{ value: 'popular', label: 'Popular' },
{ value: 'mine', label: 'My Photos' },
...(hasPhotobooth ? [{ value: 'photobooth', label: 'Fotobox' } as const] : []),
];
return (
<Card
className="border border-muted/30 bg-[var(--guest-surface)] shadow-sm dark:border-slate-800/70 dark:bg-slate-950/70"
data-testid="gallery-preview"
style={{ borderRadius: radius, fontFamily: bodyFont }}
>
<CardContent className="space-y-3 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="mb-1 inline-flex items-center rounded-full border border-white/50 bg-white/80 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70" style={headingFont ? { fontFamily: headingFont } : undefined}>
Live-Galerie
</div>
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
</div>
<Link
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
className="rounded-full border border-white/40 bg-white/70 px-3 py-1 text-sm font-semibold shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-950"
style={{ color: linkColor }}
>
Alle ansehen
</Link>
</div>
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter) => {
const isActive = mode === filter.value;
return (
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
className={cn(
'relative inline-flex items-center rounded-full px-3 py-1.5 transition',
isActive
? 'text-white'
: 'text-muted-foreground hover:text-pink-600 dark:text-white/70 dark:hover:text-white',
)}
>
{isActive && (
<motion.span
layoutId="gallery-filter-pill"
className="absolute inset-0 rounded-full bg-gradient-to-r from-pink-500 to-rose-500 shadow"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative z-10 whitespace-nowrap">{filter.label}</span>
</button>
);
})}
</div>
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground dark:border-slate-800/60 dark:bg-slate-950/60">
<Heart className="h-4 w-4" style={{ color: branding.secondaryColor }} aria-hidden />
Noch keine Fotos. Starte mit deinem ersten Upload!
</div>
)}
<div className="grid gap-3 grid-cols-2 md:grid-cols-3">
{items.map((p: PreviewPhoto) => (
<Link
key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
>
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden />
</div>
<div className="space-y-2 px-3 pb-3 pt-3">
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
{p.title || getPhotoTitle(p)}
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden />
{p.likes_count ?? 0}
</div>
</div>
</Link>
))}
</div>
<p className="text-center text-sm text-muted-foreground">
Lust auf mehr?{' '}
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="font-semibold transition" style={{ color: linkColor }}>
Zur Galerie
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,236 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '../i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '../lib/analyticsConsent';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
const SNOOZE_MS = 60 * 60 * 1000;
const ACTIVE_IDLE_LIMIT_MS = 20_000;
type PromptStorage = {
snoozedUntil?: number | null;
};
function readSnoozedUntil(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PromptStorage;
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
} catch {
return null;
}
}
function writeSnoozedUntil(value: number | null) {
if (typeof window === 'undefined') {
return;
}
try {
const payload: PromptStorage = { snoozedUntil: value };
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// ignore storage failures
}
}
function randomInt(min: number, max: number): number {
const low = Math.ceil(min);
const high = Math.floor(max);
return Math.floor(Math.random() * (high - low + 1)) + low;
}
export default function GuestAnalyticsNudge({
enabled,
pathname,
}: {
enabled: boolean;
pathname: string;
}) {
const { t } = useTranslation();
const { decisionMade, preferences, savePreferences } = useConsent();
const analyticsConsent = Boolean(preferences?.analytics);
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
const [activeSeconds, setActiveSeconds] = React.useState(0);
const [routeCount, setRouteCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
const lastPathRef = React.useRef(pathname);
const lastActivityAtRef = React.useRef(Date.now());
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
const isUpload = isUploadPath(pathname);
React.useEffect(() => {
const previousPath = lastPathRef.current;
const currentPath = pathname;
lastPathRef.current = currentPath;
if (previousPath === currentPath) {
return;
}
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
return;
}
setRouteCount((count) => count + 1);
}, [pathname]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleActivity = () => {
lastActivityAtRef.current = Date.now();
};
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'pointermove',
'keydown',
'scroll',
'touchstart',
];
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
};
}, []);
React.useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibility = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const interval = window.setInterval(() => {
const now = Date.now();
if (!visibleRef.current) {
return;
}
if (isUploadPath(lastPathRef.current)) {
return;
}
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
return;
}
setActiveSeconds((seconds) => seconds + 1);
}, 1000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
if (!enabled || analyticsConsent || decisionMade) {
setIsOpen(false);
return;
}
const shouldOpen = shouldShowAnalyticsNudge({
decisionMade,
analyticsConsent,
snoozedUntil,
now: Date.now(),
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
});
if (shouldOpen) {
setIsOpen(true);
}
}, [
enabled,
analyticsConsent,
decisionMade,
snoozedUntil,
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
]);
React.useEffect(() => {
if (isUpload) {
setIsOpen(false);
}
}, [isUpload]);
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
return null;
}
const handleSnooze = () => {
const until = Date.now() + SNOOZE_MS;
setSnoozedUntil(until);
writeSnoozedUntil(until);
setIsOpen(false);
};
const handleAllow = () => {
savePreferences({ analytics: true });
writeSnoozedUntil(null);
setIsOpen(false);
};
return (
<div
className="pointer-events-none fixed inset-x-0 z-40 px-4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<div className="pointer-events-auto mx-auto max-w-lg rounded-2xl border border-slate-200/80 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-slate-700/60 dark:bg-slate-900/95">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('consent.analytics.title')}
</p>
<p className="text-xs text-muted-foreground">
{t('consent.analytics.body')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="ghost" onClick={handleSnooze}>
{t('consent.analytics.later')}
</Button>
<Button type="button" size="sm" onClick={handleAllow}>
{t('consent.analytics.allow')}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,813 +0,0 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import {
User,
Heart,
Users,
PartyPopper,
Camera,
Bell,
ArrowUpRight,
Clock,
MessageSquare,
Sparkles,
LifeBuoy,
UploadCloud,
AlertCircle,
Check,
X,
RefreshCw,
} from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useOptionalEventStats } from '../context/EventStatsContext';
import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
heart: Heart,
guests: Users,
party: PartyPopper,
camera: Camera,
};
type LogoSize = 's' | 'm' | 'l';
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string; initials: string }> = {
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' },
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' },
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' },
};
function getLogoClasses(size?: LogoSize) {
return LOGO_SIZE_CLASSES[size ?? 'm'];
}
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
broadcast: MessageSquare,
feedback_request: MessageSquare,
achievement_major: Sparkles,
support_tip: LifeBuoy,
upload_alert: UploadCloud,
photo_activity: Camera,
};
function isLikelyEmoji(value: string): boolean {
if (!value) {
return false;
}
const characters = Array.from(value.trim());
if (characters.length === 0 || characters.length > 2) {
return false;
}
return characters.some((char) => {
const codePoint = char.codePointAt(0) ?? 0;
return codePoint > 0x2600;
});
}
function getInitials(name: string): string {
const words = name.split(' ').filter(Boolean);
if (words.length >= 2) {
return `${words[0][0]}${words[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
function toRgba(value: string, alpha: number): string {
const rgb = hexToRgb(value);
if (!rgb) {
return `rgba(255, 255, 255, ${alpha})`;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function EventAvatar({
name,
icon,
accentColor,
textColor,
logo,
}: {
name: string;
icon: unknown;
accentColor: string;
textColor: string;
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize };
}) {
const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null;
const [logoFailed, setLogoFailed] = React.useState(false);
React.useEffect(() => {
setLogoFailed(false);
}, [logoValue]);
const sizes = getLogoClasses(logo?.size);
if (logo?.mode === 'upload' && logoValue && !logoFailed) {
return (
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
<img
src={logoValue}
alt={name}
className={`rounded-full object-contain ${sizes.image}`}
onError={() => setLogoFailed(true)}
/>
</div>
);
}
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<span aria-hidden>{logo.value}</span>
<span className="sr-only">{name}</span>
</div>
);
}
if (typeof icon === 'string') {
const trimmed = icon.trim();
if (trimmed) {
const normalized = trimmed.toLowerCase();
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
if (IconComponent) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<IconComponent className={sizes.icon} aria-hidden />
</div>
);
}
if (isLikelyEmoji(trimmed)) {
return (
<div
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
<span aria-hidden>{trimmed}</span>
<span className="sr-only">{name}</span>
</div>
);
}
}
}
return (
<div
className={`flex items-center justify-center rounded-full font-semibold shadow-sm ${sizes.container} ${sizes.initials}`}
style={{ backgroundColor: accentColor, color: textColor }}
>
{getInitials(name)}
</div>
);
}
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
const statsContext = useOptionalEventStats();
const { t } = useTranslation();
const brandingContext = useOptionalEventBranding();
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
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 notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
if (!notificationsOpen) {
return;
}
const handler = (event: MouseEvent) => {
if (notificationButtonRef.current?.contains(event.target as Node)) {
return;
}
if (!panelRef.current) return;
if (panelRef.current.contains(event.target as Node)) return;
setNotificationsOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [notificationsOpen]);
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const logoPosition = branding.logo?.position ?? 'left';
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: headerTextColor,
fontFamily: headerFont,
};
const headerGlowPrimary = toRgba(branding.primaryColor, 0.35);
const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35);
const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`;
const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`;
if (!eventToken) {
return (
<div
className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 bg-white/70 px-4 py-2 shadow-[0_14px_40px_-30px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora-soft" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-8 right-0 h-24 w-24 rounded-full bg-white/60 blur-3xl dark:bg-white/10" aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/15" aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="flex min-w-0 flex-col">
<div className="font-semibold">{title}</div>
</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
}
const accentColor = branding.secondaryColor;
if (status === 'loading') {
return (
<div className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl" style={headerStyle}>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-10 right-[-32px] h-28 w-28 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-8 left-1/3 h-20 w-40 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="ml-auto flex items-center justify-end gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
</div>
);
}
if (status !== 'ready' || !event) {
return null;
}
const stats =
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div
className="guest-header sticky top-0 z-30 relative flex flex-nowrap items-center gap-3 overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl"
style={headerStyle}
>
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
<div className="pointer-events-none absolute -top-12 right-[-40px] h-32 w-32 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
<div className="pointer-events-none absolute -bottom-10 left-1/3 h-24 w-44 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
<div
className={
`relative z-10 flex min-w-0 flex-1 ${logoPosition === 'center'
? 'flex-col items-center gap-1 text-center'
: logoPosition === 'right'
? 'flex-row-reverse items-center gap-3'
: 'items-center gap-3'}`
}
>
<EventAvatar
name={event.name}
icon={event.type?.icon}
accentColor={accentColor}
textColor={headerTextColor}
logo={branding.logo}
/>
<div
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
style={headerFont ? { fontFamily: headerFont } : undefined}
>
<div className="truncate text-base font-semibold sm:text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && tasksEnabled && (
<>
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
</span>
<span className="opacity-50">|</span>
<span className="flex items-center gap-1">
<span className="font-medium">{stats.tasksSolved}</span>{' '}
{t('header.stats.tasksSolved')}
</span>
</>
)}
</div>
</div>
</div>
<div className="relative z-10 ml-auto flex shrink-0 items-center justify-end gap-2">
{notificationCenter && eventToken && (
<NotificationButton
eventToken={eventToken}
center={notificationCenter}
open={notificationsOpen}
onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef}
buttonRef={notificationButtonRef}
t={t}
/>
)}
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
}
type NotificationButtonProps = {
center: NotificationCenterValue;
eventToken: string;
open: boolean;
onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement | null>;
buttonRef: React.RefObject<HTMLButtonElement | null>;
t: TranslateFn;
};
type PushState = ReturnType<typeof usePushSubscription>;
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken);
React.useEffect(() => {
if (!open) {
setActiveTab(center.unreadCount > 0 ? 'unread' : 'all');
}
}, [open, center.unreadCount]);
const uploadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.type === 'upload_alert'),
[center.notifications]
);
const unreadNotifications = React.useMemo(
() => center.notifications.filter((item) => item.status === 'new'),
[center.notifications]
);
const filteredNotifications = React.useMemo(() => {
let base: typeof center.notifications = [];
switch (activeTab) {
case 'unread':
base = unreadNotifications;
break;
case 'uploads':
base = uploadNotifications;
break;
default:
base = center.notifications;
}
return base;
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => {
if (activeTab === 'uploads' || scopeFilter === 'all') {
return filteredNotifications;
}
return filteredNotifications.filter((item) => {
if (scopeFilter === 'tips') {
return item.type === 'support_tip' || item.type === 'achievement_major';
}
return item.type === 'broadcast' || item.type === 'feedback_request';
});
}, [filteredNotifications, scopeFilter]);
return (
<div className="relative z-50">
<button
ref={buttonRef}
type="button"
onClick={onToggle}
className="relative flex h-10 w-10 items-center justify-center rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
>
<Bell className="h-5 w-5" aria-hidden />
{badgeCount > 0 && (
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
{badgeCount > 9 ? '9+' : badgeCount}
</span>
)}
</button>
{open && createPortal(
<div
ref={panelRef}
className="fixed right-4 top-16 z-[2147483000] w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
<p className="text-xs text-slate-500">
{center.unreadCount > 0
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')}
</p>
</div>
<button
type="button"
onClick={() => center.refresh()}
disabled={center.loading}
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
{t('header.notifications.refresh', 'Aktualisieren')}
</button>
</div>
<NotificationTabs
tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
]}
activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/>
{activeTab !== 'uploads' && (
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)}
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
<div className="mt-3 space-y-2">
{center.pendingCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" aria-hidden />
<span>{t('header.notifications.pendingLabel', 'Uploads in Prüfung')}</span>
<span className="font-semibold text-amber-900">{center.pendingCount}</span>
</div>
<Link
to={`/e/${encodeURIComponent(eventToken)}/queue`}
className="inline-flex items-center gap-1 font-semibold text-amber-700"
onClick={() => {
if (center.unreadCount > 0) {
void center.refresh();
}
}}
>
{t('header.notifications.pendingCta', 'Details')}
<ArrowUpRight className="h-4 w-4" aria-hidden />
</Link>
</div>
)}
{center.queueCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-slate-50/90 px-3 py-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-slate-400" aria-hidden />
<span>{t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')}</span>
<span className="font-semibold text-slate-900">{center.queueCount}</span>
</div>
</div>
)}
</div>
)}
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'uploads'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}
push={pushState}
t={t}
/>
</div>,
(typeof document !== 'undefined' ? document.body : null) as any
)}
</div>
);
}
function NotificationListItem({
item,
onMarkRead,
onDismiss,
t,
}: {
item: NotificationCenterValue['notifications'][number];
onMarkRead: () => void;
onDismiss: () => void;
t: TranslateFn;
}) {
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
const isNew = item.status === 'new';
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
return (
<div
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
onClick={() => {
if (isNew) {
onMarkRead();
}
}}
>
<div className="flex items-start gap-3">
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
<IconComponent className="h-4 w-4" aria-hidden />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDismiss();
}}
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
{createdLabel && <span>{createdLabel}</span>}
{isNew && (
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
<Sparkles className="h-3 w-3" aria-hidden />
{t('header.notifications.badge.new', 'Neu')}
</span>
)}
</div>
{item.cta && (
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
)}
{!isNew && item.status !== 'dismissed' && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onMarkRead();
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
>
<Check className="h-3 w-3" aria-hidden />
{t('header.notifications.markRead', 'Als gelesen markieren')}
</button>
)}
</div>
</div>
</div>
);
}
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
const href = cta.href ?? '#';
const label = cta.label ?? '';
const isInternal = /^\//.test(href);
const content = (
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
</span>
);
if (isInternal) {
return (
<Link
to={href}
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</Link>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
onClick={onFollow}
>
{content}
</a>
);
}
function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
<p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
</div>
);
}
function NotificationSkeleton() {
return (
<div className="space-y-2">
{[0, 1, 2].map((index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-slate-200" />
<div className="flex-1 space-y-2">
<div className="h-3 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-200" />
</div>
</div>
</div>
))}
</div>
);
}
function formatRelativeTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
if (diffMinutes < 1) {
return 'Gerade eben';
}
if (diffMinutes < 60) {
return `${diffMinutes} min`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} h`;
}
const diffDays = Math.round(diffHours / 24);
return `${diffDays} d`;
}
function NotificationTabs({
tabs,
activeTab,
onTabChange,
}: {
tabs: Array<{ key: string; label: string; badge?: number }>;
activeTab: string;
onTabChange: (key: string) => void;
}) {
return (
<div className="mt-3 flex gap-2 rounded-full bg-slate-100/80 p-1 text-xs font-semibold text-slate-600">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-full px-3 py-1 transition ${
activeTab === tab.key ? 'bg-white text-pink-600 shadow' : 'text-slate-500'
}`}
onClick={() => onTabChange(tab.key)}
>
{tab.label}
{typeof tab.badge === 'number' && tab.badge > 0 && (
<span className="rounded-full bg-pink-100 px-2 text-[11px] text-pink-600">{tab.badge}</span>
)}
</button>
))}
</div>
);
}
function NotificationStatusBar({
lastFetchedAt,
isOffline,
push,
t,
}: {
lastFetchedAt: Date | null;
isOffline: boolean;
push: PushState;
t: TranslateFn;
}) {
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
const pushDescription = React.useMemo(() => {
if (!push.supported) {
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
}
if (push.permission === 'denied') {
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
}
if (push.subscribed) {
return t('header.notifications.pushActive', 'Push aktiv');
}
return t('header.notifications.pushInactive', 'Push deaktiviert');
}, [push.permission, push.subscribed, push.supported, t]);
const buttonLabel = push.subscribed
? t('header.notifications.pushDisable', 'Deaktivieren')
: t('header.notifications.pushEnable', 'Aktivieren');
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
return (
<div className="mt-4 space-y-2 border-t border-slate-200 pt-3 text-[11px] text-slate-500">
<div className="flex items-center justify-between">
<span>
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
</span>
{isOffline && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-semibold text-amber-700">
<AlertCircle className="h-3 w-3" aria-hidden />
{t('header.notifications.offline', 'Offline')}
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
<div className="flex items-center gap-1">
<Bell className="h-3.5 w-3.5" aria-hidden />
<span>{pushDescription}</span>
</div>
<button
type="button"
onClick={() => (push.subscribed ? push.disable() : push.enable())}
disabled={pushButtonDisabled}
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
>
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
</button>
</div>
{push.error && (
<p className="text-[11px] font-semibold text-rose-600">
{push.error}
</p>
)}
</div>
);
}

View File

@@ -1,100 +0,0 @@
import React from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
const TAB_SECTIONS = new Set(['home', 'tasks', 'achievements', 'gallery']);
export function getTabKey(pathname: string): string | null {
const match = pathname.match(/^\/e\/[^/]+(?:\/([^/]+))?$/);
if (!match) {
return null;
}
const section = match[1];
if (!section) {
return 'home';
}
return TAB_SECTIONS.has(section) ? section : null;
}
export function getTransitionKind(prevPath: string, nextPath: string): 'tab' | 'stack' {
const prevTab = getTabKey(prevPath);
const nextTab = getTabKey(nextPath);
if (prevTab && nextTab && prevTab !== nextTab) {
return 'tab';
}
return 'stack';
}
export function isTransitionDisabled(pathname: string): boolean {
if (pathname.startsWith('/share/')) {
return true;
}
return /^\/e\/[^/]+\/upload(?:\/|$)/.test(pathname);
}
export default function RouteTransition({ children }: { children?: React.ReactNode }) {
const location = useLocation();
const navigationType = useNavigationType();
const prefersReducedMotion = useReducedMotion();
const prevPathRef = React.useRef(location.pathname);
const prevPath = prevPathRef.current;
const direction = navigationType === 'POP' ? 'back' : 'forward';
const kind = getTransitionKind(prevPath, location.pathname);
const disableTransitions = prefersReducedMotion
|| isTransitionDisabled(prevPath)
|| isTransitionDisabled(location.pathname);
React.useEffect(() => {
prevPathRef.current = location.pathname;
}, [location.pathname]);
const content = children ?? <Outlet />;
if (disableTransitions) {
return <>{content}</>;
}
const stackVariants = {
enter: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? -28 : 28,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: ({ direction }: { direction: 'forward' | 'back' }) => ({
x: direction === 'back' ? 28 : -28,
opacity: 0,
}),
};
const tabVariants = {
enter: { opacity: 0, y: 8 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
const transition = kind === 'tab'
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
return (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={location.pathname}
custom={{ direction }}
variants={kind === 'tab' ? tabVariants : stackVariants}
initial="enter"
animate="center"
exit="exit"
transition={transition as any}
style={{ willChange: 'transform, opacity' }}
>
{content}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,140 +0,0 @@
import React from 'react';
import { Share2, MessageSquare, Copy } from 'lucide-react';
import { useTranslation } from '../i18n/useTranslation';
type ShareSheetProps = {
open: boolean;
photoId?: number | null;
eventName?: string | null;
url?: string | null;
loading?: boolean;
onClose: () => void;
onShareNative: () => void;
onShareWhatsApp: () => void;
onShareMessages: () => void;
onCopyLink: () => void;
radius?: number;
bodyFont?: string | null;
headingFont?: string | null;
};
const WhatsAppIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden focusable="false" {...props}>
<path
fill="currentColor"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
/>
</svg>
);
export function ShareSheet({
open,
photoId,
eventName,
url,
loading = false,
onClose,
onShareNative,
onShareWhatsApp,
onShareMessages,
onCopyLink,
radius = 12,
bodyFont,
headingFont,
}: ShareSheetProps) {
const { t } = useTranslation();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-t-3xl border border-border bg-white/98 p-4 text-slate-900 shadow-2xl ring-1 ring-black/10 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/98 dark:text-white"
style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('share.title', 'Geteiltes Foto')}
</p>
<p className="text-base font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
{photoId ? `#${photoId}` : ''}
</p>
{eventName ? <p className="text-xs text-muted-foreground line-clamp-2">{eventName}</p> : null}
</div>
<button
type="button"
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold text-foreground transition hover:bg-muted/80 dark:border-white/20 dark:text-white"
style={{ borderRadius: radius }}
onClick={onClose}
>
{t('lightbox.close', 'Schließen')}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-800 disabled:opacity-100 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/10 dark:disabled:text-white/80"
onClick={onShareNative}
disabled={loading}
style={{ borderRadius: radius }}
>
<Share2 className="h-4 w-4" aria-hidden />
<div>
<div>{t('share.button', 'Teilen')}</div>
<div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-emerald-600 disabled:opacity-60 dark:border-emerald-400/40"
onClick={onShareWhatsApp}
disabled={loading}
style={{ borderRadius: radius }}
>
<WhatsAppIcon className="h-5 w-5" />
<div>
<div>{t('share.whatsapp', 'WhatsApp')}</div>
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-sky-600 disabled:opacity-60 dark:border-sky-400/40"
onClick={onShareMessages}
disabled={loading}
style={{ borderRadius: radius }}
>
<MessageSquare className="h-5 w-5" />
<div>
<div>{t('share.imessage', 'Nachrichten')}</div>
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/5 dark:disabled:text-white/50"
onClick={onCopyLink}
disabled={loading}
style={{ borderRadius: radius }}
>
<Copy className="h-4 w-4" aria-hidden />
<div>
<div className="text-slate-900 dark:text-white">{t('share.copyLink', 'Link kopieren')}</div>
<div className="text-xs text-slate-600 dark:text-white/80">{loading ? t('share.loading', 'Lädt…') : ''}</div>
</div>
</button>
</div>
{url ? (
<p className="mt-3 truncate text-xs text-slate-700 dark:text-white/80" title={url}>
{url}
</p>
) : null}
</div>
</div>
);
}
export default ShareSheet;

View File

@@ -1,104 +0,0 @@
import React from 'react';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import BottomNav from '../BottomNav';
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalResizeObserver = globalThis.ResizeObserver;
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
status: 'ready',
event: {
id: 1,
default_locale: 'de',
},
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#0f172a',
secondaryColor: '#38bdf8',
backgroundColor: '#ffffff',
palette: {
surface: '#ffffff',
},
buttons: {
radius: 12,
style: 'filled',
linkColor: '#0f172a',
},
},
}),
}));
vi.mock('../../lib/engagement', () => ({
isTaskModeEnabled: () => false,
}));
describe('BottomNav', () => {
beforeEach(() => {
HTMLElement.prototype.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 0,
height: 80,
top: 0,
left: 0,
right: 0,
bottom: 80,
toJSON: () => ({}),
}) as DOMRect;
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
private callback: () => void;
constructor(callback: () => void) {
this.callback = callback;
}
observe() {
this.callback();
}
disconnect() {}
};
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
});
afterEach(() => {
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver;
} else {
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
}
});
it('sets the bottom nav offset CSS variable', async () => {
render(
<MemoryRouter initialEntries={['/e/demo']}>
<Routes>
<Route path="/e/:token/*" element={<BottomNav />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px');
});
});
});

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GalleryPreview from '../GalleryPreview';
vi.mock('../../polling/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({
photos: [],
loading: false,
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
locale: 'de',
}),
}));
vi.mock('../../context/EventBrandingContext', () => ({
useEventBranding: () => ({
branding: {
primaryColor: '#FF5A5F',
secondaryColor: '#FFF8F5',
buttons: { radius: 12, linkColor: '#FFF8F5' },
typography: {},
fontFamily: 'Montserrat',
},
}),
}));
describe('GalleryPreview', () => {
it('renders dark mode-ready surfaces', () => {
render(
<MemoryRouter>
<GalleryPreview token="demo" />
</MemoryRouter>,
);
const card = screen.getByTestId('gallery-preview');
expect(card.className).toContain('bg-[var(--guest-surface)]');
expect(card.className).toContain('dark:bg-slate-950/70');
const emptyState = screen.getByText(/Noch keine Fotos/i).closest('div');
expect(emptyState).not.toBeNull();
expect(emptyState?.className).toContain('dark:bg-slate-950/60');
});
});

View File

@@ -1,105 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Header from '../Header';
vi.mock('../settings-sheet', () => ({
SettingsSheet: () => <div data-testid="settings-sheet" />,
}));
vi.mock('@/components/appearance-dropdown', () => ({
default: () => <div data-testid="appearance-toggle" />,
}));
vi.mock('../../hooks/useEventData', () => ({
useEventData: () => ({
status: 'ready',
event: {
name: 'Demo Event',
type: { icon: 'heart' },
engagement_mode: 'photo_only',
},
}),
}));
vi.mock('../../context/EventStatsContext', () => ({
useOptionalEventStats: () => null,
}));
vi.mock('../../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => null,
}));
vi.mock('../../context/NotificationCenterContext', () => ({
useOptionalNotificationCenter: () => ({
notifications: [],
unreadCount: 0,
queueItems: [],
queueCount: 0,
pendingCount: 0,
loading: false,
pendingLoading: false,
refresh: vi.fn(),
setFilters: vi.fn(),
markAsRead: vi.fn(),
dismiss: vi.fn(),
eventToken: 'demo',
lastFetchedAt: null,
isOffline: false,
}),
}));
vi.mock('../../hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({
hydrated: false,
completedCount: 0,
}),
TASK_BADGE_TARGET: 10,
}));
vi.mock('../../hooks/usePushSubscription', () => ({
usePushSubscription: () => ({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null,
enable: vi.fn(),
disable: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string | { defaultValue?: string }) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return _key;
},
}),
}));
describe('Header notifications toggle', () => {
it('closes the panel when clicking the bell again', () => {
render(
<MemoryRouter>
<Header eventToken="demo" title="Demo" />
</MemoryRouter>,
);
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton);
expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton);
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PullToRefresh from '../PullToRefresh';
describe('PullToRefresh', () => {
it('renders children and labels', () => {
render(
<PullToRefresh
onRefresh={vi.fn()}
pullLabel="Pull"
releaseLabel="Release"
refreshingLabel="Refreshing"
>
<div>Content</div>
</PullToRefresh>
);
expect(screen.getByText('Content')).toBeInTheDocument();
expect(screen.getByText('Pull')).toBeInTheDocument();
});
});

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getTabKey, getTransitionKind, isTransitionDisabled } from '../RouteTransition';
describe('RouteTransition helpers', () => {
it('detects top-level tabs', () => {
expect(getTabKey('/e/demo')).toBe('home');
expect(getTabKey('/e/demo/tasks')).toBe('tasks');
expect(getTabKey('/e/demo/achievements')).toBe('achievements');
expect(getTabKey('/e/demo/gallery')).toBe('gallery');
expect(getTabKey('/e/demo/tasks/123')).toBeNull();
});
it('detects tab vs stack transitions', () => {
expect(getTransitionKind('/e/demo', '/e/demo/gallery')).toBe('tab');
expect(getTransitionKind('/e/demo/tasks', '/e/demo/tasks/1')).toBe('stack');
});
it('disables transitions for excluded routes', () => {
expect(isTransitionDisabled('/e/demo/upload')).toBe(true);
expect(isTransitionDisabled('/share/demo-photo')).toBe(true);
expect(isTransitionDisabled('/e/demo/gallery')).toBe(false);
});
});

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { LocaleProvider } from '../../i18n/LocaleContext';
import { ConsentProvider } from '../../../contexts/consent';
import { SettingsSheet } from '../settings-sheet';
describe('SettingsSheet language section', () => {
it('does not render active badge or description text', () => {
render(
<MemoryRouter>
<ConsentProvider>
<LocaleProvider>
<SettingsSheet />
</LocaleProvider>
</ConsentProvider>
</MemoryRouter>
);
fireEvent.click(screen.getByRole('button', { name: 'Einstellungen öffnen' }));
expect(screen.getByText('Sprache')).toBeInTheDocument();
expect(screen.queryByText('Wähle deine bevorzugte Sprache für diese Veranstaltung.')).not.toBeInTheDocument();
expect(screen.queryByText('aktiv')).not.toBeInTheDocument();
});
});

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { ToastProvider, useToast } from '../ToastHost';
function ToastTestHarness({ onAction }: { onAction: () => void }) {
const toast = useToast();
React.useEffect(() => {
toast.push({
text: 'Update ready',
type: 'info',
durationMs: 0,
action: {
label: 'Reload',
onClick: onAction,
},
});
}, [toast, onAction]);
return null;
}
describe('ToastHost', () => {
it('renders action toasts and dismisses after action click', async () => {
const onAction = vi.fn();
render(
<ToastProvider>
<ToastTestHarness onAction={onAction} />
</ToastProvider>
);
expect(screen.getByText('Update ready')).toBeInTheDocument();
const button = screen.getByRole('button', { name: 'Reload' });
fireEvent.click(button);
expect(onAction).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Update ready')).not.toBeInTheDocument();
});
});

View File

@@ -1,555 +0,0 @@
import React from "react";
import { Link, useLocation, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
SheetDescription,
SheetFooter,
} from '@/components/ui/sheet';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { LegalMarkdown } from './legal-markdown';
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages';
import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics';
import { getHelpSlugForPathname } from '../lib/helpRouting';
import { useConsent } from '@/contexts/consent';
const legalPages = [
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
{ slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
{ slug: 'agb', translationKey: 'settings.legal.section.terms' },
] as const;
type ViewState =
| { mode: 'home' }
| {
mode: 'legal';
slug: (typeof legalPages)[number]['slug'];
translationKey: (typeof legalPages)[number]['translationKey'];
};
type LegalDocumentState =
| { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; markdown: string; html: string };
type NameStatus = 'idle' | 'saved';
export function SettingsSheet() {
const [open, setOpen] = React.useState(false);
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const identity = useOptionalGuestIdentity();
const localeContext = useLocale();
const { t } = useTranslation();
const params = useParams<{ token?: string }>();
const location = useLocation();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
const helpSlug = getHelpSlugForPathname(location.pathname);
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
React.useEffect(() => {
if (open && identity?.hydrated) {
setNameDraft(identity.name ?? '');
setNameStatus('idle');
}
}, [open, identity?.hydrated, identity?.name]);
const handleBack = React.useCallback(() => {
setView({ mode: 'home' });
}, []);
const handleOpenLegal = React.useCallback(
(
slug: (typeof legalPages)[number]['slug'],
translationKey: (typeof legalPages)[number]['translationKey'],
) => {
setView({ mode: 'legal', slug, translationKey });
},
[],
);
const handleOpenChange = React.useCallback((next: boolean) => {
setOpen(next);
if (!next) {
setView({ mode: 'home' });
setNameStatus('idle');
}
}, []);
const canSaveName = Boolean(
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
);
const handleSaveName = React.useCallback(() => {
if (!identity || !canSaveName) {
return;
}
setSavingName(true);
try {
identity.setName(nameDraft);
setNameStatus('saved');
window.setTimeout(() => setNameStatus('idle'), 2000);
} finally {
setSavingName(false);
}
}, [identity, nameDraft, canSaveName]);
const handleResetName = React.useCallback(() => {
if (!identity) return;
identity.clearName();
setNameDraft('');
setNameStatus('idle');
}, [identity]);
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15"
>
<Settings className="h-5 w-5" />
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="sm:max-w-md">
<div className="flex h-full flex-col">
<header className="border-b bg-background px-6 py-4">
{isLegal ? (
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={handleBack}
>
<ArrowLeft className="h-5 w-5" />
<span className="sr-only">{t('settings.sheet.backLabel')}</span>
</Button>
<div className="min-w-0">
<SheetTitle className="truncate">
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: t(view.translationKey)}
</SheetTitle>
<SheetDescription>
{legalDocument.phase === 'loading'
? t('common.actions.loading')
: t('settings.sheet.legalDescription')}
</SheetDescription>
</div>
</div>
) : (
<div>
<SheetTitle>{t('settings.title')}</SheetTitle>
<SheetDescription>{t('settings.subtitle')}</SheetDescription>
</div>
)}
</header>
<main className="flex-1 overflow-y-auto px-6 py-4">
{isLegal ? (
<LegalView
document={legalDocument}
onClose={() => handleOpenChange(false)}
translationKey={view.mode === 'legal' ? view.translationKey : null}
/>
) : (
<HomeView
identity={identity}
nameDraft={nameDraft}
onNameChange={setNameDraft}
onSaveName={handleSaveName}
onResetName={handleResetName}
canSaveName={canSaveName}
savingName={savingName}
nameStatus={nameStatus}
localeContext={localeContext}
onOpenLegal={handleOpenLegal}
helpHref={helpHref}
/>
)}
</main>
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
<div>{t('settings.footer.notice')}</div>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
);
}
function LegalView({
document,
onClose,
translationKey,
}: {
document: LegalDocumentState;
onClose: () => void;
translationKey: string | null;
}) {
const { t } = useTranslation();
if (document.phase === 'error') {
return (
<div className="space-y-4">
<Alert variant="destructive">
<AlertDescription>
{t('settings.legal.error')}
</AlertDescription>
</Alert>
<Button variant="secondary" onClick={onClose}>
{t('common.actions.close')}
</Button>
</div>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
</CardHeader>
<CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
<LegalMarkdown markdown={document.markdown} html={document.html} />
</CardContent>
</Card>
</div>
);
}
interface HomeViewProps {
identity: ReturnType<typeof useOptionalGuestIdentity>;
nameDraft: string;
onNameChange: (value: string) => void;
onSaveName: () => void;
onResetName: () => void;
canSaveName: boolean;
savingName: boolean;
nameStatus: NameStatus;
localeContext: LocaleContextValue;
onOpenLegal: (
slug: (typeof legalPages)[number]['slug'],
translationKey: (typeof legalPages)[number]['translationKey'],
) => void;
helpHref: string;
}
function HomeView({
identity,
nameDraft,
onNameChange,
onSaveName,
onResetName,
canSaveName,
savingName,
nameStatus,
localeContext,
onOpenLegal,
helpHref,
}: HomeViewProps) {
const { t } = useTranslation();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
const { preferences, savePreferences } = useConsent();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const legalLinks = React.useMemo(
() =>
legalPages.map((page) => ({
slug: page.slug,
translationKey: page.translationKey,
label: t(page.translationKey),
})),
[t],
);
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.language.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
{localeContext.availableLocales.map((option) => {
const isActive = localeContext.locale === option.code;
return (
<Button
key={option.code}
type="button"
variant={isActive ? 'default' : 'outline'}
className={`flex h-12 flex-col justify-center gap-1 rounded-lg border text-sm ${
isActive ? 'bg-pink-500 text-white hover:bg-pink-600' : 'bg-background'
}`}
onClick={() => localeContext.setLocale(option.code)}
aria-pressed={isActive}
disabled={!localeContext.hydrated}
>
<span aria-hidden className="text-lg leading-none">{option.flag}</span>
<span className="font-medium">{t(`settings.language.option.${option.code}`)}</span>
</Button>
);
})}
</div>
</CardContent>
</Card>
{identity && (
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.name.title')}</CardTitle>
<CardDescription>{t('settings.name.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
<UserCircle className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="guest-name" className="text-sm font-medium">
{t('settings.name.label')}
</Label>
<Input
id="guest-name"
value={nameDraft}
placeholder={t('settings.name.placeholder')}
onChange={(event) => onNameChange(event.target.value)}
autoComplete="name"
disabled={!identity.hydrated || savingName}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
{savingName ? t('settings.name.saving') : t('settings.name.save')}
</Button>
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
{t('settings.name.reset')}
</Button>
{nameStatus === 'saved' && (
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
)}
{!identity.hydrated && (
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
)}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.haptics.title')}</CardTitle>
<CardDescription>{t('settings.haptics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.haptics.label')}</span>
<Switch
checked={hapticsEnabled}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
disabled={!hapticsSupported}
aria-label={t('settings.haptics.label')}
/>
</div>
{!hapticsSupported && (
<div className="text-xs text-muted-foreground">{t('settings.haptics.unsupported')}</div>
)}
</CardContent>
</Card>
{matomoEnabled ? (
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.analytics.title')}</CardTitle>
<CardDescription>{t('settings.analytics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.analytics.label')}</span>
<Switch
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
aria-label={t('settings.analytics.label')}
/>
</div>
<div className="text-xs text-muted-foreground">{t('settings.analytics.note')}</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-pink-500" />
{t('settings.legal.title')}
</div>
</CardTitle>
<CardDescription>{t('settings.legal.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{legalLinks.map((page) => (
<Button
key={page.slug}
variant="ghost"
className="w-full justify-between px-3"
onClick={() => onOpenLegal(page.slug, page.translationKey)}
>
<span className="text-left text-sm">{page.label}</span>
<ChevronRight className="h-4 w-4" />
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
<div className="flex items-center gap-2">
<LifeBuoy className="h-4 w-4 text-pink-500" />
{t('settings.help.title')}
</div>
</CardTitle>
<CardDescription>{t('settings.help.description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link to={helpHref}>{t('settings.help.cta')}</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('settings.cache.title')}</CardTitle>
<CardDescription>{t('settings.cache.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ClearCacheButton />
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
<span>{t('settings.cache.note')}</span>
</div>
</CardContent>
</Card>
</div>
);
}
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle',
title: '',
markdown: '',
html: '',
});
React.useEffect(() => {
if (!slug) {
setState({ phase: 'idle', title: '', markdown: '', html: '' });
return;
}
const controller = new AbortController();
setState({ phase: 'loading', title: '', markdown: '', html: '' });
const langParam = encodeURIComponent(locale);
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
throw new Error('failed');
}
const payload = await res.json();
setState({
phase: 'ready',
title: payload.title ?? '',
markdown: payload.body_markdown ?? '',
html: payload.body_html ?? '',
});
})
.catch((error) => {
if (controller.signal.aborted) {
return;
}
console.error('Failed to load legal page', error);
setState({ phase: 'error', title: '', markdown: '', html: '' });
});
return () => controller.abort();
}, [slug, locale]);
return state;
}
function ClearCacheButton() {
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const { t } = useTranslation();
async function clearAll() {
setBusy(true);
setDone(false);
try {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
if ('indexedDB' in window) {
try {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase('upload-queue');
request.onsuccess = () => resolve(null);
request.onerror = () => resolve(null);
});
} catch (error) {
console.warn('IndexedDB cleanup failed', error);
}
}
setDone(true);
} finally {
setBusy(false);
window.setTimeout(() => setDone(false), 2500);
}
}
return (
<div className="space-y-2">
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
</Button>
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
</div>
);
}

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { usePollStats } from '../polling/usePollStats';
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
eventKey: string;
slug: string;
};
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const stats = usePollStats(eventKey);
const value = React.useMemo<EventStatsContextValue>(
() => ({ eventKey, slug: eventKey, ...stats }),
[eventKey, stats]
);
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
}
export function useEventStats() {
const ctx = React.useContext(EventStatsContext);
if (!ctx) {
throw new Error('useEventStats must be used within an EventStatsProvider');
}
return ctx;
}
export function useOptionalEventStats() {
return React.useContext(EventStatsContext);
}

View File

@@ -1,111 +0,0 @@
import React from 'react';
type GuestIdentityContextValue = {
eventKey: string;
slug: string; // backward-compatible alias
name: string;
hydrated: boolean;
setName: (nextName: string) => void;
clearName: () => void;
reload: () => void;
};
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
function storageKey(eventKey: string) {
return `guestName_${eventKey}`;
}
export function readGuestName(eventKey: string) {
if (!eventKey || typeof window === 'undefined') {
return '';
}
try {
return window.localStorage.getItem(storageKey(eventKey)) ?? '';
} catch (error) {
console.warn('Failed to read guest name', error);
return '';
}
}
export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const [name, setNameState] = React.useState('');
const [hydrated, setHydrated] = React.useState(false);
const loadFromStorage = React.useCallback(() => {
if (!eventKey) {
setHydrated(true);
setNameState('');
return;
}
try {
const stored = window.localStorage.getItem(storageKey(eventKey));
setNameState(stored ?? '');
} catch (error) {
console.warn('Failed to read guest name from storage', error);
setNameState('');
} finally {
setHydrated(true);
}
}, [eventKey]);
React.useEffect(() => {
setHydrated(false);
loadFromStorage();
}, [loadFromStorage]);
const persistName = React.useCallback(
(nextName: string) => {
const trimmed = nextName.trim();
setNameState(trimmed);
try {
if (trimmed) {
window.localStorage.setItem(storageKey(eventKey), trimmed);
} else {
window.localStorage.removeItem(storageKey(eventKey));
}
} catch (error) {
console.warn('Failed to persist guest name', error);
}
},
[eventKey]
);
const clearName = React.useCallback(() => {
setNameState('');
try {
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear guest name', error);
}
}, [eventKey]);
const value = React.useMemo<GuestIdentityContextValue>(
() => ({
eventKey,
slug: eventKey,
name,
hydrated,
setName: persistName,
clearName,
reload: loadFromStorage,
}),
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
}
export function useGuestIdentity() {
const ctx = React.useContext(GuestIdentityContext);
if (!ctx) {
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
}
return ctx;
}
export function useOptionalGuestIdentity() {
return React.useContext(GuestIdentityContext);
}

View File

@@ -1,96 +0,0 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { EventBrandingProvider } from '../EventBrandingContext';
import { AppearanceProvider } from '@/hooks/use-appearance';
import type { EventBranding } from '../../types/event-branding';
const sampleBranding: EventBranding = {
primaryColor: '#ff3366',
secondaryColor: '#ff99aa',
backgroundColor: '#fef2f2',
fontFamily: 'Montserrat, sans-serif',
logoUrl: null,
typography: {
heading: null,
body: null,
sizePreset: 'l',
},
mode: 'dark',
};
describe('EventBrandingProvider', () => {
afterEach(() => {
document.documentElement.classList.remove('guest-theme', 'dark');
document.documentElement.style.removeProperty('color-scheme');
document.documentElement.style.removeProperty('--guest-background');
document.documentElement.style.removeProperty('--guest-font-scale');
localStorage.removeItem('theme');
});
it('applies guest theme classes and variables', async () => {
const { unmount } = render(
<EventBrandingProvider branding={sampleBranding}>
<div>Guest</div>
</EventBrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#0f172a');
expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08');
expect(document.documentElement.style.getPropertyValue('--foreground')).toBe('#f8fafc');
});
unmount();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
it('respects appearance override in auto mode', async () => {
localStorage.setItem('theme', 'dark');
const autoBranding: EventBranding = {
...sampleBranding,
mode: 'auto',
backgroundColor: '#fff7ed',
};
const { unmount } = render(
<AppearanceProvider>
<EventBrandingProvider branding={autoBranding}>
<div>Guest</div>
</EventBrandingProvider>
</AppearanceProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
unmount();
});
it('prefers explicit appearance over branding mode', async () => {
localStorage.setItem('theme', 'light');
const darkBranding: EventBranding = {
...sampleBranding,
mode: 'dark',
backgroundColor: '#0f172a',
};
const { unmount } = render(
<AppearanceProvider>
<EventBrandingProvider branding={darkBranding}>
<div>Guest</div>
</EventBrandingProvider>
</AppearanceProvider>
);
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
unmount();
});
});

View File

@@ -1,220 +0,0 @@
import { demoFixtures, type DemoFixtures } from './fixtures';
type DemoConfig = {
fixtures: DemoFixtures;
};
let enabled = false;
let originalFetch: typeof window.fetch | null = null;
const likeState = new Map<number, number>();
declare global {
interface Window {
__FOTOSPIEL_DEMO__?: boolean;
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
}
}
export function shouldEnableGuestDemoMode(): boolean {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
if (params.get('demo') === '1') {
return true;
}
if (window.__FOTOSPIEL_DEMO__ === true) {
return true;
}
const attr = document.documentElement?.dataset?.guestDemo;
return attr === 'true';
}
export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixtures }): void {
if (typeof window === 'undefined' || enabled) {
return;
}
originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const url = new URL(request.url, window.location.origin);
const response = handleDemoRequest(url, request, config.fixtures);
if (response) {
return response;
}
return originalFetch!(request);
};
enabled = true;
window.__FOTOSPIEL_DEMO_ACTIVE__ = true;
notifyDemoToast();
}
function handleDemoRequest(url: URL, request: Request, fixtures: DemoFixtures): Promise<Response> | null {
if (!url.pathname.startsWith('/api/')) {
return null;
}
const eventMatch = url.pathname.match(/^\/api\/v1\/events\/([^/]+)(?:\/(.*))?/);
if (eventMatch) {
const token = decodeURIComponent(eventMatch[1]);
const remainder = eventMatch[2] ?? '';
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
return null;
}
return Promise.resolve(handleDemoEventEndpoint(remainder, request, fixtures));
}
const galleryMatch = url.pathname.match(/^\/api\/v1\/gallery\/([^/]+)(?:\/(.*))?/);
if (galleryMatch) {
const token = decodeURIComponent(galleryMatch[1]);
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
return null;
}
const resource = galleryMatch[2] ?? '';
if (!resource) {
return Promise.resolve(jsonResponse(fixtures.gallery.meta));
}
if (resource.startsWith('photos')) {
return Promise.resolve(
jsonResponse({ data: fixtures.gallery.photos, next_cursor: null }, { etag: '"demo-gallery"' })
);
}
}
if (url.pathname.startsWith('/api/v1/photo-shares/')) {
return Promise.resolve(jsonResponse(fixtures.share));
}
if (url.pathname.startsWith('/api/v1/photos/')) {
return Promise.resolve(handlePhotoAction(url, request, fixtures));
}
return null;
}
function handleDemoEventEndpoint(path: string, request: Request, fixtures: DemoFixtures): Response {
const [resource, ...rest] = path.split('/').filter(Boolean);
const method = request.method.toUpperCase();
switch (resource) {
case undefined:
return jsonResponse(fixtures.event);
case 'stats':
return jsonResponse(fixtures.stats);
case 'package':
return jsonResponse(fixtures.eventPackage);
case 'tasks':
if (method === 'GET') {
return jsonResponse(fixtures.tasks, { etag: '"demo-tasks"' });
}
return blockedResponse('Aufgaben können in der Demo nicht geändert werden.');
case 'photos':
if (method === 'GET') {
return jsonResponse({ data: fixtures.photos, latest_photo_at: fixtures.photos[0]?.created_at ?? null }, {
etag: '"demo-photos"',
});
}
if (method === 'POST') {
return blockedResponse('Uploads sind in der Demo deaktiviert.');
}
break;
case 'upload':
return blockedResponse('Uploads sind in der Demo deaktiviert.');
case 'achievements':
return jsonResponse(fixtures.achievements, { etag: '"demo-achievements"' });
case 'emotions':
return jsonResponse(fixtures.emotions, { etag: '"demo-emotions"' });
case 'notifications':
if (rest.length >= 2) {
return new Response(null, { status: 204 });
}
return jsonResponse({ data: fixtures.notifications, meta: { unread_count: 1 } }, { etag: '"demo-notifications"' });
case 'push-subscriptions':
return new Response(null, { status: 204 });
default:
break;
}
return jsonResponse({ demo: true });
}
function handlePhotoAction(url: URL, request: Request, fixtures: DemoFixtures): Response {
const pathname = url.pathname.replace('/api/v1/photos/', '');
const [photoIdPart, action] = pathname.split('/');
const photoId = Number(photoIdPart);
const targetPhoto = fixtures.photos.find((photo) => photo.id === photoId);
if (action === 'like') {
if (!targetPhoto) {
return new Response(JSON.stringify({ error: { message: 'Foto nicht gefunden' } }), { status: 404, headers: demoHeaders() });
}
const current = likeState.get(photoId) ?? targetPhoto.likes_count;
const next = current + 1;
likeState.set(photoId, next);
return jsonResponse({ likes_count: next });
}
if (action === 'share' && request.method.toUpperCase() === 'POST') {
return jsonResponse({ slug: fixtures.share.slug, url: `${window.location.origin}/share/${fixtures.share.slug}` });
}
return new Response(JSON.stringify({ error: { message: 'Demo-Endpunkt nicht verfügbar.' } }), {
status: 404,
headers: demoHeaders(),
});
}
function jsonResponse(data: unknown, options: { etag?: string } = {}): Response {
const headers = demoHeaders();
if (options.etag) {
headers.ETag = options.etag;
}
return new Response(JSON.stringify(data), {
status: 200,
headers,
});
}
function demoHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
};
}
function blockedResponse(message: string): Response {
return new Response(
JSON.stringify({
error: {
code: 'demo_read_only',
message,
},
}),
{
status: 403,
headers: demoHeaders(),
}
);
}
export function isGuestDemoModeEnabled(): boolean {
return enabled;
}
function notifyDemoToast(): void {
if (typeof document === 'undefined') {
return;
}
try {
const detail = { type: 'info', text: 'Demo-Modus aktiv. Änderungen werden nicht gespeichert.' };
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('guest-toast', { detail }));
}, 0);
} catch {
// ignore
}
}

View File

@@ -1,380 +0,0 @@
import type { AchievementsPayload } from '../services/achievementApi';
import type { EventData, EventPackage, EventStats } from '../services/eventApi';
import type { GalleryMetaResponse, GalleryPhotoResource } from '../services/galleryApi';
export type DemoTask = {
id: number;
title: string;
description: string;
duration?: number;
emotion?: {
slug: string;
name: string;
emoji?: string;
} | null;
category?: string | null;
};
export type DemoPhoto = {
id: number;
url: string;
thumbnail_url: string;
created_at: string;
uploader_name: string;
likes_count: number;
task_id?: number | null;
task_title?: string | null;
ingest_source?: string | null;
};
export type DemoEmotion = {
id: number;
slug: string;
name: string;
emoji: string;
description?: string;
};
export type DemoNotification = {
id: number;
type: string;
title: string;
body: string;
status: 'new' | 'read' | 'dismissed';
created_at: string;
cta?: { label: string; href: string } | null;
};
export type DemoSharePayload = {
slug: string;
expires_at?: string;
photo: {
id: number;
title: string;
likes_count: number;
emotion?: { name: string; emoji: string } | null;
image_urls: { full: string; thumbnail: string };
};
event?: { id: number; name: string } | null;
};
export interface DemoFixtures {
token: string;
event: EventData;
stats: EventStats;
eventPackage: EventPackage;
tasks: DemoTask[];
photos: DemoPhoto[];
gallery: {
meta: GalleryMetaResponse;
photos: GalleryPhotoResource[];
};
achievements: AchievementsPayload;
emotions: DemoEmotion[];
notifications: DemoNotification[];
share: DemoSharePayload;
}
const now = () => new Date().toISOString();
export const demoFixtures: DemoFixtures = {
token: 'demo',
event: {
id: 999,
slug: 'demo-wedding-2025',
name: 'Demo Wedding 2025',
default_locale: 'de',
created_at: '2025-01-10T12:00:00Z',
updated_at: now(),
branding: {
primary_color: '#FF6B6B',
secondary_color: '#FEB47B',
background_color: '#FFF7F5',
font_family: '"General Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
logo_url: null,
},
join_token: 'demo',
type: {
slug: 'wedding',
name: 'Hochzeit',
icon: 'sparkles',
},
},
stats: {
onlineGuests: 42,
tasksSolved: 187,
guestCount: 128,
likesCount: 980,
latestPhotoAt: now(),
},
eventPackage: {
id: 501,
event_id: 999,
package_id: 301,
used_photos: 820,
used_guests: 95,
expires_at: '2025-12-31T23:59:59Z',
package: {
id: 301,
name: 'Soulmate Unlimited',
max_photos: 5000,
max_guests: 250,
gallery_days: 365,
},
limits: {
photos: {
limit: 5000,
used: 820,
remaining: 4180,
percentage: 0.164,
state: 'ok',
threshold_reached: null,
next_threshold: 0.5,
thresholds: [0.5, 0.8],
},
guests: {
limit: 250,
used: 95,
remaining: 155,
percentage: 0.38,
state: 'ok',
threshold_reached: null,
next_threshold: 0.6,
thresholds: [0.6, 0.9],
},
gallery: {
state: 'ok',
expires_at: '2025-12-31T23:59:59Z',
days_remaining: 320,
warning_thresholds: [30, 7],
warning_triggered: null,
warning_sent_at: null,
expired_notified_at: null,
},
can_upload_photos: true,
can_add_guests: true,
},
},
tasks: [
{
id: 101,
title: 'Der erste Blick',
description: 'Haltet den Moment fest, wenn sich das Paar zum ersten Mal sieht.',
duration: 4,
emotion: { slug: 'romance', name: 'Romantik', emoji: '💞' },
},
{
id: 102,
title: 'Dancefloor Close-Up',
description: 'Zoomt auf Hände, Schuhe oder Accessoires, die auf der Tanzfläche glänzen.',
duration: 3,
emotion: { slug: 'party', name: 'Party', emoji: '🎉' },
},
{
id: 103,
title: 'Tischgespräche',
description: 'Fotografiert zwei Personen, die heimlich lachen.',
duration: 2,
emotion: { slug: 'fun', name: 'Spaß', emoji: '😄' },
},
{
id: 104,
title: 'Team Selfie',
description: 'Mindestens fünf Gäste auf einem Selfie Bonus für wilde Posen.',
duration: 5,
emotion: { slug: 'squad', name: 'Squad Goals', emoji: '🤳' },
},
],
photos: [
{
id: 8801,
url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T18:45:00Z',
uploader_name: 'Lena',
likes_count: 24,
task_id: 101,
task_title: 'Der erste Blick',
ingest_source: 'guest',
},
{
id: 8802,
url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T19:12:00Z',
uploader_name: 'Nico',
likes_count: 31,
task_id: 102,
task_title: 'Dancefloor Close-Up',
ingest_source: 'guest',
},
{
id: 8803,
url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T19:40:00Z',
uploader_name: 'Aylin',
likes_count: 18,
task_id: 103,
task_title: 'Tischgespräche',
ingest_source: 'guest',
},
{
id: 8804,
url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1600&q=80',
thumbnail_url: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=600&q=60',
created_at: '2025-05-10T20:05:00Z',
uploader_name: 'Mara',
likes_count: 42,
task_id: 104,
task_title: 'Team Selfie',
ingest_source: 'guest',
},
],
gallery: {
meta: {
event: {
id: 999,
name: 'Demo Wedding 2025',
slug: 'demo-wedding-2025',
description: 'Erlebe die Story eines Demo-Events Fotos, Aufgaben und Emotionen live in der PWA.',
gallery_expires_at: '2025-12-31T23:59:59Z',
},
branding: {
primary_color: '#FF6B6B',
secondary_color: '#FEB47B',
background_color: '#FFF7F5',
},
},
photos: [
{
id: 9001,
thumbnail_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=400&q=60',
full_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
download_url: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
likes_count: 18,
guest_name: 'Leonie',
created_at: '2025-05-10T18:40:00Z',
},
{
id: 9002,
thumbnail_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=400&q=60',
full_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
download_url: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=1600&q=80',
likes_count: 25,
guest_name: 'Chris',
created_at: '2025-05-10T19:10:00Z',
},
],
},
achievements: {
summary: {
totalPhotos: 820,
uniqueGuests: 96,
tasksSolved: 312,
likesTotal: 2100,
},
personal: {
guestName: 'Demo Gast',
photos: 12,
tasks: 5,
likes: 38,
badges: [
{ id: 'starter', title: 'Warm-up', description: 'Deine ersten 3 Fotos', earned: true, progress: 3, target: 3 },
{ id: 'mission', title: 'Mission Master', description: '5 Aufgaben geschafft', earned: true, progress: 5, target: 5 },
{ id: 'marathon', title: 'Galerie-Profi', description: '50 Fotos hochladen', earned: false, progress: 12, target: 50 },
],
},
leaderboards: {
uploads: [
{ guest: 'Sven', photos: 35, likes: 120 },
{ guest: 'Lena', photos: 28, likes: 140 },
{ guest: 'Demo Gast', photos: 12, likes: 38 },
],
likes: [
{ guest: 'Mara', photos: 18, likes: 160 },
{ guest: 'Noah', photos: 22, likes: 150 },
{ guest: 'Sven', photos: 35, likes: 120 },
],
},
highlights: {
topPhoto: {
photoId: 8802,
guest: 'Nico',
likes: 31,
task: 'Dancefloor Close-Up',
createdAt: '2025-05-10T19:12:00Z',
thumbnail: 'https://images.unsplash.com/photo-1502727135889-a63a201a02f9?auto=format&fit=crop&w=600&q=60',
},
trendingEmotion: {
emotionId: 4,
name: 'Party',
count: 58,
},
timeline: [
{ date: '2025-05-08', photos: 120, guests: 25 },
{ date: '2025-05-09', photos: 240, guests: 40 },
{ date: '2025-05-10', photos: 460, guests: 55 },
],
},
feed: [
{
photoId: 8804,
guest: 'Mara',
task: 'Team Selfie',
likes: 42,
createdAt: '2025-05-10T20:05:00Z',
thumbnail: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=60',
},
{
photoId: 8803,
guest: 'Aylin',
task: 'Tischgespräche',
likes: 18,
createdAt: '2025-05-10T19:40:00Z',
thumbnail: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=400&q=60',
},
],
},
emotions: [
{ id: 1, slug: 'romance', name: 'Romantik', emoji: '💞', description: 'Samtweiche Szenen & verliebte Blicke' },
{ id: 2, slug: 'party', name: 'Party', emoji: '🎉', description: 'Alles, was knallt und funkelt' },
{ id: 3, slug: 'calm', name: 'Ruhepause', emoji: '🌙', description: 'Leise Momente zum Durchatmen' },
{ id: 4, slug: 'squad', name: 'Squad Goals', emoji: '🤳', description: 'Teams, Crews und wilde Selfies' },
],
notifications: [
{
id: 1,
type: 'broadcast',
title: 'Mission-Alarm',
body: 'Neue Spotlight-Aufgabe verfügbar: „Dancefloor Close-Up“. Schau gleich vorbei!'
+ ' ',
status: 'new',
created_at: now(),
cta: { label: 'Zur Aufgabe', href: '/e/demo/tasks' },
},
{
id: 2,
type: 'broadcast',
title: 'Galerie wächst',
body: '18 neue Uploads in den letzten 30 Minuten helft mit beim Kuratieren!',
status: 'read',
created_at: '2025-05-10T19:50:00Z',
},
],
share: {
slug: 'demo-share',
expires_at: undefined,
photo: {
id: 8801,
title: 'First Look',
likes_count: 24,
emotion: { name: 'Romantik', emoji: '💞' },
image_urls: {
full: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=1600&q=80',
thumbnail: 'https://images.unsplash.com/photo-1520854223477-5e2c1a6610f0?auto=format&fit=crop&w=600&q=60',
},
},
event: { id: 999, name: 'Demo Wedding 2025' },
},
};

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { useHapticsPreference } from '../useHapticsPreference';
import { HAPTICS_STORAGE_KEY } from '../../lib/haptics';
function TestHarness() {
const { enabled, setEnabled } = useHapticsPreference();
return (
<button
type="button"
data-testid="toggle"
onClick={() => setEnabled(!enabled)}
>
{enabled ? 'on' : 'off'}
</button>
);
}
describe('useHapticsPreference', () => {
beforeEach(() => {
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vi.fn(),
});
});
it('toggles and persists preference', () => {
render(<TestHarness />);
const button = screen.getByTestId('toggle');
expect(button).toHaveTextContent('on');
fireEvent.click(button);
expect(button).toHaveTextContent('off');
expect(window.localStorage.getItem(HAPTICS_STORAGE_KEY)).toBe('0');
});
});

View File

@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildFramePhotos,
resolveIntervalMs,
resolveItemsPerFrame,
resolvePlaybackQueue,
} from '../useLiveShowPlayback';
import type { LiveShowPhoto, LiveShowSettings } from '../../services/liveShowApi';
const baseSettings: LiveShowSettings = {
retention_window_hours: 12,
moderation_mode: 'manual',
playback_mode: 'newest_first',
pace_mode: 'auto',
fixed_interval_seconds: 8,
layout_mode: 'single',
effect_preset: 'film_cut',
effect_intensity: 70,
background_mode: 'blur_last',
};
const photos: LiveShowPhoto[] = [
{
id: 1,
full_url: '/one.jpg',
thumb_url: '/one-thumb.jpg',
approved_at: '2025-01-01T10:00:00Z',
is_featured: false,
live_priority: 0,
},
{
id: 2,
full_url: '/two.jpg',
thumb_url: '/two-thumb.jpg',
approved_at: '2025-01-01T12:00:00Z',
is_featured: true,
live_priority: 2,
},
{
id: 3,
full_url: '/three.jpg',
thumb_url: '/three-thumb.jpg',
approved_at: '2025-01-01T11:00:00Z',
is_featured: false,
live_priority: 0,
},
];
describe('useLiveShowPlayback helpers', () => {
it('resolves items per frame per layout', () => {
expect(resolveItemsPerFrame('single')).toBe(1);
expect(resolveItemsPerFrame('split')).toBe(2);
expect(resolveItemsPerFrame('grid_burst')).toBe(4);
});
it('builds a curated queue when configured', () => {
const queue = resolvePlaybackQueue(photos, {
...baseSettings,
playback_mode: 'curated',
});
expect(queue[0].id).toBe(2);
expect(queue.every((photo) => photo.id === 2 || photo.live_priority > 0 || photo.is_featured)).toBe(true);
});
it('builds frame photos without duplicates when list is smaller', () => {
const frame = buildFramePhotos([photos[0]], 0, 4);
expect(frame).toHaveLength(1);
expect(frame[0].id).toBe(1);
});
it('uses fixed interval when configured', () => {
const interval = resolveIntervalMs(
{
...baseSettings,
pace_mode: 'fixed',
fixed_interval_seconds: 12,
},
photos.length
);
expect(interval).toBe(12_000);
});
});

View File

@@ -1,170 +0,0 @@
import { useCallback, useState } from 'react';
import { compressPhoto, formatBytes } from '../lib/image';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
import { notify } from '../queue/notify';
import { useTranslation } from '../i18n/useTranslation';
import { isGuestDemoModeEnabled } from '../demo/demoMode';
import { useEventData } from './useEventData';
import { triggerHaptic } from '../lib/haptics';
type DirectUploadResult = {
success: boolean;
photoId?: number;
warning?: string | null;
error?: string | null;
dialog?: UploadErrorDialog | null;
};
type UseDirectUploadOptions = {
eventToken: string;
taskId?: number | null;
emotionSlug?: string;
onCompleted?: (photoId: number) => void;
};
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
const { name } = useGuestIdentity();
const { markCompleted } = useGuestTaskProgress(eventToken);
const { event } = useEventData();
const { t } = useTranslation();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [canUpload, setCanUpload] = useState(true);
const reset = useCallback(() => {
setProgress(0);
setWarning(null);
setError(null);
setErrorDialog(null);
}, []);
const preparePhoto = useCallback(async (file: File) => {
reset();
let prepared = file;
try {
prepared = await compressPhoto(file, {
maxEdge: 2400,
targetBytes: 4_000_000,
qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
}
} catch (err) {
console.warn('Direct upload: optimization failed, using original', err);
setWarning('Optimierung nicht möglich wir laden das Original hoch.');
}
if (prepared.size > 12_000_000) {
setError('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
return { ok: false as const };
}
return { ok: true as const, prepared };
}, [reset]);
const upload = useCallback(
async (file: File): Promise<DirectUploadResult> => {
if (!canUpload || uploading) return { success: false, warning, error };
if (isGuestDemoModeEnabled() || event?.demo_read_only) {
const demoMessage = t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.');
setError(demoMessage);
setWarning(null);
notify(demoMessage, 'error');
return { success: false, warning, error: demoMessage };
}
const preparedResult = await preparePhoto(file);
if (!preparedResult.ok) {
return { success: false, warning, error };
}
const prepared = preparedResult.prepared;
setUploading(true);
setProgress(2);
setError(null);
setErrorDialog(null);
try {
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
maxRetries: 2,
guestName: name || undefined,
onProgress: (percent) => {
setProgress(Math.max(10, Math.min(98, percent)));
},
onRetry: (attempt) => {
setWarning(`Verbindung holperig neuer Versuch (${attempt}).`);
},
});
setProgress(100);
if (taskId) {
markCompleted(taskId);
}
triggerHaptic('success');
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (persistErr) {
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
}
onCompleted?.(photoId);
return { success: true, photoId, warning };
} catch (err) {
console.error('Direct upload failed', err);
triggerHaptic('error');
const uploadErr = err as UploadError;
const meta = uploadErr.meta as Record<string, unknown> | undefined;
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
setErrorDialog(dialog);
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
setWarning(null);
if (uploadErr.code === 'demo_read_only') {
notify(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'), 'error');
}
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setWarning('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
}
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
} finally {
setUploading(false);
setProgress((p) => (p === 100 ? p : 0));
}
},
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
);
return {
upload,
uploading,
progress,
warning,
error,
errorDialog,
reset,
};
}

View File

@@ -1,101 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
fetchEvent,
EventData,
FetchEventError,
FetchEventErrorCode,
} from '../services/eventApi';
type EventDataStatus = 'loading' | 'ready' | 'error';
interface UseEventDataResult {
event: EventData | null;
status: EventDataStatus;
loading: boolean;
error: string | null;
errorCode: FetchEventErrorCode | null;
token: string | null;
}
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
const eventCache = new Map<string, EventData>();
export function useEventData(): UseEventDataResult {
const { token } = useParams<{ token: string }>();
const cachedEvent = token ? eventCache.get(token) ?? null : null;
const [event, setEvent] = useState<EventData | null>(cachedEvent);
const [status, setStatus] = useState<EventDataStatus>(token ? (cachedEvent ? 'ready' : 'loading') : 'error');
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
useEffect(() => {
if (!token) {
setEvent(null);
setStatus('error');
setErrorCode('invalid_token');
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
return;
}
let cancelled = false;
const loadEvent = async () => {
const cached = eventCache.get(token) ?? null;
if (!cached) {
setStatus('loading');
}
setErrorCode(null);
setErrorMessage(null);
try {
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
eventCache.set(token, eventData);
setEvent(eventData);
setStatus('ready');
} catch (err) {
if (cancelled) {
return;
}
if (cached) {
setEvent(cached);
setStatus('ready');
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setErrorCode(err.code);
setErrorMessage(err.message);
} else if (err instanceof Error) {
setErrorCode('unknown');
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
} else {
setErrorCode('unknown');
setErrorMessage('Event konnte nicht geladen werden.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
return {
event,
status,
loading: status === 'loading',
error: errorMessage,
errorCode,
token: token ?? null,
};
}

View File

@@ -1,168 +0,0 @@
import React from 'react';
import { getPushConfig } from '../lib/runtime-config';
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
type PushSubscriptionState = {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
refresh: () => Promise<void>;
};
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
const pushConfig = React.useMemo(() => getPushConfig(), []);
const supported = React.useMemo(() => {
return typeof window !== 'undefined'
&& typeof navigator !== 'undefined'
&& typeof Notification !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& pushConfig.enabled;
}, [pushConfig.enabled]);
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
if (typeof Notification === 'undefined') {
return 'default';
}
return Notification.permission;
});
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!supported || !eventToken) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const current = await registration.pushManager.getSubscription();
setSubscription(current);
} catch (err) {
console.warn('Unable to refresh push subscription', err);
setSubscription(null);
}
}, [eventToken, supported]);
React.useEffect(() => {
if (!supported) {
return;
}
void refresh();
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'push-subscription-change') {
void refresh();
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => {
navigator.serviceWorker?.removeEventListener('message', handleMessage);
};
}, [refresh, supported]);
const enable = React.useCallback(async () => {
if (!supported || !eventToken) {
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
return;
}
setLoading(true);
setError(null);
try {
const permissionResult = await Notification.requestPermission();
setPermission(permissionResult);
if (permissionResult !== 'granted') {
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
}
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await registerPushSubscription(eventToken, existing);
setSubscription(existing);
return;
}
if (!pushConfig.vapidPublicKey) {
throw new Error('Push-Konfiguration ist nicht vollständig.');
}
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
});
await registerPushSubscription(eventToken, newSubscription);
setSubscription(newSubscription);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
setError(message);
console.error(err);
await refresh();
} finally {
setLoading(false);
}
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
const disable = React.useCallback(async () => {
if (!supported || !eventToken || !subscription) {
return;
}
setLoading(true);
setError(null);
try {
await unregisterPushSubscription(eventToken, subscription.endpoint);
await subscription.unsubscribe();
setSubscription(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
setError(message);
console.error(err);
} finally {
setLoading(false);
}
}, [eventToken, subscription, supported]);
return {
supported,
permission,
subscribed: Boolean(subscription),
loading,
error,
enable,
disable,
refresh,
};
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = typeof window !== 'undefined'
? window.atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isUploadPath, shouldShowAnalyticsNudge } from '../analyticsConsent';
describe('isUploadPath', () => {
it('detects upload routes', () => {
expect(isUploadPath('/e/abc/upload')).toBe(true);
expect(isUploadPath('/e/abc/upload/queue')).toBe(true);
});
it('ignores non-upload routes', () => {
expect(isUploadPath('/e/abc/gallery')).toBe(false);
expect(isUploadPath('/settings')).toBe(false);
});
});
describe('shouldShowAnalyticsNudge', () => {
const baseState = {
decisionMade: false,
analyticsConsent: false,
snoozedUntil: null,
now: 1000,
activeSeconds: 60,
routeCount: 2,
thresholdSeconds: 60,
thresholdRoutes: 2,
isUpload: false,
};
it('returns true when thresholds are met', () => {
expect(shouldShowAnalyticsNudge(baseState)).toBe(true);
});
it('returns false when consent decision is made', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, decisionMade: true })).toBe(false);
});
it('returns false when snoozed', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, snoozedUntil: 2000 })).toBe(false);
});
it('returns false on upload routes', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, isUpload: true })).toBe(false);
});
});

View File

@@ -1,52 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { supportsBadging, updateAppBadge } from '../badges';
const originalSet = (navigator as any).setAppBadge;
const originalClear = (navigator as any).clearAppBadge;
const hadSet = 'setAppBadge' in navigator;
const hadClear = 'clearAppBadge' in navigator;
function restoreNavigator() {
if (hadSet) {
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: originalSet });
} else {
delete (navigator as any).setAppBadge;
}
if (hadClear) {
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: originalClear });
} else {
delete (navigator as any).clearAppBadge;
}
}
describe('badges', () => {
afterEach(() => {
restoreNavigator();
});
it('sets the badge count when supported', async () => {
const setAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: setAppBadge });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: vi.fn() });
expect(supportsBadging()).toBe(true);
await updateAppBadge(4);
expect(setAppBadge).toHaveBeenCalledWith(4);
});
it('clears the badge when count is zero', async () => {
const clearAppBadge = vi.fn();
Object.defineProperty(navigator, 'setAppBadge', { configurable: true, value: vi.fn() });
Object.defineProperty(navigator, 'clearAppBadge', { configurable: true, value: clearAppBadge });
await updateAppBadge(0);
expect(clearAppBadge).toHaveBeenCalled();
});
it('no-ops when unsupported', async () => {
delete (navigator as any).setAppBadge;
delete (navigator as any).clearAppBadge;
expect(supportsBadging()).toBe(false);
await updateAppBadge(3);
});
});

View File

@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { shouldCacheResponse } from '../cachePolicy';
describe('shouldCacheResponse', () => {
it('returns false when Cache-Control is no-store', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'no-store' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Cache-Control is private', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'private, max-age=0' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns false when Pragma is no-cache', () => {
const response = new Response('ok', { headers: { Pragma: 'no-cache' } });
expect(shouldCacheResponse(response)).toBe(false);
});
it('returns true for cacheable responses', () => {
const response = new Response('ok', { headers: { 'Cache-Control': 'public, max-age=60' } });
expect(shouldCacheResponse(response)).toBe(true);
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { buildCsrfHeaders } from '../csrf';
describe('buildCsrfHeaders', () => {
beforeEach(() => {
localStorage.setItem('device-id', 'device-123');
});
afterEach(() => {
localStorage.clear();
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
it('reads token from meta tag', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csrf-token');
meta.setAttribute('content', 'meta-token');
document.head.appendChild(meta);
const headers = buildCsrfHeaders('device-xyz');
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
expect(headers['X-Device-Id']).toBe('device-xyz');
});
it('falls back to cookie token', () => {
const raw = btoa('cookie-token');
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
const headers = buildCsrfHeaders();
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-Device-Id']).toBe('device-123');
});
});

View File

@@ -1,13 +0,0 @@
import { shouldShowPhotoboothFilter } from '../galleryFilters';
describe('shouldShowPhotoboothFilter', () => {
it('returns true when photobooth is enabled', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
});
it('returns false when photobooth is disabled or missing', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
expect(shouldShowPhotoboothFilter(null)).toBe(false);
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
});
});

View File

@@ -1,35 +0,0 @@
import { describe, expect, it, afterEach } from 'vitest';
import { applyGuestTheme } from '../guestTheme';
const baseTheme = {
primary: '#ff3366',
secondary: '#ff99aa',
background: '#111111',
surface: '#222222',
mode: 'dark' as const,
};
describe('applyGuestTheme', () => {
afterEach(() => {
const root = document.documentElement;
root.classList.remove('guest-theme', 'dark');
root.style.removeProperty('color-scheme');
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
});
it('applies and restores guest theme settings', () => {
const cleanup = applyGuestTheme(baseTheme);
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(document.documentElement.style.colorScheme).toBe('dark');
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
cleanup();
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More