Migrate guest v2 achievements and refresh share page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-05 16:46:15 +01:00
parent fa630e335d
commit 4e0d156065
22 changed files with 1142 additions and 1902 deletions

View 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();
});
});

View 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();
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
})}

View File

@@ -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

View File

@@ -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]
);