Migrate guest v2 achievements and refresh share page
This commit is contained in:
@@ -1 +1,13 @@
|
||||
{}
|
||||
{
|
||||
"achievements": {
|
||||
"summary": {
|
||||
"uniqueGuests": "Gäste beteiligt"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"invite": {
|
||||
"qrLoading": "QR wird erstellt...",
|
||||
"qrRetry": "Erneut versuchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
{}
|
||||
{
|
||||
"achievements": {
|
||||
"summary": {
|
||||
"uniqueGuests": "Guests involved"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"invite": {
|
||||
"qrLoading": "Generating QR...",
|
||||
"qrRetry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx
Normal file
128
resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AppShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/components/PullToRefresh', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'demo', tasksEnabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../context/GuestIdentityContext', () => ({
|
||||
useOptionalGuestIdentity: () => ({ name: 'Alex' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
if (typeof options === 'string') return options;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bento', () => ({
|
||||
getBentoSurfaceTokens: () => ({
|
||||
borderColor: '#eee',
|
||||
borderBottomColor: '#ddd',
|
||||
backgroundColor: '#fff',
|
||||
shadow: 'none',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/routes', () => ({
|
||||
buildEventPath: (_token: string, path: string) => `/e/demo${path}`,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/lib/localizeTaskLabel', () => ({
|
||||
localizeTaskLabel: (value: string | null) => value,
|
||||
}));
|
||||
|
||||
vi.mock('../services/achievementsApi', () => ({
|
||||
fetchAchievements: vi.fn().mockResolvedValue({
|
||||
summary: { totalPhotos: 12, uniqueGuests: 4, tasksSolved: 2, likesTotal: 30 },
|
||||
personal: {
|
||||
guestName: 'Alex',
|
||||
photos: 3,
|
||||
tasks: 1,
|
||||
likes: 5,
|
||||
badges: [
|
||||
{ id: 'b1', title: 'First upload', description: 'Upload one photo', earned: true, progress: 1, target: 1 },
|
||||
],
|
||||
},
|
||||
leaderboards: {
|
||||
uploads: [{ guest: 'Sam', photos: 6, likes: 10 }],
|
||||
likes: [{ guest: 'Kai', photos: 2, likes: 12 }],
|
||||
},
|
||||
highlights: {
|
||||
topPhoto: {
|
||||
photoId: 1,
|
||||
guest: 'Sam',
|
||||
likes: 10,
|
||||
task: 'Smile',
|
||||
createdAt: new Date().toISOString(),
|
||||
thumbnail: null,
|
||||
},
|
||||
trendingEmotion: { emotionId: 1, name: 'Joy', count: 8 },
|
||||
timeline: [{ date: '2025-01-01', photos: 4, guests: 2 }],
|
||||
},
|
||||
feed: [
|
||||
{
|
||||
photoId: 9,
|
||||
guest: 'Mia',
|
||||
task: 'Dance',
|
||||
likes: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
thumbnail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
import AchievementsScreen from '../screens/AchievementsScreen';
|
||||
|
||||
describe('AchievementsScreen', () => {
|
||||
it('renders personal achievements content', async () => {
|
||||
render(<AchievementsScreen />);
|
||||
|
||||
expect(await screen.findByText('Badges')).toBeInTheDocument();
|
||||
expect(screen.getByText('First upload')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload photo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
121
resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
Normal file
121
resources/js/guest-v2/__tests__/GalleryScreen.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
const setSearchParamsMock = vi.fn();
|
||||
const pushGuestToastMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams('photo=123'), setSearchParamsMock],
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({ data: { photos: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollStats', () => ({
|
||||
usePollStats: () => ({ stats: { onlineGuests: 0, guestCount: 0, likesCount: 0 } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
if (typeof options === 'string') return options;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/guest/i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bento', () => ({
|
||||
getBentoSurfaceTokens: () => ({
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#eee',
|
||||
borderBottomColor: '#ddd',
|
||||
shadow: 'none',
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchGalleryMock = vi.fn().mockResolvedValue({ data: [] });
|
||||
const fetchPhotoMock = vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
fetchGallery: (...args: unknown[]) => fetchGalleryMock(...args),
|
||||
fetchPhoto: (...args: unknown[]) => fetchPhotoMock(...args),
|
||||
likePhoto: vi.fn(),
|
||||
unlikePhoto: vi.fn(),
|
||||
createPhotoShareLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AppShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/PhotoFrameTile', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
|
||||
}));
|
||||
|
||||
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('lucide-react', () => ({
|
||||
Camera: () => <span>camera</span>,
|
||||
Sparkles: () => <span>sparkles</span>,
|
||||
Heart: () => <span>heart</span>,
|
||||
Share2: () => <span>share</span>,
|
||||
ChevronLeft: () => <span>left</span>,
|
||||
ChevronRight: () => <span>right</span>,
|
||||
X: () => <span>x</span>,
|
||||
}));
|
||||
|
||||
import GalleryScreen from '../screens/GalleryScreen';
|
||||
|
||||
describe('GalleryScreen', () => {
|
||||
it('clears the photo param and shows a warning when lightbox fails to load', async () => {
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushGuestToastMock).toHaveBeenCalled();
|
||||
expect(setSearchParamsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [params] = setSearchParamsMock.mock.calls.at(-1) ?? [];
|
||||
const search = params instanceof URLSearchParams ? params : new URLSearchParams(params);
|
||||
expect(search.get('photo')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
const useEventDataMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
@@ -21,8 +22,8 @@ vi.mock('@tamagui/text', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/button', () => ({
|
||||
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
|
||||
<button type="button" {...rest}>
|
||||
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
@@ -84,11 +85,17 @@ vi.mock('../services/emotionsApi', () => ({
|
||||
fetchEmotions: vi.fn().mockResolvedValue([{ slug: 'freude', name: 'Joy' }]),
|
||||
}));
|
||||
|
||||
const fetchGalleryMock = vi.fn().mockResolvedValue({ data: [] });
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
fetchGallery: vi.fn().mockResolvedValue({ data: [] }),
|
||||
fetchGallery: (...args: unknown[]) => fetchGalleryMock(...args),
|
||||
}));
|
||||
|
||||
const translate = (key: string, fallback?: string) => fallback ?? key;
|
||||
const translate = (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
if (typeof options === 'string') return options;
|
||||
return key;
|
||||
};
|
||||
|
||||
vi.mock('@/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({ t: translate, locale: 'de' }),
|
||||
@@ -119,7 +126,13 @@ vi.mock('@/guest/hooks/useGuestTaskProgress', () => ({
|
||||
import HomeScreen from '../screens/HomeScreen';
|
||||
|
||||
describe('HomeScreen', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
fetchGalleryMock.mockReset();
|
||||
});
|
||||
|
||||
it('shows task hero content when tasks are enabled', async () => {
|
||||
fetchGalleryMock.mockResolvedValueOnce({ data: [] });
|
||||
useEventDataMock.mockReturnValue({
|
||||
tasksEnabled: true,
|
||||
token: 'demo',
|
||||
@@ -133,6 +146,7 @@ describe('HomeScreen', () => {
|
||||
});
|
||||
|
||||
it('shows capture-ready content when tasks are disabled', () => {
|
||||
fetchGalleryMock.mockResolvedValueOnce({ data: [] });
|
||||
useEventDataMock.mockReturnValue({
|
||||
tasksEnabled: false,
|
||||
token: 'demo',
|
||||
@@ -144,4 +158,22 @@ describe('HomeScreen', () => {
|
||||
expect(screen.getByText('Capture ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens gallery lightbox from preview tile', async () => {
|
||||
fetchGalleryMock.mockResolvedValueOnce({
|
||||
data: [{ id: 42, thumbnail_url: '/storage/demo.jpg' }],
|
||||
});
|
||||
useEventDataMock.mockReturnValue({
|
||||
tasksEnabled: false,
|
||||
token: 'demo',
|
||||
event: { name: 'Demo Event' },
|
||||
});
|
||||
|
||||
render(<HomeScreen />);
|
||||
|
||||
const previewButton = await screen.findByRole('button', { name: /foto 42/i });
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/e/demo/gallery?photo=42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ vi.mock('@tamagui/text', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/button', () => ({
|
||||
Button: ({ children, ...rest }: { children: React.ReactNode }) => (
|
||||
<button type="button" {...rest}>
|
||||
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
@@ -24,48 +24,53 @@ vi.mock('../components/AppShell', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({
|
||||
token: 'demo-token',
|
||||
event: { name: 'Demo Event', join_token: 'demo-token' },
|
||||
}),
|
||||
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollStats', () => ({
|
||||
usePollStats: () => ({
|
||||
stats: {
|
||||
onlineGuests: 12,
|
||||
tasksSolved: 0,
|
||||
guestCount: 40,
|
||||
likesCount: 120,
|
||||
latestPhotoAt: null,
|
||||
},
|
||||
}),
|
||||
usePollStats: () => ({ stats: { onlineGuests: 3 } }),
|
||||
}));
|
||||
|
||||
vi.mock('../services/eventLink', () => ({
|
||||
buildEventShareLink: () => 'https://example.test/e/demo',
|
||||
}));
|
||||
|
||||
vi.mock('../services/qrApi', () => ({
|
||||
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: 'data:image/png;base64,abc' }),
|
||||
fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bento', () => ({
|
||||
getBentoSurfaceTokens: () => ({
|
||||
borderColor: '#eee',
|
||||
borderBottomColor: '#ddd',
|
||||
backgroundColor: '#fff',
|
||||
shadow: 'none',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: 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),
|
||||
t: (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
if (typeof options === 'string') return options;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-appearance', () => ({
|
||||
useAppearance: () => ({ resolved: 'light' }),
|
||||
}));
|
||||
|
||||
import ShareScreen from '../screens/ShareScreen';
|
||||
|
||||
describe('ShareScreen', () => {
|
||||
it('shows guests online from stats', async () => {
|
||||
it('renders invite header', async () => {
|
||||
render(<ShareScreen />);
|
||||
|
||||
expect(await screen.findByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guests joined')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guests online')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Event QR code')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Invite guests')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,173 +1,676 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Trophy, Star } from 'lucide-react';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Award, BarChart2, Camera, Flame, Sparkles, Trophy, Users } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import PullToRefresh from '@/guest/components/PullToRefresh';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { fetchAchievements, type AchievementsPayload } from '../services/achievementsApi';
|
||||
import {
|
||||
fetchAchievements,
|
||||
type AchievementBadge,
|
||||
type AchievementsPayload,
|
||||
type FeedEntry,
|
||||
type LeaderboardEntry,
|
||||
type TimelinePoint,
|
||||
type TopPhotoHighlight,
|
||||
type TrendingEmotionHighlight,
|
||||
} from '../services/achievementsApi';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { localizeTaskLabel } from '@/guest/lib/localizeTaskLabel';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const GENERIC_ERROR = 'GENERIC_ERROR';
|
||||
|
||||
type TabKey = 'personal' | 'event' | 'feed';
|
||||
|
||||
type BentoCardProps = {
|
||||
children: React.ReactNode;
|
||||
isDark: boolean;
|
||||
padding?: number;
|
||||
gradient?: string;
|
||||
};
|
||||
|
||||
function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string {
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const diff = date.getTime() - Date.now();
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const abs = Math.abs(diff);
|
||||
if (abs < minute) return formatter.format(0, 'second');
|
||||
if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute');
|
||||
if (abs < day) return formatter.format(Math.round(diff / hour), 'hour');
|
||||
return formatter.format(Math.round(diff / day), 'day');
|
||||
}
|
||||
|
||||
function BentoCard({ children, isDark, padding = 16, gradient }: BentoCardProps) {
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
return (
|
||||
<YStack
|
||||
padding={padding}
|
||||
borderRadius="$bento"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
style={{ boxShadow: surface.shadow, backgroundImage: gradient }}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
type LeaderboardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
entries: LeaderboardEntry[];
|
||||
emptyCopy: string;
|
||||
formatNumber: (value: number) => string;
|
||||
guestFallback: string;
|
||||
};
|
||||
|
||||
function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, guestFallback }: LeaderboardProps) {
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<YStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor="rgba(244, 63, 94, 0.12)"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon size={16} color="#F43F5E" />
|
||||
</YStack>
|
||||
<YStack gap="$0.5">
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{description}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
{entries.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emptyCopy}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack gap="$2">
|
||||
{entries.map((entry, index) => (
|
||||
<XStack
|
||||
key={`${entry.guest}-${index}`}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Text fontSize="$1" fontWeight="$7" color="$color" opacity={0.7}>
|
||||
#{index + 1}
|
||||
</Text>
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{entry.guest || guestFallback}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$3">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{formatNumber(entry.photos)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{formatNumber(entry.likes)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
type BadgesGridProps = {
|
||||
badges: AchievementBadge[];
|
||||
emptyCopy: string;
|
||||
completeCopy: string;
|
||||
};
|
||||
|
||||
function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) {
|
||||
if (badges.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emptyCopy}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{badges.map((badge) => {
|
||||
const target = badge.target ?? 0;
|
||||
const progress = badge.progress ?? 0;
|
||||
const ratio = target > 0 ? Math.min(1, progress / target) : 0;
|
||||
const percentage = Math.round((badge.earned ? 1 : ratio) * 100);
|
||||
return (
|
||||
<YStack
|
||||
key={badge.id}
|
||||
width="48%"
|
||||
minWidth={140}
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
borderWidth={1}
|
||||
borderColor={badge.earned ? 'rgba(16, 185, 129, 0.4)' : 'rgba(15, 23, 42, 0.1)'}
|
||||
backgroundColor={badge.earned ? 'rgba(16, 185, 129, 0.08)' : 'rgba(255, 255, 255, 0.85)'}
|
||||
gap="$1"
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{badge.title}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{badge.description}
|
||||
</Text>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$1" color="$color" opacity={0.6}>
|
||||
{percentage}%
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.6}>
|
||||
{badge.earned ? completeCopy : `${progress}/${target}`}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack height={6} borderRadius={999} backgroundColor="rgba(15, 23, 42, 0.08)">
|
||||
<YStack
|
||||
height={6}
|
||||
borderRadius={999}
|
||||
backgroundColor="rgba(244, 63, 94, 0.8)"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineProps = {
|
||||
points: TimelinePoint[];
|
||||
formatNumber: (value: number) => string;
|
||||
emptyCopy: string;
|
||||
};
|
||||
|
||||
function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emptyCopy}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
{points.map((point) => (
|
||||
<XStack
|
||||
key={point.date}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{point.date}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{formatNumber(point.photos)} photos · {formatNumber(point.guests)} guests
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
type HighlightsProps = {
|
||||
topPhoto: TopPhotoHighlight | null;
|
||||
trendingEmotion: TrendingEmotionHighlight | null;
|
||||
formatRelativeTime: (value: string) => string;
|
||||
locale: string;
|
||||
formatNumber: (value: number) => string;
|
||||
emptyCopy: string;
|
||||
topPhotoTitle: string;
|
||||
topPhotoNoPreview: string;
|
||||
topPhotoFallbackGuest: string;
|
||||
trendingTitle: string;
|
||||
trendingCountLabel: string;
|
||||
};
|
||||
|
||||
function Highlights({
|
||||
topPhoto,
|
||||
trendingEmotion,
|
||||
formatRelativeTime,
|
||||
locale,
|
||||
formatNumber,
|
||||
emptyCopy,
|
||||
topPhotoTitle,
|
||||
topPhotoNoPreview,
|
||||
topPhotoFallbackGuest,
|
||||
trendingTitle,
|
||||
trendingCountLabel,
|
||||
}: HighlightsProps) {
|
||||
if (!topPhoto && !trendingEmotion) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emptyCopy}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{topPhoto ? (
|
||||
<YStack gap="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{topPhotoTitle}
|
||||
</Text>
|
||||
<Trophy size={18} color="#FBBF24" />
|
||||
</XStack>
|
||||
<YStack gap="$2">
|
||||
{topPhoto.thumbnail ? (
|
||||
<img
|
||||
src={topPhoto.thumbnail}
|
||||
alt="Top photo"
|
||||
style={{ width: '100%', height: 180, objectFit: 'cover', borderRadius: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<YStack height={180} borderRadius={16} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{topPhotoNoPreview}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{topPhoto.guest || topPhotoFallbackGuest}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{formatNumber(topPhoto.likes)} likes · {formatRelativeTime(topPhoto.createdAt)}
|
||||
</Text>
|
||||
{topPhoto.task ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{localizeTaskLabel(topPhoto.task ?? null, locale) ?? topPhoto.task}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{trendingEmotion ? (
|
||||
<YStack gap="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{trendingTitle}
|
||||
</Text>
|
||||
<Flame size={18} color="#F43F5E" />
|
||||
</XStack>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{trendingEmotion.name}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{trendingCountLabel.replace('{count}', formatNumber(trendingEmotion.count))}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
type FeedProps = {
|
||||
feed: FeedEntry[];
|
||||
formatRelativeTime: (value: string) => string;
|
||||
locale: string;
|
||||
formatNumber: (value: number) => string;
|
||||
emptyCopy: string;
|
||||
guestFallback: string;
|
||||
likesLabel: string;
|
||||
};
|
||||
|
||||
function Feed({ feed, formatRelativeTime, locale, formatNumber, emptyCopy, guestFallback, likesLabel }: FeedProps) {
|
||||
if (feed.length === 0) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{emptyCopy}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{feed.map((item) => {
|
||||
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
|
||||
return (
|
||||
<XStack
|
||||
key={item.photoId}
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
borderRadius="$card"
|
||||
backgroundColor="rgba(15, 23, 42, 0.05)"
|
||||
borderWidth={1}
|
||||
borderColor="rgba(15, 23, 42, 0.08)"
|
||||
>
|
||||
{item.thumbnail ? (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt="Feed thumbnail"
|
||||
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<YStack width={64} height={64} borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.08)" alignItems="center" justifyContent="center">
|
||||
<Camera size={18} color="#0F172A" />
|
||||
</YStack>
|
||||
)}
|
||||
<YStack flex={1} gap="$1">
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{item.guest || guestFallback}
|
||||
</Text>
|
||||
{taskLabel ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{taskLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack gap="$3">
|
||||
<Text fontSize="$1" color="$color" opacity={0.6}>
|
||||
{formatRelativeTime(item.createdAt)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.6}>
|
||||
{likesLabel.replace('{count}', formatNumber(item.likes))}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AchievementsScreen() {
|
||||
const { token } = useEventData();
|
||||
const { token, tasksEnabled } = useEventData();
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
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 navigate = useNavigate();
|
||||
const surface = getBentoSurfaceTokens(isDark);
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const [payload, setPayload] = React.useState<AchievementsPayload | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>('personal');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) {
|
||||
setPayload(null);
|
||||
return;
|
||||
}
|
||||
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
const formatNumber = (value: number) => numberFormatter.format(value);
|
||||
const relativeFormatter = React.useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]);
|
||||
const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter);
|
||||
|
||||
let active = true;
|
||||
const loadAchievements = React.useCallback(async (forceRefresh = false) => {
|
||||
if (!token) return;
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const data = await fetchAchievements(token, {
|
||||
guestName: identity?.name ?? undefined,
|
||||
locale,
|
||||
forceRefresh,
|
||||
});
|
||||
setPayload(data);
|
||||
setActiveTab(data.personal ? 'personal' : 'event');
|
||||
} catch (err) {
|
||||
console.error('Failed to load achievements', err);
|
||||
setError(err instanceof Error ? err.message : GENERIC_ERROR);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [identity?.name, locale, token]);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, identity?.name, locale, t]);
|
||||
React.useEffect(() => {
|
||||
loadAchievements();
|
||||
}, [loadAchievements]);
|
||||
|
||||
const topPhoto = payload?.highlights?.topPhoto ?? null;
|
||||
const totalPhotos = payload?.summary?.totalPhotos ?? 0;
|
||||
const totalTasks = payload?.summary?.tasksSolved ?? 0;
|
||||
const totalLikes = payload?.summary?.likesTotal ?? 0;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
const hasPersonal = Boolean(payload?.personal);
|
||||
const summary = payload?.summary;
|
||||
const personal = payload?.personal ?? null;
|
||||
const leaderboards = payload?.leaderboards ?? null;
|
||||
const highlights = payload?.highlights ?? null;
|
||||
const feed = payload?.feed ?? [];
|
||||
|
||||
const headerGradient = isDark
|
||||
? 'radial-gradient(120% 120% at 20% 20%, rgba(79, 209, 255, 0.16), transparent 60%), radial-gradient(120% 120% at 80% 0%, rgba(244, 63, 94, 0.18), transparent 60%)'
|
||||
: 'radial-gradient(140% 140% at 20% 10%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 22%, white), transparent 55%), radial-gradient(120% 120% at 80% 0%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 22%, white), transparent 60%)';
|
||||
|
||||
const content = (
|
||||
<YStack gap="$4" paddingBottom={100}>
|
||||
<BentoCard isDark={isDark} padding={20} gradient={headerGradient}>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<YStack
|
||||
width={40}
|
||||
height={40}
|
||||
borderRadius={14}
|
||||
backgroundColor="rgba(244, 63, 94, 0.16)"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Award size={18} color="#F43F5E" />
|
||||
</YStack>
|
||||
<YStack gap="$0.5" flexShrink={1} maxWidth="100%">
|
||||
<Text fontSize="$5" fontWeight="$8">
|
||||
{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 fontSize="$2" color="$color" opacity={0.7} flexWrap="wrap">
|
||||
{t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</BentoCard>
|
||||
|
||||
<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>
|
||||
{summary ? (
|
||||
<XStack gap="$2" flexWrap="nowrap" overflow="hidden">
|
||||
{[
|
||||
{ label: t('achievements.summary.photosShared', 'Photos shared'), value: summary.totalPhotos },
|
||||
{ label: t('achievements.summary.tasksCompleted', 'Tasks completed'), value: summary.tasksSolved },
|
||||
{ label: t('achievements.summary.likesCollected', 'Likes collected'), value: summary.likesTotal },
|
||||
{ label: t('achievements.summary.uniqueGuests', 'Guests involved'), value: summary.uniqueGuests },
|
||||
].map((item) => (
|
||||
<YStack key={item.label} flex={1} minWidth={0}>
|
||||
<BentoCard isDark={isDark} padding={10}>
|
||||
<Text fontSize="$3" fontWeight="$8">
|
||||
{formatNumber(item.value)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} numberOfLines={2}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{([
|
||||
{ key: 'personal', label: t('achievements.page.buttons.personal', 'Personal'), icon: Sparkles, disabled: !hasPersonal },
|
||||
{ key: 'event', label: t('achievements.page.buttons.event', 'Event'), icon: Users, disabled: false },
|
||||
{ key: 'feed', label: t('achievements.page.buttons.feed', 'Feed'), icon: BarChart2, disabled: false },
|
||||
] as const).map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
size="$3"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={activeTab === tab.key ? '$primary' : mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={activeTab === tab.key ? '$primary' : mutedButtonBorder}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
disabled={tab.disabled}
|
||||
opacity={tab.disabled ? 0.5 : 1}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<tab.icon size={14} color={activeTab === tab.key ? '#FFFFFF' : isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$7" color={activeTab === tab.key ? '#FFFFFF' : undefined}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
{loading ? (
|
||||
<YStack gap="$3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<BentoCard key={`loading-${index}`} isDark={isDark}>
|
||||
<YStack height={56} />
|
||||
</BentoCard>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
) : error ? (
|
||||
<BentoCard isDark={isDark}>
|
||||
<Text fontSize="$2" color={isDark ? '#FCA5A5' : '#DC2626'}>
|
||||
{error === GENERIC_ERROR
|
||||
? t('achievements.page.loadError', 'Achievements could not be loaded.')
|
||||
: error}
|
||||
</Text>
|
||||
</BentoCard>
|
||||
) : payload ? (
|
||||
<YStack gap="$4">
|
||||
{activeTab === 'personal' ? (
|
||||
<YStack gap="$4">
|
||||
<BentoCard isDark={isDark}>
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('achievements.badges.title', 'Badges')}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{t('achievements.badges.description', 'Unlock achievements and keep your streak going.')}
|
||||
</Text>
|
||||
<BadgesGrid
|
||||
badges={personal?.badges ?? []}
|
||||
emptyCopy={t('achievements.badges.empty', 'No badges yet.')}
|
||||
completeCopy={t('achievements.badges.complete', 'Complete')}
|
||||
/>
|
||||
</YStack>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'event' ? (
|
||||
<YStack gap="$4">
|
||||
<BentoCard isDark={isDark}>
|
||||
<Highlights
|
||||
topPhoto={highlights?.topPhoto ?? null}
|
||||
trendingEmotion={highlights?.trendingEmotion ?? null}
|
||||
formatRelativeTime={formatRelative}
|
||||
locale={locale}
|
||||
formatNumber={formatNumber}
|
||||
emptyCopy={t('achievements.highlights.empty', 'Highlights will appear as soon as more photos are shared.')}
|
||||
topPhotoTitle={t('achievements.highlights.topTitle', 'Top photo')}
|
||||
topPhotoNoPreview={t('achievements.highlights.noPreview', 'No preview')}
|
||||
topPhotoFallbackGuest={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
trendingTitle={t('achievements.highlights.trendingTitle', 'Trending emotion')}
|
||||
trendingCountLabel={t('achievements.highlights.trendingCount', { count: '{count}' }, '{count} photos')}
|
||||
/>
|
||||
</BentoCard>
|
||||
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<YStack flex={1} minWidth={240} gap="$3">
|
||||
<BentoCard isDark={isDark}>
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('achievements.timeline.title', 'Timeline')}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{t('achievements.timeline.description', 'Daily momentum snapshot.')}
|
||||
</Text>
|
||||
<Timeline
|
||||
points={highlights?.timeline ?? []}
|
||||
formatNumber={formatNumber}
|
||||
emptyCopy={t('achievements.timeline.empty', 'No timeline data yet.')}
|
||||
/>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
<YStack flex={1} minWidth={240} gap="$3">
|
||||
<BentoCard isDark={isDark}>
|
||||
<Leaderboard
|
||||
title={t('achievements.leaderboard.uploadsTitle', 'Top uploads')}
|
||||
description={t('achievements.leaderboard.description', 'Most photos shared.')}
|
||||
icon={Users}
|
||||
entries={leaderboards?.uploads ?? []}
|
||||
emptyCopy={t('achievements.leaderboard.uploadsEmpty', 'No uploads yet.')}
|
||||
formatNumber={formatNumber}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
/>
|
||||
</BentoCard>
|
||||
<BentoCard isDark={isDark}>
|
||||
<Leaderboard
|
||||
title={t('achievements.leaderboard.likesTitle', 'Top likes')}
|
||||
description={t('achievements.leaderboard.description', 'Most loved moments.')}
|
||||
icon={Trophy}
|
||||
entries={leaderboards?.likes ?? []}
|
||||
emptyCopy={t('achievements.leaderboard.likesEmpty', 'No likes yet.')}
|
||||
formatNumber={formatNumber}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
/>
|
||||
</BentoCard>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'feed' ? (
|
||||
<BentoCard isDark={isDark}>
|
||||
<Feed
|
||||
feed={feed}
|
||||
formatRelativeTime={formatRelative}
|
||||
locale={locale}
|
||||
formatNumber={formatNumber}
|
||||
emptyCopy={t('achievements.feed.empty', 'The feed is quiet for now.')}
|
||||
guestFallback={t('achievements.leaderboard.guestFallback', 'Guest')}
|
||||
likesLabel={t('achievements.feed.likesLabel', { count: '{count}' }, '{count} likes')}
|
||||
/>
|
||||
</BentoCard>
|
||||
) : null}
|
||||
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PullToRefresh
|
||||
onRefresh={() => loadAchievements(true)}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
releaseLabel={t('common.releaseToRefresh')}
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
{content}
|
||||
</PullToRefresh>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function GalleryScreen() {
|
||||
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
const [lightboxPhoto, setLightboxPhoto] = React.useState<LightboxPhoto | null>(null);
|
||||
const [lightboxLoading, setLightboxLoading] = React.useState(false);
|
||||
const [lightboxError, setLightboxError] = React.useState<'notFound' | 'loadFailed' | null>(null);
|
||||
const [likesById, setLikesById] = React.useState<Record<number, number>>({});
|
||||
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
|
||||
url: null,
|
||||
@@ -82,6 +83,7 @@ export default function GalleryScreen() {
|
||||
});
|
||||
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
||||
const touchStartX = React.useRef<number | null>(null);
|
||||
const fallbackAttemptedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -182,6 +184,23 @@ export default function GalleryScreen() {
|
||||
setFilter('latest');
|
||||
}
|
||||
}, [filter, photos]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fallbackAttemptedRef.current = false;
|
||||
}, [selectedPhotoId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lightboxOpen || !selectedPhotoId) {
|
||||
return;
|
||||
}
|
||||
if (lightboxIndex >= 0) {
|
||||
return;
|
||||
}
|
||||
if (filter !== 'latest' && !fallbackAttemptedRef.current) {
|
||||
fallbackAttemptedRef.current = true;
|
||||
setFilter('latest');
|
||||
}
|
||||
}, [filter, lightboxIndex, lightboxOpen, selectedPhotoId]);
|
||||
const newUploads = React.useMemo(() => {
|
||||
if (delta.photos.length === 0) {
|
||||
return 0;
|
||||
@@ -275,6 +294,7 @@ export default function GalleryScreen() {
|
||||
if (!lightboxOpen) {
|
||||
setLightboxPhoto(null);
|
||||
setLightboxLoading(false);
|
||||
setLightboxError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -287,6 +307,7 @@ export default function GalleryScreen() {
|
||||
|
||||
let active = true;
|
||||
setLightboxLoading(true);
|
||||
setLightboxError(null);
|
||||
fetchPhoto(selectedPhotoId, locale)
|
||||
.then((photo) => {
|
||||
if (!active || !photo) return;
|
||||
@@ -298,6 +319,8 @@ export default function GalleryScreen() {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Lightbox photo load failed', error);
|
||||
if (!active) return;
|
||||
setLightboxError(error?.status === 404 ? 'notFound' : 'loadFailed');
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
@@ -328,6 +351,19 @@ export default function GalleryScreen() {
|
||||
};
|
||||
}, [lightboxOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lightboxOpen || !lightboxError) {
|
||||
return;
|
||||
}
|
||||
pushGuestToast({
|
||||
text: lightboxError === 'notFound'
|
||||
? t('lightbox.errors.notFound', 'Photo not found')
|
||||
: t('lightbox.errors.loadFailed', 'Failed to load photo'),
|
||||
type: 'warning',
|
||||
});
|
||||
closeLightbox();
|
||||
}, [closeLightbox, lightboxError, lightboxOpen, t]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
if (lightboxIndex <= 0) return;
|
||||
const prevId = displayPhotos[lightboxIndex - 1]?.id;
|
||||
|
||||
@@ -512,10 +512,16 @@ export default function HomeScreen() {
|
||||
likesCount: photo.likesCount,
|
||||
}));
|
||||
}, [currentTask, galleryPhotos]);
|
||||
const openPreviewPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
navigate(buildEventPath(token, `/gallery?photo=${photoId}`));
|
||||
},
|
||||
[navigate, token]
|
||||
);
|
||||
const openTaskPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!currentTask) return;
|
||||
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${currentTask.id}`));
|
||||
navigate(buildEventPath(token, `/gallery?photo=${photoId}&task=${currentTask.id}`));
|
||||
},
|
||||
[currentTask, navigate, token]
|
||||
);
|
||||
@@ -708,18 +714,24 @@ export default function HomeScreen() {
|
||||
}
|
||||
return (
|
||||
<YStack key={tile.id} flexShrink={0} width={140}>
|
||||
<PhotoFrameTile height={110} borderRadius="$bento">
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
backgroundImage: `url(${tile.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</PhotoFrameTile>
|
||||
<Button
|
||||
unstyled
|
||||
onPress={() => openPreviewPhoto(tile.id)}
|
||||
aria-label={t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`)}
|
||||
>
|
||||
<PhotoFrameTile height={110} borderRadius="$bento">
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
backgroundImage: `url(${tile.imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</PhotoFrameTile>
|
||||
</Button>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Share2, QrCode, Link, Users, Loader2, RefreshCcw } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { buildEventShareLink } from '../services/eventLink';
|
||||
@@ -10,19 +10,37 @@ import { usePollStats } from '../hooks/usePollStats';
|
||||
import { fetchEventQrCode } from '../services/qrApi';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
|
||||
export default function ShareScreen() {
|
||||
const { event, token } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
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 surface = getBentoSurfaceTokens(isDark);
|
||||
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
const { stats } = usePollStats(token ?? null);
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = React.useState('');
|
||||
const [qrLoading, setQrLoading] = React.useState(false);
|
||||
const [qrError, setQrError] = React.useState(false);
|
||||
const shareUrl = buildEventShareLink(event, token);
|
||||
|
||||
const loadQrCode = React.useCallback(async () => {
|
||||
if (!token) return;
|
||||
setQrLoading(true);
|
||||
setQrError(false);
|
||||
try {
|
||||
const payload = await fetchEventQrCode(token, 240);
|
||||
setQrCodeDataUrl(payload.qr_code_data_url ?? '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load QR code', error);
|
||||
setQrCodeDataUrl('');
|
||||
setQrError(true);
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!shareUrl) {
|
||||
return;
|
||||
@@ -30,13 +48,15 @@ export default function ShareScreen() {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(shareUrl);
|
||||
setCopyState('copied');
|
||||
pushGuestToast({ text: t('share.copySuccess', 'Link copied!'), type: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Copy failed', error);
|
||||
setCopyState('failed');
|
||||
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
|
||||
} finally {
|
||||
window.setTimeout(() => setCopyState('idle'), 2000);
|
||||
}
|
||||
}, [shareUrl]);
|
||||
}, [shareUrl, t]);
|
||||
|
||||
const handleShare = React.useCallback(async () => {
|
||||
if (!shareUrl) return;
|
||||
@@ -57,52 +77,29 @@ export default function ShareScreen() {
|
||||
if (!token) {
|
||||
setQrCodeDataUrl('');
|
||||
setQrLoading(false);
|
||||
setQrError(false);
|
||||
return;
|
||||
}
|
||||
loadQrCode();
|
||||
}, [loadQrCode, token]);
|
||||
|
||||
let active = true;
|
||||
setQrLoading(true);
|
||||
|
||||
fetchEventQrCode(token, 240)
|
||||
.then((payload) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setQrCodeDataUrl(payload.qr_code_data_url ?? '');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load QR code', error);
|
||||
setQrCodeDataUrl('');
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setQrLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const guestCountLabel = stats.onlineGuests.toString();
|
||||
const guestCountLabel = (stats.onlineGuests ?? 0).toString();
|
||||
const inviteDisabled = !shareUrl;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<YStack gap="$4">
|
||||
<YStack gap="$4" paddingBottom={80}>
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
gap="$2"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
@@ -116,18 +113,22 @@ export default function ShareScreen() {
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<XStack gap="$3">
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<YStack
|
||||
flex={1}
|
||||
height={180}
|
||||
minWidth={220}
|
||||
borderRadius="$card"
|
||||
backgroundColor="$muted"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap="$2"
|
||||
style={{
|
||||
boxShadow: surface.shadow,
|
||||
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%)',
|
||||
@@ -139,25 +140,52 @@ export default function ShareScreen() {
|
||||
alt={t('share.invite.qrAlt', 'Event QR code')}
|
||||
style={{ width: 120, height: 120, borderRadius: 16 }}
|
||||
/>
|
||||
) : qrLoading ? (
|
||||
<Loader2 size={22} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
) : (
|
||||
<QrCode size={qrLoading ? 22 : 28} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<QrCode size={28} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
)}
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('share.invite.qrLabel', 'Show QR')}
|
||||
</Text>
|
||||
{qrLoading ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('share.invite.qrLoading', 'Generating QR…')}
|
||||
</Text>
|
||||
) : null}
|
||||
{qrError ? (
|
||||
<Button
|
||||
size="$2"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'}
|
||||
borderWidth={1}
|
||||
borderColor={surface.borderColor}
|
||||
onPress={loadQrCode}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$1" fontWeight="$6">
|
||||
{t('share.invite.qrRetry', 'Retry')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack
|
||||
flex={1}
|
||||
height={180}
|
||||
minWidth={220}
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
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)',
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<Link size={24} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
@@ -168,6 +196,16 @@ export default function ShareScreen() {
|
||||
? t('share.copyError', 'Copy failed')
|
||||
: t('share.invite.copyLabel', 'Copy link')}
|
||||
</Text>
|
||||
{shareUrl ? (
|
||||
<Text
|
||||
fontSize="$1"
|
||||
color="$color"
|
||||
opacity={0.7}
|
||||
style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{shareUrl}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button size="$2" backgroundColor="$primary" borderRadius="$pill" onPress={handleCopy} disabled={!shareUrl}>
|
||||
{t('share.copyLink', 'Copy link')}
|
||||
</Button>
|
||||
@@ -177,15 +215,17 @@ export default function ShareScreen() {
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
backgroundColor={surface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={surface.borderColor}
|
||||
borderBottomColor={surface.borderBottomColor}
|
||||
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)',
|
||||
boxShadow: surface.shadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
@@ -204,7 +244,7 @@ export default function ShareScreen() {
|
||||
</YStack>
|
||||
<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.guestsSubtitleEvent', { event: event.name }, 'Share {event} with your guests.')
|
||||
: t('share.invite.guestsSubtitle', 'Share the event with your guests.')}
|
||||
</Text>
|
||||
<Button
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function TasksScreen() {
|
||||
const handleOpenPhoto = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!highlight) return;
|
||||
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${highlight.id}`));
|
||||
navigate(buildEventPath(token, `/gallery?photo=${photoId}&task=${highlight.id}`));
|
||||
},
|
||||
[highlight, navigate, token]
|
||||
);
|
||||
|
||||
@@ -392,6 +392,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
tasksCompleted: 'Aufgaben erledigt',
|
||||
photosShared: 'Fotos geteilt',
|
||||
likesCollected: 'Likes gesammelt',
|
||||
uniqueGuests: 'Gäste beteiligt',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
@@ -524,6 +525,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
description: 'Teile den Event-Link oder zeige den QR-Code.',
|
||||
qrLabel: 'QR anzeigen',
|
||||
qrAlt: 'Event-QR-Code',
|
||||
qrLoading: 'QR wird erstellt...',
|
||||
qrRetry: 'Erneut versuchen',
|
||||
copyLabel: 'Link kopieren',
|
||||
guestsTitle: 'Gäste',
|
||||
guestsSubtitle: 'Teile das Event mit deinen Gästen.',
|
||||
@@ -1305,6 +1308,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
tasksCompleted: 'Tasks completed',
|
||||
photosShared: 'Photos shared',
|
||||
likesCollected: 'Likes collected',
|
||||
uniqueGuests: 'Guests involved',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
@@ -1434,6 +1438,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
description: 'Share the event link or show the QR code to join.',
|
||||
qrLabel: 'Show QR',
|
||||
qrAlt: 'Event QR code',
|
||||
qrLoading: 'Generating QR...',
|
||||
qrRetry: 'Retry',
|
||||
copyLabel: 'Copy link',
|
||||
guestsTitle: 'Guests',
|
||||
guestsSubtitle: 'Share the event with your guests.',
|
||||
|
||||
@@ -18,18 +18,6 @@ if (shouldEnableGuestDemoMode()) {
|
||||
enableGuestDemoMode();
|
||||
}
|
||||
const rootEl = document.getElementById('root')!;
|
||||
const isShareRoute = typeof window !== 'undefined' && window.location.pathname.startsWith('/share/');
|
||||
|
||||
const shareRoot = async () => {
|
||||
const { SharedPhotoStandalone } = await import('./pages/SharedPhotoPage');
|
||||
createRoot(rootEl).render(
|
||||
<Sentry.ErrorBoundary fallback={<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />}>
|
||||
<React.StrictMode>
|
||||
<SharedPhotoStandalone />
|
||||
</React.StrictMode>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const appRoot = async () => {
|
||||
const { RouterProvider } = await import('react-router-dom');
|
||||
@@ -68,12 +56,6 @@ const appRoot = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
if (isShareRoute) {
|
||||
shareRoot().catch(() => {
|
||||
createRoot(rootEl).render(<GuestFallback message="Dieses Foto kann gerade nicht geladen werden." />);
|
||||
});
|
||||
} else {
|
||||
appRoot().catch(() => {
|
||||
createRoot(rootEl).render(<GuestFallback message="Erlebnisse können nicht geladen werden." />);
|
||||
});
|
||||
}
|
||||
appRoot().catch(() => {
|
||||
createRoot(rootEl).render(<GuestFallback message="Erlebnisse können nicht geladen werden." />);
|
||||
});
|
||||
|
||||
@@ -1,615 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { Link, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
import { Heart, Image as ImageIcon, ImagePlus, Share2, Users } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, type EventData } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useToast } from '../components/ToastHost';
|
||||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||||
import { shouldShowPhotoboothFilter } from '../lib/galleryFilters';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
FADE_SCALE,
|
||||
FADE_UP,
|
||||
STAGGER_FAST,
|
||||
getMotionContainerPropsForNavigation,
|
||||
getMotionItemPropsForNavigation,
|
||||
prefersReducedMotion,
|
||||
} from '../lib/motion';
|
||||
import PullToRefresh from '../components/PullToRefresh';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
|
||||
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||
type GalleryPhoto = {
|
||||
id: number;
|
||||
likes_count?: number | null;
|
||||
created_at?: string | null;
|
||||
ingest_source?: string | null;
|
||||
session_id?: string | null;
|
||||
task_id?: number | null;
|
||||
task_title?: string | null;
|
||||
emotion_id?: number | null;
|
||||
emotion_name?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
file_path?: string | null;
|
||||
title?: string | null;
|
||||
uploader_name?: string | null;
|
||||
};
|
||||
|
||||
const 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 GalleryPage() {
|
||||
const { token } = useParams<{ token?: string }>();
|
||||
const navigationType = useNavigationType();
|
||||
const { t, locale } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
const stats = useEventStats();
|
||||
const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
const modeParam = searchParams.get('mode');
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||
const motionEnabled = !prefersReducedMotion();
|
||||
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType);
|
||||
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType);
|
||||
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
|
||||
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
|
||||
const [event, setEvent] = useState<EventData | null>(null);
|
||||
const [eventLoading, setEventLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
|
||||
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
const [shareSheet, setShareSheet] = React.useState<{ photo: GalleryPhoto | null; url: string | null; loading: boolean }>({
|
||||
photo: null,
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const typedPhotos = photos as GalleryPhoto[];
|
||||
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]);
|
||||
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
|
||||
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
|
||||
[showPhotoboothFilter],
|
||||
);
|
||||
const parseGalleryFilter = React.useCallback(
|
||||
(value: string | null): GalleryFilter =>
|
||||
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest',
|
||||
[allowedGalleryFilters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterState(parseGalleryFilter(modeParam));
|
||||
}, [modeParam, parseGalleryFilter]);
|
||||
|
||||
const setFilter = React.useCallback((next: GalleryFilter) => {
|
||||
setFilterState(next);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('mode', next);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter === 'photobooth' && !showPhotoboothFilter) {
|
||||
setFilter('latest');
|
||||
}
|
||||
}, [filter, showPhotoboothFilter, setFilter]);
|
||||
|
||||
// Auto-open lightbox if photoId in query params
|
||||
useEffect(() => {
|
||||
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
|
||||
const index = typedPhotos.findIndex((photo) => photo.id === parseInt(photoIdParam, 10));
|
||||
if (index !== -1) {
|
||||
setCurrentPhotoIndex(index);
|
||||
setHasOpenedPhoto(true);
|
||||
}
|
||||
}
|
||||
}, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
|
||||
|
||||
// Load event and package info
|
||||
const loadEventData = React.useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setEventLoading(true);
|
||||
const eventData = await fetchEvent(token);
|
||||
setEvent(eventData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load event data', err);
|
||||
} finally {
|
||||
setEventLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEventData();
|
||||
}, [loadEventData]);
|
||||
|
||||
const handleRefresh = React.useCallback(async () => {
|
||||
await Promise.all([refreshNow(), loadEventData()]);
|
||||
acknowledgeNew();
|
||||
}, [acknowledgeNew, loadEventData, refreshNow]);
|
||||
|
||||
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>(); }
|
||||
}, []);
|
||||
|
||||
const list = React.useMemo(() => {
|
||||
let arr = typedPhotos.slice();
|
||||
if (filter === 'popular') {
|
||||
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else if (filter === 'mine') {
|
||||
arr = arr.filter((p) => myPhotoIds.has(p.id));
|
||||
} else if (filter === 'photobooth') {
|
||||
arr = arr.filter((p) => p.ingest_source === 'photobooth');
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
} else {
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
return arr;
|
||||
}, [typedPhotos, filter, myPhotoIds]);
|
||||
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
||||
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
||||
|
||||
async function onLike(id: number) {
|
||||
if (liked.has(id)) return;
|
||||
setLiked(new Set(liked).add(id));
|
||||
try {
|
||||
const c = await likePhoto(id);
|
||||
setCounts((m) => ({ ...m, [id]: c }));
|
||||
triggerHaptic('selection');
|
||||
// keep a simple record of liked items
|
||||
try {
|
||||
const raw = localStorage.getItem('liked-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist liked-photo-ids', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Like failed', error);
|
||||
const s = new Set(liked); s.delete(id); setLiked(s);
|
||||
}
|
||||
}
|
||||
|
||||
const buildShareText = (fallback?: string) => {
|
||||
const eventName = event?.name ?? fallback ?? 'Fotospiel';
|
||||
const base = t('share.shareText', 'Schau dir diesen Moment bei Fotospiel an.');
|
||||
return `${eventName} – ${base}`;
|
||||
};
|
||||
|
||||
async function onShare(photo: GalleryPhoto) {
|
||||
if (!token) return;
|
||||
setShareSheet({ photo, url: null, loading: true });
|
||||
setShareTargetId(photo.id);
|
||||
try {
|
||||
const url = await ensureShareUrl(photo);
|
||||
setShareSheet({ photo, url, loading: false });
|
||||
} catch (error) {
|
||||
console.error('share failed', error);
|
||||
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
} finally {
|
||||
setShareTargetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureShareUrl(photo: GalleryPhoto): Promise<string> {
|
||||
if (!token) throw new Error('missing token');
|
||||
const payload = await createPhotoShareLink(token, photo.id);
|
||||
return payload.url;
|
||||
}
|
||||
|
||||
function shareNative(url?: string | null) {
|
||||
if (!shareSheet.photo || !url) return;
|
||||
const localizedTask = localizeTaskLabel(shareSheet.photo.task_title ?? null, locale);
|
||||
const data: ShareData = {
|
||||
title: localizedTask ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
|
||||
text: buildShareText(),
|
||||
url,
|
||||
};
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||
navigator.share(data).catch(() => {
|
||||
// user cancelled; no toast
|
||||
});
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
return;
|
||||
}
|
||||
void copyLink(url);
|
||||
}
|
||||
|
||||
function shareWhatsApp(url?: string) {
|
||||
if (!url) return;
|
||||
const text = `${buildShareText()} ${url}`;
|
||||
const waUrl = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||
window.open(waUrl, '_blank', 'noopener');
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
}
|
||||
|
||||
function shareMessages(url?: string) {
|
||||
if (!url) return;
|
||||
const text = `${buildShareText()} ${url}`;
|
||||
const smsUrl = `sms:?&body=${encodeURIComponent(text)}`;
|
||||
window.open(smsUrl, '_blank', 'noopener');
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
}
|
||||
|
||||
async function copyLink(url?: string | null) {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(url);
|
||||
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
|
||||
} catch {
|
||||
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
|
||||
} finally {
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
function closeShareSheet() {
|
||||
setShareSheet({ photo: null, url: null, loading: false });
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Page title={t('galleryPage.title', 'Galerie')}>
|
||||
<p>{t('galleryPage.eventNotFound', 'Event nicht gefunden.')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventLoading) {
|
||||
return (
|
||||
<Page title={t('galleryPage.title', 'Galerie')}>
|
||||
<p>{t('galleryPage.loadingEvent', 'Lade Event-Info...')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const newPhotosBadgeText = t('galleryPage.badge.newPhotos', {
|
||||
count: numberFormatter.format(newCount),
|
||||
}, `${newCount} neue Fotos`);
|
||||
const badgeEmphasisClass = newCount > 0
|
||||
? 'border border-pink-200 bg-pink-500/15 text-pink-600'
|
||||
: 'border border-transparent bg-muted text-muted-foreground';
|
||||
const uploadUrl = token ? `/e/${encodeURIComponent(token)}/upload` : '/event';
|
||||
const heroStatsLine = t('galleryPage.hero.stats', {
|
||||
photoCount: numberFormatter.format(list.length),
|
||||
likeCount: numberFormatter.format(stats.likesCount ?? 0),
|
||||
guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0),
|
||||
}, `${numberFormatter.format(list.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`);
|
||||
const bentoShadow =
|
||||
'shadow-[10px_10px_0_rgba(15,23,42,0.85)] dark:shadow-[10px_10px_0_rgba(15,23,42,0.6)]';
|
||||
|
||||
return (
|
||||
<Page title="">
|
||||
<div className="relative">
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
releaseLabel={t('common.releaseToRefresh')}
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<motion.div className="space-y-6 pb-24" {...containerMotion}>
|
||||
<motion.div className="space-y-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||
<div className="grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-[28px] border-2 border-slate-900/80 bg-slate-950 text-white',
|
||||
bentoShadow
|
||||
)}
|
||||
style={{
|
||||
borderRadius: radius + 14,
|
||||
background: `radial-gradient(120% 120% at 90% 0%, ${branding.secondaryColor}55 0%, transparent 60%), linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-40 [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.45),transparent_45%),radial-gradient(circle_at_80%_20%,rgba(255,255,255,0.25),transparent_40%)]" />
|
||||
<div className="absolute inset-0 opacity-25 [background-image:linear-gradient(135deg,rgba(255,255,255,0.12)_12%,transparent_12%),linear-gradient(225deg,rgba(255,255,255,0.12)_12%,transparent_12%)] [background-size:16px_16px]" />
|
||||
<div className="relative z-10 flex h-full flex-col gap-4 p-6 sm:p-7">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-white/80">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/15">
|
||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span>{t('galleryPage.hero.label')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1
|
||||
className="text-3xl font-bold leading-tight sm:text-4xl"
|
||||
style={headingFont ? { fontFamily: headingFont } : undefined}
|
||||
>
|
||||
{event?.name ?? t('galleryPage.hero.eventFallback')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-sm text-white/85 sm:text-base">
|
||||
{t('galleryPage.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="rounded-full border border-white/30 bg-white/10 px-3 py-1">
|
||||
{heroStatsLine}
|
||||
</span>
|
||||
{newCount > 0 && (
|
||||
<span className="rounded-full border border-white/30 bg-black/30 px-3 py-1">
|
||||
{newPhotosBadgeText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 rounded-full bg-white text-slate-900 shadow-[6px_6px_0_rgba(15,23,42,0.7)] transition hover:-translate-y-0.5 hover:bg-white/90"
|
||||
>
|
||||
<Link to={uploadUrl} className="flex items-center gap-2">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
{t('galleryPage.hero.upload', 'Neues Foto hochladen')}
|
||||
</Link>
|
||||
</Button>
|
||||
{newCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className="rounded-full border border-white/30 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white/90 transition hover:bg-white/20"
|
||||
>
|
||||
{t('galleryPage.badge.markSeen', 'Gesehen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[26px] border-2 border-slate-900/80 bg-white/90 p-5 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white',
|
||||
)}
|
||||
style={{ borderRadius: radius + 10 }}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
{t('galleryPage.feed.title', 'Live-Feed')}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-700 dark:text-white/80">
|
||||
{t('galleryPage.feed.description', 'Alle paar Sekunden aktualisiert.')}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-800 shadow-[4px_4px_0_rgba(15,23,42,0.8)] dark:border-white/10 dark:bg-slate-900 dark:text-white">
|
||||
<span>{t('galleryPage.feed.newUploads', '{count} neue Uploads sind da.').replace('{count}', `${newCount}`)}</span>
|
||||
<span className={cn('rounded-full px-2 py-0.5', badgeEmphasisClass)}>{newCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-4 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
Likes
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-2xl font-bold">
|
||||
<Heart className="h-5 w-5 text-pink-500" />
|
||||
{numberFormatter.format(stats.likesCount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-4 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
{t('galleryPage.hero.statsGuests', 'Gäste online')}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-2xl font-bold">
|
||||
<Users className="h-5 w-5 text-slate-700 dark:text-white" />
|
||||
{numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-2 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<FiltersBar
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
className="mt-0"
|
||||
showPhotobooth={showPhotoboothFilter}
|
||||
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{loading && (
|
||||
<motion.p className="px-1" {...fadeUpMotion}>
|
||||
{t('galleryPage.loading', 'Lade…')}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
|
||||
{list.map((p: GalleryPhoto, idx: number) => {
|
||||
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||
const createdLabel = p.created_at
|
||||
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: t('galleryPage.photo.justNow', 'Gerade eben');
|
||||
const likeCount = counts[p.id] ?? (p.likes_count || 0);
|
||||
const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale);
|
||||
const altSuffix = localizedTaskTitle
|
||||
? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle })
|
||||
: '';
|
||||
const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
|
||||
|
||||
const openPhoto = () => {
|
||||
const index = list.findIndex((photo) => photo.id === p.id);
|
||||
setCurrentPhotoIndex(index >= 0 ? index : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPhoto}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
openPhoto();
|
||||
}
|
||||
}}
|
||||
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400 dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
|
||||
style={{ borderRadius: radius }}
|
||||
{...fadeScaleMotion}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
decoding="async"
|
||||
loading={idx < 6 ? 'eager' : 'lazy'}
|
||||
fetchPriority={idx < 6 ? 'high' : 'auto'}
|
||||
className="aspect-[3/4] w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/0 to-transparent" aria-hidden />
|
||||
</div>
|
||||
<div className="space-y-2 px-3 pb-3 pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
|
||||
{localizedTaskTitle && (
|
||||
<p
|
||||
className="text-sm font-semibold leading-tight line-clamp-2 text-foreground"
|
||||
style={headingFont ? { fontFamily: headingFont } : undefined}
|
||||
>
|
||||
{localizedTaskTitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">{createdLabel}</span>
|
||||
<span className="truncate">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLike(p.id);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
|
||||
liked.has(p.id) ? 'border-pink-200 bg-pink-50 text-pink-600' : 'hover:bg-muted/40'
|
||||
)}
|
||||
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<Heart className={`h-3.5 w-3.5 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||
{likeCount}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(p);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
|
||||
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-muted/40'
|
||||
)}
|
||||
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
|
||||
disabled={shareTargetId === p.id}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{t('galleryPage.photo.shareLabel', 'Teilen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
|
||||
<motion.div
|
||||
key={`placeholder-${idx}`}
|
||||
className="relative overflow-hidden border border-muted/40 bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
|
||||
style={{ borderRadius: radius }}
|
||||
{...fadeScaleMotion}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/60 via-white/30 to-transparent dark:from-white/5 dark:via-white/0" aria-hidden />
|
||||
<div className="flex aspect-[3/4] items-center justify-center gap-2 p-4 text-muted-foreground/70">
|
||||
<ImageIcon className="h-6 w-6" aria-hidden />
|
||||
<div className="h-2 w-10 rounded-full bg-muted/40" />
|
||||
</div>
|
||||
<div className="absolute inset-0 animate-pulse bg-white/30 dark:bg-white/5" aria-hidden />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
{currentPhotoIndex !== null && list.length > 0 && (
|
||||
<PhotoLightbox
|
||||
photos={list}
|
||||
currentIndex={currentPhotoIndex}
|
||||
onClose={() => setCurrentPhotoIndex(null)}
|
||||
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
|
||||
token={token}
|
||||
eventName={event?.name ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ShareSheet
|
||||
open={Boolean(shareSheet.photo)}
|
||||
photoId={shareSheet.photo?.id ?? null}
|
||||
eventName={event?.name ?? null}
|
||||
url={shareSheet.url}
|
||||
loading={shareSheet.loading}
|
||||
onClose={closeShareSheet}
|
||||
onShareNative={() => shareNative(shareSheet.url)}
|
||||
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
||||
onShareMessages={() => shareMessages(shareSheet.url)}
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
radius={radius}
|
||||
bodyFont={bodyFont}
|
||||
headingFont={headingFont}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { getHelpArticle, type HelpArticleDetail } from '../services/helpApi';
|
||||
import PullToRefresh from '../components/PullToRefresh';
|
||||
|
||||
export default function HelpArticlePage() {
|
||||
const params = useParams<{ token?: string; slug: string }>();
|
||||
const slug = params.slug;
|
||||
const { locale } = useLocale();
|
||||
const { t } = useTranslation();
|
||||
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'));
|
||||
|
||||
return (
|
||||
<Page title={title}>
|
||||
<PullToRefresh
|
||||
onRefresh={loadArticle}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
releaseLabel={t('common.releaseToRefresh')}
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Button variant="outline" size="sm" className="rounded-full border-border/60 bg-background/70 px-3" asChild>
|
||||
<Link to={basePath}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
{t('help.article.back')}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-5">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{t('help.article.loadingTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('help.article.loadingDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 animate-pulse">
|
||||
<div className="h-3 w-2/3 rounded-full bg-muted/60" />
|
||||
<div className="h-3 w-5/6 rounded-full bg-muted/60" />
|
||||
<div className="h-3 w-1/2 rounded-full bg-muted/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.article.unavailable')}</p>
|
||||
<Button variant="secondary" size="sm" onClick={loadArticle}>
|
||||
{t('help.article.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && article && (
|
||||
<article className="space-y-6">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{article.updated_at && (
|
||||
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
{article.related && article.related.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.related.map((rel) => (
|
||||
<Button
|
||||
key={rel.slug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
|
||||
{rel.title ?? rel.slug}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, RefreshCcw } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { getHelpArticles, type HelpArticleSummary } from '../services/helpApi';
|
||||
import PullToRefresh from '../components/PullToRefresh';
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const params = useParams<{ token?: string }>();
|
||||
const { locale } = useLocale();
|
||||
const { t } = useTranslation();
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Page title={t('help.center.title')}>
|
||||
<PullToRefresh
|
||||
onRefresh={() => loadArticles(true)}
|
||||
pullLabel={t('common.pullToRefresh')}
|
||||
releaseLabel={t('common.releaseToRefresh')}
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="flex-1"
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:w-auto"
|
||||
onClick={() => loadArticles(true)}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('help.center.retry')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{showOfflineBadge && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
<span>{t('help.center.offlineDescription')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2>
|
||||
{state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.center.error')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => loadArticles(false)}
|
||||
>
|
||||
{t('help.center.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
||||
{t('help.center.empty')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredArticles.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
to={`${basePath}/${encodeURIComponent(article.slug)}`}
|
||||
className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{article.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{article.updated_at ? formatDate(article.updated_at, locale) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</PullToRefresh>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
|
||||
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
||||
import { AlertTriangle, Download, Loader2, Share, X } from 'lucide-react';
|
||||
import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { getContrastingTextColor } from '../lib/color';
|
||||
import { applyGuestTheme } from '../lib/guestTheme';
|
||||
|
||||
interface 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 GALLERY_PAGE_SIZE = 30;
|
||||
|
||||
export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<GalleryState>(INITIAL_STATE);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
|
||||
const [shareLoading, setShareLoading] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
|
||||
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
|
||||
const effectiveLocale: LocaleCode = storedLocale && isLocaleCode(storedLocale) ? storedLocale : DEFAULT_LOCALE;
|
||||
|
||||
const applyMeta = useCallback((meta: GalleryMetaResponse) => {
|
||||
if (typeof window !== 'undefined' && token) {
|
||||
localStorage.setItem(localeStorageKey, effectiveLocale);
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
meta,
|
||||
}));
|
||||
}, [effectiveLocale, localeStorageKey, token]);
|
||||
|
||||
const loadInitial = useCallback(async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
|
||||
|
||||
try {
|
||||
const meta = await fetchGalleryMeta(token, effectiveLocale);
|
||||
applyMeta(meta);
|
||||
|
||||
const photoResponse = await fetchGalleryPhotos(token, null, GALLERY_PAGE_SIZE);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
photos: photoResponse.data,
|
||||
cursor: photoResponse.next_cursor,
|
||||
}));
|
||||
} 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'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [token, applyMeta, effectiveLocale, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
const resolvedBranding = useMemo(() => {
|
||||
if (!state.meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = state.meta.branding.palette ?? {};
|
||||
const primary = palette.primary ?? state.meta.branding.primary_color ?? '#FF5A5F';
|
||||
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? '#FFF8F5';
|
||||
const background = palette.background ?? state.meta.branding.background_color ?? '#ffffff';
|
||||
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
|
||||
const mode = state.meta.branding.mode ?? 'auto';
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
background,
|
||||
surface,
|
||||
mode,
|
||||
};
|
||||
}, [state.meta]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedBranding) {
|
||||
return;
|
||||
}
|
||||
|
||||
return applyGuestTheme(resolvedBranding);
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!token || !state.cursor || state.loadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loadingMore: true }));
|
||||
|
||||
try {
|
||||
const response = await fetchGalleryPhotos(token, state.cursor, GALLERY_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, token, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.cursor || !sentinelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const firstEntry = entries[0];
|
||||
if (firstEntry?.isIntersecting) {
|
||||
loadMore();
|
||||
}
|
||||
}, {
|
||||
rootMargin: '400px',
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
observer.observe(sentinelRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [state.cursor, loadMore]);
|
||||
|
||||
const themeStyles = useMemo(() => {
|
||||
if (!resolvedBranding) {
|
||||
return {} as React.CSSProperties;
|
||||
}
|
||||
|
||||
return {
|
||||
'--gallery-primary': resolvedBranding.primary,
|
||||
'--gallery-secondary': resolvedBranding.secondary,
|
||||
'--gallery-background': resolvedBranding.background,
|
||||
'--gallery-surface': resolvedBranding.surface,
|
||||
} as React.CSSProperties & Record<string, string>;
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const headerStyle = useMemo(() => {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
const textColor = getContrastingTextColor(resolvedBranding.primary, '#0f172a', '#ffffff');
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${resolvedBranding.primary}, ${resolvedBranding.secondary})`,
|
||||
color: textColor,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const accentStyle = useMemo(() => {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
color: resolvedBranding.primary,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const backgroundStyle = useMemo(() => {
|
||||
if (!resolvedBranding) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
backgroundColor: resolvedBranding.background,
|
||||
} satisfies React.CSSProperties;
|
||||
}, [resolvedBranding]);
|
||||
|
||||
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
|
||||
setSelectedPhoto(photo);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxOpen(false);
|
||||
setSelectedPhoto(null);
|
||||
}, []);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.expired) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center" style={backgroundStyle}>
|
||||
<AlertTriangle className="h-12 w-12 text-destructive" aria-hidden />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPublic.expiredTitle')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPublic.expiredDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ ...themeStyles, ...backgroundStyle }}>
|
||||
<header className="sticky top-0 z-20 shadow-sm" style={headerStyle}>
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
|
||||
<div className="text-left">
|
||||
<p className="text-xs uppercase tracking-widest opacity-80">Fotospiel</p>
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
{state.meta?.event.name || t('galleryPublic.title')}
|
||||
</h1>
|
||||
{state.meta?.event.gallery_expires_at && (
|
||||
<p className="text-[11px] opacity-80">
|
||||
{new Date(state.meta.event.gallery_expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6">
|
||||
{state.meta?.event.description && (
|
||||
<div className="rounded-xl bg-white/70 p-4 shadow-sm backdrop-blur">
|
||||
<p className="text-sm text-muted-foreground">{state.meta.event.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('galleryPublic.loadError')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{state.error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPublic.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && state.photos.length === 0 && !state.error && (
|
||||
<div className="rounded-xl border border-dashed border-muted/60 p-10 text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('galleryPublic.emptyTitle')}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('galleryPublic.emptyDescription')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{state.photos.map((photo) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className="group relative overflow-hidden rounded-xl bg-white shadow-sm transition-transform hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={() => openLightbox(photo)}
|
||||
style={accentStyle}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.full_url ?? ''}
|
||||
alt={photo.guest_name ? `${photo.guest_name}s Foto` : `Foto ${photo.id}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} className="h-1 w-full" aria-hidden />
|
||||
|
||||
{state.loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
{t('galleryPublic.loadingMore')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.loading && state.cursor && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={loadMore} disabled={state.loadingMore}>
|
||||
{state.loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
{t('galleryPublic.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Dialog open={lightboxOpen} onOpenChange={(open) => (open ? setLightboxOpen(true) : closeLightbox())}>
|
||||
<DialogContent className="max-w-3xl gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={closeLightbox}>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">{t('common.actions.close')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl bg-black/5">
|
||||
{selectedPhoto?.full_url && (
|
||||
<img
|
||||
src={selectedPhoto.full_url}
|
||||
alt={selectedPhoto?.guest_name || `Foto ${selectedPhoto?.id}`}
|
||||
className="w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(state.meta?.event?.guest_downloads_enabled ?? true) && selectedPhoto?.download_url ? (
|
||||
<Button asChild className="gap-2" style={accentStyle}>
|
||||
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
{t('galleryPublic.download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{(state.meta?.event?.guest_sharing_enabled ?? true) && selectedPhoto ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={shareLoading}
|
||||
onClick={async () => {
|
||||
if (!token || !selectedPhoto) return;
|
||||
setShareLoading(true);
|
||||
try {
|
||||
const payload = await createPhotoShareLink(token, selectedPhoto.id);
|
||||
const shareData: ShareData = {
|
||||
title: selectedPhoto.guest_name ?? t('share.title', 'Geteiltes Foto'),
|
||||
text: t('share.shareText', { event: state.meta?.event?.name ?? '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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('share failed', err);
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shareLoading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <Share className="h-4 w-4" aria-hidden />}
|
||||
{t('share.shareCta', 'Teilen')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { fetchPhotoShare } from '../services/photosApi';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type ShareProps = { slug: string | undefined };
|
||||
|
||||
export function SharedPhotoStandalone() {
|
||||
const slug = React.useMemo(() => {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
return parts.length >= 2 ? parts[1] : undefined;
|
||||
}, []);
|
||||
return <SharedPhotoView slug={slug} />;
|
||||
}
|
||||
|
||||
export default function SharedPhotoPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
return <SharedPhotoView slug={slug} />;
|
||||
}
|
||||
|
||||
function SharedPhotoView({ slug }: ShareProps) {
|
||||
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((error: unknown) => {
|
||||
if (!active) return;
|
||||
setState({ loading: false, error: 'Dieses Foto ist nicht mehr verfügbar.', data: null });
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">Moment wird geladen …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error || !state.data) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center">
|
||||
<div className="flex items-center gap-2 text-rose-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p className="text-lg font-semibold text-foreground">Link abgelaufen</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = state;
|
||||
const chips = buildChips(data);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-10">
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
|
||||
<div className="rounded-3xl border border-white/60 bg-white/90 p-5 text-center shadow-sm">
|
||||
<p className="text-[11px] uppercase tracking-[0.35em] text-muted-foreground">Geteiltes Foto</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? 'Ein besonderer Moment'}</h1>
|
||||
{data.photo.title && <p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/60 bg-black shadow-lg">
|
||||
<img
|
||||
src={data.photo.image_urls.full}
|
||||
alt={data.photo.title ?? 'Foto'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-200/80 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm"
|
||||
>
|
||||
{chip.icon ? <span aria-hidden className="text-sm">{chip.icon}</span> : null}
|
||||
<span className="text-[11px] uppercase tracking-wide opacity-70">{chip.label}</span>
|
||||
<span className="text-[12px]">{chip.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildChips(data: ShareResponse): { 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: 'Emotion', value: data.photo.emotion.name, icon: data.photo.emotion.emoji ?? '★' });
|
||||
}
|
||||
if (data.photo.title) {
|
||||
list.push({ id: 'task', label: 'Aufgabe', value: data.photo.title });
|
||||
}
|
||||
if (data.photo.created_at) {
|
||||
const date = formatDate(data.photo.created_at);
|
||||
list.push({ id: 'date', label: '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' });
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
|
||||
export default function SlideshowPage() {
|
||||
return (
|
||||
<Page title="Slideshow">
|
||||
<p>Auto-advancing gallery placeholder.</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GalleryPage from '../GalleryPage';
|
||||
import { LocaleProvider } from '../../i18n/LocaleContext';
|
||||
|
||||
vi.mock('../../polling/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({
|
||||
photos: [],
|
||||
loading: false,
|
||||
newCount: 0,
|
||||
acknowledgeNew: vi.fn(),
|
||||
refreshNow: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#FF5A5F',
|
||||
secondaryColor: '#FFF8F5',
|
||||
fontFamily: 'Space Grotesk, sans-serif',
|
||||
buttons: { radius: 12, style: 'filled', linkColor: '#FF5A5F' },
|
||||
typography: { heading: 'Space Grotesk, sans-serif', body: 'Space Grotesk, sans-serif' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
likesCount: 12,
|
||||
guestCount: 5,
|
||||
onlineGuests: 2,
|
||||
tasksSolved: 0,
|
||||
latestPhotoAt: null,
|
||||
loading: false,
|
||||
eventKey: 'demo',
|
||||
slug: 'demo',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/eventApi', () => ({
|
||||
fetchEvent: vi.fn().mockResolvedValue({ name: 'Demo Event' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ToastHost', () => ({
|
||||
useToast: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../PhotoLightbox', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PullToRefresh', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FiltersBar', () => ({
|
||||
default: () => <div data-testid="filters-bar" />,
|
||||
}));
|
||||
|
||||
describe('GalleryPage hero CTA', () => {
|
||||
it('links to the upload page', async () => {
|
||||
render(
|
||||
<LocaleProvider defaultLocale="de">
|
||||
<MemoryRouter initialEntries={['/e/demo/gallery']}>
|
||||
<Routes>
|
||||
<Route path="/e/:token/gallery" element={<GalleryPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</LocaleProvider>
|
||||
);
|
||||
|
||||
const link = await screen.findByRole('link', { name: /neues foto hochladen/i });
|
||||
expect(link).toHaveAttribute('href', '/e/demo/upload');
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import HelpArticlePage from '../HelpArticlePage';
|
||||
import type { HelpArticleDetail } from '../../services/helpApi';
|
||||
|
||||
vi.mock('../../i18n/LocaleContext', () => ({
|
||||
useLocale: () => ({ locale: 'de' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/helpApi', () => ({
|
||||
getHelpArticle: vi.fn(),
|
||||
}));
|
||||
|
||||
const { getHelpArticle } = await import('../../services/helpApi');
|
||||
|
||||
describe('HelpArticlePage', () => {
|
||||
it('renders a single back button after loading', async () => {
|
||||
const article: HelpArticleDetail = {
|
||||
slug: 'gallery-and-sharing',
|
||||
title: 'Galerie & Teilen',
|
||||
summary: 'Kurzfassung',
|
||||
body_html: '<p>Inhalt</p>',
|
||||
};
|
||||
|
||||
(getHelpArticle as ReturnType<typeof vi.fn>).mockResolvedValue({ article, servedFromCache: false });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/e/demo/help/gallery-and-sharing']}>
|
||||
<Routes>
|
||||
<Route path="/e/:token/help/:slug" element={<HelpArticlePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Galerie & Teilen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('help.article.back')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -28,14 +28,9 @@ const UploadPage = React.lazy(() => import('./pages/UploadPage'));
|
||||
const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage'));
|
||||
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
|
||||
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
|
||||
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
|
||||
const LiveShowPlayerPage = React.lazy(() => import('./pages/LiveShowPlayerPage'));
|
||||
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
|
||||
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
|
||||
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
||||
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
|
||||
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
|
||||
const SharedPhotoPage = React.lazy(() => import('./pages/SharedPhotoPage'));
|
||||
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
|
||||
|
||||
function HomeLayout() {
|
||||
@@ -66,7 +61,6 @@ function HomeLayout() {
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout>, errorElement: <RouteErrorElement /> },
|
||||
{ path: '/share/:slug', element: <SharedPhotoPage />, errorElement: <RouteErrorElement /> },
|
||||
{ path: '/show/:token', element: <LiveShowPlayerPage />, errorElement: <RouteErrorElement /> },
|
||||
{
|
||||
path: '/setup/:token',
|
||||
@@ -76,7 +70,6 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <ProfileSetupPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/g/:token', element: <PublicGalleryPage />, errorElement: <RouteErrorElement /> },
|
||||
{
|
||||
path: '/e/:token',
|
||||
element: <HomeLayout />,
|
||||
@@ -89,15 +82,10 @@ export const router = createBrowserRouter([
|
||||
{ path: 'queue', element: <UploadQueuePage /> },
|
||||
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
|
||||
{ path: 'achievements', element: <AchievementsPage /> },
|
||||
{ path: 'slideshow', element: <SlideshowPage /> },
|
||||
{ path: 'help', element: <HelpCenterPage /> },
|
||||
{ path: 'help/:slug', element: <HelpArticlePage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout>, errorElement: <RouteErrorElement /> },
|
||||
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout>, errorElement: <RouteErrorElement /> },
|
||||
{ path: '/help', element: <HelpStandalone />, errorElement: <RouteErrorElement /> },
|
||||
{ path: '/help/:slug', element: <HelpArticleStandalone />, errorElement: <RouteErrorElement /> },
|
||||
{ path: '*', element: <NotFoundPage />, errorElement: <RouteErrorElement /> },
|
||||
]);
|
||||
|
||||
@@ -350,21 +338,3 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
|
||||
</EventBrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpStandalone() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SimpleLayout title={t('help.center.title')}>
|
||||
<HelpCenterPage />
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpArticleStandalone() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SimpleLayout title={t('help.center.title')}>
|
||||
<HelpArticlePage />
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user