refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConsentProvider } from '@/contexts/consent';
|
||||
import { AppearanceProvider } from '@/hooks/use-appearance';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import ToastHost from './components/ToastHost';
|
||||
import PwaManager from './components/PwaManager';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ function AppThemeRouter() {
|
||||
return (
|
||||
<Theme name={themeName}>
|
||||
<RouterProvider router={router} />
|
||||
<PwaManager />
|
||||
<ToastHost />
|
||||
</Theme>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
@@ -23,7 +24,7 @@ vi.mock('../components/AppShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/components/PullToRefresh', () => ({
|
||||
vi.mock('@/shared/guest/components/PullToRefresh', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
@@ -35,7 +36,7 @@ vi.mock('../context/GuestIdentityContext', () => ({
|
||||
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
@@ -45,12 +46,12 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
useGuestThemeVariant: vi.fn(() => ({ isDark: false })),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bento', () => ({
|
||||
@@ -70,7 +71,7 @@ vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/lib/localizeTaskLabel', () => ({
|
||||
vi.mock('@/shared/guest/lib/localizeTaskLabel', () => ({
|
||||
localizeTaskLabel: (value: string | null) => value,
|
||||
}));
|
||||
|
||||
@@ -116,6 +117,7 @@ vi.mock('../services/achievementsApi', () => ({
|
||||
}));
|
||||
|
||||
import AchievementsScreen from '../screens/AchievementsScreen';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
|
||||
describe('AchievementsScreen', () => {
|
||||
it('renders personal achievements content', async () => {
|
||||
@@ -123,6 +125,16 @@ describe('AchievementsScreen', () => {
|
||||
|
||||
expect(await screen.findByText('Badges')).toBeInTheDocument();
|
||||
expect(screen.getByText('First upload')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload photo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload one photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses dark-mode feed row and placeholder styles when dark theme is active', async () => {
|
||||
vi.mocked(useGuestThemeVariant).mockReturnValue({ isDark: true });
|
||||
const { container } = render(<AchievementsScreen />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Feed' }));
|
||||
|
||||
expect(container.querySelector('[backgroundcolor="rgba(255, 255, 255, 0.08)"]')).toBeTruthy();
|
||||
expect(container.querySelector('[backgroundcolor="rgba(255, 255, 255, 0.12)"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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}</>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import EventLogo from '../components/EventLogo';
|
||||
import { EventBrandingProvider } from '@/guest/context/EventBrandingContext';
|
||||
import type { EventBranding } from '@/guest/types/event-branding';
|
||||
import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext';
|
||||
import type { EventBranding } from '@/shared/guest/types/event-branding';
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,19 +35,19 @@ vi.mock('../components/SurfaceCard', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/components/PullToRefresh', () => ({
|
||||
vi.mock('@/shared/guest/components/PullToRefresh', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/services/helpApi', () => ({
|
||||
vi.mock('@/shared/guest/services/helpApi', () => ({
|
||||
getHelpArticles: () => Promise.resolve({
|
||||
servedFromCache: false,
|
||||
articles: [{ slug: 'intro', title: 'Intro', summary: 'Summary', updated_at: null }],
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock('../components/PhotoFrameTile', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/context/EventBrandingContext', () => ({
|
||||
vi.mock('@/shared/guest/context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: { welcomeMessage: '' },
|
||||
isCustom: false,
|
||||
@@ -97,11 +97,11 @@ const translate = (key: string, options?: unknown, fallback?: string) => {
|
||||
return key;
|
||||
};
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: translate, locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
@@ -119,7 +119,7 @@ vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({ resolved: 'light' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({ isCompleted: () => false }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
|
||||
@@ -47,14 +47,14 @@ vi.mock('../services/photosApi', () => ({
|
||||
createPhotoShareLink: vi.fn().mockResolvedValue({ url: 'http://example.com' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
|
||||
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock('../services/uploadApi', () => ({
|
||||
uploadPhoto: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/services/pendingUploadsApi', () => ({
|
||||
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
|
||||
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
|
||||
}));
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({ data: { photos: [], latestPhotoAt: null, nextCursor: null }, loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
|
||||
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
|
||||
@@ -97,7 +97,7 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
|
||||
}));
|
||||
|
||||
@@ -129,7 +129,7 @@ vi.mock('../services/qrApi', () => ({
|
||||
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: null }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({ completedCount: 0 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
|
||||
}));
|
||||
|
||||
@@ -10,11 +10,11 @@ vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({ resolved: 'dark' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/components/legal-markdown', () => ({
|
||||
vi.mock('@/shared/guest/components/legal-markdown', () => ({
|
||||
LegalMarkdown: () => <div>Legal markdown</div>,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'token' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/services/pendingUploadsApi', () => ({
|
||||
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
|
||||
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
|
||||
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key),
|
||||
@@ -54,7 +54,7 @@ vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
vi.mock('@/shared/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock('../services/tasksApi', () => ({
|
||||
fetchTasks: vi.fn().mockResolvedValue([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/services/pendingUploadsApi', () => ({
|
||||
vi.mock('@/shared/guest/services/pendingUploadsApi', () => ({
|
||||
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
|
||||
}));
|
||||
|
||||
@@ -46,11 +46,11 @@ vi.mock('../context/GuestIdentityContext', () => ({
|
||||
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
vi.mock('@/shared/guest/hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({ markCompleted: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, arg2?: Record<string, string | number> | string, arg3?: string) =>
|
||||
typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? _key) : (arg3 ?? _key),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(() => {});
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
@@ -10,7 +9,7 @@ import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategi
|
||||
import { shouldCacheResponse } from './lib/cachePolicy';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<any>;
|
||||
__WB_MANIFEST: Array<unknown>;
|
||||
};
|
||||
|
||||
clientsClaim();
|
||||
@@ -27,6 +26,12 @@ const isGuestNavigation = (pathname: string) => {
|
||||
if (pathname.startsWith('/g/')) {
|
||||
return true;
|
||||
}
|
||||
if (pathname.startsWith('/show/')) {
|
||||
return true;
|
||||
}
|
||||
if (pathname.startsWith('/setup/')) {
|
||||
return true;
|
||||
}
|
||||
if (pathname.startsWith('/share/')) {
|
||||
return true;
|
||||
}
|
||||
@@ -52,7 +57,7 @@ registerRoute(
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 7 }),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
@@ -70,7 +75,7 @@ registerRoute(
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({ maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 }),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
@@ -81,7 +86,7 @@ registerRoute(
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
@@ -92,7 +97,7 @@ registerRoute(
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 }),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
@@ -101,13 +106,14 @@ self.addEventListener('message', (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('sync', (event: any) => {
|
||||
if (event.tag === 'upload-queue') {
|
||||
event.waitUntil(
|
||||
self.addEventListener('sync', (event: unknown) => {
|
||||
const syncEvent = event as { tag?: string; waitUntil: (promise: Promise<unknown>) => void };
|
||||
if (syncEvent.tag === 'upload-queue') {
|
||||
syncEvent.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
|
||||
clients.forEach((client) => client.postMessage({ type: 'sync-queue' }));
|
||||
})()
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -129,7 +135,7 @@ self.addEventListener('push', (event) => {
|
||||
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach((client) => client.postMessage({ type: 'guest-notification-refresh' }));
|
||||
})()
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,7 +154,8 @@ self.addEventListener('notificationclick', (event) => {
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,6 +163,6 @@ self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' }));
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Award, BarChart2, Camera, Flame, Sparkles, Trophy, Users } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import PullToRefresh from '@/guest/components/PullToRefresh';
|
||||
import PullToRefresh from '@/shared/guest/components/PullToRefresh';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import {
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
type TopPhotoHighlight,
|
||||
type TrendingEmotionHighlight,
|
||||
} from '../services/achievementsApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { localizeTaskLabel } from '@/guest/lib/localizeTaskLabel';
|
||||
import { localizeTaskLabel } from '@/shared/guest/lib/localizeTaskLabel';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -76,9 +76,10 @@ type LeaderboardProps = {
|
||||
emptyCopy: string;
|
||||
formatNumber: (value: number) => string;
|
||||
guestFallback: string;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback }: LeaderboardProps) {
|
||||
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback, isDark }: LeaderboardProps) {
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
@@ -114,9 +115,9 @@ function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, forma
|
||||
justifyContent="space-between"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Text fontSize="$1" fontWeight="$7" color="$color" opacity={0.7}>
|
||||
@@ -146,9 +147,10 @@ type BadgesGridProps = {
|
||||
badges: AchievementBadge[];
|
||||
emptyCopy: string;
|
||||
completeCopy: string;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
|
||||
function BadgesGrid({ badges, emptyCopy, completeCopy, isDark }: BadgesGridProps) {
|
||||
if (badges.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
@@ -172,8 +174,8 @@ function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
borderWidth={1}
|
||||
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : 'rgba(15, 23, 42, 0.1)'}
|
||||
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : 'rgba(255, 255, 255, 0.85)'}
|
||||
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.1)'}
|
||||
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.85)'}
|
||||
gap="$1"
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
@@ -190,7 +192,7 @@ function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
|
||||
{badge.earned ? completeCopy : `${progress}/${target}`}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack height={6} borderRadius={999} backgroundColor="rgba(15, 23, 42, 0.08)">
|
||||
<YStack height={6} borderRadius={999} backgroundColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}>
|
||||
<YStack
|
||||
height={6}
|
||||
borderRadius={999}
|
||||
@@ -209,9 +211,10 @@ type TimelineProps = {
|
||||
points: TimelinePoint[];
|
||||
formatNumber: (value: number) => string;
|
||||
emptyCopy: string;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
|
||||
function Timeline({ points, formatNumber, emptyCopy, isDark }: TimelineProps) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
@@ -229,9 +232,9 @@ function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
|
||||
justifyContent="space-between"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{point.date}
|
||||
@@ -257,6 +260,7 @@ type HighlightsProps = {
|
||||
topPhotoFallbackGuest: string;
|
||||
trendingTitle: string;
|
||||
trendingCountLabel: string;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function Highlights({
|
||||
@@ -271,6 +275,7 @@ function Highlights({
|
||||
topPhotoFallbackGuest,
|
||||
trendingTitle,
|
||||
trendingCountLabel,
|
||||
isDark,
|
||||
}: HighlightsProps) {
|
||||
if (!topPhoto && !trendingEmotion) {
|
||||
return (
|
||||
@@ -298,7 +303,13 @@ function Highlights({
|
||||
style={{ width: '100%', height: 180, objectFit: 'cover', borderRadius: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<YStack height={180} borderRadius={16} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
||||
<YStack
|
||||
height={180}
|
||||
borderRadius={16}
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{topPhotoNoPreview}
|
||||
</Text>
|
||||
@@ -347,9 +358,10 @@ type FeedProps = {
|
||||
emptyCopy: string;
|
||||
guestFallback: string;
|
||||
likesLabel: string;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel }: FeedProps) {
|
||||
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel, isDark }: FeedProps) {
|
||||
if (feed.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
@@ -368,9 +380,9 @@ function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guest
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.05)'}
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
borderColor={isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
>
|
||||
{item.thumbnail ? (
|
||||
<img
|
||||
@@ -379,8 +391,15 @@ function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guest
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<YStack width={64} height={64} borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
||||
<Camera size={18} color="#0F172A" />
|
||||
<YStack
|
||||
width={64}
|
||||
height={64}
|
||||
borderRadius={12}
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</YStack>
|
||||
)}
|
||||
<YStack flex={1} gap="$1">
|
||||
@@ -415,7 +434,6 @@ export default function AchievementsScreen() {
|
||||
const { locale } = useLocale();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const navigate = useNavigate();
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
|
||||
@@ -576,6 +594,7 @@ export default function AchievementsScreen() {
|
||||
badges={personal?.badges ?? []}
|
||||
emptyCopy={t('achievements.badges.empty', 'No badges yet.')}
|
||||
completeCopy={t('achievements.badges.complete', 'Complete')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</YStack>
|
||||
</BentoCard>
|
||||
@@ -597,6 +616,7 @@ export default function AchievementsScreen() {
|
||||
topPhotoFallbackGuest={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
trendingTitle={t('achievements.highlights.trendingTitle', 'Trending emotion')}
|
||||
trendingCountLabel={t('achievements.highlights.trendingCount', { count: '{count}' }, '{count} photos')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</BentoCard>
|
||||
|
||||
@@ -613,6 +633,7 @@ export default function AchievementsScreen() {
|
||||
points={highlights?.timeline ?? []}
|
||||
formatNumber={formatNumber}
|
||||
emptyCopy={t('achievements.timeline.empty', 'No timeline data yet.')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
@@ -626,6 +647,7 @@ export default function AchievementsScreen() {
|
||||
emptyCopy={t('achievements.leaderboard.uploadsEmpty', 'No uploads yet.')}
|
||||
formatNumber={formatNumber}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</BentoCard>
|
||||
<BentoCard isDark={isDark}>
|
||||
@@ -637,6 +659,7 @@ export default function AchievementsScreen() {
|
||||
emptyCopy={t('achievements.leaderboard.likesEmpty', 'No likes yet.')}
|
||||
formatNumber={formatNumber}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
@@ -654,6 +677,7 @@ export default function AchievementsScreen() {
|
||||
emptyCopy={t('achievements.feed.empty', 'The feed is quiet for now.')}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
likesLabel={t('achievements.feed.likesLabel', { count: '{count}' }, '{count} likes')}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</BentoCard>
|
||||
) : null}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import { RefreshCcw, Trash2, UploadCloud } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import SurfaceCard from '../components/SurfaceCard';
|
||||
import { useUploadQueue } from '../services/uploadApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { useLocale } from '@/shared/guest/i18n/LocaleContext';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
|
||||
import { fetchPendingUploadsSummary, type PendingUpload } from '@/shared/guest/services/pendingUploadsApi';
|
||||
|
||||
type ProgressMap = Record<number, number>;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -3,4 +3,4 @@ export {
|
||||
type AchievementsPayload,
|
||||
type AchievementBadge,
|
||||
type LeaderboardEntry,
|
||||
} from '@/guest/services/achievementApi';
|
||||
} from '@/shared/guest/services/achievementApi';
|
||||
|
||||
@@ -5,4 +5,4 @@ export {
|
||||
type EventStats,
|
||||
FetchEventError,
|
||||
type FetchEventErrorCode,
|
||||
} from '@/guest/services/eventApi';
|
||||
} from '@/shared/guest/services/eventApi';
|
||||
|
||||
@@ -3,4 +3,4 @@ export {
|
||||
fetchGalleryPhotos,
|
||||
type GalleryMetaResponse,
|
||||
type GalleryPhotoResource,
|
||||
} from '@/guest/services/galleryApi';
|
||||
} from '@/shared/guest/services/galleryApi';
|
||||
|
||||
@@ -3,4 +3,4 @@ export {
|
||||
markGuestNotificationRead,
|
||||
dismissGuestNotification,
|
||||
type GuestNotificationItem,
|
||||
} from '@/guest/services/notificationApi';
|
||||
} from '@/shared/guest/services/notificationApi';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchJson } from './apiClient';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/guest/services/photosApi';
|
||||
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/shared/guest/services/photosApi';
|
||||
|
||||
export type GalleryPhoto = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export { registerGuestPushSubscription, unregisterGuestPushSubscription } from '@/guest/services/pushApi';
|
||||
export {
|
||||
registerPushSubscription as registerGuestPushSubscription,
|
||||
unregisterPushSubscription as unregisterGuestPushSubscription,
|
||||
} from '@/shared/guest/services/pushApi';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
|
||||
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { isTaskModeEnabled } from '../lib/engagement';
|
||||
|
||||
function TabLink({
|
||||
to,
|
||||
children,
|
||||
isActive,
|
||||
accentColor,
|
||||
radius,
|
||||
style,
|
||||
compact = false,
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
isActive: boolean;
|
||||
accentColor: string;
|
||||
radius: number;
|
||||
style?: React.CSSProperties;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const activeStyle = isActive
|
||||
? {
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
|
||||
color: '#ffffff',
|
||||
boxShadow: `0 12px 30px ${accentColor}33`,
|
||||
borderRadius: radius,
|
||||
...style,
|
||||
}
|
||||
: { borderRadius: radius, ...style };
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={`
|
||||
flex ${compact ? 'h-10 text-[10px]' : 'h-14 text-xs'} flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 font-medium transition-all duration-200 ease-out
|
||||
touch-manipulation backdrop-blur-md
|
||||
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
|
||||
`}
|
||||
style={activeStyle}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BottomNav() {
|
||||
const { token } = useParams();
|
||||
const location = useLocation();
|
||||
const { event, status } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
const navRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||
const surface = branding.palette?.surface ?? branding.backgroundColor;
|
||||
|
||||
const isReady = status === 'ready' && !!event;
|
||||
|
||||
if (!token || !isReady) return null;
|
||||
|
||||
const base = `/e/${encodeURIComponent(token)}`;
|
||||
const currentPath = location.pathname;
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
|
||||
const labels = {
|
||||
home: t('navigation.home'),
|
||||
tasks: t('navigation.tasks'),
|
||||
achievements: t('navigation.achievements'),
|
||||
gallery: t('navigation.gallery'),
|
||||
upload: t('home.actions.items.upload.label'),
|
||||
};
|
||||
|
||||
const isHomeActive = currentPath === base || currentPath === `/${token}`;
|
||||
const isTasksActive = currentPath.startsWith(`${base}/tasks`);
|
||||
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
|
||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
||||
|
||||
const compact = isUploadActive;
|
||||
const navPaddingBottom = `calc(env(safe-area-inset-bottom, 0px) + ${compact ? 12 : 18}px)`;
|
||||
const setBottomOffset = React.useCallback(() => {
|
||||
if (typeof document === 'undefined' || !navRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height = Math.ceil(navRef.current.getBoundingClientRect().height);
|
||||
document.documentElement.style.setProperty('--guest-bottom-nav-offset', `${height}px`);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBottomOffset();
|
||||
|
||||
const handleResize = () => setBottomOffset();
|
||||
if (typeof ResizeObserver !== 'undefined' && navRef.current) {
|
||||
const observer = new ResizeObserver(() => setBottomOffset());
|
||||
observer.observe(navRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
};
|
||||
}, [setBottomOffset, compact]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={navRef}
|
||||
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/15 bg-gradient-to-t from-black/55 via-black/30 to-black/5 px-4 shadow-2xl backdrop-blur-xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/55 dark:to-gray-900/20 ${
|
||||
compact ? 'pt-1' : 'pt-2 pb-1'
|
||||
}`}
|
||||
style={{ paddingBottom: navPaddingBottom }}
|
||||
>
|
||||
<div className="pointer-events-none absolute -top-7 inset-x-0 h-7 bg-gradient-to-b from-black/0 via-black/30 to-black/60 dark:via-black/40 dark:to-black/70" aria-hidden />
|
||||
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||
<div className="flex flex-1 justify-evenly gap-2">
|
||||
<TabLink
|
||||
to={`${base}`}
|
||||
isActive={isHomeActive}
|
||||
accentColor={branding.primaryColor}
|
||||
radius={radius}
|
||||
compact={compact}
|
||||
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Home className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.home}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
{tasksEnabled ? (
|
||||
<TabLink
|
||||
to={`${base}/tasks`}
|
||||
isActive={isTasksActive}
|
||||
accentColor={branding.primaryColor}
|
||||
radius={radius}
|
||||
compact={compact}
|
||||
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.tasks}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`${base}/upload`}
|
||||
aria-label={labels.upload}
|
||||
className={`relative flex ${compact ? 'h-12 w-12' : 'h-16 w-16'} items-center justify-center rounded-full text-white shadow-2xl transition-all duration-300 ${
|
||||
isUploadActive
|
||||
? 'translate-y-6 scale-75 opacity-0 pointer-events-none'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
|
||||
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
|
||||
borderRadius: radius,
|
||||
}}
|
||||
tabIndex={isUploadActive ? -1 : 0}
|
||||
aria-hidden={isUploadActive}
|
||||
>
|
||||
<Camera className="h-6 w-6" aria-hidden />
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 justify-evenly gap-2">
|
||||
<TabLink
|
||||
to={`${base}/achievements`}
|
||||
isActive={isAchievementsActive}
|
||||
accentColor={branding.primaryColor}
|
||||
radius={radius}
|
||||
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||
compact={compact}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Trophy className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.achievements}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
<TabLink
|
||||
to={`${base}/gallery`}
|
||||
isActive={isGalleryActive}
|
||||
accentColor={branding.primaryColor}
|
||||
radius={radius}
|
||||
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
|
||||
compact={compact}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
||||
<span>{labels.gallery}</span>
|
||||
</div>
|
||||
</TabLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'framer-motion';
|
||||
import { ZapOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type DemoReadOnlyNoticeProps = {
|
||||
title: string;
|
||||
copy: string;
|
||||
hint?: string;
|
||||
ctaLabel?: string;
|
||||
onCta?: () => void;
|
||||
radius?: number;
|
||||
bodyFont?: string;
|
||||
motionProps?: HTMLMotionProps<'div'>;
|
||||
};
|
||||
|
||||
export default function DemoReadOnlyNotice({
|
||||
title,
|
||||
copy,
|
||||
hint,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
radius,
|
||||
bodyFont,
|
||||
motionProps,
|
||||
}: DemoReadOnlyNoticeProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-[28px] border border-white/15 bg-black/70 p-5 text-white shadow-2xl backdrop-blur"
|
||||
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||
{...motionProps}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10">
|
||||
<ZapOff className="h-5 w-5 text-amber-200" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-xs text-white/80">{copy}</p>
|
||||
{hint ? <p className="text-[11px] text-white/60">{hint}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{ctaLabel && onCta ? (
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/90 text-slate-900 hover:bg-white"
|
||||
onClick={onCta}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
interface Emotion {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface EmotionPickerProps {
|
||||
onSelect?: (emotion: Emotion) => void;
|
||||
variant?: 'standalone' | 'embedded';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showSkip?: boolean;
|
||||
}
|
||||
|
||||
export default function EmotionPicker({
|
||||
onSelect,
|
||||
variant = 'standalone',
|
||||
title,
|
||||
subtitle,
|
||||
showSkip,
|
||||
}: EmotionPickerProps) {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [emotions, setEmotions] = useState<Emotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { locale } = useTranslation();
|
||||
|
||||
// Fallback emotions (when API not available yet)
|
||||
const fallbackEmotions = React.useMemo<Emotion[]>(
|
||||
() => [
|
||||
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
|
||||
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
|
||||
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
|
||||
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
|
||||
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
|
||||
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventKey) return;
|
||||
|
||||
async function fetchEmotions() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Try API first
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions?locale=${encodeURIComponent(locale)}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
|
||||
} else {
|
||||
// Fallback to predefined emotions
|
||||
console.warn('Emotions API not available, using fallback');
|
||||
setEmotions(fallbackEmotions);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch emotions:', err);
|
||||
setError('Emotions konnten nicht geladen werden');
|
||||
setEmotions(fallbackEmotions);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchEmotions();
|
||||
}, [eventKey, locale, fallbackEmotions]);
|
||||
|
||||
const handleEmotionSelect = (emotion: Emotion) => {
|
||||
if (onSelect) {
|
||||
onSelect(emotion);
|
||||
} else {
|
||||
// Default: Navigate to tasks with emotion filter
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const headingTitle = title ?? 'Wie fühlst du dich?';
|
||||
const headingSubtitle = subtitle ?? '(optional)';
|
||||
const shouldShowSkip = showSkip ?? variant === 'standalone';
|
||||
|
||||
const content = (
|
||||
<div className="space-y-4">
|
||||
{(variant === 'standalone' || title) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
{headingTitle}
|
||||
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground dark:text-white/70">{headingSubtitle}</span>}
|
||||
</h3>
|
||||
{loading && <span className="text-xs text-muted-foreground dark:text-white/70">Lade Emotionen…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-rows-2 grid-flow-col auto-cols-[170px] sm:auto-cols-[190px] gap-3 overflow-x-auto pb-2 pr-12',
|
||||
'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent'
|
||||
)}
|
||||
aria-label="Emotions"
|
||||
>
|
||||
{emotions.map((emotion) => {
|
||||
// Localize name and description if they are JSON
|
||||
const localize = (value: string | object, defaultValue: string = ''): string => {
|
||||
if (typeof value === 'string' && value.startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(value as string);
|
||||
return data.de || data.en || defaultValue || '';
|
||||
} catch {
|
||||
return value as string;
|
||||
}
|
||||
}
|
||||
return value as string;
|
||||
};
|
||||
|
||||
const localizedName = localize(emotion.name, emotion.name);
|
||||
const localizedDescription = localize(emotion.description || '', '');
|
||||
return (
|
||||
<button
|
||||
key={emotion.id}
|
||||
type="button"
|
||||
onClick={() => handleEmotionSelect(emotion)}
|
||||
className="group relative flex flex-col gap-2 rounded-2xl p-[1px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.98]"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(135deg, color-mix(in oklch, var(--guest-primary) 45%, white), color-mix(in oklch, var(--guest-secondary) 40%, white))',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col gap-2 rounded-[0.95rem] border border-white/50 bg-white/80 px-4 py-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
{emotion.emoji}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground dark:text-white">{localizedName}</div>
|
||||
{localizedDescription && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-[var(--guest-background)] via-[var(--guest-background)]/90 to-transparent dark:from-black dark:via-black/80" aria-hidden />
|
||||
</div>
|
||||
|
||||
{/* Skip option */}
|
||||
{shouldShowSkip && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
|
||||
onClick={() => {
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
|
||||
}}
|
||||
>
|
||||
Überspringen und Aufgabe wählen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'embedded') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">{content}</div>;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
type FilterConfig = Array<{ value: GalleryFilter; labelKey: string; icon: LucideIcon }>;
|
||||
|
||||
const baseFilters: FilterConfig = [
|
||||
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: Sparkles },
|
||||
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: Flame },
|
||||
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: UserRound },
|
||||
];
|
||||
|
||||
export default function FiltersBar({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
showPhotobooth = true,
|
||||
styleOverride,
|
||||
}: {
|
||||
value: GalleryFilter;
|
||||
onChange: (v: GalleryFilter) => void;
|
||||
className?: string;
|
||||
showPhotobooth?: boolean;
|
||||
styleOverride?: React.CSSProperties;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const filters: FilterConfig = React.useMemo(
|
||||
() => (showPhotobooth
|
||||
? [...baseFilters, { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: Camera }]
|
||||
: baseFilters),
|
||||
[showPhotobooth],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
className,
|
||||
)}
|
||||
style={styleOverride}
|
||||
>
|
||||
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||
{filters.map((filter, index) => {
|
||||
const isActive = value === filter.value;
|
||||
const Icon = filter.icon;
|
||||
return (
|
||||
<div key={filter.value} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(filter.value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
|
||||
isActive
|
||||
? 'bg-pink-500 text-white shadow'
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
|
||||
</button>
|
||||
{index < filters.length - 1 && (
|
||||
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
type Props = { token: string };
|
||||
|
||||
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
type PreviewPhoto = {
|
||||
id: number;
|
||||
session_id?: string | null;
|
||||
ingest_source?: string | null;
|
||||
likes_count?: number | null;
|
||||
created_at?: string | null;
|
||||
task_id?: number | null;
|
||||
task_title?: string | null;
|
||||
emotion_id?: number | null;
|
||||
emotion_name?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
file_path?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export default function GalleryPreview({ token }: Props) {
|
||||
const { locale } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
const { photos, loading } = usePollGalleryDelta(token, locale);
|
||||
const [mode, setMode] = React.useState<PreviewFilter>('latest');
|
||||
const typedPhotos = React.useMemo(() => photos as PreviewPhoto[], [photos]);
|
||||
const hasPhotobooth = React.useMemo(() => typedPhotos.some((p) => p.ingest_source === 'photobooth'), [typedPhotos]);
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let arr = typedPhotos.slice();
|
||||
|
||||
// MyPhotos filter (requires session_id matching)
|
||||
if (mode === 'mine') {
|
||||
const deviceId = getDeviceId();
|
||||
arr = arr.filter((photo) => photo.session_id === deviceId);
|
||||
} else if (mode === 'photobooth') {
|
||||
arr = arr.filter((photo) => photo.ingest_source === 'photobooth');
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (mode === 'popular') {
|
||||
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else {
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
|
||||
return arr.slice(0, 9); // up to 3x3 preview
|
||||
}, [typedPhotos, mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode === 'photobooth' && !hasPhotobooth) {
|
||||
setMode('latest');
|
||||
}
|
||||
}, [mode, hasPhotobooth]);
|
||||
|
||||
// Helper function to generate photo title (must be before return)
|
||||
function getPhotoTitle(photo: PreviewPhoto): string {
|
||||
if (photo.task_id) {
|
||||
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
|
||||
}
|
||||
if (photo.emotion_id) {
|
||||
return `Emotion: ${photo.emotion_name || 'Gefühl'}`;
|
||||
}
|
||||
// Fallback based on creation time or placeholder
|
||||
const now = new Date();
|
||||
const created = new Date(photo.created_at || now);
|
||||
const hours = created.getHours();
|
||||
if (hours < 12) return 'Morgenmoment';
|
||||
if (hours < 18) return 'Nachmittagslicht';
|
||||
return 'Abendstimmung';
|
||||
}
|
||||
|
||||
const filters: { value: PreviewFilter; label: string }[] = [
|
||||
{ value: 'latest', label: 'Newest' },
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'mine', label: 'My Photos' },
|
||||
...(hasPhotobooth ? [{ value: 'photobooth', label: 'Fotobox' } as const] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="border border-muted/30 bg-[var(--guest-surface)] shadow-sm dark:border-slate-800/70 dark:bg-slate-950/70"
|
||||
data-testid="gallery-preview"
|
||||
style={{ borderRadius: radius, fontFamily: bodyFont }}
|
||||
>
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-1 inline-flex items-center rounded-full border border-white/50 bg-white/80 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||
Live-Galerie
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>Alle Uploads auf einen Blick</h3>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
|
||||
className="rounded-full border border-white/40 bg-white/70 px-3 py-1 text-sm font-semibold shadow-sm backdrop-blur transition hover:bg-white/90 dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-950"
|
||||
style={{ color: linkColor }}
|
||||
>
|
||||
Alle ansehen →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
|
||||
{filters.map((filter) => {
|
||||
const isActive = mode === filter.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => setMode(filter.value)}
|
||||
className={cn(
|
||||
'relative inline-flex items-center rounded-full px-3 py-1.5 transition',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground hover:text-pink-600 dark:text-white/70 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="gallery-filter-pill"
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-r from-pink-500 to-rose-500 shadow"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 whitespace-nowrap">{filter.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Lädt…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-muted/30 bg-[var(--guest-surface)] p-3 text-sm text-muted-foreground dark:border-slate-800/60 dark:bg-slate-950/60">
|
||||
<Heart className="h-4 w-4" style={{ color: branding.secondaryColor }} aria-hidden />
|
||||
Noch keine Fotos. Starte mit deinem ersten Upload!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-3">
|
||||
{items.map((p: PreviewPhoto) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
|
||||
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
alt={p.title || 'Foto'}
|
||||
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden />
|
||||
</div>
|
||||
<div className="space-y-2 px-3 pb-3 pt-3">
|
||||
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||
{p.title || getPhotoTitle(p)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden />
|
||||
{p.likes_count ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Lust auf mehr?{' '}
|
||||
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="font-semibold transition" style={{ color: linkColor }}>
|
||||
Zur Galerie →
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConsent } from '@/contexts/consent';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { isUploadPath, shouldShowAnalyticsNudge } from '../lib/analyticsConsent';
|
||||
|
||||
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
|
||||
const SNOOZE_MS = 60 * 60 * 1000;
|
||||
const ACTIVE_IDLE_LIMIT_MS = 20_000;
|
||||
|
||||
type PromptStorage = {
|
||||
snoozedUntil?: number | null;
|
||||
};
|
||||
|
||||
function readSnoozedUntil(): number | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PromptStorage;
|
||||
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnoozedUntil(value: number | null) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: PromptStorage = { snoozedUntil: value };
|
||||
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
const low = Math.ceil(min);
|
||||
const high = Math.floor(max);
|
||||
return Math.floor(Math.random() * (high - low + 1)) + low;
|
||||
}
|
||||
|
||||
export default function GuestAnalyticsNudge({
|
||||
enabled,
|
||||
pathname,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
pathname: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { decisionMade, preferences, savePreferences } = useConsent();
|
||||
const analyticsConsent = Boolean(preferences?.analytics);
|
||||
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
|
||||
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
|
||||
const [activeSeconds, setActiveSeconds] = React.useState(0);
|
||||
const [routeCount, setRouteCount] = React.useState(0);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
|
||||
const lastPathRef = React.useRef(pathname);
|
||||
const lastActivityAtRef = React.useRef(Date.now());
|
||||
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||
|
||||
const isUpload = isUploadPath(pathname);
|
||||
|
||||
React.useEffect(() => {
|
||||
const previousPath = lastPathRef.current;
|
||||
const currentPath = pathname;
|
||||
lastPathRef.current = currentPath;
|
||||
|
||||
if (previousPath === currentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteCount((count) => count + 1);
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
lastActivityAtRef.current = Date.now();
|
||||
};
|
||||
|
||||
const events: Array<keyof WindowEventMap> = [
|
||||
'pointerdown',
|
||||
'pointermove',
|
||||
'keydown',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
];
|
||||
|
||||
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
|
||||
|
||||
return () => {
|
||||
events.forEach((event) => window.removeEventListener(event, handleActivity));
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleVisibility = () => {
|
||||
visibleRef.current = document.visibilityState === 'visible';
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
if (!visibleRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUploadPath(lastPathRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSeconds((seconds) => seconds + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || analyticsConsent || decisionMade) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldOpen = shouldShowAnalyticsNudge({
|
||||
decisionMade,
|
||||
analyticsConsent,
|
||||
snoozedUntil,
|
||||
now: Date.now(),
|
||||
activeSeconds,
|
||||
routeCount,
|
||||
thresholdSeconds,
|
||||
thresholdRoutes,
|
||||
isUpload,
|
||||
});
|
||||
|
||||
if (shouldOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
analyticsConsent,
|
||||
decisionMade,
|
||||
snoozedUntil,
|
||||
activeSeconds,
|
||||
routeCount,
|
||||
thresholdSeconds,
|
||||
thresholdRoutes,
|
||||
isUpload,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUpload) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isUpload]);
|
||||
|
||||
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSnooze = () => {
|
||||
const until = Date.now() + SNOOZE_MS;
|
||||
setSnoozedUntil(until);
|
||||
writeSnoozedUntil(until);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleAllow = () => {
|
||||
savePreferences({ analytics: true });
|
||||
writeSnoozedUntil(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 z-40 px-4"
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
|
||||
>
|
||||
<div className="pointer-events-auto mx-auto max-w-lg rounded-2xl border border-slate-200/80 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-slate-700/60 dark:bg-slate-900/95">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t('consent.analytics.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('consent.analytics.body')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={handleSnooze}>
|
||||
{t('consent.analytics.later')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleAllow}>
|
||||
{t('consent.analytics.allow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,813 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import {
|
||||
User,
|
||||
Heart,
|
||||
Users,
|
||||
PartyPopper,
|
||||
Camera,
|
||||
Bell,
|
||||
ArrowUpRight,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
LifeBuoy,
|
||||
UploadCloud,
|
||||
AlertCircle,
|
||||
Check,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
import { getContrastingTextColor, relativeLuminance, hexToRgb } from '../lib/color';
|
||||
import { isTaskModeEnabled } from '../lib/engagement';
|
||||
|
||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
heart: Heart,
|
||||
guests: Users,
|
||||
party: PartyPopper,
|
||||
camera: Camera,
|
||||
};
|
||||
|
||||
type LogoSize = 's' | 'm' | 'l';
|
||||
|
||||
const LOGO_SIZE_CLASSES: Record<LogoSize, { container: string; image: string; emoji: string; icon: string; initials: string }> = {
|
||||
s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' },
|
||||
m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' },
|
||||
l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' },
|
||||
};
|
||||
|
||||
function getLogoClasses(size?: LogoSize) {
|
||||
return LOGO_SIZE_CLASSES[size ?? 'm'];
|
||||
}
|
||||
|
||||
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
broadcast: MessageSquare,
|
||||
feedback_request: MessageSquare,
|
||||
achievement_major: Sparkles,
|
||||
support_tip: LifeBuoy,
|
||||
upload_alert: UploadCloud,
|
||||
photo_activity: Camera,
|
||||
};
|
||||
|
||||
function isLikelyEmoji(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const characters = Array.from(value.trim());
|
||||
if (characters.length === 0 || characters.length > 2) {
|
||||
return false;
|
||||
}
|
||||
return characters.some((char) => {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
return codePoint > 0x2600;
|
||||
});
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const words = name.split(' ').filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
return `${words[0][0]}${words[1][0]}`.toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function toRgba(value: string, alpha: number): string {
|
||||
const rgb = hexToRgb(value);
|
||||
if (!rgb) {
|
||||
return `rgba(255, 255, 255, ${alpha})`;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function EventAvatar({
|
||||
name,
|
||||
icon,
|
||||
accentColor,
|
||||
textColor,
|
||||
logo,
|
||||
}: {
|
||||
name: string;
|
||||
icon: unknown;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize };
|
||||
}) {
|
||||
const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null;
|
||||
const [logoFailed, setLogoFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLogoFailed(false);
|
||||
}, [logoValue]);
|
||||
|
||||
const sizes = getLogoClasses(logo?.size);
|
||||
if (logo?.mode === 'upload' && logoValue && !logoFailed) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center rounded-full bg-white shadow-sm ${sizes.container}`}>
|
||||
<img
|
||||
src={logoValue}
|
||||
alt={name}
|
||||
className={`rounded-full object-contain ${sizes.image}`}
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
<span aria-hidden>{logo.value}</span>
|
||||
<span className="sr-only">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof icon === 'string') {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed) {
|
||||
const normalized = trimmed.toLowerCase();
|
||||
const IconComponent = EVENT_ICON_COMPONENTS[normalized];
|
||||
if (IconComponent) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container}`}
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
<IconComponent className={sizes.icon} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLikelyEmoji(trimmed)) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full shadow-sm ${sizes.container} ${sizes.emoji}`}
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
<span aria-hidden>{trimmed}</span>
|
||||
<span className="sr-only">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full font-semibold shadow-sm ${sizes.container} ${sizes.initials}`}
|
||||
style={{ backgroundColor: accentColor, color: textColor }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
|
||||
const statsContext = useOptionalEventStats();
|
||||
const { t } = useTranslation();
|
||||
const brandingContext = useOptionalEventBranding();
|
||||
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
|
||||
const headerTextColor = React.useMemo(() => {
|
||||
const primaryLum = relativeLuminance(branding.primaryColor);
|
||||
const secondaryLum = relativeLuminance(branding.secondaryColor);
|
||||
const avgLum = (primaryLum + secondaryLum) / 2;
|
||||
|
||||
if (avgLum > 0.55) {
|
||||
return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff');
|
||||
}
|
||||
|
||||
return '#ffffff';
|
||||
}, [branding.primaryColor, branding.secondaryColor]);
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!notificationsOpen) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (notificationButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (!panelRef.current) return;
|
||||
if (panelRef.current.contains(event.target as Node)) return;
|
||||
setNotificationsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [notificationsOpen]);
|
||||
|
||||
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const logoPosition = branding.logo?.position ?? 'left';
|
||||
const headerStyle: React.CSSProperties = {
|
||||
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
color: headerTextColor,
|
||||
fontFamily: headerFont,
|
||||
};
|
||||
const headerGlowPrimary = toRgba(branding.primaryColor, 0.35);
|
||||
const headerGlowSecondary = toRgba(branding.secondaryColor, 0.35);
|
||||
const headerShimmer = `linear-gradient(120deg, ${toRgba(branding.primaryColor, 0.28)}, transparent 45%, ${toRgba(branding.secondaryColor, 0.32)})`;
|
||||
const headerHairline = `linear-gradient(90deg, transparent, ${toRgba(headerTextColor, 0.4)}, transparent)`;
|
||||
|
||||
if (!eventToken) {
|
||||
return (
|
||||
<div
|
||||
className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 bg-white/70 px-4 py-2 shadow-[0_14px_40px_-30px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-black/40"
|
||||
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora-soft" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||
<div className="pointer-events-none absolute -top-8 right-0 h-24 w-24 rounded-full bg-white/60 blur-3xl dark:bg-white/10" aria-hidden />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-white/40 to-transparent dark:via-white/15" aria-hidden />
|
||||
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="font-semibold">{title}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center justify-end gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const accentColor = branding.secondaryColor;
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="guest-header sticky top-0 z-30 relative overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl" style={headerStyle}>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||
<div className="pointer-events-none absolute -top-10 right-[-32px] h-28 w-28 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
|
||||
<div className="pointer-events-none absolute -bottom-8 left-1/3 h-20 w-40 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
|
||||
<div className="relative z-10 flex w-full items-center gap-3 flex-nowrap">
|
||||
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
|
||||
<div className="ml-auto flex items-center justify-end gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats =
|
||||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||
return (
|
||||
<div
|
||||
className="guest-header sticky top-0 z-30 relative flex flex-nowrap items-center gap-3 overflow-hidden border-b border-white/20 px-4 py-2 shadow-[0_18px_45px_-30px_rgba(15,23,42,0.65)] backdrop-blur-2xl"
|
||||
style={headerStyle}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-70 guest-aurora" style={{ backgroundImage: headerShimmer }} aria-hidden />
|
||||
<div className="pointer-events-none absolute -top-12 right-[-40px] h-32 w-32 rounded-full blur-3xl" style={{ background: headerGlowSecondary }} aria-hidden />
|
||||
<div className="pointer-events-none absolute -bottom-10 left-1/3 h-24 w-44 -translate-x-1/2 rounded-full blur-3xl" style={{ background: headerGlowPrimary }} aria-hidden />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-px" style={{ background: headerHairline }} aria-hidden />
|
||||
<div
|
||||
className={
|
||||
`relative z-10 flex min-w-0 flex-1 ${logoPosition === 'center'
|
||||
? 'flex-col items-center gap-1 text-center'
|
||||
: logoPosition === 'right'
|
||||
? 'flex-row-reverse items-center gap-3'
|
||||
: 'items-center gap-3'}`
|
||||
}
|
||||
>
|
||||
<EventAvatar
|
||||
name={event.name}
|
||||
icon={event.type?.icon}
|
||||
accentColor={accentColor}
|
||||
textColor={headerTextColor}
|
||||
logo={branding.logo}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-col${logoPosition === 'center' ? ' items-center text-center' : ''}`}
|
||||
style={headerFont ? { fontFamily: headerFont } : undefined}
|
||||
>
|
||||
<div className="truncate text-base font-semibold sm:text-lg">{event.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs opacity-70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||
{stats && tasksEnabled && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{`${stats.onlineGuests} ${t('header.stats.online')}`}</span>
|
||||
</span>
|
||||
<span className="opacity-50">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span>{' '}
|
||||
{t('header.stats.tasksSolved')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 ml-auto flex shrink-0 items-center justify-end gap-2">
|
||||
{notificationCenter && eventToken && (
|
||||
<NotificationButton
|
||||
eventToken={eventToken}
|
||||
center={notificationCenter}
|
||||
open={notificationsOpen}
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
buttonRef={notificationButtonRef}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationButtonProps = {
|
||||
center: NotificationCenterValue;
|
||||
eventToken: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
}
|
||||
}, [open, center.unreadCount]);
|
||||
|
||||
const uploadNotifications = React.useMemo(
|
||||
() => center.notifications.filter((item) => item.type === 'upload_alert'),
|
||||
[center.notifications]
|
||||
);
|
||||
const unreadNotifications = React.useMemo(
|
||||
() => center.notifications.filter((item) => item.status === 'new'),
|
||||
[center.notifications]
|
||||
);
|
||||
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
let base: typeof center.notifications = [];
|
||||
switch (activeTab) {
|
||||
case 'unread':
|
||||
base = unreadNotifications;
|
||||
break;
|
||||
case 'uploads':
|
||||
base = uploadNotifications;
|
||||
break;
|
||||
default:
|
||||
base = center.notifications;
|
||||
}
|
||||
return base;
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||
|
||||
const scopedNotifications = React.useMemo(() => {
|
||||
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
||||
return filteredNotifications;
|
||||
}
|
||||
return filteredNotifications.filter((item) => {
|
||||
if (scopeFilter === 'tips') {
|
||||
return item.type === 'support_tip' || item.type === 'achievement_major';
|
||||
}
|
||||
return item.type === 'broadcast' || item.type === 'feedback_request';
|
||||
});
|
||||
}, [filteredNotifications, scopeFilter]);
|
||||
|
||||
return (
|
||||
<div className="relative z-50">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||
aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden />
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
|
||||
{badgeCount > 9 ? '9+' : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed right-4 top-16 z-[2147483000] w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{center.unreadCount > 0
|
||||
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
|
||||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => center.refresh()}
|
||||
disabled={center.loading}
|
||||
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
{t('header.notifications.refresh', 'Aktualisieren')}
|
||||
</button>
|
||||
</div>
|
||||
<NotificationTabs
|
||||
tabs={[
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
||||
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||
/>
|
||||
{activeTab !== 'uploads' && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
|
||||
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
|
||||
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeFilter(option.key);
|
||||
center.setFilters({ scope: option.key });
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 font-semibold transition ${
|
||||
scopeFilter === option.key
|
||||
? 'border-pink-200 bg-pink-50 text-pink-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{center.pendingCount > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-amber-500" aria-hidden />
|
||||
<span>{t('header.notifications.pendingLabel', 'Uploads in Prüfung')}</span>
|
||||
<span className="font-semibold text-amber-900">{center.pendingCount}</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||||
className="inline-flex items-center gap-1 font-semibold text-amber-700"
|
||||
onClick={() => {
|
||||
if (center.unreadCount > 0) {
|
||||
void center.refresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('header.notifications.pendingCta', 'Details')}
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{center.queueCount > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-slate-50/90 px-3 py-2 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4 text-slate-400" aria-hidden />
|
||||
<span>{t('header.notifications.queueLabel', 'Upload-Warteschlange (offline)')}</span>
|
||||
<span className="font-semibold text-slate-900">{center.queueCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'uploads'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
push={pushState}
|
||||
t={t}
|
||||
/>
|
||||
</div>,
|
||||
(typeof document !== 'undefined' ? document.body : null) as any
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationListItem({
|
||||
item,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
t,
|
||||
}: {
|
||||
item: NotificationCenterValue['notifications'][number];
|
||||
onMarkRead: () => void;
|
||||
onDismiss: () => void;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
|
||||
const isNew = item.status === 'new';
|
||||
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
|
||||
onClick={() => {
|
||||
if (isNew) {
|
||||
onMarkRead();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
<IconComponent className="h-4 w-4" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||||
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
|
||||
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||
{createdLabel && <span>{createdLabel}</span>}
|
||||
{isNew && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
{t('header.notifications.badge.new', 'Neu')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.cta && (
|
||||
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
|
||||
)}
|
||||
{!isNew && item.status !== 'dismissed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMarkRead();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
|
||||
>
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
{t('header.notifications.markRead', 'Als gelesen markieren')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
|
||||
const href = cta.href ?? '#';
|
||||
const label = cta.label ?? '';
|
||||
const isInternal = /^\//.test(href);
|
||||
const content = (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isInternal) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||
onClick={onFollow}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||
onClick={onFollow}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationEmptyState({ t, message }: { t: TranslateFn; message?: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
|
||||
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
|
||||
<p>{message ?? t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-slate-200" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-slate-200" />
|
||||
<div className="h-3 w-1/2 rounded bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'Gerade eben';
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes} min`;
|
||||
}
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} h`;
|
||||
}
|
||||
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays} d`;
|
||||
}
|
||||
|
||||
function NotificationTabs({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: {
|
||||
tabs: Array<{ key: string; label: string; badge?: number }>;
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex gap-2 rounded-full bg-slate-100/80 p-1 text-xs font-semibold text-slate-600">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-full px-3 py-1 transition ${
|
||||
activeTab === tab.key ? 'bg-white text-pink-600 shadow' : 'text-slate-500'
|
||||
}`}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.badge === 'number' && tab.badge > 0 && (
|
||||
<span className="rounded-full bg-pink-100 px-2 text-[11px] text-pink-600">{tab.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationStatusBar({
|
||||
lastFetchedAt,
|
||||
isOffline,
|
||||
push,
|
||||
t,
|
||||
}: {
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
push: PushState;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const label = lastFetchedAt ? formatRelativeTime(lastFetchedAt.toISOString()) : t('header.notifications.never', 'Noch keine Aktualisierung');
|
||||
const pushDescription = React.useMemo(() => {
|
||||
if (!push.supported) {
|
||||
return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt');
|
||||
}
|
||||
if (push.permission === 'denied') {
|
||||
return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen');
|
||||
}
|
||||
if (push.subscribed) {
|
||||
return t('header.notifications.pushActive', 'Push aktiv');
|
||||
}
|
||||
return t('header.notifications.pushInactive', 'Push deaktiviert');
|
||||
}, [push.permission, push.subscribed, push.supported, t]);
|
||||
|
||||
const buttonLabel = push.subscribed
|
||||
? t('header.notifications.pushDisable', 'Deaktivieren')
|
||||
: t('header.notifications.pushEnable', 'Aktivieren');
|
||||
|
||||
const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied';
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2 border-t border-slate-200 pt-3 text-[11px] text-slate-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label}
|
||||
</span>
|
||||
{isOffline && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-semibold text-amber-700">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{t('header.notifications.offline', 'Offline')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-full bg-slate-100/80 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Bell className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>{pushDescription}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (push.subscribed ? push.disable() : push.enable())}
|
||||
disabled={pushButtonDisabled}
|
||||
className="rounded-full bg-white/80 px-3 py-0.5 text-[11px] font-semibold text-pink-600 shadow disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{push.loading ? t('header.notifications.pushLoading', '…') : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{push.error && (
|
||||
<p className="text-[11px] font-semibold text-rose-600">
|
||||
{push.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { Outlet, useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
const TAB_SECTIONS = new Set(['home', 'tasks', 'achievements', 'gallery']);
|
||||
|
||||
export function getTabKey(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/e\/[^/]+(?:\/([^/]+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = match[1];
|
||||
if (!section) {
|
||||
return 'home';
|
||||
}
|
||||
|
||||
return TAB_SECTIONS.has(section) ? section : null;
|
||||
}
|
||||
|
||||
export function getTransitionKind(prevPath: string, nextPath: string): 'tab' | 'stack' {
|
||||
const prevTab = getTabKey(prevPath);
|
||||
const nextTab = getTabKey(nextPath);
|
||||
|
||||
if (prevTab && nextTab && prevTab !== nextTab) {
|
||||
return 'tab';
|
||||
}
|
||||
|
||||
return 'stack';
|
||||
}
|
||||
|
||||
export function isTransitionDisabled(pathname: string): boolean {
|
||||
if (pathname.startsWith('/share/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^\/e\/[^/]+\/upload(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
export default function RouteTransition({ children }: { children?: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const prevPathRef = React.useRef(location.pathname);
|
||||
const prevPath = prevPathRef.current;
|
||||
const direction = navigationType === 'POP' ? 'back' : 'forward';
|
||||
const kind = getTransitionKind(prevPath, location.pathname);
|
||||
const disableTransitions = prefersReducedMotion
|
||||
|| isTransitionDisabled(prevPath)
|
||||
|| isTransitionDisabled(location.pathname);
|
||||
|
||||
React.useEffect(() => {
|
||||
prevPathRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
const content = children ?? <Outlet />;
|
||||
|
||||
if (disableTransitions) {
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
const stackVariants = {
|
||||
enter: ({ direction }: { direction: 'forward' | 'back' }) => ({
|
||||
x: direction === 'back' ? -28 : 28,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: ({ direction }: { direction: 'forward' | 'back' }) => ({
|
||||
x: direction === 'back' ? 28 : -28,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const tabVariants = {
|
||||
enter: { opacity: 0, y: 8 },
|
||||
center: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -8 },
|
||||
};
|
||||
|
||||
const transition = kind === 'tab'
|
||||
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
|
||||
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
custom={{ direction }}
|
||||
variants={kind === 'tab' ? tabVariants : stackVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={transition as any}
|
||||
style={{ willChange: 'transform, opacity' }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Share2, MessageSquare, Copy } from 'lucide-react';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
type ShareSheetProps = {
|
||||
open: boolean;
|
||||
photoId?: number | null;
|
||||
eventName?: string | null;
|
||||
url?: string | null;
|
||||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
onShareNative: () => void;
|
||||
onShareWhatsApp: () => void;
|
||||
onShareMessages: () => void;
|
||||
onCopyLink: () => void;
|
||||
radius?: number;
|
||||
bodyFont?: string | null;
|
||||
headingFont?: string | null;
|
||||
};
|
||||
|
||||
const WhatsAppIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden focusable="false" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ShareSheet({
|
||||
open,
|
||||
photoId,
|
||||
eventName,
|
||||
url,
|
||||
loading = false,
|
||||
onClose,
|
||||
onShareNative,
|
||||
onShareWhatsApp,
|
||||
onShareMessages,
|
||||
onCopyLink,
|
||||
radius = 12,
|
||||
bodyFont,
|
||||
headingFont,
|
||||
}: ShareSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-3xl border border-border bg-white/98 p-4 text-slate-900 shadow-2xl ring-1 ring-black/10 backdrop-blur-md dark:border-white/10 dark:bg-slate-900/98 dark:text-white"
|
||||
style={{ ...(bodyFont ? { fontFamily: bodyFont } : {}), borderRadius: radius }}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('share.title', 'Geteiltes Foto')}
|
||||
</p>
|
||||
<p className="text-base font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
|
||||
{photoId ? `#${photoId}` : ''}
|
||||
</p>
|
||||
{eventName ? <p className="text-xs text-muted-foreground line-clamp-2">{eventName}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-muted px-3 py-1 text-xs font-semibold text-foreground transition hover:bg-muted/80 dark:border-white/20 dark:text-white"
|
||||
style={{ borderRadius: radius }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('lightbox.close', 'Schließen')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-800 disabled:opacity-100 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/10 dark:disabled:text-white/80"
|
||||
onClick={onShareNative}
|
||||
disabled={loading}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden />
|
||||
<div>
|
||||
<div>{t('share.button', 'Teilen')}</div>
|
||||
<div className="text-xs text-slate-600 dark:text-white/70">{t('share.title', 'Geteiltes Foto')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-emerald-600 disabled:opacity-60 dark:border-emerald-400/40"
|
||||
onClick={onShareWhatsApp}
|
||||
disabled={loading}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<WhatsAppIcon className="h-5 w-5" />
|
||||
<div>
|
||||
<div>{t('share.whatsapp', 'WhatsApp')}</div>
|
||||
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-500/90 px-3 py-3 text-left text-sm font-semibold text-white shadow transition hover:bg-sky-600 disabled:opacity-60 dark:border-sky-400/40"
|
||||
onClick={onShareMessages}
|
||||
disabled={loading}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<div>
|
||||
<div>{t('share.imessage', 'Nachrichten')}</div>
|
||||
<div className="text-xs text-white/80">{loading ? '…' : ''}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3 text-left text-sm font-semibold text-slate-900 shadow-sm transition hover:bg-slate-50 disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500 dark:border-white/15 dark:bg-white/10 dark:text-white dark:disabled:bg-white/5 dark:disabled:text-white/50"
|
||||
onClick={onCopyLink}
|
||||
disabled={loading}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
<div>
|
||||
<div className="text-slate-900 dark:text-white">{t('share.copyLink', 'Link kopieren')}</div>
|
||||
<div className="text-xs text-slate-600 dark:text-white/80">{loading ? t('share.loading', 'Lädt…') : ''}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{url ? (
|
||||
<p className="mt-3 truncate text-xs text-slate-700 dark:text-white/80" title={url}>
|
||||
{url}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareSheet;
|
||||
@@ -1,104 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import BottomNav from '../BottomNav';
|
||||
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
vi.mock('../../hooks/useEventData', () => ({
|
||||
useEventData: () => ({
|
||||
status: 'ready',
|
||||
event: {
|
||||
id: 1,
|
||||
default_locale: 'de',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#0f172a',
|
||||
secondaryColor: '#38bdf8',
|
||||
backgroundColor: '#ffffff',
|
||||
palette: {
|
||||
surface: '#ffffff',
|
||||
},
|
||||
buttons: {
|
||||
radius: 12,
|
||||
style: 'filled',
|
||||
linkColor: '#0f172a',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/engagement', () => ({
|
||||
isTaskModeEnabled: () => false,
|
||||
}));
|
||||
|
||||
describe('BottomNav', () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 80,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 80,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class {
|
||||
private callback: () => void;
|
||||
|
||||
constructor(callback: () => void) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe() {
|
||||
this.callback();
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
document.documentElement.style.removeProperty('--guest-bottom-nav-offset');
|
||||
if (originalResizeObserver) {
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
} else {
|
||||
delete (globalThis as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||
}
|
||||
});
|
||||
|
||||
it('sets the bottom nav offset CSS variable', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/e/demo']}>
|
||||
<Routes>
|
||||
<Route path="/e/:token/*" element={<BottomNav />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-bottom-nav-offset')).toBe('80px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GalleryPreview from '../GalleryPreview';
|
||||
|
||||
vi.mock('../../polling/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({
|
||||
photos: [],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
locale: 'de',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#FF5A5F',
|
||||
secondaryColor: '#FFF8F5',
|
||||
buttons: { radius: 12, linkColor: '#FFF8F5' },
|
||||
typography: {},
|
||||
fontFamily: 'Montserrat',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GalleryPreview', () => {
|
||||
it('renders dark mode-ready surfaces', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GalleryPreview token="demo" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const card = screen.getByTestId('gallery-preview');
|
||||
expect(card.className).toContain('bg-[var(--guest-surface)]');
|
||||
expect(card.className).toContain('dark:bg-slate-950/70');
|
||||
|
||||
const emptyState = screen.getByText(/Noch keine Fotos/i).closest('div');
|
||||
expect(emptyState).not.toBeNull();
|
||||
expect(emptyState?.className).toContain('dark:bg-slate-950/60');
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Header from '../Header';
|
||||
|
||||
vi.mock('../settings-sheet', () => ({
|
||||
SettingsSheet: () => <div data-testid="settings-sheet" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/appearance-dropdown', () => ({
|
||||
default: () => <div data-testid="appearance-toggle" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useEventData', () => ({
|
||||
useEventData: () => ({
|
||||
status: 'ready',
|
||||
event: {
|
||||
name: 'Demo Event',
|
||||
type: { icon: 'heart' },
|
||||
engagement_mode: 'photo_only',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventStatsContext', () => ({
|
||||
useOptionalEventStats: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/GuestIdentityContext', () => ({
|
||||
useOptionalGuestIdentity: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../context/NotificationCenterContext', () => ({
|
||||
useOptionalNotificationCenter: () => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
queueItems: [],
|
||||
queueCount: 0,
|
||||
pendingCount: 0,
|
||||
loading: false,
|
||||
pendingLoading: false,
|
||||
refresh: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
eventToken: 'demo',
|
||||
lastFetchedAt: null,
|
||||
isOffline: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGuestTaskProgress', () => ({
|
||||
useGuestTaskProgress: () => ({
|
||||
hydrated: false,
|
||||
completedCount: 0,
|
||||
}),
|
||||
TASK_BADGE_TARGET: 10,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/usePushSubscription', () => ({
|
||||
usePushSubscription: () => ({
|
||||
supported: false,
|
||||
permission: 'default',
|
||||
subscribed: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string | { defaultValue?: string }) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
if (fallback && typeof fallback.defaultValue === 'string') {
|
||||
return fallback.defaultValue;
|
||||
}
|
||||
return _key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header notifications toggle', () => {
|
||||
it('closes the panel when clicking the bell again', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Header eventToken="demo" title="Demo" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PullToRefresh from '../PullToRefresh';
|
||||
|
||||
describe('PullToRefresh', () => {
|
||||
it('renders children and labels', () => {
|
||||
render(
|
||||
<PullToRefresh
|
||||
onRefresh={vi.fn()}
|
||||
pullLabel="Pull"
|
||||
releaseLabel="Release"
|
||||
refreshingLabel="Refreshing"
|
||||
>
|
||||
<div>Content</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pull')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { LocaleProvider } from '../../i18n/LocaleContext';
|
||||
import { ConsentProvider } from '../../../contexts/consent';
|
||||
import { SettingsSheet } from '../settings-sheet';
|
||||
|
||||
describe('SettingsSheet language section', () => {
|
||||
it('does not render active badge or description text', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ConsentProvider>
|
||||
<LocaleProvider>
|
||||
<SettingsSheet />
|
||||
</LocaleProvider>
|
||||
</ConsentProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Einstellungen öffnen' }));
|
||||
|
||||
expect(screen.getByText('Sprache')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Wähle deine bevorzugte Sprache für diese Veranstaltung.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('aktiv')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { ToastProvider, useToast } from '../ToastHost';
|
||||
|
||||
function ToastTestHarness({ onAction }: { onAction: () => void }) {
|
||||
const toast = useToast();
|
||||
|
||||
React.useEffect(() => {
|
||||
toast.push({
|
||||
text: 'Update ready',
|
||||
type: 'info',
|
||||
durationMs: 0,
|
||||
action: {
|
||||
label: 'Reload',
|
||||
onClick: onAction,
|
||||
},
|
||||
});
|
||||
}, [toast, onAction]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('ToastHost', () => {
|
||||
it('renders action toasts and dismisses after action click', async () => {
|
||||
const onAction = vi.fn();
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastTestHarness onAction={onAction} />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Update ready')).toBeInTheDocument();
|
||||
const button = screen.getByRole('button', { name: 'Reload' });
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('Update ready')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { LegalMarkdown } from './legal-markdown';
|
||||
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
import { useHapticsPreference } from '../hooks/useHapticsPreference';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
import { getHelpSlugForPathname } from '../lib/helpRouting';
|
||||
import { useConsent } from '@/contexts/consent';
|
||||
|
||||
const legalPages = [
|
||||
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
|
||||
{ slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
|
||||
{ slug: 'agb', translationKey: 'settings.legal.section.terms' },
|
||||
] as const;
|
||||
|
||||
type ViewState =
|
||||
| { mode: 'home' }
|
||||
| {
|
||||
mode: 'legal';
|
||||
slug: (typeof legalPages)[number]['slug'];
|
||||
translationKey: (typeof legalPages)[number]['translationKey'];
|
||||
};
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; markdown: string; html: string }
|
||||
| { phase: 'loading'; title: string; markdown: string; html: string }
|
||||
| { phase: 'ready'; title: string; markdown: string; html: string }
|
||||
| { phase: 'error'; title: string; markdown: string; html: string };
|
||||
|
||||
type NameStatus = 'idle' | 'saved';
|
||||
|
||||
export function SettingsSheet() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const localeContext = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ token?: string }>();
|
||||
const location = useLocation();
|
||||
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
||||
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const isLegal = view.mode === 'legal';
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
|
||||
const helpSlug = getHelpSlugForPathname(location.pathname);
|
||||
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
setNameDraft(identity.name ?? '');
|
||||
setNameStatus('idle');
|
||||
}
|
||||
}, [open, identity?.hydrated, identity?.name]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setView({ mode: 'home' });
|
||||
}, []);
|
||||
|
||||
const handleOpenLegal = React.useCallback(
|
||||
(
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => {
|
||||
setView({ mode: 'legal', slug, translationKey });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setView({ mode: 'home' });
|
||||
setNameStatus('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canSaveName = Boolean(
|
||||
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
|
||||
);
|
||||
|
||||
const handleSaveName = React.useCallback(() => {
|
||||
if (!identity || !canSaveName) {
|
||||
return;
|
||||
}
|
||||
setSavingName(true);
|
||||
try {
|
||||
identity.setName(nameDraft);
|
||||
setNameStatus('saved');
|
||||
window.setTimeout(() => setNameStatus('idle'), 2000);
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
}, [identity, nameDraft, canSaveName]);
|
||||
|
||||
const handleResetName = React.useCallback(() => {
|
||||
if (!identity) return;
|
||||
identity.clearName();
|
||||
setNameDraft('');
|
||||
setNameStatus('idle');
|
||||
}, [identity]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-2xl border border-white/25 bg-white/15 text-current shadow-lg shadow-black/20 backdrop-blur transition hover:border-white/40 hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="sm:max-w-md">
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="border-b bg-background px-6 py-4">
|
||||
{isLegal ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">{t('settings.sheet.backLabel')}</span>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate">
|
||||
{legalDocument.phase === 'ready' && legalDocument.title
|
||||
? legalDocument.title
|
||||
: t(view.translationKey)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{legalDocument.phase === 'loading'
|
||||
? t('common.actions.loading')
|
||||
: t('settings.sheet.legalDescription')}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<SheetTitle>{t('settings.title')}</SheetTitle>
|
||||
<SheetDescription>{t('settings.subtitle')}</SheetDescription>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLegal ? (
|
||||
<LegalView
|
||||
document={legalDocument}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
translationKey={view.mode === 'legal' ? view.translationKey : null}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
identity={identity}
|
||||
nameDraft={nameDraft}
|
||||
onNameChange={setNameDraft}
|
||||
onSaveName={handleSaveName}
|
||||
onResetName={handleResetName}
|
||||
canSaveName={canSaveName}
|
||||
savingName={savingName}
|
||||
nameStatus={nameStatus}
|
||||
localeContext={localeContext}
|
||||
onOpenLegal={handleOpenLegal}
|
||||
helpHref={helpHref}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
|
||||
<div>{t('settings.footer.notice')}</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalView({
|
||||
document,
|
||||
onClose,
|
||||
translationKey,
|
||||
}: {
|
||||
document: LegalDocumentState;
|
||||
onClose: () => void;
|
||||
translationKey: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (document.phase === 'error') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('settings.legal.error')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document.phase === 'loading' || document.phase === 'idle') {
|
||||
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
|
||||
<LegalMarkdown markdown={document.markdown} html={document.html} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HomeViewProps {
|
||||
identity: ReturnType<typeof useOptionalGuestIdentity>;
|
||||
nameDraft: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onSaveName: () => void;
|
||||
onResetName: () => void;
|
||||
canSaveName: boolean;
|
||||
savingName: boolean;
|
||||
nameStatus: NameStatus;
|
||||
localeContext: LocaleContextValue;
|
||||
onOpenLegal: (
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => void;
|
||||
helpHref: string;
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
identity,
|
||||
nameDraft,
|
||||
onNameChange,
|
||||
onSaveName,
|
||||
onResetName,
|
||||
canSaveName,
|
||||
savingName,
|
||||
nameStatus,
|
||||
localeContext,
|
||||
onOpenLegal,
|
||||
helpHref,
|
||||
}: HomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
|
||||
const { preferences, savePreferences } = useConsent();
|
||||
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
|
||||
const legalLinks = React.useMemo(
|
||||
() =>
|
||||
legalPages.map((page) => ({
|
||||
slug: page.slug,
|
||||
translationKey: page.translationKey,
|
||||
label: t(page.translationKey),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('settings.language.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{localeContext.availableLocales.map((option) => {
|
||||
const isActive = localeContext.locale === option.code;
|
||||
return (
|
||||
<Button
|
||||
key={option.code}
|
||||
type="button"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
className={`flex h-12 flex-col justify-center gap-1 rounded-lg border text-sm ${
|
||||
isActive ? 'bg-pink-500 text-white hover:bg-pink-600' : 'bg-background'
|
||||
}`}
|
||||
onClick={() => localeContext.setLocale(option.code)}
|
||||
aria-pressed={isActive}
|
||||
disabled={!localeContext.hydrated}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">{option.flag}</span>
|
||||
<span className="font-medium">{t(`settings.language.option.${option.code}`)}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{identity && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('settings.name.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.name.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
||||
<UserCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="guest-name" className="text-sm font-medium">
|
||||
{t('settings.name.label')}
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-name"
|
||||
value={nameDraft}
|
||||
placeholder={t('settings.name.placeholder')}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!identity.hydrated || savingName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
|
||||
{savingName ? t('settings.name.saving') : t('settings.name.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
||||
{t('settings.name.reset')}
|
||||
</Button>
|
||||
{nameStatus === 'saved' && (
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
|
||||
)}
|
||||
{!identity.hydrated && (
|
||||
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('settings.haptics.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.haptics.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">{t('settings.haptics.label')}</span>
|
||||
<Switch
|
||||
checked={hapticsEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setHapticsEnabled(checked);
|
||||
if (checked) {
|
||||
triggerHaptic('selection');
|
||||
}
|
||||
}}
|
||||
disabled={!hapticsSupported}
|
||||
aria-label={t('settings.haptics.label')}
|
||||
/>
|
||||
</div>
|
||||
{!hapticsSupported && (
|
||||
<div className="text-xs text-muted-foreground">{t('settings.haptics.unsupported')}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{matomoEnabled ? (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('settings.analytics.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.analytics.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">{t('settings.analytics.label')}</span>
|
||||
<Switch
|
||||
checked={Boolean(preferences?.analytics)}
|
||||
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
|
||||
aria-label={t('settings.analytics.label')}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t('settings.analytics.note')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-pink-500" />
|
||||
{t('settings.legal.title')}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.legal.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{legalLinks.map((page) => (
|
||||
<Button
|
||||
key={page.slug}
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => onOpenLegal(page.slug, page.translationKey)}
|
||||
>
|
||||
<span className="text-left text-sm">{page.label}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoy className="h-4 w-4 text-pink-500" />
|
||||
{t('settings.help.title')}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.help.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link to={helpHref}>{t('settings.help.cta')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.cache.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.cache.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ClearCacheButton />
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
|
||||
<span>{t('settings.cache.note')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
markdown: '',
|
||||
html: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setState({ phase: 'idle', title: '', markdown: '', html: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', markdown: '', html: '' });
|
||||
|
||||
const langParam = encodeURIComponent(locale);
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('failed');
|
||||
}
|
||||
const payload = await res.json();
|
||||
setState({
|
||||
phase: 'ready',
|
||||
title: payload.title ?? '',
|
||||
markdown: payload.body_markdown ?? '',
|
||||
html: payload.body_html ?? '',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load legal page', error);
|
||||
setState({ phase: 'error', title: '', markdown: '', html: '' });
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [slug, locale]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function ClearCacheButton() {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function clearAll() {
|
||||
setBusy(true);
|
||||
setDone(false);
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
if ('indexedDB' in window) {
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
const request = indexedDB.deleteDatabase('upload-queue');
|
||||
request.onsuccess = () => resolve(null);
|
||||
request.onerror = () => resolve(null);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('IndexedDB cleanup failed', error);
|
||||
}
|
||||
}
|
||||
setDone(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
window.setTimeout(() => setDone(false), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
|
||||
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
|
||||
</Button>
|
||||
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
|
||||
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
|
||||
eventKey: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
|
||||
|
||||
export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
|
||||
const stats = usePollStats(eventKey);
|
||||
const value = React.useMemo<EventStatsContextValue>(
|
||||
() => ({ eventKey, slug: eventKey, ...stats }),
|
||||
[eventKey, stats]
|
||||
);
|
||||
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEventStats() {
|
||||
const ctx = React.useContext(EventStatsContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useEventStats must be used within an EventStatsProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalEventStats() {
|
||||
return React.useContext(EventStatsContext);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type GuestIdentityContextValue = {
|
||||
eventKey: string;
|
||||
slug: string; // backward-compatible alias
|
||||
name: string;
|
||||
hydrated: boolean;
|
||||
setName: (nextName: string) => void;
|
||||
clearName: () => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
|
||||
|
||||
function storageKey(eventKey: string) {
|
||||
return `guestName_${eventKey}`;
|
||||
}
|
||||
|
||||
export function readGuestName(eventKey: string) {
|
||||
if (!eventKey || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(eventKey)) ?? '';
|
||||
} catch (error) {
|
||||
console.warn('Failed to read guest name', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
|
||||
const [name, setNameState] = React.useState('');
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
const loadFromStorage = React.useCallback(() => {
|
||||
if (!eventKey) {
|
||||
setHydrated(true);
|
||||
setNameState('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(eventKey));
|
||||
setNameState(stored ?? '');
|
||||
} catch (error) {
|
||||
console.warn('Failed to read guest name from storage', error);
|
||||
setNameState('');
|
||||
} finally {
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [eventKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHydrated(false);
|
||||
loadFromStorage();
|
||||
}, [loadFromStorage]);
|
||||
|
||||
const persistName = React.useCallback(
|
||||
(nextName: string) => {
|
||||
const trimmed = nextName.trim();
|
||||
setNameState(trimmed);
|
||||
try {
|
||||
if (trimmed) {
|
||||
window.localStorage.setItem(storageKey(eventKey), trimmed);
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey(eventKey));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist guest name', error);
|
||||
}
|
||||
},
|
||||
[eventKey]
|
||||
);
|
||||
|
||||
const clearName = React.useCallback(() => {
|
||||
setNameState('');
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey(eventKey));
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear guest name', error);
|
||||
}
|
||||
}, [eventKey]);
|
||||
|
||||
const value = React.useMemo<GuestIdentityContextValue>(
|
||||
() => ({
|
||||
eventKey,
|
||||
slug: eventKey,
|
||||
name,
|
||||
hydrated,
|
||||
setName: persistName,
|
||||
clearName,
|
||||
reload: loadFromStorage,
|
||||
}),
|
||||
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGuestIdentity() {
|
||||
const ctx = React.useContext(GuestIdentityContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalGuestIdentity() {
|
||||
return React.useContext(GuestIdentityContext);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { EventBrandingProvider } from '../EventBrandingContext';
|
||||
import { AppearanceProvider } from '@/hooks/use-appearance';
|
||||
import type { EventBranding } from '../../types/event-branding';
|
||||
|
||||
const sampleBranding: EventBranding = {
|
||||
primaryColor: '#ff3366',
|
||||
secondaryColor: '#ff99aa',
|
||||
backgroundColor: '#fef2f2',
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
logoUrl: null,
|
||||
typography: {
|
||||
heading: null,
|
||||
body: null,
|
||||
sizePreset: 'l',
|
||||
},
|
||||
mode: 'dark',
|
||||
};
|
||||
|
||||
describe('EventBrandingProvider', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('guest-theme', 'dark');
|
||||
document.documentElement.style.removeProperty('color-scheme');
|
||||
document.documentElement.style.removeProperty('--guest-background');
|
||||
document.documentElement.style.removeProperty('--guest-font-scale');
|
||||
localStorage.removeItem('theme');
|
||||
});
|
||||
|
||||
it('applies guest theme classes and variables', async () => {
|
||||
const { unmount } = render(
|
||||
<EventBrandingProvider branding={sampleBranding}>
|
||||
<div>Guest</div>
|
||||
</EventBrandingProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#0f172a');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08');
|
||||
expect(document.documentElement.style.getPropertyValue('--foreground')).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects appearance override in auto mode', async () => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
const autoBranding: EventBranding = {
|
||||
...sampleBranding,
|
||||
mode: 'auto',
|
||||
backgroundColor: '#fff7ed',
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<AppearanceProvider>
|
||||
<EventBrandingProvider branding={autoBranding}>
|
||||
<div>Guest</div>
|
||||
</EventBrandingProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('prefers explicit appearance over branding mode', async () => {
|
||||
localStorage.setItem('theme', 'light');
|
||||
const darkBranding: EventBranding = {
|
||||
...sampleBranding,
|
||||
mode: 'dark',
|
||||
backgroundColor: '#0f172a',
|
||||
};
|
||||
|
||||
const { unmount } = render(
|
||||
<AppearanceProvider>
|
||||
<EventBrandingProvider branding={darkBranding}>
|
||||
<div>Guest</div>
|
||||
</EventBrandingProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import { demoFixtures, type DemoFixtures } from './fixtures';
|
||||
|
||||
type DemoConfig = {
|
||||
fixtures: DemoFixtures;
|
||||
};
|
||||
|
||||
let enabled = false;
|
||||
let originalFetch: typeof window.fetch | null = null;
|
||||
const likeState = new Map<number, number>();
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__FOTOSPIEL_DEMO__?: boolean;
|
||||
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldEnableGuestDemoMode(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('demo') === '1') {
|
||||
return true;
|
||||
}
|
||||
if (window.__FOTOSPIEL_DEMO__ === true) {
|
||||
return true;
|
||||
}
|
||||
const attr = document.documentElement?.dataset?.guestDemo;
|
||||
return attr === 'true';
|
||||
}
|
||||
|
||||
export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixtures }): void {
|
||||
if (typeof window === 'undefined' || enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const url = new URL(request.url, window.location.origin);
|
||||
|
||||
const response = handleDemoRequest(url, request, config.fixtures);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return originalFetch!(request);
|
||||
};
|
||||
|
||||
enabled = true;
|
||||
window.__FOTOSPIEL_DEMO_ACTIVE__ = true;
|
||||
notifyDemoToast();
|
||||
}
|
||||
|
||||
function handleDemoRequest(url: URL, request: Request, fixtures: DemoFixtures): Promise<Response> | null {
|
||||
if (!url.pathname.startsWith('/api/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventMatch = url.pathname.match(/^\/api\/v1\/events\/([^/]+)(?:\/(.*))?/);
|
||||
if (eventMatch) {
|
||||
const token = decodeURIComponent(eventMatch[1]);
|
||||
const remainder = eventMatch[2] ?? '';
|
||||
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
|
||||
return null;
|
||||
}
|
||||
return Promise.resolve(handleDemoEventEndpoint(remainder, request, fixtures));
|
||||
}
|
||||
|
||||
const galleryMatch = url.pathname.match(/^\/api\/v1\/gallery\/([^/]+)(?:\/(.*))?/);
|
||||
if (galleryMatch) {
|
||||
const token = decodeURIComponent(galleryMatch[1]);
|
||||
if (token !== fixtures.token && token !== fixtures.event.slug && token !== 'demo') {
|
||||
return null;
|
||||
}
|
||||
const resource = galleryMatch[2] ?? '';
|
||||
if (!resource) {
|
||||
return Promise.resolve(jsonResponse(fixtures.gallery.meta));
|
||||
}
|
||||
if (resource.startsWith('photos')) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ data: fixtures.gallery.photos, next_cursor: null }, { etag: '"demo-gallery"' })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/v1/photo-shares/')) {
|
||||
return Promise.resolve(jsonResponse(fixtures.share));
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/v1/photos/')) {
|
||||
return Promise.resolve(handlePhotoAction(url, request, fixtures));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleDemoEventEndpoint(path: string, request: Request, fixtures: DemoFixtures): Response {
|
||||
const [resource, ...rest] = path.split('/').filter(Boolean);
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
switch (resource) {
|
||||
case undefined:
|
||||
return jsonResponse(fixtures.event);
|
||||
case 'stats':
|
||||
return jsonResponse(fixtures.stats);
|
||||
case 'package':
|
||||
return jsonResponse(fixtures.eventPackage);
|
||||
case 'tasks':
|
||||
if (method === 'GET') {
|
||||
return jsonResponse(fixtures.tasks, { etag: '"demo-tasks"' });
|
||||
}
|
||||
return blockedResponse('Aufgaben können in der Demo nicht geändert werden.');
|
||||
case 'photos':
|
||||
if (method === 'GET') {
|
||||
return jsonResponse({ data: fixtures.photos, latest_photo_at: fixtures.photos[0]?.created_at ?? null }, {
|
||||
etag: '"demo-photos"',
|
||||
});
|
||||
}
|
||||
if (method === 'POST') {
|
||||
return blockedResponse('Uploads sind in der Demo deaktiviert.');
|
||||
}
|
||||
break;
|
||||
case 'upload':
|
||||
return blockedResponse('Uploads sind in der Demo deaktiviert.');
|
||||
case 'achievements':
|
||||
return jsonResponse(fixtures.achievements, { etag: '"demo-achievements"' });
|
||||
case 'emotions':
|
||||
return jsonResponse(fixtures.emotions, { etag: '"demo-emotions"' });
|
||||
case 'notifications':
|
||||
if (rest.length >= 2) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return jsonResponse({ data: fixtures.notifications, meta: { unread_count: 1 } }, { etag: '"demo-notifications"' });
|
||||
case 'push-subscriptions':
|
||||
return new Response(null, { status: 204 });
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return jsonResponse({ demo: true });
|
||||
}
|
||||
|
||||
function handlePhotoAction(url: URL, request: Request, fixtures: DemoFixtures): Response {
|
||||
const pathname = url.pathname.replace('/api/v1/photos/', '');
|
||||
const [photoIdPart, action] = pathname.split('/');
|
||||
const photoId = Number(photoIdPart);
|
||||
const targetPhoto = fixtures.photos.find((photo) => photo.id === photoId);
|
||||
|
||||
if (action === 'like') {
|
||||
if (!targetPhoto) {
|
||||
return new Response(JSON.stringify({ error: { message: 'Foto nicht gefunden' } }), { status: 404, headers: demoHeaders() });
|
||||
}
|
||||
const current = likeState.get(photoId) ?? targetPhoto.likes_count;
|
||||
const next = current + 1;
|
||||
likeState.set(photoId, next);
|
||||
return jsonResponse({ likes_count: next });
|
||||
}
|
||||
|
||||
if (action === 'share' && request.method.toUpperCase() === 'POST') {
|
||||
return jsonResponse({ slug: fixtures.share.slug, url: `${window.location.origin}/share/${fixtures.share.slug}` });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: 'Demo-Endpunkt nicht verfügbar.' } }), {
|
||||
status: 404,
|
||||
headers: demoHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, options: { etag?: string } = {}): Response {
|
||||
const headers = demoHeaders();
|
||||
if (options.etag) {
|
||||
headers.ETag = options.etag;
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function demoHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
};
|
||||
}
|
||||
|
||||
function blockedResponse(message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'demo_read_only',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: demoHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function isGuestDemoModeEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function notifyDemoToast(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const detail = { type: 'info', text: 'Demo-Modus aktiv. Änderungen werden nicht gespeichert.' };
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('guest-toast', { detail }));
|
||||
}, 0);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useHapticsPreference } from '../useHapticsPreference';
|
||||
import { HAPTICS_STORAGE_KEY } from '../../lib/haptics';
|
||||
|
||||
function TestHarness() {
|
||||
const { enabled, setEnabled } = useHapticsPreference();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle"
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
>
|
||||
{enabled ? 'on' : 'off'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useHapticsPreference', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem(HAPTICS_STORAGE_KEY);
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles and persists preference', () => {
|
||||
render(<TestHarness />);
|
||||
const button = screen.getByTestId('toggle');
|
||||
expect(button).toHaveTextContent('on');
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveTextContent('off');
|
||||
expect(window.localStorage.getItem(HAPTICS_STORAGE_KEY)).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { uploadPhoto, type UploadError } from '../services/photosApi';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
import { notify } from '../queue/notify';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { isGuestDemoModeEnabled } from '../demo/demoMode';
|
||||
import { useEventData } from './useEventData';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
|
||||
type DirectUploadResult = {
|
||||
success: boolean;
|
||||
photoId?: number;
|
||||
warning?: string | null;
|
||||
error?: string | null;
|
||||
dialog?: UploadErrorDialog | null;
|
||||
};
|
||||
|
||||
type UseDirectUploadOptions = {
|
||||
eventToken: string;
|
||||
taskId?: number | null;
|
||||
emotionSlug?: string;
|
||||
onCompleted?: (photoId: number) => void;
|
||||
};
|
||||
|
||||
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
|
||||
const { name } = useGuestIdentity();
|
||||
const { markCompleted } = useGuestTaskProgress(eventToken);
|
||||
const { event } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||
const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setProgress(0);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
}, []);
|
||||
|
||||
const preparePhoto = useCallback(async (file: File) => {
|
||||
reset();
|
||||
let prepared = file;
|
||||
try {
|
||||
prepared = await compressPhoto(file, {
|
||||
maxEdge: 2400,
|
||||
targetBytes: 4_000_000,
|
||||
qualityStart: 0.82,
|
||||
});
|
||||
if (prepared.size < file.size - 50_000) {
|
||||
const saved = formatBytes(file.size - prepared.size);
|
||||
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Direct upload: optimization failed, using original', err);
|
||||
setWarning('Optimierung nicht möglich – wir laden das Original hoch.');
|
||||
}
|
||||
|
||||
if (prepared.size > 12_000_000) {
|
||||
setError('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
return { ok: false as const };
|
||||
}
|
||||
|
||||
return { ok: true as const, prepared };
|
||||
}, [reset]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<DirectUploadResult> => {
|
||||
if (!canUpload || uploading) return { success: false, warning, error };
|
||||
if (isGuestDemoModeEnabled() || event?.demo_read_only) {
|
||||
const demoMessage = t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.');
|
||||
setError(demoMessage);
|
||||
setWarning(null);
|
||||
notify(demoMessage, 'error');
|
||||
return { success: false, warning, error: demoMessage };
|
||||
}
|
||||
const preparedResult = await preparePhoto(file);
|
||||
if (!preparedResult.ok) {
|
||||
return { success: false, warning, error };
|
||||
}
|
||||
|
||||
const prepared = preparedResult.prepared;
|
||||
setUploading(true);
|
||||
setProgress(2);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
|
||||
maxRetries: 2,
|
||||
guestName: name || undefined,
|
||||
onProgress: (percent) => {
|
||||
setProgress(Math.max(10, Math.min(98, percent)));
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
setWarning(`Verbindung holperig – neuer Versuch (${attempt}).`);
|
||||
},
|
||||
});
|
||||
|
||||
setProgress(100);
|
||||
if (taskId) {
|
||||
markCompleted(taskId);
|
||||
}
|
||||
triggerHaptic('success');
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (photoId && !arr.includes(photoId)) {
|
||||
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
|
||||
}
|
||||
} catch (persistErr) {
|
||||
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
|
||||
}
|
||||
|
||||
onCompleted?.(photoId);
|
||||
return { success: true, photoId, warning };
|
||||
} catch (err) {
|
||||
console.error('Direct upload failed', err);
|
||||
triggerHaptic('error');
|
||||
const uploadErr = err as UploadError;
|
||||
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
||||
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
|
||||
setErrorDialog(dialog);
|
||||
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
|
||||
setWarning(null);
|
||||
|
||||
if (uploadErr.code === 'demo_read_only') {
|
||||
notify(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.'), 'error');
|
||||
}
|
||||
|
||||
if (
|
||||
uploadErr.code === 'photo_limit_exceeded'
|
||||
|| uploadErr.code === 'upload_device_limit'
|
||||
|| uploadErr.code === 'event_package_missing'
|
||||
|| uploadErr.code === 'event_not_found'
|
||||
|| uploadErr.code === 'gallery_expired'
|
||||
) {
|
||||
setCanUpload(false);
|
||||
}
|
||||
|
||||
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
|
||||
setWarning('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
}
|
||||
|
||||
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress((p) => (p === 100 ? p : 0));
|
||||
}
|
||||
},
|
||||
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
|
||||
);
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploading,
|
||||
progress,
|
||||
warning,
|
||||
error,
|
||||
errorDialog,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
fetchEvent,
|
||||
EventData,
|
||||
FetchEventError,
|
||||
FetchEventErrorCode,
|
||||
} from '../services/eventApi';
|
||||
|
||||
type EventDataStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
interface UseEventDataResult {
|
||||
event: EventData | null;
|
||||
status: EventDataStatus;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
errorCode: FetchEventErrorCode | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
|
||||
const eventCache = new Map<string, EventData>();
|
||||
|
||||
export function useEventData(): UseEventDataResult {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const cachedEvent = token ? eventCache.get(token) ?? null : null;
|
||||
const [event, setEvent] = useState<EventData | null>(cachedEvent);
|
||||
const [status, setStatus] = useState<EventDataStatus>(token ? (cachedEvent ? 'ready' : 'loading') : 'error');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
|
||||
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setEvent(null);
|
||||
setStatus('error');
|
||||
setErrorCode('invalid_token');
|
||||
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadEvent = async () => {
|
||||
const cached = eventCache.get(token) ?? null;
|
||||
if (!cached) {
|
||||
setStatus('loading');
|
||||
}
|
||||
setErrorCode(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const eventData = await fetchEvent(token);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventCache.set(token, eventData);
|
||||
setEvent(eventData);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
setEvent(cached);
|
||||
setStatus('ready');
|
||||
return;
|
||||
}
|
||||
setEvent(null);
|
||||
setStatus('error');
|
||||
|
||||
if (err instanceof FetchEventError) {
|
||||
setErrorCode(err.code);
|
||||
setErrorMessage(err.message);
|
||||
} else if (err instanceof Error) {
|
||||
setErrorCode('unknown');
|
||||
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
|
||||
} else {
|
||||
setErrorCode('unknown');
|
||||
setErrorMessage('Event konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadEvent();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return {
|
||||
event,
|
||||
status,
|
||||
loading: status === 'loading',
|
||||
error: errorMessage,
|
||||
errorCode,
|
||||
token: token ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getPushConfig } from '../lib/runtime-config';
|
||||
import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi';
|
||||
|
||||
type PushSubscriptionState = {
|
||||
supported: boolean;
|
||||
permission: NotificationPermission;
|
||||
subscribed: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function usePushSubscription(eventToken?: string): PushSubscriptionState {
|
||||
const pushConfig = React.useMemo(() => getPushConfig(), []);
|
||||
const supported = React.useMemo(() => {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& typeof Notification !== 'undefined'
|
||||
&& 'serviceWorker' in navigator
|
||||
&& 'PushManager' in window
|
||||
&& pushConfig.enabled;
|
||||
}, [pushConfig.enabled]);
|
||||
|
||||
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
});
|
||||
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const current = await registration.pushManager.getSubscription();
|
||||
setSubscription(current);
|
||||
} catch (err) {
|
||||
console.warn('Unable to refresh push subscription', err);
|
||||
setSubscription(null);
|
||||
}
|
||||
}, [eventToken, supported]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!supported) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refresh();
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'push-subscription-change') {
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [refresh, supported]);
|
||||
|
||||
const enable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken) {
|
||||
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
|
||||
if (existing) {
|
||||
await registerPushSubscription(eventToken, existing);
|
||||
setSubscription(existing);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushConfig.vapidPublicKey) {
|
||||
throw new Error('Push-Konfiguration ist nicht vollständig.');
|
||||
}
|
||||
|
||||
const newSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
await registerPushSubscription(eventToken, newSubscription);
|
||||
setSubscription(newSubscription);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
await refresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, pushConfig.vapidPublicKey, refresh, supported]);
|
||||
|
||||
const disable = React.useCallback(async () => {
|
||||
if (!supported || !eventToken || !subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await unregisterPushSubscription(eventToken, subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
setSubscription(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
|
||||
setError(message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventToken, subscription, supported]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
subscribed: Boolean(subscription),
|
||||
loading,
|
||||
error,
|
||||
enable,
|
||||
disable,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = typeof window !== 'undefined'
|
||||
? window.atob(base64)
|
||||
: Buffer.from(base64, 'base64').toString('binary');
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { applyGuestTheme } from '../guestTheme';
|
||||
|
||||
const baseTheme = {
|
||||
primary: '#ff3366',
|
||||
secondary: '#ff99aa',
|
||||
background: '#111111',
|
||||
surface: '#222222',
|
||||
mode: 'dark' as const,
|
||||
};
|
||||
|
||||
describe('applyGuestTheme', () => {
|
||||
afterEach(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('guest-theme', 'dark');
|
||||
root.style.removeProperty('color-scheme');
|
||||
root.style.removeProperty('--guest-primary');
|
||||
root.style.removeProperty('--guest-secondary');
|
||||
root.style.removeProperty('--guest-background');
|
||||
root.style.removeProperty('--guest-surface');
|
||||
});
|
||||
|
||||
it('applies and restores guest theme settings', () => {
|
||||
const cleanup = applyGuestTheme(baseTheme);
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#111111');
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user