diff --git a/public/lang/de/guest.json b/public/lang/de/guest.json index 0967ef42..6d6de27e 100644 --- a/public/lang/de/guest.json +++ b/public/lang/de/guest.json @@ -1 +1,13 @@ -{} +{ + "achievements": { + "summary": { + "uniqueGuests": "Gäste beteiligt" + } + }, + "share": { + "invite": { + "qrLoading": "QR wird erstellt...", + "qrRetry": "Erneut versuchen" + } + } +} diff --git a/public/lang/en/guest.json b/public/lang/en/guest.json index 0967ef42..486d317c 100644 --- a/public/lang/en/guest.json +++ b/public/lang/en/guest.json @@ -1 +1,13 @@ -{} +{ + "achievements": { + "summary": { + "uniqueGuests": "Guests involved" + } + }, + "share": { + "invite": { + "qrLoading": "Generating QR...", + "qrRetry": "Retry" + } + } +} diff --git a/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx b/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx new file mode 100644 index 00000000..f0f4a8ea --- /dev/null +++ b/resources/js/guest-v2/__tests__/AchievementsScreen.test.tsx @@ -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 }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/button', () => ({ + Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('../components/AppShell', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/guest/components/PullToRefresh', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +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(); + + expect(await screen.findByText('Badges')).toBeInTheDocument(); + expect(screen.getByText('First upload')).toBeInTheDocument(); + expect(screen.getByText('Upload photo')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx new file mode 100644 index 00000000..a9311cbc --- /dev/null +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -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 }) =>
{children}
, +})); + +vi.mock('../components/PhotoFrameTile', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/ShareSheet', () => ({ + default: () => null, +})); + +vi.mock('../lib/toast', () => ({ + pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args), +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/button', () => ({ + Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => ( + + ), +})); + +vi.mock('lucide-react', () => ({ + Camera: () => camera, + Sparkles: () => sparkles, + Heart: () => heart, + Share2: () => share, + ChevronLeft: () => left, + ChevronRight: () => right, + X: () => x, +})); + +import GalleryScreen from '../screens/GalleryScreen'; + +describe('GalleryScreen', () => { + it('clears the photo param and shows a warning when lightbox fails to load', async () => { + render(); + + 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(); + }); +}); diff --git a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx index e8d31d13..0ed5a81e 100644 --- a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx @@ -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 }) => ( - ), @@ -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(); + + const previewButton = await screen.findByRole('button', { name: /foto 42/i }); + fireEvent.click(previewButton); + + expect(navigateMock).toHaveBeenCalledWith('/e/demo/gallery?photo=42'); + }); }); diff --git a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx index 92a03de8..184f3dda 100644 --- a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx @@ -12,8 +12,8 @@ vi.mock('@tamagui/text', () => ({ })); vi.mock('@tamagui/button', () => ({ - Button: ({ children, ...rest }: { children: React.ReactNode }) => ( - ), @@ -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, 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(); - - 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(); }); }); diff --git a/resources/js/guest-v2/screens/AchievementsScreen.tsx b/resources/js/guest-v2/screens/AchievementsScreen.tsx index 78b0c3d1..11325ae8 100644 --- a/resources/js/guest-v2/screens/AchievementsScreen.tsx +++ b/resources/js/guest-v2/screens/AchievementsScreen.tsx @@ -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 ( + + {children} + + ); +} + +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 ( + + + + + + + + {title} + + + {description} + + + + {entries.length === 0 ? ( + + {emptyCopy} + + ) : ( + + {entries.map((entry, index) => ( + + + + #{index + 1} + + + {entry.guest || guestFallback} + + + + + {formatNumber(entry.photos)} + + + {formatNumber(entry.likes)} + + + + ))} + + )} + + ); +} + +type BadgesGridProps = { + badges: AchievementBadge[]; + emptyCopy: string; + completeCopy: string; +}; + +function BadgesGrid({ badges, emptyCopy, completeCopy }: BadgesGridProps) { + if (badges.length === 0) { + return ( + + {emptyCopy} + + ); + } + + return ( + + {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 ( + + + {badge.title} + + + {badge.description} + + + + {percentage}% + + + {badge.earned ? completeCopy : `${progress}/${target}`} + + + + + + + ); + })} + + ); +} + +type TimelineProps = { + points: TimelinePoint[]; + formatNumber: (value: number) => string; + emptyCopy: string; +}; + +function Timeline({ points, formatNumber, emptyCopy }: TimelineProps) { + if (points.length === 0) { + return ( + + {emptyCopy} + + ); + } + + return ( + + {points.map((point) => ( + + + {point.date} + + + {formatNumber(point.photos)} photos · {formatNumber(point.guests)} guests + + + ))} + + ); +} + +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 ( + + {emptyCopy} + + ); + } + + return ( + + {topPhoto ? ( + + + + {topPhotoTitle} + + + + + {topPhoto.thumbnail ? ( + Top photo + ) : ( + + + {topPhotoNoPreview} + + + )} + + {topPhoto.guest || topPhotoFallbackGuest} + + + {formatNumber(topPhoto.likes)} likes · {formatRelativeTime(topPhoto.createdAt)} + + {topPhoto.task ? ( + + {localizeTaskLabel(topPhoto.task ?? null, locale) ?? topPhoto.task} + + ) : null} + + + ) : null} + + {trendingEmotion ? ( + + + + {trendingTitle} + + + + + {trendingEmotion.name} + + + {trendingCountLabel.replace('{count}', formatNumber(trendingEmotion.count))} + + + ) : null} + + ); +} + +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 ( + + {emptyCopy} + + ); + } + + return ( + + {feed.map((item) => { + const taskLabel = localizeTaskLabel(item.task ?? null, locale); + return ( + + {item.thumbnail ? ( + Feed thumbnail + ) : ( + + + + )} + + + {item.guest || guestFallback} + + {taskLabel ? ( + + {taskLabel} + + ) : null} + + + {formatRelativeTime(item.createdAt)} + + + {likesLabel.replace('{count}', formatNumber(item.likes))} + + + + + ); + })} + + ); +} 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(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [activeTab, setActiveTab] = React.useState('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 ( - - - - - - + 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 = ( + + + + + + + + {t('achievements.page.title', 'Achievements')} - - - {loading - ? t('common.actions.loading', 'Loading...') - : t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')} - - - - {error ? ( - - - {error ?? t('achievements.page.loadError', 'Achievements could not be loaded.')} + + {t('achievements.page.subtitle', 'Track your milestones and highlight streaks.')} - ) : null} + + - - - - - {topPhoto - ? t('achievements.highlights.topTitle', 'Top photo') - : t('achievements.summary.topContributor', 'Top contributor')} - - - - {topPhoto - ? t('achievements.highlights.likesAmount', { count: topPhoto.likes }, '{count} Likes') - : t('achievements.summary.placeholder', 'Keep sharing to unlock highlights.')} - - - - - {[1, 2].map((card) => ( - - - {card === 1 ? totalTasks : totalPhotos} - - - {card === 1 - ? t('achievements.summary.tasksCompleted', 'Tasks completed') - : t('achievements.summary.photosShared', 'Photos shared')} - + {summary ? ( + + {[ + { 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) => ( + + + + {formatNumber(item.value)} + + + {item.label} + + ))} + ) : null} - - - {totalLikes} - - - {t('achievements.summary.likesCollected', 'Likes collected')} - + + {([ + { 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) => ( + + ))} + + + {loading ? ( + + {Array.from({ length: 3 }).map((_, index) => ( + + + + ))} - + ) : error ? ( + + + {error === GENERIC_ERROR + ? t('achievements.page.loadError', 'Achievements could not be loaded.') + : error} + + + ) : payload ? ( + + {activeTab === 'personal' ? ( + + + + + {t('achievements.badges.title', 'Badges')} + + + {t('achievements.badges.description', 'Unlock achievements and keep your streak going.')} + + + + + + ) : null} + + {activeTab === 'event' ? ( + + + + + + + + + + {t('achievements.timeline.title', 'Timeline')} + + + {t('achievements.timeline.description', 'Daily momentum snapshot.')} + + + + + + + + + + + + + + + ) : null} + + {activeTab === 'feed' ? ( + + + + ) : null} + + + ) : null} + + ); + + return ( + + loadAchievements(true)} + pullLabel={t('common.pullToRefresh')} + releaseLabel={t('common.releaseToRefresh')} + refreshingLabel={t('common.refreshing')} + > + {content} + ); } diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index efb01562..1fe7a53d 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -75,6 +75,7 @@ export default function GalleryScreen() { const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); const [lightboxPhoto, setLightboxPhoto] = React.useState(null); const [lightboxLoading, setLightboxLoading] = React.useState(false); + const [lightboxError, setLightboxError] = React.useState<'notFound' | 'loadFailed' | null>(null); const [likesById, setLikesById] = React.useState>({}); 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>(new Set()); const touchStartX = React.useRef(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; diff --git a/resources/js/guest-v2/screens/HomeScreen.tsx b/resources/js/guest-v2/screens/HomeScreen.tsx index cc10459f..e2d48bdb 100644 --- a/resources/js/guest-v2/screens/HomeScreen.tsx +++ b/resources/js/guest-v2/screens/HomeScreen.tsx @@ -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 ( - - - + ); })} diff --git a/resources/js/guest-v2/screens/ShareScreen.tsx b/resources/js/guest-v2/screens/ShareScreen.tsx index 175d1fda..20b0780d 100644 --- a/resources/js/guest-v2/screens/ShareScreen.tsx +++ b/resources/js/guest-v2/screens/ShareScreen.tsx @@ -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 ( - + @@ -116,18 +113,22 @@ export default function ShareScreen() { - + + ) : qrLoading ? ( + ) : ( - + )} {t('share.invite.qrLabel', 'Show QR')} + {qrLoading ? ( + + {t('share.invite.qrLoading', 'Generating QR…')} + + ) : null} + {qrError ? ( + + ) : null} @@ -168,6 +196,16 @@ export default function ShareScreen() { ? t('share.copyError', 'Copy failed') : t('share.invite.copyLabel', 'Copy link')} + {shareUrl ? ( + + {shareUrl} + + ) : null} @@ -177,15 +215,17 @@ export default function ShareScreen() { @@ -204,7 +244,7 @@ export default function ShareScreen() { {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.')} - {newCount > 0 && ( - - )} - - - - -
-
-

- {t('galleryPage.feed.title', 'Live-Feed')} -

-

- {t('galleryPage.feed.description', 'Alle paar Sekunden aktualisiert.')} -

-
- {t('galleryPage.feed.newUploads', '{count} neue Uploads sind da.').replace('{count}', `${newCount}`)} - {newCount} -
-
- -
-
-

- Likes -

-
- - {numberFormatter.format(stats.likesCount ?? 0)} -
-
-
-

- {t('galleryPage.hero.statsGuests', 'Gäste online')} -

-
- - {numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} -
-
-
-
- - -
- -
- - - {loading && ( - - {t('galleryPage.loading', 'Lade…')} - - )} - - - {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 ( - { - 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} - > -
- {altText} { - (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; - }} - /> -
-
-
- {localizedTaskTitle && ( -

- {localizedTaskTitle} -

- )} -
- {createdLabel} - {p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')} -
-
- - -
-
- - ); - })} - {list.length === 0 && Array.from({ length: 6 }).map((_, idx) => ( - -
-
- -
-
-
- - ))} - - - -
- {currentPhotoIndex !== null && list.length > 0 && ( - setCurrentPhotoIndex(null)} - onIndexChange={(index: number) => setCurrentPhotoIndex(index)} - token={token} - eventName={event?.name ?? null} - /> - )} - - shareNative(shareSheet.url)} - onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} - onShareMessages={() => shareMessages(shareSheet.url)} - onCopyLink={() => copyLink(shareSheet.url)} - radius={radius} - bodyFont={bodyFont} - headingFont={headingFont} - /> - - ); -} diff --git a/resources/js/guest/pages/HelpArticlePage.tsx b/resources/js/guest/pages/HelpArticlePage.tsx deleted file mode 100644 index 76b8ac78..00000000 --- a/resources/js/guest/pages/HelpArticlePage.tsx +++ /dev/null @@ -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(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 ( - - -
- -
- - {state === 'loading' && ( -
-
- -
-
{t('help.article.loadingTitle')}
-
{t('help.article.loadingDescription')}
-
-
-
-
-
-
-
-
- )} - - {state === 'error' && ( -
-

{t('help.article.unavailable')}

- -
- )} - - {state === 'ready' && article && ( -
-
- {article.updated_at && ( -
{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}
- )} -
-
-
-
- {article.related && article.related.length > 0 && ( -
-

{t('help.article.relatedTitle')}

-
- {article.related.map((rel) => ( - - ))} -
-
- )} -
- )} - - - ); -} - -function formatDate(value: string, locale: string): string { - try { - return new Date(value).toLocaleDateString(locale, { - day: '2-digit', - month: 'short', - year: 'numeric', - }); - } catch { - return value; - } -} diff --git a/resources/js/guest/pages/HelpCenterPage.tsx b/resources/js/guest/pages/HelpCenterPage.tsx deleted file mode 100644 index 9397c95c..00000000 --- a/resources/js/guest/pages/HelpCenterPage.tsx +++ /dev/null @@ -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([]); - 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 ( - - loadArticles(true)} - pullLabel={t('common.pullToRefresh')} - releaseLabel={t('common.releaseToRefresh')} - refreshingLabel={t('common.refreshing')} - > -

{t('help.center.subtitle')}

- -
-
- setQuery(event.target.value)} - className="flex-1" - aria-label={t('help.center.searchPlaceholder')} - /> - -
- {showOfflineBadge && ( -
- - {t('help.center.offlineBadge')} - - {t('help.center.offlineDescription')} -
- )} -
- -
-

{t('help.center.listTitle')}

- {state === 'loading' && ( -
- - {t('common.actions.loading')} -
- )} - {state === 'error' && ( -
-

{t('help.center.error')}

- -
- )} - {state === 'ready' && filteredArticles.length === 0 && ( -
- {t('help.center.empty')} -
- )} - {state === 'ready' && filteredArticles.length > 0 && ( -
- {filteredArticles.map((article) => ( - -
-
-

{article.title}

-

{article.summary}

-
- - {article.updated_at ? formatDate(article.updated_at, locale) : ''} - -
- - ))} -
- )} -
-
-
- ); -} - -function formatDate(value: string, locale: string): string { - try { - return new Date(value).toLocaleDateString(locale, { - day: '2-digit', - month: 'short', - year: 'numeric', - }); - } catch { - return value; - } -} diff --git a/resources/js/guest/pages/PublicGalleryPage.tsx b/resources/js/guest/pages/PublicGalleryPage.tsx deleted file mode 100644 index 3cc62a94..00000000 --- a/resources/js/guest/pages/PublicGalleryPage.tsx +++ /dev/null @@ -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(INITIAL_STATE); - const [lightboxOpen, setLightboxOpen] = useState(false); - const [selectedPhoto, setSelectedPhoto] = useState(null); - const [shareLoading, setShareLoading] = useState(false); - const sentinelRef = useRef(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; - }, [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 ( -
- -
-

{t('galleryPublic.expiredTitle')}

-

{t('galleryPublic.expiredDescription')}

-
-
- ); - } - - return ( -
-
-
-
-

Fotospiel

-

- {state.meta?.event.name || t('galleryPublic.title')} -

- {state.meta?.event.gallery_expires_at && ( -

- {new Date(state.meta.event.gallery_expires_at).toLocaleDateString()} -

- )} -
-
-
- -
- {state.meta?.event.description && ( -
-

{state.meta.event.description}

-
- )} - - {state.error && ( - - {t('galleryPublic.loadError')} - - {state.error} - - - )} - - {state.loading && ( -
- -

{t('galleryPublic.loading')}

-
- )} - - {!state.loading && state.photos.length === 0 && !state.error && ( -
-

{t('galleryPublic.emptyTitle')}

-

{t('galleryPublic.emptyDescription')}

-
- )} - -
- {state.photos.map((photo) => ( - - ))} -
- -
- - {state.loadingMore && ( -
- - {t('galleryPublic.loadingMore')} -
- )} - - {!state.loading && state.cursor && ( -
- -
- )} -
- - (open ? setLightboxOpen(true) : closeLightbox())}> - -
-
-

- {selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')} -

-

- {selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''} -

-
- -
- -
- {selectedPhoto?.full_url && ( - {selectedPhoto?.guest_name - )} -
- - -
- {selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''} -
-
- {(state.meta?.event?.guest_downloads_enabled ?? true) && selectedPhoto?.download_url ? ( - - ) : null} - {(state.meta?.event?.guest_sharing_enabled ?? true) && selectedPhoto ? ( - - ) : null} -
-
-
-
-
- ); -} diff --git a/resources/js/guest/pages/SharedPhotoPage.tsx b/resources/js/guest/pages/SharedPhotoPage.tsx deleted file mode 100644 index 93bb0201..00000000 --- a/resources/js/guest/pages/SharedPhotoPage.tsx +++ /dev/null @@ -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 ; -} - -export default function SharedPhotoPage() { - const { slug } = useParams<{ slug: string }>(); - return ; -} - -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 ( -
- -

Moment wird geladen …

-
- ); - } - - if (state.error || !state.data) { - return ( -
-
- -

Link abgelaufen

-
-

- {state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'} -

-
- ); - } - - const { data } = state; - const chips = buildChips(data); - - return ( -
-
-
-

Geteiltes Foto

-

{data.event?.name ?? 'Ein besonderer Moment'}

- {data.photo.title &&

{data.photo.title}

} -
- -
- {data.photo.title -
- - {chips.length > 0 && ( -
- {chips.map((chip) => ( - - {chip.icon ? {chip.icon} : null} - {chip.label} - {chip.value} - - ))} -
- )} -
-
- ); -} - -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' }); -} diff --git a/resources/js/guest/pages/SlideshowPage.tsx b/resources/js/guest/pages/SlideshowPage.tsx deleted file mode 100644 index 790d2e07..00000000 --- a/resources/js/guest/pages/SlideshowPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { Page } from './_util'; - -export default function SlideshowPage() { - return ( - -

Auto-advancing gallery placeholder.

-
- ); -} - diff --git a/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx b/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx deleted file mode 100644 index 0af3957d..00000000 --- a/resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx +++ /dev/null @@ -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 }) =>
{children}
, -})); - -vi.mock('../../components/FiltersBar', () => ({ - default: () =>
, -})); - -describe('GalleryPage hero CTA', () => { - it('links to the upload page', async () => { - render( - - - - } /> - - - - ); - - const link = await screen.findByRole('link', { name: /neues foto hochladen/i }); - expect(link).toHaveAttribute('href', '/e/demo/upload'); - }); -}); diff --git a/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx b/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx deleted file mode 100644 index fc19d208..00000000 --- a/resources/js/guest/pages/__tests__/HelpArticlePage.test.tsx +++ /dev/null @@ -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: '

Inhalt

', - }; - - (getHelpArticle as ReturnType).mockResolvedValue({ article, servedFromCache: false }); - - render( - - - } /> - - , - ); - - await waitFor(() => { - expect(screen.getByText('Galerie & Teilen')).toBeInTheDocument(); - }); - - expect(screen.getAllByText('help.article.back')).toHaveLength(1); - }); -}); diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 378e311d..4dca56e2 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -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: , errorElement: }, - { path: '/share/:slug', element: , errorElement: }, { path: '/show/:token', element: , errorElement: }, { path: '/setup/:token', @@ -76,7 +70,6 @@ export const router = createBrowserRouter([ { index: true, element: }, ], }, - { path: '/g/:token', element: , errorElement: }, { path: '/e/:token', element: , @@ -89,15 +82,10 @@ export const router = createBrowserRouter([ { path: 'queue', element: }, { path: 'photo/:photoId', element: }, { path: 'achievements', element: }, - { path: 'slideshow', element: }, - { path: 'help', element: }, - { path: 'help/:slug', element: }, ], }, { path: '/settings', element: , errorElement: }, { path: '/legal/:page', element: , errorElement: }, - { path: '/help', element: , errorElement: }, - { path: '/help/:slug', element: , errorElement: }, { path: '*', element: , errorElement: }, ]); @@ -350,21 +338,3 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac ); } - -function HelpStandalone() { - const { t } = useTranslation(); - return ( - - - - ); -} - -function HelpArticleStandalone() { - const { t } = useTranslation(); - return ( - - - - ); -}