import React from 'react'; import { useParams } from 'react-router-dom'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Download, Share2 } from 'lucide-react'; import { Sheet } from '@tamagui/sheet'; import StandaloneShell from '../components/StandaloneShell'; import SurfaceCard from '../components/SurfaceCard'; import EventLogo from '../components/EventLogo'; import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/shared/guest/services/galleryApi'; import { createPhotoShareLink } from '@/shared/guest/services/photosApi'; import { useTranslation } from '@/shared/guest/i18n/useTranslation'; import { EventBrandingProvider } from '@/shared/guest/context/EventBrandingContext'; import { mapEventBranding } from '../lib/eventBranding'; import { BrandingTheme } from '../lib/brandingTheme'; import { useGuestThemeVariant } from '../lib/guestTheme'; type GalleryState = { meta: GalleryMetaResponse | null; photos: GalleryPhotoResource[]; cursor: string | null; loading: boolean; loadingMore: boolean; error: string | null; expired: boolean; }; const INITIAL_STATE: GalleryState = { meta: null, photos: [], cursor: null, loading: true, loadingMore: false, error: null, expired: false, }; const PAGE_SIZE = 30; export default function PublicGalleryScreen() { const { token } = useParams<{ token: string }>(); const { t, locale } = useTranslation(); const [state, setState] = React.useState(INITIAL_STATE); const [selected, setSelected] = React.useState(null); const [shareLoading, setShareLoading] = React.useState(false); const sentinelRef = React.useRef(null); const branding = React.useMemo(() => { if (!state.meta) return null; return mapEventBranding(state.meta.branding ?? null); }, [state.meta]); const { isDark } = useGuestThemeVariant(branding); const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const loadInitial = React.useCallback(async () => { if (!token) return; setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null })); try { const meta = await fetchGalleryMeta(token, locale); const photoResponse = await fetchGalleryPhotos(token, null, PAGE_SIZE); setState((prev) => ({ ...prev, meta, photos: photoResponse.data, cursor: photoResponse.next_cursor, loading: false, })); } catch (error) { const err = error as Error & { code?: string | number }; if (err.code === 'gallery_expired' || err.code === 410) { setState((prev) => ({ ...prev, loading: false, expired: true })); } else { setState((prev) => ({ ...prev, loading: false, error: err.message || t('galleryPublic.loadError') })); } } }, [locale, t, token]); React.useEffect(() => { loadInitial(); }, [loadInitial]); const loadMore = React.useCallback(async () => { if (!token || !state.cursor || state.loadingMore) return; setState((prev) => ({ ...prev, loadingMore: true })); try { const response = await fetchGalleryPhotos(token, state.cursor, PAGE_SIZE); setState((prev) => ({ ...prev, photos: [...prev.photos, ...response.data], cursor: response.next_cursor, loadingMore: false, })); } catch (error) { const err = error as Error; setState((prev) => ({ ...prev, loadingMore: false, error: err.message || t('galleryPublic.loadError') })); } }, [state.cursor, state.loadingMore, t, token]); React.useEffect(() => { if (!state.cursor || !sentinelRef.current) return; const observer = new IntersectionObserver((entries) => { if (entries[0]?.isIntersecting) loadMore(); }, { rootMargin: '400px', threshold: 0 }); observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [state.cursor, loadMore]); const content = ( {t('galleryPublic.title')} {state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} {state.loading ? ( {t('galleryPublic.loading')} ) : state.expired ? ( {t('galleryPublic.expiredTitle')} {t('galleryPublic.expiredDescription')} ) : state.error ? ( {t('galleryPublic.loadError')} {state.error} ) : state.photos.length === 0 ? ( {t('galleryPublic.emptyTitle')} {t('galleryPublic.emptyDescription')} ) : ( {state.photos.filter((_, index) => index % 2 === 0).map((photo, index) => ( ))} {state.photos.filter((_, index) => index % 2 === 1).map((photo, index) => ( ))} {state.loadingMore ? ( {t('galleryPublic.loadingMore')} ) : null}
)} { if (!next) setSelected(null); }} snapPoints={[92]} position={selected ? 0 : -1} modal > {selected ? ( {selected.guest_name {selected.likes_count ? `${selected.likes_count} ❤` : ''} {(state.meta?.event?.guest_downloads_enabled ?? true) && selected.download_url ? ( ) : null} {(state.meta?.event?.guest_sharing_enabled ?? true) ? ( ) : null} ) : null} ); if (!branding) { return content; } return ( {content} ); }