import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Image as ImageIcon, RefreshCcw, Settings } 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 { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileField, MobileSelect } from './components/FormControls'; import { useEventContext } from '../context/EventContext'; import { approveAndLiveShowPhoto, approveLiveShowPhoto, clearLiveShowPhoto, createEventAddonCheckout, EventAddonCatalogItem, EventLimitSummary, getAddonCatalog, featurePhoto, getEventPhotos, getEvents, getLiveShowQueue, LiveShowQueueStatus, rejectLiveShowPhoto, TenantEvent, TenantPhoto, unfeaturePhoto, updatePhotoStatus, updatePhotoVisibility, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { adminPath } from '../constants'; import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { useOnlineStatus } from './hooks/useOnlineStatus'; import { enqueuePhotoAction, loadPhotoQueue, removePhotoAction, replacePhotoQueue, type PhotoModerationAction, } from './lib/photoModerationQueue'; import { triggerHaptic } from './lib/haptics'; import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './lib/controlRoom'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { selectAddonKeyForScope } from './addons'; import { LimitWarnings } from './components/LimitWarnings'; type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending'; const MODERATION_FILTERS: Array<{ value: ModerationFilter; labelKey: string; fallback: string }> = [ { value: 'pending', labelKey: 'photos.filters.pending', fallback: 'Pending' }, { value: 'all', labelKey: 'photos.filters.all', fallback: 'All' }, { value: 'featured', labelKey: 'photos.filters.featured', fallback: 'Featured' }, { value: 'hidden', labelKey: 'photos.filters.hidden', fallback: 'Hidden' }, ]; const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [ { value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' }, { value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' }, { value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' }, { value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' }, ]; 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.', galleryWarningHour: 'Gallery expires in {{hours}} hour.', galleryWarningHours: 'Gallery expires in {{hours}} hours.', 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); } export default function MobileEventControlRoomPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('management'); const { activeEvent, selectEvent } = useEventContext(); const slug = slugParam ?? activeEvent?.slug ?? null; const online = useOnlineStatus(); const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme(); const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation'); const [moderationPhotos, setModerationPhotos] = React.useState([]); const [moderationFilter, setModerationFilter] = React.useState('pending'); const [moderationPage, setModerationPage] = React.useState(1); const [moderationHasMore, setModerationHasMore] = React.useState(false); const [moderationLoading, setModerationLoading] = React.useState(true); const [moderationError, setModerationError] = React.useState(null); const [moderationBusyId, setModerationBusyId] = React.useState(null); const [limits, setLimits] = React.useState(null); const [catalogAddons, setCatalogAddons] = 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 [livePhotos, setLivePhotos] = React.useState([]); const [liveStatusFilter, setLiveStatusFilter] = React.useState('pending'); const [livePage, setLivePage] = React.useState(1); const [liveHasMore, setLiveHasMore] = React.useState(false); const [liveLoading, setLiveLoading] = React.useState(true); const [liveError, setLiveError] = React.useState(null); const [liveBusyId, setLiveBusyId] = React.useState(null); const [queuedActions, setQueuedActions] = React.useState(() => loadPhotoQueue()); const [syncingQueue, setSyncingQueue] = React.useState(false); const syncingQueueRef = React.useRef(false); const moderationResetRef = React.useRef(false); const liveResetRef = React.useRef(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const infoBg = accentSoft; const infoBorder = accent; React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { selectEvent(slugParam); } }, [slugParam, activeEvent?.slug, selectEvent]); const ensureSlug = React.useCallback(async () => { if (slug) { return slug; } if (fallbackAttempted) { return null; } setFallbackAttempted(true); try { const events = await getEvents({ force: true }); const first = events[0] as TenantEvent | undefined; if (first?.slug) { selectEvent(first.slug); navigate(adminPath(`/mobile/events/${first.slug}/control-room`), { replace: true }); return first.slug; } } catch { // ignore } return null; }, [slug, fallbackAttempted, navigate, selectEvent]); React.useEffect(() => { setModerationPage(1); }, [moderationFilter, slug]); React.useEffect(() => { setLivePage(1); }, [liveStatusFilter, slug]); React.useEffect(() => { if (activeTab === 'moderation') { moderationResetRef.current = true; setModerationPhotos([]); setModerationPage(1); } else { liveResetRef.current = true; setLivePhotos([]); setLivePage(1); } }, [activeTab]); const loadModeration = React.useCallback(async () => { const resolvedSlug = await ensureSlug(); if (!resolvedSlug) { setModerationLoading(false); setModerationError(t('events.errors.missingSlug', 'No event selected.')); return; } setModerationLoading(true); setModerationError(null); try { const status = moderationFilter === 'hidden' ? 'hidden' : moderationFilter === 'pending' ? 'pending' : undefined; const result = await getEventPhotos(resolvedSlug, { page: moderationPage, perPage: 20, sort: 'desc', featured: moderationFilter === 'featured', status, }); setModerationPhotos((prev) => (moderationPage === 1 ? result.photos : [...prev, ...result.photos])); setLimits(result.limits ?? null); const lastPage = result.meta?.last_page ?? 1; setModerationHasMore(moderationPage < lastPage); const addons = await getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]); setCatalogAddons(addons ?? []); } catch (err) { if (!isAuthError(err)) { const message = getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Photos could not be loaded.')); setModerationError(message); } } finally { setModerationLoading(false); } }, [ensureSlug, moderationFilter, moderationPage, t]); const loadLiveQueue = React.useCallback(async () => { const resolvedSlug = await ensureSlug(); if (!resolvedSlug) { setLiveLoading(false); setLiveError(t('events.errors.missingSlug', 'No event selected.')); return; } setLiveLoading(true); setLiveError(null); try { const result = await getLiveShowQueue(resolvedSlug, { page: livePage, perPage: 20, liveStatus: liveStatusFilter, }); setLivePhotos((prev) => (livePage === 1 ? result.photos : [...prev, ...result.photos])); const lastPage = result.meta?.last_page ?? 1; setLiveHasMore(livePage < lastPage); } catch (err) { if (!isAuthError(err)) { const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.')); setLiveError(message); toast.error(message); } } finally { setLiveLoading(false); } }, [ensureSlug, livePage, liveStatusFilter, t]); React.useEffect(() => { if (activeTab === 'moderation') { if (moderationResetRef.current && moderationPage !== 1) { return; } moderationResetRef.current = false; void loadModeration(); } }, [activeTab, loadModeration, moderationPage]); React.useEffect(() => { if (activeTab === 'live') { if (liveResetRef.current && livePage !== 1) { return; } liveResetRef.current = false; void loadLiveQueue(); } }, [activeTab, loadLiveQueue, livePage]); 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.')); setModerationPage(1); void loadModeration(); params.delete('addon_success'); navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); } }, [location.search, slug, loadModeration, navigate, t, location.pathname]); const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { replacePhotoQueue(queue); setQueuedActions(queue); }, []); const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { setModerationPhotos((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' }; } 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', 'Action saved. Syncs when you are back online.')); triggerHaptic('selection'); }, [applyOptimisticUpdate, slug, t], ); const syncQueuedActions = React.useCallback(async () => { 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) { setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo))); } } catch (err) { toast.error(t('mobilePhotos.syncFailed', 'Sync failed. Please try again later.')); if (isAuthError(err)) { break; } } } updateQueueState(remaining); setSyncingQueue(false); syncingQueueRef.current = false; }, [online, slug, t, updateQueueState]); React.useEffect(() => { if (online) { void syncQueuedActions(); } }, [online, syncQueuedActions]); const handleModerationAction = React.useCallback( async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { if (!slug) { return; } if (!online) { enqueueModerationAction(action, photo.id); return; } setModerationBusyId(photo.id); 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 { updated = await updatePhotoVisibility(slug, photo.id, false); } setModerationPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); triggerHaptic(action === 'approve' ? 'success' : 'medium'); } catch (err) { if (!isAuthError(err)) { const message = getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.')); setModerationError(message); toast.error(message); } } finally { setModerationBusyId(null); } }, [enqueueModerationAction, online, slug, t], ); 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: scope as 'photos' | 'gallery' | 'guests', 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}/control-room`)}` : ''; 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, } as any); 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); } } async function handleApprove(photo: TenantPhoto) { if (!slug || liveBusyId) return; setLiveBusyId(photo.id); try { const updated = await approveLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show')); } catch (err) { if (!isAuthError(err)) { toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); } } finally { setLiveBusyId(null); } } async function handleApproveAndLive(photo: TenantPhoto) { if (!slug || liveBusyId) return; setLiveBusyId(photo.id); try { const updated = await approveAndLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show')); } catch (err) { if (!isAuthError(err)) { toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); } } finally { setLiveBusyId(null); } } async function handleReject(photo: TenantPhoto) { if (!slug || liveBusyId) return; setLiveBusyId(photo.id); try { const updated = await rejectLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show')); } catch (err) { if (!isAuthError(err)) { toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); } } finally { setLiveBusyId(null); } } async function handleClear(photo: TenantPhoto) { if (!slug || liveBusyId) return; setLiveBusyId(photo.id); try { const updated = await clearLiveShowPhoto(slug, photo.id); setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item))); toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed')); } catch (err) { if (!isAuthError(err)) { toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.')); } } finally { setLiveBusyId(null); } } function resolveGalleryLabel(status?: string | null): string { const key = status ?? 'pending'; const fallbackMap: Record = { approved: 'Gallery approved', pending: 'Gallery pending', rejected: 'Gallery rejected', hidden: 'Hidden', }; return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key); } function resolveLiveLabel(status?: string | null): string { const key = normalizeLiveStatus(status); return t(`liveShowQueue.status.${key}`, key); } const queuedEventCount = React.useMemo(() => { if (!slug) { return queuedActions.length; } return queuedActions.filter((action) => action.eventSlug === slug).length; }, [queuedActions, slug]); const headerActions = ( { if (activeTab === 'moderation') { void loadModeration(); return; } void loadLiveQueue(); }} ariaLabel={t('common.refresh', 'Refresh')} > {slug ? ( navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))} ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')} > ) : null} ); return ( {([ { key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') }, { key: 'live', label: t('controlRoom.tabs.live', 'Live Show') }, ] as const).map((tab) => ( setActiveTab(tab.key)} style={{ flex: 1 }}> {tab.label} ))} {activeTab === 'moderation' ? ( {queuedEventCount > 0 ? ( {t('mobilePhotos.queueTitle', 'Changes waiting to sync')} {online ? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount }) : t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })} syncQueuedActions()} tone="ghost" fullWidth={false} disabled={!online} loading={syncingQueue} /> ) : null} setModerationFilter(event.target.value as ModerationFilter)} > {MODERATION_FILTERS.map((option) => ( ))} {!moderationLoading ? ( ) : null} {moderationError ? ( {moderationError} ) : null} {moderationLoading && moderationPage === 1 ? ( {Array.from({ length: 3 }).map((_, idx) => ( ))} ) : moderationPhotos.length === 0 ? ( {t('controlRoom.emptyModeration', 'No uploads match this filter.')} ) : ( {moderationPhotos.map((photo) => { const isBusy = moderationBusyId === photo.id; const galleryStatus = photo.status ?? 'pending'; const liveStatus = normalizeLiveStatus(photo.live_status); const canApprove = galleryStatus === 'pending'; const canShow = galleryStatus === 'hidden'; const visibilityAction: PhotoModerationAction['action'] = canShow ? 'show' : 'hide'; const visibilityLabel = canShow ? t('photos.actions.show', 'Show') : t('photos.actions.hide', 'Hide'); return ( {photo.thumbnail_url ? ( {photo.original_name ) : null} {photo.original_name ?? t('common.photo', 'Photo')} {resolveGalleryLabel(galleryStatus)} {resolveLiveLabel(liveStatus)} handleModerationAction('approve', photo)} disabled={!canApprove} loading={isBusy} tone="primary" /> handleModerationAction(visibilityAction, photo)} disabled={false} loading={isBusy} tone="ghost" /> ); })} )} {moderationHasMore ? ( setModerationPage((prev) => prev + 1)} disabled={moderationLoading} /> ) : null} ) : ( {t( 'liveShowQueue.galleryApprovedOnly', 'Gallery and Live Show approvals are separate. Pending photos can be approved here.' )} {!online ? ( {t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')} ) : null} setLiveStatusFilter(event.target.value as LiveShowQueueStatus)} > {LIVE_STATUS_OPTIONS.map((option) => ( ))} {liveError ? ( {liveError} ) : null} {liveLoading && livePage === 1 ? ( {Array.from({ length: 3 }).map((_, idx) => ( ))} ) : livePhotos.length === 0 ? ( {t('controlRoom.emptyLive', 'No photos waiting for Live Show.')} ) : ( {livePhotos.map((photo) => { const isBusy = liveBusyId === photo.id; const liveStatus = normalizeLiveStatus(photo.live_status); const galleryStatus = photo.status ?? 'pending'; const approveMode = resolveLiveShowApproveMode(galleryStatus); const canApproveLive = approveMode !== 'not-eligible'; const showApproveAction = liveStatus !== 'approved'; const approveLabel = approveMode === 'approve-and-live' ? t('liveShowQueue.approveAndLive', 'Approve + Live') : approveMode === 'approve-only' ? t('liveShowQueue.approve', 'Approve for Live Show') : t('liveShowQueue.notEligible', 'Not eligible'); return ( {photo.thumbnail_url ? ( {photo.original_name ) : null} {photo.original_name ?? t('common.photo', 'Photo')} {resolveGalleryLabel(galleryStatus)} {resolveLiveLabel(liveStatus)} {showApproveAction ? ( { if (approveMode === 'approve-and-live') { void handleApproveAndLive(photo); return; } if (approveMode === 'approve-only') { void handleApprove(photo); } }} disabled={!online || !canApproveLive} loading={isBusy} tone="primary" /> ) : ( handleClear(photo)} disabled={!online} loading={isBusy} tone="ghost" /> )} {liveStatus !== 'rejected' ? ( handleReject(photo)} disabled={!online} loading={isBusy} tone="danger" /> ) : ( handleClear(photo)} disabled={!online} loading={isBusy} tone="ghost" /> )} ); })} )} {liveHasMore ? ( setLivePage((prev) => prev + 1)} disabled={liveLoading} /> ) : null} )} { if (consentBusy) return; setConsentOpen(false); setConsentTarget(null); }} onConfirm={confirmAddonCheckout} busy={consentBusy} t={t} /> ); }