diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index d142eba5..8a628091 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -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, diff --git a/resources/js/admin/mobile/ProfileAccountPage.tsx b/resources/js/admin/mobile/ProfileAccountPage.tsx index 50612ce9..31cb67fe 100644 --- a/resources/js/admin/mobile/ProfileAccountPage.tsx +++ b/resources/js/admin/mobile/ProfileAccountPage.tsx @@ -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; diff --git a/resources/js/guest-v2/App.tsx b/resources/js/guest-v2/App.tsx index e15018b0..50e725f8 100644 --- a/resources/js/guest-v2/App.tsx +++ b/resources/js/guest-v2/App.tsx @@ -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 ( + ); diff --git a/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx b/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx index f0f4a8ea..2085c9d1 100644 --- a/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx @@ -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 }) =>
{children}
, - XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + YStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, })); vi.mock('@tamagui/text', () => ({ @@ -23,7 +24,7 @@ vi.mock('../components/AppShell', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); -vi.mock('@/guest/components/PullToRefresh', () => ({ +vi.mock('@/shared/guest/components/PullToRefresh', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); @@ -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(); + + 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(); }); }); diff --git a/resources/js/guest-v2/__tests__/BottomDock.test.tsx b/resources/js/guest-v2/__tests__/BottomDock.test.tsx index ed60c145..0c7de93f 100644 --- a/resources/js/guest-v2/__tests__/BottomDock.test.tsx +++ b/resources/js/guest-v2/__tests__/BottomDock.test.tsx @@ -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, }), diff --git a/resources/js/guest-v2/__tests__/EventLayout.test.tsx b/resources/js/guest-v2/__tests__/EventLayout.test.tsx index fd6cc750..a1cc55ad 100644 --- a/resources/js/guest-v2/__tests__/EventLayout.test.tsx +++ b/resources/js/guest-v2/__tests__/EventLayout.test.tsx @@ -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}, })); diff --git a/resources/js/guest-v2/__tests__/EventLogo.test.tsx b/resources/js/guest-v2/__tests__/EventLogo.test.tsx index f96e5b4d..b7237364 100644 --- a/resources/js/guest-v2/__tests__/EventLogo.test.tsx +++ b/resources/js/guest-v2/__tests__/EventLogo.test.tsx @@ -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 }) =>
{children}
, diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx index cfab3b69..7c832f48 100644 --- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -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' }), })); diff --git a/resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx b/resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx index ff956c9b..b2915c68 100644 --- a/resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx @@ -35,19 +35,19 @@ vi.mock('../components/SurfaceCard', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); -vi.mock('@/guest/components/PullToRefresh', () => ({ +vi.mock('@/shared/guest/components/PullToRefresh', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); -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 }], diff --git a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx index 0ed5a81e..b567d995 100644 --- a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx @@ -57,7 +57,7 @@ vi.mock('../components/PhotoFrameTile', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); -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 }), })); diff --git a/resources/js/guest-v2/__tests__/LandingScreen.test.tsx b/resources/js/guest-v2/__tests__/LandingScreen.test.tsx index 0859c3a4..bda1d235 100644 --- a/resources/js/guest-v2/__tests__/LandingScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/LandingScreen.test.tsx @@ -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(), diff --git a/resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx b/resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx index 870f1cf2..bb08f203 100644 --- a/resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx @@ -10,7 +10,7 @@ vi.mock('@tamagui/text', () => ({ SizableText: ({ children }: { children: React.ReactNode }) => {children}, })); -vi.mock('@/guest/i18n/useTranslation', () => ({ +vi.mock('@/shared/guest/i18n/useTranslation', () => ({ useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, }), diff --git a/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx b/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx index b9cd49d7..0dc17061 100644 --- a/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx @@ -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, 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' }), })); diff --git a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx index d63fecd4..da3bf8b2 100644 --- a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx +++ b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx @@ -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, 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 }), })); diff --git a/resources/js/guest-v2/__tests__/SettingsContent.test.tsx b/resources/js/guest-v2/__tests__/SettingsContent.test.tsx index 325f677b..9dd29174 100644 --- a/resources/js/guest-v2/__tests__/SettingsContent.test.tsx +++ b/resources/js/guest-v2/__tests__/SettingsContent.test.tsx @@ -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 }), })); diff --git a/resources/js/guest-v2/__tests__/SettingsSheet.test.tsx b/resources/js/guest-v2/__tests__/SettingsSheet.test.tsx index 57457060..cdac1474 100644 --- a/resources/js/guest-v2/__tests__/SettingsSheet.test.tsx +++ b/resources/js/guest-v2/__tests__/SettingsSheet.test.tsx @@ -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: () =>
Legal markdown
, })); diff --git a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx index 184f3dda..45a732e7 100644 --- a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx @@ -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; diff --git a/resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx b/resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx index eb9160ca..c79521d9 100644 --- a/resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx @@ -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' }), })); diff --git a/resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx b/resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx index bf56f5eb..008d2061 100644 --- a/resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx @@ -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' }), })); diff --git a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx index df206f46..7437beb9 100644 --- a/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx @@ -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, 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' }), })); diff --git a/resources/js/guest-v2/__tests__/UploadScreen.test.tsx b/resources/js/guest-v2/__tests__/UploadScreen.test.tsx index 097503d0..b3f0f4db 100644 --- a/resources/js/guest-v2/__tests__/UploadScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/UploadScreen.test.tsx @@ -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, arg3?: string) => typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? _key) : (arg3 ?? _key), diff --git a/resources/js/guest-v2/__tests__/brandingTheme.test.ts b/resources/js/guest-v2/__tests__/brandingTheme.test.ts index d5b4018e..2132d977 100644 --- a/resources/js/guest-v2/__tests__/brandingTheme.test.ts +++ b/resources/js/guest-v2/__tests__/brandingTheme.test.ts @@ -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', diff --git a/resources/js/guest-v2/components/AppShell.tsx b/resources/js/guest-v2/components/AppShell.tsx index 85e0ada7..62625712 100644 --- a/resources/js/guest-v2/components/AppShell.tsx +++ b/resources/js/guest-v2/components/AppShell.tsx @@ -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 = { diff --git a/resources/js/guest-v2/components/BottomDock.tsx b/resources/js/guest-v2/components/BottomDock.tsx index 8020ca96..ff9419f1 100644 --- a/resources/js/guest-v2/components/BottomDock.tsx +++ b/resources/js/guest-v2/components/BottomDock.tsx @@ -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() { diff --git a/resources/js/guest-v2/components/EventLogo.tsx b/resources/js/guest-v2/components/EventLogo.tsx index a5fcdbbf..d8d3d282 100644 --- a/resources/js/guest-v2/components/EventLogo.tsx +++ b/resources/js/guest-v2/components/EventLogo.tsx @@ -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'; diff --git a/resources/js/guest-v2/components/GuestAnalyticsNudge.tsx b/resources/js/guest-v2/components/GuestAnalyticsNudge.tsx index d5420f7a..ff582e2b 100644 --- a/resources/js/guest-v2/components/GuestAnalyticsNudge.tsx +++ b/resources/js/guest-v2/components/GuestAnalyticsNudge.tsx @@ -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'; diff --git a/resources/js/guest-v2/components/NotificationSheet.tsx b/resources/js/guest-v2/components/NotificationSheet.tsx index a969efe1..d31ca095 100644 --- a/resources/js/guest-v2/components/NotificationSheet.tsx +++ b/resources/js/guest-v2/components/NotificationSheet.tsx @@ -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 = { diff --git a/resources/js/guest/components/PwaManager.tsx b/resources/js/guest-v2/components/PwaManager.tsx similarity index 62% rename from resources/js/guest/components/PwaManager.tsx rename to resources/js/guest-v2/components/PwaManager.tsx index 2853ac11..fcbccc69 100644 --- a/resources/js/guest/components/PwaManager.tsx +++ b/resources/js/guest-v2/components/PwaManager.tsx @@ -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(() => {}); }; diff --git a/resources/js/guest-v2/components/SettingsContent.tsx b/resources/js/guest-v2/components/SettingsContent.tsx index 4c061f91..d024be05 100644 --- a/resources/js/guest-v2/components/SettingsContent.tsx +++ b/resources/js/guest-v2/components/SettingsContent.tsx @@ -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'; diff --git a/resources/js/guest-v2/components/SettingsSheet.tsx b/resources/js/guest-v2/components/SettingsSheet.tsx index f1873ce0..7740dae0 100644 --- a/resources/js/guest-v2/components/SettingsSheet.tsx +++ b/resources/js/guest-v2/components/SettingsSheet.tsx @@ -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' }, diff --git a/resources/js/guest-v2/components/ShareSheet.tsx b/resources/js/guest-v2/components/ShareSheet.tsx index 974b56e2..eef4adf8 100644 --- a/resources/js/guest-v2/components/ShareSheet.tsx +++ b/resources/js/guest-v2/components/ShareSheet.tsx @@ -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 = { diff --git a/resources/js/guest-v2/components/TaskHeroCard.tsx b/resources/js/guest-v2/components/TaskHeroCard.tsx index 6ac2ead7..06369cae 100644 --- a/resources/js/guest-v2/components/TaskHeroCard.tsx +++ b/resources/js/guest-v2/components/TaskHeroCard.tsx @@ -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'; diff --git a/resources/js/guest-v2/components/TopBar.tsx b/resources/js/guest-v2/components/TopBar.tsx index 22183798..53976906 100644 --- a/resources/js/guest-v2/components/TopBar.tsx +++ b/resources/js/guest-v2/components/TopBar.tsx @@ -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'; diff --git a/resources/js/guest-v2/context/EventDataContext.tsx b/resources/js/guest-v2/context/EventDataContext.tsx index 3ee36bb3..ca507e11 100644 --- a/resources/js/guest-v2/context/EventDataContext.tsx +++ b/resources/js/guest-v2/context/EventDataContext.tsx @@ -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'; diff --git a/resources/js/guest/guest-sw.ts b/resources/js/guest-v2/guest-sw.ts similarity index 90% rename from resources/js/guest/guest-sw.ts rename to resources/js/guest-v2/guest-sw.ts index 2a27cb49..6526fe2b 100644 --- a/resources/js/guest/guest-sw.ts +++ b/resources/js/guest-v2/guest-sw.ts @@ -1,4 +1,3 @@ - /// 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; + __WB_MANIFEST: Array; }; 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) => 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' })); - }) + }), ); }); diff --git a/resources/js/guest-v2/layouts/EventLayout.tsx b/resources/js/guest-v2/layouts/EventLayout.tsx index 92a181e1..108cf5c3 100644 --- a/resources/js/guest-v2/layouts/EventLayout.tsx +++ b/resources/js/guest-v2/layouts/EventLayout.tsx @@ -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'; diff --git a/resources/js/guest-v2/layouts/GuestLocaleLayout.tsx b/resources/js/guest-v2/layouts/GuestLocaleLayout.tsx index 11518409..52625e81 100644 --- a/resources/js/guest-v2/layouts/GuestLocaleLayout.tsx +++ b/resources/js/guest-v2/layouts/GuestLocaleLayout.tsx @@ -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 ( diff --git a/resources/js/guest-v2/lib/brandingTheme.tsx b/resources/js/guest-v2/lib/brandingTheme.tsx index e87d6089..bb9711dc 100644 --- a/resources/js/guest-v2/lib/brandingTheme.tsx +++ b/resources/js/guest-v2/lib/brandingTheme.tsx @@ -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; diff --git a/resources/js/guest/lib/cachePolicy.ts b/resources/js/guest-v2/lib/cachePolicy.ts similarity index 100% rename from resources/js/guest/lib/cachePolicy.ts rename to resources/js/guest-v2/lib/cachePolicy.ts diff --git a/resources/js/guest-v2/lib/eventBranding.ts b/resources/js/guest-v2/lib/eventBranding.ts index 23bbec07..50e48ed0 100644 --- a/resources/js/guest-v2/lib/eventBranding.ts +++ b/resources/js/guest-v2/lib/eventBranding.ts @@ -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) { diff --git a/resources/js/guest-v2/lib/guestTheme.ts b/resources/js/guest-v2/lib/guestTheme.ts index 39458f18..9785b7e1 100644 --- a/resources/js/guest-v2/lib/guestTheme.ts +++ b/resources/js/guest-v2/lib/guestTheme.ts @@ -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'; diff --git a/resources/js/guest-v2/screens/AchievementsScreen.tsx b/resources/js/guest-v2/screens/AchievementsScreen.tsx index cf3c9365..e3fbcb30 100644 --- a/resources/js/guest-v2/screens/AchievementsScreen.tsx +++ b/resources/js/guest-v2/screens/AchievementsScreen.tsx @@ -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 ( @@ -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)'} > @@ -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 ( @@ -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" > @@ -190,7 +192,7 @@ function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) { {badge.earned ? completeCopy : `${progress}/${target}`} - + string; emptyCopy: string; + isDark: boolean; }; -function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) { +function Timeline({ points, formatNumber, emptyCopy, isDark }: TimelineProps) { if (points.length === 0) { return ( @@ -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)'} > {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 }} /> ) : ( - + {topPhotoNoPreview} @@ -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 ( @@ -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 ? ( ) : ( - - + + )} @@ -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(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} /> @@ -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} /> @@ -613,6 +633,7 @@ export default function AchievementsScreen() { points={highlights?.timeline ?? []} formatNumber={formatNumber} emptyCopy={t('achievements.timeline.empty', 'No timeline data yet.')} + isDark={isDark} /> @@ -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} /> @@ -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} /> @@ -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} /> ) : null} diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 9f37e477..8b289343 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/HelpArticleScreen.tsx b/resources/js/guest-v2/screens/HelpArticleScreen.tsx index 2591ea2a..553c48df 100644 --- a/resources/js/guest-v2/screens/HelpArticleScreen.tsx +++ b/resources/js/guest-v2/screens/HelpArticleScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/HelpCenterScreen.tsx b/resources/js/guest-v2/screens/HelpCenterScreen.tsx index e1d3d6ea..bdf26478 100644 --- a/resources/js/guest-v2/screens/HelpCenterScreen.tsx +++ b/resources/js/guest-v2/screens/HelpCenterScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/HomeScreen.tsx b/resources/js/guest-v2/screens/HomeScreen.tsx index e2d48bdb..a08930db 100644 --- a/resources/js/guest-v2/screens/HomeScreen.tsx +++ b/resources/js/guest-v2/screens/HomeScreen.tsx @@ -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 = { diff --git a/resources/js/guest-v2/screens/LandingScreen.tsx b/resources/js/guest-v2/screens/LandingScreen.tsx index 7db079d0..54fd62ed 100644 --- a/resources/js/guest-v2/screens/LandingScreen.tsx +++ b/resources/js/guest-v2/screens/LandingScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/LegalScreen.tsx b/resources/js/guest-v2/screens/LegalScreen.tsx index 7f3c22f4..37d43281 100644 --- a/resources/js/guest-v2/screens/LegalScreen.tsx +++ b/resources/js/guest-v2/screens/LegalScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/LiveShowScreen.tsx b/resources/js/guest-v2/screens/LiveShowScreen.tsx index 25640eaa..627d3846 100644 --- a/resources/js/guest-v2/screens/LiveShowScreen.tsx +++ b/resources/js/guest-v2/screens/LiveShowScreen.tsx @@ -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() { diff --git a/resources/js/guest-v2/screens/NotFoundScreen.tsx b/resources/js/guest-v2/screens/NotFoundScreen.tsx index 4262e9eb..9f2ae291 100644 --- a/resources/js/guest-v2/screens/NotFoundScreen.tsx +++ b/resources/js/guest-v2/screens/NotFoundScreen.tsx @@ -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(); diff --git a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx index 8ffe721d..31bdac06 100644 --- a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx +++ b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/ProfileSetupScreen.tsx b/resources/js/guest-v2/screens/ProfileSetupScreen.tsx index eb82c9cc..52ec77a0 100644 --- a/resources/js/guest-v2/screens/ProfileSetupScreen.tsx +++ b/resources/js/guest-v2/screens/ProfileSetupScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/PublicGalleryScreen.tsx b/resources/js/guest-v2/screens/PublicGalleryScreen.tsx index 65d8d99e..e8903961 100644 --- a/resources/js/guest-v2/screens/PublicGalleryScreen.tsx +++ b/resources/js/guest-v2/screens/PublicGalleryScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/ShareScreen.tsx b/resources/js/guest-v2/screens/ShareScreen.tsx index 20b0780d..c3008b5a 100644 --- a/resources/js/guest-v2/screens/ShareScreen.tsx +++ b/resources/js/guest-v2/screens/ShareScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx index 66920d30..4e679c13 100644 --- a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx +++ b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/SlideshowScreen.tsx b/resources/js/guest-v2/screens/SlideshowScreen.tsx index 35d20e42..43ff2886 100644 --- a/resources/js/guest-v2/screens/SlideshowScreen.tsx +++ b/resources/js/guest-v2/screens/SlideshowScreen.tsx @@ -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 ''; diff --git a/resources/js/guest-v2/screens/TaskDetailScreen.tsx b/resources/js/guest-v2/screens/TaskDetailScreen.tsx index 050ca8a7..cae99f51 100644 --- a/resources/js/guest-v2/screens/TaskDetailScreen.tsx +++ b/resources/js/guest-v2/screens/TaskDetailScreen.tsx @@ -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 { diff --git a/resources/js/guest-v2/screens/TasksScreen.tsx b/resources/js/guest-v2/screens/TasksScreen.tsx index 5b34890a..9c0ba12e 100644 --- a/resources/js/guest-v2/screens/TasksScreen.tsx +++ b/resources/js/guest-v2/screens/TasksScreen.tsx @@ -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'; diff --git a/resources/js/guest-v2/screens/UploadQueueScreen.tsx b/resources/js/guest-v2/screens/UploadQueueScreen.tsx index 3301c572..91b50edc 100644 --- a/resources/js/guest-v2/screens/UploadQueueScreen.tsx +++ b/resources/js/guest-v2/screens/UploadQueueScreen.tsx @@ -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; diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx index e666c42e..ed6933ac 100644 --- a/resources/js/guest-v2/screens/UploadScreen.tsx +++ b/resources/js/guest-v2/screens/UploadScreen.tsx @@ -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]; diff --git a/resources/js/guest-v2/services/achievementsApi.ts b/resources/js/guest-v2/services/achievementsApi.ts index f8b60f2d..c9243deb 100644 --- a/resources/js/guest-v2/services/achievementsApi.ts +++ b/resources/js/guest-v2/services/achievementsApi.ts @@ -3,4 +3,4 @@ export { type AchievementsPayload, type AchievementBadge, type LeaderboardEntry, -} from '@/guest/services/achievementApi'; +} from '@/shared/guest/services/achievementApi'; diff --git a/resources/js/guest-v2/services/eventApi.ts b/resources/js/guest-v2/services/eventApi.ts index 2b4ed33d..9b4f9316 100644 --- a/resources/js/guest-v2/services/eventApi.ts +++ b/resources/js/guest-v2/services/eventApi.ts @@ -5,4 +5,4 @@ export { type EventStats, FetchEventError, type FetchEventErrorCode, -} from '@/guest/services/eventApi'; +} from '@/shared/guest/services/eventApi'; diff --git a/resources/js/guest-v2/services/galleryApi.ts b/resources/js/guest-v2/services/galleryApi.ts index 8ca7410a..ce9bbc52 100644 --- a/resources/js/guest-v2/services/galleryApi.ts +++ b/resources/js/guest-v2/services/galleryApi.ts @@ -3,4 +3,4 @@ export { fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource, -} from '@/guest/services/galleryApi'; +} from '@/shared/guest/services/galleryApi'; diff --git a/resources/js/guest-v2/services/notificationsApi.ts b/resources/js/guest-v2/services/notificationsApi.ts index 5ced67c8..c14a3b17 100644 --- a/resources/js/guest-v2/services/notificationsApi.ts +++ b/resources/js/guest-v2/services/notificationsApi.ts @@ -3,4 +3,4 @@ export { markGuestNotificationRead, dismissGuestNotification, type GuestNotificationItem, -} from '@/guest/services/notificationApi'; +} from '@/shared/guest/services/notificationApi'; diff --git a/resources/js/guest-v2/services/photosApi.ts b/resources/js/guest-v2/services/photosApi.ts index 9f5e24d4..0e1eb167 100644 --- a/resources/js/guest-v2/services/photosApi.ts +++ b/resources/js/guest-v2/services/photosApi.ts @@ -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; diff --git a/resources/js/guest-v2/services/pushApi.ts b/resources/js/guest-v2/services/pushApi.ts index 1809a4b8..bde0b68b 100644 --- a/resources/js/guest-v2/services/pushApi.ts +++ b/resources/js/guest-v2/services/pushApi.ts @@ -1 +1,4 @@ -export { registerGuestPushSubscription, unregisterGuestPushSubscription } from '@/guest/services/pushApi'; +export { + registerPushSubscription as registerGuestPushSubscription, + unregisterPushSubscription as unregisterGuestPushSubscription, +} from '@/shared/guest/services/pushApi'; diff --git a/resources/js/guest-v2/services/uploadApi.ts b/resources/js/guest-v2/services/uploadApi.ts index 494a05d5..15969d56 100644 --- a/resources/js/guest-v2/services/uploadApi.ts +++ b/resources/js/guest-v2/services/uploadApi.ts @@ -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'; diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx deleted file mode 100644 index 5d781f49..00000000 --- a/resources/js/guest/components/BottomNav.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} - -export default function BottomNav() { - const { token } = useParams(); - const location = useLocation(); - const { event, status } = useEventData(); - const { t } = useTranslation(); - const { branding } = useEventBranding(); - const navRef = React.useRef(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 ( -
-
-
-
- -
- - {labels.home} -
-
- {tasksEnabled ? ( - -
- - {labels.tasks} -
-
- ) : null} -
- - - - - -
- -
- - {labels.achievements} -
-
- -
- - {labels.gallery} -
-
-
-
-
- ); -} diff --git a/resources/js/guest/components/DemoReadOnlyNotice.tsx b/resources/js/guest/components/DemoReadOnlyNotice.tsx deleted file mode 100644 index bc32bedb..00000000 --- a/resources/js/guest/components/DemoReadOnlyNotice.tsx +++ /dev/null @@ -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 ( - -
-
- -
-
-

{title}

-

{copy}

- {hint ?

{hint}

: null} -
-
- {ctaLabel && onCta ? ( -
- -
- ) : null} -
- ); -} diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx deleted file mode 100644 index fb02ebd6..00000000 --- a/resources/js/guest/components/EmotionPicker.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const { locale } = useTranslation(); - - // Fallback emotions (when API not available yet) - const fallbackEmotions = React.useMemo( - () => [ - { 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 = ( -
- {(variant === 'standalone' || title) && ( -
-

- {headingTitle} - {headingSubtitle && {headingSubtitle}} -

- {loading && Lade Emotionen…} -
- )} - -
-
- {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 ( - - ); - })} -
-
-
- - {/* Skip option */} - {shouldShowSkip && ( -
- -
- )} -
- ); - - if (error) { - return ( -
- {error} -
- ); - } - - if (variant === 'embedded') { - return content; - } - - return
{content}
; -} diff --git a/resources/js/guest/components/FiltersBar.tsx b/resources/js/guest/components/FiltersBar.tsx deleted file mode 100644 index ccf3541d..00000000 --- a/resources/js/guest/components/FiltersBar.tsx +++ /dev/null @@ -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 ( -
-
- {filters.map((filter, index) => { - const isActive = value === filter.value; - const Icon = filter.icon; - return ( -
- - {index < filters.length - 1 && ( - - )} -
- ); - })} -
-
- ); -} diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx deleted file mode 100644 index 76404d13..00000000 --- a/resources/js/guest/components/GalleryPreview.tsx +++ /dev/null @@ -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('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 ( - - -
-
-
- Live-Galerie -
-

Alle Uploads auf einen Blick

-
- - Alle ansehen → - -
- -
-
- {filters.map((filter) => { - const isActive = mode === filter.value; - - return ( - - ); - })} -
-
- - {loading &&

Lädt…

} - {!loading && items.length === 0 && ( -
- - Noch keine Fotos. Starte mit deinem ersten Upload! -
- )} - -
- {items.map((p: PreviewPhoto) => ( - -
- {p.title -
-
-
-

- {p.title || getPhotoTitle(p)} -

-
- - {p.likes_count ?? 0} -
-
- - ))} -
- -

- Lust auf mehr?{' '} - - Zur Galerie → - -

- - - ); -} diff --git a/resources/js/guest/components/GuestAnalyticsNudge.tsx b/resources/js/guest/components/GuestAnalyticsNudge.tsx deleted file mode 100644 index 9be39f86..00000000 --- a/resources/js/guest/components/GuestAnalyticsNudge.tsx +++ /dev/null @@ -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(() => 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 = [ - '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 ( -
-
-
-
-

- {t('consent.analytics.title')} -

-

- {t('consent.analytics.body')} -

-
-
- - -
-
-
-
- ); -} diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx deleted file mode 100644 index 6dfe8eb9..00000000 --- a/resources/js/guest/components/Header.tsx +++ /dev/null @@ -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> = { - heart: Heart, - guests: Users, - party: PartyPopper, - camera: Camera, -}; - -type LogoSize = 's' | 'm' | 'l'; - -const LOGO_SIZE_CLASSES: Record = { - 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> = { - 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 ( -
- {name} setLogoFailed(true)} - /> -
- ); - } - if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) { - return ( -
- {logo.value} - {name} -
- ); - } - - if (typeof icon === 'string') { - const trimmed = icon.trim(); - if (trimmed) { - const normalized = trimmed.toLowerCase(); - const IconComponent = EVENT_ICON_COMPONENTS[normalized]; - if (IconComponent) { - return ( -
- -
- ); - } - - if (isLikelyEmoji(trimmed)) { - return ( -
- {trimmed} - {name} -
- ); - } - } - } - - return ( -
- {getInitials(name)} -
- ); -} - -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(null); - const notificationButtonRef = React.useRef(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 ( -
-
-
-
-
-
-
{title}
-
-
- - -
-
-
- ); - } - - const accentColor = branding.secondaryColor; - - if (status === 'loading') { - return ( -
-
-
-
-
-
-
{t('header.loading')}
-
- - -
-
-
- ); - } - - if (status !== 'ready' || !event) { - return null; - } - - const stats = - statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; - return ( -
-
-
-
-
-
- -
-
{event.name}
-
- {stats && tasksEnabled && ( - <> - - - {`${stats.onlineGuests} ${t('header.stats.online')}`} - - | - - {stats.tasksSolved}{' '} - {t('header.stats.tasksSolved')} - - - )} -
-
-
-
- {notificationCenter && eventToken && ( - setNotificationsOpen((prev) => !prev)} - panelRef={panelRef} - buttonRef={notificationButtonRef} - t={t} - /> - )} - - -
-
- ); -} - -type NotificationButtonProps = { - center: NotificationCenterValue; - eventToken: string; - open: boolean; - onToggle: () => void; - panelRef: React.RefObject; - buttonRef: React.RefObject; - t: TranslateFn; -}; - -type PushState = ReturnType; - -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 ( -
- - {open && createPortal( -
-
-
-

{t('header.notifications.title', 'Updates')}

-

- {center.unreadCount > 0 - ? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount }) - : t('header.notifications.allRead', 'Alles gelesen')} -

-
- -
- setActiveTab(next as typeof activeTab)} - /> - {activeTab !== 'uploads' && ( -
-
- {( - [ - { 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) => ( - - ))} -
-
- )} - {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && ( -
- {center.pendingCount > 0 && ( -
-
- - {t('header.notifications.pendingLabel', 'Uploads in Prüfung')} - {center.pendingCount} -
- { - if (center.unreadCount > 0) { - void center.refresh(); - } - }} - > - {t('header.notifications.pendingCta', 'Details')} - - -
- )} - {center.queueCount > 0 && ( -
-
- - {t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')} - {center.queueCount} -
-
- )} -
- )} -
- {center.loading ? ( - - ) : scopedNotifications.length === 0 ? ( - - ) : ( - scopedNotifications.map((item) => ( - center.markAsRead(item.id)} - onDismiss={() => center.dismiss(item.id)} - t={t} - /> - )) - )} -
- -
, - (typeof document !== 'undefined' ? document.body : null) as any - )} -
- ); -} - -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 ( -
{ - if (isNew) { - onMarkRead(); - } - }} - > -
-
- -
-
-
-
-

{item.title}

- {item.body &&

{item.body}

} -
- -
-
- {createdLabel && {createdLabel}} - {isNew && ( - - - {t('header.notifications.badge.new', 'Neu')} - - )} -
- {item.cta && ( - - )} - {!isNew && item.status !== 'dismissed' && ( - - )} -
-
-
- ); -} - -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 = ( - - {label} - - - ); - - if (isInternal) { - return ( - - {content} - - ); - } - - return ( - - {content} - - ); -} - -function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) { - return ( -
- -

{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}

-
- ); -} - -function NotificationSkeleton() { - return ( -
- {[0, 1, 2].map((index) => ( -
-
-
-
-
-
-
-
-
- ))} -
- ); -} - -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 ( -
- {tabs.map((tab) => ( - - ))} -
- ); -} - -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 ( -
-
- - {t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} - - {isOffline && ( - - - {t('header.notifications.offline', 'Offline')} - - )} -
-
-
- - {pushDescription} -
- -
- {push.error && ( -

- {push.error} -

- )} -
- ); -} diff --git a/resources/js/guest/components/RouteTransition.tsx b/resources/js/guest/components/RouteTransition.tsx deleted file mode 100644 index 496dff61..00000000 --- a/resources/js/guest/components/RouteTransition.tsx +++ /dev/null @@ -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 ?? ; - - 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 ( - - - {content} - - - ); -} diff --git a/resources/js/guest/components/ShareSheet.tsx b/resources/js/guest/components/ShareSheet.tsx deleted file mode 100644 index da15b5ba..00000000 --- a/resources/js/guest/components/ShareSheet.tsx +++ /dev/null @@ -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) => ( - - - -); - -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 ( -
-
-
-
-

- {t('share.title', 'Geteiltes Foto')} -

-

- {photoId ? `#${photoId}` : ''} -

- {eventName ?

{eventName}

: null} -
- -
- -
- - - - -
- - {url ? ( -

- {url} -

- ) : null} -
-
- ); -} - -export default ShareSheet; diff --git a/resources/js/guest/components/__tests__/BottomNav.test.tsx b/resources/js/guest/components/__tests__/BottomNav.test.tsx deleted file mode 100644 index 1e138dab..00000000 --- a/resources/js/guest/components/__tests__/BottomNav.test.tsx +++ /dev/null @@ -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( - - - } /> - - - ); - - await waitFor(() => { - expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px'); - }); - }); -}); diff --git a/resources/js/guest/components/__tests__/GalleryPreview.test.tsx b/resources/js/guest/components/__tests__/GalleryPreview.test.tsx deleted file mode 100644 index 52a9beb7..00000000 --- a/resources/js/guest/components/__tests__/GalleryPreview.test.tsx +++ /dev/null @@ -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( - - - , - ); - - 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'); - }); -}); diff --git a/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx b/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx deleted file mode 100644 index ea5d34dd..00000000 --- a/resources/js/guest/components/__tests__/HeaderNotificationToggle.test.tsx +++ /dev/null @@ -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: () =>
, -})); - -vi.mock('@/components/appearance-dropdown', () => ({ - default: () =>
, -})); - -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( - -
- , - ); - - const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen'); - fireEvent.click(bellButton); - - expect(screen.getByText('Updates')).toBeInTheDocument(); - - fireEvent.click(bellButton); - - expect(screen.queryByText('Updates')).not.toBeInTheDocument(); - }); -}); diff --git a/resources/js/guest/components/__tests__/PullToRefresh.test.tsx b/resources/js/guest/components/__tests__/PullToRefresh.test.tsx deleted file mode 100644 index 77ba2812..00000000 --- a/resources/js/guest/components/__tests__/PullToRefresh.test.tsx +++ /dev/null @@ -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( - -
Content
-
- ); - - expect(screen.getByText('Content')).toBeInTheDocument(); - expect(screen.getByText('Pull')).toBeInTheDocument(); - }); -}); diff --git a/resources/js/guest/components/__tests__/RouteTransition.test.tsx b/resources/js/guest/components/__tests__/RouteTransition.test.tsx deleted file mode 100644 index ec1d3407..00000000 --- a/resources/js/guest/components/__tests__/RouteTransition.test.tsx +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/components/__tests__/SettingsSheet.test.tsx b/resources/js/guest/components/__tests__/SettingsSheet.test.tsx deleted file mode 100644 index fe6fb23a..00000000 --- a/resources/js/guest/components/__tests__/SettingsSheet.test.tsx +++ /dev/null @@ -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( - - - - - - - - ); - - 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(); - }); -}); diff --git a/resources/js/guest/components/__tests__/ToastHost.test.tsx b/resources/js/guest/components/__tests__/ToastHost.test.tsx deleted file mode 100644 index 2194d085..00000000 --- a/resources/js/guest/components/__tests__/ToastHost.test.tsx +++ /dev/null @@ -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( - - - - ); - - 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(); - }); -}); diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx deleted file mode 100644 index b4d1e884..00000000 --- a/resources/js/guest/components/settings-sheet.tsx +++ /dev/null @@ -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({ 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('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 ( - - - - - -
-
- {isLegal ? ( -
- -
- - {legalDocument.phase === 'ready' && legalDocument.title - ? legalDocument.title - : t(view.translationKey)} - - - {legalDocument.phase === 'loading' - ? t('common.actions.loading') - : t('settings.sheet.legalDescription')} - -
-
- ) : ( -
- {t('settings.title')} - {t('settings.subtitle')} -
- )} -
- -
- {isLegal ? ( - handleOpenChange(false)} - translationKey={view.mode === 'legal' ? view.translationKey : null} - /> - ) : ( - - )} -
- - -
{t('settings.footer.notice')}
-
-
-
-
- ); -} - -function LegalView({ - document, - onClose, - translationKey, -}: { - document: LegalDocumentState; - onClose: () => void; - translationKey: string | null; -}) { - const { t } = useTranslation(); - - if (document.phase === 'error') { - return ( -
- - - {t('settings.legal.error')} - - - -
- ); - } - - if (document.phase === 'loading' || document.phase === 'idle') { - return
{t('settings.legal.loading')}
; - } - - return ( -
- - - {document.title || t(translationKey ?? 'settings.legal.fallbackTitle')} - - - - - -
- ); -} - -interface HomeViewProps { - identity: ReturnType; - 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 ( -
- - - {t('settings.language.title')} - - -
- {localeContext.availableLocales.map((option) => { - const isActive = localeContext.locale === option.code; - return ( - - ); - })} -
-
-
- - {identity && ( - - - {t('settings.name.title')} - {t('settings.name.description')} - - -
-
- -
-
- - onNameChange(event.target.value)} - autoComplete="name" - disabled={!identity.hydrated || savingName} - /> -
-
-
- - - {nameStatus === 'saved' && ( - {t('settings.name.saved')} - )} - {!identity.hydrated && ( - {t('settings.name.loading')} - )} -
-
-
- )} - - - - {t('settings.haptics.title')} - {t('settings.haptics.description')} - - -
- {t('settings.haptics.label')} - { - setHapticsEnabled(checked); - if (checked) { - triggerHaptic('selection'); - } - }} - disabled={!hapticsSupported} - aria-label={t('settings.haptics.label')} - /> -
- {!hapticsSupported && ( -
{t('settings.haptics.unsupported')}
- )} -
-
- - {matomoEnabled ? ( - - - {t('settings.analytics.title')} - {t('settings.analytics.description')} - - -
- {t('settings.analytics.label')} - savePreferences({ analytics: checked })} - aria-label={t('settings.analytics.label')} - /> -
-
{t('settings.analytics.note')}
-
-
- ) : null} - - - - -
- - {t('settings.legal.title')} -
-
- {t('settings.legal.description')} -
- - {legalLinks.map((page) => ( - - ))} - -
- - - - -
- - {t('settings.help.title')} -
-
- {t('settings.help.description')} -
- - - -
- - - - {t('settings.cache.title')} - {t('settings.cache.description')} - - - -
- - {t('settings.cache.note')} -
-
-
-
- ); -} - -function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState { - const [state, setState] = React.useState({ - 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 ( -
- - {done &&
{t('settings.cache.cleared')}
} -
- ); -} diff --git a/resources/js/guest/context/EventStatsContext.tsx b/resources/js/guest/context/EventStatsContext.tsx deleted file mode 100644 index 6dc6906c..00000000 --- a/resources/js/guest/context/EventStatsContext.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { usePollStats } from '../polling/usePollStats'; - -type EventStatsContextValue = ReturnType & { - eventKey: string; - slug: string; -}; - -const EventStatsContext = React.createContext(undefined); - -export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) { - const stats = usePollStats(eventKey); - const value = React.useMemo( - () => ({ eventKey, slug: eventKey, ...stats }), - [eventKey, stats] - ); - return {children}; -} - -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); -} diff --git a/resources/js/guest/context/GuestIdentityContext.tsx b/resources/js/guest/context/GuestIdentityContext.tsx deleted file mode 100644 index 238e463c..00000000 --- a/resources/js/guest/context/GuestIdentityContext.tsx +++ /dev/null @@ -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(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( - () => ({ - eventKey, - slug: eventKey, - name, - hydrated, - setName: persistName, - clearName, - reload: loadFromStorage, - }), - [eventKey, name, hydrated, persistName, clearName, loadFromStorage] - ); - - return {children}; -} - -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); -} diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx deleted file mode 100644 index 8590203b..00000000 --- a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx +++ /dev/null @@ -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( - -
Guest
-
- ); - - 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( - - -
Guest
-
-
- ); - - 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( - - -
Guest
-
-
- ); - - await waitFor(() => { - expect(document.documentElement.classList.contains('dark')).toBe(false); - }); - - unmount(); - }); -}); diff --git a/resources/js/guest/demo/demoMode.ts b/resources/js/guest/demo/demoMode.ts deleted file mode 100644 index d08c013a..00000000 --- a/resources/js/guest/demo/demoMode.ts +++ /dev/null @@ -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(); - -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 | 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 { - 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 - } -} diff --git a/resources/js/guest/demo/fixtures.ts b/resources/js/guest/demo/fixtures.ts deleted file mode 100644 index 7c4609ad..00000000 --- a/resources/js/guest/demo/fixtures.ts +++ /dev/null @@ -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' }, - }, -}; diff --git a/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx b/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx deleted file mode 100644 index f3efdb2e..00000000 --- a/resources/js/guest/hooks/__tests__/useHapticsPreference.test.tsx +++ /dev/null @@ -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 ( - - ); -} - -describe('useHapticsPreference', () => { - beforeEach(() => { - window.localStorage.removeItem(HAPTICS_STORAGE_KEY); - Object.defineProperty(navigator, 'vibrate', { - configurable: true, - value: vi.fn(), - }); - }); - - it('toggles and persists preference', () => { - render(); - 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'); - }); -}); diff --git a/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx b/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx deleted file mode 100644 index d7813d4b..00000000 --- a/resources/js/guest/hooks/__tests__/useLiveShowPlayback.test.tsx +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/hooks/useDirectUpload.ts b/resources/js/guest/hooks/useDirectUpload.ts deleted file mode 100644 index 1b21de90..00000000 --- a/resources/js/guest/hooks/useDirectUpload.ts +++ /dev/null @@ -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(null); - const [error, setError] = useState(null); - const [errorDialog, setErrorDialog] = useState(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 => { - 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 | 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, - }; -} diff --git a/resources/js/guest/hooks/useEventData.ts b/resources/js/guest/hooks/useEventData.ts deleted file mode 100644 index e8a1a58e..00000000 --- a/resources/js/guest/hooks/useEventData.ts +++ /dev/null @@ -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(); - -export function useEventData(): UseEventDataResult { - const { token } = useParams<{ token: string }>(); - const cachedEvent = token ? eventCache.get(token) ?? null : null; - const [event, setEvent] = useState(cachedEvent); - const [status, setStatus] = useState(token ? (cachedEvent ? 'ready' : 'loading') : 'error'); - const [errorMessage, setErrorMessage] = useState(token ? null : NO_TOKEN_ERROR_MESSAGE); - const [errorCode, setErrorCode] = useState(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, - }; -} diff --git a/resources/js/guest/hooks/usePushSubscription.ts b/resources/js/guest/hooks/usePushSubscription.ts deleted file mode 100644 index 1893976d..00000000 --- a/resources/js/guest/hooks/usePushSubscription.ts +++ /dev/null @@ -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; - disable: () => Promise; - refresh: () => Promise; -}; - -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(() => { - if (typeof Notification === 'undefined') { - return 'default'; - } - - return Notification.permission; - }); - const [subscription, setSubscription] = React.useState(null); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(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; -} diff --git a/resources/js/guest/lib/__tests__/analyticsConsent.test.ts b/resources/js/guest/lib/__tests__/analyticsConsent.test.ts deleted file mode 100644 index 63cfe30d..00000000 --- a/resources/js/guest/lib/__tests__/analyticsConsent.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/lib/__tests__/badges.test.ts b/resources/js/guest/lib/__tests__/badges.test.ts deleted file mode 100644 index cb547d5d..00000000 --- a/resources/js/guest/lib/__tests__/badges.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/lib/__tests__/cachePolicy.test.ts b/resources/js/guest/lib/__tests__/cachePolicy.test.ts deleted file mode 100644 index f75bae47..00000000 --- a/resources/js/guest/lib/__tests__/cachePolicy.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/lib/__tests__/csrf.test.ts b/resources/js/guest/lib/__tests__/csrf.test.ts deleted file mode 100644 index 2fd6d33f..00000000 --- a/resources/js/guest/lib/__tests__/csrf.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/resources/js/guest/lib/__tests__/galleryFilters.test.ts b/resources/js/guest/lib/__tests__/galleryFilters.test.ts deleted file mode 100644 index 3b5ce9ee..00000000 --- a/resources/js/guest/lib/__tests__/galleryFilters.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/lib/__tests__/guestTheme.test.ts b/resources/js/guest/lib/__tests__/guestTheme.test.ts deleted file mode 100644 index 9e70d8ae..00000000 --- a/resources/js/guest/lib/__tests__/guestTheme.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/resources/js/guest/lib/__tests__/haptics.test.ts b/resources/js/guest/lib/__tests__/haptics.test.ts deleted file mode 100644 index b9f8a692..00000000 --- a/resources/js/guest/lib/__tests__/haptics.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { HAPTICS_STORAGE_KEY, getHapticsPreference, isHapticsEnabled, setHapticsPreference, supportsHaptics, triggerHaptic } from '../haptics'; - -describe('haptics', () => { - afterEach(() => { - window.localStorage.removeItem(HAPTICS_STORAGE_KEY); - }); - - it('returns false when vibrate is unavailable', () => { - const original = navigator.vibrate; - Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined }); - expect(supportsHaptics()).toBe(false); - Object.defineProperty(navigator, 'vibrate', { configurable: true, value: original }); - }); - - it('returns stored preference when set', () => { - window.localStorage.removeItem(HAPTICS_STORAGE_KEY); - expect(getHapticsPreference()).toBe(true); - setHapticsPreference(false); - expect(getHapticsPreference()).toBe(false); - }); - - it('reports disabled when reduced motion is enabled', () => { - const originalMatchMedia = window.matchMedia; - const vibrate = vi.fn(); - - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: vi.fn().mockReturnValue({ matches: true }), - }); - Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate }); - setHapticsPreference(true); - - expect(isHapticsEnabled()).toBe(false); - - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: originalMatchMedia, - }); - }); - - it('triggers vibration only when enabled', () => { - const originalMatchMedia = window.matchMedia; - const vibrate = vi.fn(); - - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: vi.fn().mockReturnValue({ matches: false }), - }); - Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vibrate }); - - triggerHaptic('selection'); - expect(vibrate).toHaveBeenCalled(); - setHapticsPreference(false); - triggerHaptic('selection'); - expect(vibrate).toHaveBeenCalledTimes(1); - - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: originalMatchMedia, - }); - }); -}); diff --git a/resources/js/guest/lib/__tests__/helpRouting.test.ts b/resources/js/guest/lib/__tests__/helpRouting.test.ts deleted file mode 100644 index f4d4c6c6..00000000 --- a/resources/js/guest/lib/__tests__/helpRouting.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getHelpSlugForPathname } from '../helpRouting'; - -describe('getHelpSlugForPathname', () => { - it('returns a getting-started slug for home paths', () => { - expect(getHelpSlugForPathname('/')).toBe('getting-started'); - expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started'); - }); - - it('returns null for help pages', () => { - expect(getHelpSlugForPathname('/help')).toBeNull(); - expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull(); - expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull(); - }); - - it('maps gallery related pages', () => { - expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing'); - expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing'); - expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing'); - }); - - it('maps upload related pages', () => { - expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos'); - expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting'); - }); - - it('maps tasks and achievements', () => { - expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions'); - expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions'); - expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges'); - }); -}); diff --git a/resources/js/guest/lib/__tests__/limitSummaries.test.ts b/resources/js/guest/lib/__tests__/limitSummaries.test.ts deleted file mode 100644 index 13dea168..00000000 --- a/resources/js/guest/lib/__tests__/limitSummaries.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { EventPackageLimits } from '../../services/eventApi'; -import { buildLimitSummaries } from '../limitSummaries'; - -const translations = new Map([ - ['upload.limitSummary.cards.photos.title', 'Fotos'], - ['upload.limitSummary.cards.photos.remaining', 'Noch {remaining} von {limit}'], - ['upload.limitSummary.cards.photos.unlimited', 'Unbegrenzte Uploads'], - ['upload.limitSummary.cards.guests.title', 'Gäste'], - ['upload.limitSummary.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'], - ['upload.limitSummary.cards.guests.unlimited', 'Unbegrenzte Gäste'], - ['upload.limitSummary.badges.ok', 'OK'], - ['upload.limitSummary.badges.warning', 'Warnung'], - ['upload.limitSummary.badges.limit_reached', 'Limit erreicht'], - ['upload.limitSummary.badges.unlimited', 'Unbegrenzt'], -]); - -const t = (key: string) => translations.get(key) ?? key; - -describe('buildLimitSummaries', () => { - it('builds photo summary with progress and warning tone', () => { - const limits: EventPackageLimits = { - photos: { - limit: 100, - used: 80, - remaining: 20, - percentage: 80, - state: 'warning', - threshold_reached: 80, - next_threshold: 95, - thresholds: [80, 95], - }, - guests: null, - gallery: null, - can_upload_photos: true, - can_add_guests: true, - }; - - const cards = buildLimitSummaries(limits, t); - - expect(cards).toHaveLength(1); - const card = cards[0]; - expect(card.id).toBe('photos'); - expect(card.tone).toBe('warning'); - expect(card.progress).toBe(80); - expect(card.valueLabel).toBe('80 / 100'); - expect(card.description).toBe('Noch 20 von 100'); - expect(card.badgeLabel).toBe('Warnung'); - }); - - it('builds unlimited guest summary without progress', () => { - const limits: EventPackageLimits = { - photos: null, - guests: { - limit: null, - used: 5, - remaining: null, - percentage: null, - state: 'unlimited', - threshold_reached: null, - next_threshold: null, - thresholds: [], - }, - gallery: null, - can_upload_photos: true, - can_add_guests: true, - }; - - const cards = buildLimitSummaries(limits, t); - - expect(cards).toHaveLength(1); - const card = cards[0]; - expect(card.id).toBe('guests'); - expect(card.progress).toBeNull(); - expect(card.tone).toBe('neutral'); - expect(card.valueLabel).toBe('Unbegrenzt'); - expect(card.description).toBe('Unbegrenzte Gäste'); - expect(card.badgeLabel).toBe('Unbegrenzt'); - }); - - it('returns empty list when no limits are provided', () => { - expect(buildLimitSummaries(null, t)).toEqual([]); - expect(buildLimitSummaries(undefined, t)).toEqual([]); - }); -}); diff --git a/resources/js/guest/lib/__tests__/liveShowEffects.test.ts b/resources/js/guest/lib/__tests__/liveShowEffects.test.ts deleted file mode 100644 index cc29fd0e..00000000 --- a/resources/js/guest/lib/__tests__/liveShowEffects.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveLiveShowEffect } from '../liveShowEffects'; - -describe('resolveLiveShowEffect', () => { - it('adds flash overlay for shutter flash preset', () => { - const effect = resolveLiveShowEffect('shutter_flash', 80, false); - expect(effect.flash).toBeDefined(); - expect(effect.frame.initial).toBeDefined(); - expect(effect.frame.animate).toBeDefined(); - }); - - it('keeps light effects simple without flash', () => { - const effect = resolveLiveShowEffect('light_effects', 80, false); - expect(effect.flash).toBeUndefined(); - }); - - it('honors reduced motion with basic fade', () => { - const effect = resolveLiveShowEffect('film_cut', 80, true); - expect(effect.flash).toBeUndefined(); - expect(effect.frame.initial).toEqual({ opacity: 0 }); - }); -}); diff --git a/resources/js/guest/lib/__tests__/motion.test.ts b/resources/js/guest/lib/__tests__/motion.test.ts deleted file mode 100644 index c61a1d5f..00000000 --- a/resources/js/guest/lib/__tests__/motion.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion'; - -describe('getMotionContainerPropsForNavigation', () => { - it('returns initial hidden for POP navigation', () => { - expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({ - variants: STAGGER_FAST, - initial: 'hidden', - animate: 'show', - }); - }); - - it('skips initial animation for PUSH navigation', () => { - expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({ - variants: STAGGER_FAST, - initial: false, - animate: 'show', - }); - }); - - it('disables motion when not enabled', () => { - expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({ - initial: false, - }); - }); -}); - -describe('getMotionItemPropsForNavigation', () => { - it('returns animate props for POP navigation', () => { - expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({ - variants: FADE_UP, - initial: 'hidden', - animate: 'show', - }); - }); - - it('skips initial animation for PUSH navigation', () => { - expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({ - variants: FADE_UP, - initial: false, - animate: 'show', - }); - }); - - it('returns empty props when motion disabled', () => { - expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({}); - }); -}); diff --git a/resources/js/guest/lib/__tests__/taskUtils.test.ts b/resources/js/guest/lib/__tests__/taskUtils.test.ts deleted file mode 100644 index ea00e632..00000000 --- a/resources/js/guest/lib/__tests__/taskUtils.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { dedupeTasksById } from '../taskUtils'; - -describe('dedupeTasksById', () => { - it('returns empty array for empty input', () => { - expect(dedupeTasksById([])).toEqual([]); - }); - - it('keeps the first occurrence and preserves order', () => { - const tasks = [ - { id: 1, title: 'A' }, - { id: 2, title: 'B' }, - { id: 1, title: 'A-dup' }, - { id: 3, title: 'C' }, - ]; - - expect(dedupeTasksById(tasks)).toEqual([ - { id: 1, title: 'A' }, - { id: 2, title: 'B' }, - { id: 3, title: 'C' }, - ]); - }); -}); diff --git a/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts b/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts deleted file mode 100644 index 33c37589..00000000 --- a/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveUploadErrorDialog } from '../uploadErrorDialog'; - -const translations = new Map([ - ['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'], - ['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'], - ['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'], - ['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'], - ['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'], - ['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'], - ['upload.dialogs.packageMissing.title', 'Event nicht bereit'], - ['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'], - ['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'], - ['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'], - ['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'], - ['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'], - ['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'], - ['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'], - ['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'], - ['upload.dialogs.generic.title', 'Upload fehlgeschlagen'], - ['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'], - ['upload.dialogs.generic.hint', 'Versuche es später erneut.'], -]); - -const t = (key: string) => translations.get(key) ?? key; - -describe('resolveUploadErrorDialog', () => { - it('renders photo limit dialog with placeholders', () => { - const dialog = resolveUploadErrorDialog( - 'photo_limit_exceeded', - { used: 120, limit: 120, remaining: 0 }, - t - ); - - expect(dialog.title).toBe('Upload-Limit erreicht'); - expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.'); - expect(dialog.hint).toBe('Wende dich an das Team.'); - expect(dialog.tone).toBe('danger'); - }); - - it('falls back to generic dialog when code is unknown', () => { - const dialog = resolveUploadErrorDialog('something_else', undefined, t); - - expect(dialog.tone).toBe('info'); - expect(dialog.title).toBe('Upload fehlgeschlagen'); - expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.'); - }); -}); diff --git a/resources/js/guest/lib/galleryFilters.ts b/resources/js/guest/lib/galleryFilters.ts deleted file mode 100644 index 80f7636c..00000000 --- a/resources/js/guest/lib/galleryFilters.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { EventData } from '../services/eventApi'; - -export function shouldShowPhotoboothFilter(event?: EventData | null): boolean { - return Boolean(event?.photobooth_enabled); -} diff --git a/resources/js/guest/lib/guestTheme.ts b/resources/js/guest/lib/guestTheme.ts deleted file mode 100644 index 9541a8ae..00000000 --- a/resources/js/guest/lib/guestTheme.ts +++ /dev/null @@ -1,103 +0,0 @@ -export type GuestThemePayload = { - primary: string; - secondary: string; - background: string; - surface: string; - mode?: 'light' | 'dark' | 'auto'; -}; - -type GuestThemeCleanup = () => void; - -const prefersDarkScheme = (): boolean => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return false; - } - - return window.matchMedia('(prefers-color-scheme: dark)').matches; -}; - -const applyColorScheme = (root: HTMLElement, theme: 'light' | 'dark') => { - if (theme === 'dark') { - root.classList.add('dark'); - root.style.colorScheme = 'dark'; - } else { - root.classList.remove('dark'); - root.style.colorScheme = 'light'; - } -}; - -export function applyGuestTheme(payload: GuestThemePayload): GuestThemeCleanup { - if (typeof document === 'undefined') { - return () => {}; - } - - const root = document.documentElement; - const hadGuestTheme = root.classList.contains('guest-theme'); - const wasDark = root.classList.contains('dark'); - const previousColorScheme = root.style.colorScheme; - const previousVars = { - primary: root.style.getPropertyValue('--guest-primary'), - secondary: root.style.getPropertyValue('--guest-secondary'), - background: root.style.getPropertyValue('--guest-background'), - surface: root.style.getPropertyValue('--guest-surface'), - }; - - root.classList.add('guest-theme'); - root.style.setProperty('--guest-primary', payload.primary); - root.style.setProperty('--guest-secondary', payload.secondary); - root.style.setProperty('--guest-background', payload.background); - root.style.setProperty('--guest-surface', payload.surface); - - const mode = payload.mode ?? 'auto'; - if (mode === 'dark') { - applyColorScheme(root, 'dark'); - } else if (mode === 'light') { - applyColorScheme(root, 'light'); - } else { - applyColorScheme(root, prefersDarkScheme() ? 'dark' : 'light'); - } - - return () => { - if (hadGuestTheme) { - root.classList.add('guest-theme'); - } else { - root.classList.remove('guest-theme'); - } - - if (wasDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - - if (previousColorScheme) { - root.style.colorScheme = previousColorScheme; - } else { - root.style.removeProperty('color-scheme'); - } - - if (previousVars.primary) { - root.style.setProperty('--guest-primary', previousVars.primary); - } else { - root.style.removeProperty('--guest-primary'); - } - - if (previousVars.secondary) { - root.style.setProperty('--guest-secondary', previousVars.secondary); - } else { - root.style.removeProperty('--guest-secondary'); - } - - if (previousVars.background) { - root.style.setProperty('--guest-background', previousVars.background); - } else { - root.style.removeProperty('--guest-background'); - } - - if (previousVars.surface) { - root.style.setProperty('--guest-surface', previousVars.surface); - } else { - root.style.removeProperty('--guest-surface'); - } - }; -} diff --git a/resources/js/guest/lib/helpRouting.ts b/resources/js/guest/lib/helpRouting.ts deleted file mode 100644 index 54e535aa..00000000 --- a/resources/js/guest/lib/helpRouting.ts +++ /dev/null @@ -1,44 +0,0 @@ -export function getHelpSlugForPathname(pathname: string): string | null { - if (!pathname) { - return null; - } - - const normalized = pathname - .replace(/^\/e\/[^/]+/, '') - .replace(/\/+$/g, '') - .toLowerCase(); - - if (!normalized || normalized === '/') { - return 'getting-started'; - } - - if (normalized.startsWith('/help')) { - return null; - } - - if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) { - return 'gallery-and-sharing'; - } - - if (normalized.startsWith('/upload')) { - return 'uploading-photos'; - } - - if (normalized.startsWith('/queue')) { - return 'upload-troubleshooting'; - } - - if (normalized.startsWith('/tasks')) { - return 'tasks-and-missions'; - } - - if (normalized.startsWith('/achievements')) { - return 'achievements-and-badges'; - } - - if (normalized.startsWith('/settings')) { - return 'settings-and-cache'; - } - - return 'how-fotospiel-works'; -} diff --git a/resources/js/guest/lib/limitSummaries.ts b/resources/js/guest/lib/limitSummaries.ts deleted file mode 100644 index ba245930..00000000 --- a/resources/js/guest/lib/limitSummaries.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi'; - -export type LimitTone = 'neutral' | 'warning' | 'danger'; - -export type LimitSummaryCard = { - id: 'photos' | 'guests'; - label: string; - state: LimitUsageSummary['state']; - tone: LimitTone; - used: number; - limit: number | null; - remaining: number | null; - progress: number | null; - valueLabel: string; - description: string; - badgeLabel: string; -}; - -type TranslateFn = (key: string, fallback?: string) => string; - -function resolveTone(state: LimitUsageSummary['state']): LimitTone { - if (state === 'limit_reached') { - return 'danger'; - } - - if (state === 'warning') { - return 'warning'; - } - - return 'neutral'; -} - -function buildCard( - id: 'photos' | 'guests', - summary: LimitUsageSummary, - t: TranslateFn -): LimitSummaryCard { - const labelKey = id === 'photos' ? 'upload.limitSummary.cards.photos.title' : 'upload.limitSummary.cards.guests.title'; - const remainingKey = id === 'photos' - ? 'upload.limitSummary.cards.photos.remaining' - : 'upload.limitSummary.cards.guests.remaining'; - const unlimitedKey = id === 'photos' - ? 'upload.limitSummary.cards.photos.unlimited' - : 'upload.limitSummary.cards.guests.unlimited'; - - const tone = resolveTone(summary.state); - const progress = typeof summary.limit === 'number' && summary.limit > 0 - ? Math.min(100, Math.round((summary.used / summary.limit) * 100)) - : null; - - const valueLabel = typeof summary.limit === 'number' && summary.limit > 0 - ? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}` - : t('upload.limitSummary.badges.unlimited'); - - const description = summary.state === 'unlimited' - ? t(unlimitedKey) - : summary.remaining !== null && summary.limit !== null - ? t(remainingKey) - .replace('{remaining}', `${Math.max(0, summary.remaining)}`) - .replace('{limit}', `${summary.limit}`) - : valueLabel; - - const badgeKey = (() => { - switch (summary.state) { - case 'limit_reached': - return 'upload.limitSummary.badges.limit_reached'; - case 'warning': - return 'upload.limitSummary.badges.warning'; - case 'unlimited': - return 'upload.limitSummary.badges.unlimited'; - default: - return 'upload.limitSummary.badges.ok'; - } - })(); - - return { - id, - label: t(labelKey), - state: summary.state, - tone, - used: summary.used, - limit: summary.limit, - remaining: summary.remaining, - progress, - valueLabel, - description, - badgeLabel: t(badgeKey), - }; -} - -export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] { - if (!limits) { - return []; - } - - const cards: LimitSummaryCard[] = []; - - if (limits.photos) { - cards.push(buildCard('photos', limits.photos, t)); - } - - if (limits.guests) { - cards.push(buildCard('guests', limits.guests, t)); - } - - return cards; -} diff --git a/resources/js/guest/lib/runtime-config.ts b/resources/js/guest/lib/runtime-config.ts deleted file mode 100644 index 0af80551..00000000 --- a/resources/js/guest/lib/runtime-config.ts +++ /dev/null @@ -1,24 +0,0 @@ -type PushConfig = { - enabled: boolean; - vapidPublicKey: string | null; -}; - -type RuntimeConfig = { - push: PushConfig; -}; - -export function getRuntimeConfig(): RuntimeConfig { - const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined; - - return { - push: { - enabled: Boolean(raw?.push?.enabled), - vapidPublicKey: raw?.push?.vapidPublicKey ?? null, - }, - }; -} - -export function getPushConfig(): PushConfig { - return getRuntimeConfig().push; -} - diff --git a/resources/js/guest/lib/sharePhoto.ts b/resources/js/guest/lib/sharePhoto.ts deleted file mode 100644 index 6aab71f2..00000000 --- a/resources/js/guest/lib/sharePhoto.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createPhotoShareLink } from '../services/photosApi'; - -type ShareOptions = { - token: string; - photoId: number; - title?: string; - text?: string; -}; - -async function copyToClipboard(text: string): Promise { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - return true; - } - } catch { - // ignore and fallback - } - - try { - const input = document.createElement('input'); - input.value = text; - document.body.appendChild(input); - input.select(); - document.execCommand('copy'); - document.body.removeChild(input); - return true; - } catch { - return false; - } -} - -export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }> -{ - const payload = await createPhotoShareLink(options.token, options.photoId); - const shareData: ShareData = { - title: options.title ?? 'Fotospiel Moment', - text: options.text ?? '', - url: payload.url, - }; - - if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { - try { - await navigator.share(shareData); - return { url: payload.url, method: 'native' }; - } catch (error: unknown) { - if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') { - return { url: payload.url, method: 'native' }; - } - // fall through to clipboard - } - } - - if (await copyToClipboard(payload.url)) { - return { url: payload.url, method: 'clipboard' }; - } - - return { url: payload.url, method: 'manual' }; -} diff --git a/resources/js/guest/lib/taskUtils.ts b/resources/js/guest/lib/taskUtils.ts deleted file mode 100644 index 9384e9d4..00000000 --- a/resources/js/guest/lib/taskUtils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type TaskIdentity = { - id: number; -}; - -export function dedupeTasksById(tasks: T[]): T[] { - const seen = new Set(); - const unique: T[] = []; - - tasks.forEach((task) => { - if (seen.has(task.id)) { - return; - } - seen.add(task.id); - unique.push(task); - }); - - return unique; -} diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx deleted file mode 100644 index 6c424567..00000000 --- a/resources/js/guest/main.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { Suspense } from 'react'; -import { createRoot } from 'react-dom/client'; -import '../../css/app.css'; -import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode'; -import { Sentry, initSentry } from '@/lib/sentry'; -import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance'; -import { ConsentProvider } from '@/contexts/consent'; - -const GuestFallback: React.FC<{ message: string }> = ({ message }) => ( -
- {message} -
-); - -initSentry('guest'); -initializeTheme(); -if (shouldEnableGuestDemoMode()) { - enableGuestDemoMode(); -} -const rootEl = document.getElementById('root')!; - -const appRoot = async () => { - const { RouterProvider } = await import('react-router-dom'); - const { router } = await import('./router'); - const { ToastProvider } = await import('./components/ToastHost'); - const { default: PwaManager } = await import('./components/PwaManager'); - const { LocaleProvider } = await import('./i18n/LocaleContext'); - const { default: MatomoTracker } = await import('@/components/analytics/MatomoTracker'); - const rawMatomo = (window as any).__MATOMO_GUEST__ as { enabled?: boolean; url?: string; siteId?: string } | undefined; - const matomoConfig = rawMatomo - ? { - enabled: Boolean(rawMatomo.enabled), - url: rawMatomo.url ?? '', - siteId: rawMatomo.siteId ?? '', - } - : undefined; - - createRoot(rootEl).render( - }> - - - - - - - - }> - - - - - - - - - ); -}; - -appRoot().catch(() => { - createRoot(rootEl).render(); -}); diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx deleted file mode 100644 index 3fcad8f3..00000000 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ /dev/null @@ -1,572 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Link, useNavigationType, useParams } from 'react-router-dom'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Separator } from '@/components/ui/separator'; -import { AnimatePresence, motion } from 'framer-motion'; -import { - AchievementBadge, - AchievementsPayload, - FeedEntry, - LeaderboardEntry, - TimelinePoint, - TopPhotoHighlight, - TrendingEmotionHighlight, - fetchAchievements, -} from '../services/achievementApi'; -import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react'; -import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; -import type { LocaleCode } from '../i18n/messages'; -import { localizeTaskLabel } from '../lib/localizeTaskLabel'; -import { useEventData } from '../hooks/useEventData'; -import { isTaskModeEnabled } from '../lib/engagement'; -import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; -import PullToRefresh from '../components/PullToRefresh'; - -const GENERIC_ERROR = 'GENERIC_ERROR'; - -function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string { - const date = new Date(input); - if (Number.isNaN(date.getTime())) return ''; - const diff = date.getTime() - Date.now(); - const minute = 60_000; - const hour = 60 * minute; - const day = 24 * hour; - const abs = Math.abs(diff); - if (abs < minute) return formatter.format(0, 'second'); - if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute'); - if (abs < day) return formatter.format(Math.round(diff / hour), 'hour'); - return formatter.format(Math.round(diff / day), 'day'); -} - -type LeaderboardProps = { - title: string; - description: string; - icon: React.ElementType; - entries: LeaderboardEntry[]; - emptyCopy: string; - formatNumber: (value: number) => string; - t: TranslateFn; -}; - -function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, t }: LeaderboardProps) { - return ( - - -
- -
-
- {title} - {description} -
-
- - {entries.length === 0 ? ( -

{emptyCopy}

- ) : ( -
    - {entries.map((entry, index) => ( -
  1. -
    - #{index + 1} - {entry.guest || t('achievements.leaderboard.guestFallback')} -
    -
    - {t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })} - {t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })} -
    -
  2. - ))} -
- )} -
-
- ); -} - -type BadgesGridProps = { - badges: AchievementBadge[]; - t: TranslateFn; -}; - -export function BadgesGrid({ badges, t }: BadgesGridProps) { - if (badges.length === 0) { - return ( - - - {t('achievements.badges.title')} - {t('achievements.badges.description')} - - -

{t('achievements.badges.empty')}

-
-
- ); - } - - return ( - - - {t('achievements.badges.title')} - {t('achievements.badges.description')} - - - {badges.map((badge) => { - const target = badge.target ?? 0; - const progress = badge.progress ?? 0; - const ratio = target > 0 ? Math.min(1, progress / target) : 0; - const percentage = Math.round((badge.earned ? 1 : ratio) * 100); - return ( -
-
-
-

{badge.title}

-

{badge.description}

-
- {percentage}% -
-
-
-
-
- ); - })} - - - ); -} - -type TimelineProps = { - points: TimelinePoint[]; - t: TranslateFn; - formatNumber: (value: number) => string; -}; - -function Timeline({ points, t, formatNumber }: TimelineProps) { - return ( - - - {t('achievements.timeline.title')} - {t('achievements.timeline.description')} - - - {points.map((point) => ( -
- {point.date} - - {t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })} - -
- ))} -
-
- ); -} - -type FeedProps = { - feed: FeedEntry[]; - t: TranslateFn; - formatRelativeTime: (value: string) => string; - locale: LocaleCode; - formatNumber: (value: number) => string; -}; - -function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps) { - if (feed.length === 0) { - return ( - - - {t('achievements.feed.title')} - {t('achievements.feed.description')} - - -

{t('achievements.feed.empty')}

-
-
- ); - } - - return ( - - - {t('achievements.feed.title')} - {t('achievements.feed.description')} - - - {feed.map((item) => { - const taskLabel = localizeTaskLabel(item.task ?? null, locale); - return ( -
- {item.thumbnail ? ( - {t('achievements.feed.thumbnailAlt')} - ) : ( -
- -
- )} -
-

{item.guest || t('achievements.leaderboard.guestFallback')}

- {taskLabel &&

{t('achievements.feed.taskLabel', { task: taskLabel })}

} -
- {formatRelativeTime(item.createdAt)} - {t('achievements.feed.likesLabel', { count: formatNumber(item.likes) })} -
-
-
- ); - })} -
-
- ); -} - -type HighlightsProps = { - topPhoto: TopPhotoHighlight | null; - trendingEmotion: TrendingEmotionHighlight | null; - t: TranslateFn; - formatRelativeTime: (value: string) => string; - locale: LocaleCode; - formatNumber: (value: number) => string; -}; - -function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale, formatNumber }: HighlightsProps) { - if (!topPhoto && !trendingEmotion) { - return null; - } - - const renderTopPhoto = () => { - if (!topPhoto) return null; - const localizedTask = localizeTaskLabel(topPhoto.task ?? null, locale); - return ( - - -
- {t('achievements.highlights.topTitle')} - {t('achievements.highlights.topDescription')} -
- -
- -
- {topPhoto.thumbnail ? ( - {t('achievements.highlights.topTitle')} - ) : ( -
- {t('achievements.highlights.noPreview')} -
- )} -
-

- {topPhoto.guest || t('achievements.leaderboard.guestFallback')} - {` – ${t('achievements.highlights.likesAmount', { count: formatNumber(topPhoto.likes) })}`} -

- {localizedTask && ( -

- {t('achievements.highlights.taskLabel', { task: localizedTask })} -

- )} -

{formatRelativeTime(topPhoto.createdAt)}

-
-
- ); - }; - - const renderTrendingEmotion = () => { - if (!trendingEmotion) return null; - return ( - - -
- {t('achievements.highlights.trendingTitle')} - {t('achievements.highlights.trendingDescription')} -
- -
- -

{trendingEmotion.name}

-

- {t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })} -

-
-
- ); - }; - - return ( -
- {renderTopPhoto()} - {renderTrendingEmotion()} -
- ); -} - -type PersonalActionsProps = { - token: string; - t: TranslateFn; - tasksEnabled: boolean; -}; - -function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) { - return ( -
- - {tasksEnabled ? ( - - ) : null} -
- ); -} - -export default function AchievementsPage() { - const { token } = useParams<{ token: string }>(); - const navigationType = useNavigationType(); - const identity = useGuestIdentity(); - const { t, locale } = useTranslation(); - const { event } = useEventData(); - const tasksEnabled = isTaskModeEnabled(event); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal'); - - const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]); - const formatNumber = (value: number) => numberFormatter.format(value); - const relativeFormatter = useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]); - const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter); - - const personalName = identity.hydrated && identity.name ? identity.name : undefined; - - const loadAchievements = React.useCallback(async (signal?: AbortSignal) => { - if (!token) return; - setLoading(true); - setError(null); - - try { - const payload = await fetchAchievements(token, { - guestName: personalName, - locale, - signal, - }); - setData(payload); - if (!payload.personal) { - setActiveTab('event'); - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - console.error('Failed to load achievements', err); - setError(err instanceof Error ? err.message : GENERIC_ERROR); - } finally { - if (!signal?.aborted) { - setLoading(false); - } - } - }, [locale, personalName, token]); - - useEffect(() => { - const controller = new AbortController(); - void loadAchievements(controller.signal); - return () => controller.abort(); - }, [loadAchievements]); - - const hasPersonal = Boolean(data?.personal); - const motionEnabled = !prefersReducedMotion(); - const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); - const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); - const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); - const tabMotion = motionEnabled - ? { variants: FADE_UP, initial: 'hidden', animate: 'show', exit: 'hidden' as const } - : {}; - const handleRefresh = React.useCallback(async () => { - await loadAchievements(); - }, [loadAchievements]); - - if (!token) { - return null; - } - - const tabContent = ( - <> - {activeTab === 'personal' && hasPersonal && data?.personal && ( -
- - -
- - {t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })} - - - {t('achievements.personal.stats', { - photos: formatNumber(data.personal.photos), - tasks: formatNumber(data.personal.tasks), - likes: formatNumber(data.personal.likes), - })} - -
- -
-
- - -
- )} - - {activeTab === 'event' && data && ( -
- - -
- - -
-
- )} - - {activeTab === 'feed' && data && ( - - )} - - ); - - return ( - - - -
-
- -
-
-

{t('achievements.page.title')}

-

{t('achievements.page.subtitle')}

-
-
-
- - {loading && ( - - - - - - )} - - {!loading && error && ( - - - - {error === GENERIC_ERROR ? t('achievements.page.loadError') : error} - - - - - )} - - {!loading && !error && data && ( - <> - - - - - - - - - - - {motionEnabled ? ( - - - {tabContent} - - - ) : ( - {tabContent} - )} - - )} -
-
- ); -} diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx deleted file mode 100644 index 825562db..00000000 --- a/resources/js/guest/pages/HomePage.tsx +++ /dev/null @@ -1,1092 +0,0 @@ -import React from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Progress } from '@/components/ui/progress'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Separator } from '@/components/ui/separator'; -import { AnimatePresence, motion } from 'framer-motion'; -import EmotionPicker from '../components/EmotionPicker'; -import GalleryPreview from '../components/GalleryPreview'; -import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { useEventData } from '../hooks/useEventData'; -import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { ArrowLeft, ArrowRight, Camera, ChevronDown, Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react'; -import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; -import { useEventBranding } from '../context/EventBrandingContext'; -import type { EventBranding } from '../types/event-branding'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { EffectCards } from 'swiper/modules'; -import 'swiper/css'; -import 'swiper/css/effect-cards'; -import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme'; -import { getDeviceId } from '../lib/device'; -import { hexToRgb } from '../lib/color'; -import { useDirectUpload } from '../hooks/useDirectUpload'; -import { useNavigate } from 'react-router-dom'; -import { isTaskModeEnabled } from '../lib/engagement'; -import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; - -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})`; -} - -export default function HomePage() { - const { token } = useParams<{ token: string }>(); - const { name, hydrated } = useGuestIdentity(); - const { event } = useEventData(); - const { completedCount } = useGuestTaskProgress(token ?? ''); - const { t, locale } = useTranslation(); - const { branding } = useEventBranding(); - const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const radius = branding.buttons?.radius ?? 12; - - const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed'; - const [heroVisible, setHeroVisible] = React.useState(false); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - try { - const stored = window.sessionStorage.getItem(heroStorageKey); - // standardmäßig versteckt, nur sichtbar falls explizit gesetzt (kann später wieder aktiviert werden) - setHeroVisible(stored === 'show'); - } catch { - setHeroVisible(false); - } - }, [heroStorageKey]); - - React.useEffect(() => { - if (typeof document === 'undefined') { - return; - } - document.body.classList.remove('guest-immersive'); - document.body.classList.remove('guest-nav-visible'); - }, []); - - const dismissHero = React.useCallback(() => { - setHeroVisible(false); - if (typeof window === 'undefined') { - return; - } - - try { - window.sessionStorage.setItem(heroStorageKey, '1'); - } catch { - // ignore storage exceptions (e.g. private mode) - } - }, [heroStorageKey]); - - const displayName = hydrated && name ? name : t('home.fallbackGuestName'); - const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName'); - const accentColor = branding.primaryColor; - const secondaryAccent = branding.secondaryColor; - const glowPrimary = toRgba(accentColor, 0.35); - const glowSecondary = toRgba(secondaryAccent, 0.32); - const shimmerGradient = `linear-gradient(120deg, ${toRgba(accentColor, 0.22)}, transparent 45%, ${toRgba(secondaryAccent, 0.32)})`; - const welcomePanelStyle = React.useMemo(() => ({ - background: `linear-gradient(135deg, color-mix(in oklch, ${accentColor} 18%, var(--background)), color-mix(in oklch, ${secondaryAccent} 16%, var(--background)))`, - borderColor: toRgba(accentColor, 0.25), - }), [accentColor, secondaryAccent]); - const uploadsRequireApproval = - (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; - const tasksEnabled = isTaskModeEnabled(event); - const motionEnabled = !prefersReducedMotion(); - const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST); - const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); - const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); - const backdropStyle = React.useMemo(() => ({ - '--guest-glow-primary': glowPrimary, - '--guest-glow-secondary': glowSecondary, - }) as React.CSSProperties & Record, [glowPrimary, glowSecondary]); - - const renderWithBackdrop = (content: React.ReactNode) => ( -
-
-
-
-
-
-
-
-
- {content} -
-
- ); - - const [missionDeck, setMissionDeck] = React.useState([]); - const [missionPool, setMissionPool] = React.useState([]); - const [missionLoading, setMissionLoading] = React.useState(false); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); - const [hasMore, setHasMore] = React.useState(true); - const [page, setPage] = React.useState(1); - const [swipeCount, setSwipeCount] = React.useState(0); - const seenIdsRef = React.useRef>(new Set()); - const poolIndexRef = React.useRef(0); - const sliderStateKey = token ? `missionSliderIndex:${token}` : null; - const swiperRef = React.useRef(null); - - const advanceDeck = React.useCallback(() => { - if (swiperRef.current) { - swiperRef.current.slideNext(); - } - }, []); - - const normalizeTasks = React.useCallback( - (tasks: Record[]): MissionPreview[] => - tasks.map((task) => ({ - id: Number(task.id), - title: typeof task.title === 'string' ? task.title : 'Mission', - description: typeof task.description === 'string' ? task.description : '', - duration: typeof task.duration === 'number' ? task.duration : 3, - emotion: (task.emotion as EmotionIdentity) ?? null, - })), - [] - ); - - const mergeIntoPool = React.useCallback( - (incoming: MissionPreview[]) => { - const slugTitle = (title: string) => - title - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - setMissionPool((prev) => { - const byId = new Map(); - const byTitle = new Map(); - - const addCandidate = (candidate: MissionPreview) => { - if (byId.has(candidate.id)) return; - const titleKey = slugTitle(candidate.title); - if (byTitle.has(titleKey)) return; - byId.set(candidate.id, candidate); - byTitle.set(titleKey, candidate); - }; - - prev.forEach(addCandidate); - incoming.forEach(addCandidate); - - return Array.from(byId.values()); - }); - }, - [] - ); - - const fetchTasksPage = React.useCallback( - async (pageToFetch: number, isInitial: boolean = false) => { - if (!token) return; - if (isInitial) { - setMissionLoading(true); - } - setIsLoadingMore(true); - try { - const perPage = 20; - const response = await fetch( - `/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`, - { - headers: { - Accept: 'application/json', - 'X-Locale': locale, - 'X-Device-Id': getDeviceId(), - }, - } - ); - if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); - const payload = await response.json(); - - let items: Record[] = []; - let hasMoreFlag = false; - let nextPage = pageToFetch + 1; - - if (Array.isArray(payload)) { - items = payload; - hasMoreFlag = false; - } else if (payload && Array.isArray(payload.tasks)) { - items = payload.tasks; - hasMoreFlag = false; - } else if (payload && Array.isArray(payload.data)) { - items = payload.data; - if (payload.meta?.current_page && payload.meta?.last_page) { - hasMoreFlag = payload.meta.current_page < payload.meta.last_page; - nextPage = (payload.meta.current_page as number) + 1; - } else if (payload.next_page_url !== undefined) { - hasMoreFlag = Boolean(payload.next_page_url); - } else { - hasMoreFlag = items.length > 0; - } - } else { - hasMoreFlag = false; - } - - const normalized = normalizeTasks(items); - const deduped = normalized.filter((task) => { - if (seenIdsRef.current.has(task.id)) return false; - seenIdsRef.current.add(task.id); - return true; - }); - - if (deduped.length) { - mergeIntoPool(deduped); - } - - setHasMore(hasMoreFlag); - if (hasMoreFlag) { - setPage(nextPage); - } - } catch (err) { - console.warn('Mission fetch failed', err); - setHasMore(false); - } finally { - setIsLoadingMore(false); - if (isInitial) { - setMissionLoading(false); - } - } - }, - [locale, normalizeTasks, token] - ); - - React.useEffect(() => { - if (!token) return; - seenIdsRef.current = new Set(); - setMissionDeck([]); - setMissionPool([]); - setPage(1); - setHasMore(true); - // restore persisted slider position for this event - let restoredIndex = 0; - if (sliderStateKey && typeof window !== 'undefined') { - try { - const stored = window.sessionStorage.getItem(sliderStateKey); - if (stored) { - const parsed = Number(stored); - if (!Number.isNaN(parsed) && parsed >= 0) { - restoredIndex = parsed; - } - } - } catch { - restoredIndex = 0; - } - } - poolIndexRef.current = restoredIndex; - if (!tasksEnabled) return; - fetchTasksPage(1, true); - }, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]); - - React.useEffect(() => { - if (missionPool.length === 0) return; - if (poolIndexRef.current >= missionPool.length) { - poolIndexRef.current = poolIndexRef.current % missionPool.length; - } - setMissionDeck(missionPool); - }, [missionPool]); - - React.useEffect(() => { - if (!swiperRef.current) return; - if (!missionDeck.length) return; - const target = poolIndexRef.current % missionDeck.length; - const inst = swiperRef.current; - if (typeof inst.slideToLoop === 'function') { - inst.slideToLoop(target, 0); - } else if (typeof inst.slideTo === 'function') { - inst.slideTo(target, 0); - } - }, [missionDeck.length]); - - React.useEffect(() => { - if (missionLoading) return; - if (!hasMore || isLoadingMore) return; - // Prefetch when we are within 6 items of the end of the current pool - const remaining = missionPool.length - poolIndexRef.current; - if (remaining <= 6) { - fetchTasksPage(page); - } - }, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]); - - if (!token) { - return null; - } - - const introArray: string[] = []; - for (let i = 0; i < 12; i += 1) { - const candidate = t(`home.introRotating.${i}`, ''); - if (candidate) { - introArray.push(candidate); - } - } - const introMessageRef = React.useRef(null); - if (!introMessageRef.current) { - introMessageRef.current = - introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : ''; - } - const introMessage = introMessageRef.current; - - if (!tasksEnabled) { - return renderWithBackdrop( - - -
-
-

- {t('home.welcomeLine').replace('{name}', displayName)} -

- {introMessage &&

{introMessage}

} -
-
-
- - - - - - - - - - - - -
- ); - } - - return renderWithBackdrop( - - -
-
-

- {t('home.welcomeLine').replace('{name}', displayName)} -

- {introMessage && ( -

- {introMessage} -

- )} -
-
-
- - {heroVisible && ( - - - - )} - - -
- { - poolIndexRef.current = idx % Math.max(1, total); - if (sliderStateKey && typeof window !== 'undefined') { - try { - window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current)); - } catch { - // ignore storage errors - } - } - }} - swiperRef={swiperRef} - /> - - -
-
- - - - - - - - -
- ); -} - -function HeroCard({ - name, - eventName, - tasksCompleted, - t, - branding, - onDismiss, - ctaLabel, - ctaHref, -}: { - name: string; - eventName: string; - tasksCompleted: number; - t: TranslateFn; - branding: EventBranding; - onDismiss: () => void; - ctaLabel?: string; - ctaHref?: string; -}) { - const heroTitle = t('home.hero.title').replace('{name}', name); - const heroDescription = t('home.hero.description').replace('{eventName}', eventName); - const progressMessage = tasksCompleted > 0 - ? t('home.hero.progress.some').replace('{count}', `${tasksCompleted}`) - : t('home.hero.progress.none'); - - const style = React.useMemo(() => ({ - background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, - color: '#ffffff', - fontFamily: branding.fontFamily ?? undefined, - }), [branding.fontFamily, branding.primaryColor, branding.secondaryColor]); - - return ( - - - - {t('home.hero.subtitle')} - {heroTitle} -

{heroDescription}

-
-

{progressMessage}

- {ctaHref && ctaLabel && ( - - )} -
-
-
- ); -} - -type MissionPreview = { - id: number; - title: string; - description?: string; - duration?: number; - emotion?: EmotionIdentity | null; -}; - -export function MissionActionCard({ - token, - mission, - loading, - onAdvance, - stack, - initialIndex, - onIndexChange, - swiperRef, - swipeHintLabel, -}: { - token: string; - mission: MissionPreview | null; - loading: boolean; - onAdvance: () => void; - stack: MissionPreview[]; - initialIndex: number; - onIndexChange: (index: number, total: number) => void; - swiperRef: React.MutableRefObject; - swipeHintLabel?: string; -}) { - const { branding } = useEventBranding(); - const radius = branding.buttons?.radius ?? 12; - const primary = branding.buttons?.primary ?? branding.primaryColor; - const secondary = branding.buttons?.secondary ?? branding.secondaryColor; - const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const cards = mission ? [mission, ...stack] : stack; - const shellRadius = `${radius + 10}px`; - const motionEnabled = !prefersReducedMotion(); - const normalizeText = (value: string | undefined | null) => - (value ?? '').trim().toLowerCase().replace(/\s+/g, ' '); - const [expandedTaskId, setExpandedTaskId] = React.useState(null); - const lastSlideIndexRef = React.useRef(initialIndex); - const titleRefs = React.useRef(new Map()); - const [expandableTitles, setExpandableTitles] = React.useState>({}); - const [showSwipeHint, setShowSwipeHint] = React.useState(false); - const hintTimeoutRef = React.useRef(null); - const hintStorageKey = token ? `guestMissionSwipeHintSeen_${token}` : 'guestMissionSwipeHintSeen'; - const shouldShowHint = motionEnabled && cards.length > 1 && Boolean(swipeHintLabel); - const ctaPulseKey = token ? `guestMissionCtaPulse_${token}` : 'guestMissionCtaPulse'; - const [ctaPulse, setCtaPulse] = React.useState(false); - - React.useEffect(() => { - if (!motionEnabled || typeof window === 'undefined') { - return; - } - try { - if (window.sessionStorage.getItem(ctaPulseKey)) { - return; - } - } catch { - // ignore storage exceptions - } - - setCtaPulse(true); - const timeout = window.setTimeout(() => setCtaPulse(false), 4800); - try { - window.sessionStorage.setItem(ctaPulseKey, '1'); - } catch { - // ignore storage exceptions - } - return () => window.clearTimeout(timeout); - }, [ctaPulseKey, motionEnabled]); - - const dismissSwipeHint = React.useCallback((persist = true) => { - if (!showSwipeHint) { - return; - } - setShowSwipeHint(false); - if (hintTimeoutRef.current) { - window.clearTimeout(hintTimeoutRef.current); - hintTimeoutRef.current = null; - } - if (persist && typeof window !== 'undefined') { - try { - window.sessionStorage.setItem(hintStorageKey, '1'); - } catch { - // ignore storage exceptions - } - } - }, [hintStorageKey, showSwipeHint]); - - React.useEffect(() => { - if (!shouldShowHint) { - setShowSwipeHint(false); - return; - } - if (typeof window === 'undefined') { - return; - } - try { - if (window.sessionStorage.getItem(hintStorageKey)) { - setShowSwipeHint(false); - return; - } - } catch { - // ignore storage exceptions - } - - setShowSwipeHint(true); - hintTimeoutRef.current = window.setTimeout(() => { - setShowSwipeHint(false); - try { - window.sessionStorage.setItem(hintStorageKey, '1'); - } catch { - // ignore storage exceptions - } - }, 3200); - - return () => { - if (hintTimeoutRef.current) { - window.clearTimeout(hintTimeoutRef.current); - hintTimeoutRef.current = null; - } - }; - }, [hintStorageKey, shouldShowHint]); - - const measureTitleOverflow = React.useCallback(() => { - setExpandableTitles((prev) => { - let hasChange = false; - const next = { ...prev }; - - titleRefs.current.forEach((element, id) => { - if (!element || element.dataset.collapsed !== 'true') { - return; - } - const isOverflowing = element.scrollHeight > element.clientHeight + 1; - if (next[id] !== isOverflowing) { - next[id] = isOverflowing; - hasChange = true; - } - }); - - return hasChange ? next : prev; - }); - }, []); - - React.useLayoutEffect(() => { - if (typeof window === 'undefined') { - return; - } - const raf = window.requestAnimationFrame(measureTitleOverflow); - return () => window.cancelAnimationFrame(raf); - }, [measureTitleOverflow, cards, headingFont]); - - React.useEffect(() => { - if (typeof window === 'undefined') { - return; - } - const handleResize = () => { - window.requestAnimationFrame(measureTitleOverflow); - }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [measureTitleOverflow]); - - const renderCardContent = (card: MissionPreview | null) => { - const theme = getEmotionTheme(card?.emotion ?? null); - const emotionIcon = getEmotionIcon(card?.emotion ?? null); - const durationMinutes = card?.duration ?? 3; - const titleFont = headingFont ? { fontFamily: headingFont } : undefined; - const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`; - const isExpanded = card ? expandedTaskId === card.id : false; - const isExpandable = Boolean(card && expandableTitles[card.id]); - const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3'; - const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`; - const titleId = card ? `task-title-${card.id}` : undefined; - const ctaStyles = { - borderRadius: `${radius}px`, - background: `linear-gradient(120deg, ${primary}, ${secondary})`, - boxShadow: `0 12px 28px ${primary}25`, - '--cta-glow': toRgba(primary, 0.35), - '--cta-ring': toRgba(primary, 0.16), - } as React.CSSProperties & Record; - const toggleExpanded = () => { - if (!card) return; - setExpandedTaskId((prev) => (prev === card.id ? null : card.id)); - }; - - return ( -
-
-
-
- -
-
-
-
-
- - {emotionIcon} - -
-
- - {card?.emotion?.name ?? 'Fotoaufgabe'} - -
- - Foto-Challenge -
-
-
- -
- - ca. {durationMinutes} min -
-
- -
- {card ? ( - isExpandable ? ( - - ) : ( -

{ - if (card) { - titleRefs.current.set(card.id, node); - } - }} - data-collapsed={!isExpanded} - className={titleClasses} - style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }} - > - {card.title} -

- ) - ) : loading ? ( -
- - - -
- ) : ( -

Ziehe deine erste Mission oder wähle eine Stimmung.

- )} -
- - {card?.description && normalizeText(card.title) !== normalizeText(card.description) ? ( -
-
-
-

- {card.title} -

-

{card.description}

-
-
-
- ) : null} - -
- - -
-
-
- ); - }; - - const slides = cards.length ? cards : [mission ?? null]; - const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1)); - - return ( - - -
- - {showSwipeHint ? ( - -
- - - - {swipeHintLabel} - - - -
-
- ) : null} -
- 1} - initialSlide={initialSlide} - onSwiper={(instance) => { - swiperRef.current = instance; - if (initialSlide > 0) { - instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0); - } - }} - onSlideChange={(instance) => { - const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0; - if (realIndex !== lastSlideIndexRef.current) { - setExpandedTaskId(null); - dismissSwipeHint(); - } - lastSlideIndexRef.current = realIndex; - onIndexChange(realIndex, slides.length); - }} - className="!pb-1" - style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }} - > - {slides.map((card, index) => { - const key = `card-${card?.id ?? 'x'}-${index}`; - return ( - -
- {renderCardContent(card)} -
-
- ); - })} -
-
-
-
- ); -} - -function EmotionActionCard() { - return ( - - - Wähle eine Stimmung und erhalte eine passende Aufgabe - - Tippe deinen Mood, wir picken die nächste Mission für dich. - - - - - - - ); -} - -export function UploadActionCard({ - token, - accentColor, - secondaryAccent, - radius, - bodyFont, - requiresApproval, -}: { - token: string; - accentColor: string; - secondaryAccent: string; - radius: number; - bodyFont?: string; - requiresApproval: boolean; -}) { - const inputRef = React.useRef(null); - const [busy, setBusy] = React.useState(false); - const [message, setMessage] = React.useState(null); - const navigate = useNavigate(); - - const { upload, uploading, error, warning, progress, reset } = useDirectUpload({ - eventToken: token, - taskId: undefined, - emotionSlug: undefined, - onCompleted: () => { - setMessage(null); - navigate(`/e/${encodeURIComponent(token)}/gallery`); - }, - }); - - const onPick = React.useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setBusy(true); - setMessage(null); - try { - await upload(file); - } finally { - setBusy(false); - } - if (inputRef.current) { - inputRef.current.value = ''; - } - }, - [upload] - ); - - React.useEffect(() => { - if (error) { - setMessage(error); - } else if (warning) { - setMessage(warning); - } else { - setMessage(null); - } - }, [error, warning]); - - return ( - - - - -

- Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich – wir laden später hoch. -

- {requiresApproval ? ( -

- Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie. -

- ) : null} - {message && ( -

- {message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''} -

- )} -
-
- ); -} diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx deleted file mode 100644 index c561bff1..00000000 --- a/resources/js/guest/pages/LandingPage.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Sparkles, Camera, ShieldCheck, QrCode, PartyPopper, Smartphone } from 'lucide-react'; -import { Html5Qrcode } from 'html5-qrcode'; -import { readGuestName } from '../context/GuestIdentityContext'; -import { useTranslation } from '../i18n/useTranslation'; - -type LandingErrorKey = 'eventClosed' | 'network' | 'camera'; - -export default function LandingPage() { - const nav = useNavigate(); - const { t } = useTranslation(); - const [eventCode, setEventCode] = useState(''); - const [loading, setLoading] = useState(false); - const [errorKey, setErrorKey] = useState(null); - const [isScanning, setIsScanning] = useState(false); - const [scanner, setScanner] = useState(null); - const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null; - - function extractEventKey(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ''; - } - - try { - const url = new URL(trimmed); - const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token'); - if (inviteParam) { - return inviteParam; - } - const segments = url.pathname.split('/').filter(Boolean); - const eventIndex = segments.findIndex((segment) => segment === 'e'); - if (eventIndex >= 0 && segments.length > eventIndex + 1) { - return decodeURIComponent(segments[eventIndex + 1]); - } - if (segments.length > 0) { - return decodeURIComponent(segments[segments.length - 1]); - } - } catch { - // Not a URL, treat as raw code - } - - return trimmed; - } - - async function join(input?: string) { - const provided = input ?? eventCode; - const normalized = extractEventKey(provided); - if (!normalized) return; - setLoading(true); - setErrorKey(null); - try { - const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`); - if (!res.ok) { - setErrorKey('eventClosed'); - return; - } - const data = await res.json(); - const targetKey = data.join_token ?? ''; - if (!targetKey) { - setErrorKey('eventClosed'); - return; - } - const storedName = readGuestName(targetKey); - if (!storedName) { - nav(`/setup/${encodeURIComponent(targetKey)}`); - } else { - nav(`/e/${encodeURIComponent(targetKey)}`); - } - } catch (e) { - console.error('Join request failed', e); - setErrorKey('network'); - } finally { - setLoading(false); - } - } - - const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const; - - async function startScanner() { - if (scanner) { - try { - await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined); - setIsScanning(true); - } catch (err) { - console.error('Scanner start failed', err); - setErrorKey('camera'); - } - return; - } - - try { - const newScanner = new Html5Qrcode('qr-reader'); - setScanner(newScanner); - await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined); - setIsScanning(true); - } catch (err) { - console.error('Scanner initialisation failed', err); - setErrorKey('camera'); - } - } - - function stopScanner() { - if (!scanner) { - setIsScanning(false); - return; - } - scanner - .stop() - .then(() => { - setIsScanning(false); - }) - .catch((err) => console.error('Scanner stop failed', err)); - } - - async function onScanSuccess(decodedText: string) { - const value = decodedText.trim(); - if (!value) return; - await join(value); - stopScanner(); - } - - useEffect(() => () => { - if (scanner) { - scanner.stop().catch(() => undefined); - } - }, [scanner]); - - const heroFeatures = [ - { - icon: Sparkles, - title: t('landing.features.momentsTitle', 'Momente mit Wow-Effekt'), - description: t('landing.features.momentsCopy', 'Moderierte Fotoaufgaben motivieren dein Team und halten die Stimmung hoch.'), - }, - { - icon: Camera, - title: t('landing.features.uploadTitle', 'Uploads ohne App-Stress'), - description: t('landing.features.uploadCopy', 'Scan & Shoot: Gäste landen direkt im Event und teilen ihre Highlights live.'), - }, - { - icon: ShieldCheck, - title: t('landing.features.trustTitle', 'Sicher & DSGVO-konform'), - description: t('landing.features.trustCopy', 'Nur eingeladene Gäste erhalten Zugriff – mit Tokens, Rollenrechten und deutschem Hosting.'), - }, - ]; - - const highlightCards = [ - { - icon: Sparkles, - title: t('landing.highlight.story', 'Storytelling statt Sammelalbum'), - description: t('landing.highlight.storyCopy', 'Fotospiel verbindet Aufgaben, Emotionen und Uploads zu einer spannenden Timeline.'), - }, - { - icon: Camera, - title: t('landing.highlight.mobile', 'Optimiert für jedes Smartphone'), - description: t('landing.highlight.mobileCopy', 'Keine App-Installation nötig – einfach Link öffnen oder QR-Code scannen.'), - }, - { - icon: ShieldCheck, - title: t('landing.highlight.privacy', 'Transparente Freigaben'), - description: t('landing.highlight.privacyCopy', 'Admin- und Gästerollen sorgen dafür, dass nur autorisierte Personen Inhalte sehen.'), - }, - { - icon: PartyPopper, - title: t('landing.highlight.live', 'Live auf Screens & Slideshows'), - description: t('landing.highlight.liveCopy', 'Uploads können sofort auf Displays, Projektoren oder dem großen Screen erscheinen.'), - }, - ]; - - const steps = [ - { icon: QrCode, label: t('landing.steps.scan', 'QR-Code vom Event scannen oder Link öffnen.') }, - { icon: Smartphone, label: t('landing.steps.profile', 'Kurz vorstellen: Name eintragen und loslegen.') }, - { icon: PartyPopper, label: t('landing.steps.upload', 'Fotos aufnehmen, Aufgaben lösen, Erinnerungen teilen.') }, - ]; - - return ( -
-
-
-
-
- {errorMessage && ( - - {errorMessage} - - )} - -
-
-

{t('landing.pageTitle')}

-
-

- {t('landing.headline', 'Die Event-Landing, die zum Marketing passt.')} -

-

- {t('landing.subheadline', 'Fotospiel begrüßt deine Gäste mit einem warmen Erlebnis, noch bevor die erste Aufnahme entsteht.')} -

-
-
    - {heroFeatures.map((feature) => ( -
  • - - - -
    -

    {feature.title}

    -

    {feature.description}

    -
    -
  • - ))} -
-
- - {t('landing.tags.private', 'Nur für eingeladene Gäste')} - - - {t('landing.tags.instant', 'Live-Uploads & Aufgaben')} - - - {t('landing.tags.deHosted', 'Gehostet in Deutschland')} - -
-
- - - - - {t('landing.join.title')} - - - {t('landing.join.description')} - -
- {t('landing.join.subline', 'QR · Code · Link')} -
-
- -
-

{t('landing.scan.headline', 'QR-Code scannen')}

-

{t('landing.scan.subline', 'Nutze die Kamera deines Smartphones oder Tablets')}

-
-
- -
- -
- -
- {t('landing.scan.manualDivider')} -
- -
- setEventCode(event.target.value)} - placeholder={t('landing.input.placeholder')} - disabled={loading} - className="h-12 rounded-2xl border-slate-200 bg-white px-4 text-base" - /> - -
-

- {t('landing.hint.support', 'Du hast einen Link erhalten? Füge ihn direkt oben ein – wir erkennen den Event-Code automatisch.')} -

- - -
- -
-

- {t('landing.steps.title', 'So funktioniert Fotospiel')} -

-
- {steps.map(({ icon: Icon, label }) => ( -
- - - -

{label}

-
- ))} -
-
- -
- {highlightCards.map((feature) => ( -
-
- - - -

{feature.title}

-
-

{feature.description}

-
- ))} -
- -
-
-
-

- {t('landing.support.title', 'Support & Fragen')} -

-

- {t('landing.support.copy', 'Frag dein Event-Team oder melde dich bei uns – wir helfen sofort weiter.')} -

-
-
- - {t('landing.support.email', 'support@fotospiel.de')} - - - {t('landing.support.reply', 'Direkt auf die Einladung antworten')} - -
-
-
-
-
-
- ); -} diff --git a/resources/js/guest/pages/LegalPage.tsx b/resources/js/guest/pages/LegalPage.tsx deleted file mode 100644 index 2abd3ed1..00000000 --- a/resources/js/guest/pages/LegalPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from "react"; -import { Page } from './_util'; -import { useParams } from 'react-router-dom'; -import { LegalMarkdown } from '../components/legal-markdown'; - -export default function LegalPage() { - const { page } = useParams(); - const [loading, setLoading] = React.useState(true); - const [title, setTitle] = React.useState(''); - const [body, setBody] = React.useState(''); - const [html, setHtml] = React.useState(''); - - React.useEffect(() => { - if (!page) { - return; - } - const slug = page; - const controller = new AbortController(); - - async function loadLegal() { - try { - setLoading(true); - const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, { - headers: { 'Cache-Control': 'no-store' }, - signal: controller.signal, - }); - if (!res.ok) { - throw new Error('failed'); - } - const data = await res.json(); - setTitle(data.title || ''); - setBody(data.body_markdown || ''); - setHtml(data.body_html || ''); - } catch (error) { - if (!controller.signal.aborted) { - console.error('Failed to load legal page', error); - setTitle(''); - setBody(''); - setHtml(''); - } - } finally { - if (!controller.signal.aborted) { - setLoading(false); - } - } - } - - loadLegal(); - return () => controller.abort(); - }, [page]); - - const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen'; - - return ( - - {loading ?

Laedt...

: } -
- ); -} diff --git a/resources/js/guest/pages/LiveShowPlayerPage.tsx b/resources/js/guest/pages/LiveShowPlayerPage.tsx deleted file mode 100644 index 7235ffc5..00000000 --- a/resources/js/guest/pages/LiveShowPlayerPage.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; -import { Loader2, Maximize2, Minimize2, Pause, Play, WifiOff } from 'lucide-react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useLiveShowState } from '../hooks/useLiveShowState'; -import { useLiveShowPlayback } from '../hooks/useLiveShowPlayback'; -import LiveShowStage from '../components/LiveShowStage'; -import LiveShowBackdrop from '../components/LiveShowBackdrop'; -import { useTranslation } from '../i18n/useTranslation'; -import { prefersReducedMotion } from '../lib/motion'; -import { resolveLiveShowEffect } from '../lib/liveShowEffects'; - -export default function LiveShowPlayerPage() { - const { token } = useParams<{ token: string }>(); - const { t } = useTranslation(); - const { status, connection, error, event, photos, settings } = useLiveShowState(token ?? null); - const [paused, setPaused] = React.useState(false); - const { frame, layout, frameKey, nextFrame } = useLiveShowPlayback(photos, settings, { paused }); - const hasPhoto = frame.length > 0; - const stageTitle = event?.name ?? t('liveShowPlayer.title', 'Live Show'); - const reducedMotion = prefersReducedMotion(); - const effect = resolveLiveShowEffect(settings.effect_preset, settings.effect_intensity, reducedMotion); - const showStage = status === 'ready' && hasPhoto; - const showEmpty = status === 'ready' && !hasPhoto; - const [controlsVisible, setControlsVisible] = React.useState(true); - const [isFullscreen, setIsFullscreen] = React.useState(false); - const [isOnline, setIsOnline] = React.useState(typeof navigator !== 'undefined' ? navigator.onLine : true); - const hideTimerRef = React.useRef(null); - const preloadRef = React.useRef>(new Set()); - const stageRef = React.useRef(null); - - React.useEffect(() => { - document.body.classList.add('guest-immersive'); - return () => { - document.body.classList.remove('guest-immersive'); - }; - }, []); - - React.useEffect(() => { - const updateOnline = () => setIsOnline(navigator.onLine); - window.addEventListener('online', updateOnline); - window.addEventListener('offline', updateOnline); - return () => { - window.removeEventListener('online', updateOnline); - window.removeEventListener('offline', updateOnline); - }; - }, []); - - React.useEffect(() => { - const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement)); - document.addEventListener('fullscreenchange', handleFullscreen); - handleFullscreen(); - return () => document.removeEventListener('fullscreenchange', handleFullscreen); - }, []); - - const revealControls = React.useCallback(() => { - setControlsVisible(true); - if (hideTimerRef.current) { - window.clearTimeout(hideTimerRef.current); - } - hideTimerRef.current = window.setTimeout(() => { - setControlsVisible(false); - }, 3000); - }, []); - - React.useEffect(() => { - if (!showStage) { - setControlsVisible(true); - return; - } - revealControls(); - }, [revealControls, showStage, frameKey]); - - const togglePause = React.useCallback(() => { - setPaused((prev) => !prev); - }, []); - - const toggleFullscreen = React.useCallback(async () => { - const target = stageRef.current ?? document.documentElement; - try { - if (!document.fullscreenElement) { - await target.requestFullscreen?.(); - } else { - await document.exitFullscreen?.(); - } - } catch (err) { - console.warn('Fullscreen toggle failed', err); - } - }, []); - - React.useEffect(() => { - const handleKey = (event: KeyboardEvent) => { - if (event.target && (event.target as HTMLElement).closest('input, textarea, select, button')) { - return; - } - if (event.code === 'Space') { - event.preventDefault(); - togglePause(); - revealControls(); - } - if (event.key.toLowerCase() === 'f') { - event.preventDefault(); - toggleFullscreen(); - revealControls(); - } - if (event.key === 'Escape' && document.fullscreenElement) { - event.preventDefault(); - document.exitFullscreen?.(); - } - }; - - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [revealControls, toggleFullscreen, togglePause]); - - React.useEffect(() => { - const candidates = [...frame, ...nextFrame].slice(0, 6); - candidates.forEach((photo) => { - const src = photo.full_url || photo.thumb_url; - if (!src || preloadRef.current.has(src)) { - return; - } - const img = new Image(); - img.src = src; - preloadRef.current.add(src); - }); - }, [frame, nextFrame]); - - return ( -
- -
- - {stageTitle} - - - {connection === 'sse' - ? t('liveShowPlayer.connection.live', 'Live') - : t('liveShowPlayer.connection.sync', 'Sync')} - -
- - {status === 'loading' && ( -
- -

{t('liveShowPlayer.loading', 'Live Show wird geladen...')}

-
- )} - - {status === 'error' && ( -
-

- {t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')} -

-

- {error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')} -

-
- )} - - - {showStage && ( - - - - )} - - - {showStage && effect.flash && ( - - )} - - - {controlsVisible && ( - - - - {!isOnline && ( - - - {t('liveShowPlayer.controls.offline', 'Offline')} - - )} - - )} - - - {paused && showStage && ( -
-
- {t('liveShowPlayer.controls.paused', 'Paused')} -
-
- )} - - {showEmpty && ( -
-

- {t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')} -

-

{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}

-
- )} -
- ); -} diff --git a/resources/js/guest/pages/NotFoundPage.tsx b/resources/js/guest/pages/NotFoundPage.tsx deleted file mode 100644 index b6fd8261..00000000 --- a/resources/js/guest/pages/NotFoundPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { Page } from './_util'; -import { useTranslation } from '../i18n/useTranslation'; - -export default function NotFoundPage() { - const { t } = useTranslation(); - return ( - -

{t('notFound.description')}

-
- ); -} diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx deleted file mode 100644 index d94a4027..00000000 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ /dev/null @@ -1,612 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useLocation, useNavigate } from 'react-router-dom'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { Heart, ChevronLeft, ChevronRight, X, Share2, Download } from 'lucide-react'; -import { likePhoto, createPhotoShareLink } from '../services/photosApi'; -import { useTranslation } from '../i18n/useTranslation'; -import { useToast } from '../components/ToastHost'; -import ShareSheet from '../components/ShareSheet'; -import { useEventBranding } from '../context/EventBrandingContext'; -import { getDeviceId } from '../lib/device'; -import { triggerHaptic } from '../lib/haptics'; -import { useGesture } from '@use-gesture/react'; -import { animated, to, useSpring } from '@react-spring/web'; - -type Photo = { - id: number; - file_path?: string; - thumbnail_path?: string; - likes_count?: number; - created_at?: string; - task_id?: number; - task_title?: string; - uploader_name?: string | null; -}; - -type Task = { id: number; title: string }; - -interface Props { - photos?: Photo[]; - currentIndex?: number; - onClose?: () => void; - onIndexChange?: (index: number) => void; - token?: string; - eventName?: string | null; -} - -export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token, eventName }: Props) { - const params = useParams<{ token?: string; photoId?: string }>(); - const location = useLocation(); - const navigate = useNavigate(); - const photoId = params.photoId; - const eventToken = params.token || token; - const { t, locale } = useTranslation(); - const toast = useToast(); - const { branding } = useEventBranding(); - - const [standalonePhoto, setStandalonePhoto] = useState(null); - const [task, setTask] = useState(null); - const [taskLoading, setTaskLoading] = useState(false); - const [likes, setLikes] = useState(0); - const [liked, setLiked] = useState(false); - const [shareSheet, setShareSheet] = useState<{ url: string | null; loading: boolean }>({ - url: null, - loading: false, - }); - - // Determine mode and photo - const isStandalone = !photos || photos.length === 0; - const currentPhotos = isStandalone ? (standalonePhoto ? [standalonePhoto] : []) : photos || []; - const currentIndexVal = isStandalone ? 0 : (currentIndex || 0); - const photo = currentPhotos[currentIndexVal]; - - // Fallback onClose for standalone - const handleClose = onClose || (() => navigate(-1)); - - // Fetch single photo for standalone mode - useEffect(() => { - if (isStandalone && photoId && !standalonePhoto && eventToken) { - const fetchPhoto = async () => { - try { - const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, { - headers: { - Accept: 'application/json', - 'X-Locale': locale, - }, - }); - if (res.ok) { - const fetchedPhoto: Photo = await res.json(); - setStandalonePhoto(fetchedPhoto); - // Check state for initial photo - if (location.state?.photo) { - setStandalonePhoto(location.state.photo); - } - } else { - toast.push({ text: t('lightbox.errors.notFound'), type: 'error' }); - } - } catch (err) { - console.warn('Standalone photo load failed', err); - toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' }); - } - }; - - fetchPhoto(); - } - }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale, toast]); - - // Update likes when photo changes - React.useEffect(() => { - if (photo) { - setLikes(photo.likes_count ?? 0); - // Check if liked from localStorage - try { - const raw = localStorage.getItem('liked-photo-ids'); - const likedIds = raw ? JSON.parse(raw) : []; - setLiked(likedIds.includes(photo.id)); - } catch { - setLiked(false); - } - } - }, [photo]); - - const radius = branding.buttons?.radius ?? 12; - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? null; - const headingFont = branding.typography?.heading ?? branding.fontFamily ?? null; - - const zoomContainerRef = React.useRef(null); - const zoomImageRef = React.useRef(null); - const baseSizeRef = React.useRef({ width: 0, height: 0 }); - const scaleRef = React.useRef(1); - const lastTapRef = React.useRef(0); - const [isZoomed, setIsZoomed] = React.useState(false); - - const [{ x, y, scale }, api] = useSpring(() => ({ - x: 0, - y: 0, - scale: 1, - config: { tension: 260, friction: 28 }, - })); - - const updateBaseSize = React.useCallback(() => { - if (!zoomImageRef.current) { - return; - } - const rect = zoomImageRef.current.getBoundingClientRect(); - baseSizeRef.current = { width: rect.width, height: rect.height }; - }, []); - - React.useEffect(() => { - updateBaseSize(); - }, [photo?.id, updateBaseSize]); - - React.useEffect(() => { - window.addEventListener('resize', updateBaseSize); - return () => window.removeEventListener('resize', updateBaseSize); - }, [updateBaseSize]); - - const clamp = React.useCallback((value: number, min: number, max: number) => { - return Math.min(max, Math.max(min, value)); - }, []); - - const getBounds = React.useCallback( - (nextScale: number) => { - const container = zoomContainerRef.current?.getBoundingClientRect(); - const { width, height } = baseSizeRef.current; - if (!container || !width || !height) { - return { maxX: 0, maxY: 0 }; - } - const scaledWidth = width * nextScale; - const scaledHeight = height * nextScale; - const maxX = Math.max(0, (scaledWidth - container.width) / 2); - const maxY = Math.max(0, (scaledHeight - container.height) / 2); - return { maxX, maxY }; - }, - [] - ); - - const resetZoom = React.useCallback(() => { - scaleRef.current = 1; - setIsZoomed(false); - api.start({ x: 0, y: 0, scale: 1 }); - }, [api]); - - React.useEffect(() => { - resetZoom(); - }, [photo?.id, resetZoom]); - - const toggleZoom = React.useCallback(() => { - const nextScale = scaleRef.current > 1.01 ? 1 : 2; - scaleRef.current = nextScale; - setIsZoomed(nextScale > 1.01); - api.start({ x: 0, y: 0, scale: nextScale }); - }, [api]); - - const bind = useGesture( - { - onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => { - if (event.cancelable) { - event.preventDefault(); - } - const zoomed = scaleRef.current > 1.01; - if (!zoomed) { - api.start({ x: down ? mx : 0, y: 0, immediate: down }); - if (last) { - api.start({ x: 0, y: 0, immediate: false }); - const threshold = 80; - if (Math.abs(mx) > threshold) { - if (mx > 0 && currentIndexVal > 0) { - onIndexChange?.(currentIndexVal - 1); - } else if (mx < 0 && currentIndexVal < currentPhotos.length - 1) { - onIndexChange?.(currentIndexVal + 1); - } - } - } - return; - } - - const { maxX, maxY } = getBounds(scaleRef.current); - api.start({ - x: clamp(ox, -maxX, maxX), - y: clamp(oy, -maxY, maxY), - immediate: down, - }); - }, - onPinch: ({ offset: [nextScale], last, event }) => { - if (event.cancelable) { - event.preventDefault(); - } - const clampedScale = clamp(nextScale, 1, 3); - scaleRef.current = clampedScale; - setIsZoomed(clampedScale > 1.01); - const { maxX, maxY } = getBounds(clampedScale); - api.start({ - scale: clampedScale, - x: clamp(x.get(), -maxX, maxX), - y: clamp(y.get(), -maxY, maxY), - immediate: true, - }); - if (last && clampedScale <= 1.01) { - resetZoom(); - } - }, - }, - { - drag: { - from: () => [x.get(), y.get()], - filterTaps: true, - threshold: 4, - }, - pinch: { - scaleBounds: { min: 1, max: 3 }, - rubberband: true, - }, - eventOptions: { passive: false }, - } - ); - - const handlePointerUp = (event: React.PointerEvent) => { - if (event.pointerType !== 'touch') { - return; - } - const now = Date.now(); - if (now - lastTapRef.current < 280) { - lastTapRef.current = 0; - toggleZoom(); - return; - } - lastTapRef.current = now; - }; - - - // Load task info if photo has task_id and event key is available - React.useEffect(() => { - if (!photo?.task_id || !eventToken) { - setTask(null); - setTaskLoading(false); - return; - } - - const taskId = photo.task_id; - - (async () => { - setTaskLoading(true); - try { - const res = await fetch( - `/api/v1/events/${encodeURIComponent(eventToken)}/tasks?locale=${encodeURIComponent(locale)}`, - { - headers: { - Accept: 'application/json', - 'X-Locale': locale, - 'X-Device-Id': getDeviceId(), - }, - } - ); - if (res.ok) { - const payload = (await res.json()) as unknown; - const tasks = Array.isArray(payload) - ? payload - : Array.isArray((payload as any)?.data) - ? (payload as any).data - : Array.isArray((payload as any)?.tasks) - ? (payload as any).tasks - : []; - const foundTask = (tasks as Task[]).find((t) => t.id === taskId); - if (foundTask) { - setTask({ - id: foundTask.id, - title: foundTask.title || t('lightbox.fallbackTitle').replace('{id}', `${taskId}`) - }); - } else { - setTask({ - id: taskId, - title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) - }); - } - } else { - setTask({ - id: taskId, - title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) - }); - } - } catch (error) { - console.error('Failed to load task:', error); - setTask({ - id: taskId, - title: t('lightbox.unknownTitle').replace('{id}', `${taskId}`) - }); - } finally { - setTaskLoading(false); - } - })(); - }, [photo?.task_id, eventToken, t, locale]); - - async function onLike() { - if (liked || !photo) return; - setLiked(true); - try { - const count = await likePhoto(photo.id); - setLikes(count); - triggerHaptic('selection'); - // Update localStorage - try { - const raw = localStorage.getItem('liked-photo-ids'); - const arr: number[] = raw ? JSON.parse(raw) : []; - if (!arr.includes(photo.id)) { - localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id])); - } - } catch (storageError) { - console.warn('Failed to persist liked photo IDs', storageError); - } - } catch (error) { - console.error('Like failed:', error); - setLiked(false); - } - } - - const shareTitle = photo?.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto'); - const shareText = t('share.shareText', { event: eventName ?? shareTitle ?? 'Fotospiel' }); - const createdLabel = React.useMemo(() => { - if (!photo?.created_at) return null; - try { - const date = new Date(photo.created_at); - return date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }); - } catch { - return null; - } - }, [photo?.created_at, locale]); - - const uploaderInitial = React.useMemo(() => { - const name = photo?.uploader_name; - if (!name) return 'G'; - return (name.trim()[0] || 'G').toUpperCase(); - }, [photo?.uploader_name]); - - const primaryColor = branding.primaryColor || '#0ea5e9'; - const secondaryColor = branding.secondaryColor || '#6366f1'; - - async function openShareSheet() { - if (!photo || !eventToken) return; - setShareSheet({ url: null, loading: true }); - try { - const payload = await createPhotoShareLink(eventToken, photo.id); - setShareSheet({ url: payload.url, loading: false }); - } catch (error) { - console.error('share failed', error); - toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' }); - setShareSheet({ url: null, loading: false }); - } - } - - function shareWhatsApp(url?: string | null) { - if (!url) return; - const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`; - window.open(waUrl, '_blank', 'noopener'); - setShareSheet({ url: null, loading: false }); - } - - function shareMessages(url?: string | null) { - if (!url) return; - const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`; - window.open(smsUrl, '_blank', 'noopener'); - setShareSheet({ url: null, loading: false }); - } - - function shareNative(url?: string | null) { - if (!url) return; - const data: ShareData = { - title: shareTitle, - text: shareText, - url, - }; - if (navigator.share && (!navigator.canShare || navigator.canShare(data))) { - navigator.share(data).catch(() => {}); - setShareSheet({ url: null, loading: false }); - return; - } - void copyLink(url); - } - - async function copyLink(url?: string | null) { - if (!url) return; - try { - await navigator.clipboard?.writeText(url); - toast.push({ text: t('share.copySuccess', 'Link kopiert!') }); - } catch { - toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' }); - } finally { - setShareSheet({ url: null, loading: false }); - } - } - - function closeShareSheet() { - setShareSheet({ url: null, loading: false }); - } - - function onOpenChange(open: boolean) { - if (!open) handleClose(); - } - - return ( - - -
-
- -
-
- -
- {currentIndexVal + 1} / {currentPhotos.length} -
-
-
- - -
-
- -
-
- {currentIndexVal > 0 && ( - - )} - - `translate3d(${xValue}px, ${yValue}px, 0) scale(${scaleValue})` - ), - }} - > - {t('lightbox.photoAlt') { - console.error('Image load error:', e); - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - - {currentIndexVal < currentPhotos.length - 1 && ( - - )} -
- -
-
- - {uploaderInitial} - -
- {photo?.uploader_name ? ( -

{photo.uploader_name}

- ) : ( -

{t('galleryPage.photo.anonymous', 'Gast')}

- )} - {createdLabel ?

{createdLabel}

: null} -
-
-
- {task ? ( - - {t('lightbox.taskLabel')}: {task.title} - - ) : null} -
- - - -
-
-
- - {taskLoading && !task && ( -
-
- {t('lightbox.loadingTask')} -
- )} -
-
- - shareNative(shareSheet.url)} - onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} - onShareMessages={() => shareMessages(shareSheet.url)} - onCopyLink={() => copyLink(shareSheet.url)} - radius={radius} - bodyFont={bodyFont} - headingFont={headingFont} - /> - -
- ); -} diff --git a/resources/js/guest/pages/ProfileSetupPage.tsx b/resources/js/guest/pages/ProfileSetupPage.tsx deleted file mode 100644 index d3d94f01..00000000 --- a/resources/js/guest/pages/ProfileSetupPage.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useEventData } from '../hooks/useEventData'; -import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { useTranslation } from '../i18n/useTranslation'; -import { motion } from 'framer-motion'; -import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; - -export default function ProfileSetupPage() { - const { token } = useParams<{ token: string }>(); - const nav = useNavigate(); - const { event, loading, error } = useEventData(); - const { name: storedName, setName: persistName, hydrated } = useGuestIdentity(); - const [name, setName] = useState(storedName); - const [submitting, setSubmitting] = useState(false); - const { t } = useTranslation(); - const motionEnabled = !prefersReducedMotion(); - const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST); - const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); - const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); - - useEffect(() => { - if (!token) { - nav('/'); - return; - } - }, [token, nav]); - - useEffect(() => { - if (hydrated) { - setName(storedName); - } - }, [hydrated, storedName]); - - function handleChange(value: string) { - setName(value); - } - - function submitName() { - if (!token) return; - const trimmedName = name.trim(); - if (!trimmedName) return; - - setSubmitting(true); - try { - persistName(trimmedName); - nav(`/e/${token}`); - } catch (e) { - console.error('Fehler beim Speichern des Namens:', e); - setSubmitting(false); - } - } - - if (loading) { - return ( -
-
{t('profileSetup.loading')}
-
- ); - } - - if (error || !event) { - return ( -
-

{error || t('profileSetup.error.default')}

- -
- ); - } - - return ( - - - - - - {event.name} - - {t('profileSetup.card.description')} - - - -
- - handleChange(e.target.value)} - placeholder={t('profileSetup.form.placeholder')} - className="text-lg" - disabled={submitting || !hydrated} - autoComplete="name" - /> -
- -
-
-
-
-
- ); -} diff --git a/resources/js/guest/pages/SettingsPage.tsx b/resources/js/guest/pages/SettingsPage.tsx deleted file mode 100644 index e38e02ba..00000000 --- a/resources/js/guest/pages/SettingsPage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Page } from './_util'; -import { useTranslation } from '../i18n/useTranslation'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Switch } from '@/components/ui/switch'; -import { useHapticsPreference } from '../hooks/useHapticsPreference'; -import { triggerHaptic } from '../lib/haptics'; - -export default function SettingsPage() { - const { t } = useTranslation(); - const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference(); - return ( - -

{t('settings.subtitle')}

-
- - - {t('settings.haptics.title')} - {t('settings.haptics.description')} - - -
- {t('settings.haptics.label')} - { - setHapticsEnabled(checked); - if (checked) { - triggerHaptic('selection'); - } - }} - disabled={!hapticsSupported} - aria-label={t('settings.haptics.label')} - /> -
- {!hapticsSupported && ( -
{t('settings.haptics.unsupported')}
- )} -
-
-
-
- ); -} diff --git a/resources/js/guest/pages/TaskDetailPage.tsx b/resources/js/guest/pages/TaskDetailPage.tsx deleted file mode 100644 index a965ef71..00000000 --- a/resources/js/guest/pages/TaskDetailPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { Page } from './_util'; - -export default function TaskDetailPage() { - return ( - -

Aufgabenbeschreibung, Dauer, Gruppengröße.

-
- ); -} - diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx deleted file mode 100644 index 9146619a..00000000 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ /dev/null @@ -1,799 +0,0 @@ -import React from 'react'; -import { useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; -import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { cn } from '@/lib/utils'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { useEventBranding } from '../context/EventBrandingContext'; -import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; -import { motion } from 'framer-motion'; -import { - getEmotionIcon, - getEmotionTheme, - type EmotionIdentity, - type EmotionTheme, -} from '../lib/emotionTheme'; -import { getDeviceId } from '../lib/device'; -import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; -import PullToRefresh from '../components/PullToRefresh'; -import { triggerHaptic } from '../lib/haptics'; -import { dedupeTasksById } from '../lib/taskUtils'; - -interface Task { - id: number; - title: string; - description: string; - instructions: string; - duration: number; // minutes - emotion?: { - slug: string; - name: string; - }; - is_completed: boolean; -} - -type EmotionOption = { - slug: string; - name: string; -}; - -type EventPhoto = { - id: number; - thumbnail_path?: string | null; - file_path?: string | null; - likes_count?: number | null; - task_id?: number | null; -}; - -const SWIPE_THRESHOLD_PX = 40; -const SIMILAR_PHOTO_LIMIT = 6; - -export default function TaskPickerPage() { - const { token } = useParams<{ token: string }>(); - const eventKey = token ?? ''; - const navigate = useNavigate(); - const navigationType = useNavigationType(); - const [searchParams, setSearchParams] = useSearchParams(); - const { branding } = useEventBranding(); - const { t, locale } = useTranslation(); - const radius = branding.buttons?.radius ?? 12; - const buttonStyle = branding.buttons?.style ?? 'filled'; - const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; - - const { isCompleted } = useGuestTaskProgress(eventKey); - - const [tasks, setTasks] = React.useState([]); - const [currentTask, setCurrentTask] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [selectedEmotion, setSelectedEmotion] = React.useState('all'); - const [isFetching, setIsFetching] = React.useState(false); - const [photoPool, setPhotoPool] = React.useState([]); - const [photoPoolLoading, setPhotoPoolLoading] = React.useState(false); - const [photoPoolError, setPhotoPoolError] = React.useState(null); - const [hasSwiped, setHasSwiped] = React.useState(false); - const [emotionPickerOpen, setEmotionPickerOpen] = React.useState(false); - const [recentEmotionSlug, setRecentEmotionSlug] = React.useState(null); - - const heroCardRef = React.useRef(null); - - const cameraButtonStyle = React.useMemo(() => ({ - background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`, - boxShadow: `0 18px 30px ${branding.primaryColor}44`, - color: '#ffffff', - }), [branding.primaryColor, branding.secondaryColor]); - - const recentTaskIdsRef = React.useRef([]); - const tasksCacheRef = React.useRef>(new Map()); - const initialEmotionRef = React.useRef(false); - - const fetchTasks = React.useCallback(async () => { - if (!eventKey) return; - const cacheKey = `${eventKey}:${locale}`; - const cached = tasksCacheRef.current.get(cacheKey); - setIsFetching(true); - setLoading(!cached); - setError(null); - - if (cached) { - setTasks(cached.data); - } - - try { - const headers: HeadersInit = { - Accept: 'application/json', - 'X-Locale': locale, - 'X-Device-Id': getDeviceId(), - }; - - if (cached?.etag) { - headers['If-None-Match'] = cached.etag; - } - - const response = await fetch( - `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, - { headers } - ); - - if (response.status === 304 && cached) { - return; - } - - if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); - const payload = await response.json(); - const taskList: Task[] = Array.isArray(payload) - ? payload - : Array.isArray(payload?.data) - ? payload.data - : Array.isArray(payload?.tasks) - ? payload.tasks - : []; - - const uniqueTasks = dedupeTasksById(taskList); - const entry = { data: uniqueTasks, etag: response.headers.get('ETag') }; - tasksCacheRef.current.set(cacheKey, entry); - setTasks(uniqueTasks); - } catch (err) { - console.error('Failed to load tasks', err); - setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); - if (!cached) { - setTasks([]); - } - } finally { - setIsFetching(false); - setLoading(false); - } - }, [eventKey, locale]); - - React.useEffect(() => { - fetchTasks(); - }, [fetchTasks]); - - React.useEffect(() => { - if (initialEmotionRef.current) return; - const queryEmotion = searchParams.get('emotion'); - if (queryEmotion) { - setSelectedEmotion(queryEmotion); - } - initialEmotionRef.current = true; - }, [searchParams]); - - const emotionOptions = React.useMemo(() => { - const map = new Map(); - tasks.forEach((task) => { - if (task.emotion?.slug) { - map.set(task.emotion.slug, task.emotion.name); - } - }); - return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name })); - }, [tasks]); - - const emotionCounts = React.useMemo(() => { - const map = new Map(); - tasks.forEach((task) => { - const slugValue = task.emotion?.slug; - if (!slugValue) return; - map.set(slugValue, (map.get(slugValue) ?? 0) + 1); - }); - return map; - }, [tasks]); - - const filteredTasks = React.useMemo(() => { - if (selectedEmotion === 'all') return tasks; - return tasks.filter((task) => task.emotion?.slug === selectedEmotion); - }, [tasks, selectedEmotion]); - - const alternativeTasks = React.useMemo(() => { - return filteredTasks.filter((task) => task.id !== currentTask?.id).slice(0, 6); - }, [filteredTasks, currentTask]); - - const selectRandomTask = React.useCallback( - (list: Task[]) => { - if (!list.length) { - setCurrentTask(null); - return; - } - const avoidIds = recentTaskIdsRef.current; - const available = list.filter((task) => !isCompleted(task.id)); - const base = available.length ? available : list; - let candidates = base.filter((task) => !avoidIds.includes(task.id)); - if (!candidates.length) { - candidates = base; - } - const chosen = candidates[Math.floor(Math.random() * candidates.length)]; - setCurrentTask(chosen); - recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3); - }, - [isCompleted] - ); - - const handleSelectEmotion = React.useCallback( - (slugValue: string) => { - setSelectedEmotion(slugValue); - const next = new URLSearchParams(searchParams.toString()); - if (slugValue === 'all') { - next.delete('emotion'); - } else { - next.set('emotion', slugValue); - setRecentEmotionSlug(slugValue); - } - setSearchParams(next, { replace: true }); - }, - [searchParams, setSearchParams] - ); - - const handleNewTask = React.useCallback(() => { - selectRandomTask(filteredTasks); - triggerHaptic('selection'); - }, [filteredTasks, selectRandomTask]); - - const handleStartUpload = () => { - if (!currentTask || !eventKey) return; - triggerHaptic('light'); - navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`); - }; - - const handleViewSimilar = React.useCallback(() => { - if (!currentTask || !eventKey) return; - navigate(`/e/${encodeURIComponent(eventKey)}/gallery?task=${currentTask.id}`); - }, [currentTask, eventKey, navigate]); - - const handleSelectTask = React.useCallback((task: Task) => { - setCurrentTask(task); - triggerHaptic('selection'); - }, []); - - const handleRetryFetch = () => { - fetchTasks(); - }; - - const handleRefresh = React.useCallback(async () => { - tasksCacheRef.current.clear(); - await fetchTasks(); - setPhotoPool([]); - setPhotoPoolError(null); - }, [fetchTasks]); - - const handlePhotoPreview = React.useCallback( - (photoId: number) => { - if (!eventKey) return; - navigate(`/e/${encodeURIComponent(eventKey)}/gallery?photoId=${photoId}&task=${currentTask?.id ?? ''}`); - }, - [eventKey, navigate, currentTask?.id] - ); - - React.useEffect(() => { - if (!filteredTasks.length) { - setCurrentTask(null); - return; - } - if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) { - selectRandomTask(filteredTasks); - } - }, [filteredTasks, currentTask, selectRandomTask]); - - React.useEffect(() => { - if (currentTask?.emotion?.slug) { - setRecentEmotionSlug(currentTask.emotion.slug); - } - }, [currentTask?.emotion?.slug]); - - React.useEffect(() => { - if (!eventKey || photoPool.length) return; - const controller = new AbortController(); - setPhotoPoolLoading(true); - setPhotoPoolError(null); - fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=${encodeURIComponent(locale)}`, { - signal: controller.signal, - headers: { - Accept: 'application/json', - 'X-Locale': locale, - }, - }) - .then((res) => { - if (!res.ok) { - throw new Error(t('tasks.page.inspirationError')); - } - return res.json(); - }) - .then((payload) => { - const data = Array.isArray(payload?.data) ? (payload.data as EventPhoto[]) : []; - setPhotoPool(data); - }) - .catch((err) => { - if (controller.signal.aborted) return; - console.error('Failed to load photos', err); - setPhotoPoolError(t('tasks.page.inspirationError')); - }) - .finally(() => { - if (!controller.signal.aborted) { - setPhotoPoolLoading(false); - } - }); - - return () => controller.abort(); - }, [eventKey, photoPool.length, t, locale]); - - const similarPhotos = React.useMemo(() => { - if (!currentTask) return []; - const matches = photoPool.filter((photo) => photo.task_id === currentTask.id); - return matches.slice(0, SIMILAR_PHOTO_LIMIT); - }, [photoPool, currentTask]); - - React.useEffect(() => { - const card = heroCardRef.current; - if (!card) return; - let startX: number | null = null; - let startY: number | null = null; - - const onTouchStart = (event: TouchEvent) => { - const touch = event.touches[0]; - startX = touch.clientX; - startY = touch.clientY; - }; - - const onTouchEnd = (event: TouchEvent) => { - if (startX === null || startY === null) return; - const touch = event.changedTouches[0]; - const deltaX = touch.clientX - startX; - const deltaY = touch.clientY - startY; - if (Math.abs(deltaX) > SWIPE_THRESHOLD_PX && Math.abs(deltaY) < 60) { - if (deltaX < 0) { - handleNewTask(); - } else { - handleViewSimilar(); - } - setHasSwiped(true); - } - startX = null; - startY = null; - }; - - card.addEventListener('touchstart', onTouchStart, { passive: true }); - card.addEventListener('touchend', onTouchEnd); - return () => { - card.removeEventListener('touchstart', onTouchStart); - card.removeEventListener('touchend', onTouchEnd); - }; - }, [handleNewTask, handleViewSimilar]); - - const emptyState = !loading && (!filteredTasks.length || !currentTask); - const heroTheme = React.useMemo(() => getEmotionTheme(currentTask?.emotion ?? null), [currentTask?.emotion]); - const heroEmotionIcon = getEmotionIcon(currentTask?.emotion ?? null); - const recentEmotionOption = React.useMemo( - () => emotionOptions.find((option) => option.slug === recentEmotionSlug) ?? null, - [emotionOptions, recentEmotionSlug] - ); - const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent'; - const motionEnabled = !prefersReducedMotion(); - const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType); - const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP); - const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE); - - const handleToggleChange = React.useCallback( - (value: string) => { - if (!value) return; - if (value === 'picker') { - setEmotionPickerOpen(true); - return; - } - if (value === 'none') { - handleSelectEmotion('all'); - return; - } - if (value === 'recent') { - if (recentEmotionSlug) { - handleSelectEmotion(recentEmotionSlug); - } else { - setEmotionPickerOpen(true); - } - } - }, - [handleSelectEmotion, recentEmotionSlug] - ); - - return ( - <> - - - -
-

{t('tasks.page.eyebrow')}

-

{t('tasks.page.title')}

-

{t('tasks.page.subtitle')}

-
- {emotionOptions.length > 0 && ( - - - - 🎲 - {t('tasks.page.filters.none')} - - - {getEmotionIcon(recentEmotionOption)} - {recentEmotionOption?.name ?? t('tasks.page.filters.recentFallback')} - - - 🗂️ - {t('tasks.page.filters.showAll')} - - - - )} -
- - {loading && ( - - - - - )} - - {error && !loading && ( - - - - {error} - - - - - )} - - {emptyState && ( - - - - )} - - {!emptyState && currentTask && ( - - -
-
- - - {heroEmotionIcon} {currentTask.emotion?.name ?? 'Neue Mission'} - - - - {currentTask.duration} Min - -
- -
-

{currentTask.title}

-

{currentTask.description}

-
- - {!hasSwiped && ( -

- {t('tasks.page.swipeHint')} -

- )} - - {currentTask.instructions && ( -
- {currentTask.instructions} -
- )} - -
- {isCompleted(currentTask.id) && ( - - - {t('tasks.page.completedLabel')} - - )} -
- -
- - -
- - {(photoPoolLoading || photoPoolError || similarPhotos.length > 0) && ( -
-
- {t('tasks.page.inspirationTitle')} - {photoPoolLoading && {t('tasks.page.inspirationLoading')}} -
- {photoPoolError && similarPhotos.length === 0 ? ( -

{photoPoolError}

- ) : similarPhotos.length > 0 ? ( -
- {similarPhotos.map((photo) => ( - - ))} - -
- ) : ( - - )} -
- )} -
-
- - {alternativeTasks.length > 0 && ( - -
-
-

{t('tasks.page.suggestionsEyebrow')}

-

{t('tasks.page.suggestionsTitle')}

-
- -
-
- {alternativeTasks.map((task) => ( - - ))} -
-
- )} -
- )} - - {!loading && !tasks.length && !error && ( - - - {t('tasks.page.noTasksAlert')} - - - )} -
-
- - - - {t('tasks.page.filters.dialogTitle')} - - {emotionOptions.length ? ( -
- {emotionOptions.map((emotion) => { - const count = emotionCounts.get(emotion.slug) ?? 0; - return ( - - ); - })} -
- ) : ( -

{t('tasks.page.filters.empty')}

- )} -
-
- - ); -} - -function SkeletonBlock() { - return
; -} - -function EmptyState({ - hasTasks, - onRetry, - emotionOptions, - onEmotionSelect, - t, -}: { - hasTasks: boolean; - onRetry: () => void; - emotionOptions: EmotionOption[]; - onEmotionSelect: (slug: string) => void; - t: TranslateFn; -}) { - return ( -
- -
-

{t('tasks.page.emptyTitle')}

-

- {hasTasks ? t('tasks.page.emptyDescriptionWithTasks') : t('tasks.page.emptyDescriptionNoTasks')} -

-
- {hasTasks && emotionOptions.length > 0 && ( -
- {emotionOptions.map((emotion) => ( - - ))} -
- )} - -
- ); -} - -function HeroActionButton({ - icon: Icon, - label, - detail, - onClick, - className, -}: { - icon: LucideIcon; - label: string; - detail?: string; - onClick: () => void; - className?: string; -}) { - return ( - - ); -} - -function SimilarPhotoChip({ photo, onOpen }: { photo: EventPhoto; onOpen: (photoId: number) => void }) { - const cover = photo.thumbnail_path || photo.file_path || ''; - return ( - - ); -} - -function TaskSuggestionCard({ task, onSelect }: { task: Task; onSelect: (task: Task) => void }) { - const theme = getEmotionTheme(task.emotion ?? null); - const emotionIcon = getEmotionIcon(task.emotion ?? null); - return ( - - ); -} diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx deleted file mode 100644 index 62a523b7..00000000 --- a/resources/js/guest/pages/UploadPage.tsx +++ /dev/null @@ -1,1701 +0,0 @@ -// @ts-nocheck -import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Switch } from '@/components/ui/switch'; -import { motion } from 'framer-motion'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { uploadPhoto, type UploadError } from '../services/photosApi'; -import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; -import { cn } from '@/lib/utils'; -import { - AlertTriangle, - Check, - Camera, - ChevronDown, - Grid3X3, - Menu, - ImagePlus, - Info, - Loader2, - Timer, - Sparkles, - FlipHorizontal, - X, - Zap, - ZapOff, -} from 'lucide-react'; -import { getEventPackage, type EventPackage } from '../services/eventApi'; -import { isGuestDemoModeEnabled } from '../demo/demoMode'; -import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; -import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; -import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; -import { useEventStats } from '../context/EventStatsContext'; -import { useEventBranding } from '../context/EventBrandingContext'; -import DemoReadOnlyNotice from '../components/DemoReadOnlyNotice'; -import { compressPhoto, formatBytes } from '../lib/image'; -import { FADE_SCALE, FADE_UP, prefersReducedMotion } from '../lib/motion'; -import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { useEventData } from '../hooks/useEventData'; -import { isTaskModeEnabled } from '../lib/engagement'; -import { getDeviceId } from '../lib/device'; - -interface Task { - id: number; - title: string; - description: string; - instructions?: string; - duration: number; - emotion?: { slug: string; name: string }; - difficulty?: 'easy' | 'medium' | 'hard'; -} - -type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported' | 'blocked'; -type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading'; - -type CameraPreferences = { - facingMode: 'user' | 'environment'; - countdownSeconds: number; - countdownEnabled: boolean; - gridEnabled: boolean; - mirrorFrontPreview: boolean; - flashPreferred: boolean; -}; - -type TaskPayload = Partial & { id: number }; - -function isTaskPayload(value: unknown): value is TaskPayload { - if (typeof value !== 'object' || value === null) { - return false; - } - - const candidate = value as { id?: unknown }; - return typeof candidate.id === 'number'; -} - -function getErrorName(error: unknown): string | undefined { - if (typeof error === 'object' && error !== null && 'name' in error) { - const name = (error as { name?: unknown }).name; - return typeof name === 'string' ? name : undefined; - } - - return undefined; -} - -function isCameraBlockedByPolicy(): boolean { - if (typeof document === 'undefined') { - return false; - } - - const policy = (document as { permissionsPolicy?: { allowsFeature?: (feature: string) => boolean } }) - .permissionsPolicy; - - if (!policy?.allowsFeature) { - return false; - } - - return !policy.allowsFeature('camera'); -} - -const DEFAULT_PREFS: CameraPreferences = { - facingMode: 'environment', - countdownSeconds: 3, - countdownEnabled: true, - gridEnabled: true, - mirrorFrontPreview: true, - flashPreferred: false, -}; - -const LIMIT_CARD_STYLES: Record = { - neutral: { - card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white', - badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white', - bar: 'bg-emerald-500', - }, - warning: { - card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50', - badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50', - bar: 'bg-amber-500', - }, - danger: { - card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50', - badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50', - bar: 'bg-rose-500', - }, -}; - - -export default function UploadPage() { - const { token } = useParams<{ token: string }>(); - const eventKey = token ?? ''; - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { markCompleted } = useGuestTaskProgress(token); - const identity = useGuestIdentity(); - const { event } = useEventData(); - const tasksEnabled = isTaskModeEnabled(event); - const { t, locale } = useTranslation(); - const stats = useEventStats(); - const { branding } = useEventBranding(); - const radius = branding.buttons?.radius ?? 12; - const buttonStyle = branding.buttons?.style ?? 'filled'; - const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const uploadsRequireApproval = - (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; - const demoModeActive = isGuestDemoModeEnabled(); - const demoReadOnly = Boolean(event?.demo_read_only) || demoModeActive; - const liveShowModeration = event?.live_show?.moderation_mode ?? 'manual'; - const motionEnabled = !prefersReducedMotion(); - const overlayMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_SCALE } : {}; - const fadeUpMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_UP } : {}; - - const taskIdParam = searchParams.get('task'); - const emotionSlug = searchParams.get('emotion') || ''; - - const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed'; - const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs'; - - const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; - - const [task, setTask] = useState(null); - const [loadingTask, setLoadingTask] = useState(true); - - const [permissionState, setPermissionState] = useState('idle'); - const [permissionMessage, setPermissionMessage] = useState(null); - - const [preferences, setPreferences] = useState(DEFAULT_PREFS); - const [mode, setMode] = useState('preview'); - const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds); - const [statusMessage, setStatusMessage] = useState(''); - - const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); -const [uploadProgress, setUploadProgress] = useState(0); -const [uploadError, setUploadError] = useState(null); -const [uploadWarning, setUploadWarning] = useState(null); -const [immersiveMode, setImmersiveMode] = useState(true); -const [showCelebration, setShowCelebration] = useState(false); -const [showHeroOverlay, setShowHeroOverlay] = useState(true); -const kpiChipsRef = useRef(null); -const navSentinelRef = useRef(null); - - const [errorDialog, setErrorDialog] = useState(null); - const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false); - -const [eventPackage, setEventPackage] = useState(null); -const [canUpload, setCanUpload] = useState(true); -const [submitToLive, setSubmitToLive] = useState(true); - - const limitCards = useMemo( - () => buildLimitSummaries(eventPackage?.limits ?? null, t), - [eventPackage?.limits, t] - ); - - useEffect(() => { - if (typeof document === 'undefined') return undefined; - const className = 'guest-immersive'; - document.body.classList.add(className); - - return () => { - document.body.classList.remove(className); - document.body.classList.remove('guest-nav-visible'); - }; - }, []); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - const root = document.documentElement; - const updateViewportVar = () => { - const viewport = window.visualViewport?.height ?? window.innerHeight; - root.style.setProperty('--guest-viewport-height', `${viewport}px`); - }; - - updateViewportVar(); - window.visualViewport?.addEventListener('resize', updateViewportVar); - window.visualViewport?.addEventListener('scroll', updateViewportVar); - window.addEventListener('resize', updateViewportVar); - - return () => { - window.visualViewport?.removeEventListener('resize', updateViewportVar); - window.visualViewport?.removeEventListener('scroll', updateViewportVar); - window.removeEventListener('resize', updateViewportVar); - }; - }, []); - - useEffect(() => { - if (!event) return; - setSubmitToLive(true); - }, [event?.slug]); - - const updateNavVisibility = useCallback(() => { - if (typeof document === 'undefined') { - return; - } - const shouldShow = typeof window !== 'undefined' && window.scrollY > 24; - document.body.classList.toggle('guest-nav-visible', shouldShow); - }, []); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - updateNavVisibility(); - window.addEventListener('scroll', updateNavVisibility, { passive: true }); - - return () => { - document.body.classList.remove('guest-nav-visible'); - window.removeEventListener('scroll', updateNavVisibility); - }; - }, [updateNavVisibility]); - - const [showPrimer, setShowPrimer] = useState(() => { - if (typeof window === 'undefined') return false; - return window.localStorage.getItem(primerStorageKey) !== '1'; - }); - - const videoRef = useRef(null); - const canvasRef = useRef(null); - const fileInputRef = useRef(null); - const liveRegionRef = useRef(null); - const cameraViewportRef = useRef(null); - const cameraShellRef = useRef(null); - - const streamRef = useRef(null); - const countdownTimerRef = useRef(null); - const uploadProgressTimerRef = useRef(null); - - const taskId = useMemo(() => { - if (!taskIdParam) return null; - const parsed = parseInt(taskIdParam, 10); - return Number.isFinite(parsed) ? parsed : null; - }, [taskIdParam]); - - const tasksUrl = useMemo(() => { - if (!eventKey) return '/tasks'; - return `/e/${encodeURIComponent(eventKey)}/tasks`; - }, [eventKey]); - const galleryUrl = useMemo(() => { - if (!eventKey) return '/gallery'; - return `/e/${encodeURIComponent(eventKey)}/gallery`; - }, [eventKey]); - const demoGalleryUrl = useMemo(() => { - if (!eventKey) return '/gallery'; - return `/e/${encodeURIComponent(eventKey)}/gallery`; - }, [eventKey]); - - // Load preferences from storage - useEffect(() => { - if (typeof window === 'undefined') return; - try { - const stored = window.localStorage.getItem(prefsStorageKey); - if (stored) { - const parsed = JSON.parse(stored) as Partial; - setPreferences((prev) => ({ ...prev, ...parsed })); - } - } catch (error) { - console.warn('Failed to parse camera preferences', error); - } - }, [prefsStorageKey]); - - // Persist preferences when they change - useEffect(() => { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(prefsStorageKey, JSON.stringify(preferences)); - } catch (error) { - console.warn('Failed to persist camera preferences', error); - } - }, [preferences, prefsStorageKey]); - - // Load task metadata - useEffect(() => { - if (!token || taskId === null || !tasksEnabled) { - setLoadingTask(false); - return; - } - - let active = true; - - async function loadTask() { - if (taskId === null) return; - - const currentTaskId = Number(taskId); - const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`); - const fallbackDescription = t('upload.taskInfo.fallbackDescription'); - const fallbackInstructions = t('upload.taskInfo.fallbackInstructions'); - - try { - setLoadingTask(true); - - const res = await fetch( - `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, - { - headers: { - Accept: 'application/json', - 'X-Locale': locale, - 'X-Device-Id': getDeviceId(), - }, - } - ); - if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); - const payload = (await res.json()) as unknown; - const entries = Array.isArray(payload) - ? payload.filter(isTaskPayload) - : Array.isArray((payload as any)?.data) - ? (payload as any).data.filter(isTaskPayload) - : Array.isArray((payload as any)?.tasks) - ? (payload as any).tasks.filter(isTaskPayload) - : []; - const found = entries.find((entry) => entry.id === currentTaskId) ?? null; - - if (!active) return; - - if (found) { - setTask({ - id: found.id, - title: found.title || fallbackTitle, - description: found.description || fallbackDescription, - instructions: found.instructions ?? fallbackInstructions, - duration: found.duration || 2, - emotion: found.emotion, - difficulty: found.difficulty ?? 'medium', - }); - } else { - setTask({ - id: currentTaskId, - title: fallbackTitle, - description: fallbackDescription, - instructions: fallbackInstructions, - duration: 2, - emotion: emotionSlug - ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } - : undefined, - difficulty: 'medium', - }); - } - } catch (error) { - console.error('Failed to fetch task', error); - if (active) { - setTask({ - id: currentTaskId, - title: fallbackTitle, - description: fallbackDescription, - instructions: fallbackInstructions, - duration: 2, - emotion: emotionSlug - ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } - : undefined, - difficulty: 'medium', - }); - } - } finally { - if (active) setLoadingTask(false); - } - } - - loadTask(); - return () => { - active = false; - }; - }, [eventKey, taskId, emotionSlug, t, token, locale]); - - // Check upload limits - useEffect(() => { - if (!eventKey) return; - - const checkLimits = async () => { - if (demoReadOnly) { - setCanUpload(true); - setUploadError(null); - setUploadWarning(null); - return; - } - - try { - const pkg = await getEventPackage(eventKey); - setEventPackage(pkg); - if (!pkg) { - setCanUpload(true); - setUploadError(null); - setUploadWarning(null); - return; - } - - const photoLimits = pkg.limits?.photos ?? null; - const galleryLimits = pkg.limits?.gallery ?? null; - - let canUploadCurrent = pkg.limits?.can_upload_photos ?? true; - let errorMessage: string | null = null; - - if (photoLimits?.state === 'limit_reached') { - canUploadCurrent = false; - if (typeof photoLimits.limit === 'number') { - errorMessage = t('upload.limitReached') - .replace('{used}', `${photoLimits.used}`) - .replace('{max}', `${photoLimits.limit}`); - } else { - errorMessage = t('upload.errors.photoLimit'); - } - } - - if (galleryLimits?.state === 'expired') { - canUploadCurrent = false; - errorMessage = t('upload.errors.galleryExpired'); - } - - setCanUpload(canUploadCurrent); - setUploadError(errorMessage); - setUploadWarning(null); - } catch (err) { - console.error('Failed to check package limits', err); - setCanUpload(false); - setUploadError(t('upload.limitCheckError')); - setUploadWarning(null); - } - }; - - checkLimits(); - }, [demoReadOnly, eventKey, t]); - - const stopStream = useCallback(() => { - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); - streamRef.current = null; - } - }, []); - - const attachStreamToVideo = useCallback((stream: MediaStream) => { - if (!videoRef.current) return; - videoRef.current.srcObject = stream; - videoRef.current - .play() - .then(() => { - if (videoRef.current) { - videoRef.current.muted = true; - } - }) - .catch((error) => console.error('Video play error', error)); - }, []); - - const createConstraint = useCallback( - (mode: 'user' | 'environment'): MediaStreamConstraints => ({ - video: { - width: { ideal: 1920 }, - height: { ideal: 1080 }, - facingMode: { ideal: mode }, - }, - audio: false, - }), - [] - ); - - const startCamera = useCallback(async () => { - if (demoReadOnly) { - setPermissionState('idle'); - setPermissionMessage(null); - stopStream(); - return; - } - if (!supportsCamera) { - setPermissionState('unsupported'); - setPermissionMessage(t('upload.cameraUnsupported.message')); - return; - } - - if (mode === 'uploading') return; - - try { - if (isCameraBlockedByPolicy()) { - setPermissionState('blocked'); - setPermissionMessage(t('upload.cameraBlocked.message')); - return; - } - - setPermissionState('prompt'); - setPermissionMessage(null); - - const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode)); - stopStream(); - streamRef.current = stream; - attachStreamToVideo(stream); - setPermissionState('granted'); - } catch (error: unknown) { - console.error('Camera access error', error); - stopStream(); - - const errorName = getErrorName(error); - if (errorName === 'NotAllowedError') { - setPermissionState('denied'); - setPermissionMessage(t('upload.cameraDenied.explanation')); - } else if (errorName === 'NotFoundError') { - setPermissionState('error'); - setPermissionMessage(t('upload.cameraUnsupported.message')); - } else { - setPermissionState('error'); - setPermissionMessage(t('upload.cameraError.explanation')); - } - } - }, [attachStreamToVideo, createConstraint, demoReadOnly, mode, preferences.facingMode, stopStream, supportsCamera, t]); - - const handleRecheckCamera = useCallback(() => { - if (isCameraBlockedByPolicy()) { - setPermissionState('blocked'); - setPermissionMessage(t('upload.cameraBlocked.message')); - return; - } - - setPermissionState('idle'); - setPermissionMessage(null); - void startCamera(); - }, [startCamera, t]); - - useEffect(() => { - if (loadingTask) return; - startCamera(); - return () => { - stopStream(); - }; - }, [loadingTask, startCamera, stopStream, preferences.facingMode]); - - // Countdown live region updates - useEffect(() => { - if (!liveRegionRef.current) return; - if (mode === 'countdown') { - liveRegionRef.current.textContent = t('upload.countdown.ready').replace('{count}', `${countdownValue}`); - } else if (mode === 'review') { - liveRegionRef.current.textContent = t('upload.review.readyAnnouncement'); - } else if (mode === 'uploading') { - liveRegionRef.current.textContent = t('upload.status.uploading'); - } else { - liveRegionRef.current.textContent = ''; - } - }, [mode, countdownValue, t]); - - const dismissPrimer = useCallback(() => { - setShowPrimer(false); - if (typeof window !== 'undefined') { - window.localStorage.setItem(primerStorageKey, '1'); - } - }, [primerStorageKey]); - - const handleToggleGrid = useCallback(() => { - setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled })); - }, []); - - const handleToggleMirror = useCallback(() => { - setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview })); - }, []); - - const handleToggleCountdown = useCallback(() => { - setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled })); - }, []); - - const handleToggleFlashPreference = useCallback(() => { - setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred })); - }, []); - - const handleToggleImmersive = useCallback(async () => { - setImmersiveMode((prev) => !prev); - const shell = cameraShellRef.current; - if (!shell) return; - const prefersReducedMotion = typeof window !== 'undefined' - ? window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches - : false; - if (prefersReducedMotion) return; - try { - if (!document.fullscreenElement) { - await shell.requestFullscreen?.(); - } else { - await document.exitFullscreen?.(); - } - } catch (error) { - console.warn('Fullscreen toggle failed', error); - } - }, []); - - const triggerConfetti = useCallback(async () => { - if (typeof window === 'undefined') return; - const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; - if (prefersReducedMotion) return; - - try { - const { default: confetti } = await import('canvas-confetti'); - confetti({ - particleCount: 70, - spread: 65, - origin: { x: 0.5, y: 0.35 }, - ticks: 160, - scalar: 0.9, - }); - } catch (error) { - console.warn('Confetti could not start', error); - } - }, []); - - const resetCountdownTimer = useCallback(() => { - if (countdownTimerRef.current) { - window.clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - }, []); - - const performCapture = useCallback(() => { - if (!videoRef.current || !canvasRef.current) { - setUploadError(t('upload.captureError')); - setMode('preview'); - return; - } - - const video = videoRef.current; - const canvas = canvasRef.current; - const width = video.videoWidth; - const height = video.videoHeight; - - if (!width || !height) { - setUploadError(t('upload.feedError')); - setMode('preview'); - startCamera(); - return; - } - - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d'); - if (!context) { - setUploadError(t('upload.canvasError')); - setMode('preview'); - return; - } - - context.save(); - const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview; - if (shouldMirror) { - context.scale(-1, 1); - context.drawImage(video, -width, 0, width, height); - } else { - context.drawImage(video, 0, 0, width, height); - } - context.restore(); - - canvas.toBlob( - (blob) => { - if (!blob) { - setUploadError(t('upload.captureError')); - setMode('preview'); - return; - } - const timestamp = Date.now(); - const fileName = `photo-${timestamp}.jpg`; - const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp }); - const dataUrl = canvas.toDataURL('image/jpeg', 0.92); - setReviewPhoto({ dataUrl, file }); - setMode('review'); - }, - 'image/jpeg', - 0.92 - ); - }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera, t]); - - const beginCapture = useCallback(() => { - setUploadError(null); - if (preferences.countdownEnabled && preferences.countdownSeconds > 0) { - setMode('countdown'); - setCountdownValue(preferences.countdownSeconds); - resetCountdownTimer(); - countdownTimerRef.current = window.setInterval(() => { - setCountdownValue((prev) => { - if (prev <= 1) { - resetCountdownTimer(); - performCapture(); - return preferences.countdownSeconds; - } - return prev - 1; - }); - }, 1000); - } else { - performCapture(); - } - }, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]); - - const handleRetake = useCallback(() => { - setReviewPhoto(null); - setUploadProgress(0); - setUploadError(null); - setMode('preview'); - }, []); - - const navigateAfterUpload = useCallback( - (photoId: number | undefined) => { - if (!eventKey) return; - const params = new URLSearchParams(); - params.set('uploaded', 'true'); - if (task?.id) params.set('task', String(task.id)); - if (photoId) params.set('photo', String(photoId)); - if (emotionSlug) params.set('emotion', emotionSlug); - const target = uploadsRequireApproval ? 'queue' : 'gallery'; - navigate(`/e/${encodeURIComponent(eventKey)}/${target}?${params.toString()}`); - }, - [emotionSlug, navigate, eventKey, task?.id, uploadsRequireApproval] - ); - - const handleUsePhoto = useCallback(async () => { - if (!eventKey || !reviewPhoto) return; - if (demoReadOnly) { - setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); - setUploadError(null); - return; - } - if (!canUpload) return; - setMode('uploading'); - setUploadProgress(2); - setUploadError(null); - setUploadWarning(null); - setStatusMessage(t('upload.status.preparing')); - - if (uploadProgressTimerRef.current) { - window.clearInterval(uploadProgressTimerRef.current); - uploadProgressTimerRef.current = null; - } - - const maxEdge = 2400; - const targetBytes = 4_000_000; - let fileForUpload = reviewPhoto.file; - - try { - setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…')); - const optimized = await compressPhoto(reviewPhoto.file, { - maxEdge, - targetBytes, - qualityStart: 0.82, - }); - - fileForUpload = optimized; - setUploadProgress(10); - - if (optimized.size < reviewPhoto.file.size - 50_000) { - const saved = formatBytes(reviewPhoto.file.size - optimized.size); - setUploadWarning( - t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}') - .replace('{saved}', saved) - ); - } - } catch (e) { - console.warn('Image optimization failed, uploading original', e); - setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.')); - } - - try { - const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, { - maxRetries: 2, - guestName: identity.name || undefined, - liveShowOptIn: submitToLive, - onProgress: (percent) => { - setUploadProgress(Math.max(15, Math.min(98, percent))); - setStatusMessage(t('upload.status.uploading')); - }, - onRetry: (attempt) => { - setUploadWarning( - t('upload.retrying', 'Verbindung holperig – neuer Versuch ({attempt}).') - .replace('{attempt}', `${attempt}`) - ); - }, - }); - setUploadProgress(100); - setStatusMessage(t('upload.status.completed')); - if (task?.id) { - markCompleted(task.id); - } - setShowCelebration(true); - if (typeof window !== 'undefined') { - window.setTimeout(() => setShowCelebration(false), 1800); - } - void triggerConfetti(); - 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 (error) { - console.warn('Failed to persist my-photo-ids', error); - } - await new Promise((resolve) => { - window.setTimeout(resolve, 420); - }); - stopStream(); - navigateAfterUpload(photoId); - } catch (error: unknown) { - console.error('Upload failed', error); - const uploadErr = error as UploadError; - setUploadWarning(null); - const meta = uploadErr.meta as Record | undefined; - const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t); - setErrorDialog(dialog); - setUploadError(dialog.description); - - if (uploadErr.status === 422 || uploadErr.code === 'validation_error') { - setUploadWarning( - t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.') - ); - } - - 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); - } - - setMode('review'); - } finally { - setStatusMessage(''); - } - }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive, demoReadOnly]); - - const handleGalleryPick = useCallback(async (event: React.ChangeEvent) => { - if (demoReadOnly) { - setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); - setUploadError(null); - return; - } - if (!canUpload) return; - const file = event.target.files?.[0]; - if (!file) return; - setUploadError(null); - setUploadWarning(null); - setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…')); - - 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); - setUploadWarning( - t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}') - .replace('{saved}', saved) - ); - } - } catch (error) { - console.warn('Gallery image optimization failed, falling back to original', error); - setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.')); - } - - if (prepared.size > 12_000_000) { - setStatusMessage(''); - setUploadError( - t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.') - ); - event.target.value = ''; - return; - } - - const dataUrl = await readAsDataUrl(prepared); - setReviewPhoto({ dataUrl, file: prepared }); - setMode('review'); - setStatusMessage(''); - event.target.value = ''; - }, [canUpload, t, demoReadOnly]); - - const emotionLabel = useMemo(() => { - if (task?.emotion?.name) return task.emotion.name; - if (emotionSlug) { - return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); - } - return t('upload.hud.moodFallback'); - }, [emotionSlug, t, task?.emotion?.name]); - - const difficultyBadgeClass = useMemo(() => { - if (!task) return 'text-white'; - switch (task.difficulty) { - case 'easy': - return 'text-emerald-400'; - case 'hard': - return 'text-rose-400'; - default: - return 'text-amber-300'; - } - }, [task]); - - const isCameraActive = !demoReadOnly && permissionState === 'granted' && mode !== 'uploading'; - const showTaskOverlay = task && mode !== 'uploading'; - - const relativeLastUpload = useMemo( - () => formatRelativeTimeLabel(stats.latestPhotoAt, t), - [stats.latestPhotoAt, t], - ); - - const socialChips = useMemo( - () => [ - { - id: 'online', - label: t('upload.hud.cards.online'), - value: stats.onlineGuests > 0 ? `${stats.onlineGuests}` : '0', - }, - { - id: 'emotion', - label: t('upload.taskInfo.emotion').replace('{value}', emotionLabel), - value: t('upload.hud.moodLabel').replace('{mood}', emotionLabel), - }, - { - id: 'last-upload', - label: t('upload.hud.cards.lastUpload'), - value: relativeLastUpload, - }, - ], - [emotionLabel, relativeLastUpload, stats.onlineGuests, t], - ); - - useEffect(() => () => { - resetCountdownTimer(); - if (uploadProgressTimerRef.current) { - window.clearInterval(uploadProgressTimerRef.current); - } - }, [resetCountdownTimer]); - - useEffect(() => { - setTaskDetailsExpanded(false); - }, [task?.id]); - - useEffect(() => { - if (task) { - setShowHeroOverlay(false); - } else { - setShowHeroOverlay(true); - } - }, [task]); - - const handlePrimaryAction = useCallback(() => { - setShowHeroOverlay(false); - if (demoReadOnly) { - return; - } - if (!isCameraActive) { - startCamera(); - return; - } - beginCapture(); - }, [beginCapture, demoReadOnly, isCameraActive, startCamera]); - - const taskFloatingCard = showTaskOverlay && task ? ( - setTaskDetailsExpanded((prev) => !prev)} - className="absolute left-4 right-4 top-6 z-30 rounded-3xl border border-white/40 bg-black/60 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/60 sm:left-6 sm:right-6 sm:top-8" - {...overlayMotion} - > -
- - - {t('upload.taskInfo.badge').replace('{id}', `${task.id}`)} - - - {t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)} - - - {emotionLabel} - - -
-

{task.title}

- {taskDetailsExpanded ? ( -
-

{task.description}

-
- {task.instructions && ( - - {t('upload.taskInfo.instructionsPrefix')}: {task.instructions} - - )} - {preferences.countdownEnabled && ( - - {t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)} - - )} - {emotionLabel && ( - - {t('upload.taskInfo.emotion').replace('{value}', emotionLabel)} - - )} -
-
- ) : null} -
- ) : null; - - const heroOverlay = !task && showHeroOverlay && mode !== 'uploading' ? ( - -
-
-

Bereit für dein Foto?

-

Teile den Moment mit allen Gästen.

-
- - Live - -
-
- ) : null; - - const dialogToneIconClass: Record, string> = { - danger: 'text-rose-500', - warning: 'text-amber-500', - info: 'text-sky-500', - }; - - const errorDialogNode = ( - { if (!open) setErrorDialog(null); }}> - - -
- {errorDialog?.tone === 'info' ? ( - - ) : ( - - )} - {errorDialog?.title ?? ''} -
- {errorDialog?.description ?? ''} - {errorDialog?.hint ? ( -

{errorDialog.hint}

- ) : null} -
- - - -
-
- ); - -const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => ( - <> -
{content}
- {errorDialogNode} - -); - - if (loadingTask) { - return renderWithDialog( -
- -

{t('upload.preparing')}

-
- ); - } - - if (!canUpload) { - return renderWithDialog( - - - - {uploadError - ?? t('upload.limitReached') - .replace('{used}', `${eventPackage?.used_photos || 0}`) - .replace('{max}', `${eventPackage?.package?.max_photos || 0}`)} - - - ); - } - - const renderPrimer = () => ( - showPrimer && ( - -
- -
-

{t('upload.primer.title')}

-

- {t('upload.primer.body.part1')}{' '} - {t('upload.primer.body.part2')} -

-
- -
-
- ) - ); - - const renderPermissionNotice = () => { - if (demoReadOnly || permissionState === 'granted') return null; - - const titles: Record = { - idle: t('upload.cameraDenied.title'), - prompt: t('upload.cameraDenied.title'), - granted: '', - denied: t('upload.cameraDenied.title'), - error: t('upload.cameraError.title'), - unsupported: t('upload.cameraUnsupported.title'), - blocked: t('upload.cameraBlocked.title'), - }; - - const fallbackMessages: Record = { - idle: t('upload.cameraDenied.prompt'), - prompt: t('upload.cameraDenied.prompt'), - granted: '', - denied: t('upload.cameraDenied.explanation'), - error: t('upload.cameraError.explanation'), - unsupported: t('upload.cameraUnsupported.message'), - blocked: t('upload.cameraBlocked.message'), - }; - - const title = titles[permissionState]; - const description = permissionMessage ?? fallbackMessages[permissionState]; - const canRetryCamera = permissionState !== 'unsupported' && permissionState !== 'blocked'; - const canRecheckCamera = permissionState === 'blocked'; - const helpText = permissionState === 'blocked' - ? t('upload.cameraBlocked.hint') - : permissionState === 'denied' - ? t('upload.cameraDenied.hint') - : permissionState === 'error' - ? t('upload.cameraError.hint') - : null; - - return ( - -
-
- -
-
-

{title}

-

{description}

-
-
- {helpText ? ( -

{helpText}

- ) : null} -
- {canRetryCamera && ( - - )} - {canRecheckCamera && ( - - )} - -
-
- ); - }; - - const isCountdownActive = mode === 'countdown'; - const countdownProgress = preferences.countdownEnabled && preferences.countdownSeconds > 0 - ? Math.max(0, Math.min(1, countdownValue / preferences.countdownSeconds)) - : 0; - const countdownDegrees = Math.round(countdownProgress * 360); - const controlIconButtonBase = - 'flex h-10 w-10 items-center justify-center rounded-full border border-white/25 bg-white/10 text-white shadow-sm backdrop-blur transition hover:border-white/40 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60'; - const actionIconButtonBase = - 'flex h-14 w-14 items-center justify-center rounded-2xl border border-white/25 bg-white/10 text-white shadow-lg shadow-black/25 backdrop-blur transition hover:border-white/40 hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:pointer-events-none disabled:opacity-50'; - const actionDockClass = - 'rounded-[28px] border border-white/15 bg-white/10 p-4 shadow-2xl backdrop-blur'; - const cameraControlsInset = 'calc(env(safe-area-inset-bottom, 0px) + 104px)'; - - return renderWithDialog( - <> -
} - className="relative flex min-h-screen flex-col gap-4 pb-[calc(env(safe-area-inset-bottom,0px)+72px)] pt-3" - style={bodyFont ? { fontFamily: bodyFont } : undefined} - > - {taskFloatingCard} - {heroOverlay} -
-
- {demoReadOnly && ( -
-
-
-
-
-
-
-
-
-
- {t('upload.demoReadOnly.label', 'Demo')} -
-
- )} -
-
- - {socialChips.length > 0 && ( - <> -
-
- {socialChips.map((chip) => ( -
- {chip.label} - {chip.value} -
- ))} -
- - )} - - {renderPrimer()} - - - -
- - - - , - 'space-y-6 pb-[140px]' - ); -} - -function readAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error ?? new Error('Failed to read file')); - reader.readAsDataURL(file); - }); -} - -function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string { - if (!value) { - return t('upload.hud.relative.now'); - } - const timestamp = new Date(value).getTime(); - if (Number.isNaN(timestamp)) { - return t('upload.hud.relative.now'); - } - const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); - if (diffMinutes < 1) { - return t('upload.hud.relative.now'); - } - if (diffMinutes < 60) { - return t('upload.hud.relative.minutes').replace('{count}', `${diffMinutes}`); - } - const hours = Math.round(diffMinutes / 60); - if (hours < 24) { - return t('upload.hud.relative.hours').replace('{count}', `${hours}`); - } - const days = Math.round(hours / 24); - return t('upload.hud.relative.days').replace('{count}', `${days}`); -} diff --git a/resources/js/guest/pages/UploadQueuePage.tsx b/resources/js/guest/pages/UploadQueuePage.tsx deleted file mode 100644 index 48984966..00000000 --- a/resources/js/guest/pages/UploadQueuePage.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Page } from './_util'; -import { useTranslation } from '../i18n/useTranslation'; -import { fetchPendingUploadsSummary, type PendingUpload } from '../services/pendingUploadsApi'; -import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Image as ImageIcon, Loader2, RefreshCcw } from 'lucide-react'; -import { useEventBranding } from '../context/EventBrandingContext'; - -export default function UploadQueuePage() { - const { t, locale } = useTranslation(); - const { token } = useParams<{ token?: string }>(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const { branding } = useEventBranding(); - const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; - const [pending, setPending] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const showSuccess = searchParams.get('uploaded') === 'true'; - const buttonStyle = branding.buttons?.style ?? 'filled'; - const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; - const radius = branding.buttons?.radius ?? 12; - - const formatter = useMemo( - () => new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }), - [locale], - ); - - const formatTimestamp = useCallback((value?: string | null) => { - if (!value) { - return t('pendingUploads.card.justNow'); - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return t('pendingUploads.card.justNow'); - } - return formatter.format(date); - }, [formatter, t]); - - const loadPendingUploads = useCallback(async () => { - if (!token) return; - try { - setLoading(true); - setError(null); - const result = await fetchPendingUploadsSummary(token, 12); - setPending(result.items); - } catch (err) { - console.error('Pending uploads load failed', err); - setError(t('pendingUploads.error')); - } finally { - setLoading(false); - } - }, [t, token]); - - useEffect(() => { - if (!token) return; - loadPendingUploads(); - }, [loadPendingUploads, token]); - - const emptyState = !loading && pending.length === 0; - - return ( - -
-

{t('pendingUploads.subtitle')}

- - {showSuccess && ( - - -

{t('pendingUploads.successTitle')}

-

{t('pendingUploads.successBody')}

-
-
- )} - - {error && ( - - {error} - - )} - -
- - -
- - {loading ? ( -
- - {t('pendingUploads.loading', 'Lade Uploads...')} -
- ) : ( -
- {pending.map((photo) => ( -
-
- {photo.thumbnail_url ? ( - - ) : ( -
- -
- )} -
-
-

{t('pendingUploads.card.pending')}

-

- {t('pendingUploads.card.uploadedAt').replace('{time}', formatTimestamp(photo.created_at))} -

-
-
- ))} - - {emptyState && ( -
-

{t('pendingUploads.emptyTitle')}

-

{t('pendingUploads.emptyBody')}

-
- )} -
- )} -
-
- ); -} diff --git a/resources/js/guest/pages/__tests__/BadgesGrid.test.tsx b/resources/js/guest/pages/__tests__/BadgesGrid.test.tsx deleted file mode 100644 index 4fba0662..00000000 --- a/resources/js/guest/pages/__tests__/BadgesGrid.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { BadgesGrid } from '../AchievementsPage'; - -const t = (key: string) => key; - -describe('BadgesGrid', () => { - it('adds dark mode classes for earned and pending badges', () => { - render( - , - ); - - const earnedCard = screen.getByTestId('badge-card-1'); - expect(earnedCard.className).toContain('dark:from-emerald-400/20'); - expect(earnedCard.className).toContain('dark:text-emerald-50'); - - const pendingCard = screen.getByTestId('badge-card-2'); - expect(pendingCard.className).toContain('bg-card/90'); - expect(pendingCard.className).toContain('border-border/60'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx b/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx deleted file mode 100644 index 475ed91b..00000000 --- a/resources/js/guest/pages/__tests__/LiveShowPlayerPage.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import LiveShowPlayerPage from '../LiveShowPlayerPage'; - -vi.mock('../../hooks/useLiveShowState', () => ({ - useLiveShowState: () => ({ - status: 'ready', - connection: 'polling', - error: null, - event: { id: 1, name: 'Showcase' }, - photos: [], - settings: { - 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: 'gradient', - }, - }), -})); - -vi.mock('../../hooks/useLiveShowPlayback', () => ({ - useLiveShowPlayback: () => ({ - frame: [], - nextFrame: [], - layout: 'single', - frameKey: 'empty', - }), -})); - -vi.mock('../../i18n/useTranslation', () => ({ - useTranslation: () => ({ - t: (_key: string, fallback: string) => fallback, - }), -})); - -describe('LiveShowPlayerPage', () => { - it('renders empty state when no photos', () => { - render( - - - } /> - - - ); - - expect(screen.getByText('Noch keine Live-Fotos')).toBeInTheDocument(); - }); -}); diff --git a/resources/js/guest/pages/__tests__/MissionActionCardSpacing.test.tsx b/resources/js/guest/pages/__tests__/MissionActionCardSpacing.test.tsx deleted file mode 100644 index 31c585e3..00000000 --- a/resources/js/guest/pages/__tests__/MissionActionCardSpacing.test.tsx +++ /dev/null @@ -1,62 +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 { MissionActionCard } from '../HomePage'; - -vi.mock('../../context/EventBrandingContext', () => ({ - useEventBranding: () => ({ - branding: { - primaryColor: '#FF5A5F', - secondaryColor: '#FFF8F5', - buttons: { radius: 12 }, - typography: {}, - fontFamily: 'Montserrat', - }, - }), -})); - -vi.mock('../../lib/emotionTheme', () => ({ - getEmotionTheme: () => ({ - gradientBackground: 'linear-gradient(135deg, #FF5A5F, #FFF8F5)', - }), - getEmotionIcon: () => '🙂', -})); - -vi.mock('swiper/react', () => ({ - Swiper: ({ children }: { children: React.ReactNode }) =>
{children}
, - SwiperSlide: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -vi.mock('swiper/modules', () => ({ - EffectCards: {}, -})); - -describe('MissionActionCard layout spacing', () => { - it('uses a tighter min height for the stack container', () => { - render( - - {}} - stack={[]} - initialIndex={0} - onIndexChange={() => {}} - swiperRef={{ current: null }} - /> - , - ); - - const stack = screen.getByTestId('mission-card-stack'); - expect(stack.className).toContain('min-h-[240px]'); - expect(stack.className).toContain('sm:min-h-[260px]'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/PhotoLightboxZoom.test.tsx b/resources/js/guest/pages/__tests__/PhotoLightboxZoom.test.tsx deleted file mode 100644 index 609db7f2..00000000 --- a/resources/js/guest/pages/__tests__/PhotoLightboxZoom.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { beforeAll, vi } from 'vitest'; -import { MemoryRouter } from 'react-router-dom'; -import PhotoLightbox from '../PhotoLightbox'; -import { EventBrandingProvider } from '../../context/EventBrandingContext'; -import { LocaleProvider } from '../../i18n/LocaleContext'; -import { ToastProvider } from '../../components/ToastHost'; - -const photo = { - id: 1, - file_path: '/test.jpg', - likes_count: 0, -}; - -describe('PhotoLightbox zoom gestures', () => { - beforeAll(() => { - if (!window.matchMedia) { - Object.defineProperty(window, 'matchMedia', { - configurable: true, - value: vi.fn().mockReturnValue({ - matches: false, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }), - }); - } - }); - - it('toggles zoom state on double click', () => { - render( - - - - - - - - - - ); - - const zoomSurface = screen.getByTestId('lightbox-zoom'); - const container = zoomSurface.closest('[data-zoomed]'); - expect(container).toHaveAttribute('data-zoomed', 'false'); - - fireEvent.doubleClick(zoomSurface); - expect(container).toHaveAttribute('data-zoomed', 'true'); - - fireEvent.doubleClick(zoomSurface); - expect(container).toHaveAttribute('data-zoomed', 'false'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/UploadActionCard.test.tsx b/resources/js/guest/pages/__tests__/UploadActionCard.test.tsx deleted file mode 100644 index eb1e2156..00000000 --- a/resources/js/guest/pages/__tests__/UploadActionCard.test.tsx +++ /dev/null @@ -1,44 +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 { UploadActionCard } from '../HomePage'; - -vi.mock('../../hooks/useDirectUpload', () => ({ - useDirectUpload: () => ({ - upload: vi.fn(), - uploading: false, - error: null, - warning: null, - progress: 0, - reset: vi.fn(), - }), -})); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => vi.fn(), - }; -}); - -describe('UploadActionCard', () => { - it('renders with dark mode surface classes', () => { - render( - - - , - ); - - const card = screen.getByTestId('upload-action-card'); - expect(card.className).toContain('bg-[var(--guest-surface)]'); - expect(card.className).toContain('dark:bg-slate-950/70'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/UploadPageDemoMode.test.tsx b/resources/js/guest/pages/__tests__/UploadPageDemoMode.test.tsx deleted file mode 100644 index e389e1da..00000000 --- a/resources/js/guest/pages/__tests__/UploadPageDemoMode.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import UploadPage from '../UploadPage'; - -vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(), - useParams: () => ({ token: 'demo' }), - useSearchParams: () => [new URLSearchParams(), vi.fn()], -})); - -vi.mock('../../demo/demoMode', () => ({ - isGuestDemoModeEnabled: () => true, -})); - -vi.mock('../../hooks/useGuestTaskProgress', () => ({ - useGuestTaskProgress: () => ({ - markCompleted: vi.fn(), - }), -})); - -vi.mock('../../context/GuestIdentityContext', () => ({ - useGuestIdentity: () => ({ - name: 'Guest', - }), -})); - -vi.mock('../../hooks/useEventData', () => ({ - useEventData: () => ({ - event: { - guest_upload_visibility: 'immediate', - demo_read_only: false, - engagement_mode: 'photo_only', - }, - }), -})); - -vi.mock('../../context/EventStatsContext', () => ({ - useEventStats: () => ({ - latestPhotoAt: null, - onlineGuests: 2, - tasksSolved: 0, - guestCount: 2, - likesCount: 0, - }), -})); - -vi.mock('../../context/EventBrandingContext', () => ({ - useEventBranding: () => ({ - branding: { - primaryColor: '#FF5A5F', - secondaryColor: '#FFF8F5', - buttons: { radius: 12 }, - typography: {}, - fontFamily: 'Montserrat', - }, - }), -})); - -vi.mock('../../i18n/useTranslation', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string) => fallback ?? key, - locale: 'de', - }), -})); - -vi.mock('../../services/eventApi', () => ({ - getEventPackage: vi.fn().mockResolvedValue(null), -})); - -vi.mock('../../services/photosApi', () => ({ - uploadPhoto: vi.fn(), -})); - -describe('UploadPage demo mode', () => { - it('keeps the UI visible and shows the demo notice', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Demo-Modus aktiv')).toBeInTheDocument(); - }); - }); -}); diff --git a/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx b/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx deleted file mode 100644 index 8a4aa01c..00000000 --- a/resources/js/guest/pages/__tests__/UploadPageImmersive.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import UploadPage from '../UploadPage'; - -vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(), - useParams: () => ({ token: 'demo' }), - useSearchParams: () => [new URLSearchParams(), vi.fn()], -})); - -vi.mock('../../hooks/useGuestTaskProgress', () => ({ - useGuestTaskProgress: () => ({ - markCompleted: vi.fn(), - }), -})); - -vi.mock('../../context/GuestIdentityContext', () => ({ - useGuestIdentity: () => ({ - name: 'Guest', - }), -})); - -vi.mock('../../hooks/useEventData', () => ({ - useEventData: () => ({ - event: { - guest_upload_visibility: 'immediate', - demo_read_only: false, - engagement_mode: 'photo_only', - }, - }), -})); - -vi.mock('../../context/EventStatsContext', () => ({ - useEventStats: () => ({ - latestPhotoAt: null, - onlineGuests: 0, - tasksSolved: 0, - guestCount: 0, - likesCount: 0, - }), -})); - -vi.mock('../../context/EventBrandingContext', () => ({ - useEventBranding: () => ({ - branding: { - primaryColor: '#FF5A5F', - secondaryColor: '#FFF8F5', - buttons: { radius: 12 }, - typography: {}, - fontFamily: 'Montserrat', - }, - }), -})); - -vi.mock('../../i18n/useTranslation', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string) => fallback ?? key, - locale: 'de', - }), -})); - -vi.mock('../../services/eventApi', () => ({ - getEventPackage: vi.fn().mockResolvedValue(null), -})); - -vi.mock('../../services/photosApi', () => ({ - uploadPhoto: vi.fn(), -})); - -describe('UploadPage immersive mode', () => { - it('adds the guest-immersive class on mount', async () => { - render(); - - await waitFor(() => { - expect(document.body.classList.contains('guest-immersive')).toBe(true); - }); - }); - - it('centers the capture button within the countdown ring', () => { - render(); - - const captureButton = screen.getByRole('button', { name: 'upload.buttons.startCamera' }); - const wrapper = captureButton.parentElement; - - expect(wrapper).not.toBeNull(); - expect(wrapper?.className).toContain('items-center'); - expect(wrapper?.className).toContain('justify-center'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx b/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx deleted file mode 100644 index bf5f4c6b..00000000 --- a/resources/js/guest/pages/__tests__/UploadPageNavVisibility.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import UploadPage from '../UploadPage'; - -vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(), - useParams: () => ({ token: 'demo' }), - useSearchParams: () => [new URLSearchParams(), vi.fn()], -})); - -vi.mock('../../hooks/useGuestTaskProgress', () => ({ - useGuestTaskProgress: () => ({ - markCompleted: vi.fn(), - }), -})); - -vi.mock('../../context/GuestIdentityContext', () => ({ - useGuestIdentity: () => ({ - name: 'Guest', - }), -})); - -vi.mock('../../hooks/useEventData', () => ({ - useEventData: () => ({ - event: { - guest_upload_visibility: 'immediate', - demo_read_only: false, - engagement_mode: 'photo_only', - }, - }), -})); - -vi.mock('../../context/EventStatsContext', () => ({ - useEventStats: () => ({ - latestPhotoAt: null, - onlineGuests: 2, - tasksSolved: 0, - guestCount: 2, - likesCount: 0, - }), -})); - -vi.mock('../../context/EventBrandingContext', () => ({ - useEventBranding: () => ({ - branding: { - primaryColor: '#FF5A5F', - secondaryColor: '#FFF8F5', - buttons: { radius: 12 }, - typography: {}, - fontFamily: 'Montserrat', - }, - }), -})); - -vi.mock('../../i18n/useTranslation', () => ({ - useTranslation: () => ({ - t: (key: string, fallback?: string) => fallback ?? key, - locale: 'de', - }), -})); - -vi.mock('../../services/eventApi', () => ({ - getEventPackage: vi.fn().mockResolvedValue(null), -})); - -vi.mock('../../services/photosApi', () => ({ - uploadPhoto: vi.fn(), -})); - -describe('UploadPage bottom nav visibility', () => { - beforeEach(() => { - document.body.classList.remove('guest-nav-visible'); - document.body.classList.remove('guest-immersive'); - Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true }); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - cb(0); - return 0; - }); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - }); - - it('toggles the nav visibility based on scroll position', async () => { - render(); - - expect(document.body.classList.contains('guest-nav-visible')).toBe(false); - - window.scrollY = 120; - window.dispatchEvent(new Event('scroll')); - await waitFor(() => { - expect(document.body.classList.contains('guest-nav-visible')).toBe(true); - }); - - window.scrollY = 0; - window.dispatchEvent(new Event('scroll')); - await waitFor(() => { - expect(document.body.classList.contains('guest-nav-visible')).toBe(false); - }); - }); -}); diff --git a/resources/js/guest/pages/_util.tsx b/resources/js/guest/pages/_util.tsx deleted file mode 100644 index e2058901..00000000 --- a/resources/js/guest/pages/_util.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { motion } from 'framer-motion'; -import { FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion'; - -export function Page({ title, children }: { title: string; children?: React.ReactNode }) { - const motionEnabled = !prefersReducedMotion(); - const containerProps = getMotionContainerProps(motionEnabled, STAGGER_FAST); - const itemProps = getMotionItemProps(motionEnabled, FADE_UP); - - return ( - - - {title} - - {children} - - ); -} diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts deleted file mode 100644 index 13c615e1..00000000 --- a/resources/js/guest/polling/usePollGalleryDelta.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { LocaleCode } from '../i18n/messages'; - -type Photo = { - id: number; - file_path?: string; - thumbnail_path?: string; - created_at?: string; - session_id?: string | null; - uploader_name?: string | null; -}; -type RawPhoto = Record; - -export function usePollGalleryDelta(token: string, locale: LocaleCode) { - const [photos, setPhotos] = useState([]); - const [loading, setLoading] = useState(true); - const [newCount, setNewCount] = useState(0); - const latestAt = useRef(null); - const etagRef = useRef(null); - const timer = useRef(null); - const [visible, setVisible] = useState( - typeof document !== 'undefined' ? document.visibilityState === 'visible' : true - ); - - const fetchDelta = useCallback(async () => { - if (!token) { - setLoading(false); - return; - } - - try { - const params = new URLSearchParams(); - if (latestAt.current) { - params.set('since', latestAt.current); - } - params.set('locale', locale); - - const headers: HeadersInit = { - 'Cache-Control': 'no-store', - 'X-Locale': locale, - 'Accept': 'application/json', - }; - - if (etagRef.current) { - headers['If-None-Match'] = etagRef.current; - } - - const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos?${params.toString()}`, { - headers, - }); - - if (res.status === 304) return; // No new content - - if (!res.ok) { - console.warn(`Gallery API error: ${res.status} ${res.statusText}`); - return; // Don't update state on error - } - - const json = await res.json(); - etagRef.current = res.headers.get('ETag'); - - // Handle different response formats - const rawPhotos = Array.isArray(json.data) ? json.data : - Array.isArray(json) ? json : - json.photos || []; - - const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({ - ...(photo as Photo), - session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null, - uploader_name: - typeof photo.uploader_name === 'string' - ? (photo.uploader_name as string) - : typeof photo.guest_name === 'string' - ? (photo.guest_name as string) - : null, - })); - - if (newPhotos.length > 0) { - const added = newPhotos.length; - const hasBaseline = latestAt.current !== null; - - setPhotos((prev) => { - if (hasBaseline) { - // Delta mode: merge new photos with existing list by id - const merged = [...newPhotos, ...prev]; - const byId = new Map(); - merged.forEach((photo) => byId.set(photo.id, photo)); - return Array.from(byId.values()); - } - - return newPhotos; - }); - - if (hasBaseline && added > 0) { - setNewCount((c) => c + added); - } - - // Update latest timestamp - if (json.latest_photo_at) { - latestAt.current = json.latest_photo_at; - } else if (newPhotos.length > 0) { - // Fallback: use newest photo timestamp - const newest = newPhotos.reduce((latest: number, photo: RawPhoto) => { - const photoTime = new Date((photo.created_at as string | undefined) || (photo.created_at_timestamp as number | undefined) || 0).getTime(); - return photoTime > latest ? photoTime : latest; - }, 0); - latestAt.current = new Date(newest).toISOString(); - } - } else if (latestAt.current) { - // Delta mode but no new photos: keep existing photos - console.log('No new photos, keeping existing gallery state'); - // Don't update photos state - } else { - // Initial load with no photos - setPhotos([]); - } - - setLoading(false); - } catch (error) { - console.error('Gallery polling error:', error); - setLoading(false); - // Don't update state on error - keep previous photos - } - }, [locale, token]); - - useEffect(() => { - const onVis = () => setVisible(document.visibilityState === 'visible'); - document.addEventListener('visibilitychange', onVis); - return () => document.removeEventListener('visibilitychange', onVis); - }, []); - - useEffect(() => { - if (!token) { - setPhotos([]); - setLoading(false); - return; - } - - setLoading(true); - latestAt.current = null; - etagRef.current = null; - void fetchDelta(); - if (timer.current) window.clearInterval(timer.current); - // Poll less aggressively when hidden - const interval = visible ? 30_000 : 90_000; - timer.current = window.setInterval(() => { void fetchDelta(); }, interval); - return () => { - if (timer.current) window.clearInterval(timer.current); - }; - }, [token, visible, locale, fetchDelta]); - - const refreshNow = useCallback(async () => { - if (!token) { - return; - } - setLoading(true); - latestAt.current = null; - etagRef.current = null; - setNewCount(0); - await fetchDelta(); - }, [fetchDelta, token]); - - function acknowledgeNew() { setNewCount(0); } - return { loading, photos, newCount, acknowledgeNew, refreshNow }; -} diff --git a/resources/js/guest/polling/usePollStats.ts b/resources/js/guest/polling/usePollStats.ts deleted file mode 100644 index fc227167..00000000 --- a/resources/js/guest/polling/usePollStats.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -export type EventStats = { - onlineGuests: number; - tasksSolved: number; - guestCount: number; - likesCount: number; - latestPhotoAt: string | null; -}; - -type StatsResponse = { - online_guests?: number; - tasks_solved?: number; - guest_count?: number; - likes_count?: number; - latest_photo_at?: string; -}; - -export function usePollStats(eventKey: string | null | undefined) { - const [data, setData] = useState({ - onlineGuests: 0, - tasksSolved: 0, - guestCount: 0, - likesCount: 0, - latestPhotoAt: null, - }); - const [loading, setLoading] = useState(true); - const timer = useRef(null); - const [visible, setVisible] = useState( - typeof document !== 'undefined' ? document.visibilityState === 'visible' : true - ); - - const canPoll = Boolean(eventKey); - - async function fetchOnce(activeKey: string) { - try { - const res = await fetch(`/api/v1/events/${encodeURIComponent(activeKey)}/stats`, { - headers: { 'Cache-Control': 'no-store' }, - }); - if (res.status === 304) return; - if (!res.ok) { - if (res.status === 404) { - setData({ - onlineGuests: 0, - tasksSolved: 0, - guestCount: 0, - likesCount: 0, - latestPhotoAt: null, - }); - } - return; - } - const json: StatsResponse = await res.json(); - setData({ - onlineGuests: json.online_guests ?? 0, - tasksSolved: json.tasks_solved ?? 0, - guestCount: json.guest_count ?? 0, - likesCount: json.likes_count ?? 0, - latestPhotoAt: json.latest_photo_at ?? null, - }); - } finally { - setLoading(false); - } - } - - useEffect(() => { - const onVis = () => setVisible(document.visibilityState === 'visible'); - document.addEventListener('visibilitychange', onVis); - return () => document.removeEventListener('visibilitychange', onVis); - }, []); - - useEffect(() => { - if (!canPoll) { - setLoading(false); - return; - } - - setLoading(true); - const activeKey = String(eventKey); - fetchOnce(activeKey); - if (timer.current) window.clearInterval(timer.current); - if (visible) { - timer.current = window.setInterval(() => fetchOnce(activeKey), 10_000); - } - return () => { - if (timer.current) window.clearInterval(timer.current); - }; - }, [eventKey, visible, canPoll]); - - return { ...data, loading }; -} diff --git a/resources/js/guest/queue/__tests__/queueUrl.test.ts b/resources/js/guest/queue/__tests__/queueUrl.test.ts deleted file mode 100644 index 682c306f..00000000 --- a/resources/js/guest/queue/__tests__/queueUrl.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildQueueUploadUrl } from '../queue'; - -describe('buildQueueUploadUrl', () => { - it('builds the guest upload endpoint', () => { - expect(buildQueueUploadUrl('demo-token')).toBe('/api/v1/events/demo-token/upload'); - }); - - it('encodes event tokens safely', () => { - expect(buildQueueUploadUrl('token/with space')).toBe('/api/v1/events/token%2Fwith%20space/upload'); - }); -}); diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx deleted file mode 100644 index 4dca56e2..00000000 --- a/resources/js/guest/router.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import React from 'react'; -import { Button } from '@/components/ui/button'; -import { createBrowserRouter, useLocation, useParams, Link, Navigate } from 'react-router-dom'; -import Header from './components/Header'; -import BottomNav from './components/BottomNav'; -import RouteTransition from './components/RouteTransition'; -import { useEventData } from './hooks/useEventData'; -import { AlertTriangle, Loader2 } from 'lucide-react'; -import { EventStatsProvider } from './context/EventStatsContext'; -import { GuestIdentityProvider, useOptionalGuestIdentity } from './context/GuestIdentityContext'; -import { EventBrandingProvider } from './context/EventBrandingContext'; -import { LocaleProvider } from './i18n/LocaleContext'; -import { DEFAULT_LOCALE, isLocaleCode } from './i18n/messages'; -import { useTranslation, type TranslateFn } from './i18n/useTranslation'; -import type { EventBranding } from './types/event-branding'; -import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi'; -import { NotificationCenterProvider } from './context/NotificationCenterContext'; -import RouteErrorElement from '@/components/RouteErrorElement'; -import { isTaskModeEnabled } from './lib/engagement'; -import GuestAnalyticsNudge from './components/GuestAnalyticsNudge'; - -const LandingPage = React.lazy(() => import('./pages/LandingPage')); -const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage')); -const HomePage = React.lazy(() => import('./pages/HomePage')); -const TaskPickerPage = React.lazy(() => import('./pages/TaskPickerPage')); -const TaskDetailPage = React.lazy(() => import('./pages/TaskDetailPage')); -const UploadPage = React.lazy(() => import('./pages/UploadPage')); -const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage')); -const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox')); -const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage')); -const LiveShowPlayerPage = React.lazy(() => import('./pages/LiveShowPlayerPage')); -const SettingsPage = React.lazy(() => import('./pages/SettingsPage')); -const LegalPage = React.lazy(() => import('./pages/LegalPage')); -const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage')); - -function HomeLayout() { - const { token } = useParams(); - const location = useLocation(); - const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled); - - if (!token) { - return ( -
-
-
- -
- - -
- ); - } - - return ( - - - - - ); -} - -export const router = createBrowserRouter([ - { path: '/event', element: , errorElement: }, - { path: '/show/:token', element: , errorElement: }, - { - path: '/setup/:token', - element: , - errorElement: , - children: [ - { index: true, element: }, - ], - }, - { - path: '/e/:token', - element: , - errorElement: , - children: [ - { index: true, element: }, - { path: 'tasks', element: }, - { path: 'tasks/:taskId', element: }, - { path: 'upload', element: }, - { path: 'queue', element: }, - { path: 'photo/:photoId', element: }, - { path: 'achievements', element: }, - ], - }, - { path: '/settings', element: , errorElement: }, - { path: '/legal/:page', element: , errorElement: }, - { path: '*', element: , errorElement: }, -]); - -function EventBoundary({ token }: { token: string }) { - const identity = useOptionalGuestIdentity(); - const { event, status, error, errorCode } = useEventData(); - const location = useLocation(); - - if (status === 'loading') { - return ; - } - - if (status === 'error' || !event) { - return ; - } - - if (identity?.hydrated && !identity.name) { - return ; - } - - const eventLocale = isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; - const localeStorageKey = `guestLocale_event_${event.id ?? token}`; - const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null); - - const isGalleryRoute = /^\/e\/[^/]+\/gallery(?:\/|$)/.test(location.pathname); - const contentPaddingClass = isGalleryRoute ? 'px-0 py-0' : 'px-4 py-3'; - - return ( - - - - -
-
-
- -
- -
-
-
-
-
- ); -} - -function TaskGuard({ children }: { children: React.ReactNode }) { - const { token } = useParams<{ token: string }>(); - const { event, status } = useEventData(); - - if (status === 'loading') { - return ; - } - - if (event && !isTaskModeEnabled(event)) { - return ; - } - - return <>{children}; -} - -function SetupLayout() { - const { token } = useParams<{ token: string }>(); - const location = useLocation(); - const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled); - const { event } = useEventData(); - if (!token) return null; - const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE; - const localeStorageKey = event ? `guestLocale_event_${event.id}` : `guestLocale_event_${token}`; - const branding = event ? mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null) : null; - return ( - - - - - -
-
- -
-
-
-
-
- -
- ); -} - -function EventLoadingView() { - const { t } = useTranslation(); - return ( -
- -
-

{t('eventAccess.loading.title')}

-

{t('eventAccess.loading.subtitle')}

-
-
- ); -} - -function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null { - if (!raw) { - return null; - } - - const palette = raw.palette ?? {}; - const typography = raw.typography ?? {}; - const buttons = raw.buttons ?? {}; - const logo = raw.logo ?? {}; - const primary = palette.primary ?? raw.primary_color ?? ''; - const secondary = palette.secondary ?? raw.secondary_color ?? ''; - const background = palette.background ?? raw.background_color ?? ''; - const surface = palette.surface ?? raw.surface_color ?? background; - const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null; - const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null; - const sizePreset = (typography.size as 's' | 'm' | 'l' | undefined) ?? (raw.font_size as 's' | 'm' | 'l' | undefined) ?? 'm'; - const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon'); - const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null; - const logoPosition = logo.position ?? raw.logo_position ?? 'left'; - const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm'; - const buttonStyle = (buttons.style as 'filled' | 'outline' | undefined) ?? (raw.button_style as 'filled' | 'outline' | undefined) ?? 'filled'; - const buttonRadius = typeof buttons.radius === 'number' ? buttons.radius : (typeof raw.button_radius === 'number' ? raw.button_radius : 12); - const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? ''; - const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? ''; - const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? ''; - const welcomeMessage = raw.welcome_message ?? null; - - return { - primaryColor: primary ?? '', - secondaryColor: secondary ?? '', - backgroundColor: background ?? '', - fontFamily: bodyFont, - logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null, - palette: { - primary: primary ?? '', - secondary: secondary ?? '', - background: background ?? '', - surface: surface ?? background ?? '', - }, - typography: { - heading: headingFont, - body: bodyFont, - sizePreset, - }, - logo: { - mode: logoMode, - value: logoValue, - position: logoPosition, - size: logoSize, - }, - buttons: { - style: buttonStyle, - radius: buttonRadius, - primary: buttonPrimary, - secondary: buttonSecondary, - linkColor, - }, - mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto', - useDefaultBranding: raw.use_default_branding ?? undefined, - welcomeMessage, - }; -} - -interface EventErrorViewProps { - code: FetchEventErrorCode | null; - message: string | null; -} - -function EventErrorView({ code, message }: EventErrorViewProps) { - const { t } = useTranslation(); - const content = getErrorContent(t, code, message); - - return ( -
-
- -
-
-

{content.title}

-

{content.description}

- {content.hint && ( -

{content.hint}

- )} -
- {content.ctaHref && content.ctaLabel && ( - - )} -
- ); -} - -function getErrorContent( - t: TranslateFn, - code: FetchEventErrorCode | null, - message: string | null, -) { - const build = (key: string, options?: { ctaHref?: string }) => { - const ctaLabel = t(`eventAccess.error.${key}.ctaLabel`, ''); - const hint = t(`eventAccess.error.${key}.hint`, ''); - return { - title: t(`eventAccess.error.${key}.title`), - description: message ?? t(`eventAccess.error.${key}.description`), - ctaLabel: ctaLabel.trim().length > 0 ? ctaLabel : undefined, - ctaHref: options?.ctaHref, - hint: hint.trim().length > 0 ? hint : null, - }; - }; - - switch (code) { - case 'invalid_token': - return build('invalid_token', { ctaHref: '/event' }); - case 'token_revoked': - return build('token_revoked', { ctaHref: '/event' }); - case 'token_expired': - return build('token_expired', { ctaHref: '/event' }); - case 'token_rate_limited': - return build('token_rate_limited'); - case 'access_rate_limited': - return build('access_rate_limited'); - case 'event_not_public': - return build('event_not_public'); - case 'gallery_expired': - return build('gallery_expired', { ctaHref: '/event' }); - case 'network_error': - return build('network_error'); - case 'server_error': - return build('server_error'); - default: - return build('default', { ctaHref: '/event' }); - } -} - -function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) { - const location = useLocation(); - const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled); - return ( - -
-
-
- {children} -
- - -
-
- ); -} diff --git a/resources/js/guest/services/__tests__/eventApi.test.ts b/resources/js/guest/services/__tests__/eventApi.test.ts deleted file mode 100644 index 6aea06dc..00000000 --- a/resources/js/guest/services/__tests__/eventApi.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fetchEvent, FetchEventError } from '../eventApi'; - -describe('fetchEvent', () => { - it('maps guest_limit_exceeded error codes', async () => { - const originalFetch = global.fetch; - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - json: async () => ({ - error: { code: 'guest_limit_exceeded', message: 'Limit reached' }, - }), - } as Response); - - await expect(fetchEvent('token')).rejects.toEqual( - expect.objectContaining>({ - code: 'guest_limit_exceeded', - }) - ); - - global.fetch = originalFetch; - }); -}); diff --git a/resources/js/guest/services/__tests__/liveShowApi.test.ts b/resources/js/guest/services/__tests__/liveShowApi.test.ts deleted file mode 100644 index beb8d088..00000000 --- a/resources/js/guest/services/__tests__/liveShowApi.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - buildLiveShowStreamUrl, - DEFAULT_LIVE_SHOW_SETTINGS, - normalizeLiveShowSettings, -} from '../liveShowApi'; - -describe('liveShowApi', () => { - it('merges live show settings with defaults', () => { - const result = normalizeLiveShowSettings({ - fixed_interval_seconds: 12, - effect_intensity: 15, - }); - - expect(result.fixed_interval_seconds).toBe(12); - expect(result.effect_intensity).toBe(15); - expect(result.layout_mode).toBe(DEFAULT_LIVE_SHOW_SETTINGS.layout_mode); - }); - - it('builds stream url with query params', () => { - const url = buildLiveShowStreamUrl('demo-token', { - cursor: { approved_at: '2025-01-01T00:00:00Z', id: 42 }, - settingsVersion: 'abc', - limit: 60, - }); - - const parsed = new URL(url, 'http://example.test'); - expect(parsed.pathname).toBe('/api/v1/live-show/demo-token/stream'); - expect(parsed.searchParams.get('after_approved_at')).toBe('2025-01-01T00:00:00Z'); - expect(parsed.searchParams.get('after_id')).toBe('42'); - expect(parsed.searchParams.get('settings_version')).toBe('abc'); - expect(parsed.searchParams.get('limit')).toBe('60'); - }); -}); diff --git a/resources/js/guest/services/__tests__/photosApi.test.ts b/resources/js/guest/services/__tests__/photosApi.test.ts deleted file mode 100644 index e5ad32ca..00000000 --- a/resources/js/guest/services/__tests__/photosApi.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createPhotoShareLink } from '../photosApi'; - -const fetchMock = vi.fn(); - -describe('photosApi', () => { - beforeEach(() => { - fetchMock.mockReset(); - global.fetch = fetchMock as unknown as typeof fetch; - document.head.innerHTML = ''; - localStorage.setItem('device-id', 'device-123'); - }); - - it('creates a share link with CSRF headers', async () => { - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ slug: 'demo', url: 'http://example.com/share/demo' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ); - - const payload = await createPhotoShareLink('token', 123); - - expect(payload.url).toBe('http://example.com/share/demo'); - expect(fetchMock).toHaveBeenCalledTimes(1); - - const [, options] = fetchMock.mock.calls[0]; - const headers = options?.headers as Record; - expect(headers['X-CSRF-TOKEN']).toBe('csrf-token-demo'); - expect(headers['X-XSRF-TOKEN']).toBe('csrf-token-demo'); - expect(headers['X-Device-Id']).toBe('device-123'); - }); -}); diff --git a/resources/js/guest/types/global.d.ts b/resources/js/guest/types/global.d.ts deleted file mode 100644 index 7c90655a..00000000 --- a/resources/js/guest/types/global.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -export {}; - -declare global { - interface Window { - __GUEST_RUNTIME_CONFIG__?: { - push?: { - enabled?: boolean; - vapidPublicKey?: string | null; - }; - }; - } -} - diff --git a/resources/js/guest/components/LiveShowBackdrop.tsx b/resources/js/shared/guest/components/LiveShowBackdrop.tsx similarity index 100% rename from resources/js/guest/components/LiveShowBackdrop.tsx rename to resources/js/shared/guest/components/LiveShowBackdrop.tsx diff --git a/resources/js/guest/components/LiveShowStage.tsx b/resources/js/shared/guest/components/LiveShowStage.tsx similarity index 100% rename from resources/js/guest/components/LiveShowStage.tsx rename to resources/js/shared/guest/components/LiveShowStage.tsx diff --git a/resources/js/guest/components/PullToRefresh.tsx b/resources/js/shared/guest/components/PullToRefresh.tsx similarity index 100% rename from resources/js/guest/components/PullToRefresh.tsx rename to resources/js/shared/guest/components/PullToRefresh.tsx diff --git a/resources/js/guest/components/ToastHost.tsx b/resources/js/shared/guest/components/ToastHost.tsx similarity index 100% rename from resources/js/guest/components/ToastHost.tsx rename to resources/js/shared/guest/components/ToastHost.tsx diff --git a/resources/js/guest/components/legal-markdown.tsx b/resources/js/shared/guest/components/legal-markdown.tsx similarity index 100% rename from resources/js/guest/components/legal-markdown.tsx rename to resources/js/shared/guest/components/legal-markdown.tsx diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/shared/guest/context/EventBrandingContext.tsx similarity index 100% rename from resources/js/guest/context/EventBrandingContext.tsx rename to resources/js/shared/guest/context/EventBrandingContext.tsx diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/shared/guest/context/NotificationCenterContext.tsx similarity index 100% rename from resources/js/guest/context/NotificationCenterContext.tsx rename to resources/js/shared/guest/context/NotificationCenterContext.tsx diff --git a/resources/js/guest/hooks/useGuestTaskProgress.ts b/resources/js/shared/guest/hooks/useGuestTaskProgress.ts similarity index 100% rename from resources/js/guest/hooks/useGuestTaskProgress.ts rename to resources/js/shared/guest/hooks/useGuestTaskProgress.ts diff --git a/resources/js/guest/hooks/useHapticsPreference.ts b/resources/js/shared/guest/hooks/useHapticsPreference.ts similarity index 100% rename from resources/js/guest/hooks/useHapticsPreference.ts rename to resources/js/shared/guest/hooks/useHapticsPreference.ts diff --git a/resources/js/guest/hooks/useLiveShowPlayback.ts b/resources/js/shared/guest/hooks/useLiveShowPlayback.ts similarity index 100% rename from resources/js/guest/hooks/useLiveShowPlayback.ts rename to resources/js/shared/guest/hooks/useLiveShowPlayback.ts diff --git a/resources/js/guest/hooks/useLiveShowState.ts b/resources/js/shared/guest/hooks/useLiveShowState.ts similarity index 100% rename from resources/js/guest/hooks/useLiveShowState.ts rename to resources/js/shared/guest/hooks/useLiveShowState.ts diff --git a/resources/js/guest/i18n/LocaleContext.tsx b/resources/js/shared/guest/i18n/LocaleContext.tsx similarity index 100% rename from resources/js/guest/i18n/LocaleContext.tsx rename to resources/js/shared/guest/i18n/LocaleContext.tsx diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/shared/guest/i18n/messages.ts similarity index 100% rename from resources/js/guest/i18n/messages.ts rename to resources/js/shared/guest/i18n/messages.ts diff --git a/resources/js/guest/i18n/useTranslation.ts b/resources/js/shared/guest/i18n/useTranslation.ts similarity index 100% rename from resources/js/guest/i18n/useTranslation.ts rename to resources/js/shared/guest/i18n/useTranslation.ts diff --git a/resources/js/guest/lib/analyticsConsent.ts b/resources/js/shared/guest/lib/analyticsConsent.ts similarity index 100% rename from resources/js/guest/lib/analyticsConsent.ts rename to resources/js/shared/guest/lib/analyticsConsent.ts diff --git a/resources/js/guest/lib/badges.ts b/resources/js/shared/guest/lib/badges.ts similarity index 100% rename from resources/js/guest/lib/badges.ts rename to resources/js/shared/guest/lib/badges.ts diff --git a/resources/js/guest/lib/color.ts b/resources/js/shared/guest/lib/color.ts similarity index 100% rename from resources/js/guest/lib/color.ts rename to resources/js/shared/guest/lib/color.ts diff --git a/resources/js/guest/lib/csrf.ts b/resources/js/shared/guest/lib/csrf.ts similarity index 100% rename from resources/js/guest/lib/csrf.ts rename to resources/js/shared/guest/lib/csrf.ts diff --git a/resources/js/guest/lib/device.ts b/resources/js/shared/guest/lib/device.ts similarity index 100% rename from resources/js/guest/lib/device.ts rename to resources/js/shared/guest/lib/device.ts diff --git a/resources/js/guest/lib/emotionTheme.ts b/resources/js/shared/guest/lib/emotionTheme.ts similarity index 100% rename from resources/js/guest/lib/emotionTheme.ts rename to resources/js/shared/guest/lib/emotionTheme.ts diff --git a/resources/js/guest/lib/engagement.ts b/resources/js/shared/guest/lib/engagement.ts similarity index 100% rename from resources/js/guest/lib/engagement.ts rename to resources/js/shared/guest/lib/engagement.ts diff --git a/resources/js/guest/lib/haptics.ts b/resources/js/shared/guest/lib/haptics.ts similarity index 100% rename from resources/js/guest/lib/haptics.ts rename to resources/js/shared/guest/lib/haptics.ts diff --git a/resources/js/guest/lib/image.ts b/resources/js/shared/guest/lib/image.ts similarity index 100% rename from resources/js/guest/lib/image.ts rename to resources/js/shared/guest/lib/image.ts diff --git a/resources/js/guest/lib/liveShowEffects.ts b/resources/js/shared/guest/lib/liveShowEffects.ts similarity index 100% rename from resources/js/guest/lib/liveShowEffects.ts rename to resources/js/shared/guest/lib/liveShowEffects.ts diff --git a/resources/js/guest/lib/localizeTaskLabel.ts b/resources/js/shared/guest/lib/localizeTaskLabel.ts similarity index 100% rename from resources/js/guest/lib/localizeTaskLabel.ts rename to resources/js/shared/guest/lib/localizeTaskLabel.ts diff --git a/resources/js/guest/lib/motion.ts b/resources/js/shared/guest/lib/motion.ts similarity index 100% rename from resources/js/guest/lib/motion.ts rename to resources/js/shared/guest/lib/motion.ts diff --git a/resources/js/guest/lib/uploadErrorDialog.ts b/resources/js/shared/guest/lib/uploadErrorDialog.ts similarity index 100% rename from resources/js/guest/lib/uploadErrorDialog.ts rename to resources/js/shared/guest/lib/uploadErrorDialog.ts diff --git a/resources/js/guest/queue/hooks.ts b/resources/js/shared/guest/queue/hooks.ts similarity index 100% rename from resources/js/guest/queue/hooks.ts rename to resources/js/shared/guest/queue/hooks.ts diff --git a/resources/js/guest/queue/idb.ts b/resources/js/shared/guest/queue/idb.ts similarity index 100% rename from resources/js/guest/queue/idb.ts rename to resources/js/shared/guest/queue/idb.ts diff --git a/resources/js/guest/queue/notify.ts b/resources/js/shared/guest/queue/notify.ts similarity index 100% rename from resources/js/guest/queue/notify.ts rename to resources/js/shared/guest/queue/notify.ts diff --git a/resources/js/guest/queue/queue.ts b/resources/js/shared/guest/queue/queue.ts similarity index 100% rename from resources/js/guest/queue/queue.ts rename to resources/js/shared/guest/queue/queue.ts diff --git a/resources/js/guest/queue/xhr.ts b/resources/js/shared/guest/queue/xhr.ts similarity index 100% rename from resources/js/guest/queue/xhr.ts rename to resources/js/shared/guest/queue/xhr.ts diff --git a/resources/js/guest/services/achievementApi.ts b/resources/js/shared/guest/services/achievementApi.ts similarity index 100% rename from resources/js/guest/services/achievementApi.ts rename to resources/js/shared/guest/services/achievementApi.ts diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/shared/guest/services/eventApi.ts similarity index 100% rename from resources/js/guest/services/eventApi.ts rename to resources/js/shared/guest/services/eventApi.ts diff --git a/resources/js/guest/services/galleryApi.ts b/resources/js/shared/guest/services/galleryApi.ts similarity index 100% rename from resources/js/guest/services/galleryApi.ts rename to resources/js/shared/guest/services/galleryApi.ts diff --git a/resources/js/guest/services/helpApi.ts b/resources/js/shared/guest/services/helpApi.ts similarity index 100% rename from resources/js/guest/services/helpApi.ts rename to resources/js/shared/guest/services/helpApi.ts diff --git a/resources/js/guest/services/liveShowApi.ts b/resources/js/shared/guest/services/liveShowApi.ts similarity index 100% rename from resources/js/guest/services/liveShowApi.ts rename to resources/js/shared/guest/services/liveShowApi.ts diff --git a/resources/js/guest/services/notificationApi.ts b/resources/js/shared/guest/services/notificationApi.ts similarity index 100% rename from resources/js/guest/services/notificationApi.ts rename to resources/js/shared/guest/services/notificationApi.ts diff --git a/resources/js/guest/services/pendingUploadsApi.ts b/resources/js/shared/guest/services/pendingUploadsApi.ts similarity index 100% rename from resources/js/guest/services/pendingUploadsApi.ts rename to resources/js/shared/guest/services/pendingUploadsApi.ts diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/shared/guest/services/photosApi.ts similarity index 100% rename from resources/js/guest/services/photosApi.ts rename to resources/js/shared/guest/services/photosApi.ts diff --git a/resources/js/guest/services/pushApi.ts b/resources/js/shared/guest/services/pushApi.ts similarity index 100% rename from resources/js/guest/services/pushApi.ts rename to resources/js/shared/guest/services/pushApi.ts diff --git a/resources/js/guest/types/event-branding.ts b/resources/js/shared/guest/types/event-branding.ts similarity index 100% rename from resources/js/guest/types/event-branding.ts rename to resources/js/shared/guest/types/event-branding.ts diff --git a/resources/views/guest-v2.blade.php b/resources/views/guest-v2.blade.php index 722f5062..50ac64c6 100644 --- a/resources/views/guest-v2.blade.php +++ b/resources/views/guest-v2.blade.php @@ -6,6 +6,7 @@ {{ config('app.name', 'Fotospiel') }} - Guest V2 + @viteReactRefresh diff --git a/resources/views/guest.blade.php b/resources/views/guest.blade.php deleted file mode 100644 index f48581e6..00000000 --- a/resources/views/guest.blade.php +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - {{ config('app.name', 'Fotospiel') }} - - - - - - @viteReactRefresh - @vite(['resources/css/app.css', 'resources/js/guest/main.tsx']) - @php - $guestRuntimeConfig = [ - 'push' => [ - 'enabled' => config('push.enabled', false), - 'vapidPublicKey' => config('push.vapid.public_key'), - ], - ]; - $matomoConfig = config('services.matomo'); - $matomoGuest = ($matomoConfig['enabled'] ?? false) && !empty($matomoConfig['url']) && !empty($matomoConfig['site_id_guest']) - ? [ - 'enabled' => true, - 'url' => rtrim($matomoConfig['url'], '/'), - 'siteId' => (string) $matomoConfig['site_id_guest'], - ] - : ['enabled' => false]; - @endphp - - - - - @php - $noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de'; - @endphp - -
- - diff --git a/tsconfig.json b/tsconfig.json index 8bc23ef0..980a86b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -125,12 +125,12 @@ "resources/js/types/**/*.ts", "resources/js/lib/**/*.ts", "resources/js/lib/**/*.tsx", - "resources/js/guest/**/*.ts", - "resources/js/guest/**/*.tsx", - "resources/js/guest/**/*.d.ts", "resources/js/guest-v2/**/*.ts", "resources/js/guest-v2/**/*.tsx", "resources/js/guest-v2/**/*.d.ts", + "resources/js/shared/**/*.ts", + "resources/js/shared/**/*.tsx", + "resources/js/shared/**/*.d.ts", "resources/js/guest-v3/**/*.ts", "resources/js/guest-v3/**/*.tsx", "resources/js/guest-v3/**/*.d.ts" diff --git a/vite.config.ts b/vite.config.ts index 9da139e3..0ad3f46a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,7 +27,6 @@ const plugins: PluginOption[] = [ 'resources/css/app.css', 'resources/js/app.js', 'resources/js/app.tsx', - 'resources/js/guest/main.tsx', 'resources/js/guest-v2/main.tsx', 'resources/js/admin/main.tsx', ], @@ -46,7 +45,7 @@ const plugins: PluginOption[] = [ }), VitePWA({ strategies: 'injectManifest', - srcDir: 'resources/js/guest', + srcDir: 'resources/js/guest-v2', filename: 'guest-sw.ts', manifestFilename: 'guest.webmanifest', outDir: 'public',