Add control room automations and uploader overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-20 15:49:04 +01:00
parent e5e74febbd
commit 5e5b69f655
11 changed files with 738 additions and 33 deletions

View File

@@ -73,6 +73,19 @@ export type LiveShowSettings = {
background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand';
};
export type ControlRoomUploaderRule = {
device_id: string;
label?: string | null;
};
export type ControlRoomSettings = {
auto_approve_highlights?: boolean;
auto_add_approved_to_live?: boolean;
auto_remove_live_on_hide?: boolean;
trusted_uploaders?: ControlRoomUploaderRule[];
force_review_uploaders?: ControlRoomUploaderRule[];
};
export type LiveShowLink = {
token: string;
url: string;
@@ -101,6 +114,7 @@ export type TenantEvent = {
engagement_mode?: 'tasks' | 'photo_only';
guest_upload_visibility?: 'review' | 'immediate';
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
watermark?: WatermarkSettings;
watermark_allowed?: boolean | null;
watermark_removal_allowed?: boolean | null;
@@ -164,6 +178,7 @@ export type TenantPhoto = {
likes_count: number;
uploaded_at: string;
uploader_name: string | null;
created_by_device_id?: string | null;
ingest_source?: string | null;
caption?: string | null;
};
@@ -809,6 +824,7 @@ type EventSavePayload = {
accepted_waiver?: boolean;
settings?: Record<string, unknown> & {
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null;
};
@@ -1022,6 +1038,7 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
likes_count: Number(photo.likes_count ?? 0),
uploaded_at: photo.uploaded_at,
uploader_name: photo.uploader_name ?? null,
created_by_device_id: photo.created_by_device_id ?? null,
caption: photo.caption ?? null,
};
}

View File

@@ -2482,6 +2482,34 @@
"moderation": "Moderation",
"live": "Live-Show"
},
"automation": {
"title": "Automatik",
"autoApproveHighlights": {
"label": "Highlights automatisch freigeben",
"help": "Wenn du ein ausstehendes Foto highlightest, wird es automatisch freigegeben."
},
"autoAddApproved": {
"label": "Freigegebene automatisch in die Live-Show",
"help": "Freigegebene oder gehighlightete Fotos gehen direkt in die Live-Show.",
"locked": "Aktiv, weil Uploads sofort sichtbar sind."
},
"autoRemoveLive": {
"label": "Aus der Live-Show entfernen",
"help": "Versteckte oder abgelehnte Fotos werden automatisch aus der Live-Show entfernt."
},
"uploaders": {
"title": "Uploader-Ausnahmen",
"subtitle": "Wähle Geräte aus den letzten Uploads, die immer freigegeben oder immer geprüft werden sollen.",
"trustedLabel": "Immer freigeben",
"trustedHelp": "Uploads von diesen Geräten überspringen die Prüfung.",
"forceLabel": "Immer prüfen",
"forceHelp": "Uploads von diesen Geräten müssen immer geprüft werden.",
"add": "Hinzufügen",
"remove": "Entfernen",
"empty": "Noch keine Ausnahmen."
},
"saveFailed": "Automatik-Einstellungen konnten nicht gespeichert werden."
},
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
},

View File

@@ -2484,6 +2484,34 @@
"moderation": "Moderation",
"live": "Live Show"
},
"automation": {
"title": "Automation",
"autoApproveHighlights": {
"label": "Auto-approve highlights",
"help": "When you highlight a pending photo it will be approved automatically."
},
"autoAddApproved": {
"label": "Auto-add approved to Live Show",
"help": "Approved or highlighted photos go straight to the Live Show.",
"locked": "Enabled because uploads are visible immediately."
},
"autoRemoveLive": {
"label": "Auto-remove from Live Show",
"help": "Hidden or rejected photos are removed from the Live Show automatically."
},
"uploaders": {
"title": "Uploader overrides",
"subtitle": "Pick devices from recent uploads to always approve or always review their photos.",
"trustedLabel": "Always approve",
"trustedHelp": "Uploads from these devices skip the approval queue.",
"forceLabel": "Always review",
"forceHelp": "Uploads from these devices always need approval.",
"add": "Add",
"remove": "Remove",
"empty": "No overrides yet."
},
"saveFailed": "Automation settings could not be saved."
},
"emptyModeration": "No uploads match this filter.",
"emptyLive": "No photos waiting for Live Show."
},

View File

@@ -5,6 +5,7 @@ import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Switch } from '@tamagui/switch';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
@@ -13,6 +14,8 @@ import {
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
ControlRoomSettings,
ControlRoomUploaderRule,
createEventAddonCheckout,
EventAddonCatalogItem,
EventLimitSummary,
@@ -27,6 +30,7 @@ import {
TenantPhoto,
unfeaturePhoto,
updatePhotoStatus,
updateEvent,
updatePhotoVisibility,
} from '../api';
import { isAuthError } from '../auth/tokens';
@@ -252,17 +256,27 @@ function PhotoStatusTag({ label }: { label: string }) {
);
}
function formatDeviceId(deviceId: string): string {
if (!deviceId) {
return '';
}
if (deviceId.length <= 10) {
return deviceId;
}
return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`;
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext();
const { activeEvent, selectEvent, refetch } = useEventContext();
const { user } = useAuth();
const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, accentSoft, accent, danger, primary } = useAdminTheme();
const { textStrong, text, muted, border, accentSoft, accent, danger, primary, surfaceMuted } = useAdminTheme();
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
@@ -287,6 +301,23 @@ export default function MobileEventControlRoomPage() {
const [liveError, setLiveError] = React.useState<string | null>(null);
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
auto_approve_highlights: false,
auto_add_approved_to_live: false,
auto_remove_live_on_hide: false,
trusted_uploaders: [],
force_review_uploaders: [],
});
const [controlRoomSaving, setControlRoomSaving] = React.useState(false);
const [trustedUploaderSelection, setTrustedUploaderSelection] = React.useState('');
const [forceReviewSelection, setForceReviewSelection] = React.useState('');
const isImmediateUploads = (activeEvent?.settings?.guest_upload_visibility ?? 'review') === 'immediate';
const autoApproveHighlights = Boolean(controlRoomSettings.auto_approve_highlights);
const autoAddApprovedToLive = Boolean(controlRoomSettings.auto_add_approved_to_live) || isImmediateUploads;
const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide);
const trustedUploaders = controlRoomSettings.trusted_uploaders ?? [];
const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? [];
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const [syncingQueue, setSyncingQueue] = React.useState(false);
const syncingQueueRef = React.useRef(false);
@@ -297,12 +328,152 @@ export default function MobileEventControlRoomPage() {
const infoBg = accentSoft;
const infoBorder = accent;
const saveControlRoomSettings = React.useCallback(
async (nextSettings: ControlRoomSettings) => {
if (!slug) {
return;
}
const previous = controlRoomSettings;
setControlRoomSettings(nextSettings);
setControlRoomSaving(true);
try {
await updateEvent(slug, {
settings: {
control_room: nextSettings,
},
});
refetch();
} catch (err) {
setControlRoomSettings(previous);
toast.error(
getApiErrorMessage(
err,
t('controlRoom.automation.saveFailed', 'Automation settings could not be saved.')
)
);
} finally {
setControlRoomSaving(false);
}
},
[controlRoomSettings, refetch, slug, t],
);
const uploaderOptions = React.useMemo(() => {
const options = new Map<string, { deviceId: string; label: string }>();
const addPhoto = (photo: TenantPhoto) => {
const deviceId = photo.created_by_device_id ?? '';
if (!deviceId) {
return;
}
if (options.has(deviceId)) {
return;
}
options.set(deviceId, {
deviceId,
label: photo.uploader_name ?? t('common.anonymous', 'Anonymous'),
});
};
moderationPhotos.forEach(addPhoto);
livePhotos.forEach(addPhoto);
return Array.from(options.values()).map((entry) => ({
...entry,
display: `${entry.label}${formatDeviceId(entry.deviceId)}`,
}));
}, [livePhotos, moderationPhotos, t]);
const mergeUploaderRule = (list: ControlRoomUploaderRule[], rule: ControlRoomUploaderRule) => {
if (list.some((entry) => entry.device_id === rule.device_id)) {
return list;
}
return [...list, rule];
};
const addUploaderRule = React.useCallback(
(target: 'trusted' | 'force') => {
const selectedId = target === 'trusted' ? trustedUploaderSelection : forceReviewSelection;
if (!selectedId) {
return;
}
const option = uploaderOptions.find((entry) => entry.deviceId === selectedId);
const rule: ControlRoomUploaderRule = {
device_id: selectedId,
label: option?.label ?? t('common.anonymous', 'Anonymous'),
};
const currentTrusted = controlRoomSettings.trusted_uploaders ?? [];
const currentForceReview = controlRoomSettings.force_review_uploaders ?? [];
const nextTrusted =
target === 'trusted'
? mergeUploaderRule(currentTrusted, rule)
: currentTrusted.filter((entry) => entry.device_id !== selectedId);
const nextForceReview =
target === 'force'
? mergeUploaderRule(currentForceReview, rule)
: currentForceReview.filter((entry) => entry.device_id !== selectedId);
saveControlRoomSettings({
...controlRoomSettings,
trusted_uploaders: nextTrusted,
force_review_uploaders: nextForceReview,
});
setTrustedUploaderSelection('');
setForceReviewSelection('');
},
[
controlRoomSettings,
forceReviewSelection,
saveControlRoomSettings,
t,
trustedUploaderSelection,
uploaderOptions,
],
);
const removeUploaderRule = React.useCallback(
(target: 'trusted' | 'force', deviceId: string) => {
const nextTrusted =
target === 'trusted'
? (controlRoomSettings.trusted_uploaders ?? []).filter((entry) => entry.device_id !== deviceId)
: controlRoomSettings.trusted_uploaders ?? [];
const nextForceReview =
target === 'force'
? (controlRoomSettings.force_review_uploaders ?? []).filter((entry) => entry.device_id !== deviceId)
: controlRoomSettings.force_review_uploaders ?? [];
saveControlRoomSettings({
...controlRoomSettings,
trusted_uploaders: nextTrusted,
force_review_uploaders: nextForceReview,
});
},
[controlRoomSettings, saveControlRoomSettings],
);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
React.useEffect(() => {
const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings;
setControlRoomSettings({
auto_approve_highlights: Boolean(settings.auto_approve_highlights),
auto_add_approved_to_live: Boolean(settings.auto_add_approved_to_live),
auto_remove_live_on_hide: Boolean(settings.auto_remove_live_on_hide),
trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [],
force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [],
});
setTrustedUploaderSelection('');
setForceReviewSelection('');
}, [activeEvent?.settings?.control_room, activeEvent?.slug]);
const ensureSlug = React.useCallback(async () => {
if (slug) {
return slug;
@@ -506,6 +677,23 @@ export default function MobileEventControlRoomPage() {
[applyOptimisticUpdate, slug, t],
);
const enqueueModerationActions = React.useCallback(
(actions: PhotoModerationAction['action'][], photoId: number) => {
if (!slug) {
return;
}
let nextQueue: PhotoModerationAction[] = [];
actions.forEach((action) => {
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
applyOptimisticUpdate(photoId, action);
});
setQueuedActions(nextQueue);
toast.success(t('mobilePhotos.queued', 'Action saved. Syncs when you are back online.'));
triggerHaptic('selection');
},
[applyOptimisticUpdate, slug, t],
);
const syncQueuedActions = React.useCallback(async () => {
if (!online || syncingQueueRef.current) {
return;
@@ -566,7 +754,13 @@ export default function MobileEventControlRoomPage() {
return;
}
const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending';
if (!online) {
if (shouldAutoApproveHighlight) {
enqueueModerationActions(['approve', 'feature'], photo.id);
return;
}
enqueueModerationAction(action, photo.id);
return;
}
@@ -576,12 +770,21 @@ export default function MobileEventControlRoomPage() {
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = await updatePhotoStatus(slug, photo.id, 'approved');
updated = autoAddApprovedToLive
? await approveAndLiveShowPhoto(slug, photo.id)
: await updatePhotoStatus(slug, photo.id, 'approved');
} else if (action === 'hide') {
updated = await updatePhotoVisibility(slug, photo.id, true);
} else if (action === 'show') {
updated = await updatePhotoVisibility(slug, photo.id, false);
} else if (action === 'feature') {
if (shouldAutoApproveHighlight) {
if (autoAddApprovedToLive) {
await approveAndLiveShowPhoto(slug, photo.id);
} else {
await updatePhotoStatus(slug, photo.id, 'approved');
}
}
updated = await featurePhoto(slug, photo.id);
} else {
updated = await unfeaturePhoto(slug, photo.id);
@@ -605,7 +808,16 @@ export default function MobileEventControlRoomPage() {
setModerationBusyId(null);
}
},
[enqueueModerationAction, online, slug, t, updatePhotoInCollections],
[
autoAddApprovedToLive,
autoApproveHighlights,
enqueueModerationAction,
enqueueModerationActions,
online,
slug,
t,
updatePhotoInCollections,
],
);
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
@@ -805,6 +1017,254 @@ export default function MobileEventControlRoomPage() {
))}
</XStack>
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.title', 'Automation')}
</Text>
<MobileField
label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
hint={t(
'controlRoom.automation.autoApproveHighlights.help',
'When you highlight a pending photo it will be approved automatically.',
)}
>
<Switch
size="$3"
checked={autoApproveHighlights}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_approve_highlights: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
hint={
isImmediateUploads
? t(
'controlRoom.automation.autoAddApproved.locked',
'Enabled because uploads are visible immediately.',
)
: t(
'controlRoom.automation.autoAddApproved.help',
'Approved or highlighted photos go straight to the Live Show.',
)
}
>
<Switch
size="$3"
checked={autoAddApprovedToLive}
disabled={controlRoomSaving || isImmediateUploads}
onCheckedChange={(checked) => {
if (isImmediateUploads) {
return;
}
saveControlRoomSettings({
...controlRoomSettings,
auto_add_approved_to_live: Boolean(checked),
});
}}
aria-label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
hint={t(
'controlRoom.automation.autoRemoveLive.help',
'Hidden or rejected photos are removed from the Live Show automatically.',
)}
>
<Switch
size="$3"
checked={autoRemoveLiveOnHide}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_remove_live_on_hide: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
>
<Switch.Thumb />
</Switch>
</MobileField>
</YStack>
</MobileCard>
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'controlRoom.automation.uploaders.subtitle',
'Pick devices from recent uploads to always approve or always review their photos.',
)}
</Text>
<MobileField
label={t('controlRoom.automation.uploaders.trustedLabel', 'Always approve')}
hint={t(
'controlRoom.automation.uploaders.trustedHelp',
'Uploads from these devices skip the approval queue.',
)}
>
<XStack alignItems="center" space="$2">
<MobileSelect
value={trustedUploaderSelection}
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
style={{ flex: 1 }}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('trusted')}
tone="ghost"
fullWidth={false}
disabled={controlRoomSaving || !trustedUploaderSelection}
style={{ width: 100 }}
/>
</XStack>
{trustedUploaders.length ? (
<YStack space="$2">
{trustedUploaders.map((rule) => (
<XStack
key={`trusted-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => removeUploaderRule('trusted', rule.device_id)}
disabled={controlRoomSaving}
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: withAlpha(danger, 0.12),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
<MobileField
label={t('controlRoom.automation.uploaders.forceLabel', 'Always review')}
hint={t(
'controlRoom.automation.uploaders.forceHelp',
'Uploads from these devices always need approval.',
)}
>
<XStack alignItems="center" space="$2">
<MobileSelect
value={forceReviewSelection}
onChange={(event) => setForceReviewSelection(event.target.value)}
style={{ flex: 1 }}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('force')}
tone="ghost"
fullWidth={false}
disabled={controlRoomSaving || !forceReviewSelection}
style={{ width: 100 }}
/>
</XStack>
{forceReviewUploaders.length ? (
<YStack space="$2">
{forceReviewUploaders.map((rule) => (
<XStack
key={`force-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => removeUploaderRule('force', rule.device_id)}
disabled={controlRoomSaving}
aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: withAlpha(danger, 0.12),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
</YStack>
</MobileCard>
{activeTab === 'moderation' ? (
<YStack space="$2">
{queuedEventCount > 0 ? (

View File

@@ -4,6 +4,8 @@ import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn();
const selectEventMock = vi.fn();
const refetchMock = vi.fn();
const activeEventMock = { slug: 'demo-event', settings: { guest_upload_visibility: 'review', control_room: {} } };
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
@@ -36,8 +38,9 @@ vi.mock('../../auth/context', () => ({
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: { slug: 'demo-event' },
activeEvent: activeEventMock,
selectEvent: selectEventMock,
refetch: refetchMock,
}),
}));
@@ -98,6 +101,13 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ Thumb: () => <div /> },
),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({
children,
@@ -143,6 +153,7 @@ vi.mock('../../api', () => ({
approveAndLiveShowPhoto: vi.fn(),
approveLiveShowPhoto: vi.fn(),
clearLiveShowPhoto: vi.fn(),
updateEvent: vi.fn(),
createEventAddonCheckout: vi.fn(),
featurePhoto: vi.fn(),
getAddonCatalog: vi.fn().mockResolvedValue([]),