import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Image as ImageIcon, RefreshCcw, Filter, Check } 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 { AnimatePresence, motion } from 'framer-motion'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; import { getEventPhotos, getEventPhoto, updatePhotoVisibility, featurePhoto, unfeaturePhoto, updatePhotoStatus, TenantPhoto, EventAddonCatalogItem, createEventAddonCheckout, getAddonCatalog, EventAddonSummary, EventLimitSummary, getEvent, } from '../api'; import toast from 'react-hot-toast'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; import { useEventContext } from '../context/EventContext'; import { useTheme } from '@tamagui/core'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { adminPath } from '../constants'; import { scopeDefaults, selectAddonKeyForScope } from './addons'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { triggerHaptic } from './lib/haptics'; import { useOnlineStatus } from './hooks/useOnlineStatus'; import { enqueuePhotoAction, loadPhotoQueue, removePhotoAction, replacePhotoQueue, type PhotoModerationAction, } from './lib/photoModerationQueue'; import { ADMIN_EVENT_PHOTOS_PATH } from '../constants'; type FilterKey = 'all' | 'featured' | 'hidden' | 'pending'; export default function MobileEventPhotosPage() { const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>(); const { activeEvent, selectEvent } = useEventContext(); const slug = slugParam ?? activeEvent?.slug ?? null; const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('management'); const [photos, setPhotos] = React.useState([]); const [filter, setFilter] = React.useState('all'); const [page, setPage] = React.useState(1); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [busyId, setBusyId] = React.useState(null); const [totalCount, setTotalCount] = React.useState(0); const [hasMore, setHasMore] = React.useState(false); const [search, setSearch] = React.useState(''); const [showFilters, setShowFilters] = React.useState(false); const [uploaderFilter, setUploaderFilter] = React.useState(''); const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false); const [lightboxId, setLightboxId] = React.useState(null); const [pendingPhotoId, setPendingPhotoId] = React.useState(null); const [syncingQueue, setSyncingQueue] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState([]); const [bulkBusy, setBulkBusy] = React.useState(false); const [limits, setLimits] = React.useState(null); const [catalogAddons, setCatalogAddons] = React.useState([]); const [eventAddons, setEventAddons] = React.useState([]); const [busyScope, setBusyScope] = React.useState(null); const [consentOpen, setConsentOpen] = React.useState(false); const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null); const [consentBusy, setConsentBusy] = React.useState(false); const [queuedActions, setQueuedActions] = React.useState(() => loadPhotoQueue()); const online = useOnlineStatus(); const syncingQueueRef = React.useRef(false); const theme = useTheme(); const text = String(theme.color?.val ?? '#111827'); const muted = String(theme.gray?.val ?? '#4b5563'); const border = String(theme.borderColor?.val ?? '#e5e7eb'); const infoBg = String(theme.blue3?.val ?? '#e8f1ff'); const infoBorder = String(theme.blue6?.val ?? '#bfdbfe'); const danger = String(theme.red10?.val ?? '#b91c1c'); const surface = String(theme.surface?.val ?? '#ffffff'); const backdrop = String(theme.gray12?.val ?? '#0f172a'); const lightboxIndex = React.useMemo(() => { if (lightboxId === null) { return -1; } return photos.findIndex((photo) => photo.id === lightboxId); }, [photos, lightboxId]); const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null; const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events'); const parsedPhotoId = React.useMemo(() => { if (!photoIdParam) { return null; } const parsed = Number(photoIdParam); return Number.isFinite(parsed) ? parsed : null; }, [photoIdParam]); React.useEffect(() => { if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) { setLightboxId(null); } }, [lightboxId, lightboxIndex, loading, pendingPhotoId]); React.useEffect(() => { if (lightboxId !== null) { setSelectionMode(false); setSelectedIds([]); } }, [lightboxId]); React.useEffect(() => { if (parsedPhotoId === null) { if (!photoIdParam) { setLightboxId(null); } setPendingPhotoId(null); return; } setLightboxId(parsedPhotoId); setPendingPhotoId(parsedPhotoId); }, [parsedPhotoId, photoIdParam]); React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { selectEvent(slugParam); } }, [slugParam, activeEvent?.slug, selectEvent]); const load = React.useCallback(async () => { if (!slug) return; setLoading(true); setError(null); try { const status = filter === 'hidden' || onlyHidden ? 'hidden' : filter === 'pending' ? 'pending' : undefined; const result = await getEventPhotos(slug, { page, perPage: 20, sort: 'desc', featured: filter === 'featured' || onlyFeatured, status, search: search || undefined, }); setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos])); setTotalCount(result.meta?.total ?? result.photos.length); setLimits(result.limits ?? null); const lastPage = result.meta?.last_page ?? 1; setHasMore(page < lastPage); const [addons, event] = await Promise.all([ getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]), getEvent(slug).catch(() => null), ]); setCatalogAddons(addons ?? []); setEventAddons(event?.addons ?? []); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'))); } } finally { setLoading(false); } }, [slug, filter, t, page, onlyFeatured, onlyHidden, search]); React.useEffect(() => { void load(); }, [load]); React.useEffect(() => { if (!location.search || !slug) return; const params = new URLSearchParams(location.search); if (params.get('addon_success')) { toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.')); setPage(1); void load(); params.delete('addon_success'); navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); } }, [location.search, slug, load, navigate, t, location.pathname]); React.useEffect(() => { setPage(1); }, [filter, slug]); const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { replacePhotoQueue(queue); setQueuedActions(queue); }, []); const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { setPhotos((prev) => prev.map((photo) => { if (photo.id !== photoId) { return photo; } if (action === 'approve') { return { ...photo, status: 'approved' }; } if (action === 'hide') { return { ...photo, status: 'hidden' }; } if (action === 'show') { return { ...photo, status: 'approved' }; } if (action === 'feature') { return { ...photo, is_featured: true }; } if (action === 'unfeature') { return { ...photo, is_featured: false }; } return photo; }), ); }, []); const enqueueModerationAction = React.useCallback( (action: PhotoModerationAction['action'], photoId: number) => { if (!slug) { return; } const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action }); setQueuedActions(nextQueue); applyOptimisticUpdate(photoId, action); toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); triggerHaptic('selection'); }, [applyOptimisticUpdate, slug, t], ); const syncQueuedActions = React.useCallback( async (options?: { silent?: boolean }) => { if (!online || syncingQueueRef.current) { return; } const queue = loadPhotoQueue(); if (queue.length === 0) { return; } syncingQueueRef.current = true; setSyncingQueue(true); let remaining = queue; for (const entry of queue) { try { let updated: TenantPhoto | null = null; if (entry.action === 'approve') { updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved'); } else if (entry.action === 'hide') { updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true); } else if (entry.action === 'show') { updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false); } else if (entry.action === 'feature') { updated = await featurePhoto(entry.eventSlug, entry.photoId); } else if (entry.action === 'unfeature') { updated = await unfeaturePhoto(entry.eventSlug, entry.photoId); } remaining = removePhotoAction(remaining, entry.id); if (updated && entry.eventSlug === slug) { setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo))); } } catch (err) { if (!options?.silent) { toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.')); } if (isAuthError(err)) { break; } } } updateQueueState(remaining); setSyncingQueue(false); syncingQueueRef.current = false; }, [online, slug, t, updateQueueState], ); React.useEffect(() => { if (online) { void syncQueuedActions({ silent: true }); } }, [online, syncQueuedActions]); const setLightboxWithUrl = React.useCallback( (photoId: number | null, options?: { replace?: boolean }) => { setLightboxId(photoId); if (!slug) { return; } const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath; if (location.pathname !== nextPath) { navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false }); } }, [basePhotosPath, location.pathname, location.search, navigate, slug], ); const handleModerationAction = React.useCallback( async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { if (!slug) { return; } if (!online) { enqueueModerationAction(action, photo.id); return; } setBusyId(photo.id); const successMessage = () => { if (action === 'approve') { return t('mobilePhotos.approveSuccess', 'Photo approved'); } if (action === 'hide') { return t('mobilePhotos.hideSuccess', 'Photo hidden'); } if (action === 'show') { return t('mobilePhotos.showSuccess', 'Photo shown'); } if (action === 'feature') { return t('mobilePhotos.featureSuccess', 'Als Highlight markiert'); } return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'); }; const errorMessage = () => { if (action === 'approve') { return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'); } if (action === 'hide' || action === 'show') { return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'); } return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'); }; try { let updated: TenantPhoto; if (action === 'approve') { updated = await updatePhotoStatus(slug, photo.id, 'approved'); } else if (action === 'hide') { updated = await updatePhotoVisibility(slug, photo.id, true); } else if (action === 'show') { updated = await updatePhotoVisibility(slug, photo.id, false); } else if (action === 'feature') { updated = await featurePhoto(slug, photo.id); } else { updated = await unfeaturePhoto(slug, photo.id); } setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); toast.success(successMessage()); triggerHaptic(action === 'approve' ? 'success' : 'medium'); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, errorMessage())); toast.error(errorMessage()); } } finally { setBusyId(null); } }, [enqueueModerationAction, online, slug, t], ); React.useEffect(() => { if (!slug || pendingPhotoId === null) { return; } if (photos.some((photo) => photo.id === pendingPhotoId)) { setPendingPhotoId(null); return; } if (loading) { return; } let active = true; void (async () => { try { const fetched = await getEventPhoto(slug, pendingPhotoId); if (!active) { return; } setPhotos((prev) => { if (prev.some((photo) => photo.id === fetched.id)) { return prev; } return [fetched, ...prev]; }); } catch (err) { if (!isAuthError(err)) { toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')); } if (active) { setLightboxWithUrl(null, { replace: true }); } } finally { if (active) { setPendingPhotoId(null); } } })(); return () => { active = false; }; }, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]); async function toggleVisibility(photo: TenantPhoto) { const action = photo.status === 'hidden' ? 'show' : 'hide'; await handleModerationAction(action, photo); } async function toggleFeature(photo: TenantPhoto) { const action = photo.is_featured ? 'unfeature' : 'feature'; await handleModerationAction(action, photo); } async function approvePhoto(photo: TenantPhoto) { await handleModerationAction('approve', photo); } const selectedPhotos = React.useMemo( () => photos.filter((photo) => selectedIds.includes(photo.id)), [photos, selectedIds], ); const hasPendingSelection = selectedPhotos.some((photo) => photo.status === 'pending'); const hasHiddenSelection = selectedPhotos.some((photo) => photo.status === 'hidden'); const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden'); const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured); const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured); const queuedEventCount = React.useMemo(() => { if (!slug) { return queuedActions.length; } return queuedActions.filter((action) => action.eventSlug === slug).length; }, [queuedActions, slug]); function toggleSelection(id: number) { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); } function clearSelection() { setSelectedIds([]); setSelectionMode(false); } const handleLightboxDragEnd = React.useCallback( (_event: PointerEvent, info: { offset: { x: number; y: number } }) => { if (lightboxIndex < 0) { return; } const { x, y } = info.offset; const absX = Math.abs(x); const absY = Math.abs(y); const swipeThreshold = 80; const dismissThreshold = 90; if (absY > absX && y > dismissThreshold) { setLightboxWithUrl(null, { replace: true }); return; } if (absX > swipeThreshold) { const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1; if (nextIndex >= 0 && nextIndex < photos.length) { setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true }); } } }, [lightboxIndex, photos, setLightboxWithUrl], ); async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') { if (!slug || bulkBusy || selectedPhotos.length === 0) return; setBulkBusy(true); const targets = selectedPhotos.filter((photo) => { if (action === 'approve') return photo.status === 'pending'; if (action === 'hide') return photo.status !== 'hidden'; if (action === 'show') return photo.status === 'hidden'; if (action === 'feature') return !photo.is_featured; if (action === 'unfeature') return photo.is_featured; return false; }); if (targets.length === 0) { setBulkBusy(false); return; } if (!online) { let nextQueue: PhotoModerationAction[] = []; targets.forEach((photo) => { nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action }); applyOptimisticUpdate(photo.id, action); }); setQueuedActions(nextQueue); toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); triggerHaptic('selection'); setBulkBusy(false); return; } try { const results = await Promise.allSettled( targets.map(async (photo) => { if (action === 'approve') { return await updatePhotoStatus(slug, photo.id, 'approved'); } if (action === 'hide') { return await updatePhotoVisibility(slug, photo.id, true); } if (action === 'show') { return await updatePhotoVisibility(slug, photo.id, false); } if (action === 'feature') { return await featurePhoto(slug, photo.id); } return await unfeaturePhoto(slug, photo.id); }), ); const updates = results .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') .map((result) => result.value); if (updates.length) { setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo)); toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied')); triggerHaptic('success'); } } catch { toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed')); } finally { setBulkBusy(false); } } function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : scopeOrKey.includes('guest') ? 'guests' : 'photos'; const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' ? selectAddonKeyForScope(catalogAddons, scope) : scopeOrKey; return { scope, addonKey }; } function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { if (!slug) return; const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey); setConsentTarget({ scope, addonKey }); setConsentOpen(true); } async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) { if (!slug || !consentTarget) return; const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : ''; const successUrl = `${currentUrl}?addon_success=1`; setBusyScope(consentTarget.scope); setConsentBusy(true); try { const checkout = await createEventAddonCheckout(slug, { addon_key: consentTarget.addonKey, quantity: 1, success_url: successUrl, cancel_url: currentUrl, accepted_terms: consents.acceptedTerms, accepted_waiver: consents.acceptedWaiver, }); if (checkout.checkout_url) { window.location.href = checkout.checkout_url; } else { toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.')); } } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.'))); } finally { setConsentBusy(false); setConsentOpen(false); setConsentTarget(null); setBusyScope(null); } } return ( navigate(-1)} headerActions={ { if (selectionMode) { clearSelection(); } else { setSelectionMode(true); } }} ariaLabel={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')} > {selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')} setShowFilters(true)} ariaLabel={t('mobilePhotos.filtersTitle', 'Filter')}> load()} ariaLabel={t('common.refresh', 'Refresh')}> } > {error ? ( {error} ) : null} {queuedEventCount > 0 ? ( {t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')} {online ? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', { count: queuedEventCount, }) : t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert – offline.', { count: queuedEventCount, })} syncQueuedActions()} tone="ghost" fullWidth={false} disabled={!online} loading={syncingQueue} /> ) : null} { setSearch(e.target.value); setPage(1); }} placeholder={t('photos.filters.search', 'Search uploads …')} compact style={{ marginBottom: 12 }} /> {(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => ( setFilter(key)} style={{ flex: 1 }}> {key === 'all' ? t('common.all', 'All') : key === 'featured' ? t('photos.filters.featured', 'Featured') : key === 'pending' ? t('photos.filters.pending', 'Pending') : t('photos.filters.hidden', 'Hidden')} ))} {!loading ? ( ) : null} {loading ? ( {Array.from({ length: 4 }).map((_, idx) => ( ))} ) : photos.length === 0 ? ( {t('mobilePhotos.empty', 'No photos found.')} ) : ( {t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
{photos.map((photo) => { const isSelected = selectedIds.includes(photo.id); return ( (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))} > {photo.is_featured ? {t('photos.filters.featured', 'Featured')} : null} {photo.status === 'pending' ? ( {t('photos.filters.pending', 'Pending')} ) : null} {photo.status === 'hidden' ? {t('photos.filters.hidden', 'Hidden')} : null} {selectionMode ? ( {isSelected ? : null} ) : null} ); })}
{hasMore ? ( setPage((prev) => prev + 1)} /> ) : null}
)} {selectionMode ? ( {t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })} clearSelection()}> {t('common.clear', 'Clear')} {hasPendingSelection ? ( applyBulkAction('approve')} fullWidth={false} disabled={bulkBusy} loading={bulkBusy} /> ) : null} {hasVisibleSelection ? ( applyBulkAction('hide')} tone="ghost" fullWidth={false} disabled={bulkBusy} loading={bulkBusy} /> ) : null} {hasHiddenSelection ? ( applyBulkAction('show')} tone="ghost" fullWidth={false} disabled={bulkBusy} loading={bulkBusy} /> ) : null} {hasUnfeaturedSelection ? ( applyBulkAction('feature')} tone="ghost" fullWidth={false} disabled={bulkBusy} loading={bulkBusy} /> ) : null} {hasFeaturedSelection ? ( applyBulkAction('unfeature')} tone="ghost" fullWidth={false} disabled={bulkBusy} loading={bulkBusy} /> ) : null} ) : null} {lightbox ? ( {lightbox.uploader_name || t('events.members.roles.guest', 'Guest')} ❤️ {lightbox.likes_count ?? 0} {lightbox.status === 'pending' ? ( {t('photos.filters.pending', 'Pending')} ) : null} {lightbox.status === 'hidden' ? ( {t('photos.filters.hidden', 'Hidden')} ) : null} {lightbox.status === 'pending' ? ( approvePhoto(lightbox)} style={{ flex: 1, minWidth: 140 }} /> ) : null} toggleFeature(lightbox)} style={{ flex: 1, minWidth: 140 }} /> toggleVisibility(lightbox)} style={{ flex: 1, minWidth: 140 }} /> setLightboxWithUrl(null, { replace: true })} /> ) : null} setShowFilters(false)} title={t('mobilePhotos.filtersTitle', 'Filter')} footer={ { setPage(1); setShowFilters(false); void load(); }} /> } > setUploaderFilter(e.target.value)} placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')} compact /> { setUploaderFilter(''); setOnlyFeatured(false); setOnlyHidden(false); }} /> {eventAddons.length ? ( {t('events.sections.addons.title', 'Add-ons & Upgrades')} ) : null} { if (consentBusy) return; setConsentOpen(false); setConsentTarget(null); }} onConfirm={confirmAddonCheckout} busy={consentBusy} t={t} />
); } type LimitTranslator = (key: string, options?: Record) => string; function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator { const defaults: Record = { photosBlocked: 'Upload limit reached. Buy more photos to continue.', photosWarning: '{{remaining}} of {{limit}} photos remaining.', guestsBlocked: 'Guest limit reached.', guestsWarning: '{{remaining}} of {{limit}} guests remaining.', galleryExpired: 'Gallery expired. Extend to keep it online.', galleryWarningDay: 'Gallery expires in {{days}} day.', galleryWarningDays: 'Gallery expires in {{days}} days.', buyMorePhotos: 'Buy more photos', extendGallery: 'Extend gallery', buyMoreGuests: 'Add more guests', }; return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options); } function LimitWarnings({ limits, addons, onCheckout, busyScope, translate, textColor, borderColor, }: { limits: EventLimitSummary | null; addons: EventAddonCatalogItem[]; onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void; busyScope: string | null; translate: LimitTranslator; textColor: string; borderColor: string; }) { const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); if (!warnings.length) { return null; } return ( {warnings.map((warning) => ( {warning.message} {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? ( ) : null} onCheckout(warning.scope)} loading={busyScope === warning.scope} /> ))} ); } function MobileAddonsPicker({ scope, addons, busy, onCheckout, translate, }: { scope: 'photos' | 'gallery' | 'guests'; addons: EventAddonCatalogItem[]; busy: boolean; onCheckout: (addonKey: string) => void; translate: LimitTranslator; }) { const options = React.useMemo(() => { const whitelist = scopeDefaults[scope]; const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key)); return filtered.length ? filtered : addons.filter((addon) => addon.price_id); }, [addons, scope]); const [selected, setSelected] = React.useState(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope)); React.useEffect(() => { if (options[0]?.key) { setSelected(options[0].key); } }, [options]); if (!options.length) { return null; } return ( setSelected(event.target.value)} style={{ flex: 1 }} compact> {options.map((addon) => ( ))} selected && onCheckout(selected)} loading={busy} /> ); } function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) { return ( {addons.map((addon) => ( {addon.label ?? addon.key} {addon.status} {addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'} {addon.extra_photos ? +{addon.extra_photos} photos : null} {addon.extra_guests ? +{addon.extra_guests} guests : null} {addon.extra_gallery_days ? +{addon.extra_gallery_days} days : null} ))} ); }