1109 lines
39 KiB
TypeScript
1109 lines
39 KiB
TypeScript
import React from 'react';
|
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Check, 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 { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
import { MobileCard, CTAButton, 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 { useAuth } from '../auth/context';
|
|
import { withAlpha } from './components/colors';
|
|
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, unknown>) => string;
|
|
|
|
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
|
const defaults: Record<string, string> = {
|
|
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 (
|
|
<div style={gridStyle}>
|
|
{photos.map((photo) => (
|
|
<PhotoGridTile
|
|
key={photo.id}
|
|
photo={photo}
|
|
actions={actionsForPhoto(photo)}
|
|
badges={badgesForPhoto ? badgesForPhoto(photo) : []}
|
|
isBusy={isBusy ? isBusy(photo) : false}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
border: `1px solid ${border}`,
|
|
aspectRatio: '1 / 1',
|
|
backgroundColor: surfaceMuted,
|
|
opacity: isBusy ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{photo.thumbnail_url ? (
|
|
<img
|
|
src={photo.thumbnail_url}
|
|
alt={photo.original_name ?? 'Photo'}
|
|
loading="lazy"
|
|
decoding="async"
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<XStack alignItems="center" justifyContent="center" height="100%" backgroundColor={surfaceMuted}>
|
|
<ImageIcon size={22} color={muted} />
|
|
</XStack>
|
|
)}
|
|
|
|
{badges.length ? (
|
|
<XStack position="absolute" top={6} left={6} space="$1.5">
|
|
{badges.map((label) => (
|
|
<PhotoStatusTag key={`${photo.id}-${label}`} label={label} />
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
|
|
<XStack
|
|
position="absolute"
|
|
left={6}
|
|
right={6}
|
|
bottom={6}
|
|
padding="$1"
|
|
borderRadius={12}
|
|
backgroundColor={overlayBg}
|
|
space="$2"
|
|
justifyContent="space-between"
|
|
>
|
|
{actions.map((action) => (
|
|
<PhotoActionButton
|
|
key={action.key}
|
|
label={action.label}
|
|
icon={action.icon}
|
|
onPress={action.onPress}
|
|
disabled={action.disabled}
|
|
backgroundColor={action.backgroundColor ?? actionBg}
|
|
iconColor={action.iconColor ?? '#fff'}
|
|
/>
|
|
))}
|
|
</XStack>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Pressable
|
|
onPress={disabled ? undefined : onPress}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
style={{ flex: 1, opacity: disabled ? 0.55 : 1 }}
|
|
>
|
|
<YStack
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
paddingVertical={8}
|
|
borderRadius={12}
|
|
minHeight={40}
|
|
style={{ backgroundColor }}
|
|
>
|
|
<Icon size={18} color={iconColor} />
|
|
</YStack>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function PhotoStatusTag({ label }: { label: string }) {
|
|
return (
|
|
<XStack paddingHorizontal={6} paddingVertical={2} borderRadius={999} backgroundColor={withAlpha('#0f172a', 0.7)}>
|
|
<Text fontSize={9} fontWeight="700" color="#fff">
|
|
{label}
|
|
</Text>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
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 { user } = useAuth();
|
|
const isMember = user?.role === 'member';
|
|
const slug = slugParam ?? activeEvent?.slug ?? null;
|
|
const online = useOnlineStatus();
|
|
const { textStrong, text, muted, border, accentSoft, accent, danger, primary } = useAdminTheme();
|
|
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
|
|
|
|
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
|
|
const [moderationFilter, setModerationFilter] = React.useState<ModerationFilter>('pending');
|
|
const [moderationPage, setModerationPage] = React.useState(1);
|
|
const [moderationHasMore, setModerationHasMore] = React.useState(false);
|
|
const [moderationLoading, setModerationLoading] = React.useState(true);
|
|
const [moderationError, setModerationError] = React.useState<string | null>(null);
|
|
const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null);
|
|
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
|
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
|
const [busyScope, setBusyScope] = React.useState<string | null>(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<TenantPhoto[]>([]);
|
|
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
|
const [livePage, setLivePage] = React.useState(1);
|
|
const [liveHasMore, setLiveHasMore] = React.useState(false);
|
|
const [liveLoading, setLiveLoading] = React.useState(true);
|
|
const [liveError, setLiveError] = React.useState<string | null>(null);
|
|
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
|
|
|
|
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => 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 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 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;
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
updatePhotoInCollections(updated);
|
|
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);
|
|
}
|
|
},
|
|
[enqueueModerationAction, 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)));
|
|
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<string, string> = {
|
|
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 = (
|
|
<XStack space="$2">
|
|
<HeaderActionButton
|
|
onPress={() => {
|
|
if (activeTab === 'moderation') {
|
|
void loadModeration();
|
|
return;
|
|
}
|
|
void loadLiveQueue();
|
|
}}
|
|
ariaLabel={t('common.refresh', 'Refresh')}
|
|
>
|
|
<RefreshCcw size={18} color={textStrong} />
|
|
</HeaderActionButton>
|
|
{slug && !isMember ? (
|
|
<HeaderActionButton
|
|
onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))}
|
|
ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')}
|
|
>
|
|
<Settings size={18} color={textStrong} />
|
|
</HeaderActionButton>
|
|
) : null}
|
|
</XStack>
|
|
);
|
|
|
|
return (
|
|
<MobileShell
|
|
activeTab="uploads"
|
|
title={t('controlRoom.title', 'Moderation & Live Show')}
|
|
subtitle={t('controlRoom.subtitle', 'Review uploads and manage the live slideshow.')}
|
|
onBack={back}
|
|
headerActions={headerActions}
|
|
>
|
|
<XStack space="$2">
|
|
{([
|
|
{ key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') },
|
|
{ key: 'live', label: t('controlRoom.tabs.live', 'Live Show') },
|
|
] as const).map((tab) => (
|
|
<Pressable key={tab.key} onPress={() => setActiveTab(tab.key)} style={{ flex: 1 }}>
|
|
<MobileCard
|
|
backgroundColor={activeTab === tab.key ? infoBg : 'transparent'}
|
|
borderColor={activeTab === tab.key ? infoBorder : border}
|
|
padding="$2.5"
|
|
>
|
|
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={textStrong}>
|
|
{tab.label}
|
|
</Text>
|
|
</MobileCard>
|
|
</Pressable>
|
|
))}
|
|
</XStack>
|
|
|
|
{activeTab === 'moderation' ? (
|
|
<YStack space="$2">
|
|
{queuedEventCount > 0 ? (
|
|
<MobileCard>
|
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
|
<YStack space="$1" flex={1}>
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
|
|
</Text>
|
|
<Text fontSize="$xs" color={muted}>
|
|
{online
|
|
? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount })
|
|
: t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })}
|
|
</Text>
|
|
</YStack>
|
|
<CTAButton
|
|
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
|
|
onPress={() => syncQueuedActions()}
|
|
tone="ghost"
|
|
fullWidth={false}
|
|
disabled={!online}
|
|
loading={syncingQueue}
|
|
/>
|
|
</XStack>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
<MobileCard>
|
|
<MobileField label={t('mobilePhotos.filtersTitle', 'Filter')}>
|
|
<MobileSelect
|
|
value={moderationFilter}
|
|
onChange={(event) => setModerationFilter(event.target.value as ModerationFilter)}
|
|
>
|
|
{MODERATION_FILTERS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{t(option.labelKey, option.fallback)}
|
|
</option>
|
|
))}
|
|
</MobileSelect>
|
|
</MobileField>
|
|
</MobileCard>
|
|
|
|
{!moderationLoading ? (
|
|
<LimitWarnings
|
|
limits={limits}
|
|
addons={catalogAddons}
|
|
onCheckout={startAddonCheckout}
|
|
busyScope={busyScope}
|
|
translate={translateLimits(t as any)}
|
|
textColor={text}
|
|
borderColor={border}
|
|
/>
|
|
) : null}
|
|
|
|
{moderationError ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{moderationError}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
{moderationLoading && moderationPage === 1 ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 3 }).map((_, idx) => (
|
|
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
|
|
))}
|
|
</YStack>
|
|
) : moderationPhotos.length === 0 ? (
|
|
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
|
<ImageIcon size={28} color={muted} />
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
|
|
</Text>
|
|
</MobileCard>
|
|
) : (
|
|
<PhotoGrid
|
|
photos={moderationPhotos}
|
|
isBusy={(photo) => 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 ? (
|
|
<MobileCard>
|
|
<CTAButton
|
|
label={t('common.loadMore', 'Load more')}
|
|
onPress={() => setModerationPage((prev) => prev + 1)}
|
|
disabled={moderationLoading}
|
|
/>
|
|
</MobileCard>
|
|
) : null}
|
|
</YStack>
|
|
) : (
|
|
<YStack space="$2">
|
|
<MobileCard borderColor={border} backgroundColor="transparent">
|
|
<Text fontSize="$sm" color={muted}>
|
|
{t(
|
|
'liveShowQueue.galleryApprovedOnly',
|
|
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
|
|
)}
|
|
</Text>
|
|
{!online ? (
|
|
<Text fontSize="$sm" color={danger}>
|
|
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
|
|
</Text>
|
|
) : null}
|
|
</MobileCard>
|
|
|
|
<MobileCard>
|
|
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
|
|
<MobileSelect
|
|
value={liveStatusFilter}
|
|
onChange={(event) => setLiveStatusFilter(event.target.value as LiveShowQueueStatus)}
|
|
>
|
|
{LIVE_STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{t(option.labelKey, option.fallback)}
|
|
</option>
|
|
))}
|
|
</MobileSelect>
|
|
</MobileField>
|
|
</MobileCard>
|
|
|
|
{liveError ? (
|
|
<MobileCard>
|
|
<Text fontWeight="700" color={danger}>
|
|
{liveError}
|
|
</Text>
|
|
</MobileCard>
|
|
) : null}
|
|
|
|
{liveLoading && livePage === 1 ? (
|
|
<YStack space="$2">
|
|
{Array.from({ length: 3 }).map((_, idx) => (
|
|
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
|
|
))}
|
|
</YStack>
|
|
) : livePhotos.length === 0 ? (
|
|
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
|
|
</Text>
|
|
</MobileCard>
|
|
) : (
|
|
<PhotoGrid
|
|
photos={livePhotos}
|
|
isBusy={(photo) => 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 ? (
|
|
<MobileCard>
|
|
<CTAButton
|
|
label={t('common.loadMore', 'Load more')}
|
|
onPress={() => setLivePage((prev) => prev + 1)}
|
|
disabled={liveLoading}
|
|
/>
|
|
</MobileCard>
|
|
) : null}
|
|
</YStack>
|
|
)}
|
|
|
|
<LegalConsentSheet
|
|
open={consentOpen}
|
|
onClose={() => {
|
|
if (consentBusy) return;
|
|
setConsentOpen(false);
|
|
setConsentTarget(null);
|
|
}}
|
|
onConfirm={confirmAddonCheckout}
|
|
busy={consentBusy}
|
|
t={t}
|
|
/>
|
|
</MobileShell>
|
|
);
|
|
}
|