feat: update package copy and admin control room

This commit is contained in:
Codex Agent
2026-01-15 19:54:04 +01:00
parent ad829ae509
commit 7e32d8f706
42 changed files with 1310 additions and 2017 deletions

View File

@@ -1,4 +1,3 @@
{"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"}
{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-02T14:18:31.676816348+01:00","closed_at":"2026-01-02T14:18:31.676816348+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"}
{"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"}
@@ -74,6 +73,7 @@
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}

View File

@@ -1 +1 @@
fotospiel-app-6yz
fotospiel-app-de7

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@@ -82,7 +82,7 @@
"packages": {
"title": "Our Packages",
"features": "Features",
"subscription_annual": "Event kontingent",
"subscription_annual": "Event bundle",
"auto_renew": "auto-renew",
"cancel_anytime": "cancel anytime",
"trial_start": "Free Trial for :days days",
@@ -99,10 +99,10 @@
"tab_endcustomer": "End Customers",
"tab_reseller": "Partner / Agency",
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
"section_reseller": "Packages for Partner / Agencies (Event kontingent)",
"section_reseller": "Packages for Partner / Agencies (Event bundle)",
"free": "Free",
"one_time": "One-time purchase",
"subscription": "Event kontingent",
"subscription": "Event bundle",
"year": "Year",
"max_photos": "Photos",
"max_guests": "Guests",
@@ -112,7 +112,7 @@
"recommended_usage_label": "Recommendation",
"recommended_usage_window": "Recommended to use within 24 months.",
"buy_now": "Buy Now",
"subscribe_now": "Buy event kontingent",
"subscribe_now": "Buy event bundle",
"register_buy": "Register and Buy",
"register_subscribe": "Register and buy",
"faq_title": "Frequently Asked Questions about Packages",
@@ -151,7 +151,7 @@
"badge_starter": "Perfect Starter",
"billing_per_event": "per event",
"billing_per_year": "per year",
"billing_per_kontingent": "per bundle",
"billing_per_bundle": "per bundle",
"more_features": "+{{count}} more features",
"feature_overview": "Feature overview",
"order_hint": "Launch instantly secure Paddle checkout, no hidden fees.",
@@ -342,7 +342,7 @@
"purchase_complete_desc": "Log in to continue.",
"login": "Log In",
"no_account": "No Account? Register",
"manage_subscription": "Manage kontingent",
"manage_subscription": "Manage bundle",
"stripe_dashboard": "Stripe Dashboard",
"trial_activated": "Trial activated for 14 days!"
},
@@ -485,7 +485,7 @@
"summary_title": "Your order",
"package_label": "Selected package",
"billing_type_one_time": "One-time purchase (per event)",
"billing_type_subscription": "One-time purchase (kontingent)",
"billing_type_subscription": "One-time purchase (bundle)",
"legal_links_intro": "Details on the withdrawal policy:",
"link_terms": "Terms & Conditions",
"link_privacy": "Privacy Policy",

View File

@@ -28,7 +28,6 @@ export const ADMIN_EVENT_CREATE_PATH = adminPath('/mobile/events/new');
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/edit`);
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photos`);
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/qr`);
@@ -40,3 +39,5 @@ export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/m
export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string =>
adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show/settings`);
export const ADMIN_EVENT_CONTROL_ROOM_PATH = (slug: string): string =>
adminPath(`/mobile/events/${encodeURIComponent(slug)}/control-room`);

View File

@@ -1872,6 +1872,8 @@
"tasks": "Aufgaben & Checklisten",
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"liveShow": "Live-Show-Warteschlange",
"liveShowSettings": "Live-Show Einstellungen",
"guests": "Gästeverwaltung",
"branding": "Branding & Design",
"moderation": "Foto-Moderation",
@@ -2197,7 +2199,7 @@
"custom_branding": "Benutzerdefiniertes Branding",
"custom_tasks": "Individuelle Aufgaben",
"unlimited_sharing": "Unbegrenztes Sharing",
"analytics": "Analytics",
"analytics": "Statistiken",
"advanced_reporting": "Erweitertes Reporting",
"live_slideshow": "Live-Slideshow",
"basic_uploads": "Gäste-Uploads",
@@ -2206,7 +2208,7 @@
"prints": "Print-Uploads",
"photo_likes_enabled": "Foto-Likes",
"event_checklist": "Event-Checkliste",
"advanced_analytics": "Erweiterte Analytics",
"advanced_analytics": "Erweiterte Statistiken",
"branding_allowed": "Branding",
"watermark_allowed": "Wasserzeichen"
},
@@ -2235,6 +2237,7 @@
"shortcutInvites": "Team-/Helfer-Einladungen",
"shortcutSettings": "Event-Einstellungen",
"shortcutBranding": "Branding & Moderation",
"shortcutAnalytics": "Statistiken",
"kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Tasks",
"kpiPhotos": "Fotos",
@@ -2336,6 +2339,16 @@
"notEligible": "Nicht zulässig",
"actionFailed": "Live-Show-Aktion fehlgeschlagen."
},
"controlRoom": {
"title": "Moderation & Live-Show",
"subtitle": "Uploads prüfen und Live-Slideshow steuern.",
"tabs": {
"moderation": "Moderation",
"live": "Live-Show"
},
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
},
"liveShowSettings": {
"title": "Live-Show Einstellungen",
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
@@ -2516,6 +2529,7 @@
"tasks": "Aufgaben & Checklisten",
"qr": "QR-Code-Layouts",
"images": "Bildverwaltung",
"controlRoom": "Moderation & Live-Show",
"guests": "Gästeverwaltung",
"guestMessages": "Gästebenachrichtigungen",
"branding": "Branding & Design",
@@ -3037,7 +3051,7 @@
}
},
"analytics": {
"title": "Analytics",
"title": "Statistiken",
"upgradeAction": "Upgrade auf Premium",
"kpiTitle": "Event-Überblick",
"kpiUploads": "Uploads",
@@ -3058,7 +3072,7 @@
"tasksTitle": "Beliebte Aufgaben",
"noTasks": "Noch keine Aufgabenaktivität",
"emptyActionOpenTasks": "Aufgaben öffnen",
"lockedTitle": "Analytics freischalten",
"lockedTitle": "Statistiken freischalten",
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
},
"shop": {
@@ -3122,7 +3136,7 @@
"days_other": "{{count}} Tage Galerie"
},
"features": {
"advanced_analytics": "Erweiterte Analytics",
"advanced_analytics": "Erweiterte Statistiken",
"basic_uploads": "Basis-Uploads",
"custom_branding": "Eigenes Branding",
"custom_tasks": "Benutzerdefinierte Aufgaben",

View File

@@ -30,6 +30,6 @@
"queueTitle": "Foto-Aktionen warten",
"queueBodyOnline": "{{count}} Aktionen bereit zur Synchronisierung.",
"queueBodyOffline": "{{count}} Aktionen offline gespeichert.",
"queueAction": "Fotos öffnen"
"queueAction": "Moderation öffnen"
}
}

View File

@@ -74,8 +74,8 @@
},
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event kontingent.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the kontingent.",
"eventLimit": "Your current package has no remaining event bundle.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the bundle.",
"photoLimit": "This event reached its photo upload limit.",
"goToBilling": "Manage subscription"
},
@@ -194,7 +194,7 @@
"title": "Partner Start",
"badge": "For agencies",
"highlight": "Manage multiple events",
"p1": "Up to 5 events per kontingent",
"p1": "Up to 5 events per bundle",
"p2": "Task collections and templates",
"p3": "Team roles & permissions"
}
@@ -208,7 +208,7 @@
},
"resellers": {
"title": "Partner / Agencies",
"description": "Track multiple events, monitor kontingent and reuse templates."
"description": "Track multiple events, monitor bundle and reuse templates."
},
"cta": "Just a few clicks to go live"
},

View File

@@ -32,8 +32,8 @@
"publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)",
"taskProgress": "Task progress",
"credits": "Event kontingent",
"lowCredits": "Add kontingent soon"
"credits": "Event bundle",
"lowCredits": "Add bundle soon"
}
},
"liveNow": {
@@ -238,8 +238,8 @@
"publishedHint": "{{count}} published",
"newPhotos": "New photos (7 days)",
"taskProgress": "Task progress",
"credits": "Event kontingent",
"lowCredits": "Add kontingent soon"
"credits": "Event bundle",
"lowCredits": "Add bundle soon"
}
},
"quickActions": {

View File

@@ -90,7 +90,7 @@
},
"warnings": {
"noEvents": "Event allowance exhausted. Please upgrade or renew your package.",
"lowEvents": "Only {{remaining}} events remaining in the kontingent.",
"lowEvents": "Only {{remaining}} events remaining in the bundle.",
"expiresSoon": "Package expires on {{date}}.",
"expired": "Package has expired."
}
@@ -108,7 +108,7 @@
"expires": "Expires",
"warnings": {
"noEvents": "Event allowance exhausted.",
"lowEvents": "Only {{remaining}} events remaining in the kontingent.",
"lowEvents": "Only {{remaining}} events remaining in the bundle.",
"expiresSoon": "Expires on {{date}}.",
"expired": "Package has expired."
}
@@ -1556,12 +1556,12 @@
"title": "Notification overview",
"channel": "Email channel",
"channelCopy": "All warnings are delivered via email.",
"credits": "Event kontingent",
"credits": "Event bundle",
"threshold": "Warning at {{count}} remaining events"
},
"meta": {
"creditLast": "Last kontingent warning: {{date}}",
"creditNever": "No kontingent warning sent yet."
"creditLast": "Last bundle warning: {{date}}",
"creditNever": "No bundle warning sent yet."
},
"items": {
"photoThresholds": {
@@ -1876,6 +1876,8 @@
"tasks": "Tasks & checklists",
"qr": "QR code layouts",
"images": "Image management",
"liveShow": "Live show queue",
"liveShowSettings": "Live show settings",
"guests": "Guest management",
"branding": "Branding & theme",
"moderation": "Photo moderation",
@@ -2239,6 +2241,7 @@
"shortcutInvites": "Team / helper invites",
"shortcutSettings": "Event settings",
"shortcutBranding": "Branding & moderation",
"shortcutAnalytics": "Analytics",
"kpiTitle": "Key performance indicators",
"kpiTasks": "Open tasks",
"kpiPhotos": "Photos",
@@ -2340,6 +2343,16 @@
"notEligible": "Not eligible",
"actionFailed": "Live Show update failed."
},
"controlRoom": {
"title": "Moderation & Live Show",
"subtitle": "Review uploads and manage the live slideshow.",
"tabs": {
"moderation": "Moderation",
"live": "Live Show"
},
"emptyModeration": "No uploads match this filter.",
"emptyLive": "No photos waiting for Live Show."
},
"liveShowSettings": {
"title": "Live Show settings",
"subtitle": "Tune the playback, pacing, and effects shown on the screen.",
@@ -2520,6 +2533,7 @@
"tasks": "Tasks & checklists",
"qr": "QR code layouts",
"images": "Image management",
"controlRoom": "Moderation & Live Show",
"guests": "Guest management",
"guestMessages": "Guest messages",
"branding": "Branding & theme",
@@ -2911,7 +2925,7 @@
"max_guests": "Guests",
"max_tasks": "Tasks",
"gallery_days": "Gallery days",
"max_events_per_year": "Event kontingent"
"max_events_per_year": "Event bundle"
},
"mobileEvents": {
"edit": "Edit event"
@@ -3069,13 +3083,13 @@
"title": "Upgrade Package",
"subtitle": "Choose a package to unlock more features and limits.",
"partner": {
"title": "Buy event kontingent",
"subtitle": "Buy event kontingents to run multiple events with our services.",
"title": "Buy event bundle",
"subtitle": "Buy event bundles to run multiple events with our services.",
"buy": "Buy",
"unavailable": "Unavailable",
"confirmSubtitle": "You're buying:",
"includedTier": "Included event tier: {{tier}}",
"eventsIncluded": "{{count}} events in kontingent",
"eventsIncluded": "{{count}} events in bundle",
"recommendedUsage": "Recommended to use within 24 months.",
"tiers": {
"starter": "Starter",
@@ -3085,7 +3099,7 @@
"compare": {
"rows": {
"includedTier": "Included event tier",
"events": "Events in kontingent"
"events": "Events in bundle"
},
"values": {
"unknown": "—"

View File

@@ -30,6 +30,6 @@
"queueTitle": "Photo actions pending",
"queueBodyOnline": "{{count}} actions ready to sync.",
"queueBodyOffline": "{{count}} actions saved offline.",
"queueAction": "Open Photos"
"queueAction": "Open moderation"
}
}

View File

@@ -41,7 +41,7 @@
"ctaList": {
"choosePackage": {
"label": "Choose your package",
"description": "Reserve event kontingent or packages to activate events instantly. Flexible options for any event size.",
"description": "Reserve event bundle or packages to activate events instantly. Flexible options for any event size.",
"button": "Continue to packages"
},
"createEvent": {
@@ -61,7 +61,7 @@
"steps": {
"package": {
"title": "Secure your package",
"hint": "Event kontingent or a package is required before guests go live."
"hint": "Event bundle or a package is required before guests go live."
},
"invite": {
"title": "Invite your co-hosts",
@@ -77,10 +77,10 @@
"layout": {
"eyebrow": "Step 2",
"title": "Choose your package",
"subtitle": "Fotospiel supports flexible pricing: single event packages or kontingent for multiple events."
"subtitle": "Fotospiel supports flexible pricing: single event packages or bundle for multiple events."
},
"step": {
"title": "Activate the right event kontingent",
"title": "Activate the right event bundle",
"description": "Secure capacity for your next event. Upgrade at any time only pay for what you need."
},
"state": {
@@ -92,7 +92,7 @@
},
"card": {
"subscription": "Subscription",
"creditPack": "Event kontingent",
"creditPack": "Event bundle",
"description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package",
@@ -151,7 +151,7 @@
},
"details": {
"subscription": "Subscription",
"creditPack": "Event kontingent",
"creditPack": "Event bundle",
"photos": "Up to {{count}} photos",
"galleryDays": "{{count}} gallery days",
"guests": "{{count}} guests",
@@ -188,7 +188,7 @@
"activate": "Activate free package",
"progress": "Activating …",
"successTitle": "Free package activated",
"successDescription": "Event kontingent added. Continue with the setup.",
"successDescription": "Event bundle added. Continue with the setup.",
"failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated."
},
@@ -205,12 +205,12 @@
"nextSteps": [
"Optional: finish billing via Paddle inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.",
"Check your event kontingent before go-live and share your guest link."
"Check your event bundle before go-live and share your guest link."
],
"cta": {
"billing": {
"label": "Start billing",
"description": "Opens the billing area with Paddle kontingent options.",
"description": "Opens the billing area with Paddle bundle options.",
"button": "Go to billing"
},
"setup": {

View File

@@ -2,7 +2,7 @@ import type { TenantEvent } from '../api';
import {
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_CONTROL_ROOM_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_RECAP_PATH,
@@ -47,7 +47,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts
{
key: 'photos',
label: translate('eventMenu.photos', 'Uploads'),
href: ADMIN_EVENT_PHOTOS_PATH(event.slug),
href: ADMIN_EVENT_CONTROL_ROOM_PATH(event.slug),
badge: formatBadge(counts.photos),
},
{

View File

@@ -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,
},

View 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>
);
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>,

View 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>
);
}

View File

@@ -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>

View File

@@ -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">

View 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');
});
});

View 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';
}

View File

@@ -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', () => {

View File

@@ -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);
}

View File

@@ -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');

View File

@@ -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 = {

View File

@@ -26,8 +26,7 @@ const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage'));
const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage'));
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage'));
const MobileEventControlRoomPage = React.lazy(() => import('./mobile/EventControlRoomPage'));
const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventLiveShowSettingsPage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
@@ -186,7 +185,7 @@ export const router = createBrowserRouter([
{ path: 'events/:slug', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'events/:slug/recap', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'events/:slug/edit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/edit`} /> },
{ path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photos`} /> },
{ path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
{ path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
@@ -201,8 +200,9 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos/:photoId?', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/live-show', element: <RequireAdminAccess><MobileEventLiveShowQueuePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/control-room', element: <RequireAdminAccess><MobileEventControlRoomPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos/:photoId?', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
{ path: 'mobile/events/:slug/live-show', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },

View File

@@ -46,7 +46,7 @@ return [
'emotion' => 'Emotion',
'event_type' => 'Event Type',
'last_activity' => 'Last activity',
'credits' => 'Event kontingent',
'credits' => 'Event bundle',
'settings' => 'Settings',
'join' => 'Join',
'unnamed' => 'Unnamed',
@@ -503,7 +503,7 @@ return [
'heading' => 'Uploads (14 days)',
],
'credit_alerts' => [
'low_balance_label' => 'Tenants with low event kontingent',
'low_balance_label' => 'Tenants with low event bundle',
'low_balance_desc' => 'May require follow-up',
'monthly_revenue_label' => 'Revenue (month)',
'monthly_revenue_desc' => 'Current month (:month)',
@@ -532,7 +532,7 @@ return [
'name' => 'Tenant name',
'slug' => 'Slug',
'contact_email' => 'Contact email',
'event_credits_balance' => 'Event kontingent',
'event_credits_balance' => 'Event bundle',
'features' => 'Features',
'total_revenue' => 'Total revenue',
'active_reseller_package' => 'Active partner / agency package',
@@ -560,12 +560,12 @@ return [
'timeline' => 'Audit timeline',
],
'actions' => [
'adjust_credits' => 'Adjust kontingent',
'adjust_credits_delta' => 'Event kontingent delta (positive/negative)',
'adjust_credits_delta_hint' => 'Positive values add kontingent, negative values deduct it.',
'adjust_credits' => 'Adjust bundle',
'adjust_credits_delta' => 'Event bundle delta (positive/negative)',
'adjust_credits_delta_hint' => 'Positive values add bundle, negative values deduct it.',
'adjust_credits_reason' => 'Internal note',
'adjust_credits_success_title' => 'Kontingent updated',
'adjust_credits_success_body' => 'Kontingent changed by :delta. New balance: :balance.',
'adjust_credits_success_title' => 'Bundle updated',
'adjust_credits_success_body' => 'Bundle changed by :delta. New balance: :balance.',
'lifecycle' => 'Lifecycle',
'activate' => 'Activate',
'deactivate' => 'Deactivate',
@@ -649,7 +649,7 @@ return [
'fields' => [
'tenant' => 'Tenant',
'package' => 'Package',
'credits' => 'Event kontingent',
'credits' => 'Event bundle',
'price' => 'Price',
'currency' => 'Currency',
'platform' => 'Platform',

View File

@@ -17,15 +17,15 @@ return [
'packages' => [
'event_tier_unavailable' => [
'title' => 'Selected tier unavailable',
'message' => 'No Event-Kontingent is available for the selected event tier. Choose a different tier or purchase the matching Event-Kontingent.',
'message' => 'No Event-Bundle is available for the selected event tier. Choose a different tier or purchase the matching Event-Bundle.',
],
'event_limit_exceeded' => [
'title' => 'Event-Kontingent depleted',
'message' => 'Your current Event-Kontingent has no remaining events. Purchase another Event-Kontingent to create new events.',
'title' => 'Event-Bundle depleted',
'message' => 'Your current Event-Bundle has no remaining events. Purchase another Event-Bundle to create new events.',
],
'event_limit_missing' => [
'title' => 'No package assigned',
'message' => 'Purchase an Event-Kontingent to create events.',
'message' => 'Purchase an Event-Bundle to create events.',
],
'event_not_found' => [
'title' => 'Event not accessible',

View File

@@ -49,13 +49,13 @@
"tab_endcustomer": "End Customers",
"tab_reseller": "Partner / Agency",
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
"section_reseller": "Packages for Partner / Agencies (Event-Kontingent)",
"section_reseller": "Packages for Partner / Agencies (Event-Bundle)",
"free": "Free",
"one_time": "One-time purchase",
"subscription": "One-time purchase",
"year": "Year",
"billing_per_event": "per event",
"billing_per_kontingent": "per bundle",
"billing_per_bundle": "per bundle",
"available": "Available",
"not_available": "Not available",
"standard_support": "Standard support",
@@ -117,7 +117,7 @@
"no_watermark": "No Watermark",
"custom_branding": "Custom Branding",
"max_tenants": "Max. Tenants",
"max_events": "Events in kontingent",
"max_events": "Events in bundle",
"faq_free": "What is the Free Package?",
"faq_upgrade": "Can I upgrade?",
"faq_reseller": "What for Partner / Agencies?",

View File

@@ -9,17 +9,17 @@ return [
'tab_endcustomer' => 'End Customers',
'tab_reseller' => 'Partner / Agencies',
'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)',
'section_reseller' => 'Packages for Partner / Agencies (Event kontingent)',
'section_reseller' => 'Packages for Partner / Agencies (Event bundle)',
'free' => 'Free',
'one_time' => 'One-time purchase',
'subscription' => 'Event kontingent',
'subscription' => 'Event bundle',
'year' => 'Year',
'max_photos' => 'Photos',
'max_guests' => 'Guests',
'gallery_days' => 'Gallery Days',
'max_events_year' => 'Events included',
'buy_now' => 'Buy Now',
'subscribe_now' => 'Buy event kontingent',
'subscribe_now' => 'Buy event bundle',
'register_buy' => 'Register and Buy',
'register_subscribe' => 'Register and buy',
'faq_title' => 'Frequently Asked Questions about Packages',
@@ -57,7 +57,7 @@ return [
'badge_starter' => 'Perfect Starter',
'billing_per_event' => 'per event',
'billing_per_year' => 'per year',
'billing_per_kontingent' => 'per bundle',
'billing_per_bundle' => 'per bundle',
'recommended_usage_window' => 'Recommended to use within 24 months.',
'more_features' => '+:count more features',
'max_photos_label' => 'Max. photos',
@@ -111,7 +111,7 @@ return [
'summary_title' => 'Your order',
'package_label' => 'Selected package',
'billing_type_one_time' => 'One-time purchase (per event)',
'billing_type_subscription' => 'One-time purchase (kontingent)',
'billing_type_subscription' => 'One-time purchase (bundle)',
'legal_links_intro' => 'By completing your order you accept our',
'link_terms' => 'Terms & Conditions',
'link_privacy' => 'Privacy Policy',

View File

@@ -119,14 +119,14 @@ test.describe('Tenant admin add-on upgrades', () => {
status: 200,
contentType: 'application/json',
body: JSON.stringify({
checkout_url: '/event-admin/events/limit-event/photos?addon_success=1',
checkout_url: '/event-admin/mobile/events/limit-event/control-room?addon_success=1',
checkout_id: 'chk_addon_1',
expires_at: new Date(Date.now() + 600000).toISOString(),
}),
});
});
await page.goto('/event-admin/mobile/events/limit-event/photos');
await page.goto('/event-admin/mobile/events/limit-event/control-room');
await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible();

View File

@@ -58,8 +58,8 @@ test.describe('Tenant Admin PWA end-to-end coverage', () => {
await page.waitForLoadState('networkidle');
await expect(page.getByText(eventName, { exact: false })).toBeVisible();
await page.goto(`/event-admin/mobile/events/${createdSlug}/photos`);
await expect(page.getByText(/Foto-Moderation|Photo moderation/i)).toBeVisible();
await page.goto(`/event-admin/mobile/events/${createdSlug}/control-room`);
await expect(page.getByText(/Moderation & Live Show|Moderation & Live-Show/i)).toBeVisible();
await page.goto(`/event-admin/mobile/events/${createdSlug}/members`);
await expect(page.getByText(/Event-Mitglieder|Event members/i)).toBeVisible();