feat: update package copy and admin control room
This commit is contained in:
@@ -235,7 +235,7 @@ export default function MobileDashboardPage() {
|
||||
return;
|
||||
}
|
||||
closeTour();
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`));
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/control-room`));
|
||||
},
|
||||
showAction: Boolean(tourTargetSlug),
|
||||
},
|
||||
@@ -1223,20 +1223,20 @@ function EventManagementGrid({
|
||||
icon: ImageIcon,
|
||||
label: t('events.quick.images', 'Image Management'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: t('events.quick.liveShow', 'Live Show queue'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
|
||||
label: t('events.quick.controlRoom', 'Moderation & Live Show'),
|
||||
color: ADMIN_ACTION_COLORS.liveShow,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
color: ADMIN_ACTION_COLORS.liveShowSettings,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
|
||||
936
resources/js/admin/mobile/EventControlRoomPage.tsx
Normal file
936
resources/js/admin/mobile/EventControlRoomPage.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
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, 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);
|
||||
}
|
||||
|
||||
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<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 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<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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{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 (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{photo.original_name ?? t('common.photo', 'Photo')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{resolveLiveLabel(liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
<CTAButton
|
||||
label={t('photos.actions.approve', 'Approve')}
|
||||
onPress={() => handleModerationAction('approve', photo)}
|
||||
disabled={!canApprove}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
<CTAButton
|
||||
label={visibilityLabel}
|
||||
onPress={() => handleModerationAction(visibilityAction, photo)}
|
||||
disabled={false}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{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 (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{photo.original_name ?? t('common.photo', 'Photo')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{resolveLiveLabel(liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{showApproveAction ? (
|
||||
<CTAButton
|
||||
label={approveLabel}
|
||||
onPress={() => {
|
||||
if (approveMode === 'approve-and-live') {
|
||||
void handleApproveAndLive(photo);
|
||||
return;
|
||||
}
|
||||
if (approveMode === 'approve-only') {
|
||||
void handleApprove(photo);
|
||||
}
|
||||
}}
|
||||
disabled={!online || !canApproveLive}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSelect, MobileField } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
approveAndLiveShowPhoto,
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
getEvents,
|
||||
getLiveShowQueue,
|
||||
LiveShowQueueStatus,
|
||||
rejectLiveShowPhoto,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
} 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';
|
||||
|
||||
const 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' },
|
||||
];
|
||||
|
||||
export default function MobileEventLiveShowQueuePage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const online = useOnlineStatus();
|
||||
const { textStrong, text, muted, border, danger } = useAdminTheme();
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
selectEvent(slugParam);
|
||||
}
|
||||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
||||
|
||||
const loadQueue = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
if (!fallbackAttempted) {
|
||||
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}/live-show`), { replace: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setError(t('events.errors.missingSlug', 'No event selected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getLiveShowQueue(slug, {
|
||||
page,
|
||||
perPage: 20,
|
||||
liveStatus: statusFilter,
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadQueue();
|
||||
}, [loadQueue]);
|
||||
|
||||
async function handleApprove(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((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 {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApproveAndLive(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveAndLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((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 {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await rejectLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((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 {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await clearLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((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 {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'pending') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function resolveGalleryLabel(status?: string | null): string {
|
||||
const fallbackMap: Record<string, string> = {
|
||||
approved: 'Gallery approved',
|
||||
pending: 'Gallery pending',
|
||||
rejected: 'Gallery rejected',
|
||||
hidden: 'Hidden',
|
||||
};
|
||||
const key = status ?? 'pending';
|
||||
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('liveShowQueue.title', 'Live Show queue')}
|
||||
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
<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={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</MobileCard>
|
||||
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading && page === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<SkeletonCard key={`skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : photos.length === 0 ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={text}>
|
||||
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{photos.map((photo) => {
|
||||
const isBusy = busyId === photo.id;
|
||||
const liveStatus = photo.live_status ?? 'pending';
|
||||
const galleryStatus = photo.status ?? 'pending';
|
||||
const canApproveGallery = galleryStatus === 'pending';
|
||||
const canApproveLiveOnly = galleryStatus === 'approved';
|
||||
const canApproveLive = canApproveGallery || canApproveLiveOnly;
|
||||
const showApproveAction = liveStatus !== 'approved';
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{photo.uploaded_at}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{showApproveAction ? (
|
||||
<CTAButton
|
||||
label={
|
||||
canApproveGallery
|
||||
? t('liveShowQueue.approveAndLive', 'Approve + Live')
|
||||
: canApproveLiveOnly
|
||||
? t('liveShowQueue.approve', 'Approve for Live Show')
|
||||
: t('liveShowQueue.notEligible', 'Not eligible')
|
||||
}
|
||||
onPress={() => {
|
||||
if (canApproveGallery) {
|
||||
void handleApproveAndLive(photo);
|
||||
return;
|
||||
}
|
||||
if (canApproveLiveOnly) {
|
||||
void handleApprove(photo);
|
||||
}
|
||||
}}
|
||||
disabled={!online || !canApproveLive}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setPage((prev) => prev + 1)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export default function MobileUploadsTabPage() {
|
||||
const { text, muted, border, primary } = useAdminTheme();
|
||||
|
||||
if (activeEvent?.slug) {
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/photos`)} replace />;
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
|
||||
}
|
||||
|
||||
if (!hasEvents) {
|
||||
@@ -54,25 +54,25 @@ export default function MobileUploadsTabPage() {
|
||||
onPress={() => {
|
||||
selectEvent(event.slug ?? null);
|
||||
if (event.slug) {
|
||||
navigate(adminPath(`/mobile/events/${event.slug}/photos`));
|
||||
navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MobileCard borderColor={border} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{resolveEventDisplayName(event)}
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{resolveEventDisplayName(event)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('mobileUploads.open', 'Open')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('mobileUploads.open', 'Open')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { LimitWarnings } from '../EventPhotosPage';
|
||||
import { LimitWarnings } from '../components/LimitWarnings';
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
131
resources/js/admin/mobile/components/LimitWarnings.tsx
Normal file
131
resources/js/admin/mobile/components/LimitWarnings.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { XStack, YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { buildLimitWarnings } from '../../lib/limitWarnings';
|
||||
import type { EventAddonCatalogItem, EventLimitSummary } from '../../api';
|
||||
import { scopeDefaults, selectAddonKeyForScope } from '../addons';
|
||||
import { CTAButton, MobileCard } from './Primitives';
|
||||
import { MobileSelect } from './FormControls';
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function LimitWarnings({
|
||||
limits,
|
||||
addons,
|
||||
onCheckout,
|
||||
busyScope,
|
||||
translate,
|
||||
textColor,
|
||||
borderColor,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
addons: EventAddonCatalogItem[];
|
||||
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
|
||||
busyScope: string | null;
|
||||
translate: LimitTranslator;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
|
||||
if (!warnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{warnings.map((warning) => (
|
||||
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
|
||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||
{warning.message}
|
||||
</Text>
|
||||
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests')
|
||||
&& resolveAddonOptions(addons, warning.scope).length ? (
|
||||
<MobileAddonsPicker
|
||||
scope={warning.scope}
|
||||
addons={addons}
|
||||
busy={busyScope === warning.scope}
|
||||
onCheckout={onCheckout}
|
||||
translate={translate}
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={
|
||||
warning.scope === 'photos'
|
||||
? translate('buyMorePhotos')
|
||||
: warning.scope === 'gallery'
|
||||
? translate('extendGallery')
|
||||
: translate('buyMoreGuests')
|
||||
}
|
||||
onPress={() => onCheckout(warning.scope)}
|
||||
loading={busyScope === warning.scope}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] {
|
||||
const whitelist = scopeDefaults[scope];
|
||||
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
||||
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
||||
}
|
||||
|
||||
function MobileAddonsPicker({
|
||||
scope,
|
||||
addons,
|
||||
busy,
|
||||
onCheckout,
|
||||
translate,
|
||||
}: {
|
||||
scope: 'photos' | 'gallery' | 'guests';
|
||||
addons: EventAddonCatalogItem[];
|
||||
busy: boolean;
|
||||
onCheckout: (addonKey: string) => void;
|
||||
translate: LimitTranslator;
|
||||
}) {
|
||||
const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]);
|
||||
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (options[0]?.key) {
|
||||
setSelected(options[0].key);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack space="$2" alignItems="center">
|
||||
<MobileSelect
|
||||
value={selected}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
containerStyle={{ flex: 1, minWidth: 0 }}
|
||||
compact
|
||||
>
|
||||
{options.map((addon) => (
|
||||
<option key={addon.key} value={addon.key}>
|
||||
{addon.label ?? addon.key}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
<CTAButton
|
||||
label={
|
||||
scope === 'gallery'
|
||||
? translate('extendGallery')
|
||||
: scope === 'guests'
|
||||
? translate('buyMoreGuests')
|
||||
: translate('buyMorePhotos')
|
||||
}
|
||||
disabled={!selected || busy}
|
||||
onPress={() => selected && onCheckout(selected)}
|
||||
loading={busy}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -281,10 +281,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</Text>
|
||||
{effectiveActive?.slug ? (
|
||||
<CTAButton
|
||||
label={t('status.queueAction', 'Open Photos')}
|
||||
label={t('status.queueAction', 'Open moderation')}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/control-room`))}
|
||||
/>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
@@ -193,6 +193,15 @@ export function ActionTile({
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const { textStrong } = useAdminTheme();
|
||||
const backgroundColor = `${color}18`;
|
||||
const borderColor = `${color}40`;
|
||||
const shadowColor = `${color}2b`;
|
||||
const iconShadow = `${color}55`;
|
||||
const tileStyle = {
|
||||
...(delayMs ? { animationDelay: `${delayMs}ms` } : {}),
|
||||
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`,
|
||||
boxShadow: `0 10px 24px ${shadowColor}`,
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
onPress={disabled ? undefined : onPress}
|
||||
@@ -201,18 +210,26 @@ export function ActionTile({
|
||||
>
|
||||
<YStack
|
||||
className="admin-fade-up"
|
||||
style={delayMs ? { animationDelay: `${delayMs}ms` } : undefined}
|
||||
style={tileStyle}
|
||||
borderRadius={16}
|
||||
padding="$3"
|
||||
space="$2.5"
|
||||
backgroundColor={`${color}22`}
|
||||
backgroundColor={backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={`${color}55`}
|
||||
borderColor={borderColor}
|
||||
minHeight={110}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
backgroundColor={color}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{ boxShadow: `0 6px 14px ${iconShadow}` }}
|
||||
>
|
||||
<IconCmp size={16} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
|
||||
|
||||
47
resources/js/admin/mobile/lib/controlRoom.test.ts
Normal file
47
resources/js/admin/mobile/lib/controlRoom.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './controlRoom';
|
||||
|
||||
describe('normalizeLiveStatus', () => {
|
||||
it('maps nullish statuses to none', () => {
|
||||
expect(normalizeLiveStatus(null)).toBe('none');
|
||||
expect(normalizeLiveStatus(undefined)).toBe('none');
|
||||
expect(normalizeLiveStatus('')).toBe('none');
|
||||
});
|
||||
|
||||
it('passes through supported statuses', () => {
|
||||
expect(normalizeLiveStatus('pending')).toBe('pending');
|
||||
expect(normalizeLiveStatus('approved')).toBe('approved');
|
||||
expect(normalizeLiveStatus('rejected')).toBe('rejected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLiveShowApproveMode', () => {
|
||||
it('prefers approve-and-live for pending gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('pending')).toBe('approve-and-live');
|
||||
});
|
||||
|
||||
it('returns approve-only for approved gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('approved')).toBe('approve-only');
|
||||
});
|
||||
|
||||
it('returns not-eligible for rejected or hidden gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('rejected')).toBe('not-eligible');
|
||||
expect(resolveLiveShowApproveMode('hidden')).toBe('not-eligible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStatusTone', () => {
|
||||
it('maps approved to success', () => {
|
||||
expect(resolveStatusTone('approved')).toBe('success');
|
||||
});
|
||||
|
||||
it('maps pending to warning', () => {
|
||||
expect(resolveStatusTone('pending')).toBe('warning');
|
||||
});
|
||||
|
||||
it('maps other statuses to muted', () => {
|
||||
expect(resolveStatusTone('rejected')).toBe('muted');
|
||||
expect(resolveStatusTone('hidden')).toBe('muted');
|
||||
expect(resolveStatusTone(undefined)).toBe('muted');
|
||||
});
|
||||
});
|
||||
28
resources/js/admin/mobile/lib/controlRoom.ts
Normal file
28
resources/js/admin/mobile/lib/controlRoom.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type LiveShowApproveMode = 'approve-and-live' | 'approve-only' | 'not-eligible';
|
||||
|
||||
export function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'approved') {
|
||||
return 'success';
|
||||
}
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
export function normalizeLiveStatus(status?: string | null): 'pending' | 'approved' | 'rejected' | 'none' {
|
||||
if (status === 'approved' || status === 'pending' || status === 'rejected') {
|
||||
return status;
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function resolveLiveShowApproveMode(galleryStatus?: string | null): LiveShowApproveMode {
|
||||
if (galleryStatus === 'pending') {
|
||||
return 'approve-and-live';
|
||||
}
|
||||
if (galleryStatus === 'approved') {
|
||||
return 'approve-only';
|
||||
}
|
||||
return 'not-eligible';
|
||||
}
|
||||
@@ -23,9 +23,9 @@ describe('tabHistory', () => {
|
||||
});
|
||||
|
||||
it('reuses stored event route when slug matches', () => {
|
||||
setTabHistory('uploads', adminPath('/mobile/events/summer/photos'));
|
||||
setTabHistory('uploads', adminPath('/mobile/events/summer/control-room'));
|
||||
const target = resolveTabTarget('uploads', 'summer');
|
||||
expect(target).toBe(adminPath('/mobile/events/summer/photos'));
|
||||
expect(target).toBe(adminPath('/mobile/events/summer/control-room'));
|
||||
});
|
||||
|
||||
it('falls back to active slug when stored slug differs', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ function readHistory(): TabHistory {
|
||||
return { paths: {}, updatedAt: 0 };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as TabHistory;
|
||||
|
||||
|
||||
// Check for expiry
|
||||
if (Date.now() - parsed.updatedAt > EXPIRY_MS) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
@@ -61,7 +61,7 @@ export function resolveDefaultTarget(key: NavKey, slug?: string | null): string
|
||||
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
|
||||
}
|
||||
if (key === 'uploads') {
|
||||
return slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/uploads');
|
||||
return slug ? adminPath(`/mobile/events/${slug}/control-room`) : adminPath('/mobile/uploads');
|
||||
}
|
||||
if (key === 'profile') {
|
||||
return adminPath('/mobile/profile');
|
||||
@@ -78,7 +78,7 @@ function resolveEventScopedTarget(path: string, slug: string | null | undefined,
|
||||
return path;
|
||||
}
|
||||
|
||||
const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|photos)(?:\/.*)?$/);
|
||||
const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|control-room)(?:\/.*)?$/);
|
||||
if (!match) {
|
||||
return resolveDefaultTarget(key, slug);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function prefetchMobileRoutes() {
|
||||
schedule(() => {
|
||||
void import('./DashboardPage');
|
||||
void import('./EventsPage');
|
||||
void import('./EventPhotosPage');
|
||||
void import('./EventControlRoomPage');
|
||||
void import('./EventTasksPage');
|
||||
void import('./NotificationsPage');
|
||||
void import('./ProfilePage');
|
||||
|
||||
@@ -19,18 +19,20 @@ export const ADMIN_COLORS = {
|
||||
};
|
||||
|
||||
export const ADMIN_ACTION_COLORS = {
|
||||
tasks: '#FF8A8E',
|
||||
qr: ADMIN_COLORS.warning,
|
||||
images: ADMIN_COLORS.accent,
|
||||
guests: ADMIN_COLORS.success,
|
||||
guestMessages: ADMIN_COLORS.primary,
|
||||
invites: ADMIN_COLORS.primaryStrong,
|
||||
branding: ADMIN_COLORS.accent,
|
||||
photobooth: '#FF8A8E',
|
||||
recap: ADMIN_COLORS.warning,
|
||||
settings: '#14B8A6',
|
||||
tasks: '#F59E0B',
|
||||
qr: '#3B82F6',
|
||||
images: '#8B5CF6',
|
||||
liveShow: '#EC4899',
|
||||
liveShowSettings: '#0EA5E9',
|
||||
guests: '#10B981',
|
||||
guestMessages: '#F97316',
|
||||
branding: '#6366F1',
|
||||
photobooth: '#E11D48',
|
||||
recap: '#64748B',
|
||||
packages: ADMIN_COLORS.primary,
|
||||
analytics: '#8b5cf6',
|
||||
settings: ADMIN_COLORS.success,
|
||||
analytics: '#22C55E',
|
||||
invites: ADMIN_COLORS.primaryStrong,
|
||||
};
|
||||
|
||||
export const ADMIN_GRADIENTS = {
|
||||
|
||||
Reference in New Issue
Block a user