upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
31
resources/js/guest-v2/App.tsx
Normal file
31
resources/js/guest-v2/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
resources/js/guest-v2/__tests__/BottomDock.test.tsx
Normal file
53
resources/js/guest-v2/__tests__/BottomDock.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
resources/js/guest-v2/__tests__/EventLayout.test.tsx
Normal file
53
resources/js/guest-v2/__tests__/EventLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
73
resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx
Normal file
73
resources/js/guest-v2/__tests__/HelpCenterScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
75
resources/js/guest-v2/__tests__/HomeScreen.test.tsx
Normal file
75
resources/js/guest-v2/__tests__/HomeScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
61
resources/js/guest-v2/__tests__/LandingScreen.test.tsx
Normal file
61
resources/js/guest-v2/__tests__/LandingScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx
Normal file
28
resources/js/guest-v2/__tests__/NotFoundScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
70
resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx
Normal file
70
resources/js/guest-v2/__tests__/PhotoLightboxScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
155
resources/js/guest-v2/__tests__/ScreensCopy.test.tsx
Normal file
155
resources/js/guest-v2/__tests__/ScreensCopy.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
124
resources/js/guest-v2/__tests__/SettingsContent.test.tsx
Normal file
124
resources/js/guest-v2/__tests__/SettingsContent.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
58
resources/js/guest-v2/__tests__/SettingsSheet.test.tsx
Normal file
58
resources/js/guest-v2/__tests__/SettingsSheet.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
31
resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx
Normal file
31
resources/js/guest-v2/__tests__/SlideshowScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
60
resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx
Normal file
60
resources/js/guest-v2/__tests__/TaskDetailScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
72
resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
Normal file
72
resources/js/guest-v2/__tests__/UploadQueueScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
87
resources/js/guest-v2/__tests__/UploadScreen.test.tsx
Normal file
87
resources/js/guest-v2/__tests__/UploadScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
74
resources/js/guest-v2/__tests__/brandingTheme.test.ts
Normal file
74
resources/js/guest-v2/__tests__/brandingTheme.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
31
resources/js/guest-v2/__tests__/eventBranding.test.ts
Normal file
31
resources/js/guest-v2/__tests__/eventBranding.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
33
resources/js/guest-v2/__tests__/statsApi.test.ts
Normal file
33
resources/js/guest-v2/__tests__/statsApi.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal file
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
resources/js/guest-v2/components/AppShell.tsx
Normal file
236
resources/js/guest-v2/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
resources/js/guest-v2/components/BottomDock.tsx
Normal file
75
resources/js/guest-v2/components/BottomDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
resources/js/guest-v2/components/CompassHub.tsx
Normal file
150
resources/js/guest-v2/components/CompassHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal file
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal file
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal file
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal file
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal file
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal file
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal file
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal 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;
|
||||
}
|
||||
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal file
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal file
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
resources/js/guest-v2/components/TopBar.tsx
Normal file
97
resources/js/guest-v2/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
resources/js/guest-v2/context/EventDataContext.tsx
Normal file
103
resources/js/guest-v2/context/EventDataContext.tsx
Normal 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);
|
||||
}
|
||||
111
resources/js/guest-v2/context/GuestIdentityContext.tsx
Normal file
111
resources/js/guest-v2/context/GuestIdentityContext.tsx
Normal 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);
|
||||
}
|
||||
82
resources/js/guest-v2/hooks/usePollGalleryDelta.ts
Normal file
82
resources/js/guest-v2/hooks/usePollGalleryDelta.ts
Normal 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;
|
||||
}
|
||||
57
resources/js/guest-v2/hooks/usePollStats.ts
Normal file
57
resources/js/guest-v2/hooks/usePollStats.ts
Normal 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;
|
||||
}
|
||||
74
resources/js/guest-v2/layouts/EventLayout.tsx
Normal file
74
resources/js/guest-v2/layouts/EventLayout.tsx
Normal 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}</>;
|
||||
}
|
||||
12
resources/js/guest-v2/layouts/GuestLocaleLayout.tsx
Normal file
12
resources/js/guest-v2/layouts/GuestLocaleLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
1
resources/js/guest-v2/lib/brandingTheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './brandingTheme.tsx';
|
||||
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal file
66
resources/js/guest-v2/lib/brandingTheme.tsx
Normal 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>;
|
||||
}
|
||||
18
resources/js/guest-v2/lib/device.ts
Normal file
18
resources/js/guest-v2/lib/device.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
74
resources/js/guest-v2/lib/eventBranding.ts
Normal file
74
resources/js/guest-v2/lib/eventBranding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
resources/js/guest-v2/lib/routes.ts
Normal file
13
resources/js/guest-v2/lib/routes.ts
Normal 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}`;
|
||||
}
|
||||
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal file
39
resources/js/guest-v2/lib/usePulseAnimation.ts
Normal 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;
|
||||
}
|
||||
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal file
29
resources/js/guest-v2/lib/useStaggeredReveal.ts
Normal 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;
|
||||
}
|
||||
25
resources/js/guest-v2/main.tsx
Normal file
25
resources/js/guest-v2/main.tsx
Normal 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>
|
||||
);
|
||||
106
resources/js/guest-v2/router.tsx
Normal file
106
resources/js/guest-v2/router.tsx
Normal 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 /> },
|
||||
],
|
||||
{}
|
||||
);
|
||||
174
resources/js/guest-v2/screens/AchievementsScreen.tsx
Normal file
174
resources/js/guest-v2/screens/AchievementsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal file
347
resources/js/guest-v2/screens/GalleryScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
resources/js/guest-v2/screens/HelpArticleScreen.tsx
Normal file
170
resources/js/guest-v2/screens/HelpArticleScreen.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
174
resources/js/guest-v2/screens/HelpCenterScreen.tsx
Normal file
174
resources/js/guest-v2/screens/HelpCenterScreen.tsx
Normal 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>;
|
||||
}
|
||||
415
resources/js/guest-v2/screens/HomeScreen.tsx
Normal file
415
resources/js/guest-v2/screens/HomeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
resources/js/guest-v2/screens/LandingScreen.tsx
Normal file
237
resources/js/guest-v2/screens/LandingScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
resources/js/guest-v2/screens/LegalScreen.tsx
Normal file
83
resources/js/guest-v2/screens/LegalScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
resources/js/guest-v2/screens/LiveShowScreen.tsx
Normal file
213
resources/js/guest-v2/screens/LiveShowScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
resources/js/guest-v2/screens/NotFoundScreen.tsx
Normal file
26
resources/js/guest-v2/screens/NotFoundScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
589
resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
Normal file
589
resources/js/guest-v2/screens/PhotoLightboxScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
resources/js/guest-v2/screens/ProfileSetupScreen.tsx
Normal file
104
resources/js/guest-v2/screens/ProfileSetupScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
resources/js/guest-v2/screens/PublicGalleryScreen.tsx
Normal file
306
resources/js/guest-v2/screens/PublicGalleryScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
resources/js/guest-v2/screens/SettingsScreen.tsx
Normal file
11
resources/js/guest-v2/screens/SettingsScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
resources/js/guest-v2/screens/ShareScreen.tsx
Normal file
170
resources/js/guest-v2/screens/ShareScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
resources/js/guest-v2/screens/SharedPhotoScreen.tsx
Normal file
183
resources/js/guest-v2/screens/SharedPhotoScreen.tsx
Normal 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' });
|
||||
}
|
||||
186
resources/js/guest-v2/screens/SlideshowScreen.tsx
Normal file
186
resources/js/guest-v2/screens/SlideshowScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
resources/js/guest-v2/screens/TaskDetailScreen.tsx
Normal file
237
resources/js/guest-v2/screens/TaskDetailScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
resources/js/guest-v2/screens/TasksScreen.tsx
Normal file
271
resources/js/guest-v2/screens/TasksScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
resources/js/guest-v2/screens/UploadQueueScreen.tsx
Normal file
307
resources/js/guest-v2/screens/UploadQueueScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
742
resources/js/guest-v2/screens/UploadScreen.tsx
Normal file
742
resources/js/guest-v2/screens/UploadScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
81
resources/js/guest-v2/screens/mockups/Mockup05CompassHub.tsx
Normal file
81
resources/js/guest-v2/screens/mockups/Mockup05CompassHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
94
resources/js/guest-v2/screens/mockups/Mockup07SwipeDeck.tsx
Normal file
94
resources/js/guest-v2/screens/mockups/Mockup07SwipeDeck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
resources/js/guest-v2/screens/mockups/Mockup08Daybook.tsx
Normal file
58
resources/js/guest-v2/screens/mockups/Mockup08Daybook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Today’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
65
resources/js/guest-v2/screens/mockups/MockupFrame.tsx
Normal file
65
resources/js/guest-v2/screens/mockups/MockupFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
65
resources/js/guest-v2/screens/mockups/MockupPrimitives.tsx
Normal file
65
resources/js/guest-v2/screens/mockups/MockupPrimitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
82
resources/js/guest-v2/screens/mockups/MockupsIndexScreen.tsx
Normal file
82
resources/js/guest-v2/screens/mockups/MockupsIndexScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
resources/js/guest-v2/services/achievementsApi.ts
Normal file
6
resources/js/guest-v2/services/achievementsApi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
fetchAchievements,
|
||||
type AchievementsPayload,
|
||||
type AchievementBadge,
|
||||
type LeaderboardEntry,
|
||||
} from '@/guest/services/achievementApi';
|
||||
95
resources/js/guest-v2/services/apiClient.ts
Normal file
95
resources/js/guest-v2/services/apiClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
resources/js/guest-v2/services/emotionsApi.ts
Normal file
24
resources/js/guest-v2/services/emotionsApi.ts
Normal 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 ?? [];
|
||||
}
|
||||
8
resources/js/guest-v2/services/eventApi.ts
Normal file
8
resources/js/guest-v2/services/eventApi.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
fetchEvent,
|
||||
fetchStats,
|
||||
type EventData,
|
||||
type EventStats,
|
||||
FetchEventError,
|
||||
type FetchEventErrorCode,
|
||||
} from '@/guest/services/eventApi';
|
||||
16
resources/js/guest-v2/services/eventLink.ts
Normal file
16
resources/js/guest-v2/services/eventLink.ts
Normal 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)}`;
|
||||
}
|
||||
6
resources/js/guest-v2/services/galleryApi.ts
Normal file
6
resources/js/guest-v2/services/galleryApi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
fetchGalleryMeta,
|
||||
fetchGalleryPhotos,
|
||||
type GalleryMetaResponse,
|
||||
type GalleryPhotoResource,
|
||||
} from '@/guest/services/galleryApi';
|
||||
6
resources/js/guest-v2/services/notificationsApi.ts
Normal file
6
resources/js/guest-v2/services/notificationsApi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
fetchGuestNotifications,
|
||||
markGuestNotificationRead,
|
||||
dismissGuestNotification,
|
||||
type GuestNotificationItem,
|
||||
} from '@/guest/services/notificationApi';
|
||||
78
resources/js/guest-v2/services/photosApi.ts
Normal file
78
resources/js/guest-v2/services/photosApi.ts
Normal 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();
|
||||
}
|
||||
1
resources/js/guest-v2/services/pushApi.ts
Normal file
1
resources/js/guest-v2/services/pushApi.ts
Normal 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
Reference in New Issue
Block a user