import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } 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 { Accordion } from '@tamagui/accordion'; import { ScrollView } from '@tamagui/scroll-view'; import { ToggleGroup } from '@tamagui/toggle-group'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard, ContentTabs } from './components/Primitives'; import { MobileField, MobileSelect } from './components/FormControls'; import { useEventContext } from '../context/EventContext'; import { approveAndLiveShowPhoto, approveLiveShowPhoto, clearLiveShowPhoto, ControlRoomSettings, ControlRoomUploaderRule, createEventAddonCheckout, EventAddonCatalogItem, EventLimitSummary, getAddonCatalog, featurePhoto, getEventPhotos, getEvents, getLiveShowQueue, LiveShowQueueStatus, rejectLiveShowPhoto, TenantEvent, TenantPhoto, unfeaturePhoto, updatePhotoStatus, updateEvent, 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 { useAuth } from '../auth/context'; import { withAlpha } from './components/colors'; import { ContextHelpLink } from './components/ContextHelpLink'; import { enqueuePhotoAction, loadPhotoQueue, removePhotoAction, replacePhotoQueue, type PhotoModerationAction, } from './lib/photoModerationQueue'; import { triggerHaptic } from './lib/haptics'; import { normalizeLiveStatus, resolveLiveShowApproveMode } 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); } type PhotoGridAction = { key: string; label: string; icon: React.ComponentType<{ size?: number; color?: string }>; onPress: () => void; disabled?: boolean; backgroundColor?: string; iconColor?: string; }; function PhotoGrid({ photos, actionsForPhoto, badgesForPhoto, isBusy, }: { photos: TenantPhoto[]; actionsForPhoto: (photo: TenantPhoto) => PhotoGridAction[]; badgesForPhoto?: (photo: TenantPhoto) => string[]; isBusy?: (photo: TenantPhoto) => boolean; }) { const gridStyle: React.CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, }; return (
{photos.map((photo) => ( ))}
); } function PhotoGridTile({ photo, actions, badges, isBusy, }: { photo: TenantPhoto; actions: PhotoGridAction[]; badges: string[]; isBusy: boolean; }) { const { border, muted, surfaceMuted } = useAdminTheme(); const overlayBg = withAlpha('#0f172a', 0.55); const actionBg = withAlpha('#0f172a', 0.55); return (
{photo.thumbnail_url ? ( {photo.original_name ) : ( )} {badges.length ? ( {badges.map((label) => ( ))} ) : null} {actions.map((action) => ( ))}
); } function PhotoActionButton({ label, icon: Icon, onPress, disabled = false, backgroundColor, iconColor, }: { label: string; icon: React.ComponentType<{ size?: number; color?: string }>; onPress: () => void; disabled?: boolean; backgroundColor: string; iconColor: string; }) { return ( ); } function PhotoStatusTag({ label }: { label: string }) { return ( {label} ); } function formatDeviceId(deviceId: string): string { if (!deviceId) { return ''; } if (deviceId.length <= 10) { return deviceId; } return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`; } export default function MobileEventControlRoomPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('management'); const { activeEvent, selectEvent, refetch } = useEventContext(); const { user } = useAuth(); const isMember = user?.role === 'member'; const slug = slugParam ?? activeEvent?.slug ?? null; const online = useOnlineStatus(); const { textStrong, text, muted, border, primary, danger, accent, surfaceMuted, surface } = 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 [moderationCounts, setModerationCounts] = React.useState>({ all: 0, pending: 0, featured: 0, hidden: 0, }); 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 [liveCounts, setLiveCounts] = React.useState>({ pending: 0, approved: 0, rejected: 0, none: 0, all: 0, expired: 0, }); const [controlRoomSettings, setControlRoomSettings] = React.useState({ auto_approve_highlights: true, auto_add_approved_to_live: true, auto_remove_live_on_hide: true, trusted_uploaders: [], force_review_uploaders: [], }); const [controlRoomSaving, setControlRoomSaving] = React.useState(false); const [trustedUploaderSelection, setTrustedUploaderSelection] = React.useState(''); const [forceReviewSelection, setForceReviewSelection] = React.useState(''); const isImmediateUploads = (activeEvent?.settings?.guest_upload_visibility ?? 'review') === 'immediate'; const autoApproveHighlights = Boolean(controlRoomSettings.auto_approve_highlights); const autoAddApprovedToLive = Boolean(controlRoomSettings.auto_add_approved_to_live) || isImmediateUploads; const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide); const trustedUploaders = controlRoomSettings.trusted_uploaders ?? []; const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? []; 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 activeFilterBg = primary; const saveControlRoomSettings = React.useCallback( async (nextSettings: ControlRoomSettings) => { if (!slug) { return; } const previous = controlRoomSettings; setControlRoomSettings(nextSettings); setControlRoomSaving(true); try { await updateEvent(slug, { settings: { control_room: nextSettings, }, }); refetch(); } catch (err) { setControlRoomSettings(previous); toast.error( getApiErrorMessage( err, t('controlRoom.automation.saveFailed', 'Automation settings could not be saved.') ) ); } finally { setControlRoomSaving(false); } }, [controlRoomSettings, refetch, slug, t], ); const uploaderOptions = React.useMemo(() => { const options = new Map(); const addPhoto = (photo: TenantPhoto) => { const deviceId = photo.created_by_device_id ?? ''; if (!deviceId) { return; } if (options.has(deviceId)) { return; } options.set(deviceId, { deviceId, label: photo.uploader_name ?? t('common.anonymous', 'Anonymous'), }); }; moderationPhotos.forEach(addPhoto); livePhotos.forEach(addPhoto); return Array.from(options.values()).map((entry) => ({ ...entry, display: `${entry.label} • ${formatDeviceId(entry.deviceId)}`, })); }, [livePhotos, moderationPhotos, t]); const mergeUploaderRule = (list: ControlRoomUploaderRule[], rule: ControlRoomUploaderRule) => { if (list.some((entry) => entry.device_id === rule.device_id)) { return list; } return [...list, rule]; }; const addUploaderRule = React.useCallback( (target: 'trusted' | 'force') => { const selectedId = target === 'trusted' ? trustedUploaderSelection : forceReviewSelection; if (!selectedId) { return; } const option = uploaderOptions.find((entry) => entry.deviceId === selectedId); const rule: ControlRoomUploaderRule = { device_id: selectedId, label: option?.label ?? t('common.anonymous', 'Anonymous'), }; const currentTrusted = controlRoomSettings.trusted_uploaders ?? []; const currentForceReview = controlRoomSettings.force_review_uploaders ?? []; const nextTrusted = target === 'trusted' ? mergeUploaderRule(currentTrusted, rule) : currentTrusted.filter((entry) => entry.device_id !== selectedId); const nextForceReview = target === 'force' ? mergeUploaderRule(currentForceReview, rule) : currentForceReview.filter((entry) => entry.device_id !== selectedId); saveControlRoomSettings({ ...controlRoomSettings, trusted_uploaders: nextTrusted, force_review_uploaders: nextForceReview, }); setTrustedUploaderSelection(''); setForceReviewSelection(''); }, [ controlRoomSettings, forceReviewSelection, saveControlRoomSettings, t, trustedUploaderSelection, uploaderOptions, ], ); const removeUploaderRule = React.useCallback( (target: 'trusted' | 'force', deviceId: string) => { const nextTrusted = target === 'trusted' ? (controlRoomSettings.trusted_uploaders ?? []).filter((entry) => entry.device_id !== deviceId) : controlRoomSettings.trusted_uploaders ?? []; const nextForceReview = target === 'force' ? (controlRoomSettings.force_review_uploaders ?? []).filter((entry) => entry.device_id !== deviceId) : controlRoomSettings.force_review_uploaders ?? []; saveControlRoomSettings({ ...controlRoomSettings, trusted_uploaders: nextTrusted, force_review_uploaders: nextForceReview, }); }, [controlRoomSettings, saveControlRoomSettings], ); React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { selectEvent(slugParam); } }, [slugParam, activeEvent?.slug, selectEvent]); React.useEffect(() => { const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings; const resolveFlag = (value: boolean | undefined) => (typeof value === 'boolean' ? value : true); setControlRoomSettings({ auto_approve_highlights: resolveFlag(settings.auto_approve_highlights), auto_add_approved_to_live: resolveFlag(settings.auto_add_approved_to_live), auto_remove_live_on_hide: resolveFlag(settings.auto_remove_live_on_hide), trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [], force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [], }); setTrustedUploaderSelection(''); setForceReviewSelection(''); }, [activeEvent?.settings?.control_room, activeEvent?.slug]); 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 loadModerationCounts = React.useCallback(async () => { if (!slug) { return; } try { const [all, pending, featured, hidden] = await Promise.all([ getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc' }), getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'pending' }), getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', featured: true }), getEventPhotos(slug, { page: 1, perPage: 1, sort: 'desc', status: 'hidden' }), ]); setModerationCounts({ all: all.meta?.total ?? all.photos.length, pending: pending.meta?.total ?? pending.photos.length, featured: featured.meta?.total ?? featured.photos.length, hidden: hidden.meta?.total ?? hidden.photos.length, }); } catch { // ignore } }, [slug]); 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]); const loadLiveCounts = React.useCallback(async () => { if (!slug) { return; } try { const statuses: LiveShowQueueStatus[] = ['pending', 'approved', 'rejected', 'none']; const results = await Promise.all( statuses.map((status) => getLiveShowQueue(slug, { page: 1, perPage: 1, liveStatus: status })) ); const nextCounts: Record = { pending: results[0]?.meta?.total ?? results[0]?.photos.length ?? 0, approved: results[1]?.meta?.total ?? results[1]?.photos.length ?? 0, rejected: results[2]?.meta?.total ?? results[2]?.photos.length ?? 0, none: results[3]?.meta?.total ?? results[3]?.photos.length ?? 0, all: 0, expired: 0, }; setLiveCounts(nextCounts); } catch { // ignore } }, [slug]); const refreshCounts = React.useCallback(() => { void loadModerationCounts(); void loadLiveCounts(); }, [loadLiveCounts, loadModerationCounts]); 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(() => { refreshCounts(); }, [refreshCounts]); 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 updatePhotoInCollections = React.useCallback((updated: TenantPhoto) => { setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo))); setLivePhotos((prev) => prev.map((photo) => (photo.id === updated.id ? updated : photo))); }, []); const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { const applyUpdate = (photo: TenantPhoto) => { 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; }; setModerationPhotos((prev) => prev.map(applyUpdate)); setLivePhotos((prev) => prev.map(applyUpdate)); }, []); 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 enqueueModerationActions = React.useCallback( (actions: PhotoModerationAction['action'][], photoId: number) => { if (!slug) { return; } let nextQueue: PhotoModerationAction[] = []; actions.forEach((action) => { nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action }); applyOptimisticUpdate(photoId, action); }); setQueuedActions(nextQueue); 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) { updatePhotoInCollections(updated); } } 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, updatePhotoInCollections, updateQueueState]); React.useEffect(() => { if (online) { void syncQueuedActions(); } }, [online, syncQueuedActions]); const handleModerationAction = React.useCallback( async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { if (!slug) { return; } const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending'; if (!online) { if (shouldAutoApproveHighlight) { enqueueModerationActions(['approve', 'feature'], photo.id); return; } enqueueModerationAction(action, photo.id); return; } setModerationBusyId(photo.id); try { let updated: TenantPhoto; if (action === 'approve') { updated = autoAddApprovedToLive ? await approveAndLiveShowPhoto(slug, photo.id) : 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') { if (shouldAutoApproveHighlight) { if (autoAddApprovedToLive) { await approveAndLiveShowPhoto(slug, photo.id); } else { await updatePhotoStatus(slug, photo.id, 'approved'); } } updated = await featurePhoto(slug, photo.id); } else { updated = await unfeaturePhoto(slug, photo.id); } updatePhotoInCollections(updated); refreshCounts(); triggerHaptic(action === 'approve' || action === 'feature' ? 'success' : 'medium'); } catch (err) { if (!isAuthError(err)) { const fallbackMessage = action === 'approve' ? t('mobilePhotos.approveFailed', 'Approval failed.') : action === 'feature' || action === 'unfeature' ? t('mobilePhotos.featureFailed', 'Highlight could not be changed.') : t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.'); const message = getApiErrorMessage(err, fallbackMessage); setModerationError(message); toast.error(message); } } finally { setModerationBusyId(null); } }, [ autoAddApprovedToLive, autoApproveHighlights, enqueueModerationAction, enqueueModerationActions, online, slug, t, updatePhotoInCollections, ], ); 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))); refreshCounts(); 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))); refreshCounts(); 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))); refreshCounts(); 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))); refreshCounts(); 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 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 && !isMember ? ( navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))} ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')} > ) : null} ); return ( setActiveTab(val as 'moderation' | 'live')} header={( {t('controlRoom.automation.sectionTitle', 'Einstellungen für Uploads')} {t('controlRoom.automation.title', 'Automation')} saveControlRoomSettings({ ...controlRoomSettings, auto_approve_highlights: Boolean(checked), }) } aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')} > { if (isImmediateUploads) { return; } saveControlRoomSettings({ ...controlRoomSettings, auto_add_approved_to_live: Boolean(checked), }); }} aria-label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')} > saveControlRoomSettings({ ...controlRoomSettings, auto_remove_live_on_hide: Boolean(checked), }) } aria-label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')} > {t('controlRoom.automation.uploaders.title', 'Uploader overrides')} {t( 'controlRoom.automation.uploaders.subtitle', 'Pick devices from recent uploads to always approve or always review their photos.', )} setTrustedUploaderSelection(event.target.value)} > {uploaderOptions.map((option) => ( ))} addUploaderRule('trusted')} tone="ghost" disabled={controlRoomSaving || !trustedUploaderSelection} /> {trustedUploaders.length ? ( {trustedUploaders.map((rule) => ( {rule.label ?? t('common.anonymous', 'Anonymous')} {formatDeviceId(rule.device_id)} removeUploaderRule('trusted', rule.device_id)} disabled={controlRoomSaving} aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} style={{ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: withAlpha(danger, 0.12), }} > {t('controlRoom.automation.uploaders.remove', 'Remove')} ))} ) : ( {t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} )} setForceReviewSelection(event.target.value)} > {uploaderOptions.map((option) => ( ))} addUploaderRule('force')} tone="ghost" disabled={controlRoomSaving || !forceReviewSelection} /> {forceReviewUploaders.length ? ( {forceReviewUploaders.map((rule) => ( {rule.label ?? t('common.anonymous', 'Anonymous')} {formatDeviceId(rule.device_id)} removeUploaderRule('force', rule.device_id)} disabled={controlRoomSaving} aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} style={{ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: withAlpha(danger, 0.12), }} > {t('controlRoom.automation.uploaders.remove', 'Remove')} ))} ) : ( {t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} )} )} tabs={[ { value: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation'), content: ( {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} {t('mobilePhotos.filtersTitle', 'Filter')} value && setModerationFilter(value as ModerationFilter)} > {MODERATION_FILTERS.map((option) => { const active = option.value === moderationFilter; const count = moderationCounts[option.value] ?? 0; return ( {t(option.labelKey, option.fallback)} {count} ); })} {!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.')} ) : ( moderationBusyId === photo.id} badgesForPhoto={(photo) => { const badges: string[] = []; if (photo.status === 'hidden') { badges.push(t('photos.filters.hidden', 'Hidden')); } if (photo.is_featured) { badges.push(t('photos.filters.featured', 'Highlights')); } return badges; }} actionsForPhoto={(photo) => { const isBusy = moderationBusyId === photo.id; const galleryStatus = photo.status ?? 'pending'; 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'); const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature'; const featureLabel = photo.is_featured ? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.feature', 'Set highlight'); const approveBg = withAlpha(primary, 0.78); const hideBg = withAlpha(danger, 0.75); const highlightBg = photo.is_featured ? withAlpha(accent, 0.85) : withAlpha(accent, 0.55); return [ { key: 'approve', label: t('photos.actions.approve', 'Approve'), icon: Check, onPress: () => handleModerationAction('approve', photo), disabled: !canApprove || isBusy, backgroundColor: approveBg, }, { key: 'visibility', label: visibilityLabel, icon: canShow ? Eye : EyeOff, onPress: () => handleModerationAction(visibilityAction, photo), disabled: isBusy, backgroundColor: hideBg, }, { key: 'feature', label: featureLabel, icon: Sparkles, onPress: () => handleModerationAction(featureAction, photo), disabled: isBusy, backgroundColor: highlightBg, }, ]; }} /> )} {moderationHasMore ? ( setModerationPage((prev) => prev + 1)} disabled={moderationLoading} /> ) : null} ), }, { value: 'live', label: t('controlRoom.tabs.live', 'Live Show'), content: ( {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} {t('liveShowQueue.filterLabel', 'Live status')} value && setLiveStatusFilter(value as LiveShowQueueStatus)} > {LIVE_STATUS_OPTIONS.map((option) => { const active = option.value === liveStatusFilter; const count = liveCounts[option.value] ?? 0; return ( {t(option.labelKey, option.fallback)} {count} ); })} {liveError ? ( {liveError} ) : null} {liveLoading && livePage === 1 ? ( {Array.from({ length: 3 }).map((_, idx) => ( ))} ) : livePhotos.length === 0 ? ( {t('controlRoom.emptyLive', 'No photos waiting for Live Show.')} ) : ( moderationBusyId === photo.id || liveBusyId === photo.id} badgesForPhoto={(photo) => { const badges: string[] = []; const liveStatus = normalizeLiveStatus(photo.live_status); if (liveStatus !== 'pending') { badges.push(resolveLiveLabel(liveStatus)); } if (photo.status === 'hidden') { badges.push(t('photos.filters.hidden', 'Hidden')); } if (photo.is_featured) { badges.push(t('photos.filters.featured', 'Highlights')); } return badges; }} actionsForPhoto={(photo) => { const isLiveBusy = liveBusyId === photo.id; const isModerationBusy = moderationBusyId === photo.id; const liveStatus = normalizeLiveStatus(photo.live_status); const galleryStatus = photo.status ?? 'pending'; const approveMode = resolveLiveShowApproveMode(galleryStatus); const canApproveLive = approveMode !== 'not-eligible'; const approveDisabled = !online || !canApproveLive || liveStatus === 'approved' || isLiveBusy; const visibilityLabel = t('photos.actions.hide', 'Hide'); const featureAction: PhotoModerationAction['action'] = photo.is_featured ? 'unfeature' : 'feature'; const featureLabel = photo.is_featured ? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.feature', 'Set highlight'); const approveBg = withAlpha(primary, 0.78); const hideBg = withAlpha(danger, 0.75); const highlightBg = photo.is_featured ? withAlpha(accent, 0.85) : withAlpha(accent, 0.55); return [ { key: 'approve', label: t('photos.actions.approve', 'Approve'), icon: Check, onPress: () => { if (approveMode === 'approve-and-live') { void handleApproveAndLive(photo); return; } if (approveMode === 'approve-only') { void handleApprove(photo); } }, disabled: approveDisabled, backgroundColor: approveBg, }, { key: 'visibility', label: visibilityLabel, icon: EyeOff, onPress: () => { if (liveStatus === 'approved' || liveStatus === 'rejected') { void handleClear(photo); return; } void handleReject(photo); }, disabled: !online || isLiveBusy || isModerationBusy, backgroundColor: hideBg, }, { key: 'feature', label: featureLabel, icon: Sparkles, onPress: () => handleModerationAction(featureAction, photo), disabled: isModerationBusy, backgroundColor: highlightBg, }, ]; }} /> )} {liveHasMore ? ( setLivePage((prev) => prev + 1)} disabled={liveLoading} /> ) : null} ), }, ]} /> { if (consentBusy) return; setConsentOpen(false); setConsentTarget(null); }} onConfirm={confirmAddonCheckout} busy={consentBusy} t={t} /> ); }