feat: update package copy and admin control room
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-6yz
|
||||
fotospiel-app-de7
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "—"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function MobileDashboardPage() {
|
||||
return;
|
||||
}
|
||||
closeTour();
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`));
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/control-room`));
|
||||
},
|
||||
showAction: Boolean(tourTargetSlug),
|
||||
},
|
||||
@@ -1223,20 +1223,20 @@ function EventManagementGrid({
|
||||
icon: ImageIcon,
|
||||
label: t('events.quick.images', 'Image Management'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: t('events.quick.liveShow', 'Live Show queue'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
|
||||
label: t('events.quick.controlRoom', 'Moderation & Live Show'),
|
||||
color: ADMIN_ACTION_COLORS.liveShow,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
color: ADMIN_ACTION_COLORS.liveShowSettings,
|
||||
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||
disabled: !slug,
|
||||
},
|
||||
|
||||
936
resources/js/admin/mobile/EventControlRoomPage.tsx
Normal file
936
resources/js/admin/mobile/EventControlRoomPage.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, RefreshCcw, Settings } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileField, MobileSelect } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
approveAndLiveShowPhoto,
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
createEventAddonCheckout,
|
||||
EventAddonCatalogItem,
|
||||
EventLimitSummary,
|
||||
getAddonCatalog,
|
||||
featurePhoto,
|
||||
getEventPhotos,
|
||||
getEvents,
|
||||
getLiveShowQueue,
|
||||
LiveShowQueueStatus,
|
||||
rejectLiveShowPhoto,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
unfeaturePhoto,
|
||||
updatePhotoStatus,
|
||||
updatePhotoVisibility,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath } from '../constants';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
import {
|
||||
enqueuePhotoAction,
|
||||
loadPhotoQueue,
|
||||
removePhotoAction,
|
||||
replacePhotoQueue,
|
||||
type PhotoModerationAction,
|
||||
} from './lib/photoModerationQueue';
|
||||
import { triggerHaptic } from './lib/haptics';
|
||||
import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './lib/controlRoom';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import { selectAddonKeyForScope } from './addons';
|
||||
import { LimitWarnings } from './components/LimitWarnings';
|
||||
|
||||
type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending';
|
||||
|
||||
const MODERATION_FILTERS: Array<{ value: ModerationFilter; labelKey: string; fallback: string }> = [
|
||||
{ value: 'pending', labelKey: 'photos.filters.pending', fallback: 'Pending' },
|
||||
{ value: 'all', labelKey: 'photos.filters.all', fallback: 'All' },
|
||||
{ value: 'featured', labelKey: 'photos.filters.featured', fallback: 'Featured' },
|
||||
{ value: 'hidden', labelKey: 'photos.filters.hidden', fallback: 'Hidden' },
|
||||
];
|
||||
|
||||
const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
|
||||
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
|
||||
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
|
||||
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
|
||||
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
|
||||
];
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
||||
const defaults: Record<string, string> = {
|
||||
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
|
||||
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
|
||||
guestsBlocked: 'Guest limit reached.',
|
||||
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
|
||||
galleryExpired: 'Gallery expired. Extend to keep it online.',
|
||||
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
|
||||
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
|
||||
galleryWarningDay: 'Gallery expires in {{days}} day.',
|
||||
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
||||
buyMorePhotos: 'Buy more photos',
|
||||
extendGallery: 'Extend gallery',
|
||||
buyMoreGuests: 'Add more guests',
|
||||
};
|
||||
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
||||
}
|
||||
|
||||
export default function MobileEventControlRoomPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const online = useOnlineStatus();
|
||||
const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme();
|
||||
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
|
||||
|
||||
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [moderationFilter, setModerationFilter] = React.useState<ModerationFilter>('pending');
|
||||
const [moderationPage, setModerationPage] = React.useState(1);
|
||||
const [moderationHasMore, setModerationHasMore] = React.useState(false);
|
||||
const [moderationLoading, setModerationLoading] = React.useState(true);
|
||||
const [moderationError, setModerationError] = React.useState<string | null>(null);
|
||||
const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null);
|
||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
|
||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
||||
|
||||
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||
const [livePage, setLivePage] = React.useState(1);
|
||||
const [liveHasMore, setLiveHasMore] = React.useState(false);
|
||||
const [liveLoading, setLiveLoading] = React.useState(true);
|
||||
const [liveError, setLiveError] = React.useState<string | null>(null);
|
||||
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
|
||||
|
||||
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||||
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
||||
const syncingQueueRef = React.useRef(false);
|
||||
const moderationResetRef = React.useRef(false);
|
||||
const liveResetRef = React.useRef(false);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
const infoBg = accentSoft;
|
||||
const infoBorder = accent;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
selectEvent(slugParam);
|
||||
}
|
||||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
||||
|
||||
const ensureSlug = React.useCallback(async () => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
if (fallbackAttempted) {
|
||||
return null;
|
||||
}
|
||||
setFallbackAttempted(true);
|
||||
try {
|
||||
const events = await getEvents({ force: true });
|
||||
const first = events[0] as TenantEvent | undefined;
|
||||
if (first?.slug) {
|
||||
selectEvent(first.slug);
|
||||
navigate(adminPath(`/mobile/events/${first.slug}/control-room`), { replace: true });
|
||||
return first.slug;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}, [slug, fallbackAttempted, navigate, selectEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setModerationPage(1);
|
||||
}, [moderationFilter, slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLivePage(1);
|
||||
}, [liveStatusFilter, slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'moderation') {
|
||||
moderationResetRef.current = true;
|
||||
setModerationPhotos([]);
|
||||
setModerationPage(1);
|
||||
} else {
|
||||
liveResetRef.current = true;
|
||||
setLivePhotos([]);
|
||||
setLivePage(1);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const loadModeration = React.useCallback(async () => {
|
||||
const resolvedSlug = await ensureSlug();
|
||||
if (!resolvedSlug) {
|
||||
setModerationLoading(false);
|
||||
setModerationError(t('events.errors.missingSlug', 'No event selected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setModerationLoading(true);
|
||||
setModerationError(null);
|
||||
try {
|
||||
const status =
|
||||
moderationFilter === 'hidden'
|
||||
? 'hidden'
|
||||
: moderationFilter === 'pending'
|
||||
? 'pending'
|
||||
: undefined;
|
||||
const result = await getEventPhotos(resolvedSlug, {
|
||||
page: moderationPage,
|
||||
perPage: 20,
|
||||
sort: 'desc',
|
||||
featured: moderationFilter === 'featured',
|
||||
status,
|
||||
});
|
||||
setModerationPhotos((prev) => (moderationPage === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
setLimits(result.limits ?? null);
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setModerationHasMore(moderationPage < lastPage);
|
||||
const addons = await getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]);
|
||||
setCatalogAddons(addons ?? []);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Photos could not be loaded.'));
|
||||
setModerationError(message);
|
||||
}
|
||||
} finally {
|
||||
setModerationLoading(false);
|
||||
}
|
||||
}, [ensureSlug, moderationFilter, moderationPage, t]);
|
||||
|
||||
const loadLiveQueue = React.useCallback(async () => {
|
||||
const resolvedSlug = await ensureSlug();
|
||||
if (!resolvedSlug) {
|
||||
setLiveLoading(false);
|
||||
setLiveError(t('events.errors.missingSlug', 'No event selected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveLoading(true);
|
||||
setLiveError(null);
|
||||
try {
|
||||
const result = await getLiveShowQueue(resolvedSlug, {
|
||||
page: livePage,
|
||||
perPage: 20,
|
||||
liveStatus: liveStatusFilter,
|
||||
});
|
||||
setLivePhotos((prev) => (livePage === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setLiveHasMore(livePage < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
|
||||
setLiveError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLiveLoading(false);
|
||||
}
|
||||
}, [ensureSlug, livePage, liveStatusFilter, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'moderation') {
|
||||
if (moderationResetRef.current && moderationPage !== 1) {
|
||||
return;
|
||||
}
|
||||
moderationResetRef.current = false;
|
||||
void loadModeration();
|
||||
}
|
||||
}, [activeTab, loadModeration, moderationPage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'live') {
|
||||
if (liveResetRef.current && livePage !== 1) {
|
||||
return;
|
||||
}
|
||||
liveResetRef.current = false;
|
||||
void loadLiveQueue();
|
||||
}
|
||||
}, [activeTab, loadLiveQueue, livePage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search || !slug) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('addon_success')) {
|
||||
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
|
||||
setModerationPage(1);
|
||||
void loadModeration();
|
||||
params.delete('addon_success');
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
||||
}
|
||||
}, [location.search, slug, loadModeration, navigate, t, location.pathname]);
|
||||
|
||||
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
|
||||
replacePhotoQueue(queue);
|
||||
setQueuedActions(queue);
|
||||
}, []);
|
||||
|
||||
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
|
||||
setModerationPhotos((prev) =>
|
||||
prev.map((photo) => {
|
||||
if (photo.id !== photoId) {
|
||||
return photo;
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
return { ...photo, status: 'approved' };
|
||||
}
|
||||
|
||||
if (action === 'hide') {
|
||||
return { ...photo, status: 'hidden' };
|
||||
}
|
||||
|
||||
if (action === 'show') {
|
||||
return { ...photo, status: 'approved' };
|
||||
}
|
||||
|
||||
return photo;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const enqueueModerationAction = React.useCallback(
|
||||
(action: PhotoModerationAction['action'], photoId: number) => {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
|
||||
setQueuedActions(nextQueue);
|
||||
applyOptimisticUpdate(photoId, action);
|
||||
toast.success(t('mobilePhotos.queued', 'Action saved. Syncs when you are back online.'));
|
||||
triggerHaptic('selection');
|
||||
},
|
||||
[applyOptimisticUpdate, slug, t],
|
||||
);
|
||||
|
||||
const syncQueuedActions = React.useCallback(async () => {
|
||||
if (!online || syncingQueueRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = loadPhotoQueue();
|
||||
if (queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncingQueueRef.current = true;
|
||||
setSyncingQueue(true);
|
||||
|
||||
let remaining = queue;
|
||||
|
||||
for (const entry of queue) {
|
||||
try {
|
||||
let updated: TenantPhoto | null = null;
|
||||
if (entry.action === 'approve') {
|
||||
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
|
||||
} else if (entry.action === 'hide') {
|
||||
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
|
||||
} else if (entry.action === 'show') {
|
||||
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
|
||||
} else if (entry.action === 'feature') {
|
||||
updated = await featurePhoto(entry.eventSlug, entry.photoId);
|
||||
} else if (entry.action === 'unfeature') {
|
||||
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
|
||||
}
|
||||
|
||||
remaining = removePhotoAction(remaining, entry.id);
|
||||
|
||||
if (updated && entry.eventSlug === slug) {
|
||||
setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('mobilePhotos.syncFailed', 'Sync failed. Please try again later.'));
|
||||
if (isAuthError(err)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateQueueState(remaining);
|
||||
setSyncingQueue(false);
|
||||
syncingQueueRef.current = false;
|
||||
}, [online, slug, t, updateQueueState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (online) {
|
||||
void syncQueuedActions();
|
||||
}
|
||||
}, [online, syncQueuedActions]);
|
||||
|
||||
const handleModerationAction = React.useCallback(
|
||||
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
|
||||
if (!slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!online) {
|
||||
enqueueModerationAction(action, photo.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setModerationBusyId(photo.id);
|
||||
|
||||
try {
|
||||
let updated: TenantPhoto;
|
||||
if (action === 'approve') {
|
||||
updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
||||
} else if (action === 'hide') {
|
||||
updated = await updatePhotoVisibility(slug, photo.id, true);
|
||||
} else {
|
||||
updated = await updatePhotoVisibility(slug, photo.id, false);
|
||||
}
|
||||
|
||||
setModerationPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
triggerHaptic(action === 'approve' ? 'success' : 'medium');
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.'));
|
||||
setModerationError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setModerationBusyId(null);
|
||||
}
|
||||
},
|
||||
[enqueueModerationAction, online, slug, t],
|
||||
);
|
||||
|
||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
const scope =
|
||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||
? scopeOrKey
|
||||
: scopeOrKey.includes('gallery')
|
||||
? 'gallery'
|
||||
: scopeOrKey.includes('guest')
|
||||
? 'guests'
|
||||
: 'photos';
|
||||
|
||||
const addonKey =
|
||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||
? selectAddonKeyForScope(catalogAddons, scope)
|
||||
: scopeOrKey;
|
||||
|
||||
return { scope, addonKey };
|
||||
}
|
||||
|
||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
if (!slug) return;
|
||||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
||||
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
|
||||
setConsentOpen(true);
|
||||
}
|
||||
|
||||
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
||||
if (!slug || !consentTarget) return;
|
||||
|
||||
const currentUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}${adminPath(`/mobile/events/${slug}/control-room`)}`
|
||||
: '';
|
||||
const successUrl = `${currentUrl}?addon_success=1`;
|
||||
setBusyScope(consentTarget.scope);
|
||||
setConsentBusy(true);
|
||||
try {
|
||||
const checkout = await createEventAddonCheckout(slug, {
|
||||
addon_key: consentTarget.addonKey,
|
||||
quantity: 1,
|
||||
success_url: successUrl,
|
||||
cancel_url: currentUrl,
|
||||
accepted_terms: consents.acceptedTerms,
|
||||
accepted_waiver: consents.acceptedWaiver,
|
||||
} as any);
|
||||
if (checkout.checkout_url) {
|
||||
window.location.href = checkout.checkout_url;
|
||||
} else {
|
||||
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
|
||||
} finally {
|
||||
setConsentBusy(false);
|
||||
setConsentOpen(false);
|
||||
setConsentTarget(null);
|
||||
setBusyScope(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(photo: TenantPhoto) {
|
||||
if (!slug || liveBusyId) return;
|
||||
setLiveBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveLiveShowPhoto(slug, photo.id);
|
||||
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setLiveBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApproveAndLive(photo: TenantPhoto) {
|
||||
if (!slug || liveBusyId) return;
|
||||
setLiveBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveAndLiveShowPhoto(slug, photo.id);
|
||||
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setLiveBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(photo: TenantPhoto) {
|
||||
if (!slug || liveBusyId) return;
|
||||
setLiveBusyId(photo.id);
|
||||
try {
|
||||
const updated = await rejectLiveShowPhoto(slug, photo.id);
|
||||
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setLiveBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(photo: TenantPhoto) {
|
||||
if (!slug || liveBusyId) return;
|
||||
setLiveBusyId(photo.id);
|
||||
try {
|
||||
const updated = await clearLiveShowPhoto(slug, photo.id);
|
||||
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setLiveBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGalleryLabel(status?: string | null): string {
|
||||
const key = status ?? 'pending';
|
||||
const fallbackMap: Record<string, string> = {
|
||||
approved: 'Gallery approved',
|
||||
pending: 'Gallery pending',
|
||||
rejected: 'Gallery rejected',
|
||||
hidden: 'Hidden',
|
||||
};
|
||||
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
|
||||
}
|
||||
|
||||
function resolveLiveLabel(status?: string | null): string {
|
||||
const key = normalizeLiveStatus(status);
|
||||
return t(`liveShowQueue.status.${key}`, key);
|
||||
}
|
||||
|
||||
const queuedEventCount = React.useMemo(() => {
|
||||
if (!slug) {
|
||||
return queuedActions.length;
|
||||
}
|
||||
return queuedActions.filter((action) => action.eventSlug === slug).length;
|
||||
}, [queuedActions, slug]);
|
||||
|
||||
const headerActions = (
|
||||
<XStack space="$2">
|
||||
<HeaderActionButton
|
||||
onPress={() => {
|
||||
if (activeTab === 'moderation') {
|
||||
void loadModeration();
|
||||
return;
|
||||
}
|
||||
void loadLiveQueue();
|
||||
}}
|
||||
ariaLabel={t('common.refresh', 'Refresh')}
|
||||
>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
{slug ? (
|
||||
<HeaderActionButton
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))}
|
||||
ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')}
|
||||
>
|
||||
<Settings size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
) : null}
|
||||
</XStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="uploads"
|
||||
title={t('controlRoom.title', 'Moderation & Live Show')}
|
||||
subtitle={t('controlRoom.subtitle', 'Review uploads and manage the live slideshow.')}
|
||||
onBack={back}
|
||||
headerActions={headerActions}
|
||||
>
|
||||
<XStack space="$2">
|
||||
{([
|
||||
{ key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') },
|
||||
{ key: 'live', label: t('controlRoom.tabs.live', 'Live Show') },
|
||||
] as const).map((tab) => (
|
||||
<Pressable key={tab.key} onPress={() => setActiveTab(tab.key)} style={{ flex: 1 }}>
|
||||
<MobileCard
|
||||
backgroundColor={activeTab === tab.key ? infoBg : 'transparent'}
|
||||
borderColor={activeTab === tab.key ? infoBorder : border}
|
||||
padding="$2.5"
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={textStrong}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
{activeTab === 'moderation' ? (
|
||||
<YStack space="$2">
|
||||
{queuedEventCount > 0 ? (
|
||||
<MobileCard>
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||
<YStack space="$1" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{online
|
||||
? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount })
|
||||
: t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })}
|
||||
</Text>
|
||||
</YStack>
|
||||
<CTAButton
|
||||
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
|
||||
onPress={() => syncQueuedActions()}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
disabled={!online}
|
||||
loading={syncingQueue}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard>
|
||||
<MobileField label={t('mobilePhotos.filtersTitle', 'Filter')}>
|
||||
<MobileSelect
|
||||
value={moderationFilter}
|
||||
onChange={(event) => setModerationFilter(event.target.value as ModerationFilter)}
|
||||
>
|
||||
{MODERATION_FILTERS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</MobileCard>
|
||||
|
||||
{!moderationLoading ? (
|
||||
<LimitWarnings
|
||||
limits={limits}
|
||||
addons={catalogAddons}
|
||||
onCheckout={startAddonCheckout}
|
||||
busyScope={busyScope}
|
||||
translate={translateLimits(t as any)}
|
||||
textColor={text}
|
||||
borderColor={border}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{moderationError ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{moderationError}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{moderationLoading && moderationPage === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : moderationPhotos.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<ImageIcon size={28} color={muted} />
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{moderationPhotos.map((photo) => {
|
||||
const isBusy = moderationBusyId === photo.id;
|
||||
const galleryStatus = photo.status ?? 'pending';
|
||||
const liveStatus = normalizeLiveStatus(photo.live_status);
|
||||
const canApprove = galleryStatus === 'pending';
|
||||
const canShow = galleryStatus === 'hidden';
|
||||
const visibilityAction: PhotoModerationAction['action'] = canShow ? 'show' : 'hide';
|
||||
const visibilityLabel = canShow
|
||||
? t('photos.actions.show', 'Show')
|
||||
: t('photos.actions.hide', 'Hide');
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{photo.original_name ?? t('common.photo', 'Photo')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{resolveLiveLabel(liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
<CTAButton
|
||||
label={t('photos.actions.approve', 'Approve')}
|
||||
onPress={() => handleModerationAction('approve', photo)}
|
||||
disabled={!canApprove}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
<CTAButton
|
||||
label={visibilityLabel}
|
||||
onPress={() => handleModerationAction(visibilityAction, photo)}
|
||||
disabled={false}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{moderationHasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setModerationPage((prev) => prev + 1)}
|
||||
disabled={moderationLoading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t(
|
||||
'liveShowQueue.galleryApprovedOnly',
|
||||
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
|
||||
)}
|
||||
</Text>
|
||||
{!online ? (
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard>
|
||||
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
|
||||
<MobileSelect
|
||||
value={liveStatusFilter}
|
||||
onChange={(event) => setLiveStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{LIVE_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</MobileCard>
|
||||
|
||||
{liveError ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{liveError}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{liveLoading && livePage === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : livePhotos.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{livePhotos.map((photo) => {
|
||||
const isBusy = liveBusyId === photo.id;
|
||||
const liveStatus = normalizeLiveStatus(photo.live_status);
|
||||
const galleryStatus = photo.status ?? 'pending';
|
||||
const approveMode = resolveLiveShowApproveMode(galleryStatus);
|
||||
const canApproveLive = approveMode !== 'not-eligible';
|
||||
const showApproveAction = liveStatus !== 'approved';
|
||||
const approveLabel =
|
||||
approveMode === 'approve-and-live'
|
||||
? t('liveShowQueue.approveAndLive', 'Approve + Live')
|
||||
: approveMode === 'approve-only'
|
||||
? t('liveShowQueue.approve', 'Approve for Live Show')
|
||||
: t('liveShowQueue.notEligible', 'Not eligible');
|
||||
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{photo.original_name ?? t('common.photo', 'Photo')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{resolveLiveLabel(liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{showApproveAction ? (
|
||||
<CTAButton
|
||||
label={approveLabel}
|
||||
onPress={() => {
|
||||
if (approveMode === 'approve-and-live') {
|
||||
void handleApproveAndLive(photo);
|
||||
return;
|
||||
}
|
||||
if (approveMode === 'approve-only') {
|
||||
void handleApprove(photo);
|
||||
}
|
||||
}}
|
||||
disabled={!online || !canApproveLive}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{liveHasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setLivePage((prev) => prev + 1)}
|
||||
disabled={liveLoading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<LegalConsentSheet
|
||||
open={consentOpen}
|
||||
onClose={() => {
|
||||
if (consentBusy) return;
|
||||
setConsentOpen(false);
|
||||
setConsentTarget(null);
|
||||
}}
|
||||
onConfirm={confirmAddonCheckout}
|
||||
busy={consentBusy}
|
||||
t={t}
|
||||
/>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSelect, MobileField } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
approveAndLiveShowPhoto,
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
getEvents,
|
||||
getLiveShowQueue,
|
||||
LiveShowQueueStatus,
|
||||
rejectLiveShowPhoto,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath } from '../constants';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
|
||||
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
|
||||
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
|
||||
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
|
||||
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
|
||||
];
|
||||
|
||||
export default function MobileEventLiveShowQueuePage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const online = useOnlineStatus();
|
||||
const { textStrong, text, muted, border, danger } = useAdminTheme();
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||
selectEvent(slugParam);
|
||||
}
|
||||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
||||
|
||||
const loadQueue = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
if (!fallbackAttempted) {
|
||||
setFallbackAttempted(true);
|
||||
try {
|
||||
const events = await getEvents({ force: true });
|
||||
const first = events[0] as TenantEvent | undefined;
|
||||
if (first?.slug) {
|
||||
selectEvent(first.slug);
|
||||
navigate(adminPath(`/mobile/events/${first.slug}/live-show`), { replace: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setError(t('events.errors.missingSlug', 'No event selected.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getLiveShowQueue(slug, {
|
||||
page,
|
||||
perPage: 20,
|
||||
liveStatus: statusFilter,
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadQueue();
|
||||
}, [loadQueue]);
|
||||
|
||||
async function handleApprove(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApproveAndLive(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await approveAndLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await rejectLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(photo: TenantPhoto) {
|
||||
if (!slug || busyId) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await clearLiveShowPhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'pending') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function resolveGalleryLabel(status?: string | null): string {
|
||||
const fallbackMap: Record<string, string> = {
|
||||
approved: 'Gallery approved',
|
||||
pending: 'Gallery pending',
|
||||
rejected: 'Gallery rejected',
|
||||
hidden: 'Hidden',
|
||||
};
|
||||
const key = status ?? 'pending';
|
||||
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="home"
|
||||
title={t('liveShowQueue.title', 'Live Show queue')}
|
||||
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
<MobileCard borderColor={border} backgroundColor="transparent">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t(
|
||||
'liveShowQueue.galleryApprovedOnly',
|
||||
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
|
||||
)}
|
||||
</Text>
|
||||
{!online ? (
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard>
|
||||
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
|
||||
<MobileSelect
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
</MobileCard>
|
||||
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading && page === 1 ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<SkeletonCard key={`skeleton-${idx}`} height={120} />
|
||||
))}
|
||||
</YStack>
|
||||
) : photos.length === 0 ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={text}>
|
||||
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{photos.map((photo) => {
|
||||
const isBusy = busyId === photo.id;
|
||||
const liveStatus = photo.live_status ?? 'pending';
|
||||
const galleryStatus = photo.status ?? 'pending';
|
||||
const canApproveGallery = galleryStatus === 'pending';
|
||||
const canApproveLiveOnly = galleryStatus === 'approved';
|
||||
const canApproveLive = canApproveGallery || canApproveLiveOnly;
|
||||
const showApproveAction = liveStatus !== 'approved';
|
||||
return (
|
||||
<MobileCard key={photo.id}>
|
||||
<XStack space="$3" alignItems="center">
|
||||
{photo.thumbnail_url ? (
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.original_name ?? 'Photo'}
|
||||
style={{
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${border}`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<YStack flex={1} space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<PillBadge tone={resolveStatusTone(galleryStatus)}>
|
||||
{resolveGalleryLabel(galleryStatus)}
|
||||
</PillBadge>
|
||||
<PillBadge tone={resolveStatusTone(liveStatus)}>
|
||||
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{photo.uploaded_at}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack space="$2" marginTop="$2">
|
||||
{showApproveAction ? (
|
||||
<CTAButton
|
||||
label={
|
||||
canApproveGallery
|
||||
? t('liveShowQueue.approveAndLive', 'Approve + Live')
|
||||
: canApproveLiveOnly
|
||||
? t('liveShowQueue.approve', 'Approve for Live Show')
|
||||
: t('liveShowQueue.notEligible', 'Not eligible')
|
||||
}
|
||||
onPress={() => {
|
||||
if (canApproveGallery) {
|
||||
void handleApproveAndLive(photo);
|
||||
return;
|
||||
}
|
||||
if (canApproveLiveOnly) {
|
||||
void handleApprove(photo);
|
||||
}
|
||||
}}
|
||||
disabled={!online || !canApproveLive}
|
||||
loading={isBusy}
|
||||
tone="primary"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
{liveStatus !== 'rejected' ? (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.reject', 'Reject')}
|
||||
onPress={() => handleReject(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="danger"
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={t('liveShowQueue.clear', 'Remove from Live Show')}
|
||||
onPress={() => handleClear(photo)}
|
||||
disabled={!online}
|
||||
loading={isBusy}
|
||||
tone="ghost"
|
||||
/>
|
||||
)}
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<MobileCard>
|
||||
<CTAButton
|
||||
label={t('common.loadMore', 'Load more')}
|
||||
onPress={() => setPage((prev) => prev + 1)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export default function MobileUploadsTabPage() {
|
||||
const { text, muted, border, primary } = useAdminTheme();
|
||||
|
||||
if (activeEvent?.slug) {
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/photos`)} replace />;
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/control-room`)} replace />;
|
||||
}
|
||||
|
||||
if (!hasEvents) {
|
||||
@@ -54,7 +54,7 @@ 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`));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { LimitWarnings } from '../EventPhotosPage';
|
||||
import { LimitWarnings } from '../components/LimitWarnings';
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
|
||||
131
resources/js/admin/mobile/components/LimitWarnings.tsx
Normal file
131
resources/js/admin/mobile/components/LimitWarnings.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { XStack, YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { buildLimitWarnings } from '../../lib/limitWarnings';
|
||||
import type { EventAddonCatalogItem, EventLimitSummary } from '../../api';
|
||||
import { scopeDefaults, selectAddonKeyForScope } from '../addons';
|
||||
import { CTAButton, MobileCard } from './Primitives';
|
||||
import { MobileSelect } from './FormControls';
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function LimitWarnings({
|
||||
limits,
|
||||
addons,
|
||||
onCheckout,
|
||||
busyScope,
|
||||
translate,
|
||||
textColor,
|
||||
borderColor,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
addons: EventAddonCatalogItem[];
|
||||
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
|
||||
busyScope: string | null;
|
||||
translate: LimitTranslator;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
|
||||
if (!warnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{warnings.map((warning) => (
|
||||
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
|
||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||
{warning.message}
|
||||
</Text>
|
||||
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests')
|
||||
&& resolveAddonOptions(addons, warning.scope).length ? (
|
||||
<MobileAddonsPicker
|
||||
scope={warning.scope}
|
||||
addons={addons}
|
||||
busy={busyScope === warning.scope}
|
||||
onCheckout={onCheckout}
|
||||
translate={translate}
|
||||
/>
|
||||
) : (
|
||||
<CTAButton
|
||||
label={
|
||||
warning.scope === 'photos'
|
||||
? translate('buyMorePhotos')
|
||||
: warning.scope === 'gallery'
|
||||
? translate('extendGallery')
|
||||
: translate('buyMoreGuests')
|
||||
}
|
||||
onPress={() => onCheckout(warning.scope)}
|
||||
loading={busyScope === warning.scope}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] {
|
||||
const whitelist = scopeDefaults[scope];
|
||||
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
||||
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
||||
}
|
||||
|
||||
function MobileAddonsPicker({
|
||||
scope,
|
||||
addons,
|
||||
busy,
|
||||
onCheckout,
|
||||
translate,
|
||||
}: {
|
||||
scope: 'photos' | 'gallery' | 'guests';
|
||||
addons: EventAddonCatalogItem[];
|
||||
busy: boolean;
|
||||
onCheckout: (addonKey: string) => void;
|
||||
translate: LimitTranslator;
|
||||
}) {
|
||||
const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]);
|
||||
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (options[0]?.key) {
|
||||
setSelected(options[0].key);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack space="$2" alignItems="center">
|
||||
<MobileSelect
|
||||
value={selected}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
containerStyle={{ flex: 1, minWidth: 0 }}
|
||||
compact
|
||||
>
|
||||
{options.map((addon) => (
|
||||
<option key={addon.key} value={addon.key}>
|
||||
{addon.label ?? addon.key}
|
||||
</option>
|
||||
))}
|
||||
</MobileSelect>
|
||||
<CTAButton
|
||||
label={
|
||||
scope === 'gallery'
|
||||
? translate('extendGallery')
|
||||
: scope === 'guests'
|
||||
? translate('buyMoreGuests')
|
||||
: translate('buyMorePhotos')
|
||||
}
|
||||
disabled={!selected || busy}
|
||||
onPress={() => selected && onCheckout(selected)}
|
||||
loading={busy}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -281,10 +281,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</Text>
|
||||
{effectiveActive?.slug ? (
|
||||
<CTAButton
|
||||
label={t('status.queueAction', 'Open Photos')}
|
||||
label={t('status.queueAction', 'Open moderation')}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/control-room`))}
|
||||
/>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
@@ -193,6 +193,15 @@ export function ActionTile({
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const { textStrong } = useAdminTheme();
|
||||
const backgroundColor = `${color}18`;
|
||||
const borderColor = `${color}40`;
|
||||
const shadowColor = `${color}2b`;
|
||||
const iconShadow = `${color}55`;
|
||||
const tileStyle = {
|
||||
...(delayMs ? { animationDelay: `${delayMs}ms` } : {}),
|
||||
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`,
|
||||
boxShadow: `0 10px 24px ${shadowColor}`,
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
onPress={disabled ? undefined : onPress}
|
||||
@@ -201,18 +210,26 @@ export function ActionTile({
|
||||
>
|
||||
<YStack
|
||||
className="admin-fade-up"
|
||||
style={delayMs ? { animationDelay: `${delayMs}ms` } : undefined}
|
||||
style={tileStyle}
|
||||
borderRadius={16}
|
||||
padding="$3"
|
||||
space="$2.5"
|
||||
backgroundColor={`${color}22`}
|
||||
backgroundColor={backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={`${color}55`}
|
||||
borderColor={borderColor}
|
||||
minHeight={110}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
backgroundColor={color}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{ boxShadow: `0 6px 14px ${iconShadow}` }}
|
||||
>
|
||||
<IconCmp size={16} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
|
||||
|
||||
47
resources/js/admin/mobile/lib/controlRoom.test.ts
Normal file
47
resources/js/admin/mobile/lib/controlRoom.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './controlRoom';
|
||||
|
||||
describe('normalizeLiveStatus', () => {
|
||||
it('maps nullish statuses to none', () => {
|
||||
expect(normalizeLiveStatus(null)).toBe('none');
|
||||
expect(normalizeLiveStatus(undefined)).toBe('none');
|
||||
expect(normalizeLiveStatus('')).toBe('none');
|
||||
});
|
||||
|
||||
it('passes through supported statuses', () => {
|
||||
expect(normalizeLiveStatus('pending')).toBe('pending');
|
||||
expect(normalizeLiveStatus('approved')).toBe('approved');
|
||||
expect(normalizeLiveStatus('rejected')).toBe('rejected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLiveShowApproveMode', () => {
|
||||
it('prefers approve-and-live for pending gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('pending')).toBe('approve-and-live');
|
||||
});
|
||||
|
||||
it('returns approve-only for approved gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('approved')).toBe('approve-only');
|
||||
});
|
||||
|
||||
it('returns not-eligible for rejected or hidden gallery', () => {
|
||||
expect(resolveLiveShowApproveMode('rejected')).toBe('not-eligible');
|
||||
expect(resolveLiveShowApproveMode('hidden')).toBe('not-eligible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStatusTone', () => {
|
||||
it('maps approved to success', () => {
|
||||
expect(resolveStatusTone('approved')).toBe('success');
|
||||
});
|
||||
|
||||
it('maps pending to warning', () => {
|
||||
expect(resolveStatusTone('pending')).toBe('warning');
|
||||
});
|
||||
|
||||
it('maps other statuses to muted', () => {
|
||||
expect(resolveStatusTone('rejected')).toBe('muted');
|
||||
expect(resolveStatusTone('hidden')).toBe('muted');
|
||||
expect(resolveStatusTone(undefined)).toBe('muted');
|
||||
});
|
||||
});
|
||||
28
resources/js/admin/mobile/lib/controlRoom.ts
Normal file
28
resources/js/admin/mobile/lib/controlRoom.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type LiveShowApproveMode = 'approve-and-live' | 'approve-only' | 'not-eligible';
|
||||
|
||||
export function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'approved') {
|
||||
return 'success';
|
||||
}
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
export function normalizeLiveStatus(status?: string | null): 'pending' | 'approved' | 'rejected' | 'none' {
|
||||
if (status === 'approved' || status === 'pending' || status === 'rejected') {
|
||||
return status;
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function resolveLiveShowApproveMode(galleryStatus?: string | null): LiveShowApproveMode {
|
||||
if (galleryStatus === 'pending') {
|
||||
return 'approve-and-live';
|
||||
}
|
||||
if (galleryStatus === 'approved') {
|
||||
return 'approve-only';
|
||||
}
|
||||
return 'not-eligible';
|
||||
}
|
||||
@@ -23,9 +23,9 @@ describe('tabHistory', () => {
|
||||
});
|
||||
|
||||
it('reuses stored event route when slug matches', () => {
|
||||
setTabHistory('uploads', adminPath('/mobile/events/summer/photos'));
|
||||
setTabHistory('uploads', adminPath('/mobile/events/summer/control-room'));
|
||||
const target = resolveTabTarget('uploads', 'summer');
|
||||
expect(target).toBe(adminPath('/mobile/events/summer/photos'));
|
||||
expect(target).toBe(adminPath('/mobile/events/summer/control-room'));
|
||||
});
|
||||
|
||||
it('falls back to active slug when stored slug differs', () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export function resolveDefaultTarget(key: NavKey, slug?: string | null): string
|
||||
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
|
||||
}
|
||||
if (key === 'uploads') {
|
||||
return slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/uploads');
|
||||
return slug ? adminPath(`/mobile/events/${slug}/control-room`) : adminPath('/mobile/uploads');
|
||||
}
|
||||
if (key === 'profile') {
|
||||
return adminPath('/mobile/profile');
|
||||
@@ -78,7 +78,7 @@ function resolveEventScopedTarget(path: string, slug: string | null | undefined,
|
||||
return path;
|
||||
}
|
||||
|
||||
const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|photos)(?:\/.*)?$/);
|
||||
const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|control-room)(?:\/.*)?$/);
|
||||
if (!match) {
|
||||
return resolveDefaultTarget(key, slug);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function prefetchMobileRoutes() {
|
||||
schedule(() => {
|
||||
void import('./DashboardPage');
|
||||
void import('./EventsPage');
|
||||
void import('./EventPhotosPage');
|
||||
void import('./EventControlRoomPage');
|
||||
void import('./EventTasksPage');
|
||||
void import('./NotificationsPage');
|
||||
void import('./ProfilePage');
|
||||
|
||||
@@ -19,18 +19,20 @@ export const ADMIN_COLORS = {
|
||||
};
|
||||
|
||||
export const ADMIN_ACTION_COLORS = {
|
||||
tasks: '#FF8A8E',
|
||||
qr: ADMIN_COLORS.warning,
|
||||
images: ADMIN_COLORS.accent,
|
||||
guests: ADMIN_COLORS.success,
|
||||
guestMessages: ADMIN_COLORS.primary,
|
||||
invites: ADMIN_COLORS.primaryStrong,
|
||||
branding: ADMIN_COLORS.accent,
|
||||
photobooth: '#FF8A8E',
|
||||
recap: ADMIN_COLORS.warning,
|
||||
settings: '#14B8A6',
|
||||
tasks: '#F59E0B',
|
||||
qr: '#3B82F6',
|
||||
images: '#8B5CF6',
|
||||
liveShow: '#EC4899',
|
||||
liveShowSettings: '#0EA5E9',
|
||||
guests: '#10B981',
|
||||
guestMessages: '#F97316',
|
||||
branding: '#6366F1',
|
||||
photobooth: '#E11D48',
|
||||
recap: '#64748B',
|
||||
packages: ADMIN_COLORS.primary,
|
||||
analytics: '#8b5cf6',
|
||||
settings: ADMIN_COLORS.success,
|
||||
analytics: '#22C55E',
|
||||
invites: ADMIN_COLORS.primaryStrong,
|
||||
};
|
||||
|
||||
export const ADMIN_GRADIENTS = {
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user