import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Switch } from '@tamagui/switch'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { DataExportsPanel } from './DataExportsPage'; import { getEvent, getEventStats, getEventQrInvites, getEventEngagement, updateEvent, TenantEvent, EventStats, EventQrInvite, EventEngagement, EventAddonCatalogItem, getAddonCatalog, createEventAddonCheckout, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { adminPath } from '../constants'; import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; type GalleryCounts = { photos: number; likes: number; pending: number; }; export default function MobileEventRecapPage() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const { t, i18n } = useTranslation('management'); const { textStrong, text, muted, border, primary, danger } = useAdminTheme(); const locale = i18n.language; const [activeTab, setActiveTab] = React.useState<'overview' | 'engagement' | 'compliance'>('overview'); const [event, setEvent] = React.useState(null); const [stats, setEventStats] = React.useState(null); const [invites, setInvites] = React.useState([]); const [addons, setAddons] = React.useState([]); const [engagement, setEngagement] = React.useState(null); const [engagementLoading, setEngagementLoading] = React.useState(false); const [engagementError, setEngagementError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [consentOpen, setConsentOpen] = React.useState(false); const [busyScope, setBusyScope] = React.useState(null); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const load = React.useCallback(async () => { if (!slug) return; setLoading(true); try { const [eventData, statsData, invitesData, addonsData] = await Promise.all([ getEvent(slug), getEventStats(slug), getEventQrInvites(slug), getAddonCatalog(), ]); setEvent(eventData); setEventStats(statsData); setInvites(invitesData); setAddons(addonsData); setError(null); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Recap konnte nicht geladen werden.'))); } } finally { setLoading(false); } }, [slug, t]); React.useEffect(() => { void load(); }, [load]); React.useEffect(() => { if (event?.status && event.status !== 'published') { setEngagement(null); setEngagementError(null); setEngagementLoading(false); return; } const token = invites.find((invite) => invite.is_active)?.token ?? invites[0]?.token ?? ''; if (!token) { setEngagement(null); setEngagementError(null); setEngagementLoading(false); return; } const controller = new AbortController(); setEngagementLoading(true); setEngagementError(null); getEventEngagement(token, { locale: i18n.language, signal: controller.signal }) .then((payload) => { setEngagement(payload); }) .catch((err) => { if (err?.name === 'AbortError') { return; } if (isApiError(err) && (err.status === 403 || err.status === 404)) { setEngagement(null); setEngagementError(null); return; } setEngagement(null); setEngagementError(getApiErrorMessage(err, t('events.recap.engagementError', 'Engagement konnte nicht geladen werden.'))); }) .finally(() => { setEngagementLoading(false); }); return () => { controller.abort(); }; }, [event?.status, i18n.language, invites, t]); const handleCheckout = async (addonKey: string) => { if (!slug || busyScope) return; setBusyScope(addonKey); try { const { checkout_url } = await createEventAddonCheckout(slug, { addon_key: addonKey, success_url: window.location.href, cancel_url: window.location.href, }); if (checkout_url) { window.location.href = checkout_url; } } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.'))); setBusyScope(null); } }; const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => { if (!slug || !busyScope) return; try { const { checkout_url } = await createEventAddonCheckout(slug, { addon_key: busyScope, success_url: window.location.href, cancel_url: window.location.href, accepted_terms: consents.acceptedTerms, } as any); if (checkout_url) { window.location.href = checkout_url; } } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.'))); setBusyScope(null); setConsentOpen(false); } }; if (loading) { return ( ); } if (error || !event) { return ( {error || t('common.error', 'Ein Fehler ist aufgetreten.')} ); } const galleryCounts: GalleryCounts = { photos: stats?.total ?? 0, likes: stats?.likes ?? 0, pending: stats?.pending_photos ?? 0, }; const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null; const guestLink = activeInvite?.url ?? ''; return ( setActiveTab('overview')} /> setActiveTab('engagement')} /> setActiveTab('compliance')} /> {activeTab === 'overview' ? ( {t('events.recap.completedTitle', 'Event abgeschlossen')} {formatDate(event.event_date, locale)} {t('events.recap.statusClosed', 'Archiviert')} {t('events.recap.shareGuests', 'Gäste-Galerie teilen')} {t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')} {guestLink ? ( {guestLink} copyToClipboard(guestLink, t)} /> {typeof navigator !== 'undefined' && !!navigator.share && ( shareLink(guestLink, event, t)} /> )} ) : ( {t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')} )} {guestLink && activeInvite?.qr_code_data_url ? ( {t('events.recap.qrAlt', downloadQr(activeInvite.qr_code_data_url!)} /> ) : null} {t('events.recap.settings', 'Nachlauf-Optionen')} updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)} /> updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)} /> {t('events.recap.addons', 'Galerie verlängern')} {t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')} {addons .filter((a) => a.key === 'gallery_extension') .map((addon) => ( handleCheckout(addon.key)} loading={busyScope === addon.key} /> ))} ) : null} {activeTab === 'engagement' ? ( {engagementLoading ? ( ) : engagementError ? ( {engagementError} ) : !engagement ? ( {activeInvite ? t('events.recap.engagement.empty', 'No engagement data yet.') : t('events.recap.engagement.noInvite', 'No active guest link available yet.')} ) : ( {t('events.recap.engagement.title', 'Guest engagement')} {t('events.recap.engagement.subtitle', 'Highlights, leaderboards, and milestones from your guests.')} {t('events.recap.engagement.leaderboards.uploadsTitle', 'Top contributors')} {engagement.leaderboards.uploads.length === 0 ? ( {t('events.recap.engagement.leaderboards.uploadsEmpty', 'No uploads yet.')} ) : ( {engagement.leaderboards.uploads.slice(0, 5).map((entry, index) => ( ))} )} {t('events.recap.engagement.leaderboards.likesTitle', 'Most liked')} {engagement.leaderboards.likes.length === 0 ? ( {t('events.recap.engagement.leaderboards.likesEmpty', 'No likes yet.')} ) : ( {engagement.leaderboards.likes.slice(0, 5).map((entry, index) => ( ))} )} {t('events.recap.engagement.highlightsTitle', 'Highlights')} {engagement.highlights.topPhoto?.thumbnail ? ( {t('events.recap.engagement.topPhoto', ) : ( )} {t('events.recap.engagement.topPhoto', 'Top photo')} {engagement.highlights.topPhoto ? `${engagement.highlights.topPhoto.guest || t('events.recap.engagement.guestFallback', 'Guest')} · ${formatCount(engagement.highlights.topPhoto.likes, locale)} ${t('events.recap.engagement.likesLabel', 'Likes')}` : t('events.recap.engagement.topPhotoEmpty', 'No photo highlights yet.')} {t('events.recap.engagement.trendingEmotion', 'Trending emotion')} {engagement.highlights.trendingEmotion ? `${engagement.highlights.trendingEmotion.name} · ${formatCount(engagement.highlights.trendingEmotion.count, locale)}` : t('events.recap.engagement.trendingEmpty', 'No trends yet.')} {t('events.recap.engagement.timeline', 'Uploads over time')} {engagement.highlights.timeline.length === 0 ? ( {t('events.recap.engagement.timelineEmpty', 'No timeline data yet.')} ) : ( {engagement.highlights.timeline.slice(-5).map((point) => ( {formatShortDate(point.date, locale)} {t('events.recap.engagement.timelineRow', '{{photos}} photos · {{guests}} guests', { photos: formatCount(point.photos, locale), guests: formatCount(point.guests, locale), })} ))} )} )} ) : null} {activeTab === 'compliance' ? ( ) : null} { setConsentOpen(false); setBusyScope(null); }} onConfirm={handleConsentConfirm} busy={Boolean(busyScope)} t={t as any} /> ); } function Stat({ label, value, pill }: { label: string; value: string; pill?: boolean }) { const { textStrong, muted, accentSoft, border } = useAdminTheme(); return ( {label} {value} ); } function ToggleOption({ label, value, onToggle }: { label: string; value: boolean; onToggle: (val: boolean) => void }) { const { textStrong } = useAdminTheme(); return ( {label} ); } function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { const { primary, surfaceMuted, border, surface, textStrong } = useAdminTheme(); return ( {label} ); } function LeaderboardRow({ rank, name, value }: { rank: number; name: string; value: string }) { const { textStrong, muted, border, surfaceMuted } = useAdminTheme(); return ( #{rank} {name} {value} ); } function formatCount(value: number, locale?: string): string { const normalized = Number.isFinite(value) ? value : 0; return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(normalized); } function formatShortDate(iso: string, locale?: string): string { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format(date); } async function updateSetting( event: TenantEvent, setEvent: (event: TenantEvent) => void, slug: string | undefined, key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean, setError: (message: string | null) => void, t: (key: string, fallback?: string) => string, ): Promise { if (!slug) return; try { const updated = await updateEvent(slug, { settings: { ...(event.settings ?? {}), [key]: value, }, }); setEvent(updated); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.'))); } } } function copyToClipboard(value: string, t: any) { if (typeof window !== 'undefined') { void window.navigator.clipboard.writeText(value); toast.success(t('events.recap.copySuccess', 'Link kopiert')); } } async function shareLink(value: string, event: TenantEvent | null, t: any) { if (typeof window !== 'undefined' && navigator.share) { try { await navigator.share({ title: resolveName(event?.name ?? ''), text: t('events.recap.shareText', 'Schau dir die Fotos von unserem Event an!'), url: value, }); } catch { // silently ignore or fallback to copy } } } function downloadQr(dataUrl: string) { const link = document.createElement('a'); link.href = dataUrl; link.download = 'guest-gallery-qr.png'; link.click(); } function resolveName(name: TenantEvent['name']): string { if (typeof name === 'string') return name; if (name && typeof name === 'object') { return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event'; } return 'Event'; } function formatDate(iso?: string | null, locale?: string): string { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' }); }