upgrade to tamagui v2 and guest pwa overhaul

This commit is contained in:
Codex Agent
2026-02-02 13:01:20 +01:00
parent 2e78f3ab8d
commit 7c6e14ffe2
168 changed files with 47462 additions and 8914 deletions

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { TamaguiProvider, Theme } from '@tamagui/core';
import { RouterProvider } from 'react-router-dom';
import tamaguiConfig from '../../../tamagui.config';
import { router } from './router';
import { ConsentProvider } from '@/contexts/consent';
import { AppearanceProvider } from '@/hooks/use-appearance';
import { useAppearance } from '@/hooks/use-appearance';
export default function App() {
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme="guestLight" themeClassNameOnRoot>
<AppearanceProvider>
<ConsentProvider>
<AppThemeRouter />
</ConsentProvider>
</AppearanceProvider>
</TamaguiProvider>
);
}
function AppThemeRouter() {
const { resolved } = useAppearance();
const themeName = resolved === 'dark' ? 'guestNight' : 'guestLight';
return (
<Theme name={themeName}>
<RouterProvider router={router} />
</Theme>
);
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/e/demo' }),
useNavigate: () => vi.fn(),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo' }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
vi.mock('@tamagui/stacks', () => ({
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Home: () => <span>home</span>,
Image: () => <span>image</span>,
Share2: () => <span>share</span>,
}));
import BottomDock from '../components/BottomDock';
describe('BottomDock', () => {
it('renders navigation labels', () => {
render(<BottomDock />);
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Gallery')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
let identityState = { hydrated: true, name: '' };
vi.mock('react-router-dom', () => ({
useParams: () => ({ token: 'demo-token' }),
Navigate: ({ to }: { to: string }) => <div>navigate:{to}</div>,
Outlet: () => <div>outlet</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ event: null }),
}));
vi.mock('@/guest/context/EventBrandingContext', () => ({
EventBrandingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
LocaleProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DEFAULT_LOCALE: 'de',
isLocaleCode: () => true,
}));
vi.mock('@/guest/context/NotificationCenterContext', () => ({
NotificationCenterProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../context/GuestIdentityContext', () => ({
GuestIdentityProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useOptionalGuestIdentity: () => identityState,
}));
import EventLayout from '../layouts/EventLayout';
describe('EventLayout profile gate', () => {
it('redirects to setup when profile is missing', () => {
identityState = { hydrated: true, name: '' };
render(<EventLayout requireProfile />);
expect(screen.getByText('navigate:/setup/demo-token')).toBeInTheDocument();
});
it('renders outlet when profile exists', () => {
identityState = { hydrated: true, name: 'Ava' };
render(<EventLayout requireProfile />);
expect(screen.getByText('outlet')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ value }: { value?: string }) => <input value={value} readOnly />,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/StandaloneShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/components/PullToRefresh', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/services/helpApi', () => ({
getHelpArticles: () => Promise.resolve({
servedFromCache: false,
articles: [{ slug: 'intro', title: 'Intro', summary: 'Summary', updated_at: null }],
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('react-router-dom', () => ({
useParams: () => ({}),
Link: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import HelpCenterScreen from '../screens/HelpCenterScreen';
describe('HelpCenterScreen', () => {
it('renders help center title', async () => {
render(<HelpCenterScreen />);
expect(await screen.findByText('help.center.title')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
Camera: () => <span>camera</span>,
Sparkles: () => <span>sparkles</span>,
Image: () => <span>image</span>,
Star: () => <span>star</span>,
Trophy: () => <span>trophy</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import HomeScreen from '../screens/HomeScreen';
describe('HomeScreen', () => {
it('shows prompt quest content when tasks are enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
expect(screen.getByText('Start prompt')).toBeInTheDocument();
});
it('shows capture-ready content when tasks are disabled', () => {
render(
<EventDataProvider tasksEnabledFallback={false}>
<HomeScreen />
</EventDataProvider>
);
expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({ ...rest }: { [key: string]: unknown }) => <input {...rest} />,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('html5-qrcode', () => ({
Html5Qrcode: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}));
vi.mock('lucide-react', () => ({
QrCode: () => <span>qr</span>,
ArrowRight: () => <span>arrow</span>,
}));
import LandingScreen from '../screens/LandingScreen';
describe('LandingScreen', () => {
it('renders join panel copy', () => {
render(
<LocaleProvider>
<LandingScreen />
</LocaleProvider>
);
expect(screen.getByRole('button', { name: 'Event beitreten' })).toBeInTheDocument();
expect(screen.getAllByText('Event beitreten').length).toBeGreaterThan(1);
expect(screen.getByPlaceholderText('Event-Code eingeben')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
import NotFoundScreen from '../screens/NotFoundScreen';
describe('NotFoundScreen', () => {
it('renders fallback copy', () => {
render(<NotFoundScreen />);
expect(screen.getByText('Seite nicht gefunden')).toBeInTheDocument();
expect(screen.getByText('Die Seite konnte nicht gefunden werden.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-router-dom', () => ({
useParams: () => ({ photoId: '123' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null }),
fetchPhoto: vi.fn().mockResolvedValue({ id: 123, file_path: 'storage/demo.jpg', likes_count: 5 }),
likePhoto: vi.fn().mockResolvedValue(6),
createPhotoShareLink: vi.fn().mockResolvedValue({ url: 'http://example.com' }),
}));
vi.mock('@/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', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
describe('PhotoLightboxScreen', () => {
it('renders lightbox layout', async () => {
render(<PhotoLightboxScreen />);
expect(await screen.findByText('Gallery')).toBeInTheDocument();
expect(await screen.findByText('Like')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
return { Sheet };
});
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams()],
}));
vi.mock('lucide-react', () => ({
Image: () => <span>image</span>,
Filter: () => <span>filter</span>,
Camera: () => <span>camera</span>,
Grid2x2: () => <span>grid</span>,
Zap: () => <span>zap</span>,
UploadCloud: () => <span>upload</span>,
ListVideo: () => <span>list</span>,
RefreshCcw: () => <span>refresh</span>,
FlipHorizontal: () => <span>flip</span>,
X: () => <span>close</span>,
Sparkles: () => <span>sparkles</span>,
Trophy: () => <span>trophy</span>,
Play: () => <span>play</span>,
Share2: () => <span>share</span>,
QrCode: () => <span>qr</span>,
Link: () => <span>link</span>,
Users: () => <span>users</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../context/EventDataContext', () => ({
EventDataProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useEventData: () => ({ token: 'demo', tasksEnabled: true, event: null }),
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, add: vi.fn() }),
uploadPhoto: vi.fn(),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }),
}));
vi.mock('../hooks/usePollGalleryDelta', () => ({
usePollGalleryDelta: () => ({ data: { photos: [], latestPhotoAt: null, nextCursor: null }, loading: false, error: null }),
}));
vi.mock('@/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),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([]),
}));
vi.mock('../services/emotionsApi', () => ({
fetchEmotions: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ completedCount: 0 }),
}));
import GalleryScreen from '../screens/GalleryScreen';
import UploadScreen from '../screens/UploadScreen';
import TasksScreen from '../screens/TasksScreen';
import ShareScreen from '../screens/ShareScreen';
describe('Guest v2 screens copy', () => {
it('renders gallery header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<GalleryScreen />
</EventDataProvider>
);
expect(screen.getByText('Gallery')).toBeInTheDocument();
});
it('renders upload preview prompt', () => {
render(
<EventDataProvider tasksEnabledFallback>
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Camera')).toBeInTheDocument();
});
it('renders tasks quest when enabled', () => {
render(
<EventDataProvider tasksEnabledFallback>
<TasksScreen />
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
});
it('renders share hub header', () => {
render(
<EventDataProvider tasksEnabledFallback>
<ShareScreen />
</EventDataProvider>
);
expect(screen.getByText('Invite guests')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
const updateAppearance = vi.fn();
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ appearance: 'dark', updateAppearance }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de', availableLocales: [], setLocale: vi.fn() }),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'demo-token' }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ hydrated: false, name: '', setName: vi.fn(), clearName: vi.fn() }),
}));
vi.mock('@/guest/hooks/useHapticsPreference', () => ({
useHapticsPreference: () => ({ enabled: false, setEnabled: vi.fn(), supported: true }),
}));
vi.mock('@/contexts/consent', () => ({
useConsent: () => ({ preferences: { analytics: false }, savePreferences: vi.fn() }),
}));
vi.mock('react-router-dom', () => ({
Link: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({
children,
onPress,
...rest
}: {
children: React.ReactNode;
onPress?: () => void;
}) => (
<button type="button" onClick={onPress} {...rest}>
{children}
</button>
),
}));
vi.mock('@tamagui/input', () => ({
Input: ({
value,
onChange,
onChangeText,
...rest
}: React.InputHTMLAttributes<HTMLInputElement> & { onChangeText?: (next: string) => void }) => (
<input
value={value}
onChange={(event) => {
onChange?.(event);
onChangeText?.(event.target.value);
}}
readOnly={!onChange && !onChangeText}
{...rest}
/>
),
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({
checked,
onCheckedChange,
'aria-label': ariaLabel,
children,
}: {
checked?: boolean;
onCheckedChange?: (next: boolean) => void;
'aria-label'?: string;
children?: React.ReactNode;
}) => (
<label>
<input
type="checkbox"
aria-label={ariaLabel}
checked={checked}
onChange={(event) => onCheckedChange?.(event.target.checked)}
/>
{children}
</label>
),
{ Thumb: ({ children }: { children?: React.ReactNode }) => <span>{children}</span> },
),
}));
import SettingsContent from '../components/SettingsContent';
describe('SettingsContent', () => {
it('toggles appearance mode', () => {
render(<SettingsContent />);
const toggle = screen.getByLabelText('Dark mode');
fireEvent.click(toggle);
expect(updateAppearance).toHaveBeenCalledWith('light');
});
});

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'dark' }),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/guest/components/legal-markdown', () => ({
LegalMarkdown: () => <div>Legal markdown</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('lucide-react', () => ({
X: () => <span>x</span>,
}));
vi.mock('../components/SettingsContent', () => ({
default: () => <div>Settings content</div>,
}));
import SettingsSheet from '../components/SettingsSheet';
describe('SettingsSheet', () => {
it('renders settings content inside the sheet', () => {
render(<SettingsSheet open onOpenChange={vi.fn()} />);
expect(screen.getByText('Settings content')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
motion: { div: ({ children }: { children: React.ReactNode }) => <div>{children}</div> },
}));
vi.mock('../services/photosApi', () => ({
fetchGallery: () => Promise.resolve({ data: [] }),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
import SlideshowScreen from '../screens/SlideshowScreen';
describe('SlideshowScreen', () => {
it('shows empty state when no photos', async () => {
render(
<EventDataProvider token="token">
<SlideshowScreen />
</EventDataProvider>
);
expect(await screen.findByText('Noch keine Fotos')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useParams: () => ({ taskId: '12' }),
useNavigate: () => vi.fn(),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: () => Promise.resolve([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/i18n/useTranslation', () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key, locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import TaskDetailScreen from '../screens/TaskDetailScreen';
describe('TaskDetailScreen', () => {
it('renders task title', async () => {
render(
<EventDataProvider token="token">
<TaskDetailScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/SurfaceCard', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({
items: [],
loading: false,
retryAll: vi.fn(),
clearFinished: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => ({ token: 'token' }),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('@/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),
locale: 'de',
}),
}));
vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadQueueScreen from '../screens/UploadQueueScreen';
describe('UploadQueueScreen', () => {
it('renders empty queue state', () => {
render(<UploadQueueScreen />);
expect(screen.getByText('Uploads')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext';
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams('taskId=12')],
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
<button type="button" {...rest}>
{children}
</button>
),
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
uploadPhoto: vi.fn(),
useUploadQueue: () => ({ items: [], add: vi.fn() }),
}));
vi.mock('../services/tasksApi', () => ({
fetchTasks: vi.fn().mockResolvedValue([{ id: 12, title: 'Capture the dancefloor', description: 'Find the happiest crew.' }]),
}));
vi.mock('@/guest/services/pendingUploadsApi', () => ({
fetchPendingUploadsSummary: vi.fn().mockResolvedValue({ items: [], totalCount: 0 }),
}));
vi.mock('../context/GuestIdentityContext', () => ({
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
}));
vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
useGuestTaskProgress: () => ({ markCompleted: vi.fn() }),
}));
vi.mock('@/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),
locale: 'en',
}),
}));
vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }),
}));
import UploadScreen from '../screens/UploadScreen';
describe('UploadScreen', () => {
it('renders queue entry point', () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(screen.getByText('Queue')).toBeInTheDocument();
});
it('renders task summary when taskId is present', async () => {
render(
<EventDataProvider token="token">
<UploadScreen />
</EventDataProvider>
);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { resolveGuestThemeName } from '../lib/brandingTheme';
import type { EventBranding } from '@/guest/types/event-branding';
const baseBranding: EventBranding = {
primaryColor: '#FF5A5F',
secondaryColor: '#F43F5E',
backgroundColor: '#ffffff',
fontFamily: 'Inter',
logoUrl: null,
palette: {
primary: '#FF5A5F',
secondary: '#F43F5E',
background: '#ffffff',
surface: '#ffffff',
},
typography: {
heading: 'Inter',
body: 'Inter',
sizePreset: 'm',
},
mode: 'auto',
};
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
}
describe('resolveGuestThemeName', () => {
beforeEach(() => {
mockMatchMedia(false);
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
it('uses branding mode overrides', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'dark' }, 'light')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'light' }, 'dark')).toBe('guestLight');
});
it('respects explicit appearance when mode is auto', () => {
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'dark')).toBe('guestNight');
expect(resolveGuestThemeName({ ...baseBranding, mode: 'auto' }, 'light')).toBe('guestLight');
});
it('falls back to background luminance when appearance is system', () => {
const darkBackground = { ...baseBranding, backgroundColor: '#0a0f1f' };
expect(resolveGuestThemeName(darkBackground, 'system')).toBe('guestNight');
const lightBackground = { ...baseBranding, backgroundColor: '#fdf9f4' };
expect(resolveGuestThemeName(lightBackground, 'system')).toBe('guestLight');
});
it('uses system preference when background is neutral', () => {
const neutralBackground = { ...baseBranding, backgroundColor: '#b0b0b0' };
mockMatchMedia(true);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestNight');
mockMatchMedia(false);
expect(resolveGuestThemeName(neutralBackground, 'system')).toBe('guestLight');
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { mapEventBranding } from '../lib/eventBranding';
describe('mapEventBranding', () => {
it('maps palette, typography, and buttons from payload', () => {
const result = mapEventBranding({
primary_color: '#112233',
secondary_color: '#445566',
background_color: '#000000',
font_family: 'Event Body',
heading_font: 'Event Heading',
button_radius: 16,
button_primary_color: '#abcdef',
palette: {
surface: '#111111',
},
typography: {
size: 'l',
},
});
expect(result?.primaryColor).toBe('#112233');
expect(result?.secondaryColor).toBe('#445566');
expect(result?.palette?.surface).toBe('#111111');
expect(result?.typography?.heading).toBe('Event Heading');
expect(result?.typography?.body).toBe('Event Body');
expect(result?.typography?.sizePreset).toBe('l');
expect(result?.buttons?.radius).toBe(16);
expect(result?.buttons?.primary).toBe('#abcdef');
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fetchEventStats, clearStatsCache } from '../services/statsApi';
const fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
describe('fetchEventStats', () => {
beforeEach(() => {
fetchMock.mockReset();
clearStatsCache();
});
afterEach(() => {
clearStatsCache();
});
it('returns cached stats on 304', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ online_guests: 4, tasks_solved: 1, latest_photo_at: '2024-01-01T00:00:00Z' }), {
status: 200,
headers: { ETag: '"demo"' },
})
);
const first = await fetchEventStats('demo');
expect(first.onlineGuests).toBe(4);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 304, headers: { ETag: '"demo"' } }));
const second = await fetchEventStats('demo');
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type AmbientBackgroundProps = {
children: React.ReactNode;
};
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
flex={1}
position="relative"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))'
: 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))',
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
animation: 'guestNightAmbientDrift 18s ease-in-out infinite',
}}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import TopBar from './TopBar';
import BottomDock from './BottomDock';
import FloatingActionButton from './FloatingActionButton';
import FabActionSheet from './FabActionSheet';
import CompassHub, { type CompassAction } from './CompassHub';
import AmbientBackground from './AmbientBackground';
import NotificationSheet from './NotificationSheet';
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 { useAppearance } from '@/hooks/use-appearance';
type AppShellProps = {
children: React.ReactNode;
};
export default function AppShell({ children }: AppShellProps) {
const [sheetOpen, setSheetOpen] = React.useState(false);
const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const { tasksEnabled, event, token } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const showFab = !/\/photo\/\d+/.test(location.pathname);
const goTo = (path: string) => () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
navigate(buildEventPath(token, path));
};
const openSheet = () => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setSheetOpen(true);
};
const openCompass = () => {
setSheetOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setCompassOpen(true);
};
const actions = [
{
key: 'upload',
label: t('appShell.actions.upload.label', 'Upload / Take photo'),
description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'),
icon: <UploadCloud size={18} color={actionIconColor} />,
onPress: goTo('/upload'),
},
{
key: 'compass',
label: t('appShell.actions.compass.label', 'Compass hub'),
description: t('appShell.actions.compass.description', 'Quick jump to key areas.'),
icon: <Compass size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
openCompass();
},
},
tasksEnabled
? {
key: 'task',
label: t('appShell.actions.task.label', 'Start a task'),
description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: null,
{
key: 'live',
label: t('appShell.actions.live.label', 'Live show'),
description: t('appShell.actions.live.description', 'See the real-time highlight stream.'),
icon: <Cast size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
if (token) {
navigate(`/show/${encodeURIComponent(token)}`);
}
},
},
{
key: 'slideshow',
label: t('appShell.actions.slideshow.label', 'Slideshow'),
description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/slideshow'),
},
{
key: 'share',
label: t('appShell.actions.share.label', 'Share invite'),
description: t('appShell.actions.share.description', 'Send the event link or QR code.'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
tasksEnabled
? {
key: 'achievements',
label: t('appShell.actions.achievements.label', 'Achievements'),
description: t('appShell.actions.achievements.description', 'Track your photo streaks.'),
icon: <Trophy size={18} color={actionIconColor} />,
onPress: goTo('/achievements'),
}
: null,
].filter(Boolean) as Array<{
key: string;
label: string;
description: string;
icon: React.ReactNode;
onPress?: () => void;
}>;
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [
{
key: 'home',
label: t('navigation.home', 'Home'),
icon: <Home size={18} color={actionIconColor} />,
onPress: goTo('/'),
},
{
key: 'gallery',
label: t('navigation.gallery', 'Gallery'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/gallery'),
},
tasksEnabled
? {
key: 'tasks',
label: t('navigation.tasks', 'Tasks'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: {
key: 'settings',
label: t('settings.title', 'Settings'),
icon: <Settings size={18} color={actionIconColor} />,
onPress: goTo('/settings'),
},
{
key: 'share',
label: t('navigation.share', 'Share'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
];
return (
<AmbientBackground>
<YStack minHeight="100vh" position="relative">
<YStack
position="fixed"
top={0}
left={0}
right={0}
zIndex={1000}
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<TopBar
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
onProfilePress={() => {
setNotificationsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setSettingsOpen(true);
}}
onNotificationsPress={() => {
setSettingsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(true);
}}
notificationCount={notificationCenter?.unreadCount ?? 0}
/>
</YStack>
<YStack
flex={1}
padding="$4"
gap="$4"
position="relative"
zIndex={1}
style={{ paddingTop: '88px', paddingBottom: '128px' }}
>
{children}
</YStack>
{showFab ? <FloatingActionButton onPress={openSheet} onLongPress={openCompass} /> : null}
<BottomDock />
<FabActionSheet
open={sheetOpen}
onOpenChange={(next) => setSheetOpen(next)}
title={t('appShell.fab.title', 'Create a moment')}
actions={actions}
/>
<CompassHub
open={compassOpen}
onOpenChange={setCompassOpen}
centerAction={{
key: 'capture',
label: t('appShell.compass.capture', 'Capture'),
icon: <Camera size={18} color="white" />,
onPress: goTo('/upload'),
}}
quadrants={compassQuadrants}
/>
<NotificationSheet open={notificationsOpen} onOpenChange={setNotificationsOpen} />
<SettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
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 { useAppearance } from '@/hooks/use-appearance';
export default function BottomDock() {
const location = useLocation();
const navigate = useNavigate();
const { token } = useEventData();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const dockItems = [
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
{ key: 'gallery', label: t('navigation.gallery', 'Gallery'), path: '/gallery', icon: Image },
{ key: 'share', label: t('navigation.share', 'Share'), path: '/share', icon: Share2 },
];
const activeIconColor = isDark ? '#F8FAFF' : '#0F172A';
const inactiveIconColor = isDark ? '#94A3B8' : '#64748B';
return (
<XStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1000}
paddingHorizontal="$4"
paddingBottom="$3"
paddingTop="$2"
alignItems="flex-end"
justifyContent="space-between"
borderTopWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
style={{
paddingBottom: 'calc(12px + env(safe-area-inset-bottom))',
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.85)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{dockItems.map((item) => {
const targetPath = buildEventPath(token, item.path);
const active = location.pathname === targetPath || (item.path !== '/' && location.pathname.startsWith(targetPath));
const Icon = item.icon;
return (
<Button
key={item.key}
unstyled
onPress={() => navigate(targetPath)}
padding="$2"
borderRadius="$pill"
backgroundColor={active ? '$surface' : 'transparent'}
borderWidth={active ? 1 : 0}
borderColor={active ? '$borderColor' : 'transparent'}
>
<YStack alignItems="center" gap="$1">
<Icon size={18} color={active ? activeIconColor : inactiveIconColor} />
<Text fontSize="$1" color={active ? '$color' : '$color'} opacity={active ? 1 : 0.6}>
{item.label}
</Text>
</YStack>
</Button>
);
})}
</XStack>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Sheet } from '@tamagui/sheet';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useAppearance } from '@/hooks/use-appearance';
export type CompassAction = {
key: string;
label: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type CompassHubProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
quadrants: [CompassAction, CompassAction, CompassAction, CompassAction];
centerAction: CompassAction;
title?: string;
};
const quadrantPositions: Array<{
top?: number;
right?: number;
bottom?: number;
left?: number;
}> = [
{ top: 0, left: 0 },
{ top: 0, right: 0 },
{ bottom: 0, left: 0 },
{ bottom: 0, right: 0 },
];
export default function CompassHub({
open,
onOpenChange,
quadrants,
centerAction,
title = 'Quick jump',
}: CompassHubProps) {
const close = () => onOpenChange(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[100]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay
{...({
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)',
pointerEvents: 'auto',
onClick: close,
onMouseDown: close,
onTouchStart: close,
} as any)}
/>
<Sheet.Frame
{...({
width: '100%',
height: '100%',
alignSelf: 'center',
backgroundColor: 'transparent',
padding: 24,
pointerEvents: 'box-none',
} as any)}
>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
pointerEvents="auto"
onPress={close}
onClick={close}
onTouchStart={close}
/>
<YStack flex={1} alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
{title}
</Text>
<YStack width={280} height={280} position="relative" className="guest-compass-flyin">
{quadrants.map((action, index) => (
<Button
key={action.key}
onPress={() => {
action.onPress?.();
close();
}}
width={120}
height={120}
borderRadius={24}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
position="absolute"
{...quadrantPositions[index]}
>
<YStack alignItems="center" gap="$2">
{action.icon}
<Text fontSize="$3" fontWeight="$7">
{action.label}
</Text>
</YStack>
</Button>
))}
<Button
onPress={() => {
centerAction.onPress?.();
close();
}}
width={90}
height={90}
borderRadius={45}
backgroundColor="$primary"
position="absolute"
top="50%"
left="50%"
style={{ transform: 'translate(-45px, -45px)' }}
>
<YStack alignItems="center" gap="$1">
{centerAction.icon}
<Text fontSize="$2" fontWeight="$7" color="white">
{centerAction.label}
</Text>
</YStack>
</Button>
</YStack>
<Text fontSize="$2" color="$color" opacity={0.6}>
Tap outside to close
</Text>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sheet } from '@tamagui/sheet';
import { useAppearance } from '@/hooks/use-appearance';
export type FabAction = {
key: string;
label: string;
description?: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type FabActionSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
actions: FabAction[];
};
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[70]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)' } as any)} />
<Sheet.Frame
{...({
width: '100%',
maxWidth: 560,
alignSelf: 'center',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
backgroundColor: '$surface',
padding: 20,
shadowColor: isDark ? 'rgba(15, 23, 42, 0.25)' : 'rgba(15, 23, 42, 0.12)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: -6 },
} as any)}
style={{ marginBottom: 'calc(16px + env(safe-area-inset-bottom))' }}
>
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack gap="$3">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{title}
</Text>
<YStack gap="$2">
{actions.map((action) => (
<Button
key={action.key}
onPress={action.onPress}
backgroundColor="$surface"
borderRadius="$card"
borderWidth={1}
borderColor="$borderColor"
padding="$3"
justifyContent="flex-start"
>
<XStack alignItems="center" gap="$3">
<YStack
width={40}
height={40}
alignItems="center"
justifyContent="center"
borderRadius={999}
backgroundColor="$accentSoft"
>
{action.icon ? action.icon : null}
</YStack>
<YStack gap="$1" flex={1}>
<Text fontSize="$4" fontWeight="$7">
{action.label}
</Text>
{action.description ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{action.description}
</Text>
) : null}
</YStack>
</XStack>
</Button>
))}
</YStack>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Button } from '@tamagui/button';
import { Plus } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type FloatingActionButtonProps = {
onPress: () => void;
onLongPress?: () => void;
};
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
const longPressTriggered = React.useRef(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<Button
onPress={() => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
onPress();
}}
onPressIn={() => {
longPressTriggered.current = false;
}}
onLongPress={() => {
longPressTriggered.current = true;
onLongPress?.();
}}
position="fixed"
bottom={88}
right={20}
zIndex={1100}
width={56}
height={56}
borderRadius={999}
backgroundColor="$primary"
borderWidth={0}
elevation={4}
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
shadowOpacity={0.5}
shadowRadius={18}
shadowOffset={{ width: 0, height: 10 }}
style={{
boxShadow: isDark
? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)'
: '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)',
}}
>
<Plus size={22} color="white" />
</Button>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
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 { useAppearance } from '@/hooks/use-appearance';
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 { resolved } = useAppearance();
const isDark = resolved === 'dark';
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 (
<YStack
position="fixed"
left={0}
right={0}
zIndex={1400}
pointerEvents="none"
paddingHorizontal="$4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<YStack
pointerEvents="auto"
marginHorizontal="auto"
maxWidth={560}
borderRadius="$6"
padding="$4"
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'}
style={{ backdropFilter: 'blur(16px)' }}
>
<XStack flexWrap="wrap" gap="$3" alignItems="center" justifyContent="space-between">
<YStack gap="$1" flexShrink={1} minWidth={220}>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.title')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('consent.analytics.body')}
</Text>
</YStack>
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleSnooze}
>
<Text fontSize="$2" fontWeight="$6" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.later')}
</Text>
</Button>
<Button size="$2" borderRadius="$pill" backgroundColor="$primary" onPress={handleAllow}>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('consent.analytics.allow')}
</Text>
</Button>
</XStack>
</XStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
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 { useAppearance } from '@/hooks/use-appearance';
type NotificationSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
const { t } = useTranslation();
const center = useOptionalNotificationCenter();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const notifications = center?.notifications ?? [];
const unreadCount = center?.unreadCount ?? 0;
const uploadCount = (center?.queueCount ?? 0) + (center?.pendingCount ?? 0);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1300}
padding="$4"
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateY(0)' : 'translateY(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
maxHeight: '82vh',
paddingBottom: 'calc(16px + env(safe-area-inset-bottom))',
}}
>
<YStack
width={52}
height={5}
borderRadius={999}
marginBottom="$3"
alignSelf="center"
style={{ backgroundColor: isDark ? 'rgba(148, 163, 184, 0.6)' : '#CBD5E1' }}
/>
<XStack alignItems="center" justifyContent="space-between" marginBottom="$3">
<YStack gap="$1">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('header.notifications.title', 'Updates')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{unreadCount > 0
? t('header.notifications.unread', { count: unreadCount }, '{count} neu')
: t('header.notifications.allRead', 'Alles gelesen')}
</Text>
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label="Close notifications"
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false}>
<YStack gap="$4" paddingBottom="$2">
{center ? (
<XStack gap="$3" flexWrap="wrap">
<InfoBadge label={t('header.notifications.tabUploads', 'Uploads')} value={uploadCount} />
<InfoBadge label={t('header.notifications.tabUnread', 'Nachrichten')} value={unreadCount} />
</XStack>
) : null}
{center?.loading ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('common.actions.loading', 'Loading...')}
</Text>
) : notifications.length === 0 ? (
<YStack gap="$1">
<Text color={isDark ? '#F8FAFF' : '#0F172A'} fontSize="$5" fontWeight="$7">
{t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')}
</Text>
</YStack>
) : (
<YStack gap="$3">
{notifications.map((item) => (
<YStack
key={item.id}
padding="$3"
borderRadius="$4"
backgroundColor={
item.status === 'new'
? isDark
? 'rgba(148, 163, 184, 0.18)'
: 'rgba(15, 23, 42, 0.06)'
: isDark
? 'rgba(15, 23, 42, 0.7)'
: 'rgba(255, 255, 255, 0.8)'
}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$2"
>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{item.title}
</Text>
{item.body ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{item.body}
</Text>
) : null}
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
backgroundColor="$primary"
color="#FFFFFF"
onPress={() => center?.markAsRead(item.id)}
>
{t('header.notifications.markRead', 'Als gelesen markieren')}
</Button>
<Button
size="$2"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => center?.dismiss(item.id)}
>
{t('header.notifications.dismiss', 'Ausblenden')}
</Button>
</XStack>
</YStack>
))}
</YStack>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function InfoBadge({ label, value }: { label: string; value: number }) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$1"
>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{label}
</Text>
<Text fontSize="$5" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{value}
</Text>
</YStack>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type PhotoFrameTileProps = {
height: number;
borderRadius?: number | string;
children?: React.ReactNode;
shimmer?: boolean;
shimmerDelayMs?: number;
};
export default function PhotoFrameTile({
height,
borderRadius = '$tile',
children,
shimmer = false,
shimmerDelayMs = 0,
}: PhotoFrameTileProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
height={height}
borderRadius={borderRadius}
padding={6}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(15, 23, 42, 0.04)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
style={{
boxShadow: isDark ? '0 18px 32px rgba(2, 6, 23, 0.4)' : '0 16px 28px rgba(15, 23, 42, 0.12)',
}}
>
<YStack
flex={1}
borderRadius={borderRadius}
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.1)'}
overflow="hidden"
position="relative"
style={{
boxShadow: isDark
? 'inset 0 0 0 1px rgba(255, 255, 255, 0.06)'
: 'inset 0 0 0 1px rgba(15, 23, 42, 0.04)',
}}
>
{shimmer ? (
<YStack
position="absolute"
top={-40}
bottom={-40}
left="-60%"
width="60%"
backgroundColor="transparent"
style={{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
animation: 'guestNightShimmer 4.6s ease-in-out infinite',
animationDelay: `${shimmerDelayMs}ms`,
}}
/>
) : null}
<YStack position="relative" zIndex={1} flex={1}>
{children}
</YStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,362 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
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 { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { useHapticsPreference } from '@/guest/hooks/useHapticsPreference';
import { triggerHaptic } from '@/guest/lib/haptics';
import { useConsent } from '@/contexts/consent';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type SettingsContentProps = {
onNavigate?: () => void;
showHeader?: boolean;
onOpenLegal?: (slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => void;
};
export default function SettingsContent({ onNavigate, showHeader = true, onOpenLegal }: SettingsContentProps) {
const { t } = useTranslation();
const locale = useLocale();
const identity = useOptionalGuestIdentity();
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 { appearance, updateAppearance } = useAppearance();
const { token } = useEventData();
const isDark = appearance === 'dark';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.82)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [status, setStatus] = React.useState<'idle' | 'saved'>('idle');
const helpPath = token ? buildEventPath(token, '/help') : '/help';
const supportsInlineLegal = Boolean(onOpenLegal);
React.useEffect(() => {
if (identity?.hydrated) {
setNameDraft(identity.name ?? '');
setStatus('idle');
}
}, [identity?.hydrated, identity?.name]);
const canSaveName = Boolean(
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
);
const handleSaveName = React.useCallback(() => {
if (!identity || !canSaveName) {
return;
}
identity.setName(nameDraft);
setStatus('saved');
window.setTimeout(() => setStatus('idle'), 2000);
}, [identity, nameDraft, canSaveName]);
const handleResetName = React.useCallback(() => {
if (!identity) {
return;
}
identity.clearName();
setNameDraft('');
setStatus('idle');
}, [identity]);
return (
<YStack gap="$4">
{showHeader ? (
<YStack gap="$2">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={primaryText}>
{t('settings.title', 'Settings')}
</Text>
<Text color={mutedText}>{t('settings.subtitle', 'Make this app yours.')}</Text>
</YStack>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<XStack gap="$2" alignItems="center">
<Languages size={16} color={primaryText} />
<XStack gap="$2">
{locale.availableLocales.map((option) => (
<Button
key={option.code}
size="$3"
circular
onPress={() => locale.setLocale(option.code)}
backgroundColor={option.code === locale.locale ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t(`settings.language.option.${option.code}`, option.label ?? option.code.toUpperCase())}
>
<Text fontSize="$2" color={option.code === locale.locale ? '#FFFFFF' : primaryText}>
{option.flag ?? option.code.toUpperCase()}
</Text>
</Button>
))}
</XStack>
</XStack>
<Button
size="$3"
circular
onPress={() => updateAppearance(isDark ? 'light' : 'dark')}
backgroundColor={isDark ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.appearance.darkLabel', 'Dark mode')}
>
{isDark ? <Moon size={16} color="#FFFFFF" /> : <Sun size={16} color={primaryText} />}
</Button>
</XStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.name.title', 'Your name')}
</Text>
<XStack gap="$2" alignItems="center">
<Input
flex={1}
value={nameDraft}
onChangeText={setNameDraft}
placeholder={t('settings.name.placeholder', t('profileSetup.form.placeholder'))}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
color={primaryText}
/>
<Button
size="$3"
circular
onPress={handleSaveName}
disabled={!canSaveName}
backgroundColor={canSaveName ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.save', 'Save name')}
>
<Check size={16} color={canSaveName ? '#FFFFFF' : primaryText} />
</Button>
<Button
size="$3"
circular
onPress={handleResetName}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.reset', 'Reset')}
>
<RotateCcw size={16} color={primaryText} />
</Button>
</XStack>
{status === 'saved' ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.name.saved', 'Saved')}
</Text>
) : null}
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.haptics.label', 'Haptic feedback')}
</Text>
<Switch
size="$3"
checked={hapticsEnabled}
disabled={!hapticsSupported}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
aria-label="haptics-toggle"
backgroundColor={hapticsEnabled ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={hapticsEnabled ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
{!hapticsSupported ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.haptics.unsupported', 'Haptics are not available on this device.')}
</Text>
) : null}
</Card>
{matomoEnabled ? (
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.analytics.label', 'Share anonymous analytics')}
</Text>
<Switch
size="$3"
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
backgroundColor={preferences?.analytics ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={preferences?.analytics ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('settings.analytics.note', 'You can change this anytime.')}
</Text>
</Card>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.legal.title', 'Legal')}
</Text>
<YStack gap="$2">
{legalLinks.map((page) => {
const label = t(page.labelKey, page.fallback);
if (supportsInlineLegal) {
return (
<Button
key={page.slug}
onPress={() => onOpenLegal?.(page.slug, page.labelKey)}
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<XStack alignItems="center" gap="$2">
<FileText size={16} color={primaryText} />
<Text color={primaryText}>{label}</Text>
</XStack>
</Button>
);
}
return (
<Button
key={page.slug}
asChild
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Link to={`/legal/${page.slug}`} onClick={onNavigate}>
{label}
</Link>
</Button>
);
})}
</YStack>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.cache.title', 'Offline cache')}
</Text>
<ClearCacheButton />
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.note', 'This only affects this browser. Pending uploads may be lost.')}
</Text>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.help.title', 'Help Center')}
</Text>
<Button asChild backgroundColor={mutedButton} borderColor={mutedButtonBorder} borderWidth={1}>
<Link to={helpPath} onClick={onNavigate}>
<XStack alignItems="center" gap="$2">
<LifeBuoy size={16} color={primaryText} />
<Text color={primaryText}>{t('settings.help.cta', 'Open help center')}</Text>
</XStack>
</Link>
</Button>
</YStack>
</Card>
</YStack>
);
}
function ClearCacheButton() {
const { t } = useTranslation();
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const { appearance } = useAppearance();
const isDark = appearance === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const clearAll = React.useCallback(async () => {
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) {
const databases = ['guest-upload-queue', 'upload-queue'];
await Promise.all(
databases.map(
(name) =>
new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve(null);
request.onerror = () => resolve(null);
})
)
);
}
setDone(true);
} finally {
setBusy(false);
window.setTimeout(() => setDone(false), 2500);
}
}, []);
return (
<YStack gap="$2">
<Button
onPress={clearAll}
disabled={busy}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
{busy ? t('settings.cache.clearing', 'Clearing cache...') : t('settings.cache.clear', 'Clear cache')}
</Button>
{done ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.cleared', 'Cache cleared.')}
</Text>
) : null}
</YStack>
);
}

View File

@@ -0,0 +1,268 @@
import React from 'react';
import { ScrollView } from '@tamagui/scroll-view';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, X } from 'lucide-react';
import SettingsContent from './SettingsContent';
import { useAppearance } from '@/hooks/use-appearance';
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';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type ViewState =
| { mode: 'home' }
| { mode: 'legal'; slug: (typeof legalLinks)[number]['slug']; labelKey: (typeof legalLinks)[number]['labelKey'] };
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 SettingsSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, locale);
const handleBack = React.useCallback(() => setView({ mode: 'home' }), []);
const handleOpenLegal = React.useCallback(
(slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => {
setView({ mode: 'legal', slug, labelKey });
},
[]
);
React.useEffect(() => {
if (!open) {
setView({ mode: 'home' });
}
}, [open]);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
zIndex={1300}
width="85%"
maxWidth={420}
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderBottomLeftRadius="$6"
borderTopRightRadius={0}
borderBottomRightRadius={0}
overflow="hidden"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
}}
>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
position: 'sticky',
top: 0,
zIndex: 2,
backgroundColor: isDark ? 'rgba(11, 16, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.08)' : '1px solid rgba(15, 23, 42, 0.1)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{isLegal ? (
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleBack}
aria-label={t('common.actions.back', 'Back')}
>
<ArrowLeft size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<YStack>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: t(view.labelKey, 'Legal')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{legalDocument.phase === 'loading'
? t('common.actions.loading', 'Loading...')
: t('settings.legal.description', 'Legal notice')}
</Text>
</YStack>
</XStack>
) : (
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.title', 'Settings')}
</Text>
)}
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<YStack gap="$4">
{isLegal ? (
<LegalView
document={legalDocument}
fallbackTitle={t(view.labelKey, 'Legal')}
/>
) : (
<SettingsContent
onNavigate={() => onOpenChange(false)}
showHeader={false}
onOpenLegal={handleOpenLegal}
/>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function LegalView({ document, fallbackTitle }: { document: LegalDocumentState; fallbackTitle: string }) {
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
if (document.phase === 'error') {
return (
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.legal.error', 'Etwas ist schiefgelaufen.')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
</YStack>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return (
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
);
}
return (
<YStack gap="$3">
<Text fontSize="$5" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{document.title || fallbackTitle}
</Text>
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.85)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
>
<LegalMarkdown markdown={document.markdown} html={document.html} />
</YStack>
</YStack>
);
}
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((prev) => ({ ...prev, phase: 'loading' }));
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${encodeURIComponent(locale)}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
throw new Error('Failed to load legal page');
}
return res.json();
})
.then((data) => {
setState({
phase: 'ready',
title: data?.title ?? '',
markdown: data?.body_markdown ?? '',
html: data?.body_html ?? '',
});
})
.catch((error) => {
if (error?.name === 'AbortError') return;
console.error('Failed to load legal page', error);
setState((prev) => ({ ...prev, phase: 'error' }));
});
return () => controller.abort();
}, [slug, locale]);
return state;
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import AmbientBackground from './AmbientBackground';
type StandaloneShellProps = {
children: React.ReactNode;
compact?: boolean;
};
export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) {
return (
<AmbientBackground>
<YStack minHeight="100vh" padding="$4" paddingTop={compact ? '$4' : '$6'} paddingBottom="$6" gap="$4">
{children}
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import type { YStackProps } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type SurfaceCardProps = YStackProps & {
glow?: boolean;
};
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const boxShadow = isDark
? glow
? '0 22px 40px rgba(6, 10, 22, 0.55)'
: '0 16px 30px rgba(2, 6, 23, 0.35)'
: glow
? '0 22px 38px rgba(15, 23, 42, 0.16)'
: '0 14px 24px rgba(15, 23, 42, 0.12)';
return (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={borderColor}
style={{ boxShadow }}
{...props}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Bell, Settings } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type TopBarProps = {
eventName: string;
onProfilePress?: () => void;
onNotificationsPress?: () => void;
notificationCount?: number;
};
export default function TopBar({
eventName,
onProfilePress,
onNotificationsPress,
notificationCount = 0,
}: TopBarProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<Text
fontSize="$6"
fontFamily="$display"
fontWeight="$8"
numberOfLines={1}
style={{ textShadow: '0 6px 18px rgba(2, 6, 23, 0.7)' }}
>
{eventName}
</Text>
<XStack gap="$2" alignItems="center">
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
position: 'relative',
}}
onPress={onNotificationsPress}
>
<Bell size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
{notificationCount > 0 ? (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
width: 18,
height: 18,
borderRadius: 999,
backgroundColor: '#F97316',
color: '#0B101E',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{notificationCount > 9 ? '9+' : notificationCount}
</span>
) : null}
</Button>
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
}}
onPress={onProfilePress}
>
<Settings size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
</XStack>
);
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { fetchEvent, type EventData, FetchEventError } from '../services/eventApi';
import { isTaskModeEnabled } from '@/guest/lib/engagement';
type EventDataStatus = 'idle' | 'loading' | 'ready' | 'error';
type EventDataContextValue = {
event: EventData | null;
status: EventDataStatus;
error: string | null;
token: string | null;
tasksEnabled: boolean;
};
const EventDataContext = React.createContext<EventDataContextValue>({
event: null,
status: 'idle',
error: null,
token: null,
tasksEnabled: true,
});
type EventDataProviderProps = {
token?: string | null;
tasksEnabledFallback?: boolean;
children: React.ReactNode;
};
export function EventDataProvider({
token,
tasksEnabledFallback = true,
children,
}: EventDataProviderProps) {
const [event, setEvent] = React.useState<EventData | null>(null);
const [status, setStatus] = React.useState<EventDataStatus>(token ? 'loading' : 'idle');
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!token) {
setEvent(null);
setStatus('idle');
setError(null);
return;
}
let cancelled = false;
const loadEvent = async () => {
setStatus('loading');
setError(null);
try {
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
setEvent(eventData);
setStatus('ready');
} catch (err) {
if (cancelled) {
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setError(err.message);
} else if (err instanceof Error) {
setError(err.message || 'Event could not be loaded.');
} else {
setError('Event could not be loaded.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
const tasksEnabled = event ? isTaskModeEnabled(event) : tasksEnabledFallback;
return (
<EventDataContext.Provider
value={{
event,
status,
error,
token: token ?? null,
tasksEnabled,
}}
>
{children}
</EventDataContext.Provider>
);
}
export function useEventData() {
return React.useContext(EventDataContext);
}

View File

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

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { fetchGallery } from '../services/photosApi';
export type GalleryDelta = {
photos: Record<string, unknown>[];
latestPhotoAt: string | null;
nextCursor: string | null;
};
const emptyDelta: GalleryDelta = {
photos: [],
latestPhotoAt: null,
nextCursor: null,
};
export function usePollGalleryDelta(
eventToken: string | null,
options: { intervalMs?: number; locale?: string } = {}
) {
const intervalMs = options.intervalMs ?? 30000;
const [data, setData] = React.useState<GalleryDelta>(emptyDelta);
const [loading, setLoading] = React.useState(Boolean(eventToken));
const [error, setError] = React.useState<string | null>(null);
const latestRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!eventToken) {
setData(emptyDelta);
setLoading(false);
setError(null);
latestRef.current = null;
return;
}
let active = true;
let timer: number | null = null;
const poll = async () => {
if (document.visibilityState === 'hidden') {
timer = window.setTimeout(poll, intervalMs);
return;
}
try {
setLoading(true);
const response = await fetchGallery(eventToken, {
since: latestRef.current ?? undefined,
locale: options.locale,
});
if (!active) return;
const photos = Array.isArray(response.data) ? response.data : [];
const latestPhotoAt = response.latest_photo_at ?? latestRef.current ?? null;
latestRef.current = latestPhotoAt;
setData({
photos,
latestPhotoAt,
nextCursor: response.next_cursor ?? null,
});
setError(null);
} catch (err) {
if (!active) return;
setError(err instanceof Error ? err.message : 'Failed to load gallery updates');
} finally {
if (active) {
setLoading(false);
timer = window.setTimeout(poll, intervalMs);
}
}
};
poll();
return () => {
active = false;
if (timer) {
window.clearTimeout(timer);
}
};
}, [eventToken, intervalMs, options.locale]);
return { data, loading, error } as const;
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { fetchEventStats } from '../services/statsApi';
import type { EventStats } from '../services/eventApi';
const defaultStats: EventStats = { onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null };
export function usePollStats(eventToken: string | null, intervalMs = 10000) {
const [stats, setStats] = React.useState<EventStats>(defaultStats);
const [loading, setLoading] = React.useState<boolean>(Boolean(eventToken));
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!eventToken) {
setStats(defaultStats);
setLoading(false);
setError(null);
return;
}
let active = true;
let timer: number | null = null;
const poll = async () => {
if (document.visibilityState === 'hidden') {
timer = window.setTimeout(poll, intervalMs);
return;
}
try {
setLoading(true);
const next = await fetchEventStats(eventToken);
if (!active) return;
setStats(next);
setError(null);
} catch (err) {
if (!active) return;
setError(err instanceof Error ? err.message : 'Failed to load stats');
} finally {
if (active) {
setLoading(false);
timer = window.setTimeout(poll, intervalMs);
}
}
};
poll();
return () => {
active = false;
if (timer) {
window.clearTimeout(timer);
}
};
}, [eventToken, intervalMs]);
return { stats, loading, error } as const;
}

View File

@@ -0,0 +1,74 @@
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 { EventDataProvider, useEventData } from '../context/EventDataContext';
import { GuestIdentityProvider, useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { mapEventBranding } from '../lib/eventBranding';
import { BrandingTheme } from '../lib/brandingTheme';
type EventLayoutProps = {
tasksEnabledFallback?: boolean;
requireProfile?: boolean;
};
export default function EventLayout({ tasksEnabledFallback = true, requireProfile = false }: EventLayoutProps) {
const { token } = useParams<{ token: string }>();
return (
<EventDataProvider token={token} tasksEnabledFallback={tasksEnabledFallback}>
<EventProviders token={token} requireProfile={requireProfile}>
<Outlet />
</EventProviders>
</EventDataProvider>
);
}
function EventProviders({
token,
children,
requireProfile,
}: {
token?: string;
children: React.ReactNode;
requireProfile: boolean;
}) {
const { event } = useEventData();
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
const localeStorageKey = event
? `guestLocale_event_${event.id ?? token ?? 'global'}`
: `guestLocale_event_${token ?? 'global'}`;
const branding = mapEventBranding(
event?.branding ?? (event as unknown as { settings?: { branding?: any } })?.settings?.branding ?? null
);
const content = (
<EventBrandingProvider branding={branding}>
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<GuestIdentityProvider eventKey={token ?? ''}>
<BrandingTheme>
{requireProfile ? <ProfileGate token={token}>{children}</ProfileGate> : children}
</BrandingTheme>
</GuestIdentityProvider>
</LocaleProvider>
</EventBrandingProvider>
);
if (!token) {
return content;
}
return <NotificationCenterProvider eventToken={token}>{content}</NotificationCenterProvider>;
}
function ProfileGate({ token, children }: { token?: string; children: React.ReactNode }) {
const identity = useOptionalGuestIdentity();
if (token && identity?.hydrated && !identity.name) {
return <Navigate to={`/setup/${encodeURIComponent(token)}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { LocaleProvider } from '@/guest/i18n/LocaleContext';
import { DEFAULT_LOCALE } from '@/guest/i18n/messages';
export default function GuestLocaleLayout() {
return (
<LocaleProvider defaultLocale={DEFAULT_LOCALE} storageKey="guestLocale_global">
<Outlet />
</LocaleProvider>
);
}

View File

@@ -0,0 +1 @@
export * from './brandingTheme.tsx';

View File

@@ -0,0 +1,66 @@
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';
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
const DARK_LUMINANCE_THRESHOLD = 0.35;
type ThemeVariant = 'light' | 'dark';
function resolveThemeVariant(
mode: EventBranding['mode'],
backgroundColor: string,
appearanceOverride: 'light' | 'dark' | null
): ThemeVariant {
const prefersDark =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const backgroundLuminance = relativeLuminance(backgroundColor);
const backgroundPrefers =
backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
? 'light'
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
? 'dark'
: null;
if (mode === 'dark') {
return 'dark';
}
if (mode === 'light') {
return 'light';
}
if (appearanceOverride) {
return appearanceOverride;
}
if (backgroundPrefers) {
return backgroundPrefers;
}
return prefersDark ? 'dark' : 'light';
}
export function resolveGuestThemeName(
branding: EventBranding,
appearance: Appearance
): 'guestLight' | 'guestNight' {
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
const background = branding.backgroundColor || branding.palette?.background || '#ffffff';
const variant = resolveThemeVariant(branding.mode ?? 'auto', background, appearanceOverride);
return variant === 'dark' ? 'guestNight' : 'guestLight';
}
export function BrandingTheme({ children }: { children: React.ReactNode }) {
const { branding } = useEventBranding();
const { appearance } = useAppearance();
const themeName = resolveGuestThemeName(branding, appearance);
return <Theme name={themeName}>{children}</Theme>;
}

View File

@@ -0,0 +1,18 @@
export function getDeviceId(): string {
const KEY = 'device-id';
let id = localStorage.getItem(KEY);
if (!id) {
id = genId();
localStorage.setItem(KEY, id);
}
return id;
}
function genId() {
// Simple UUID v4-ish generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,74 @@
import type { EventBranding } from '@/guest/types/event-branding';
import type { EventBrandingPayload } from '@/guest/services/eventApi';
export function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | null {
if (!raw) {
return null;
}
const palette = raw.palette ?? {};
const typography = raw.typography ?? {};
const buttons = raw.buttons ?? {};
const logo = raw.logo ?? {};
const primary = palette.primary ?? raw.primary_color ?? '';
const secondary = palette.secondary ?? raw.secondary_color ?? '';
const background = palette.background ?? raw.background_color ?? '';
const surface = palette.surface ?? raw.surface_color ?? background;
const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null;
const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null;
const sizePreset =
(typography.size as 's' | 'm' | 'l' | undefined)
?? (raw.font_size as 's' | 'm' | 'l' | undefined)
?? 'm';
const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon');
const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null;
const logoPosition = logo.position ?? raw.logo_position ?? 'left';
const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm';
const buttonStyle =
(buttons.style as 'filled' | 'outline' | undefined)
?? (raw.button_style as 'filled' | 'outline' | undefined)
?? 'filled';
const buttonRadius =
typeof buttons.radius === 'number'
? buttons.radius
: typeof raw.button_radius === 'number'
? raw.button_radius
: 12;
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
return {
primaryColor: primary ?? '',
secondaryColor: secondary ?? '',
backgroundColor: background ?? '',
fontFamily: bodyFont,
logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null,
palette: {
primary: primary ?? '',
secondary: secondary ?? '',
background: background ?? '',
surface: surface ?? background ?? '',
},
typography: {
heading: headingFont,
body: bodyFont,
sizePreset,
},
logo: {
mode: logoMode,
value: logoValue,
position: logoPosition,
size: logoSize,
},
buttons: {
style: buttonStyle,
radius: buttonRadius,
primary: buttonPrimary,
secondary: buttonSecondary,
linkColor,
},
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined,
};
}

View File

@@ -0,0 +1,13 @@
export function buildEventPath(token: string | null, path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (!token) {
return normalized;
}
if (normalized === '/') {
return `/e/${encodeURIComponent(token)}`;
}
return `/e/${encodeURIComponent(token)}${normalized}`;
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
type UsePulseAnimationOptions = {
intervalMs?: number;
delayMs?: number;
};
export function usePulseAnimation({ intervalMs = 2400, delayMs = 0 }: UsePulseAnimationOptions = {}) {
const [active, setActive] = React.useState(false);
React.useEffect(() => {
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const start = () => {
setActive((prev) => !prev);
interval = setInterval(() => {
setActive((prev) => !prev);
}, intervalMs);
};
if (delayMs > 0) {
timeout = setTimeout(start, delayMs);
} else {
start();
}
return () => {
if (interval) {
clearInterval(interval);
}
if (timeout) {
clearTimeout(timeout);
}
};
}, [delayMs, intervalMs]);
return active;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
type UseStaggeredRevealOptions = {
steps: number;
intervalMs?: number;
delayMs?: number;
};
export function useStaggeredReveal({ steps, intervalMs = 140, delayMs = 80 }: UseStaggeredRevealOptions) {
const [stage, setStage] = React.useState(0);
React.useEffect(() => {
const timers: Array<ReturnType<typeof setTimeout>> = [];
for (let index = 1; index <= steps; index += 1) {
timers.push(
setTimeout(() => {
setStage(index);
}, delayMs + intervalMs * (index - 1))
);
}
return () => {
timers.forEach((timer) => clearTimeout(timer));
};
}, [delayMs, intervalMs, steps]);
return stage;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '@tamagui/core/reset.css';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Guest v2 root element not found.');
}
initializeTheme();
if (typeof window !== 'undefined' && !window.localStorage.getItem('theme')) {
window.localStorage.setItem('theme', 'light');
initializeTheme();
}
createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { createBrowserRouter } from 'react-router-dom';
import HomeScreen from './screens/HomeScreen';
import GalleryScreen from './screens/GalleryScreen';
import PhotoLightboxScreen from './screens/PhotoLightboxScreen';
import TasksScreen from './screens/TasksScreen';
import TaskDetailScreen from './screens/TaskDetailScreen';
import SettingsScreen from './screens/SettingsScreen';
import UploadScreen from './screens/UploadScreen';
import UploadQueueScreen from './screens/UploadQueueScreen';
import ShareScreen from './screens/ShareScreen';
import AchievementsScreen from './screens/AchievementsScreen';
import NotFoundScreen from './screens/NotFoundScreen';
import LandingScreen from './screens/LandingScreen';
import ProfileSetupScreen from './screens/ProfileSetupScreen';
import LegalScreen from './screens/LegalScreen';
import HelpCenterScreen from './screens/HelpCenterScreen';
import HelpArticleScreen from './screens/HelpArticleScreen';
import PublicGalleryScreen from './screens/PublicGalleryScreen';
import SharedPhotoScreen from './screens/SharedPhotoScreen';
import LiveShowScreen from './screens/LiveShowScreen';
import SlideshowScreen from './screens/SlideshowScreen';
import EventLayout from './layouts/EventLayout';
import GuestLocaleLayout from './layouts/GuestLocaleLayout';
import MockupsIndexScreen from './screens/mockups/MockupsIndexScreen';
import MockupsHomeIndexScreen from './screens/mockups/MockupsHomeIndexScreen';
import Mockup01CaptureOrbit from './screens/mockups/Mockup01CaptureOrbit';
import Mockup02GalleryMosaic from './screens/mockups/Mockup02GalleryMosaic';
import Mockup03PromptQuest from './screens/mockups/Mockup03PromptQuest';
import Mockup04TimelineStream from './screens/mockups/Mockup04TimelineStream';
import Mockup05CompassHub from './screens/mockups/Mockup05CompassHub';
import Mockup06SplitCapture from './screens/mockups/Mockup06SplitCapture';
import Mockup07SwipeDeck from './screens/mockups/Mockup07SwipeDeck';
import Mockup08Daybook from './screens/mockups/Mockup08Daybook';
import Mockup09ChecklistFlow from './screens/mockups/Mockup09ChecklistFlow';
import Mockup10SpotlightReel from './screens/mockups/Mockup10SpotlightReel';
import MockupHome01PulseHero from './screens/mockups/MockupHome01PulseHero';
import MockupHome02StoryRings from './screens/mockups/MockupHome02StoryRings';
import MockupHome03LiveStream from './screens/mockups/MockupHome03LiveStream';
import MockupHome04TaskSprint from './screens/mockups/MockupHome04TaskSprint';
import MockupHome05GalleryFirst from './screens/mockups/MockupHome05GalleryFirst';
import MockupHome06CalmFocus from './screens/mockups/MockupHome06CalmFocus';
import MockupHome07MomentStack from './screens/mockups/MockupHome07MomentStack';
import MockupHome08CountdownStage from './screens/mockups/MockupHome08CountdownStage';
import MockupHome09ShareHub from './screens/mockups/MockupHome09ShareHub';
import MockupHome10Moodboard from './screens/mockups/MockupHome10Moodboard';
const screenChildren = [
{ index: true, element: <HomeScreen /> },
{ path: 'gallery', element: <GalleryScreen /> },
{ path: 'photo/:photoId', element: <PhotoLightboxScreen /> },
{ path: 'tasks', element: <TasksScreen /> },
{ path: 'tasks/:taskId', element: <TaskDetailScreen /> },
{ path: 'upload', element: <UploadScreen /> },
{ path: 'queue', element: <UploadQueueScreen /> },
{ path: 'share', element: <ShareScreen /> },
{ path: 'achievements', element: <AchievementsScreen /> },
{ path: 'settings', element: <SettingsScreen /> },
{ path: 'help', element: <HelpCenterScreen /> },
{ path: 'help/:slug', element: <HelpArticleScreen /> },
{ path: 'slideshow', element: <SlideshowScreen /> },
];
export const router = createBrowserRouter(
[
{
element: <GuestLocaleLayout />,
children: [
{ path: '/event', element: <LandingScreen /> },
{ path: '/event-v2', element: <LandingScreen /> },
{ path: '/legal/:page', element: <LegalScreen /> },
{ path: '/help', element: <HelpCenterScreen /> },
{ path: '/help/:slug', element: <HelpArticleScreen /> },
{ path: '/g/:token', element: <PublicGalleryScreen /> },
{ path: '/share/:slug', element: <SharedPhotoScreen /> },
],
},
{ path: '/setup/:token', element: <EventLayout tasksEnabledFallback />, children: [{ index: true, element: <ProfileSetupScreen /> }] },
{ path: '/e/:token', element: <EventLayout tasksEnabledFallback={false} requireProfile />, children: screenChildren },
{ path: '/show/:token', element: <EventLayout tasksEnabledFallback={false} />, children: [{ index: true, element: <LiveShowScreen /> }] },
{ path: '/mockups', element: <MockupsIndexScreen /> },
{ path: '/mockups/1', element: <Mockup01CaptureOrbit /> },
{ path: '/mockups/2', element: <Mockup02GalleryMosaic /> },
{ path: '/mockups/3', element: <Mockup03PromptQuest /> },
{ path: '/mockups/4', element: <Mockup04TimelineStream /> },
{ path: '/mockups/5', element: <Mockup05CompassHub /> },
{ path: '/mockups/6', element: <Mockup06SplitCapture /> },
{ path: '/mockups/7', element: <Mockup07SwipeDeck /> },
{ path: '/mockups/8', element: <Mockup08Daybook /> },
{ path: '/mockups/9', element: <Mockup09ChecklistFlow /> },
{ path: '/mockups/10', element: <Mockup10SpotlightReel /> },
{ path: '/mockups/home', element: <MockupsHomeIndexScreen /> },
{ path: '/mockups/home/1', element: <MockupHome01PulseHero /> },
{ path: '/mockups/home/2', element: <MockupHome02StoryRings /> },
{ path: '/mockups/home/3', element: <MockupHome03LiveStream /> },
{ path: '/mockups/home/4', element: <MockupHome04TaskSprint /> },
{ path: '/mockups/home/5', element: <MockupHome05GalleryFirst /> },
{ path: '/mockups/home/6', element: <MockupHome06CalmFocus /> },
{ path: '/mockups/home/7', element: <MockupHome07MomentStack /> },
{ path: '/mockups/home/8', element: <MockupHome08CountdownStage /> },
{ path: '/mockups/home/9', element: <MockupHome09ShareHub /> },
{ path: '/mockups/home/10', element: <MockupHome10Moodboard /> },
{ path: '*', element: <NotFoundScreen /> },
],
{}
);

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Trophy, Star } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { fetchAchievements, type AchievementsPayload } from '../services/achievementsApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useAppearance } from '@/hooks/use-appearance';
export default function AchievementsScreen() {
const { token } = useEventData();
const identity = useOptionalGuestIdentity();
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!token) {
setPayload(null);
return;
}
let active = true;
setLoading(true);
setError(null);
fetchAchievements(token, { guestName: identity?.name ?? undefined, locale })
.then((data) => {
if (!active) return;
setPayload(data);
})
.catch((err) => {
console.error('Failed to load achievements', err);
if (active) {
setError(t('achievements.error', 'Achievements could not be loaded.'));
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token, identity?.name, locale, t]);
const topPhoto = payload?.highlights?.topPhoto ?? null;
const totalPhotos = payload?.summary?.totalPhotos ?? 0;
const totalTasks = payload?.summary?.tasksSolved ?? 0;
const totalLikes = payload?.summary?.likesTotal ?? 0;
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Trophy size={18} color="#FDE047" />
<Text fontSize="$4" fontWeight="$7">
{t('achievements.page.title', 'Achievements')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{loading
? t('common.actions.loading', 'Loading...')
: t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')}
</Text>
</YStack>
{error ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="rgba(248, 113, 113, 0.12)"
borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)"
>
<Text fontSize="$2" color="#FEE2E2">
{error ?? t('achievements.page.loadError', 'Achievements could not be loaded.')}
</Text>
</YStack>
) : null}
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.18), rgba(79, 209, 255, 0.12))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 12%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
}}
>
<XStack alignItems="center" gap="$2">
<Star size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$3" fontWeight="$7">
{topPhoto
? t('achievements.highlights.topTitle', 'Top photo')
: t('achievements.summary.topContributor', 'Top contributor')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{topPhoto
? t('achievements.highlights.likesAmount', { count: topPhoto.likes }, '{count} Likes')
: t('achievements.summary.placeholder', 'Keep sharing to unlock highlights.')}
</Text>
</YStack>
<XStack gap="$3">
{[1, 2].map((card) => (
<YStack
key={card}
flex={1}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
>
<Text fontSize="$4" fontWeight="$8">
{card === 1 ? totalTasks : totalPhotos}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{card === 1
? t('achievements.summary.tasksCompleted', 'Tasks completed')
: t('achievements.summary.photosShared', 'Photos shared')}
</Text>
</YStack>
))}
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
>
<Text fontSize="$4" fontWeight="$7">
{totalLikes}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('achievements.summary.likesCollected', 'Likes collected')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,347 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Image as ImageIcon, Filter } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery } from '../services/photosApi';
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useNavigate } from 'react-router-dom';
import { buildEventPath } from '../lib/routes';
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
type GalleryTile = {
id: number;
imageUrl: string;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
sessionId?: string | null;
};
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
}
export default function GalleryScreen() {
const { token } = useEventData();
const { t } = useTranslation();
const { locale } = useLocale();
const navigate = useNavigate();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
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 [photos, setPhotos] = React.useState<GalleryTile[]>([]);
const [loading, setLoading] = React.useState(false);
const { data: delta } = usePollGalleryDelta(token ?? null, { locale });
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
React.useEffect(() => {
if (!token) {
setPhotos([]);
return;
}
let active = true;
setLoading(true);
fetchGallery(token, { limit: 18, locale })
.then((response) => {
if (!active) return;
const list = Array.isArray(response.data) ? response.data : [];
const mapped = list
.map((photo) => {
const record = photo as Record<string, unknown>;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
?? (record.file_path as string | null | undefined)
?? (record.full_url as string | null | undefined)
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
return {
id,
imageUrl,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
};
})
.filter((item) => item.id && item.imageUrl);
setPhotos(mapped);
})
.catch((error) => {
console.error('Failed to load gallery', error);
if (active) {
setPhotos([]);
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token, locale]);
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
return new Set<number>(raw ? JSON.parse(raw) : []);
} catch {
return new Set<number>();
}
}, [token]);
const filteredPhotos = React.useMemo(() => {
let list = photos.slice();
if (filter === 'popular') {
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
} else if (filter === 'mine') {
list = list.filter((photo) => myPhotoIds.has(photo.id));
} else if (filter === 'photobooth') {
list = list.filter((photo) => photo.ingestSource === 'photobooth');
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
} else {
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
}
return list;
}, [filter, myPhotoIds, photos]);
const displayPhotos = filteredPhotos;
const leftColumn = displayPhotos.filter((_, index) => index % 2 === 0);
const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1);
React.useEffect(() => {
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
setFilter('latest');
}
}, [filter, photos]);
const newUploads = React.useMemo(() => {
if (delta.photos.length === 0) {
return 0;
}
const existing = new Set(photos.map((item) => item.id));
return delta.photos.reduce((count, photo) => {
const id = Number((photo as Record<string, unknown>).id ?? 0);
if (id && !existing.has(id)) {
return count + 1;
}
return count;
}, 0);
}, [delta.photos, photos]);
const openLightbox = React.useCallback(
(photoId: number) => {
if (!token) return;
navigate(buildEventPath(token, `/photo/${photoId}`));
},
[navigate, token]
);
React.useEffect(() => {
if (delta.photos.length === 0) {
return;
}
setPhotos((prev) => {
const existing = new Set(prev.map((item) => item.id));
const mapped = delta.photos
.map((photo) => {
const record = photo as Record<string, unknown>;
const id = Number(record.id ?? 0);
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0;
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
?? (record.file_path as string | null | undefined)
?? (record.full_url as string | null | undefined)
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
if (!id || !imageUrl || existing.has(id)) {
return null;
}
return {
id,
imageUrl,
likes: likesCount,
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
} satisfies GalleryTile;
})
.filter(Boolean) as GalleryTile[];
if (mapped.length === 0) {
return prev;
}
return [...mapped, ...prev];
});
}, [delta.photos]);
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.title', 'Gallery')}
</Text>
</XStack>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
>
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<XStack gap="$2" flexWrap="wrap">
{(
[
{ value: 'latest', label: t('galleryPage.filters.latest', 'Newest') },
{ value: 'popular', label: t('galleryPage.filters.popular', 'Popular') },
{ value: 'mine', label: t('galleryPage.filters.mine', 'My photos') },
photos.some((photo) => photo.ingestSource === 'photobooth')
? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') }
: null,
].filter(Boolean) as Array<{ value: GalleryFilter; label: string }>
).map((chip) => (
<Button
key={chip.value}
size="$3"
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
onPress={() => setFilter(chip.value)}
>
<Text fontSize="$2" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
{chip.label}
</Text>
</Button>
))}
</XStack>
</YStack>
<XStack gap="$3">
<YStack flex={1} gap="$3">
{(loading || leftColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : leftColumn).map(
(tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={140 + (index % 3) * 24}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
}
)}
</YStack>
<YStack flex={1} gap="$3">
{(loading || rightColumn.length === 0 ? Array.from({ length: 5 }, (_, index) => index) : rightColumn).map(
(tile, index) => {
if (typeof tile === 'number') {
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
}
return (
<Button
key={tile.id}
unstyled
onPress={() => openLightbox(tile.id)}
>
<PhotoFrameTile height={120 + (index % 3) * 28}>
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
<img
src={tile.imageUrl}
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
</PhotoFrameTile>
</Button>
);
}
)}
</YStack>
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$3" fontWeight="$7">
{t('galleryPage.feed.title', 'Live feed')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{newUploads > 0
? t('galleryPage.feed.newUploads', { count: newUploads }, '{count} new uploads just landed.')
: t('galleryPage.feed.description', 'Updated every few seconds.')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
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 { useAppearance } from '@/hooks/use-appearance';
export default function HelpArticleScreen() {
const params = useParams<{ token?: string; slug: string }>();
const slug = params.slug;
const { locale } = useLocale();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticle = React.useCallback(async () => {
if (!slug) {
setState('error');
return;
}
setState('loading');
try {
const result = await getHelpArticle(slug, locale);
setArticle(result.article);
setState('ready');
} catch (error) {
console.error('[HelpArticle] Failed to load article', error);
setState('error');
}
}, [slug, locale]);
React.useEffect(() => {
loadArticle();
}, [loadArticle]);
const title = state === 'loading'
? t('help.article.loadingTitle')
: (article?.title ?? t('help.article.unavailable'));
const wrapper = (
<PullToRefresh
onRefresh={loadArticle}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<YStack gap="$4">
<SurfaceCard>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
asChild
>
<Link to={basePath}>
<XStack alignItems="center" gap="$2">
<ArrowLeft size={16} />
<Text fontSize="$2" fontWeight="$7">
{t('help.article.back')}
</Text>
</XStack>
</Link>
</Button>
</SurfaceCard>
<SurfaceCard glow>
<Text fontSize="$5" fontWeight="$8">
{title}
</Text>
{article?.updated_at ? (
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
</Text>
) : null}
</SurfaceCard>
{state === 'loading' ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Loader2 size={16} className="animate-spin" />
<Text fontSize="$2" color={mutedText}>
{t('help.article.loadingDescription')}
</Text>
</XStack>
</SurfaceCard>
) : null}
{state === 'error' ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('help.article.unavailable')}
</Text>
<Button size="$3" borderRadius="$pill" marginTop="$2" onPress={loadArticle}>
{t('help.article.reload')}
</Button>
</SurfaceCard>
) : null}
{state === 'ready' && article ? (
<SurfaceCard>
<YStack gap="$4">
<YStack>
<div
style={{ color: mutedText, fontSize: '0.95rem', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
/>
</YStack>
{article.related && article.related.length > 0 ? (
<YStack gap="$2">
<Text fontSize="$3" fontWeight="$7">
{t('help.article.relatedTitle')}
</Text>
<XStack gap="$2" flexWrap="wrap">
{article.related.map((rel) => (
<Button
key={rel.slug}
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
asChild
>
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
{rel.title ?? rel.slug}
</Link>
</Button>
))}
</XStack>
</YStack>
) : null}
</YStack>
</SurfaceCard>
) : null}
</YStack>
</PullToRefresh>
);
if (params.token) {
return <AppShell>{wrapper}</AppShell>;
}
return <StandaloneShell>{wrapper}</StandaloneShell>;
}
function formatDate(value: string, locale: string): string {
try {
return new Date(value).toLocaleDateString(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return value;
}
}

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Input } from '@tamagui/input';
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 { useAppearance } from '@/hooks/use-appearance';
export default function HelpCenterScreen() {
const params = useParams<{ token?: string }>();
const { locale } = useLocale();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]);
const [query, setQuery] = React.useState('');
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
const [servedFromCache, setServedFromCache] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticles = React.useCallback(async (forceRefresh = false) => {
setState('loading');
try {
const result = await getHelpArticles(locale, { forceRefresh });
setArticles(result.articles);
setServedFromCache(result.servedFromCache);
setState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load articles', error);
setState('error');
}
}, [locale]);
React.useEffect(() => {
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const showOfflineBadge = servedFromCache && !isOnline;
const filteredArticles = React.useMemo(() => {
if (!query.trim()) return articles;
const needle = query.trim().toLowerCase();
return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle));
}, [articles, query]);
const wrapper = (
<PullToRefresh
onRefresh={() => loadArticles(true)}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<YStack gap="$4">
<SurfaceCard glow>
<Text fontSize="$5" fontWeight="$8">
{t('help.center.title')}
</Text>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('help.center.subtitle')}
</Text>
</SurfaceCard>
<SurfaceCard>
<XStack gap="$2" alignItems="center">
<Search size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Input
flex={1}
placeholder={t('help.center.searchPlaceholder')}
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
aria-label={t('help.center.searchPlaceholder')}
/>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={() => loadArticles(true)}
disabled={state === 'loading'}
>
{state === 'loading' ? <Loader2 size={16} className="animate-spin" /> : <RefreshCcw size={16} />}
</Button>
</XStack>
{showOfflineBadge ? (
<YStack marginTop="$3">
<Text fontSize="$2" color={mutedText}>
{t('help.center.offlineDescription')}
</Text>
</YStack>
) : null}
</SurfaceCard>
<YStack gap="$3">
<Text fontSize="$3" fontWeight="$7">
{t('help.center.listTitle')}
</Text>
{state === 'loading' ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Loader2 size={16} className="animate-spin" />
<Text fontSize="$2" color={mutedText}>
{t('common.actions.loading')}
</Text>
</XStack>
</SurfaceCard>
) : null}
{state === 'error' ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('help.center.error')}
</Text>
<Button size="$3" borderRadius="$pill" marginTop="$2" onPress={() => loadArticles(false)}>
{t('help.center.retry')}
</Button>
</SurfaceCard>
) : null}
{state === 'ready' && filteredArticles.length === 0 ? (
<SurfaceCard>
<Text fontSize="$2" color={mutedText}>
{t('help.center.empty')}
</Text>
</SurfaceCard>
) : null}
{state === 'ready' && filteredArticles.length > 0 ? (
<YStack gap="$3">
{filteredArticles.map((article) => (
<SurfaceCard key={article.slug} padding="$3">
<Link to={`${basePath}/${encodeURIComponent(article.slug)}`}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7">
{article.title}
</Text>
<Text fontSize="$2" color={mutedText}>
{article.summary}
</Text>
</YStack>
</Link>
</SurfaceCard>
))}
</YStack>
) : null}
</YStack>
</YStack>
</PullToRefresh>
);
if (params.token) {
return <AppShell>{wrapper}</AppShell>;
}
return <StandaloneShell>{wrapper}</StandaloneShell>;
}

View File

@@ -0,0 +1,415 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useStaggeredReveal } from '../lib/useStaggeredReveal';
import { usePollStats } from '../hooks/usePollStats';
import { fetchGallery } from '../services/photosApi';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
type ActionRingProps = {
label: string;
icon: React.ReactNode;
onPress: () => void;
};
type GalleryPreview = {
id: number;
imageUrl: string;
};
function ActionRing({
label,
icon,
onPress,
isDark,
}: ActionRingProps & { isDark: boolean }) {
return (
<Button unstyled onPress={onPress}>
<YStack alignItems="center" gap="$2">
<YStack
width={74}
height={74}
borderRadius={37}
backgroundColor="$surface"
borderWidth={2}
borderColor="$primary"
alignItems="center"
justifyContent="center"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.05))'
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 20%, white), rgba(255, 255, 255, 0.7))',
boxShadow: isDark
? '0 10px 24px rgba(255, 79, 216, 0.2)'
: '0 10px 24px rgba(15, 23, 42, 0.12)',
}}
>
{icon}
</YStack>
<Text fontSize="$2" fontWeight="$6" color="$color" opacity={0.9}>
{label}
</Text>
</YStack>
</Button>
);
}
function QuickStats({
reveal,
stats,
queueCount,
isDark,
}: {
reveal: number;
stats: { onlineGuests: number; tasksSolved: number };
queueCount: number;
isDark: boolean;
}) {
const { t } = useTranslation();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 16px 30px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
return (
<XStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={reveal >= 3 ? 1 : 0}
y={reveal >= 3 ? 0 : 12}
>
<YStack
flex={1}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$4" fontWeight="$8">
{stats.onlineGuests}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('home.stats.online', 'Guests online')}
</Text>
</YStack>
<YStack
flex={1}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$1"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$4" fontWeight="$8">
{queueCount}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
</Text>
</YStack>
</XStack>
);
}
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
}
export default function HomeScreen() {
const { tasksEnabled, token } = useEventData();
const navigate = useNavigate();
const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 });
const { stats } = usePollStats(token ?? null);
const { items } = useUploadQueue();
const [preview, setPreview] = React.useState<GalleryPreview[]>([]);
const [previewLoading, setPreviewLoading] = React.useState(false);
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
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 cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 32px rgba(15, 23, 42, 0.12)';
const goTo = (path: string) => () => navigate(buildEventPath(token, path));
const rings = [
tasksEnabled
? {
label: t('home.actions.items.tasks.label', 'Draw a task card'),
icon: <Sparkles size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/tasks',
}
: {
label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload',
},
{
label: t('homeV2.rings.newUploads', 'New uploads'),
icon: <ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/gallery',
},
{
label: t('homeV2.rings.topMoments', 'Top moments'),
icon: <Star size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/gallery',
},
{
label: t('navigation.achievements', 'Achievements'),
icon: <Trophy size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/achievements',
},
];
React.useEffect(() => {
if (!token) {
setPreview([]);
return;
}
let active = true;
setPreviewLoading(true);
fetchGallery(token, { limit: 3 })
.then((response) => {
if (!active) return;
const photos = Array.isArray(response.data) ? response.data : [];
const mapped = photos
.map((photo) => {
const record = photo as Record<string, unknown>;
const id = Number(record.id ?? 0);
const imageUrl = normalizeImageUrl(
(record.thumbnail_url as string | null | undefined)
?? (record.thumbnail_path as string | null | undefined)
?? (record.file_path as string | null | undefined)
?? (record.full_url as string | null | undefined)
?? (record.url as string | null | undefined)
?? (record.image_url as string | null | undefined)
);
return { id, imageUrl };
})
.filter((item) => item.id && item.imageUrl);
setPreview(mapped);
})
.catch((error) => {
console.error('Failed to load gallery preview', error);
if (active) {
setPreview([]);
}
})
.finally(() => {
if (active) {
setPreviewLoading(false);
}
});
return () => {
active = false;
};
}, [token]);
const queueCount = items.filter((item) => item.status !== 'done').length;
return (
<AppShell>
<YStack gap="$4">
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12}
>
<XStack gap="$2" justifyContent="space-between">
{rings.map((ring) => (
<YStack key={ring.label} flex={1} alignItems="center">
<ActionRing label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
</YStack>
))}
</XStack>
</YStack>
{tasksEnabled ? (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
y={revealStage >= 2 ? 0 : 16}
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.25), rgba(79, 209, 255, 0.12))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color="#FF4FD8" />
<Text fontSize="$3" fontWeight="$7">
{t('homeV2.promptQuest.label', 'Prompt quest')}
</Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{t('homeV2.promptQuest.title', 'Capture the happiest laugh')}
</Text>
<Text fontSize="$3" color="$color" opacity={0.75}>
{t('homeV2.promptQuest.subtitle', 'Earn points and keep the gallery lively.')}
</Text>
<XStack gap="$2" flexWrap="wrap">
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
{t('homeV2.promptQuest.ctaStart', 'Start prompt')}
</Button>
<Button
size="$4"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={goTo('/tasks')}
>
{t('homeV2.promptQuest.ctaBrowse', 'Browse tasks')}
</Button>
</XStack>
</YStack>
) : (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
y={revealStage >= 2 ? 0 : 16}
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(79, 209, 255, 0.18), rgba(255, 79, 216, 0.12))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white), color-mix(in oklab, var(--guest-primary, #FF5A5F) 10%, white))',
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$3" fontWeight="$7">
{t('homeV2.captureReady.label', 'Capture ready')}
</Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{t('homeV2.captureReady.title', 'Add a photo to the shared gallery')}
</Text>
<Text fontSize="$3" color="$color" opacity={0.75}>
{t('homeV2.captureReady.subtitle', 'Quick add from your camera or device.')}
</Text>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill" onPress={goTo('/upload')}>
{t('homeV2.captureReady.cta', 'Upload / Take photo')}
</Button>
</YStack>
)}
<QuickStats
reveal={revealStage}
stats={{ onlineGuests: stats.onlineGuests, tasksSolved: stats.tasksSolved }}
queueCount={queueCount}
isDark={isDark}
/>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 4 ? 1 : 0}
y={revealStage >= 4 ? 0 : 16}
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$4" fontWeight="$7">
{t('homeV2.galleryPreview.title', 'Gallery preview')}
</Text>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={goTo('/gallery')}
>
{t('homeV2.galleryPreview.cta', 'View all')}
</Button>
</XStack>
<XStack
gap="$2"
style={{
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
paddingBottom: 6,
}}
>
{(previewLoading || preview.length === 0 ? [1, 2, 3, 4] : preview).map((tile, index) => {
if (typeof tile === 'number') {
return (
<YStack key={tile} flexShrink={0} width={140}>
<PhotoFrameTile height={110} shimmer shimmerDelayMs={tile * 140} />
</YStack>
);
}
return (
<YStack key={tile.id} flexShrink={0} width={140}>
<PhotoFrameTile height={110}>
<YStack
flex={1}
width="100%"
height="100%"
style={{
backgroundImage: `url(${tile.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</PhotoFrameTile>
</YStack>
);
})}
</XStack>
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,237 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
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 { fetchEvent } from '../services/eventApi';
import { readGuestName } from '../context/GuestIdentityContext';
import { useAppearance } from '@/hooks/use-appearance';
const qrConfig = { fps: 10, qrbox: { width: 240, height: 240 } } as const;
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
export default function LandingScreen() {
const navigate = useNavigate();
const { t } = useTranslation();
const [eventCode, setEventCode] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [errorKey, setErrorKey] = React.useState<LandingErrorKey | null>(null);
const [isScanning, setIsScanning] = React.useState(false);
const scannerRef = React.useRef<Html5Qrcode | null>(null);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.9)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.78)' : 'rgba(15, 23, 42, 0.6)';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.1)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
const extractEventKey = React.useCallback((raw: string): string => {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
if (inviteParam) {
return inviteParam;
}
const segments = url.pathname.split('/').filter(Boolean);
const eventIndex = segments.findIndex((segment) => segment === 'e');
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
return decodeURIComponent(segments[eventIndex + 1]);
}
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch {
// Not a URL, treat as raw code.
}
return trimmed;
}, []);
const join = React.useCallback(
async (input?: string) => {
const provided = input ?? eventCode;
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setErrorKey(null);
try {
const event = await fetchEvent(normalized);
const targetKey = event.join_token ?? normalized;
if (!targetKey) {
setErrorKey('eventClosed');
return;
}
const storedName = readGuestName(targetKey);
if (!storedName) {
navigate(`/setup/${encodeURIComponent(targetKey)}`);
} else {
navigate(`/e/${encodeURIComponent(targetKey)}`);
}
} catch (error) {
console.error('Join request failed', error);
setErrorKey('network');
} finally {
setLoading(false);
}
},
[eventCode, extractEventKey, navigate]
);
const onScanSuccess = React.useCallback(
async (decodedText: string) => {
const value = decodedText.trim();
if (!value) return;
await join(value);
if (scannerRef.current) {
try {
await scannerRef.current.stop();
} catch (error) {
console.warn('Scanner stop failed', error);
}
}
setIsScanning(false);
},
[join]
);
const startScanner = React.useCallback(async () => {
if (scannerRef.current) {
try {
await scannerRef.current.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (error) {
console.error('Scanner start failed', error);
setErrorKey('camera');
}
return;
}
try {
const scanner = new Html5Qrcode('qr-reader');
scannerRef.current = scanner;
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (error) {
console.error('Scanner init failed', error);
setErrorKey('camera');
}
}, [onScanSuccess]);
const stopScanner = React.useCallback(async () => {
if (!scannerRef.current) {
setIsScanning(false);
return;
}
try {
await scannerRef.current.stop();
} catch (error) {
console.warn('Scanner stop failed', error);
} finally {
setIsScanning(false);
}
}, []);
React.useEffect(() => () => {
if (scannerRef.current) {
scannerRef.current.stop().catch(() => undefined);
}
}, []);
return (
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
<YStack gap="$5" maxWidth={480} width="100%" alignSelf="center">
<YStack gap="$2">
<Text fontSize="$8" fontFamily="$display" fontWeight="$9" color={primaryText}>
{t('landing.pageTitle')}
</Text>
<Text color={mutedText}>
{t('landing.subheadline')}
</Text>
</YStack>
{errorMessage ? (
<Card padding="$3" backgroundColor="rgba(248, 113, 113, 0.12)" borderColor="rgba(248, 113, 113, 0.4)" borderWidth={1}>
<Text color="#FEE2E2">{errorMessage}</Text>
</Card>
) : null}
<Card padding="$4" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$4">
<XStack alignItems="center" gap="$2">
<QrCode size={18} color={primaryText} />
<Text fontSize="$5" fontWeight="$8" color={primaryText}>
{t('landing.join.title')}
</Text>
</XStack>
<Text color={mutedText}>
{t('landing.join.description')}
</Text>
<YStack gap="$3">
<YStack
id="qr-reader"
height={240}
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.05)'}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.15)' : 'rgba(15, 23, 42, 0.12)'}
overflow="hidden"
/>
<Button
onPress={isScanning ? stopScanner : startScanner}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
{isScanning ? t('landing.scan.stop', 'Scanner stoppen') : t('landing.scan.start', 'QR-Code scannen')}
</Button>
</YStack>
<Text color={mutedText} textAlign="center">
{t('landing.scan.manualDivider')}
</Text>
<YStack gap="$3">
<Input
value={eventCode}
onChangeText={setEventCode}
placeholder={t('landing.input.placeholder')}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
borderColor={cardBorder}
color={primaryText}
/>
<Button
onPress={() => join()}
disabled={loading || !eventCode.trim()}
backgroundColor="$primary"
color="#FFFFFF"
iconAfter={loading ? undefined : () => <ArrowRight size={16} color="#FFFFFF" />}
>
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
</Button>
</YStack>
</YStack>
</Card>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { YStack } 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 { useAppearance } from '@/hooks/use-appearance';
export default function LegalScreen() {
const { page } = useParams<{ page: string }>();
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
const [loading, setLoading] = React.useState(true);
const [title, setTitle] = React.useState('');
const [body, setBody] = React.useState('');
const [html, setHtml] = React.useState('');
React.useEffect(() => {
if (!page) {
return;
}
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=${encodeURIComponent(locale)}`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
if (!res.ok) {
throw new Error('failed');
}
const data = await res.json();
setTitle(data.title || '');
setBody(data.body_markdown || '');
setHtml(data.body_html || '');
} catch (error) {
if (!controller.signal.aborted) {
console.error('Failed to load legal page', error);
setTitle('');
setBody('');
setHtml('');
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadLegal();
return () => controller.abort();
}, [page, locale]);
const fallbackTitle = page ? `Rechtliches: ${page}` : t('settings.legal.fallbackTitle', 'Rechtliche Informationen');
return (
<YStack flex={1} minHeight="100vh" padding="$5" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
<YStack gap="$4" maxWidth={720} width="100%" alignSelf="center">
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
{title || fallbackTitle}
</Text>
<Card padding="$4" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
{loading ? (
<Text color={mutedText}>{t('settings.legal.loading', 'Lade...')}</Text>
) : (
<LegalMarkdown markdown={body} html={html} />
)}
</Card>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,213 @@
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';
export default function LiveShowScreen() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
const { status, connection, error, event, photos, settings } = useLiveShowState(token ?? null);
const [paused, setPaused] = React.useState(false);
const { frame, layout, frameKey, nextFrame } = useLiveShowPlayback(photos, settings, { paused });
const hasPhoto = frame.length > 0;
const stageTitle = event?.name ?? t('liveShowPlayer.title', 'Live Show');
const reducedMotion = prefersReducedMotion();
const effect = resolveLiveShowEffect(settings.effect_preset, settings.effect_intensity, reducedMotion);
const showStage = status === 'ready' && hasPhoto;
const showEmpty = status === 'ready' && !hasPhoto;
const [controlsVisible, setControlsVisible] = React.useState(true);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const hideTimerRef = React.useRef<number | null>(null);
const preloadRef = React.useRef<Set<string>>(new Set());
const stageRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
document.body.classList.add('guest-immersive');
return () => {
document.body.classList.remove('guest-immersive');
};
}, []);
React.useEffect(() => {
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', handleFullscreen);
handleFullscreen();
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
}, []);
const revealControls = React.useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) {
window.clearTimeout(hideTimerRef.current);
}
hideTimerRef.current = window.setTimeout(() => {
setControlsVisible(false);
}, 3000);
}, []);
React.useEffect(() => {
if (!showStage) {
setControlsVisible(true);
return;
}
revealControls();
}, [revealControls, showStage, frameKey]);
const togglePause = React.useCallback(() => {
setPaused((prev) => !prev);
}, []);
const toggleFullscreen = React.useCallback(async () => {
const target = stageRef.current ?? document.documentElement;
try {
if (!document.fullscreenElement) {
await target.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (err) {
console.warn('Fullscreen toggle failed', err);
}
}, []);
React.useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.target && (event.target as HTMLElement).closest('input, textarea, select, button')) {
return;
}
if (event.code === 'Space') {
event.preventDefault();
togglePause();
revealControls();
}
if (event.key.toLowerCase() === 'f') {
event.preventDefault();
toggleFullscreen();
revealControls();
}
if (event.key === 'Escape' && document.fullscreenElement) {
event.preventDefault();
document.exitFullscreen?.();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [revealControls, toggleFullscreen, togglePause]);
React.useEffect(() => {
const candidates = [...frame, ...nextFrame].slice(0, 6);
candidates.forEach((photo) => {
const src = photo.full_url || photo.thumb_url;
if (!src || preloadRef.current.has(src)) {
return;
}
const img = new Image();
img.src = src;
preloadRef.current.add(src);
});
}, [frame, nextFrame]);
return (
<div
ref={stageRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white"
aria-busy={status === 'loading'}
onMouseMove={revealControls}
onTouchStart={revealControls}
>
<LiveShowBackdrop mode={settings.background_mode} photo={frame[0]} intensity={settings.effect_intensity} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between px-6 py-4 text-sm">
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
{stageTitle}
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
{connection === 'sse'
? t('liveShowPlayer.connection.live', 'Live')
: t('liveShowPlayer.connection.sync', 'Sync')}
</span>
</div>
{status === 'loading' && (
<div className="flex flex-col items-center gap-4 text-white/70">
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
<p className="text-sm">{t('liveShowPlayer.loading', 'Live Show wird geladen...')}</p>
</div>
)}
{status === 'error' && (
<div className="max-w-md space-y-2 px-6 text-center">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.error.title', 'Live Show nicht erreichbar')}
</p>
<p className="text-sm text-white/70">
{error ?? t('liveShowPlayer.error.description', 'Bitte überprüfe den Live-Link.')}
</p>
</div>
)}
{showEmpty && (
<div className="max-w-md space-y-2 px-6 text-center text-white/80">
<p className="text-lg font-semibold text-white">
{t('liveShowPlayer.empty.title', 'Noch keine Live-Fotos')}
</p>
<p className="text-sm text-white/70">
{t('liveShowPlayer.empty.description', 'Warte auf die ersten Uploads...')}
</p>
</div>
)}
<AnimatePresence initial={false} mode="sync">
{showStage && (
<motion.div key={frameKey} className="relative z-10 flex min-h-0 w-full flex-1 items-stretch" {...effect.frame}>
<LiveShowStage layout={layout} photos={frame} title={stageTitle} />
</motion.div>
)}
</AnimatePresence>
{showStage && effect.flash && (
<motion.div key={`flash-${frameKey}`} className="pointer-events-none absolute inset-0 z-20 bg-white" {...effect.flash} />
)}
<AnimatePresence initial={false}>
{controlsVisible && (
<motion.div
className="absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2 }}
>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={togglePause}
>
{paused ? <Play className="h-4 w-4" aria-hidden /> : <Pause className="h-4 w-4" aria-hidden />}
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" aria-hidden /> : <Maximize2 className="h-4 w-4" aria-hidden />}
<span>
{isFullscreen
? t('liveShowPlayer.controls.exitFullscreen', 'Exit fullscreen')
: t('liveShowPlayer.controls.fullscreen', 'Fullscreen')}
</span>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTranslation } from '@/guest/i18n/useTranslation';
export default function NotFoundScreen() {
const { t } = useTranslation();
return (
<YStack
minHeight="100vh"
alignItems="center"
justifyContent="center"
backgroundColor="$background"
padding="$4"
gap="$2"
>
<Text fontSize="$6" fontWeight="$7">
{t('notFound.title', 'Seite nicht gefunden')}
</Text>
<Text fontSize="$3" color="$color" opacity={0.72}>
{t('notFound.description', 'Die Seite konnte nicht gefunden werden.')}
</Text>
</YStack>
);
}

View File

@@ -0,0 +1,589 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, ChevronLeft, ChevronRight, Heart, Share2 } from 'lucide-react';
import { useGesture } from '@use-gesture/react';
import { animated, to, useSpring } from '@react-spring/web';
import AppShell from '../components/AppShell';
import SurfaceCard from '../components/SurfaceCard';
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 { useAppearance } from '@/hooks/use-appearance';
import { buildEventPath } from '../lib/routes';
type LightboxPhoto = {
id: number;
imageUrl: string;
likes: number;
createdAt?: string | null;
ingestSource?: string | null;
};
function normalizeImageUrl(src?: string | null) {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
}
function mapPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
const id = Number(photo.id ?? 0);
if (!id) return null;
const imageUrl = normalizeImageUrl(
(photo.full_url as string | null | undefined)
?? (photo.file_path as string | null | undefined)
?? (photo.thumbnail_url as string | null | undefined)
?? (photo.thumbnail_path as string | null | undefined)
?? (photo.url as string | null | undefined)
?? (photo.image_url as string | null | undefined)
);
if (!imageUrl) return null;
return {
id,
imageUrl,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
createdAt: typeof photo.created_at === 'string' ? photo.created_at : null,
ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null,
};
}
export default function PhotoLightboxScreen() {
const { token } = useEventData();
const { photoId } = useParams<{ photoId: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
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 groupBackground = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(15, 23, 42, 0.04)';
const [photos, setPhotos] = React.useState<LightboxPhoto[]>([]);
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [cursor, setCursor] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [likes, setLikes] = React.useState<Record<number, number>>({});
const [shareStatus, setShareStatus] = React.useState<'idle' | 'loading' | 'copied' | 'failed'>('idle');
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
const baseSizeRef = React.useRef({ width: 0, height: 0 });
const scaleRef = React.useRef(1);
const lastTapRef = React.useRef(0);
const [isZoomed, setIsZoomed] = React.useState(false);
const [{ x, y, scale }, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
config: { tension: 260, friction: 28 },
}));
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
const loadPage = React.useCallback(
async (nextCursor?: string | null, replace = false) => {
if (!token) return { items: [], nextCursor: null };
const response = await fetchGallery(token, { cursor: nextCursor ?? undefined, limit: 28, locale });
const mapped = (response.data ?? [])
.map((photo) => mapPhoto(photo as Record<string, unknown>))
.filter(Boolean) as LightboxPhoto[];
setCursor(response.next_cursor ?? null);
setPhotos((prev) => (replace ? mapped : [...prev, ...mapped]));
setLikes((prev) => {
const next = { ...prev };
for (const item of mapped) {
if (next[item.id] === undefined) {
next[item.id] = item.likes;
}
}
return next;
});
return { items: mapped, nextCursor: response.next_cursor ?? null };
},
[locale, token]
);
React.useEffect(() => {
if (!token || !photoId) {
setError(t('lightbox.errors.notFound', 'Photo not found'));
return;
}
let active = true;
const targetId = Number(photoId);
const init = async () => {
setLoading(true);
setError(null);
try {
let foundIndex = -1;
let nextCursor: string | null = null;
let combined: LightboxPhoto[] = [];
for (let page = 0; page < 5; page += 1) {
const response = await fetchGallery(token, {
cursor: nextCursor ?? undefined,
limit: 28,
locale,
});
const mapped = (response.data ?? [])
.map((photo) => mapPhoto(photo as Record<string, unknown>))
.filter(Boolean) as LightboxPhoto[];
combined = [...combined, ...mapped];
nextCursor = response.next_cursor ?? null;
foundIndex = combined.findIndex((item) => item.id === targetId);
if (foundIndex >= 0 || !nextCursor) {
break;
}
}
if (!active) return;
if (combined.length > 0) {
setPhotos(combined);
setCursor(nextCursor);
setLikes((prev) => {
const next = { ...prev };
for (const item of combined) {
if (next[item.id] === undefined) {
next[item.id] = item.likes;
}
}
return next;
});
}
if (foundIndex >= 0) {
setSelectedIndex(foundIndex);
return;
}
const single = await fetchPhoto(targetId, locale);
if (!active) return;
const mappedSingle = single ? mapPhoto(single as Record<string, unknown>) : null;
if (mappedSingle) {
setPhotos([mappedSingle]);
setSelectedIndex(0);
return;
}
setError(t('lightbox.errors.notFound', 'Photo not found'));
} catch (err) {
if (!active) return;
console.error('Photo lightbox load failed', err);
setError(t('lightbox.errors.loadFailed', 'Failed to load photo'));
} finally {
if (active) setLoading(false);
}
};
init();
return () => {
active = false;
};
}, [photoId, token, locale, t]);
const updateBaseSize = React.useCallback(() => {
if (!zoomImageRef.current) {
return;
}
const rect = zoomImageRef.current.getBoundingClientRect();
baseSizeRef.current = { width: rect.width, height: rect.height };
}, []);
React.useEffect(() => {
updateBaseSize();
}, [selected?.id, updateBaseSize]);
React.useEffect(() => {
window.addEventListener('resize', updateBaseSize);
return () => window.removeEventListener('resize', updateBaseSize);
}, [updateBaseSize]);
const clamp = React.useCallback((value: number, min: number, max: number) => {
return Math.min(max, Math.max(min, value));
}, []);
const getBounds = React.useCallback(
(nextScale: number) => {
const container = zoomContainerRef.current?.getBoundingClientRect();
const { width, height } = baseSizeRef.current;
if (!container || !width || !height) {
return { maxX: 0, maxY: 0 };
}
const scaledWidth = width * nextScale;
const scaledHeight = height * nextScale;
const maxX = Math.max(0, (scaledWidth - container.width) / 2);
const maxY = Math.max(0, (scaledHeight - container.height) / 2);
return { maxX, maxY };
},
[]
);
const resetZoom = React.useCallback(() => {
scaleRef.current = 1;
setIsZoomed(false);
api.start({ x: 0, y: 0, scale: 1 });
}, [api]);
React.useEffect(() => {
resetZoom();
}, [selected?.id, resetZoom]);
const toggleZoom = React.useCallback(() => {
const nextScale = scaleRef.current > 1.01 ? 1 : 2;
scaleRef.current = nextScale;
setIsZoomed(nextScale > 1.01);
api.start({ x: 0, y: 0, scale: nextScale });
}, [api]);
const goPrev = React.useCallback(() => {
if (selectedIndex === null || selectedIndex <= 0) return;
setSelectedIndex(selectedIndex - 1);
}, [selectedIndex]);
const goNext = React.useCallback(async () => {
if (selectedIndex === null) return;
if (selectedIndex < photos.length - 1) {
setSelectedIndex(selectedIndex + 1);
return;
}
if (!cursor || loadingMore) {
return;
}
setLoadingMore(true);
try {
const { items } = await loadPage(cursor);
if (items.length > 0) {
setSelectedIndex((prev) => (typeof prev === 'number' ? prev + 1 : prev));
}
} finally {
setLoadingMore(false);
}
}, [cursor, loadPage, loadingMore, photos.length, selectedIndex]);
const handleLike = React.useCallback(async () => {
if (!selected || !token) return;
setLikes((prev) => ({ ...prev, [selected.id]: (prev[selected.id] ?? selected.likes) + 1 }));
try {
const count = await likePhoto(selected.id);
setLikes((prev) => ({ ...prev, [selected.id]: count }));
} catch (error) {
console.error('Like failed', error);
}
}, [selected, token]);
const handleShare = React.useCallback(async () => {
if (!selected || !token) return;
setShareStatus('loading');
try {
const payload = await createPhotoShareLink(token, selected.id);
const url = payload?.url ?? '';
if (!url) {
throw new Error('missing share url');
}
const data: ShareData = {
title: t('share.defaultEvent', 'A special moment'),
text: t('share.shareText', 'Check out this moment on Fotospiel.'),
url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
await navigator.share(data);
setShareStatus('idle');
return;
}
await navigator.clipboard?.writeText(url);
setShareStatus('copied');
} catch (error) {
console.error('Share failed', error);
setShareStatus('failed');
} finally {
window.setTimeout(() => setShareStatus('idle'), 2000);
}
}, [selected, t, token]);
const bind = useGesture(
{
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const zoomed = scaleRef.current > 1.01;
if (!zoomed) {
api.start({ x: down ? mx : 0, y: 0, immediate: down });
if (last) {
api.start({ x: 0, y: 0, immediate: false });
const threshold = 80;
if (Math.abs(mx) > threshold) {
if (mx > 0) {
goPrev();
} else {
void goNext();
}
}
}
return;
}
const { maxX, maxY } = getBounds(scaleRef.current);
api.start({
x: clamp(ox, -maxX, maxX),
y: clamp(oy, -maxY, maxY),
immediate: down,
});
},
onPinch: ({ offset: [nextScale], last, event }) => {
if (event.cancelable) {
event.preventDefault();
}
const clampedScale = clamp(nextScale, 1, 3);
scaleRef.current = clampedScale;
setIsZoomed(clampedScale > 1.01);
const { maxX, maxY } = getBounds(clampedScale);
api.start({
scale: clampedScale,
x: clamp(x.get(), -maxX, maxX),
y: clamp(y.get(), -maxY, maxY),
immediate: true,
});
if (last && clampedScale <= 1.01) {
resetZoom();
}
},
},
{
drag: {
from: () => [x.get(), y.get()],
filterTaps: true,
threshold: 4,
},
pinch: {
scaleBounds: { min: 1, max: 3 },
rubberband: true,
},
eventOptions: { passive: false },
}
);
const handlePointerUp = (event: React.PointerEvent) => {
if (event.pointerType !== 'touch') {
return;
}
const now = Date.now();
if (now - lastTapRef.current < 280) {
lastTapRef.current = 0;
toggleZoom();
return;
}
lastTapRef.current = now;
};
return (
<AppShell>
<YStack gap="$4">
<SurfaceCard>
<XStack alignItems="center" justifyContent="space-between">
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(buildEventPath(token, '/gallery'))}
paddingHorizontal="$3"
aria-label={t('common.actions.close', 'Close')}
>
<ArrowLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.title', 'Gallery')}
</Text>
<XStack width={48} />
</XStack>
</SurfaceCard>
<SurfaceCard padding="$4">
{loading ? (
<Text fontSize="$3" color="$color" opacity={0.7}>
{t('galleryPage.loading', 'Loading…')}
</Text>
) : error ? (
<Text fontSize="$3" color="#FCA5A5">
{error}
</Text>
) : selected ? (
<YStack gap="$3">
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
alignItems="center"
justifyContent="center"
style={{ height: 'min(70vh, 520px)' }}
>
<div
ref={zoomContainerRef}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
}}
{...bind()}
onPointerUp={handlePointerUp}
>
<animated.div
style={{
transform: to([x, y, scale], (nx, ny, ns) => `translate3d(${nx}px, ${ny}px, 0) scale(${ns})`),
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
ref={zoomImageRef}
src={selected.imageUrl}
alt={t('galleryPage.photo.alt', { id: selected.id }, 'Photo {id}')}
onLoad={updateBaseSize}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
userSelect: 'none',
pointerEvents: isZoomed ? 'auto' : 'none',
}}
/>
</animated.div>
</div>
</YStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="$color" opacity={0.7}>
{selectedIndex !== null ? `${selectedIndex + 1} / ${photos.length}` : ''}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={groupBackground}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button
unstyled
disabled={selectedIndex === null || selectedIndex <= 0}
onPress={goPrev}
paddingHorizontal="$3"
paddingVertical="$2"
opacity={selectedIndex === null || selectedIndex <= 0 ? 0.4 : 1}
>
<XStack alignItems="center" gap="$2">
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('galleryPage.lightbox.prev', 'Prev')}
</Text>
</XStack>
</Button>
<Button
unstyled
disabled={selectedIndex === null || (selectedIndex >= photos.length - 1 && !cursor)}
onPress={goNext}
paddingHorizontal="$3"
paddingVertical="$2"
opacity={selectedIndex === null || (selectedIndex >= photos.length - 1 && !cursor) ? 0.4 : 1}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$2" fontWeight="$6">
{loadingMore ? t('common.actions.loading', 'Loading...') : t('galleryPage.lightbox.next', 'Next')}
</Text>
<ChevronRight size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</XStack>
</Button>
</XStack>
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.lightbox.likes', { count: likes[selected.id] ?? selected.likes }, '{count} likes')}
</Text>
<XStack
gap="$1"
padding="$1"
borderRadius="$pill"
backgroundColor={groupBackground}
borderWidth={1}
borderColor={mutedButtonBorder}
alignItems="center"
flexWrap="wrap"
justifyContent="flex-end"
>
<Button
unstyled
onPress={handleLike}
paddingHorizontal="$3"
paddingVertical="$2"
>
<XStack alignItems="center" gap="$2">
<Heart size={16} color="#FFFFFF" />
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('galleryPage.photo.likeAria', 'Like')}
</Text>
</XStack>
</Button>
<Button
unstyled
onPress={handleShare}
paddingHorizontal="$3"
paddingVertical="$2"
>
<XStack alignItems="center" gap="$2">
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{shareStatus === 'loading'
? t('share.loading', 'Sharing...')
: shareStatus === 'copied'
? t('share.copySuccess', 'Copied')
: shareStatus === 'failed'
? t('share.copyError', 'Copy failed')
: t('share.button', 'Share')}
</Text>
</XStack>
</Button>
</XStack>
</XStack>
</YStack>
) : (
<Text fontSize="$3" color="$color" opacity={0.7}>
{t('lightbox.errors.notFound', 'Photo not found')}
</Text>
)}
</SurfaceCard>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { YStack } from '@tamagui/stacks';
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 { useEventData } from '../context/EventDataContext';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useAppearance } from '@/hooks/use-appearance';
export default function ProfileSetupScreen() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { event, status } = useEventData();
const identity = useGuestIdentity();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
const [nameDraft, setNameDraft] = React.useState(identity.name ?? '');
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (identity.hydrated) {
setNameDraft(identity.name ?? '');
}
}, [identity.hydrated, identity.name]);
const handleSubmit = React.useCallback(() => {
if (!token) {
return;
}
const trimmed = nameDraft.trim();
if (!trimmed) {
return;
}
setSaving(true);
identity.setName(trimmed);
navigate(`/e/${encodeURIComponent(token)}`);
}, [identity, nameDraft, navigate, token]);
if (status === 'loading' || status === 'idle') {
return (
<YStack flex={1} minHeight="100vh" justifyContent="center" padding="$5" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
<Text color={mutedText}>{t('profileSetup.loading')}</Text>
</YStack>
);
}
if (!event) {
return (
<YStack flex={1} minHeight="100vh" justifyContent="center" padding="$5" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
<Text color="#FCA5A5">{t('profileSetup.error.default')}</Text>
<Button onPress={() => navigate('/event')} marginTop="$3">
{t('profileSetup.error.backToStart')}
</Button>
</YStack>
);
}
return (
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
<YStack gap="$5" maxWidth={420} width="100%" alignSelf="center">
<YStack gap="$2">
<Text fontSize="$7" fontFamily="$display" fontWeight="$9" color={primaryText}>
{event.name}
</Text>
<Text color={mutedText}>
{t('profileSetup.card.description')}
</Text>
</YStack>
<Card padding="$4" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text color={mutedText} fontSize="$3">
{t('profileSetup.form.label')}
</Text>
<Input
value={nameDraft}
onChangeText={setNameDraft}
placeholder={t('profileSetup.form.placeholder')}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
borderColor={cardBorder}
color={primaryText}
/>
<Button
onPress={handleSubmit}
disabled={!nameDraft.trim() || saving}
backgroundColor="$primary"
color="#FFFFFF"
>
{saving ? t('profileSetup.form.submitting') : t('profileSetup.form.submit')}
</Button>
</YStack>
</Card>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,306 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Image as ImageIcon, Download, Share2 } from 'lucide-react';
import { Sheet } from '@tamagui/sheet';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
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 { mapEventBranding } from '../lib/eventBranding';
import { BrandingTheme } from '../lib/brandingTheme';
import { useAppearance } from '@/hooks/use-appearance';
type GalleryState = {
meta: GalleryMetaResponse | null;
photos: GalleryPhotoResource[];
cursor: string | null;
loading: boolean;
loadingMore: boolean;
error: string | null;
expired: boolean;
};
const INITIAL_STATE: GalleryState = {
meta: null,
photos: [],
cursor: null,
loading: true,
loadingMore: false,
error: null,
expired: false,
};
const PAGE_SIZE = 30;
export default function PublicGalleryScreen() {
const { token } = useParams<{ token: string }>();
const { t, locale } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [state, setState] = React.useState<GalleryState>(INITIAL_STATE);
const [selected, setSelected] = React.useState<GalleryPhotoResource | null>(null);
const [shareLoading, setShareLoading] = React.useState(false);
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
const branding = React.useMemo(() => {
if (!state.meta) return null;
const raw = state.meta.branding ?? null;
return mapEventBranding({
primary_color: raw.primary_color,
secondary_color: raw.secondary_color,
background_color: raw.background_color,
surface_color: raw.surface_color ?? raw.background_color,
palette: raw.palette ?? undefined,
mode: raw.mode ?? 'auto',
} as any);
}, [state.meta]);
const loadInitial = React.useCallback(async () => {
if (!token) return;
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
try {
const meta = await fetchGalleryMeta(token, locale);
const photoResponse = await fetchGalleryPhotos(token, null, PAGE_SIZE);
setState((prev) => ({
...prev,
meta,
photos: photoResponse.data,
cursor: photoResponse.next_cursor,
loading: false,
}));
} catch (error) {
const err = error as Error & { code?: string | number };
if (err.code === 'gallery_expired' || err.code === 410) {
setState((prev) => ({ ...prev, loading: false, expired: true }));
} else {
setState((prev) => ({ ...prev, loading: false, error: err.message || t('galleryPublic.loadError') }));
}
}
}, [locale, t, token]);
React.useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadMore = React.useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) return;
setState((prev) => ({ ...prev, loadingMore: true }));
try {
const response = await fetchGalleryPhotos(token, state.cursor, PAGE_SIZE);
setState((prev) => ({
...prev,
photos: [...prev.photos, ...response.data],
cursor: response.next_cursor,
loadingMore: false,
}));
} catch (error) {
const err = error as Error;
setState((prev) => ({ ...prev, loadingMore: false, error: err.message || t('galleryPublic.loadError') }));
}
}, [state.cursor, state.loadingMore, t, token]);
React.useEffect(() => {
if (!state.cursor || !sentinelRef.current) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) loadMore();
}, { rootMargin: '400px', threshold: 0 });
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [state.cursor, loadMore]);
const content = (
<StandaloneShell>
<SurfaceCard glow>
<XStack alignItems="center" gap="$2">
<ImageIcon size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('galleryPublic.title')}
</Text>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
</Text>
</SurfaceCard>
{state.loading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('galleryPublic.loading')}
</Text>
</SurfaceCard>
) : state.expired ? (
<SurfaceCard>
<Text fontSize="$4" fontWeight="$7">
{t('galleryPublic.expiredTitle')}
</Text>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('galleryPublic.expiredDescription')}
</Text>
</SurfaceCard>
) : state.error ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('galleryPublic.loadError')}
</Text>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{state.error}
</Text>
</SurfaceCard>
) : state.photos.length === 0 ? (
<SurfaceCard>
<Text fontSize="$4" fontWeight="$7">
{t('galleryPublic.emptyTitle')}
</Text>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('galleryPublic.emptyDescription')}
</Text>
</SurfaceCard>
) : (
<YStack gap="$3">
<XStack gap="$3">
<YStack flex={1} gap="$3">
{state.photos.filter((_, index) => index % 2 === 0).map((photo, index) => (
<Button key={photo.id} unstyled onPress={() => setSelected(photo)}>
<SurfaceCard padding={0} overflow="hidden" height={150 + (index % 3) * 32}>
<YStack
flex={1}
style={{
backgroundImage: `url(${photo.thumbnail_url ?? photo.full_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</SurfaceCard>
</Button>
))}
</YStack>
<YStack flex={1} gap="$3">
{state.photos.filter((_, index) => index % 2 === 1).map((photo, index) => (
<Button key={photo.id} unstyled onPress={() => setSelected(photo)}>
<SurfaceCard padding={0} overflow="hidden" height={140 + (index % 3) * 28}>
<YStack
flex={1}
style={{
backgroundImage: `url(${photo.thumbnail_url ?? photo.full_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</SurfaceCard>
</Button>
))}
</YStack>
</XStack>
{state.loadingMore ? (
<SurfaceCard>
<Text fontSize="$2" color={mutedText}>
{t('galleryPublic.loadingMore')}
</Text>
</SurfaceCard>
) : null}
<div ref={sentinelRef} />
</YStack>
)}
<Sheet
open={Boolean(selected)}
onOpenChange={(next) => {
if (!next) setSelected(null);
}}
snapPoints={[92]}
position={selected ? 0 : -1}
modal
>
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.3)' } as any)} />
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
{selected ? (
<YStack gap="$4">
<YStack
height={360}
borderRadius="$card"
backgroundColor="$muted"
overflow="hidden"
alignItems="center"
justifyContent="center"
>
<img
src={selected.full_url ?? selected.thumbnail_url ?? ''}
alt={selected.guest_name ?? t('galleryPublic.lightboxGuestFallback', 'Guest')}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</YStack>
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
<Text fontSize="$3" fontWeight="$7">
{selected.likes_count ? `${selected.likes_count}` : ''}
</Text>
<XStack gap="$2">
{(state.meta?.event?.guest_downloads_enabled ?? true) && selected.download_url ? (
<Button size="$3" borderRadius="$pill" backgroundColor="$primary" asChild>
<a href={selected.download_url} target="_blank" rel="noopener noreferrer">
<Download size={16} color="white" />
<Text fontSize="$2" fontWeight="$7" color="white">
{t('galleryPublic.download')}
</Text>
</a>
</Button>
) : null}
{(state.meta?.event?.guest_sharing_enabled ?? true) ? (
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={async () => {
if (!token || !selected) return;
setShareLoading(true);
try {
const payload = await createPhotoShareLink(token, selected.id);
const shareData: ShareData = {
title: selected.guest_name ?? t('share.title', 'Geteiltes Foto'),
text: t('share.shareText', 'Check out this moment on Fotospiel.'),
url: payload.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
await navigator.share(shareData).catch(() => undefined);
} else if (payload.url) {
await navigator.clipboard.writeText(payload.url);
}
} finally {
setShareLoading(false);
}
}}
disabled={shareLoading}
>
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{shareLoading ? t('common.actions.loading', 'Loading...') : t('share.shareCta', 'Teilen')}
</Text>
</Button>
) : null}
</XStack>
</XStack>
</YStack>
) : null}
</Sheet.Frame>
</Sheet>
</StandaloneShell>
);
if (!branding) {
return content;
}
return (
<EventBrandingProvider branding={branding}>
<BrandingTheme>{content}</BrandingTheme>
</EventBrandingProvider>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import AppShell from '../components/AppShell';
import SettingsContent from '../components/SettingsContent';
export default function SettingsScreen() {
return (
<AppShell>
<SettingsContent />
</AppShell>
);
}

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Share2, QrCode, Link, Users } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { buildEventShareLink } from '../services/eventLink';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/guest/i18n/useTranslation';
export default function ShareScreen() {
const { event, token } = useEventData();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle');
const shareUrl = buildEventShareLink(event, token);
const qrUrl = shareUrl
? `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(shareUrl)}`
: '';
const handleCopy = React.useCallback(async () => {
if (!shareUrl) {
return;
}
try {
await navigator.clipboard?.writeText(shareUrl);
setCopyState('copied');
} catch (error) {
console.error('Copy failed', error);
setCopyState('failed');
} finally {
window.setTimeout(() => setCopyState('idle'), 2000);
}
}, [shareUrl]);
const handleShare = React.useCallback(async () => {
if (!shareUrl) return;
const title = event?.name ?? t('share.defaultEvent', 'Fotospiel');
const data: ShareData = { title, text: title, url: shareUrl };
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
try {
await navigator.share(data);
} catch (error) {
// user dismissed
}
} else {
await handleCopy();
}
}, [event?.name, handleCopy, shareUrl]);
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" gap="$2">
<Share2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('share.invite.title', 'Invite guests')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('share.invite.description', 'Share the event link or show the QR code to join.')}
</Text>
</YStack>
<XStack gap="$3">
<YStack
flex={1}
height={180}
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 30% 30%, rgba(255, 79, 216, 0.2), transparent 55%)'
: 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), transparent 60%)',
}}
>
{qrUrl ? (
<img
src={qrUrl}
alt={t('share.invite.qrAlt', 'Event QR code')}
style={{ width: 120, height: 120, borderRadius: 16 }}
/>
) : (
<QrCode size={28} color={isDark ? '#F8FAFF' : '#0F172A'} />
)}
<Text fontSize="$3" fontWeight="$7">
{t('share.invite.qrLabel', 'Show QR')}
</Text>
</YStack>
<YStack
flex={1}
height={180}
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: isDark ? '0 16px 30px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)',
}}
>
<Link size={24} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$3" fontWeight="$7">
{copyState === 'copied'
? t('share.copySuccess', 'Copied')
: copyState === 'failed'
? t('share.copyError', 'Copy failed')
: t('share.invite.copyLabel', 'Copy link')}
</Text>
<Button size="$2" backgroundColor="$primary" borderRadius="$pill" onPress={handleCopy} disabled={!shareUrl}>
{t('share.copyLink', 'Copy link')}
</Button>
</YStack>
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(79, 209, 255, 0.12), rgba(255, 79, 216, 0.18))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-secondary, #F43F5E) 8%, white), color-mix(in oklab, var(--guest-primary, #FF5A5F) 12%, white))',
boxShadow: isDark ? '0 22px 44px rgba(2, 6, 23, 0.45)' : '0 18px 32px rgba(15, 23, 42, 0.12)',
}}
>
<XStack alignItems="center" gap="$2">
<Users size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('share.invite.guestsTitle', 'Guests joined')}
</Text>
</XStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{event?.name
? t('share.invite.guestsSubtitleEvent', 'Share {event} with your guests.', { event: event.name })
: t('share.invite.guestsSubtitle', 'Share the event with your guests.')}
</Text>
<Button size="$3" backgroundColor="$primary" borderRadius="$pill" alignSelf="flex-start" onPress={handleShare}>
{t('share.invite.send', 'Send invite')}
</Button>
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,183 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { AlertCircle, Download } from 'lucide-react';
import StandaloneShell from '../components/StandaloneShell';
import SurfaceCard from '../components/SurfaceCard';
import { fetchPhotoShare } from '@/guest/services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
interface ShareResponse {
slug: string;
expires_at?: string;
photo: {
id: number;
title?: string;
likes_count?: number;
emotion?: { name?: string; emoji?: string | null } | null;
created_at?: string | null;
image_urls: { full: string; thumbnail: string };
};
event?: { id: number; name?: string | null } | null;
}
export default function SharedPhotoScreen() {
const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [state, setState] = React.useState<{ loading: boolean; error: string | null; data: ShareResponse | null }>({
loading: true,
error: null,
data: null,
});
React.useEffect(() => {
let active = true;
if (!slug) return;
setState({ loading: true, error: null, data: null });
fetchPhotoShare(slug)
.then((data) => {
if (active) setState({ loading: false, error: null, data });
})
.catch(() => {
if (active) {
setState({
loading: false,
error: t('share.expiredDescription', 'Dieser Link ist nicht mehr verfügbar.'),
data: null,
});
}
});
return () => {
active = false;
};
}, [slug]);
if (state.loading) {
return (
<StandaloneShell>
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('share.loading', 'Moment wird geladen …')}
</Text>
</SurfaceCard>
</StandaloneShell>
);
}
if (state.error || !state.data) {
return (
<StandaloneShell>
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<AlertCircle size={18} color={isDark ? '#FCA5A5' : '#B91C1C'} />
<Text fontSize="$4" fontWeight="$7">
{t('share.expiredTitle', 'Link abgelaufen')}
</Text>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}
</Text>
</SurfaceCard>
</StandaloneShell>
);
}
const { data } = state;
const chips = buildChips(data, t);
return (
<StandaloneShell>
<SurfaceCard glow>
<Text fontSize="$2" letterSpacing={2} textTransform="uppercase" color={mutedText}>
{t('share.title', 'Geteiltes Foto')}
</Text>
<Text fontSize="$6" fontWeight="$8" marginTop="$2">
{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}
</Text>
{data.photo.title ? (
<Text fontSize="$3" color={mutedText} marginTop="$1">
{data.photo.title}
</Text>
) : null}
</SurfaceCard>
<SurfaceCard padding={0} overflow="hidden">
<YStack
height={360}
style={{
backgroundImage: `url(${data.photo.image_urls.full})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</SurfaceCard>
{chips.length > 0 ? (
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
{chips.map((chip) => (
<SurfaceCard key={chip.id} padding="$2" borderRadius="$pill">
<XStack alignItems="center" gap="$2">
{chip.icon ? <Text fontSize="$3">{chip.icon}</Text> : null}
<Text fontSize="$2" color={mutedText}>
{chip.label}
</Text>
<Text fontSize="$2" fontWeight="$7">
{chip.value}
</Text>
</XStack>
</SurfaceCard>
))}
</XStack>
) : null}
<Button
size="$4"
borderRadius="$pill"
backgroundColor="$primary"
onPress={() => window.open(data.photo.image_urls.full, '_blank')}
>
<Download size={18} color="white" />
<Text fontSize="$3" fontWeight="$7" color="white">
{t('galleryPublic.download', 'Download')}
</Text>
</Button>
</StandaloneShell>
);
}
function buildChips(
data: ShareResponse,
t: (key: string, fallback?: string) => string
): { id: string; label: string; value: string; icon?: string }[] {
const list: { id: string; label: string; value: string; icon?: string }[] = [];
if (data.photo.emotion?.name) {
list.push({
id: 'emotion',
label: t('share.chips.emotion', 'Emotion'),
value: data.photo.emotion.name,
icon: data.photo.emotion.emoji ?? '★',
});
}
if (data.photo.title) {
list.push({ id: 'task', label: t('share.chips.task', 'Aufgabe'), value: data.photo.title });
}
if (data.photo.created_at) {
const date = formatDate(data.photo.created_at);
list.push({ id: 'date', label: t('share.chips.date', 'Aufgenommen'), value: date });
}
return list;
}
function formatDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lucide-react';
import { useEventData } from '../context/EventDataContext';
import { fetchGallery, type GalleryPhoto } from '../services/photosApi';
import { useTranslation } from '@/guest/i18n/useTranslation';
function normalizeImageUrl(src?: string | null) {
if (!src) return '';
if (/^https?:/i.test(src)) return src;
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) cleanPath = `storage/${cleanPath}`;
return `/${cleanPath}`.replace(/\/+/g, '/');
}
export default function SlideshowScreen() {
const { token, event } = useEventData();
const { t } = useTranslation();
const [photos, setPhotos] = React.useState<GalleryPhoto[]>([]);
const [index, setIndex] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const intervalRef = React.useRef<number | null>(null);
React.useEffect(() => {
document.body.classList.add('guest-immersive');
return () => {
document.body.classList.remove('guest-immersive');
};
}, []);
React.useEffect(() => {
if (!token) return;
let active = true;
fetchGallery(token, { limit: 50 })
.then((response) => {
if (!active) return;
setPhotos(response.data ?? []);
setIndex(0);
})
.catch(() => {
if (active) setPhotos([]);
});
return () => {
active = false;
};
}, [token]);
React.useEffect(() => {
if (paused || photos.length <= 1) {
if (intervalRef.current) window.clearInterval(intervalRef.current);
return;
}
intervalRef.current = window.setInterval(() => {
setIndex((prev) => (prev + 1) % photos.length);
}, 5000);
return () => {
if (intervalRef.current) window.clearInterval(intervalRef.current);
};
}, [paused, photos.length]);
React.useEffect(() => {
const handleFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', handleFullscreen);
handleFullscreen();
return () => document.removeEventListener('fullscreenchange', handleFullscreen);
}, []);
const current = photos[index] as Record<string, unknown> | undefined;
const imageUrl = normalizeImageUrl(
(current?.full_url as string | undefined)
?? (current?.thumbnail_url as string | undefined)
?? (current?.thumbnail_path as string | undefined)
?? (current?.file_path as string | undefined)
?? (current?.url as string | undefined)
?? (current?.image_url as string | undefined)
);
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
console.warn('Fullscreen toggle failed', error);
}
};
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-4 text-sm">
<span className="font-semibold text-white" style={{ fontFamily: 'var(--guest-heading-font)' }}>
{event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
{t('galleryPage.title', 'Gallery')}
</span>
</div>
{imageUrl ? (
<motion.div
key={`bg-${imageUrl}`}
className="pointer-events-none absolute inset-0 z-0"
initial={{ opacity: 0 }}
animate={{ opacity: 0.35 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8 }}
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(30px)',
transform: 'scale(1.08)',
}}
/>
) : null}
<AnimatePresence mode="wait">
<motion.div
key={imageUrl || index}
className="absolute inset-0 z-10"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.6 }}
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: imageUrl ? 'drop-shadow(0 20px 40px rgba(0,0,0,0.6))' : undefined,
}}
/>
</AnimatePresence>
{photos.length === 0 ? (
<div className="z-10 max-w-md space-y-2 px-6 text-center text-white/80">
<p className="text-lg font-semibold text-white">{t('galleryPublic.emptyTitle', 'Noch keine Fotos')}</p>
<p className="text-sm text-white/70">{t('galleryPublic.emptyDescription', 'Sobald Fotos freigegeben sind, erscheinen sie hier.')}</p>
</div>
) : null}
<div className="absolute bottom-6 left-1/2 z-20 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-black/60 px-4 py-2 text-xs text-white/80 shadow-lg backdrop-blur">
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={() => setPaused((prev) => !prev)}
>
{paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
<span>{paused ? t('liveShowPlayer.controls.play', 'Play') : t('liveShowPlayer.controls.pause', 'Pause')}</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={() => {
if (!photos.length) return;
setIndex((prev) => (prev - 1 + photos.length) % photos.length);
}}
disabled={photos.length <= 1}
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={() => {
if (!photos.length) return;
setIndex((prev) => (prev + 1) % photos.length);
}}
disabled={photos.length <= 1}
>
<ChevronRight className="h-4 w-4" />
</button>
<button
type="button"
className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,237 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sparkles, ChevronLeft, Camera } from 'lucide-react';
import AppShell from '../components/AppShell';
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 { useAppearance } from '@/hooks/use-appearance';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const candidate = Object.values(obj).find((item) => typeof item === 'string' && item.trim() !== '');
if (typeof candidate === 'string') return candidate;
}
return undefined;
}
function getTaskList(task: TaskItem, key: string): string[] {
const value = task?.[key as keyof TaskItem];
if (Array.isArray(value)) {
return value.filter((item) => typeof item === 'string' && item.trim() !== '') as string[];
}
if (typeof value === 'string' && value.trim() !== '') {
return [value];
}
return [];
}
export default function TaskDetailScreen() {
const { token } = useEventData();
const { taskId } = useParams<{ taskId: string }>();
const { t, locale } = useTranslation();
const navigate = useNavigate();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [task, setTask] = React.useState<TaskItem | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
if (!token || !taskId) {
setLoading(false);
setTask(null);
return;
}
setLoading(true);
setError(null);
fetchTasks(token, { locale })
.then((tasks) => {
if (!active) return;
const match = tasks.find((item) => {
const id = item?.id ?? item?.task_id ?? item?.slug;
return String(id) === String(taskId);
}) ?? null;
setTask(match);
setLoading(false);
})
.catch((err) => {
if (!active) return;
setError(err instanceof Error ? err.message : t('tasks.error', 'Tasks could not be loaded.'));
setLoading(false);
});
return () => {
active = false;
};
}, [locale, taskId, t, token]);
const title = task ? (getTaskValue(task, 'title') ?? getTaskValue(task, 'name') ?? 'Task') : 'Task';
const description = task ? (getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt') ?? '') : '';
const tips = task ? getTaskList(task, 'tips') : [];
const steps = task ? getTaskList(task, 'steps') : [];
const inspiration = task ? getTaskList(task, 'inspiration') : [];
const duration = task ? (task.duration ?? task.time_limit ?? null) : null;
const groupSize = task ? (task.group_size ?? task.groupSize ?? null) : null;
return (
<AppShell>
<YStack gap="$4">
<SurfaceCard glow>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Sparkles size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('tasks.page.title', 'Your next task')}
</Text>
</XStack>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={() => navigate(-1)}
>
<ChevronLeft size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('tasks.page.subtitle', 'Choose a mood or get surprised.')}
</Text>
</SurfaceCard>
{loading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('tasks.loading', 'Loading tasks...')}
</Text>
</SurfaceCard>
) : error ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.emptyTitle', 'No matching task found')}
</Text>
<Text fontSize="$2" color={mutedText}>
{error}
</Text>
</SurfaceCard>
) : task ? (
<>
<SurfaceCard>
<Text fontSize="$5" fontWeight="$8">
{title}
</Text>
{description ? (
<Text fontSize="$3" color={mutedText} marginTop="$2">
{description}
</Text>
) : null}
<XStack gap="$2" flexWrap="wrap" marginTop="$3">
{duration ? (
<Button size="$3" borderRadius="$pill" backgroundColor={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.06)'}>
<Text fontSize="$2" fontWeight="$6">{String(duration)} min</Text>
</Button>
) : null}
{groupSize ? (
<Button size="$3" borderRadius="$pill" backgroundColor={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.06)'}>
<Text fontSize="$2" fontWeight="$6">{String(groupSize)} guests</Text>
</Button>
) : null}
</XStack>
<Button
size="$4"
borderRadius="$pill"
backgroundColor="$primary"
marginTop="$4"
width="100%"
justifyContent="center"
onPress={() => {
if (!taskId) {
navigate(buildEventPath(token, '/upload'));
return;
}
const target = buildEventPath(token, `/upload?taskId=${encodeURIComponent(taskId)}`);
navigate(target);
}}
>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<Camera size={18} color="white" />
<Text fontSize="$3" fontWeight="$7" color="white">
{t('tasks.page.ctaStart', "Let's go!")}
</Text>
</XStack>
</Button>
</SurfaceCard>
{steps.length > 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.checklist', 'Checklist')}
</Text>
<YStack gap="$2" marginTop="$2">
{steps.map((step, index) => (
<XStack key={`${step}-${index}`} alignItems="flex-start" gap="$2">
<Text fontSize="$2" color={mutedText}>
{index + 1}.
</Text>
<Text fontSize="$3">{step}</Text>
</XStack>
))}
</YStack>
</SurfaceCard>
) : null}
{tips.length > 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.tips', 'Tips')}
</Text>
<YStack gap="$2" marginTop="$2">
{tips.map((tip, index) => (
<XStack key={`${tip}-${index}`} alignItems="flex-start" gap="$2">
<Text fontSize="$2" color={mutedText}>
</Text>
<Text fontSize="$3">{tip}</Text>
</XStack>
))}
</YStack>
</SurfaceCard>
) : null}
{inspiration.length > 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.inspirationTitleSecondary', 'Inspiration')}
</Text>
<YStack gap="$2" marginTop="$2">
{inspiration.map((item, index) => (
<Text key={`${item}-${index}`} fontSize="$3" color={mutedText}>
{item}
</Text>
))}
</YStack>
</SurfaceCard>
) : null}
</>
) : (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('tasks.page.emptyTitle', 'No matching task found')}
</Text>
</SurfaceCard>
)}
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sparkles, Trophy, Play } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { fetchTasks } from '../services/tasksApi';
import { fetchEmotions } from '../services/emotionsApi';
import { useAppearance } from '@/hooks/use-appearance';
import { useNavigate } from 'react-router-dom';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
type TaskItem = {
id: number;
title: string;
points?: number;
description?: string | null;
emotion?: string | null;
};
export default function TasksScreen() {
const { tasksEnabled, token } = useEventData();
const { t } = useTranslation();
const { locale } = useLocale();
const navigate = useNavigate();
const { completedCount } = useGuestTaskProgress(token ?? undefined);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)';
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 [tasks, setTasks] = React.useState<TaskItem[]>([]);
const [highlight, setHighlight] = React.useState<TaskItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [emotions, setEmotions] = React.useState<Record<string, string>>({});
const progressTotal = tasks.length || 1;
const progressPercent = Math.min(100, Math.round((completedCount / progressTotal) * 100));
React.useEffect(() => {
if (!token) {
setTasks([]);
setHighlight(null);
return;
}
let active = true;
setLoading(true);
setError(null);
Promise.all([
fetchTasks(token, { locale, perPage: 12 }),
fetchEmotions(token, locale),
])
.then(([taskList, emotionList]) => {
if (!active) return;
const emotionMap: Record<string, string> = {};
for (const emotion of emotionList) {
const record = emotion as Record<string, unknown>;
const slug = typeof record.slug === 'string' ? record.slug : '';
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
if (slug) {
emotionMap[slug] = title || slug;
}
}
setEmotions(emotionMap);
const mapped = taskList
.map((task) => {
const record = task as Record<string, unknown>;
const id = Number(record.id ?? 0);
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
if (!id || !title) return null;
return {
id,
title,
points: typeof record.points === 'number' ? record.points : undefined,
description: typeof record.description === 'string' ? record.description : null,
emotion: typeof record.emotion_slug === 'string' ? record.emotion_slug : null,
} satisfies TaskItem;
})
.filter(Boolean) as TaskItem[];
setTasks(mapped);
setHighlight(mapped[0] ?? null);
})
.catch((err) => {
console.error('Failed to load tasks', err);
if (active) {
setError(t('tasks.error', 'Tasks could not be loaded.'));
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [token, locale, t]);
if (!tasksEnabled) {
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
>
<Text fontSize="$4" fontWeight="$7">
{t('tasks.disabled.title', 'Tasks are disabled')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.disabled.subtitle', 'This event is set to photo-only mode.')}
</Text>
</YStack>
</YStack>
</AppShell>
);
}
return (
<AppShell>
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.22), rgba(79, 209, 255, 0.1))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
boxShadow: isDark ? '0 22px 46px rgba(2, 6, 23, 0.45)' : '0 18px 32px rgba(15, 23, 42, 0.12)',
}}
>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color="#FF4FD8" />
<Text fontSize="$4" fontWeight="$7">
Prompt quest
</Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{highlight?.title ?? t('tasks.loading', 'Loading tasks...')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{highlight?.description ?? t('tasks.subtitle', 'Complete this quest to unlock new prompts.')}
</Text>
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
{t('tasks.page.progressLabel', 'Quest progress')}
</Text>
<Text fontSize="$2" fontWeight="$7">
{highlight ? `${progressPercent}%` : '--'}
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width={highlight ? `${progressPercent}%` : '20%'} height={10} />
</YStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
</Text>
</YStack>
<Button
size="$4"
backgroundColor="$primary"
borderRadius="$pill"
disabled={!highlight || loading}
onPress={() => {
if (highlight) navigate(`./${highlight.id}`);
}}
>
{loading ? t('tasks.loading', 'Loading tasks...') : t('tasks.start', 'Start quest')}
</Button>
</YStack>
{error ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="rgba(248, 113, 113, 0.12)"
borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)"
>
<Text fontSize="$2" color="#FEE2E2">
{error}
</Text>
</YStack>
) : null}
<YStack gap="$3">
{tasks.length === 0 && loading ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.loading', 'Loading tasks...')}
</Text>
</YStack>
) : null}
{tasks.map((task) => (
<YStack
key={task.id}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1" flex={1}>
<Text fontSize="$4" fontWeight="$7">
{task.title}
</Text>
{task.emotion ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{emotions[task.emotion] ?? task.emotion}
</Text>
) : null}
</YStack>
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#FDE047" />
<Text fontSize="$2" fontWeight="$7">
+{task.points ?? 0}
</Text>
</XStack>
</XStack>
<Button
size="$3"
backgroundColor={mutedButton}
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(`./${task.id}`)}
>
<XStack alignItems="center" gap="$2">
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$7">
{t('tasks.startTask', 'Start task')}
</Text>
</XStack>
</Button>
</YStack>
))}
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,307 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
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 { useAppearance } from '@/hooks/use-appearance';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useEventData } from '../context/EventDataContext';
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
type ProgressMap = Record<number, number>;
export default function UploadQueueScreen() {
const { t } = useTranslation();
const { locale } = useLocale();
const { token } = useEventData();
const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue();
const [progress, setProgress] = React.useState<ProgressMap>({});
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)';
const [pending, setPending] = React.useState<PendingUpload[]>([]);
const [pendingLoading, setPendingLoading] = React.useState(false);
const [pendingError, setPendingError] = React.useState<string | null>(null);
const formatter = React.useMemo(
() => new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
[locale]
);
const formatTimestamp = React.useCallback(
(value?: string | null) => {
if (!value) {
return t('pendingUploads.card.justNow', 'Just now');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return t('pendingUploads.card.justNow', 'Just now');
}
return formatter.format(date);
},
[formatter, t]
);
const loadPendingUploads = React.useCallback(async () => {
if (!token) {
setPending([]);
return;
}
try {
setPendingLoading(true);
setPendingError(null);
const result = await fetchPendingUploadsSummary(token, 12);
setPending(result.items);
} catch (err) {
console.error('Pending uploads load failed', err);
setPendingError(t('pendingUploads.error', 'Failed to load uploads. Please try again.'));
} finally {
setPendingLoading(false);
}
}, [t, token]);
React.useEffect(() => {
void loadPendingUploads();
}, [loadPendingUploads]);
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ id?: number; progress?: number }>).detail;
if (!detail?.id || typeof detail.progress !== 'number') return;
setProgress((prev) => ({ ...prev, [detail.id!]: detail.progress! }));
};
window.addEventListener('queue-progress', handler as EventListener);
return () => window.removeEventListener('queue-progress', handler as EventListener);
}, []);
const activeCount = items.filter((item) => item.status !== 'done').length;
const failedCount = items.filter((item) => item.status === 'error').length;
return (
<AppShell>
<YStack gap="$4">
<SurfaceCard glow>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<UploadCloud size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7">
{t('uploadQueue.title', 'Uploads')}
</Text>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('uploadQueue.description', 'Keep track of queued uploads and retries.')}
</Text>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={refresh}
>
<RefreshCcw size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t(
'uploadQueue.summary',
{ waiting: activeCount, failed: failedCount },
'{waiting} waiting · {failed} failed'
)}
</Text>
</SurfaceCard>
<SurfaceCard>
<XStack gap="$2" flexWrap="wrap">
<Button size="$3" borderRadius="$pill" backgroundColor="$primary" onPress={retryAll} flex={1} minWidth={140}>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<RefreshCcw size={16} color="white" />
<Text fontSize="$2" fontWeight="$6" color="white">
{t('uploadQueue.actions.retryAll', 'Retry all')}
</Text>
</XStack>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={clearFinished}
flex={1}
minWidth={140}
>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<Trash2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadQueue.actions.clearFinished', 'Clear finished')}
</Text>
</XStack>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
onPress={loadPendingUploads}
flex={1}
minWidth={160}
>
<XStack alignItems="center" justifyContent="center" gap="$2" width="100%">
<RefreshCcw size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$2" fontWeight="$6">
{t('pendingUploads.refresh', 'Refresh')}
</Text>
</XStack>
</Button>
</XStack>
</SurfaceCard>
<YStack gap="$3">
{loading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('common.actions.loading', 'Loading...')}
</Text>
</SurfaceCard>
) : items.length === 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('uploadQueue.emptyTitle', 'No queued uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('uploadQueue.emptyDescription', 'Once photos are queued, they will appear here.')}
</Text>
</SurfaceCard>
) : (
items
.slice()
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
.map((item) => {
const pct = typeof item.id === 'number' ? progress[item.id] : undefined;
return (
<SurfaceCard key={item.id ?? `${item.fileName}-${item.createdAt}`} padding="$3">
<XStack alignItems="center" justifyContent="space-between" gap="$3">
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="$7" numberOfLines={1}>
{item.fileName}
</Text>
<Text fontSize="$2" color={mutedText}>
{item.status === 'done'
? t('uploadQueue.status.uploaded', 'Uploaded')
: item.status === 'uploading'
? t('uploadQueue.status.uploading', 'Uploading')
: item.status === 'error'
? t('uploadQueue.status.failed', 'Failed')
: t('uploadQueue.status.waiting', 'Waiting')}
{item.retries
? t('uploadQueue.status.retries', { count: item.retries }, ' · {count} retries')
: ''}
</Text>
</YStack>
<YStack minWidth={72} alignItems="flex-end">
<Text fontSize="$2" color={mutedText}>
{pct !== undefined
? t('uploadQueue.progress', { progress: pct }, '{progress}%')
: item.status === 'done'
? t('uploadQueue.progress', { progress: 100 }, '{progress}%')
: ''}
</Text>
</YStack>
</XStack>
<YStack
height={6}
borderRadius={999}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
overflow="hidden"
marginTop="$2"
>
<YStack
height="100%"
width={`${pct ?? (item.status === 'done' ? 100 : 0)}%`}
backgroundColor="$primary"
/>
</YStack>
</SurfaceCard>
);
})
)}
</YStack>
<SurfaceCard>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7">
{t('pendingUploads.title', 'Pending uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.subtitle', 'Your photos are waiting for approval.')}
</Text>
</YStack>
</SurfaceCard>
<YStack gap="$3">
{pendingLoading ? (
<SurfaceCard>
<Text fontSize="$3" color={mutedText}>
{t('pendingUploads.loading', 'Loading uploads...')}
</Text>
</SurfaceCard>
) : pendingError ? (
<SurfaceCard>
<Text fontSize="$3" color="#FCA5A5">
{pendingError}
</Text>
</SurfaceCard>
) : pending.length === 0 ? (
<SurfaceCard>
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.emptyTitle', 'No pending uploads')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.emptyBody', 'Once you upload a photo, it will appear here until it is approved.')}
</Text>
</SurfaceCard>
) : (
pending.map((photo) => (
<SurfaceCard key={photo.id} padding="$3">
<XStack alignItems="center" gap="$3">
<YStack
width={72}
height={72}
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
overflow="hidden"
alignItems="center"
justifyContent="center"
>
{photo.thumbnail_url ? (
<img src={photo.thumbnail_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<UploadCloud size={20} color={isDark ? '#F8FAFF' : '#0F172A'} />
)}
</YStack>
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.card.pending', 'Waiting for approval')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('pendingUploads.card.uploadedAt', 'Uploaded {time}').replace('{time}', formatTimestamp(photo.created_at))}
</Text>
</YStack>
</XStack>
</SurfaceCard>
))
)}
</YStack>
</YStack>
</AppShell>
);
}

View File

@@ -0,0 +1,742 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, FlipHorizontal, Image, ListVideo, RefreshCcw, Sparkles, UploadCloud, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import { useEventData } from '../context/EventDataContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { uploadPhoto, useUploadQueue } from '../services/uploadApi';
import { useAppearance } from '@/hooks/use-appearance';
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 { fetchTasks, type TaskItem } from '../services/tasksApi';
import SurfaceCard from '../components/SurfaceCard';
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const candidate = Object.values(obj).find((item) => typeof item === 'string' && item.trim() !== '');
if (typeof candidate === 'string') return candidate;
}
return undefined;
}
export default function UploadScreen() {
const { token, event } = useEventData();
const identity = useOptionalGuestIdentity();
const { items, add } = useUploadQueue();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, locale } = useTranslation();
const { markCompleted } = useGuestTaskProgress(token ?? undefined);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [uploadDialog, setUploadDialog] = React.useState<UploadErrorDialog | null>(null);
const [cameraState, setCameraState] = React.useState<'idle' | 'starting' | 'ready' | 'denied' | 'blocked' | 'unsupported' | 'error' | 'preview'>('idle');
const [facingMode, setFacingMode] = React.useState<'user' | 'environment'>('environment');
const [mirror, setMirror] = React.useState(true);
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
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 autoApprove = event?.guest_upload_visibility === 'immediate';
const isExpanded = cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview';
const queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').length;
const taskIdParam = searchParams.get('taskId');
const parsedTaskId = taskIdParam ? Number(taskIdParam) : NaN;
const taskId = Number.isFinite(parsedTaskId) ? parsedTaskId : undefined;
const [task, setTask] = React.useState<TaskItem | null>(null);
const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null);
const [pendingItems, setPendingItems] = React.useState<PendingUpload[]>([]);
const [pendingCount, setPendingCount] = React.useState(0);
const [pendingLoading, setPendingLoading] = React.useState(false);
const previewQueueItems = React.useMemo(() => items.filter((item) => item.status !== 'done').slice(0, 3), [items]);
const previewQueueUrls = React.useMemo(() => {
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
return [];
}
return previewQueueItems.map((item) => ({
key: item.id ?? item.fileName,
url: URL.createObjectURL(item.blob),
}));
}, [previewQueueItems]);
React.useEffect(() => {
return () => {
previewQueueUrls.forEach((item) => URL.revokeObjectURL(item.url));
};
}, [previewQueueUrls]);
const loadPending = React.useCallback(async () => {
if (!token) {
setPendingItems([]);
setPendingCount(0);
return;
}
setPendingLoading(true);
try {
const result = await fetchPendingUploadsSummary(token, 3);
setPendingItems(result.items);
setPendingCount(result.totalCount);
} catch (err) {
console.error('Pending uploads load failed', err);
setPendingItems([]);
setPendingCount(0);
} finally {
setPendingLoading(false);
}
}, [token]);
React.useEffect(() => {
void loadPending();
}, [loadPending]);
React.useEffect(() => {
let active = true;
if (!token || !taskId) {
setTask(null);
setTaskLoading(false);
setTaskError(null);
return;
}
setTaskLoading(true);
setTaskError(null);
fetchTasks(token, { locale })
.then((tasks) => {
if (!active) return;
const match = tasks.find((item) => {
const id = item?.id ?? item?.task_id ?? item?.slug;
return String(id) === String(taskId);
}) ?? null;
setTask(match);
setTaskLoading(false);
})
.catch((err) => {
if (!active) return;
setTaskError(err instanceof Error ? err.message : t('tasks.error', 'Tasks could not be loaded.'));
setTask(null);
setTaskLoading(false);
});
return () => {
active = false;
};
}, [locale, t, taskId, token]);
const enqueueFile = React.useCallback(
async (file: File) => {
if (!token) return;
await add({ eventToken: token, fileName: file.name, blob: file, task_id: taskId ?? null });
},
[add, taskId, token]
);
const triggerConfetti = React.useCallback(async () => {
if (typeof window === 'undefined') return;
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (prefersReducedMotion) return;
try {
const { default: confetti } = await import('canvas-confetti');
confetti({
particleCount: 70,
spread: 65,
origin: { x: 0.5, y: 0.35 },
ticks: 160,
scalar: 0.9,
});
} catch (error) {
console.warn('Confetti could not start', error);
}
}, []);
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
setError(null);
setUploadDialog(null);
for (const file of files) {
if (!navigator.onLine) {
await enqueueFile(file);
continue;
}
try {
setUploading({ name: file.name, progress: 0 });
await uploadPhoto(token, file, taskId, undefined, {
guestName: identity?.name ?? undefined,
onProgress: (percent) => {
setUploading((prev) => (prev ? { ...prev, progress: percent } : prev));
},
maxRetries: 1,
});
if (taskId) {
markCompleted(taskId);
}
if (autoApprove) {
void triggerConfetti();
}
void loadPending();
} catch (err) {
const uploadErr = err as { code?: string; meta?: Record<string, unknown> };
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(file);
} finally {
setUploading(null);
}
}
},
[autoApprove, enqueueFile, identity?.name, loadPending, markCompleted, t, taskId, token, triggerConfetti]
);
const handleFiles = React.useCallback(
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
await uploadFiles(files);
},
[uploadFiles]
);
const handlePick = React.useCallback(() => {
inputRef.current?.click();
}, []);
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setCameraState('idle');
}, []);
const startCamera = React.useCallback(
async (modeOverride?: 'user' | 'environment') => {
if (!navigator.mediaDevices?.getUserMedia) {
setCameraState('unsupported');
return;
}
const mode = modeOverride ?? facingMode;
setCameraState('starting');
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: mode },
audio: false,
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setFacingMode(mode);
setCameraState('ready');
} catch (err) {
const error = err as { name?: string };
if (error?.name === 'NotAllowedError') {
setCameraState('denied');
} else if (error?.name === 'SecurityError') {
setCameraState('blocked');
} else if (error?.name === 'NotFoundError') {
setCameraState('error');
} else {
setCameraState('error');
}
}
},
[facingMode]
);
const handleSwitchCamera = React.useCallback(async () => {
const nextMode = facingMode === 'user' ? 'environment' : 'user';
stopCamera();
await startCamera(nextMode);
}, [facingMode, startCamera, stopCamera]);
const handleCapture = React.useCallback(async () => {
const video = videoRef.current;
if (!video) return;
if (cameraState !== 'ready') {
await startCamera();
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
setError(t('upload.cameraError.explanation', 'Camera could not be started.'));
return;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (mirror && facingMode === 'user') {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
stopCamera();
setCameraState('preview');
}, [cameraState, facingMode, mirror, startCamera, stopCamera, t]);
const handleRetake = React.useCallback(async () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
await startCamera();
}, [previewUrl, startCamera]);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
await uploadFiles([previewFile]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]);
const handleAbortCamera = React.useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
stopCamera();
}, [previewUrl, stopCamera]);
React.useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
if (!token) {
return (
<AppShell>
<YStack padding="$4" borderRadius="$card" backgroundColor="$surface">
<Text fontSize="$4" fontWeight="$7">
{t('uploadV2.errors.eventMissing', 'Event not found')}
</Text>
</YStack>
</AppShell>
);
}
return (
<AppShell>
<YStack gap="$4">
{taskId ? (
<SurfaceCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={iconColor} />
<Text fontSize="$3" fontWeight="$7">
{t('tasks.page.title', 'Your next task')}
</Text>
</XStack>
{taskLoading ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{t('tasks.loading', 'Loading tasks...')}
</Text>
) : task ? (
<>
<Text fontSize="$4" fontWeight="$7" marginTop="$3">
{getTaskValue(task, 'title') ?? getTaskValue(task, 'name') ?? t('tasks.page.title', 'Task')}
</Text>
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt') ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{getTaskValue(task, 'description') ?? getTaskValue(task, 'prompt')}
</Text>
) : null}
</>
) : taskError ? (
<Text fontSize="$2" color="$color" opacity={0.7} marginTop="$2">
{taskError}
</Text>
) : null}
</SurfaceCard>
) : null}
<YStack
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(8, 12, 24, 0.9)), radial-gradient(circle at 20% 20%, rgba(255, 79, 216, 0.2), transparent 50%)'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 255, 0.82)), radial-gradient(circle at 20% 20%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), transparent 60%)',
boxShadow: isExpanded
? isDark
? '0 28px 60px rgba(2, 6, 23, 0.55)'
: '0 22px 44px rgba(15, 23, 42, 0.16)'
: isDark
? '0 22px 40px rgba(2, 6, 23, 0.5)'
: '0 18px 32px rgba(15, 23, 42, 0.12)',
borderRadius: isExpanded ? 28 : undefined,
transition: 'box-shadow 360ms ease, border-radius 360ms ease',
}}
>
<YStack
position="relative"
alignItems="center"
justifyContent="center"
style={{
height: isExpanded ? 'min(84vh, 720px)' : 320,
transition: 'height 360ms cubic-bezier(0.22, 0.61, 0.36, 1)',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt={t('upload.preview.imageAlt', 'Captured photo')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<video
ref={videoRef}
playsInline
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: cameraState === 'ready' ? 1 : 0,
transform: mirror && facingMode === 'user' ? 'scaleX(-1)' : 'none',
transition: 'opacity 200ms ease',
}}
/>
)}
{(cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview') ? (
<Button
size="$3"
circular
position="absolute"
top="$3"
right="$3"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={cardBorder}
onPress={handleAbortCamera}
aria-label={t('common.actions.close', 'Close')}
>
<X size={16} color={iconColor} />
</Button>
) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4">
<Camera size={32} color={isDark ? '#F8FAFF' : '#0F172A'} />
<Text fontSize="$4" fontWeight="$7" textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.title', 'Camera not available')
: cameraState === 'blocked'
? t('upload.cameraBlocked.title', 'Camera blocked')
: cameraState === 'denied'
? t('upload.cameraDenied.title', 'Camera access denied')
: t('upload.cameraTitle', 'Camera')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7} textAlign="center">
{cameraState === 'unsupported'
? t('upload.cameraUnsupported.message', 'Your device does not support live camera preview in this browser.')
: cameraState === 'blocked'
? t('upload.cameraBlocked.message', 'Camera access is blocked by the site security policy.')
: cameraState === 'denied'
? t('upload.cameraDenied.prompt', 'We need access to your camera. Allow the request or pick a photo from your gallery.')
: t('uploadV2.preview.subtitle', 'Stay in the flow keep the camera ready.')}
</Text>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={() => startCamera()}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
{t('upload.buttons.startCamera', 'Start camera')}
</Button>
</YStack>
) : null}
</YStack>
<XStack
gap="$2"
padding={isExpanded ? '$2' : '$3'}
alignItems="center"
justifyContent="space-between"
borderTopWidth={1}
borderColor={cardBorder}
backgroundColor={isDark ? 'rgba(10, 14, 28, 0.7)' : 'rgba(255, 255, 255, 0.75)'}
>
{cameraState === 'preview' ? (
<XStack gap="$2" flex={1} justifyContent="space-between">
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleRetake}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6">
{t('upload.review.retake', 'Retake')}
</Text>
</Button>
<Button
size="$3"
borderRadius="$pill"
backgroundColor="$primary"
onPress={handleUseImage}
flex={1}
justifyContent="center"
>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('upload.review.keep', 'Use image')}
</Text>
</Button>
</XStack>
) : (
<>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handlePick}
paddingHorizontal="$4"
gap="$2"
alignSelf="center"
flexShrink={0}
justifyContent="center"
>
<Image size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.galleryCta', 'Upload from gallery')}
</Text>
</Button>
<XStack gap="$2">
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={handleSwitchCamera}
disabled={cameraState === 'unsupported' || cameraState === 'blocked'}
>
<RefreshCcw size={16} color={iconColor} />
</Button>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('upload.controls.switchCamera', 'Switch camera')}
</Text>
</XStack>
{facingMode === 'user' ? (
<Button
size="$3"
circular
backgroundColor={mirror ? '$primary' : mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => setMirror((prev) => !prev)}
>
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
</Button>
) : null}
</XStack>
</>
)}
</XStack>
</YStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={cardBorder}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<Text fontSize="$4" fontWeight="$7">
{t('uploadQueue.title', 'Uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('uploadV2.queue.summary', { waiting: queueCount, sending: sendingCount }, '{waiting} waiting, {sending} sending')}
</Text>
{uploading ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{t(
'uploadV2.queue.uploading',
{ name: uploading.name, progress: uploading.progress },
'Uploading {name} · {progress}%'
)}
</Text>
) : null}
{uploadDialog ? (
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7" color={uploadDialog.tone === 'danger' ? '#FCA5A5' : '$color'}>
{uploadDialog.title}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{uploadDialog.description}
</Text>
{uploadDialog.hint ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{uploadDialog.hint}
</Text>
) : null}
</YStack>
) : null}
{error ? (
<Text fontSize="$2" color="#FCA5A5">
{error}
</Text>
) : null}
<XStack gap="$2">
{previewQueueUrls.length > 0 ? (
previewQueueUrls.map((item) => (
<YStack
key={item.key}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
>
<img src={item.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</YStack>
))
) : (
<YStack flex={1} height={72} borderRadius="$tile" backgroundColor="$muted" borderWidth={1} borderColor={cardBorder} />
)}
</XStack>
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
{t('pendingUploads.title', 'Pending uploads')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{pendingLoading
? t('pendingUploads.loading', 'Loading uploads...')
: t('pendingUploads.subtitle', 'Your photos are waiting for approval.')}
</Text>
{pendingItems.length > 0 ? (
<XStack gap="$2">
{pendingItems.map((photo) => (
<YStack
key={photo.id}
flex={1}
height={72}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor={cardBorder}
overflow="hidden"
alignItems="center"
justifyContent="center"
>
{photo.thumbnail_url ? (
<img src={photo.thumbnail_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<UploadCloud size={20} color={iconColor} />
)}
</YStack>
))}
</XStack>
) : (
<Text fontSize="$2" color="$color" opacity={0.6}>
{t('pendingUploads.emptyTitle', 'No pending uploads')}
</Text>
)}
</YStack>
<Button
size="$3"
borderRadius="$pill"
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate('../queue')}
alignSelf="flex-start"
width="auto"
paddingHorizontal="$3"
gap="$2"
justifyContent="flex-start"
>
<ListVideo size={16} color={iconColor} />
<Text fontSize="$2" fontWeight="$6">
{t('uploadV2.queue.button', 'Queue')}
</Text>
</Button>
</YStack>
</YStack>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={(event) => {
handleFiles(event.target.files);
if (event.target) {
event.target.value = '';
}
}}
/>
</AppShell>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, UploadCloud, Sparkles, Zap } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function Mockup01CaptureOrbit() {
return (
<MockupFrame
title="Mockup 01 - Capture Orbit"
subtitle="Full-screen capture hub with orbiting actions"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 50% 10%, rgba(248, 113, 113, 0.18), transparent 55%)',
}}
>
<MockupCard padding="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
Event energy
</Text>
<MockupLabel>87 moments today</MockupLabel>
</YStack>
<XStack alignItems="center" gap="$2">
<Zap size={16} color="#F97316" />
<Text fontSize="$3" fontWeight="$7">
Live
</Text>
</XStack>
</XStack>
</MockupCard>
<YStack alignItems="center" gap="$4">
<YStack
width={260}
height={260}
borderRadius={130}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
gap="$2"
style={{ boxShadow: '0 12px 40px rgba(15, 23, 42, 0.08)' }}
>
<Camera size={32} color="#0F172A" />
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
Tap to capture
</Text>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
Capture
</Button>
</YStack>
<XStack gap="$3">
{[
{ icon: <UploadCloud size={20} color="#0F172A" />, label: 'Upload' },
{ icon: <Sparkles size={20} color="#0F172A" />, label: 'Prompt' },
{ icon: <Camera size={20} color="#0F172A" />, label: 'Burst' },
].map((item) => (
<YStack
key={item.label}
width={72}
height={72}
borderRadius={36}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
gap="$1"
>
{item.icon}
<Text fontSize="$2" fontWeight="$6">
{item.label}
</Text>
</YStack>
))}
</XStack>
</YStack>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
Upload queue
</Text>
<MockupLabel>3 queued, 1 sending</MockupLabel>
</YStack>
<Button size="$3" backgroundColor="$surface" borderRadius="$pill" borderWidth={1} borderColor="$borderColor">
View
</Button>
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Filter, Image as ImageIcon } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupChip, MockupLabel, MockupTile } from './MockupPrimitives';
export default function Mockup02GalleryMosaic() {
return (
<MockupFrame
title="Mockup 02 - Gallery Mosaic"
subtitle="Image-first grid with fast filters"
backgroundStyle={{
backgroundImage: 'linear-gradient(160deg, rgba(248, 250, 252, 0.9), rgba(255, 245, 240, 0.9))',
}}
>
<MockupCard padding="$3">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<ImageIcon size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$8">
Gallery
</Text>
</XStack>
<Filter size={18} color="#0F172A" />
</XStack>
<XStack gap="$2" flexWrap="wrap">
{['All', 'Friends', 'Dancefloor', 'Candid'].map((chip) => (
<MockupChip key={chip}>
<Text fontSize="$2" fontWeight="$6">
{chip}
</Text>
</MockupChip>
))}
</XStack>
</MockupCard>
<XStack gap="$3">
<YStack flex={1} gap="$3">
<MockupTile height={160}>
<MockupLabel>Hero moment</MockupLabel>
</MockupTile>
<XStack gap="$3">
<MockupTile flex={1} height={90} />
<MockupTile flex={1} height={90} />
</XStack>
<MockupTile height={120} />
</YStack>
<YStack flex={1} gap="$3">
<XStack gap="$3">
<MockupTile flex={1} height={110} />
<MockupTile flex={1} height={110} />
</XStack>
<MockupTile height={180}>
<MockupLabel>Live spotlight</MockupLabel>
</MockupTile>
<MockupTile height={90} />
</YStack>
</XStack>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
Newest uploads
</Text>
<MockupLabel>Refreshes every 10s</MockupLabel>
</YStack>
<Text fontSize="$2" fontWeight="$7">
22 new
</Text>
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sparkles, Trophy, Play } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function Mockup03PromptQuest() {
return (
<MockupFrame
title="Mockup 03 - Prompt Quest"
subtitle="Prompt hero with progress and task ladder"
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color="#F43F5E" />
<Text fontSize="$3" fontWeight="$7">
Prompt of the hour
</Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
Capture the boldest dance move
</Text>
<MockupLabel>Earn 120 points for completing this prompt.</MockupLabel>
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
Quest progress
</Text>
<Text fontSize="$2" fontWeight="$7">
65%
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width="65%" height={10} />
</YStack>
</YStack>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
Start capture
</Button>
</MockupCard>
<YStack gap="$3">
{[1, 2, 3].map((item) => (
<MockupCard key={item} padding="$3">
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$4" fontWeight="$7">
Task {item}
</Text>
<MockupLabel>Quick challenge to spark the moment.</MockupLabel>
</YStack>
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#F59E0B" />
<Text fontSize="$2" fontWeight="$7">
+50
</Text>
</XStack>
</XStack>
<Button size="$3" backgroundColor="$surface" borderRadius="$pill" borderWidth={1} borderColor="$borderColor">
<XStack alignItems="center" gap="$2">
<Play size={14} color="#0F172A" />
<Text fontSize="$2" fontWeight="$7">
Play task
</Text>
</XStack>
</Button>
</MockupCard>
))}
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Clock, Image as ImageIcon } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
const timeline = [
{ time: '18:40', title: 'Sparkler exit', caption: '12 new uploads' },
{ time: '18:22', title: 'First dance', caption: '7 moments added' },
{ time: '18:10', title: 'Toast round', caption: '5 guest favorites' },
{ time: '17:55', title: 'Arrival glow', caption: '4 candid shots' },
];
export default function Mockup04TimelineStream() {
return (
<MockupFrame
title="Mockup 04 - Timeline Stream"
subtitle="Chronological feed with time markers"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(254, 252, 232, 0.9), rgba(255, 255, 255, 0.95))',
}}
>
<MockupCard padding="$3">
<XStack alignItems="center" gap="$2">
<Clock size={16} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Live timeline
</Text>
</XStack>
<MockupLabel>Moments grouped by when they happened.</MockupLabel>
</MockupCard>
<YStack gap="$3">
{timeline.map((item, index) => (
<XStack key={item.time} gap="$3" alignItems="flex-start">
<YStack alignItems="center" gap="$2" width={60}>
<Text fontSize="$2" fontWeight="$7">
{item.time}
</Text>
<YStack width={2} flex={1} backgroundColor="$borderColor" opacity={index === timeline.length - 1 ? 0 : 1} />
</YStack>
<MockupCard flex={1} padding="$3">
<XStack alignItems="center" gap="$2">
<ImageIcon size={16} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
{item.title}
</Text>
</XStack>
<MockupLabel>{item.caption}</MockupLabel>
<XStack gap="$2">
{[1, 2, 3].map((tile) => (
<YStack
key={tile}
flex={1}
height={56}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor="$borderColor"
/>
))}
</XStack>
</MockupCard>
</XStack>
))}
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Compass, Image as ImageIcon, Sparkles, UploadCloud } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function Mockup05CompassHub() {
return (
<MockupFrame
title="Mockup 05 - Compass Hub"
subtitle="Quadrant navigation around a central action"
>
<YStack position="relative" height={360} gap="$3">
<XStack gap="$3">
<MockupCard flex={1} height={150} justifyContent="center" alignItems="center">
<ImageIcon size={20} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Gallery
</Text>
<MockupLabel>Browse moments</MockupLabel>
</MockupCard>
<MockupCard flex={1} height={150} justifyContent="center" alignItems="center">
<UploadCloud size={20} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Upload
</Text>
<MockupLabel>Quick add</MockupLabel>
</MockupCard>
</XStack>
<XStack gap="$3">
<MockupCard flex={1} height={150} justifyContent="center" alignItems="center">
<Sparkles size={20} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Tasks
</Text>
<MockupLabel>Earn points</MockupLabel>
</MockupCard>
<MockupCard flex={1} height={150} justifyContent="center" alignItems="center">
<Compass size={20} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Live show
</Text>
<MockupLabel>See highlights</MockupLabel>
</MockupCard>
</XStack>
<YStack
position="absolute"
top="50%"
left="50%"
width={110}
height={110}
borderRadius={55}
backgroundColor="$primary"
alignItems="center"
justifyContent="center"
style={{ transform: 'translate(-50%, -50%)' }}
>
<Text fontSize="$4" fontWeight="$8" color="white">
Capture
</Text>
</YStack>
</YStack>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
Event compass
</Text>
<MockupLabel>Tap any quadrant to jump.</MockupLabel>
</YStack>
<Text fontSize="$2" fontWeight="$7">
4 zones
</Text>
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Camera, Grid2x2, Zap, UploadCloud } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
export default function Mockup06SplitCapture() {
return (
<MockupFrame
title="Mockup 06 - Split Capture"
subtitle="Camera preview plus quick tools and queue"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(240, 249, 255, 0.95), rgba(255, 255, 255, 0.95))',
}}
>
<YStack gap="$3">
<YStack
height={260}
borderRadius="$card"
backgroundColor="$muted"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
gap="$2"
>
<Camera size={32} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Live preview
</Text>
<MockupLabel>Tap anywhere to focus.</MockupLabel>
</YStack>
<XStack gap="$3">
{[
{ icon: <Grid2x2 size={18} color="#0F172A" />, label: 'Grid' },
{ icon: <Zap size={18} color="#F97316" />, label: 'Flash' },
{ icon: <UploadCloud size={18} color="#0F172A" />, label: 'Upload' },
].map((tool) => (
<MockupCard key={tool.label} flex={1} padding="$3" alignItems="center">
{tool.icon}
<Text fontSize="$3" fontWeight="$7">
{tool.label}
</Text>
</MockupCard>
))}
</XStack>
</YStack>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$4" fontWeight="$7">
Upload queue
</Text>
<MockupLabel>2 waiting</MockupLabel>
</XStack>
<XStack gap="$2">
{[1, 2, 3].map((tile) => (
<MockupTile key={tile} flex={1} height={72} />
))}
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Heart, X, Send } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function Mockup07SwipeDeck() {
return (
<MockupFrame
title="Mockup 07 - Swipe Deck"
subtitle="Stacked cards for rapid review"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 80% 0%, rgba(190, 24, 93, 0.12), transparent 50%)',
}}
>
<MockupCard padding="$3">
<Text fontSize="$4" fontWeight="$7">
Review queue
</Text>
<MockupLabel>Swipe to approve or skip highlights.</MockupLabel>
</MockupCard>
<YStack height={360} position="relative">
<MockupCard
height={320}
position="absolute"
left={0}
right={0}
style={{ transform: 'translateY(24px) rotate(-2deg)' }}
>
<Text fontSize="$4" fontWeight="$7">
Guest capture 3
</Text>
</MockupCard>
<MockupCard
height={330}
position="absolute"
left={0}
right={0}
style={{ transform: 'translateY(12px) rotate(1deg)' }}
>
<Text fontSize="$4" fontWeight="$7">
Guest capture 2
</Text>
</MockupCard>
<MockupCard height={340} position="absolute" left={0} right={0}>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
Guest capture 1
</Text>
<MockupLabel>Tap for full view</MockupLabel>
<YStack flex={1} borderRadius="$card" backgroundColor="$muted" />
</MockupCard>
</YStack>
<XStack gap="$3" justifyContent="center">
<YStack
width={64}
height={64}
borderRadius={32}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
>
<X size={20} color="#EF4444" />
</YStack>
<YStack
width={72}
height={72}
borderRadius={36}
backgroundColor="$primary"
alignItems="center"
justifyContent="center"
>
<Heart size={24} color="white" />
</YStack>
<YStack
width={64}
height={64}
borderRadius={32}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
justifyContent="center"
>
<Send size={20} color="#0F172A" />
</YStack>
</XStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Sun, CloudSun, MoonStar } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
const sections = [
{
key: 'morning',
title: 'Morning glow',
icon: <Sun size={18} color="#F97316" />,
},
{
key: 'afternoon',
title: 'Afternoon energy',
icon: <CloudSun size={18} color="#F59E0B" />,
},
{
key: 'night',
title: 'Night sparkle',
icon: <MoonStar size={18} color="#6366F1" />,
},
];
export default function Mockup08Daybook() {
return (
<MockupFrame
title="Mockup 08 - Daybook"
subtitle="Morning/afternoon/night memory sections"
>
<MockupCard>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8">
Today in moments
</Text>
<MockupLabel>Group memories by vibe, not just time.</MockupLabel>
</MockupCard>
<YStack gap="$4">
{sections.map((section) => (
<YStack key={section.key} gap="$2">
<XStack alignItems="center" gap="$2">
{section.icon}
<Text fontSize="$4" fontWeight="$7">
{section.title}
</Text>
</XStack>
<XStack gap="$2">
{[1, 2, 3].map((tile) => (
<MockupTile key={tile} flex={1} height={110} />
))}
</XStack>
</YStack>
))}
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { CheckCircle2, Circle, ListChecks } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
const tasks = [
{ id: 1, label: 'Capture the first toast', done: true },
{ id: 2, label: 'Find the loudest laugh', done: false },
{ id: 3, label: 'Snap a detail shot', done: false },
{ id: 4, label: 'Find the dance duo', done: true },
];
export default function Mockup09ChecklistFlow() {
return (
<MockupFrame
title="Mockup 09 - Checklist Flow"
subtitle="Tasks checklist paired with gallery progress"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(244, 244, 255, 0.8), rgba(255, 255, 255, 0.9))',
}}
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<ListChecks size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Todays checklist
</Text>
</XStack>
<MockupLabel>Complete tasks to unlock more prompts.</MockupLabel>
</MockupCard>
<MockupCard>
<YStack gap="$2">
{tasks.map((task) => (
<XStack key={task.id} alignItems="center" gap="$2">
{task.done ? (
<CheckCircle2 size={18} color="#22C55E" />
) : (
<Circle size={18} color="#94A3B8" />
)}
<Text fontSize="$3" fontWeight="$6">
{task.label}
</Text>
</XStack>
))}
</YStack>
</MockupCard>
<MockupCard>
<Text fontSize="$3" fontWeight="$7">
Progress gallery
</Text>
<XStack gap="$2">
{[1, 2, 3, 4].map((tile) => (
<MockupTile key={tile} flex={1} height={70} />
))}
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Play, Star, Share2 } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
export default function Mockup10SpotlightReel() {
return (
<MockupFrame
title="Mockup 10 - Spotlight Reel"
subtitle="Live highlight reel with action strip"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 20% 0%, rgba(251, 191, 36, 0.2), transparent 55%)',
}}
>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$4" fontWeight="$7">
Spotlight reel
</Text>
<MockupLabel>Live highlights curated by guests.</MockupLabel>
</YStack>
<Play size={18} color="#0F172A" />
</XStack>
</MockupCard>
<XStack gap="$2">
{[1, 2, 3, 4, 5].map((tile) => (
<MockupTile key={tile} flex={1} height={70} />
))}
</XStack>
<MockupCard>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
Now showing
</Text>
<YStack height={200} borderRadius="$card" backgroundColor="$muted" />
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Star size={16} color="#F59E0B" />
<Text fontSize="$3" fontWeight="$7">
4.8 rating
</Text>
</XStack>
<Share2 size={16} color="#0F172A" />
</XStack>
</MockupCard>
<XStack gap="$2">
{['Like', 'Save', 'Share'].map((action) => (
<MockupCard key={action} flex={1} padding="$3" alignItems="center">
<Text fontSize="$3" fontWeight="$7">
{action}
</Text>
</MockupCard>
))}
</XStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft } from 'lucide-react';
type MockupFrameProps = {
title: string;
subtitle?: string;
hideHeader?: boolean;
backgroundStyle?: React.CSSProperties;
children: React.ReactNode;
};
export default function MockupFrame({
title,
subtitle,
hideHeader,
backgroundStyle,
children,
}: MockupFrameProps) {
const navigate = useNavigate();
return (
<YStack
minHeight="100vh"
backgroundColor="$background"
padding="$4"
gap="$4"
style={backgroundStyle}
>
{!hideHeader && (
<XStack alignItems="center" justifyContent="space-between" gap="$3">
<Button
size="$3"
backgroundColor="$surface"
borderRadius="$pill"
borderWidth={1}
borderColor="$borderColor"
onPress={() => navigate('/mockups')}
>
<XStack alignItems="center" gap="$2">
<ArrowLeft size={16} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Back
</Text>
</XStack>
</Button>
<YStack alignItems="flex-end" gap="$1">
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
{title}
</Text>
{subtitle ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{subtitle}
</Text>
) : null}
</YStack>
</XStack>
)}
{children}
</YStack>
);
}

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, Zap, Users } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome01PulseHero() {
return (
<MockupFrame
title="Home 01 - Pulse Hero"
subtitle="Live stats + big capture call-to-action"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 20% 0%, rgba(59, 130, 246, 0.16), transparent 50%)',
}}
>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$3" fontWeight="$7">
Sofia + Luca
</Text>
<MockupLabel>Reception in progress</MockupLabel>
</YStack>
<XStack alignItems="center" gap="$2">
<Zap size={16} color="#F97316" />
<Text fontSize="$3" fontWeight="$7">
Live
</Text>
</XStack>
</XStack>
</MockupCard>
<YStack
padding="$5"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
alignItems="center"
gap="$3"
>
<Camera size={28} color="#0F172A" />
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
Capture the next moment
</Text>
<MockupLabel>Share it instantly with the room.</MockupLabel>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
Start capture
</Button>
</YStack>
<XStack gap="$3">
<MockupCard flex={1} alignItems="center">
<Text fontSize="$6" fontWeight="$8">
128
</Text>
<MockupLabel>Photos today</MockupLabel>
</MockupCard>
<MockupCard flex={1} alignItems="center">
<Users size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
54 guests
</Text>
</MockupCard>
</XStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Camera, Sparkles, Image as ImageIcon, Users } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
const rings = [
{ label: 'Capture', icon: <Camera size={18} color="#0F172A" /> },
{ label: 'Prompts', icon: <Sparkles size={18} color="#0F172A" /> },
{ label: 'Gallery', icon: <ImageIcon size={18} color="#0F172A" /> },
{ label: 'Guests', icon: <Users size={18} color="#0F172A" /> },
];
export default function MockupHome02StoryRings() {
return (
<MockupFrame
title="Home 02 - Story Rings"
subtitle="Circular quick actions with story chips"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 80% 0%, rgba(244, 63, 94, 0.14), transparent 55%)',
}}
>
<MockupCard>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8">
Morning glow
</Text>
<MockupLabel>Tap a ring to jump in.</MockupLabel>
</MockupCard>
<XStack justifyContent="space-between">
{rings.map((ring) => (
<YStack key={ring.label} alignItems="center" gap="$2">
<YStack
width={72}
height={72}
borderRadius={36}
backgroundColor="$surface"
borderWidth={2}
borderColor="$primary"
alignItems="center"
justifyContent="center"
>
{ring.icon}
</YStack>
<Text fontSize="$2" fontWeight="$6">
{ring.label}
</Text>
</YStack>
))}
</XStack>
<YStack gap="$3">
<MockupCard>
<Text fontSize="$4" fontWeight="$7">
Story highlights
</Text>
<XStack gap="$2">
{[1, 2, 3].map((chip) => (
<YStack
key={chip}
flex={1}
height={90}
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor="$borderColor"
/>
))}
</XStack>
</MockupCard>
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Play, Share2, Cast } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
export default function MockupHome03LiveStream() {
return (
<MockupFrame
title="Home 03 - Live Stream"
subtitle="Highlight reel + action strip"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(254, 242, 242, 0.9), rgba(255, 255, 255, 0.95))',
}}
>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Cast size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Live highlight stream
</Text>
</XStack>
<Text fontSize="$2" fontWeight="$7">
24 now
</Text>
</XStack>
<MockupLabel>Tap to join the live wall.</MockupLabel>
</MockupCard>
<MockupCard>
<YStack height={210} borderRadius="$card" backgroundColor="$muted" />
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Play size={16} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Now playing
</Text>
</XStack>
<Share2 size={16} color="#0F172A" />
</XStack>
</MockupCard>
<XStack gap="$2">
{[1, 2, 3, 4].map((tile) => (
<MockupTile key={tile} flex={1} height={70} />
))}
</XStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Sparkles, Trophy } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome04TaskSprint() {
return (
<MockupFrame
title="Home 04 - Task Sprint"
subtitle="Prompt ladder + progress meter"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 20% 0%, rgba(167, 139, 250, 0.2), transparent 55%)',
}}
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color="#8B5CF6" />
<Text fontSize="$4" fontWeight="$7">
Sprint of the hour
</Text>
</XStack>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
Capture three smiles in 5 minutes
</Text>
<MockupLabel>Earn double points for finishing early.</MockupLabel>
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
Progress
</Text>
<Text fontSize="$2" fontWeight="$7">
2/3
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width="66%" height={10} />
</YStack>
</YStack>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
Continue sprint
</Button>
</MockupCard>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" fontWeight="$7">
Rewards
</Text>
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#F59E0B" />
<Text fontSize="$2" fontWeight="$7">
+180
</Text>
</XStack>
</XStack>
<MockupLabel>Unlock more prompts after this sprint.</MockupLabel>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Image as ImageIcon, Filter } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
export default function MockupHome05GalleryFirst() {
return (
<MockupFrame
title="Home 05 - Gallery First"
subtitle="Grid preview + quick filters"
>
<MockupCard padding="$3">
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<ImageIcon size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Moments
</Text>
</XStack>
<Filter size={16} color="#0F172A" />
</XStack>
<MockupLabel>Start browsing before you upload.</MockupLabel>
</MockupCard>
<XStack gap="$2">
{[1, 2, 3].map((tile) => (
<MockupTile key={tile} flex={1} height={100} />
))}
</XStack>
<XStack gap="$2">
{[4, 5, 6].map((tile) => (
<MockupTile key={tile} flex={1} height={120} />
))}
</XStack>
<MockupCard>
<Text fontSize="$3" fontWeight="$7">
Latest uploads
</Text>
<MockupLabel>Updated every 15 seconds.</MockupLabel>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, CheckCircle2 } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome06CalmFocus() {
return (
<MockupFrame
title="Home 06 - Calm Focus"
subtitle="Minimal home with one primary action"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(236, 254, 255, 0.9), rgba(255, 255, 255, 0.95))',
}}
>
<YStack
padding="$5"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
gap="$3"
>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
Capture something quiet
</Text>
<MockupLabel>One great photo beats five ok ones.</MockupLabel>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
<XStack alignItems="center" gap="$2">
<Camera size={18} color="white" />
<Text fontSize="$3" color="white">
Open camera
</Text>
</XStack>
</Button>
</YStack>
<MockupCard>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" fontWeight="$7">
Uploads today
</Text>
<XStack alignItems="center" gap="$2">
<CheckCircle2 size={16} color="#22C55E" />
<Text fontSize="$2" fontWeight="$7">
12 approved
</Text>
</XStack>
</XStack>
<MockupLabel>Everything is synced and safe.</MockupLabel>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Camera, ArrowUpRight } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome07MomentStack() {
return (
<MockupFrame
title="Home 07 - Moment Stack"
subtitle="Stacked cards for rapid capture"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 70% 0%, rgba(14, 165, 233, 0.15), transparent 50%)',
}}
>
<MockupCard>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8">
Moment stack
</Text>
<MockupLabel>Start at the top and keep capturing.</MockupLabel>
</MockupCard>
<YStack height={320} position="relative">
<MockupCard
height={220}
position="absolute"
left={0}
right={0}
style={{ transform: 'translateY(28px) rotate(-2deg)' }}
>
<Text fontSize="$4" fontWeight="$7">
Prompt: hands in the air
</Text>
</MockupCard>
<MockupCard
height={230}
position="absolute"
left={0}
right={0}
style={{ transform: 'translateY(14px) rotate(1deg)' }}
>
<Text fontSize="$4" fontWeight="$7">
Prompt: candid laugh
</Text>
</MockupCard>
<MockupCard height={240} position="absolute" left={0} right={0}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$5" fontWeight="$8">
Prompt: dance floor
</Text>
<ArrowUpRight size={18} color="#0F172A" />
</XStack>
<MockupLabel>Tap to start this capture.</MockupLabel>
<XStack alignItems="center" gap="$2">
<Camera size={18} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Open camera
</Text>
</XStack>
</MockupCard>
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Clock, Play } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome08CountdownStage() {
return (
<MockupFrame
title="Home 08 - Countdown Stage"
subtitle="Event timing + live show entry"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(254, 249, 195, 0.9), rgba(255, 255, 255, 0.95))',
}}
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Clock size={16} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Next highlight in
</Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
04:32
</Text>
<MockupLabel>Stay ready for the next big moment.</MockupLabel>
</MockupCard>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
gap="$3"
>
<Text fontSize="$4" fontWeight="$7">
Live show stage
</Text>
<MockupLabel>Jump into the projected highlight wall.</MockupLabel>
<Button size="$4" backgroundColor="$primary" borderRadius="$pill">
<XStack alignItems="center" gap="$2">
<Play size={16} color="white" />
<Text fontSize="$3" color="white">
Open live show
</Text>
</XStack>
</Button>
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Share2, QrCode, Link } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
export default function MockupHome09ShareHub() {
return (
<MockupFrame
title="Home 09 - Share Hub"
subtitle="Invite + QR + guest sharing tools"
backgroundStyle={{
backgroundImage: 'radial-gradient(circle at 25% 0%, rgba(14, 165, 233, 0.18), transparent 50%)',
}}
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Share2 size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Invite guests
</Text>
</XStack>
<MockupLabel>Share the link or show the QR code.</MockupLabel>
</MockupCard>
<XStack gap="$3">
<MockupCard flex={1} alignItems="center" gap="$2">
<QrCode size={24} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Show QR
</Text>
<MockupLabel>Scan to join</MockupLabel>
</MockupCard>
<MockupCard flex={1} alignItems="center" gap="$2">
<Link size={24} color="#0F172A" />
<Text fontSize="$3" fontWeight="$7">
Copy link
</Text>
<MockupLabel>Send in chat</MockupLabel>
</MockupCard>
</XStack>
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
gap="$3"
>
<Text fontSize="$4" fontWeight="$7">
Welcome pack
</Text>
<MockupLabel>Tips for guests and upload etiquette.</MockupLabel>
<Button size="$3" backgroundColor="$primary" borderRadius="$pill" alignSelf="flex-start">
Open guide
</Button>
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Palette, Sparkles } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel, MockupTile } from './MockupPrimitives';
const palettes = ['#FDE68A', '#F9A8D4', '#A5B4FC', '#6EE7B7', '#FCA5A5'];
export default function MockupHome10Moodboard() {
return (
<MockupFrame
title="Home 10 - Moodboard"
subtitle="Palette + prompts to set the vibe"
backgroundStyle={{
backgroundImage: 'linear-gradient(180deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.95))',
}}
>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Palette size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$7">
Moodboard
</Text>
</XStack>
<MockupLabel>Pick a vibe for the next wave of shots.</MockupLabel>
</MockupCard>
<XStack gap="$2">
{palettes.map((color) => (
<YStack
key={color}
flex={1}
height={54}
borderRadius="$pill"
style={{ backgroundColor: color }}
/>
))}
</XStack>
<MockupCard>
<XStack alignItems="center" gap="$2">
<Sparkles size={16} color="#F43F5E" />
<Text fontSize="$3" fontWeight="$7">
Prompt ideas
</Text>
</XStack>
<XStack gap="$2">
{[1, 2, 3].map((tile) => (
<MockupTile key={tile} flex={1} height={80} />
))}
</XStack>
</MockupCard>
</MockupFrame>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
type StackProps = React.ComponentProps<typeof YStack>;
type RowProps = React.ComponentProps<typeof XStack>;
export function MockupCard({ children, ...props }: StackProps) {
return (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
gap="$3"
{...props}
>
{children}
</YStack>
);
}
export function MockupTile({ children, ...props }: StackProps) {
return (
<YStack
padding="$3"
borderRadius="$tile"
backgroundColor="$muted"
borderWidth={1}
borderColor="$borderColor"
gap="$2"
{...props}
>
{children}
</YStack>
);
}
export function MockupChip({ children, ...props }: RowProps) {
return (
<XStack
alignItems="center"
gap="$2"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius="$pill"
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
{...props}
>
{children}
</XStack>
);
}
export function MockupLabel({ children }: { children: React.ReactNode }) {
return (
<Text fontSize="$2" color="$color" opacity={0.6}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowRight, Home } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
const mockups = [
{ id: '1', title: 'Pulse Hero', description: 'Live stats + big capture call-to-action.' },
{ id: '2', title: 'Story Rings', description: 'Circular quick actions with story chips.' },
{ id: '3', title: 'Live Stream', description: 'Highlight reel + action strip.' },
{ id: '4', title: 'Task Sprint', description: 'Prompt ladder + progress meter.' },
{ id: '5', title: 'Gallery First', description: 'Grid preview + quick filters.' },
{ id: '6', title: 'Calm Focus', description: 'Minimal home with one primary action.' },
{ id: '7', title: 'Moment Stack', description: 'Stacked cards for rapid capture.' },
{ id: '8', title: 'Countdown Stage', description: 'Event timing + live show entry.' },
{ id: '9', title: 'Share Hub', description: 'Invite + QR + guest sharing tools.' },
{ id: '10', title: 'Moodboard', description: 'Palette + prompts to set the vibe.' },
];
export default function MockupsHomeIndexScreen() {
const navigate = useNavigate();
return (
<MockupFrame
title="Home concepts"
subtitle="10 start screen ideas for the new guest PWA"
>
<MockupCard padding="$3">
<XStack alignItems="center" gap="$2">
<Home size={18} color="#0F172A" />
<Text fontSize="$4" fontWeight="$8">
Start screen concepts
</Text>
</XStack>
<MockupLabel>Pick one as the north star for v2.</MockupLabel>
</MockupCard>
<YStack gap="$3">
{mockups.map((mockup) => (
<MockupCard key={mockup.id} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
{mockup.title}
</Text>
<Button
size="$3"
backgroundColor="$primary"
borderRadius="$pill"
onPress={() => navigate(`/mockups/home/${mockup.id}`)}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$3" color="white">
View
</Text>
<ArrowRight size={16} color="white" />
</XStack>
</Button>
</XStack>
<MockupLabel>{mockup.description}</MockupLabel>
</MockupCard>
))}
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowRight } from 'lucide-react';
import MockupFrame from './MockupFrame';
import { MockupCard, MockupLabel } from './MockupPrimitives';
const mockups = [
{ id: '1', title: 'Capture Orbit', description: 'Full-screen capture hub with orbiting actions.' },
{ id: '2', title: 'Gallery Mosaic', description: 'Image-first grid with filters and fast scan.' },
{ id: '3', title: 'Prompt Quest', description: 'Prompt hero with progress and task ladder.' },
{ id: '4', title: 'Timeline Stream', description: 'Chronological feed with time markers.' },
{ id: '5', title: 'Compass Hub', description: 'Quadrant navigation around a central action.' },
{ id: '6', title: 'Split Capture', description: 'Camera preview plus quick tools and queue.' },
{ id: '7', title: 'Swipe Deck', description: 'Stacked cards for rapid review.' },
{ id: '8', title: 'Daybook', description: 'Morning/afternoon/night memory sections.' },
{ id: '9', title: 'Checklist Flow', description: 'Tasks checklist paired with gallery progress.' },
{ id: '10', title: 'Spotlight Reel', description: 'Live highlight reel with action strip.' },
];
export default function MockupsIndexScreen() {
const navigate = useNavigate();
return (
<MockupFrame
title="Guest PWA mockups"
subtitle="10 layout concepts for the new event experience"
>
<MockupCard gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack gap="$1">
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
Home concepts
</Text>
<MockupLabel>Explore 10 start screen ideas.</MockupLabel>
</YStack>
<Button
size="$3"
backgroundColor="$primary"
borderRadius="$pill"
onPress={() => navigate('/mockups/home')}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$3" color="white">
Open
</Text>
<ArrowRight size={16} color="white" />
</XStack>
</Button>
</XStack>
</MockupCard>
<YStack gap="$3">
{mockups.map((mockup) => (
<MockupCard key={mockup.id} gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$5" fontWeight="$8" fontFamily="$display">
{mockup.title}
</Text>
<Button
size="$3"
backgroundColor="$primary"
borderRadius="$pill"
onPress={() => navigate(`/mockups/${mockup.id}`)}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$3" color="white">
View
</Text>
<ArrowRight size={16} color="white" />
</XStack>
</Button>
</XStack>
<MockupLabel>{mockup.description}</MockupLabel>
</MockupCard>
))}
</YStack>
</MockupFrame>
);
}

View File

@@ -0,0 +1,6 @@
export {
fetchAchievements,
type AchievementsPayload,
type AchievementBadge,
type LeaderboardEntry,
} from '@/guest/services/achievementApi';

View File

@@ -0,0 +1,95 @@
export type ApiErrorPayload = {
error?: {
code?: string;
title?: string;
message?: string;
meta?: Record<string, unknown>;
};
code?: string;
message?: string;
};
export type ApiError = Error & {
status?: number;
code?: string;
meta?: Record<string, unknown>;
};
export type FetchJsonResult<T> = {
data: T | null;
etag: string | null;
notModified: boolean;
status: number;
};
type FetchJsonOptions = {
method?: string;
headers?: HeadersInit;
body?: BodyInit | null;
signal?: AbortSignal;
etag?: string | null;
noStore?: boolean;
};
export async function fetchJson<T>(url: string, options: FetchJsonOptions = {}): Promise<FetchJsonResult<T>> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (options.noStore) {
headers['Cache-Control'] = 'no-store';
}
if (options.etag) {
headers['If-None-Match'] = options.etag;
}
if (options.headers) {
Object.assign(headers, options.headers as Record<string, string>);
}
const response = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ?? null,
signal: options.signal,
credentials: 'include',
});
if (response.status === 304) {
return {
data: null,
etag: response.headers.get('ETag') ?? options.etag ?? null,
notModified: true,
status: response.status,
};
}
if (!response.ok) {
const errorPayload = await safeParseError(response);
const error: ApiError = new Error(errorPayload?.error?.message ?? errorPayload?.message ?? `Request failed (${response.status})`);
error.status = response.status;
error.code = errorPayload?.error?.code ?? errorPayload?.code;
if (errorPayload?.error?.meta) {
error.meta = errorPayload.error.meta;
}
throw error;
}
const data = (await response.json()) as T;
return {
data,
etag: response.headers.get('ETag'),
notModified: false,
status: response.status,
};
}
async function safeParseError(response: Response): Promise<ApiErrorPayload | null> {
try {
const payload = (await response.clone().json()) as ApiErrorPayload;
return payload;
} catch (error) {
return null;
}
}

View File

@@ -0,0 +1,24 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export type EmotionItem = Record<string, unknown>;
type EmotionResponse = {
data?: EmotionItem[];
};
export async function fetchEmotions(eventToken: string, locale?: string) {
const params = new URLSearchParams();
if (locale) params.set('locale', locale);
const response = await fetchJson<EmotionResponse>(
`/api/v1/events/${encodeURIComponent(eventToken)}/emotions${params.toString() ? `?${params.toString()}` : ''}`,
{
headers: {
'X-Device-Id': getDeviceId(),
},
}
);
return response.data?.data ?? [];
}

View File

@@ -0,0 +1,8 @@
export {
fetchEvent,
fetchStats,
type EventData,
type EventStats,
FetchEventError,
type FetchEventErrorCode,
} from '@/guest/services/eventApi';

View File

@@ -0,0 +1,16 @@
import type { EventData } from './eventApi';
export function buildEventShareLink(event: EventData | null, token: string | null): string {
if (typeof window === 'undefined') {
return '';
}
const origin = window.location.origin;
const eventToken = token ?? event?.join_token ?? '';
if (!eventToken) {
return origin;
}
return `${origin}/e/${encodeURIComponent(eventToken)}`;
}

View File

@@ -0,0 +1,6 @@
export {
fetchGalleryMeta,
fetchGalleryPhotos,
type GalleryMetaResponse,
type GalleryPhotoResource,
} from '@/guest/services/galleryApi';

View File

@@ -0,0 +1,6 @@
export {
fetchGuestNotifications,
markGuestNotificationRead,
dismissGuestNotification,
type GuestNotificationItem,
} from '@/guest/services/notificationApi';

View File

@@ -0,0 +1,78 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export { likePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
export type GalleryPhoto = Record<string, unknown>;
type GalleryResponse = {
data?: GalleryPhoto[];
next_cursor?: string | null;
latest_photo_at?: string | null;
};
const galleryCache = new Map<string, { etag: string | null; data: GalleryResponse }>();
export async function fetchGallery(
eventToken: string,
params: { cursor?: string; since?: string; limit?: number; locale?: string } = {}
) {
const search = new URLSearchParams();
if (params.cursor) search.set('cursor', params.cursor);
if (params.since) search.set('since', params.since);
if (params.limit) search.set('limit', params.limit.toString());
if (params.locale) search.set('locale', params.locale);
const cacheKey = `${eventToken}:${search.toString()}`;
const cached = galleryCache.get(cacheKey);
const response = await fetchJson<GalleryResponse>(
`/api/v1/events/${encodeURIComponent(eventToken)}/photos${search.toString() ? `?${search.toString()}` : ''}`,
{
headers: {
'X-Device-Id': getDeviceId(),
},
etag: cached?.etag ?? null,
noStore: true,
}
);
if (response.notModified && cached) {
return { ...cached.data, notModified: true };
}
const payload = response.data ?? { data: [], next_cursor: null, latest_photo_at: null };
const items = Array.isArray((payload as GalleryResponse).data)
? (payload as GalleryResponse).data ?? []
: Array.isArray((payload as { photos?: GalleryPhoto[] }).photos)
? (payload as { photos?: GalleryPhoto[] }).photos ?? []
: Array.isArray(payload)
? (payload as GalleryPhoto[])
: [];
const data = {
data: items,
next_cursor: (payload as GalleryResponse).next_cursor ?? null,
latest_photo_at: (payload as GalleryResponse).latest_photo_at ?? null,
};
galleryCache.set(cacheKey, { etag: response.etag, data });
return { ...data, notModified: false };
}
export async function fetchPhoto(photoId: number, locale?: string) {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : '';
const response = await fetchJson<GalleryPhoto>(`/api/v1/photos/${photoId}${search}`, {
headers: {
'X-Device-Id': getDeviceId(),
...(locale ? { 'X-Locale': locale } : {}),
},
noStore: true,
});
return response.data;
}
export function clearGalleryCache() {
galleryCache.clear();
}

View File

@@ -0,0 +1 @@
export { registerGuestPushSubscription, unregisterGuestPushSubscription } from '@/guest/services/pushApi';

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