feat: update package copy and admin control room

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

View File

@@ -1,4 +1,3 @@
{"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"}
{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-02T14:18:31.676816348+01:00","closed_at":"2026-01-02T14:18:31.676816348+01:00","close_reason":"Closed"} {"id":"fotospiel-app-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-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)"} {"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-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-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-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-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-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"} {"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -28,7 +28,6 @@ export const ADMIN_EVENT_CREATE_PATH = adminPath('/mobile/events/new');
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_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_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_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_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`); 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_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`);
export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string => export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string =>
adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show/settings`); adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show/settings`);
export const ADMIN_EVENT_CONTROL_ROOM_PATH = (slug: string): string =>
adminPath(`/mobile/events/${encodeURIComponent(slug)}/control-room`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@
"ctaList": { "ctaList": {
"choosePackage": { "choosePackage": {
"label": "Choose your package", "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" "button": "Continue to packages"
}, },
"createEvent": { "createEvent": {
@@ -61,7 +61,7 @@
"steps": { "steps": {
"package": { "package": {
"title": "Secure your 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": { "invite": {
"title": "Invite your co-hosts", "title": "Invite your co-hosts",
@@ -77,10 +77,10 @@
"layout": { "layout": {
"eyebrow": "Step 2", "eyebrow": "Step 2",
"title": "Choose your package", "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": { "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." "description": "Secure capacity for your next event. Upgrade at any time only pay for what you need."
}, },
"state": { "state": {
@@ -92,7 +92,7 @@
}, },
"card": { "card": {
"subscription": "Subscription", "subscription": "Subscription",
"creditPack": "Event kontingent", "creditPack": "Event bundle",
"description": "Ready for your next event right away.", "description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.", "descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package", "active": "Active package",
@@ -151,7 +151,7 @@
}, },
"details": { "details": {
"subscription": "Subscription", "subscription": "Subscription",
"creditPack": "Event kontingent", "creditPack": "Event bundle",
"photos": "Up to {{count}} photos", "photos": "Up to {{count}} photos",
"galleryDays": "{{count}} gallery days", "galleryDays": "{{count}} gallery days",
"guests": "{{count}} guests", "guests": "{{count}} guests",
@@ -188,7 +188,7 @@
"activate": "Activate free package", "activate": "Activate free package",
"progress": "Activating …", "progress": "Activating …",
"successTitle": "Free package activated", "successTitle": "Free package activated",
"successDescription": "Event kontingent added. Continue with the setup.", "successDescription": "Event bundle added. Continue with the setup.",
"failureTitle": "Activation failed", "failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated." "errorMessage": "The free package could not be activated."
}, },
@@ -205,12 +205,12 @@
"nextSteps": [ "nextSteps": [
"Optional: finish billing via Paddle inside the billing area.", "Optional: finish billing via Paddle inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.", "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": { "cta": {
"billing": { "billing": {
"label": "Start 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" "button": "Go to billing"
}, },
"setup": { "setup": {

View File

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

View File

@@ -235,7 +235,7 @@ export default function MobileDashboardPage() {
return; return;
} }
closeTour(); closeTour();
navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`)); navigate(adminPath(`/mobile/events/${tourTargetSlug}/control-room`));
}, },
showAction: Boolean(tourTargetSlug), showAction: Boolean(tourTargetSlug),
}, },
@@ -1223,20 +1223,20 @@ function EventManagementGrid({
icon: ImageIcon, icon: ImageIcon,
label: t('events.quick.images', 'Image Management'), label: t('events.quick.images', 'Image Management'),
color: ADMIN_ACTION_COLORS.images, 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, disabled: !slug,
}, },
{ {
icon: Tv, icon: Tv,
label: t('events.quick.liveShow', 'Live Show queue'), label: t('events.quick.controlRoom', 'Moderation & Live Show'),
color: ADMIN_ACTION_COLORS.images, color: ADMIN_ACTION_COLORS.liveShow,
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
icon: Settings, icon: Settings,
label: t('events.quick.liveShowSettings', 'Live Show 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, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
disabled: !slug, disabled: !slug,
}, },

View File

@@ -0,0 +1,936 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Settings } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
createEventAddonCheckout,
EventAddonCatalogItem,
EventLimitSummary,
getAddonCatalog,
featurePhoto,
getEventPhotos,
getEvents,
getLiveShowQueue,
LiveShowQueueStatus,
rejectLiveShowPhoto,
TenantEvent,
TenantPhoto,
unfeaturePhoto,
updatePhotoStatus,
updatePhotoVisibility,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { useOnlineStatus } from './hooks/useOnlineStatus';
import {
enqueuePhotoAction,
loadPhotoQueue,
removePhotoAction,
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { triggerHaptic } from './lib/haptics';
import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './lib/controlRoom';
import { LegalConsentSheet } from './components/LegalConsentSheet';
import { selectAddonKeyForScope } from './addons';
import { LimitWarnings } from './components/LimitWarnings';
type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending';
const MODERATION_FILTERS: Array<{ value: ModerationFilter; labelKey: string; fallback: string }> = [
{ value: 'pending', labelKey: 'photos.filters.pending', fallback: 'Pending' },
{ value: 'all', labelKey: 'photos.filters.all', fallback: 'All' },
{ value: 'featured', labelKey: 'photos.filters.featured', fallback: 'Featured' },
{ value: 'hidden', labelKey: 'photos.filters.hidden', fallback: 'Hidden' },
];
const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
];
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
const defaults: Record<string, string> = {
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
guestsBlocked: 'Guest limit reached.',
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
galleryExpired: 'Gallery expired. Extend to keep it online.',
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
galleryWarningDay: 'Gallery expires in {{days}} day.',
galleryWarningDays: 'Gallery expires in {{days}} days.',
buyMorePhotos: 'Buy more photos',
extendGallery: 'Extend gallery',
buyMoreGuests: 'Add more guests',
};
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme();
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
const [moderationFilter, setModerationFilter] = React.useState<ModerationFilter>('pending');
const [moderationPage, setModerationPage] = React.useState(1);
const [moderationHasMore, setModerationHasMore] = React.useState(false);
const [moderationLoading, setModerationLoading] = React.useState(true);
const [moderationError, setModerationError] = React.useState<string | null>(null);
const [moderationBusyId, setModerationBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
const [liveStatusFilter, setLiveStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
const [livePage, setLivePage] = React.useState(1);
const [liveHasMore, setLiveHasMore] = React.useState(false);
const [liveLoading, setLiveLoading] = React.useState(true);
const [liveError, setLiveError] = React.useState<string | null>(null);
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const [syncingQueue, setSyncingQueue] = React.useState(false);
const syncingQueueRef = React.useRef(false);
const moderationResetRef = React.useRef(false);
const liveResetRef = React.useRef(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const infoBg = accentSoft;
const infoBorder = accent;
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const ensureSlug = React.useCallback(async () => {
if (slug) {
return slug;
}
if (fallbackAttempted) {
return null;
}
setFallbackAttempted(true);
try {
const events = await getEvents({ force: true });
const first = events[0] as TenantEvent | undefined;
if (first?.slug) {
selectEvent(first.slug);
navigate(adminPath(`/mobile/events/${first.slug}/control-room`), { replace: true });
return first.slug;
}
} catch {
// ignore
}
return null;
}, [slug, fallbackAttempted, navigate, selectEvent]);
React.useEffect(() => {
setModerationPage(1);
}, [moderationFilter, slug]);
React.useEffect(() => {
setLivePage(1);
}, [liveStatusFilter, slug]);
React.useEffect(() => {
if (activeTab === 'moderation') {
moderationResetRef.current = true;
setModerationPhotos([]);
setModerationPage(1);
} else {
liveResetRef.current = true;
setLivePhotos([]);
setLivePage(1);
}
}, [activeTab]);
const loadModeration = React.useCallback(async () => {
const resolvedSlug = await ensureSlug();
if (!resolvedSlug) {
setModerationLoading(false);
setModerationError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setModerationLoading(true);
setModerationError(null);
try {
const status =
moderationFilter === 'hidden'
? 'hidden'
: moderationFilter === 'pending'
? 'pending'
: undefined;
const result = await getEventPhotos(resolvedSlug, {
page: moderationPage,
perPage: 20,
sort: 'desc',
featured: moderationFilter === 'featured',
status,
});
setModerationPhotos((prev) => (moderationPage === 1 ? result.photos : [...prev, ...result.photos]));
setLimits(result.limits ?? null);
const lastPage = result.meta?.last_page ?? 1;
setModerationHasMore(moderationPage < lastPage);
const addons = await getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]);
setCatalogAddons(addons ?? []);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Photos could not be loaded.'));
setModerationError(message);
}
} finally {
setModerationLoading(false);
}
}, [ensureSlug, moderationFilter, moderationPage, t]);
const loadLiveQueue = React.useCallback(async () => {
const resolvedSlug = await ensureSlug();
if (!resolvedSlug) {
setLiveLoading(false);
setLiveError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setLiveLoading(true);
setLiveError(null);
try {
const result = await getLiveShowQueue(resolvedSlug, {
page: livePage,
perPage: 20,
liveStatus: liveStatusFilter,
});
setLivePhotos((prev) => (livePage === 1 ? result.photos : [...prev, ...result.photos]));
const lastPage = result.meta?.last_page ?? 1;
setLiveHasMore(livePage < lastPage);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
setLiveError(message);
toast.error(message);
}
} finally {
setLiveLoading(false);
}
}, [ensureSlug, livePage, liveStatusFilter, t]);
React.useEffect(() => {
if (activeTab === 'moderation') {
if (moderationResetRef.current && moderationPage !== 1) {
return;
}
moderationResetRef.current = false;
void loadModeration();
}
}, [activeTab, loadModeration, moderationPage]);
React.useEffect(() => {
if (activeTab === 'live') {
if (liveResetRef.current && livePage !== 1) {
return;
}
liveResetRef.current = false;
void loadLiveQueue();
}
}, [activeTab, loadLiveQueue, livePage]);
React.useEffect(() => {
if (!location.search || !slug) {
return;
}
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
setModerationPage(1);
void loadModeration();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, loadModeration, navigate, t, location.pathname]);
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
replacePhotoQueue(queue);
setQueuedActions(queue);
}, []);
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
setModerationPhotos((prev) =>
prev.map((photo) => {
if (photo.id !== photoId) {
return photo;
}
if (action === 'approve') {
return { ...photo, status: 'approved' };
}
if (action === 'hide') {
return { ...photo, status: 'hidden' };
}
if (action === 'show') {
return { ...photo, status: 'approved' };
}
return photo;
}),
);
}, []);
const enqueueModerationAction = React.useCallback(
(action: PhotoModerationAction['action'], photoId: number) => {
if (!slug) {
return;
}
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
setQueuedActions(nextQueue);
applyOptimisticUpdate(photoId, action);
toast.success(t('mobilePhotos.queued', 'Action saved. Syncs when you are back online.'));
triggerHaptic('selection');
},
[applyOptimisticUpdate, slug, t],
);
const syncQueuedActions = React.useCallback(async () => {
if (!online || syncingQueueRef.current) {
return;
}
const queue = loadPhotoQueue();
if (queue.length === 0) {
return;
}
syncingQueueRef.current = true;
setSyncingQueue(true);
let remaining = queue;
for (const entry of queue) {
try {
let updated: TenantPhoto | null = null;
if (entry.action === 'approve') {
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
} else if (entry.action === 'hide') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
} else if (entry.action === 'show') {
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
} else if (entry.action === 'feature') {
updated = await featurePhoto(entry.eventSlug, entry.photoId);
} else if (entry.action === 'unfeature') {
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
}
remaining = removePhotoAction(remaining, entry.id);
if (updated && entry.eventSlug === slug) {
setModerationPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
}
} catch (err) {
toast.error(t('mobilePhotos.syncFailed', 'Sync failed. Please try again later.'));
if (isAuthError(err)) {
break;
}
}
}
updateQueueState(remaining);
setSyncingQueue(false);
syncingQueueRef.current = false;
}, [online, slug, t, updateQueueState]);
React.useEffect(() => {
if (online) {
void syncQueuedActions();
}
}, [online, syncQueuedActions]);
const handleModerationAction = React.useCallback(
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
if (!slug) {
return;
}
if (!online) {
enqueueModerationAction(action, photo.id);
return;
}
setModerationBusyId(photo.id);
try {
let updated: TenantPhoto;
if (action === 'approve') {
updated = await updatePhotoStatus(slug, photo.id, 'approved');
} else if (action === 'hide') {
updated = await updatePhotoVisibility(slug, photo.id, true);
} else {
updated = await updatePhotoVisibility(slug, photo.id, false);
}
setModerationPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
triggerHaptic(action === 'approve' ? 'success' : 'medium');
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Visibility could not be changed.'));
setModerationError(message);
toast.error(message);
}
} finally {
setModerationBusyId(null);
}
},
[enqueueModerationAction, online, slug, t],
);
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
? 'guests'
: 'photos';
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
setConsentOpen(true);
}
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
if (!slug || !consentTarget) return;
const currentUrl = typeof window !== 'undefined'
? `${window.location.origin}${adminPath(`/mobile/events/${slug}/control-room`)}`
: '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(consentTarget.scope);
setConsentBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: consentTarget.addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
accepted_terms: consents.acceptedTerms,
accepted_waiver: consents.acceptedWaiver,
} as any);
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.')));
} finally {
setConsentBusy(false);
setConsentOpen(false);
setConsentTarget(null);
setBusyScope(null);
}
}
async function handleApprove(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await approveLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setLiveBusyId(null);
}
}
async function handleApproveAndLive(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await approveAndLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setLiveBusyId(null);
}
}
async function handleReject(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await rejectLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setLiveBusyId(null);
}
}
async function handleClear(photo: TenantPhoto) {
if (!slug || liveBusyId) return;
setLiveBusyId(photo.id);
try {
const updated = await clearLiveShowPhoto(slug, photo.id);
setLivePhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setLiveBusyId(null);
}
}
function resolveGalleryLabel(status?: string | null): string {
const key = status ?? 'pending';
const fallbackMap: Record<string, string> = {
approved: 'Gallery approved',
pending: 'Gallery pending',
rejected: 'Gallery rejected',
hidden: 'Hidden',
};
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
}
function resolveLiveLabel(status?: string | null): string {
const key = normalizeLiveStatus(status);
return t(`liveShowQueue.status.${key}`, key);
}
const queuedEventCount = React.useMemo(() => {
if (!slug) {
return queuedActions.length;
}
return queuedActions.filter((action) => action.eventSlug === slug).length;
}, [queuedActions, slug]);
const headerActions = (
<XStack space="$2">
<HeaderActionButton
onPress={() => {
if (activeTab === 'moderation') {
void loadModeration();
return;
}
void loadLiveQueue();
}}
ariaLabel={t('common.refresh', 'Refresh')}
>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
{slug ? (
<HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))}
ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')}
>
<Settings size={18} color={textStrong} />
</HeaderActionButton>
) : null}
</XStack>
);
return (
<MobileShell
activeTab="uploads"
title={t('controlRoom.title', 'Moderation & Live Show')}
subtitle={t('controlRoom.subtitle', 'Review uploads and manage the live slideshow.')}
onBack={back}
headerActions={headerActions}
>
<XStack space="$2">
{([
{ key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') },
{ key: 'live', label: t('controlRoom.tabs.live', 'Live Show') },
] as const).map((tab) => (
<Pressable key={tab.key} onPress={() => setActiveTab(tab.key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={activeTab === tab.key ? infoBg : 'transparent'}
borderColor={activeTab === tab.key ? infoBorder : border}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={textStrong}>
{tab.label}
</Text>
</MobileCard>
</Pressable>
))}
</XStack>
{activeTab === 'moderation' ? (
<YStack space="$2">
{queuedEventCount > 0 ? (
<MobileCard>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<YStack space="$1" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.queueTitle', 'Changes waiting to sync')}
</Text>
<Text fontSize="$xs" color={muted}>
{online
? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount })
: t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })}
</Text>
</YStack>
<CTAButton
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
onPress={() => syncQueuedActions()}
tone="ghost"
fullWidth={false}
disabled={!online}
loading={syncingQueue}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard>
<MobileField label={t('mobilePhotos.filtersTitle', 'Filter')}>
<MobileSelect
value={moderationFilter}
onChange={(event) => setModerationFilter(event.target.value as ModerationFilter)}
>
{MODERATION_FILTERS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
</MobileCard>
{!moderationLoading ? (
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={startAddonCheckout}
busyScope={busyScope}
translate={translateLimits(t as any)}
textColor={text}
borderColor={border}
/>
) : null}
{moderationError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{moderationError}
</Text>
</MobileCard>
) : null}
{moderationLoading && moderationPage === 1 ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`moderation-skeleton-${idx}`} height={120} />
))}
</YStack>
) : moderationPhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyModeration', 'No uploads match this filter.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{moderationPhotos.map((photo) => {
const isBusy = moderationBusyId === photo.id;
const galleryStatus = photo.status ?? 'pending';
const liveStatus = normalizeLiveStatus(photo.live_status);
const canApprove = galleryStatus === 'pending';
const canShow = galleryStatus === 'hidden';
const visibilityAction: PhotoModerationAction['action'] = canShow ? 'show' : 'hide';
const visibilityLabel = canShow
? t('photos.actions.show', 'Show')
: t('photos.actions.hide', 'Hide');
return (
<MobileCard key={photo.id}>
<XStack space="$3" alignItems="center">
{photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.original_name ?? 'Photo'}
style={{
width: 72,
height: 72,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${border}`,
}}
/>
) : null}
<YStack flex={1} space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{photo.original_name ?? t('common.photo', 'Photo')}
</Text>
<XStack alignItems="center" space="$2">
<PillBadge tone={resolveStatusTone(galleryStatus)}>
{resolveGalleryLabel(galleryStatus)}
</PillBadge>
<PillBadge tone={resolveStatusTone(liveStatus)}>
{resolveLiveLabel(liveStatus)}
</PillBadge>
</XStack>
</YStack>
</XStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={t('photos.actions.approve', 'Approve')}
onPress={() => handleModerationAction('approve', photo)}
disabled={!canApprove}
loading={isBusy}
tone="primary"
/>
<CTAButton
label={visibilityLabel}
onPress={() => handleModerationAction(visibilityAction, photo)}
disabled={false}
loading={isBusy}
tone="ghost"
/>
</XStack>
</MobileCard>
);
})}
</YStack>
)}
{moderationHasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setModerationPage((prev) => prev + 1)}
disabled={moderationLoading}
/>
</MobileCard>
) : null}
</YStack>
) : (
<YStack space="$2">
<MobileCard borderColor={border} backgroundColor="transparent">
<Text fontSize="$sm" color={muted}>
{t(
'liveShowQueue.galleryApprovedOnly',
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
)}
</Text>
{!online ? (
<Text fontSize="$sm" color={danger}>
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
</Text>
) : null}
</MobileCard>
<MobileCard>
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
<MobileSelect
value={liveStatusFilter}
onChange={(event) => setLiveStatusFilter(event.target.value as LiveShowQueueStatus)}
>
{LIVE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
</MobileCard>
{liveError ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{liveError}
</Text>
</MobileCard>
) : null}
{liveLoading && livePage === 1 ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`live-skeleton-${idx}`} height={120} />
))}
</YStack>
) : livePhotos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('controlRoom.emptyLive', 'No photos waiting for Live Show.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{livePhotos.map((photo) => {
const isBusy = liveBusyId === photo.id;
const liveStatus = normalizeLiveStatus(photo.live_status);
const galleryStatus = photo.status ?? 'pending';
const approveMode = resolveLiveShowApproveMode(galleryStatus);
const canApproveLive = approveMode !== 'not-eligible';
const showApproveAction = liveStatus !== 'approved';
const approveLabel =
approveMode === 'approve-and-live'
? t('liveShowQueue.approveAndLive', 'Approve + Live')
: approveMode === 'approve-only'
? t('liveShowQueue.approve', 'Approve for Live Show')
: t('liveShowQueue.notEligible', 'Not eligible');
return (
<MobileCard key={photo.id}>
<XStack space="$3" alignItems="center">
{photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.original_name ?? 'Photo'}
style={{
width: 72,
height: 72,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${border}`,
}}
/>
) : null}
<YStack flex={1} space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{photo.original_name ?? t('common.photo', 'Photo')}
</Text>
<XStack alignItems="center" space="$2">
<PillBadge tone={resolveStatusTone(galleryStatus)}>
{resolveGalleryLabel(galleryStatus)}
</PillBadge>
<PillBadge tone={resolveStatusTone(liveStatus)}>
{resolveLiveLabel(liveStatus)}
</PillBadge>
</XStack>
</YStack>
</XStack>
<XStack space="$2" marginTop="$2">
{showApproveAction ? (
<CTAButton
label={approveLabel}
onPress={() => {
if (approveMode === 'approve-and-live') {
void handleApproveAndLive(photo);
return;
}
if (approveMode === 'approve-only') {
void handleApprove(photo);
}
}}
disabled={!online || !canApproveLive}
loading={isBusy}
tone="primary"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
{liveStatus !== 'rejected' ? (
<CTAButton
label={t('liveShowQueue.reject', 'Reject')}
onPress={() => handleReject(photo)}
disabled={!online}
loading={isBusy}
tone="danger"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
</XStack>
</MobileCard>
);
})}
</YStack>
)}
{liveHasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setLivePage((prev) => prev + 1)}
disabled={liveLoading}
/>
</MobileCard>
) : null}
</YStack>
)}
<LegalConsentSheet
open={consentOpen}
onClose={() => {
if (consentBusy) return;
setConsentOpen(false);
setConsentTarget(null);
}}
onConfirm={confirmAddonCheckout}
busy={consentBusy}
t={t}
/>
</MobileShell>
);
}

View File

@@ -1,360 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { MobileSelect, MobileField } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
getEvents,
getLiveShowQueue,
LiveShowQueueStatus,
rejectLiveShowPhoto,
TenantEvent,
TenantPhoto,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath } from '../constants';
import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { useOnlineStatus } from './hooks/useOnlineStatus';
const STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string; fallback: string }> = [
{ value: 'pending', labelKey: 'liveShowQueue.statusPending', fallback: 'Pending' },
{ value: 'approved', labelKey: 'liveShowQueue.statusApproved', fallback: 'Approved' },
{ value: 'rejected', labelKey: 'liveShowQueue.statusRejected', fallback: 'Rejected' },
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
];
export default function MobileEventLiveShowQueuePage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, danger } = useAdminTheme();
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [statusFilter, setStatusFilter] = React.useState<LiveShowQueueStatus>('pending');
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
const loadQueue = React.useCallback(async () => {
if (!slug) {
if (!fallbackAttempted) {
setFallbackAttempted(true);
try {
const events = await getEvents({ force: true });
const first = events[0] as TenantEvent | undefined;
if (first?.slug) {
selectEvent(first.slug);
navigate(adminPath(`/mobile/events/${first.slug}/live-show`), { replace: true });
}
} catch {
// ignore
}
}
setLoading(false);
setError(t('events.errors.missingSlug', 'No event selected.'));
return;
}
setLoading(true);
setError(null);
try {
const result = await getLiveShowQueue(slug, {
page,
perPage: 20,
liveStatus: statusFilter,
});
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('liveShowQueue.loadFailed', 'Live Show queue could not be loaded.'));
setError(message);
toast.error(message);
}
} finally {
setLoading(false);
}
}, [slug, page, statusFilter, fallbackAttempted, navigate, selectEvent, t]);
React.useEffect(() => {
setPage(1);
}, [statusFilter]);
React.useEffect(() => {
void loadQueue();
}, [loadQueue]);
async function handleApprove(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await approveLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveSuccess', 'Photo approved for Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
async function handleApproveAndLive(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await approveAndLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.approveAndLiveSuccess', 'Photo approved and added to Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
async function handleReject(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await rejectLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.rejectSuccess', 'Photo removed from Live Show'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
async function handleClear(photo: TenantPhoto) {
if (!slug || busyId) return;
setBusyId(photo.id);
try {
const updated = await clearLiveShowPhoto(slug, photo.id);
setPhotos((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(t('liveShowQueue.clearSuccess', 'Live Show approval removed'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('liveShowQueue.actionFailed', 'Live Show update failed.'));
}
} finally {
setBusyId(null);
}
}
function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
if (status === 'approved') return 'success';
if (status === 'pending') return 'warning';
return 'muted';
}
function resolveGalleryLabel(status?: string | null): string {
const fallbackMap: Record<string, string> = {
approved: 'Gallery approved',
pending: 'Gallery pending',
rejected: 'Gallery rejected',
hidden: 'Hidden',
};
const key = status ?? 'pending';
return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key);
}
return (
<MobileShell
activeTab="home"
title={t('liveShowQueue.title', 'Live Show queue')}
subtitle={t('liveShowQueue.subtitle', 'Approve photos for the live slideshow')}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={textStrong} />
</HeaderActionButton>
}
>
<MobileCard borderColor={border} backgroundColor="transparent">
<Text fontSize="$sm" color={muted}>
{t(
'liveShowQueue.galleryApprovedOnly',
'Gallery and Live Show approvals are separate. Pending photos can be approved here.'
)}
</Text>
{!online ? (
<Text fontSize="$sm" color={danger}>
{t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')}
</Text>
) : null}
</MobileCard>
<MobileCard>
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
<MobileSelect
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
</MobileCard>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
{loading && page === 1 ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<SkeletonCard key={`skeleton-${idx}`} height={120} />
))}
</YStack>
) : photos.length === 0 ? (
<MobileCard>
<Text fontWeight="700" color={text}>
{t('liveShowQueue.empty', 'No photos waiting for Live Show.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{photos.map((photo) => {
const isBusy = busyId === photo.id;
const liveStatus = photo.live_status ?? 'pending';
const galleryStatus = photo.status ?? 'pending';
const canApproveGallery = galleryStatus === 'pending';
const canApproveLiveOnly = galleryStatus === 'approved';
const canApproveLive = canApproveGallery || canApproveLiveOnly;
const showApproveAction = liveStatus !== 'approved';
return (
<MobileCard key={photo.id}>
<XStack space="$3" alignItems="center">
{photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.original_name ?? 'Photo'}
style={{
width: 86,
height: 86,
borderRadius: 14,
objectFit: 'cover',
border: `1px solid ${border}`,
}}
/>
) : null}
<YStack flex={1} space="$2">
<XStack alignItems="center" space="$2">
<PillBadge tone={resolveStatusTone(galleryStatus)}>
{resolveGalleryLabel(galleryStatus)}
</PillBadge>
<PillBadge tone={resolveStatusTone(liveStatus)}>
{t(`liveShowQueue.status.${liveStatus}`, liveStatus)}
</PillBadge>
</XStack>
<Text fontSize="$sm" color={muted}>
{photo.uploaded_at}
</Text>
</YStack>
</XStack>
<XStack space="$2" marginTop="$2">
{showApproveAction ? (
<CTAButton
label={
canApproveGallery
? t('liveShowQueue.approveAndLive', 'Approve + Live')
: canApproveLiveOnly
? t('liveShowQueue.approve', 'Approve for Live Show')
: t('liveShowQueue.notEligible', 'Not eligible')
}
onPress={() => {
if (canApproveGallery) {
void handleApproveAndLive(photo);
return;
}
if (canApproveLiveOnly) {
void handleApprove(photo);
}
}}
disabled={!online || !canApproveLive}
loading={isBusy}
tone="primary"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
{liveStatus !== 'rejected' ? (
<CTAButton
label={t('liveShowQueue.reject', 'Reject')}
onPress={() => handleReject(photo)}
disabled={!online}
loading={isBusy}
tone="danger"
/>
) : (
<CTAButton
label={t('liveShowQueue.clear', 'Remove from Live Show')}
onPress={() => handleClear(photo)}
disabled={!online}
loading={isBusy}
tone="ghost"
/>
)}
</XStack>
</MobileCard>
);
})}
</YStack>
)}
{hasMore ? (
<MobileCard>
<CTAButton
label={t('common.loadMore', 'Load more')}
onPress={() => setPage((prev) => prev + 1)}
disabled={loading}
/>
</MobileCard>
) : null}
</MobileShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ export default function MobileUploadsTabPage() {
const { text, muted, border, primary } = useAdminTheme(); const { text, muted, border, primary } = useAdminTheme();
if (activeEvent?.slug) { 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) { if (!hasEvents) {
@@ -54,7 +54,7 @@ export default function MobileUploadsTabPage() {
onPress={() => { onPress={() => {
selectEvent(event.slug ?? null); selectEvent(event.slug ?? null);
if (event.slug) { if (event.slug) {
navigate(adminPath(`/mobile/events/${event.slug}/photos`)); navigate(adminPath(`/mobile/events/${event.slug}/control-room`));
} }
}} }}
> >

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { LimitWarnings } from '../EventPhotosPage'; import { LimitWarnings } from '../components/LimitWarnings';
vi.mock('@tamagui/stacks', () => ({ vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { buildLimitWarnings } from '../../lib/limitWarnings';
import type { EventAddonCatalogItem, EventLimitSummary } from '../../api';
import { scopeDefaults, selectAddonKeyForScope } from '../addons';
import { CTAButton, MobileCard } from './Primitives';
import { MobileSelect } from './FormControls';
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
export function LimitWarnings({
limits,
addons,
onCheckout,
busyScope,
translate,
textColor,
borderColor,
}: {
limits: EventLimitSummary | null;
addons: EventAddonCatalogItem[];
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
busyScope: string | null;
translate: LimitTranslator;
textColor: string;
borderColor: string;
}) {
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
if (!warnings.length) {
return null;
}
return (
<YStack space="$2">
{warnings.map((warning) => (
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{warning.message}
</Text>
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests')
&& resolveAddonOptions(addons, warning.scope).length ? (
<MobileAddonsPicker
scope={warning.scope}
addons={addons}
busy={busyScope === warning.scope}
onCheckout={onCheckout}
translate={translate}
/>
) : (
<CTAButton
label={
warning.scope === 'photos'
? translate('buyMorePhotos')
: warning.scope === 'gallery'
? translate('extendGallery')
: translate('buyMoreGuests')
}
onPress={() => onCheckout(warning.scope)}
loading={busyScope === warning.scope}
/>
)}
</MobileCard>
))}
</YStack>
);
}
function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] {
const whitelist = scopeDefaults[scope];
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
}
function MobileAddonsPicker({
scope,
addons,
busy,
onCheckout,
translate,
}: {
scope: 'photos' | 'gallery' | 'guests';
addons: EventAddonCatalogItem[];
busy: boolean;
onCheckout: (addonKey: string) => void;
translate: LimitTranslator;
}) {
const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]);
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
React.useEffect(() => {
if (options[0]?.key) {
setSelected(options[0].key);
}
}, [options]);
if (!options.length) {
return null;
}
return (
<XStack space="$2" alignItems="center">
<MobileSelect
value={selected}
onChange={(event) => setSelected(event.target.value)}
containerStyle={{ flex: 1, minWidth: 0 }}
compact
>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</MobileSelect>
<CTAButton
label={
scope === 'gallery'
? translate('extendGallery')
: scope === 'guests'
? translate('buyMoreGuests')
: translate('buyMorePhotos')
}
disabled={!selected || busy}
onPress={() => selected && onCheckout(selected)}
loading={busy}
fullWidth={false}
/>
</XStack>
);
}

View File

@@ -281,10 +281,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Text> </Text>
{effectiveActive?.slug ? ( {effectiveActive?.slug ? (
<CTAButton <CTAButton
label={t('status.queueAction', 'Open Photos')} label={t('status.queueAction', 'Open moderation')}
tone="ghost" tone="ghost"
fullWidth={false} fullWidth={false}
onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))} onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/control-room`))}
/> />
) : null} ) : null}
</MobileCard> </MobileCard>

View File

@@ -193,6 +193,15 @@ export function ActionTile({
delayMs?: number; delayMs?: number;
}) { }) {
const { textStrong } = useAdminTheme(); 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 ( return (
<Pressable <Pressable
onPress={disabled ? undefined : onPress} onPress={disabled ? undefined : onPress}
@@ -201,18 +210,26 @@ export function ActionTile({
> >
<YStack <YStack
className="admin-fade-up" className="admin-fade-up"
style={delayMs ? { animationDelay: `${delayMs}ms` } : undefined} style={tileStyle}
borderRadius={16} borderRadius={16}
padding="$3" padding="$3"
space="$2.5" space="$2.5"
backgroundColor={`${color}22`} backgroundColor={backgroundColor}
borderWidth={1} borderWidth={1}
borderColor={`${color}55`} borderColor={borderColor}
minHeight={110} minHeight={110}
alignItems="center" alignItems="center"
justifyContent="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" /> <IconCmp size={16} color="white" />
</XStack> </XStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center"> <Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { normalizeLiveStatus, resolveLiveShowApproveMode, resolveStatusTone } from './controlRoom';
describe('normalizeLiveStatus', () => {
it('maps nullish statuses to none', () => {
expect(normalizeLiveStatus(null)).toBe('none');
expect(normalizeLiveStatus(undefined)).toBe('none');
expect(normalizeLiveStatus('')).toBe('none');
});
it('passes through supported statuses', () => {
expect(normalizeLiveStatus('pending')).toBe('pending');
expect(normalizeLiveStatus('approved')).toBe('approved');
expect(normalizeLiveStatus('rejected')).toBe('rejected');
});
});
describe('resolveLiveShowApproveMode', () => {
it('prefers approve-and-live for pending gallery', () => {
expect(resolveLiveShowApproveMode('pending')).toBe('approve-and-live');
});
it('returns approve-only for approved gallery', () => {
expect(resolveLiveShowApproveMode('approved')).toBe('approve-only');
});
it('returns not-eligible for rejected or hidden gallery', () => {
expect(resolveLiveShowApproveMode('rejected')).toBe('not-eligible');
expect(resolveLiveShowApproveMode('hidden')).toBe('not-eligible');
});
});
describe('resolveStatusTone', () => {
it('maps approved to success', () => {
expect(resolveStatusTone('approved')).toBe('success');
});
it('maps pending to warning', () => {
expect(resolveStatusTone('pending')).toBe('warning');
});
it('maps other statuses to muted', () => {
expect(resolveStatusTone('rejected')).toBe('muted');
expect(resolveStatusTone('hidden')).toBe('muted');
expect(resolveStatusTone(undefined)).toBe('muted');
});
});

View File

@@ -0,0 +1,28 @@
export type LiveShowApproveMode = 'approve-and-live' | 'approve-only' | 'not-eligible';
export function resolveStatusTone(status?: string | null): 'success' | 'warning' | 'muted' {
if (status === 'approved') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'muted';
}
export function normalizeLiveStatus(status?: string | null): 'pending' | 'approved' | 'rejected' | 'none' {
if (status === 'approved' || status === 'pending' || status === 'rejected') {
return status;
}
return 'none';
}
export function resolveLiveShowApproveMode(galleryStatus?: string | null): LiveShowApproveMode {
if (galleryStatus === 'pending') {
return 'approve-and-live';
}
if (galleryStatus === 'approved') {
return 'approve-only';
}
return 'not-eligible';
}

View File

@@ -23,9 +23,9 @@ describe('tabHistory', () => {
}); });
it('reuses stored event route when slug matches', () => { 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'); 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', () => { it('falls back to active slug when stored slug differs', () => {

View File

@@ -61,7 +61,7 @@ export function resolveDefaultTarget(key: NavKey, slug?: string | null): string
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks'); return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
} }
if (key === 'uploads') { 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') { if (key === 'profile') {
return adminPath('/mobile/profile'); return adminPath('/mobile/profile');
@@ -78,7 +78,7 @@ function resolveEventScopedTarget(path: string, slug: string | null | undefined,
return path; 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) { if (!match) {
return resolveDefaultTarget(key, slug); return resolveDefaultTarget(key, slug);
} }

View File

@@ -12,7 +12,7 @@ export function prefetchMobileRoutes() {
schedule(() => { schedule(() => {
void import('./DashboardPage'); void import('./DashboardPage');
void import('./EventsPage'); void import('./EventsPage');
void import('./EventPhotosPage'); void import('./EventControlRoomPage');
void import('./EventTasksPage'); void import('./EventTasksPage');
void import('./NotificationsPage'); void import('./NotificationsPage');
void import('./ProfilePage'); void import('./ProfilePage');

View File

@@ -19,18 +19,20 @@ export const ADMIN_COLORS = {
}; };
export const ADMIN_ACTION_COLORS = { export const ADMIN_ACTION_COLORS = {
tasks: '#FF8A8E', settings: '#14B8A6',
qr: ADMIN_COLORS.warning, tasks: '#F59E0B',
images: ADMIN_COLORS.accent, qr: '#3B82F6',
guests: ADMIN_COLORS.success, images: '#8B5CF6',
guestMessages: ADMIN_COLORS.primary, liveShow: '#EC4899',
invites: ADMIN_COLORS.primaryStrong, liveShowSettings: '#0EA5E9',
branding: ADMIN_COLORS.accent, guests: '#10B981',
photobooth: '#FF8A8E', guestMessages: '#F97316',
recap: ADMIN_COLORS.warning, branding: '#6366F1',
photobooth: '#E11D48',
recap: '#64748B',
packages: ADMIN_COLORS.primary, packages: ADMIN_COLORS.primary,
analytics: '#8b5cf6', analytics: '#22C55E',
settings: ADMIN_COLORS.success, invites: ADMIN_COLORS.primaryStrong,
}; };
export const ADMIN_GRADIENTS = { export const ADMIN_GRADIENTS = {

View File

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

View File

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

View File

@@ -17,15 +17,15 @@ return [
'packages' => [ 'packages' => [
'event_tier_unavailable' => [ 'event_tier_unavailable' => [
'title' => 'Selected 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' => [ 'event_limit_exceeded' => [
'title' => 'Event-Kontingent depleted', 'title' => 'Event-Bundle depleted',
'message' => 'Your current Event-Kontingent has no remaining events. Purchase another Event-Kontingent to create new events.', 'message' => 'Your current Event-Bundle has no remaining events. Purchase another Event-Bundle to create new events.',
], ],
'event_limit_missing' => [ 'event_limit_missing' => [
'title' => 'No package assigned', 'title' => 'No package assigned',
'message' => 'Purchase an Event-Kontingent to create events.', 'message' => 'Purchase an Event-Bundle to create events.',
], ],
'event_not_found' => [ 'event_not_found' => [
'title' => 'Event not accessible', 'title' => 'Event not accessible',

View File

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

View File

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

View File

@@ -119,14 +119,14 @@ test.describe('Tenant admin add-on upgrades', () => {
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ 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', checkout_id: 'chk_addon_1',
expires_at: new Date(Date.now() + 600000).toISOString(), 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(); await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible();

View File

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