diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 27a4fd1..9188a35 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,3 @@ -{"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"} {"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-02T14:18:31.676816348+01:00","closed_at":"2026-01-02T14:18:31.676816348+01:00","close_reason":"Closed"} {"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"} {"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"} @@ -74,6 +73,7 @@ {"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"} {"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"} {"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"} +{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"} {"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"} diff --git a/.beads/last-touched b/.beads/last-touched index 7159d8d..4f57549 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-6yz +fotospiel-app-de7 diff --git a/playwright-report/data/0c405bc8e3fa9cf3ac19706420f2cff9cc5fa21f.webm b/playwright-report/data/0c405bc8e3fa9cf3ac19706420f2cff9cc5fa21f.webm new file mode 100644 index 0000000..e640923 Binary files /dev/null and b/playwright-report/data/0c405bc8e3fa9cf3ac19706420f2cff9cc5fa21f.webm differ diff --git a/playwright-report/data/106ad501bf644b8de89f3f73c8dadc522be08667.webm b/playwright-report/data/106ad501bf644b8de89f3f73c8dadc522be08667.webm new file mode 100644 index 0000000..10576d3 Binary files /dev/null and b/playwright-report/data/106ad501bf644b8de89f3f73c8dadc522be08667.webm differ diff --git a/playwright-report/data/2820ac79cd269bf2de2343063af540ebe3c9da54.webm b/playwright-report/data/2820ac79cd269bf2de2343063af540ebe3c9da54.webm new file mode 100644 index 0000000..87d2a47 Binary files /dev/null and b/playwright-report/data/2820ac79cd269bf2de2343063af540ebe3c9da54.webm differ diff --git a/playwright-report/data/52376ac7632cafbeedc0c0df348caadc7851f2e6.webm b/playwright-report/data/52376ac7632cafbeedc0c0df348caadc7851f2e6.webm new file mode 100644 index 0000000..0a4712e Binary files /dev/null and b/playwright-report/data/52376ac7632cafbeedc0c0df348caadc7851f2e6.webm differ diff --git a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png new file mode 100644 index 0000000..6d360f6 Binary files /dev/null and b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png differ diff --git a/playwright-report/data/86ac33f2fa804f2ef383501b7a99af2a30842591.webm b/playwright-report/data/86ac33f2fa804f2ef383501b7a99af2a30842591.webm new file mode 100644 index 0000000..7f1e68e Binary files /dev/null and b/playwright-report/data/86ac33f2fa804f2ef383501b7a99af2a30842591.webm differ diff --git a/playwright-report/data/c4c448de3e13c98b25e0c6aa86e1ba7198048621.webm b/playwright-report/data/c4c448de3e13c98b25e0c6aa86e1ba7198048621.webm new file mode 100644 index 0000000..c91b84a Binary files /dev/null and b/playwright-report/data/c4c448de3e13c98b25e0c6aa86e1ba7198048621.webm differ diff --git a/playwright-report/index.html b/playwright-report/index.html index f3ac1d9..357ab74 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 668eb24..2231d6c 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -82,7 +82,7 @@ "packages": { "title": "Our Packages", "features": "Features", - "subscription_annual": "Event kontingent", + "subscription_annual": "Event bundle", "auto_renew": "auto-renew", "cancel_anytime": "cancel anytime", "trial_start": "Free Trial for :days days", @@ -99,10 +99,10 @@ "tab_endcustomer": "End Customers", "tab_reseller": "Partner / Agency", "section_endcustomer": "Packages for End Customers (One-time purchase per event)", - "section_reseller": "Packages for Partner / Agencies (Event kontingent)", + "section_reseller": "Packages for Partner / Agencies (Event bundle)", "free": "Free", "one_time": "One-time purchase", - "subscription": "Event kontingent", + "subscription": "Event bundle", "year": "Year", "max_photos": "Photos", "max_guests": "Guests", @@ -112,7 +112,7 @@ "recommended_usage_label": "Recommendation", "recommended_usage_window": "Recommended to use within 24 months.", "buy_now": "Buy Now", - "subscribe_now": "Buy event kontingent", + "subscribe_now": "Buy event bundle", "register_buy": "Register and Buy", "register_subscribe": "Register and buy", "faq_title": "Frequently Asked Questions about Packages", @@ -151,7 +151,7 @@ "badge_starter": "Perfect Starter", "billing_per_event": "per event", "billing_per_year": "per year", - "billing_per_kontingent": "per bundle", + "billing_per_bundle": "per bundle", "more_features": "+{{count}} more features", "feature_overview": "Feature overview", "order_hint": "Launch instantly – secure Paddle checkout, no hidden fees.", @@ -342,7 +342,7 @@ "purchase_complete_desc": "Log in to continue.", "login": "Log In", "no_account": "No Account? Register", - "manage_subscription": "Manage kontingent", + "manage_subscription": "Manage bundle", "stripe_dashboard": "Stripe Dashboard", "trial_activated": "Trial activated for 14 days!" }, @@ -485,7 +485,7 @@ "summary_title": "Your order", "package_label": "Selected package", "billing_type_one_time": "One-time purchase (per event)", - "billing_type_subscription": "One-time purchase (kontingent)", + "billing_type_subscription": "One-time purchase (bundle)", "legal_links_intro": "Details on the withdrawal policy:", "link_terms": "Terms & Conditions", "link_privacy": "Privacy Policy", diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index d7bee77..664e68f 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -28,7 +28,6 @@ export const ADMIN_EVENT_CREATE_PATH = adminPath('/mobile/events/new'); export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/edit`); -export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photos`); export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/members`); export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/tasks`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/qr`); @@ -40,3 +39,5 @@ export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/m export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`); export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show/settings`); +export const ADMIN_EVENT_CONTROL_ROOM_PATH = (slug: string): string => + adminPath(`/mobile/events/${encodeURIComponent(slug)}/control-room`); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index dc77e10..bc4ffa7 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1872,6 +1872,8 @@ "tasks": "Aufgaben & Checklisten", "qr": "QR-Code-Layouts", "images": "Bildverwaltung", + "liveShow": "Live-Show-Warteschlange", + "liveShowSettings": "Live-Show Einstellungen", "guests": "Gästeverwaltung", "branding": "Branding & Design", "moderation": "Foto-Moderation", @@ -2197,7 +2199,7 @@ "custom_branding": "Benutzerdefiniertes Branding", "custom_tasks": "Individuelle Aufgaben", "unlimited_sharing": "Unbegrenztes Sharing", - "analytics": "Analytics", + "analytics": "Statistiken", "advanced_reporting": "Erweitertes Reporting", "live_slideshow": "Live-Slideshow", "basic_uploads": "Gäste-Uploads", @@ -2206,7 +2208,7 @@ "prints": "Print-Uploads", "photo_likes_enabled": "Foto-Likes", "event_checklist": "Event-Checkliste", - "advanced_analytics": "Erweiterte Analytics", + "advanced_analytics": "Erweiterte Statistiken", "branding_allowed": "Branding", "watermark_allowed": "Wasserzeichen" }, @@ -2235,6 +2237,7 @@ "shortcutInvites": "Team-/Helfer-Einladungen", "shortcutSettings": "Event-Einstellungen", "shortcutBranding": "Branding & Moderation", + "shortcutAnalytics": "Statistiken", "kpiTitle": "Wichtigste Kennzahlen", "kpiTasks": "Offene Tasks", "kpiPhotos": "Fotos", @@ -2336,6 +2339,16 @@ "notEligible": "Nicht zulässig", "actionFailed": "Live-Show-Aktion fehlgeschlagen." }, + "controlRoom": { + "title": "Moderation & Live-Show", + "subtitle": "Uploads prüfen und Live-Slideshow steuern.", + "tabs": { + "moderation": "Moderation", + "live": "Live-Show" + }, + "emptyModeration": "Keine Uploads passen zu diesem Filter.", + "emptyLive": "Keine Fotos für die Live-Show in der Warteschlange." + }, "liveShowSettings": { "title": "Live-Show Einstellungen", "subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.", @@ -2516,6 +2529,7 @@ "tasks": "Aufgaben & Checklisten", "qr": "QR-Code-Layouts", "images": "Bildverwaltung", + "controlRoom": "Moderation & Live-Show", "guests": "Gästeverwaltung", "guestMessages": "Gästebenachrichtigungen", "branding": "Branding & Design", @@ -3037,7 +3051,7 @@ } }, "analytics": { - "title": "Analytics", + "title": "Statistiken", "upgradeAction": "Upgrade auf Premium", "kpiTitle": "Event-Überblick", "kpiUploads": "Uploads", @@ -3058,7 +3072,7 @@ "tasksTitle": "Beliebte Aufgaben", "noTasks": "Noch keine Aufgabenaktivität", "emptyActionOpenTasks": "Aufgaben öffnen", - "lockedTitle": "Analytics freischalten", + "lockedTitle": "Statistiken freischalten", "lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket." }, "shop": { @@ -3122,7 +3136,7 @@ "days_other": "{{count}} Tage Galerie" }, "features": { - "advanced_analytics": "Erweiterte Analytics", + "advanced_analytics": "Erweiterte Statistiken", "basic_uploads": "Basis-Uploads", "custom_branding": "Eigenes Branding", "custom_tasks": "Benutzerdefinierte Aufgaben", diff --git a/resources/js/admin/i18n/locales/de/mobile.json b/resources/js/admin/i18n/locales/de/mobile.json index 0afe334..a1237be 100644 --- a/resources/js/admin/i18n/locales/de/mobile.json +++ b/resources/js/admin/i18n/locales/de/mobile.json @@ -30,6 +30,6 @@ "queueTitle": "Foto-Aktionen warten", "queueBodyOnline": "{{count}} Aktionen bereit zur Synchronisierung.", "queueBodyOffline": "{{count}} Aktionen offline gespeichert.", - "queueAction": "Fotos öffnen" + "queueAction": "Moderation öffnen" } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index ea883a5..b2ed61f 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -74,8 +74,8 @@ }, "errors": { "generic": "Something went wrong. Please try again.", - "eventLimit": "Your current package has no remaining event kontingent.", - "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the kontingent.", + "eventLimit": "Your current package has no remaining event bundle.", + "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining in the bundle.", "photoLimit": "This event reached its photo upload limit.", "goToBilling": "Manage subscription" }, @@ -194,7 +194,7 @@ "title": "Partner Start", "badge": "For agencies", "highlight": "Manage multiple events", - "p1": "Up to 5 events per kontingent", + "p1": "Up to 5 events per bundle", "p2": "Task collections and templates", "p3": "Team roles & permissions" } @@ -208,7 +208,7 @@ }, "resellers": { "title": "Partner / Agencies", - "description": "Track multiple events, monitor kontingent and reuse templates." + "description": "Track multiple events, monitor bundle and reuse templates." }, "cta": "Just a few clicks to go live" }, diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index bd4d61c..1bfc60e 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -32,8 +32,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Event kontingent", - "lowCredits": "Add kontingent soon" + "credits": "Event bundle", + "lowCredits": "Add bundle soon" } }, "liveNow": { @@ -238,8 +238,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Event kontingent", - "lowCredits": "Add kontingent soon" + "credits": "Event bundle", + "lowCredits": "Add bundle soon" } }, "quickActions": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 592cda5..7e53ee8 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -90,7 +90,7 @@ }, "warnings": { "noEvents": "Event allowance exhausted. Please upgrade or renew your package.", - "lowEvents": "Only {{remaining}} events remaining in the kontingent.", + "lowEvents": "Only {{remaining}} events remaining in the bundle.", "expiresSoon": "Package expires on {{date}}.", "expired": "Package has expired." } @@ -108,7 +108,7 @@ "expires": "Expires", "warnings": { "noEvents": "Event allowance exhausted.", - "lowEvents": "Only {{remaining}} events remaining in the kontingent.", + "lowEvents": "Only {{remaining}} events remaining in the bundle.", "expiresSoon": "Expires on {{date}}.", "expired": "Package has expired." } @@ -1556,12 +1556,12 @@ "title": "Notification overview", "channel": "Email channel", "channelCopy": "All warnings are delivered via email.", - "credits": "Event kontingent", + "credits": "Event bundle", "threshold": "Warning at {{count}} remaining events" }, "meta": { - "creditLast": "Last kontingent warning: {{date}}", - "creditNever": "No kontingent warning sent yet." + "creditLast": "Last bundle warning: {{date}}", + "creditNever": "No bundle warning sent yet." }, "items": { "photoThresholds": { @@ -1876,6 +1876,8 @@ "tasks": "Tasks & checklists", "qr": "QR code layouts", "images": "Image management", + "liveShow": "Live show queue", + "liveShowSettings": "Live show settings", "guests": "Guest management", "branding": "Branding & theme", "moderation": "Photo moderation", @@ -2239,6 +2241,7 @@ "shortcutInvites": "Team / helper invites", "shortcutSettings": "Event settings", "shortcutBranding": "Branding & moderation", + "shortcutAnalytics": "Analytics", "kpiTitle": "Key performance indicators", "kpiTasks": "Open tasks", "kpiPhotos": "Photos", @@ -2340,6 +2343,16 @@ "notEligible": "Not eligible", "actionFailed": "Live Show update failed." }, + "controlRoom": { + "title": "Moderation & Live Show", + "subtitle": "Review uploads and manage the live slideshow.", + "tabs": { + "moderation": "Moderation", + "live": "Live Show" + }, + "emptyModeration": "No uploads match this filter.", + "emptyLive": "No photos waiting for Live Show." + }, "liveShowSettings": { "title": "Live Show settings", "subtitle": "Tune the playback, pacing, and effects shown on the screen.", @@ -2520,6 +2533,7 @@ "tasks": "Tasks & checklists", "qr": "QR code layouts", "images": "Image management", + "controlRoom": "Moderation & Live Show", "guests": "Guest management", "guestMessages": "Guest messages", "branding": "Branding & theme", @@ -2911,7 +2925,7 @@ "max_guests": "Guests", "max_tasks": "Tasks", "gallery_days": "Gallery days", - "max_events_per_year": "Event kontingent" + "max_events_per_year": "Event bundle" }, "mobileEvents": { "edit": "Edit event" @@ -3069,13 +3083,13 @@ "title": "Upgrade Package", "subtitle": "Choose a package to unlock more features and limits.", "partner": { - "title": "Buy event kontingent", - "subtitle": "Buy event kontingents to run multiple events with our services.", + "title": "Buy event bundle", + "subtitle": "Buy event bundles to run multiple events with our services.", "buy": "Buy", "unavailable": "Unavailable", "confirmSubtitle": "You're buying:", "includedTier": "Included event tier: {{tier}}", - "eventsIncluded": "{{count}} events in kontingent", + "eventsIncluded": "{{count}} events in bundle", "recommendedUsage": "Recommended to use within 24 months.", "tiers": { "starter": "Starter", @@ -3085,7 +3099,7 @@ "compare": { "rows": { "includedTier": "Included event tier", - "events": "Events in kontingent" + "events": "Events in bundle" }, "values": { "unknown": "—" diff --git a/resources/js/admin/i18n/locales/en/mobile.json b/resources/js/admin/i18n/locales/en/mobile.json index 0717d20..b7656c0 100644 --- a/resources/js/admin/i18n/locales/en/mobile.json +++ b/resources/js/admin/i18n/locales/en/mobile.json @@ -30,6 +30,6 @@ "queueTitle": "Photo actions pending", "queueBodyOnline": "{{count}} actions ready to sync.", "queueBodyOffline": "{{count}} actions saved offline.", - "queueAction": "Open Photos" + "queueAction": "Open moderation" } } diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index e0531a1..b97916f 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -41,7 +41,7 @@ "ctaList": { "choosePackage": { "label": "Choose your package", - "description": "Reserve event kontingent or packages to activate events instantly. Flexible options for any event size.", + "description": "Reserve event bundle or packages to activate events instantly. Flexible options for any event size.", "button": "Continue to packages" }, "createEvent": { @@ -61,7 +61,7 @@ "steps": { "package": { "title": "Secure your package", - "hint": "Event kontingent or a package is required before guests go live." + "hint": "Event bundle or a package is required before guests go live." }, "invite": { "title": "Invite your co-hosts", @@ -77,10 +77,10 @@ "layout": { "eyebrow": "Step 2", "title": "Choose your package", - "subtitle": "Fotospiel supports flexible pricing: single event packages or kontingent for multiple events." + "subtitle": "Fotospiel supports flexible pricing: single event packages or bundle for multiple events." }, "step": { - "title": "Activate the right event kontingent", + "title": "Activate the right event bundle", "description": "Secure capacity for your next event. Upgrade at any time – only pay for what you need." }, "state": { @@ -92,7 +92,7 @@ }, "card": { "subscription": "Subscription", - "creditPack": "Event kontingent", + "creditPack": "Event bundle", "description": "Ready for your next event right away.", "descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.", "active": "Active package", @@ -151,7 +151,7 @@ }, "details": { "subscription": "Subscription", - "creditPack": "Event kontingent", + "creditPack": "Event bundle", "photos": "Up to {{count}} photos", "galleryDays": "{{count}} gallery days", "guests": "{{count}} guests", @@ -188,7 +188,7 @@ "activate": "Activate free package", "progress": "Activating …", "successTitle": "Free package activated", - "successDescription": "Event kontingent added. Continue with the setup.", + "successDescription": "Event bundle added. Continue with the setup.", "failureTitle": "Activation failed", "errorMessage": "The free package could not be activated." }, @@ -205,12 +205,12 @@ "nextSteps": [ "Optional: finish billing via Paddle inside the billing area.", "Complete the event setup and configure tasks, team, and gallery.", - "Check your event kontingent before go-live and share your guest link." + "Check your event bundle before go-live and share your guest link." ], "cta": { "billing": { "label": "Start billing", - "description": "Opens the billing area with Paddle kontingent options.", + "description": "Opens the billing area with Paddle bundle options.", "button": "Go to billing" }, "setup": { diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index 0c6dbe3..28a3d76 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -2,7 +2,7 @@ import type { TenantEvent } from '../api'; import { ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH, - ADMIN_EVENT_PHOTOS_PATH, + ADMIN_EVENT_CONTROL_ROOM_PATH, ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_RECAP_PATH, @@ -47,7 +47,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts { key: 'photos', label: translate('eventMenu.photos', 'Uploads'), - href: ADMIN_EVENT_PHOTOS_PATH(event.slug), + href: ADMIN_EVENT_CONTROL_ROOM_PATH(event.slug), badge: formatBadge(counts.photos), }, { diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 9869df9..e6e721b 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -235,7 +235,7 @@ export default function MobileDashboardPage() { return; } closeTour(); - navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`)); + navigate(adminPath(`/mobile/events/${tourTargetSlug}/control-room`)); }, showAction: Boolean(tourTargetSlug), }, @@ -1223,20 +1223,20 @@ function EventManagementGrid({ icon: ImageIcon, label: t('events.quick.images', 'Image Management'), color: ADMIN_ACTION_COLORS.images, - onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined, + onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, disabled: !slug, }, { icon: Tv, - label: t('events.quick.liveShow', 'Live Show queue'), - color: ADMIN_ACTION_COLORS.images, - onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined, + label: t('events.quick.controlRoom', 'Moderation & Live Show'), + color: ADMIN_ACTION_COLORS.liveShow, + onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, disabled: !slug, }, { icon: Settings, label: t('events.quick.liveShowSettings', 'Live Show settings'), - color: ADMIN_ACTION_COLORS.images, + color: ADMIN_ACTION_COLORS.liveShowSettings, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined, disabled: !slug, }, diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx new file mode 100644 index 0000000..55a8a8b --- /dev/null +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -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; + +function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator { + const defaults: Record = { + 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([]); + const [moderationFilter, setModerationFilter] = React.useState('pending'); + const [moderationPage, setModerationPage] = React.useState(1); + const [moderationHasMore, setModerationHasMore] = React.useState(false); + const [moderationLoading, setModerationLoading] = React.useState(true); + const [moderationError, setModerationError] = React.useState(null); + const [moderationBusyId, setModerationBusyId] = React.useState(null); + const [limits, setLimits] = React.useState(null); + const [catalogAddons, setCatalogAddons] = React.useState([]); + const [busyScope, setBusyScope] = React.useState(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([]); + const [liveStatusFilter, setLiveStatusFilter] = React.useState('pending'); + const [livePage, setLivePage] = React.useState(1); + const [liveHasMore, setLiveHasMore] = React.useState(false); + const [liveLoading, setLiveLoading] = React.useState(true); + const [liveError, setLiveError] = React.useState(null); + const [liveBusyId, setLiveBusyId] = React.useState(null); + + const [queuedActions, setQueuedActions] = React.useState(() => 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 = { + 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 = ( + + { + if (activeTab === 'moderation') { + void loadModeration(); + return; + } + void loadLiveQueue(); + }} + ariaLabel={t('common.refresh', 'Refresh')} + > + + + {slug ? ( + navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))} + ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')} + > + + + ) : null} + + ); + + return ( + + + {([ + { key: 'moderation', label: t('controlRoom.tabs.moderation', 'Moderation') }, + { key: 'live', label: t('controlRoom.tabs.live', 'Live Show') }, + ] as const).map((tab) => ( + setActiveTab(tab.key)} style={{ flex: 1 }}> + + + {tab.label} + + + + ))} + + + {activeTab === 'moderation' ? ( + + {queuedEventCount > 0 ? ( + + + + + {t('mobilePhotos.queueTitle', 'Changes waiting to sync')} + + + {online + ? t('mobilePhotos.queueOnline', '{{count}} actions ready to sync.', { count: queuedEventCount }) + : t('mobilePhotos.queueOffline', '{{count}} actions saved offline.', { count: queuedEventCount })} + + + syncQueuedActions()} + tone="ghost" + fullWidth={false} + disabled={!online} + loading={syncingQueue} + /> + + + ) : null} + + + + setModerationFilter(event.target.value as ModerationFilter)} + > + {MODERATION_FILTERS.map((option) => ( + + ))} + + + + + {!moderationLoading ? ( + + ) : null} + + {moderationError ? ( + + + {moderationError} + + + ) : null} + + {moderationLoading && moderationPage === 1 ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} + + ) : moderationPhotos.length === 0 ? ( + + + + {t('controlRoom.emptyModeration', 'No uploads match this filter.')} + + + ) : ( + + {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 ( + + + {photo.thumbnail_url ? ( + {photo.original_name + ) : null} + + + {photo.original_name ?? t('common.photo', 'Photo')} + + + + {resolveGalleryLabel(galleryStatus)} + + + {resolveLiveLabel(liveStatus)} + + + + + + handleModerationAction('approve', photo)} + disabled={!canApprove} + loading={isBusy} + tone="primary" + /> + handleModerationAction(visibilityAction, photo)} + disabled={false} + loading={isBusy} + tone="ghost" + /> + + + ); + })} + + )} + + {moderationHasMore ? ( + + setModerationPage((prev) => prev + 1)} + disabled={moderationLoading} + /> + + ) : null} + + ) : ( + + + + {t( + 'liveShowQueue.galleryApprovedOnly', + 'Gallery and Live Show approvals are separate. Pending photos can be approved here.' + )} + + {!online ? ( + + {t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')} + + ) : null} + + + + + setLiveStatusFilter(event.target.value as LiveShowQueueStatus)} + > + {LIVE_STATUS_OPTIONS.map((option) => ( + + ))} + + + + + {liveError ? ( + + + {liveError} + + + ) : null} + + {liveLoading && livePage === 1 ? ( + + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} + + ) : livePhotos.length === 0 ? ( + + + {t('controlRoom.emptyLive', 'No photos waiting for Live Show.')} + + + ) : ( + + {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 ( + + + {photo.thumbnail_url ? ( + {photo.original_name + ) : null} + + + {photo.original_name ?? t('common.photo', 'Photo')} + + + + {resolveGalleryLabel(galleryStatus)} + + + {resolveLiveLabel(liveStatus)} + + + + + + {showApproveAction ? ( + { + if (approveMode === 'approve-and-live') { + void handleApproveAndLive(photo); + return; + } + if (approveMode === 'approve-only') { + void handleApprove(photo); + } + }} + disabled={!online || !canApproveLive} + loading={isBusy} + tone="primary" + /> + ) : ( + handleClear(photo)} + disabled={!online} + loading={isBusy} + tone="ghost" + /> + )} + {liveStatus !== 'rejected' ? ( + handleReject(photo)} + disabled={!online} + loading={isBusy} + tone="danger" + /> + ) : ( + handleClear(photo)} + disabled={!online} + loading={isBusy} + tone="ghost" + /> + )} + + + ); + })} + + )} + + {liveHasMore ? ( + + setLivePage((prev) => prev + 1)} + disabled={liveLoading} + /> + + ) : null} + + )} + + { + if (consentBusy) return; + setConsentOpen(false); + setConsentTarget(null); + }} + onConfirm={confirmAddonCheckout} + busy={consentBusy} + t={t} + /> + + ); +} diff --git a/resources/js/admin/mobile/EventLiveShowQueuePage.tsx b/resources/js/admin/mobile/EventLiveShowQueuePage.tsx deleted file mode 100644 index f65a4b7..0000000 --- a/resources/js/admin/mobile/EventLiveShowQueuePage.tsx +++ /dev/null @@ -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([]); - const [statusFilter, setStatusFilter] = React.useState('pending'); - const [page, setPage] = React.useState(1); - const [hasMore, setHasMore] = React.useState(false); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [busyId, setBusyId] = React.useState(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 = { - approved: 'Gallery approved', - pending: 'Gallery pending', - rejected: 'Gallery rejected', - hidden: 'Hidden', - }; - const key = status ?? 'pending'; - return t(`liveShowQueue.galleryStatus.${key}`, fallbackMap[key] ?? key); - } - - return ( - loadQueue()} ariaLabel={t('common.refresh', 'Refresh')}> - - - } - > - - - {t( - 'liveShowQueue.galleryApprovedOnly', - 'Gallery and Live Show approvals are separate. Pending photos can be approved here.' - )} - - {!online ? ( - - {t('liveShowQueue.offlineNotice', 'You are offline. Live Show actions are disabled.')} - - ) : null} - - - - - setStatusFilter(event.target.value as LiveShowQueueStatus)} - > - {STATUS_OPTIONS.map((option) => ( - - ))} - - - - - {error ? ( - - - {error} - - - ) : null} - - {loading && page === 1 ? ( - - {Array.from({ length: 4 }).map((_, idx) => ( - - ))} - - ) : photos.length === 0 ? ( - - - {t('liveShowQueue.empty', 'No photos waiting for Live Show.')} - - - ) : ( - - {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 ( - - - {photo.thumbnail_url ? ( - {photo.original_name - ) : null} - - - - {resolveGalleryLabel(galleryStatus)} - - - {t(`liveShowQueue.status.${liveStatus}`, liveStatus)} - - - - {photo.uploaded_at} - - - - - {showApproveAction ? ( - { - if (canApproveGallery) { - void handleApproveAndLive(photo); - return; - } - if (canApproveLiveOnly) { - void handleApprove(photo); - } - }} - disabled={!online || !canApproveLive} - loading={isBusy} - tone="primary" - /> - ) : ( - handleClear(photo)} - disabled={!online} - loading={isBusy} - tone="ghost" - /> - )} - {liveStatus !== 'rejected' ? ( - handleReject(photo)} - disabled={!online} - loading={isBusy} - tone="danger" - /> - ) : ( - handleClear(photo)} - disabled={!online} - loading={isBusy} - tone="ghost" - /> - )} - - - ); - })} - - )} - - {hasMore ? ( - - setPage((prev) => prev + 1)} - disabled={loading} - /> - - ) : null} - - ); -} diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx deleted file mode 100644 index 39efec3..0000000 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ /dev/null @@ -1,1537 +0,0 @@ -import React from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff, Sparkles } 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 { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion'; -import { MobileShell, HeaderActionButton } from './components/MobileShell'; -import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives'; -import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; -import { - getEventPhotos, - getEventPhoto, - updatePhotoVisibility, - featurePhoto, - unfeaturePhoto, - updatePhotoStatus, - TenantPhoto, - EventAddonCatalogItem, - createEventAddonCheckout, - getAddonCatalog, - EventAddonSummary, - EventLimitSummary, - getEvent, -} from '../api'; -import toast from 'react-hot-toast'; -import { isAuthError } from '../auth/tokens'; -import { getApiErrorMessage } from '../lib/apiError'; -import { MobileSheet } from './components/Sheet'; -import { useEventContext } from '../context/EventContext'; -import { useAdminTheme } from './theme'; -import { buildLimitWarnings } from '../lib/limitWarnings'; -import { adminPath } from '../constants'; -import { scopeDefaults, selectAddonKeyForScope } from './addons'; -import { LegalConsentSheet } from './components/LegalConsentSheet'; -import { triggerHaptic } from './lib/haptics'; -import { useOnlineStatus } from './hooks/useOnlineStatus'; -import { useBackNavigation } from './hooks/useBackNavigation'; -import { - enqueuePhotoAction, - loadPhotoQueue, - removePhotoAction, - replacePhotoQueue, - type PhotoModerationAction, -} from './lib/photoModerationQueue'; -import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe'; -import { resolveLightboxSources } from './lib/lightboxImage'; - -type FilterKey = 'all' | 'featured' | 'hidden' | 'pending'; - -export default function MobileEventPhotosPage() { - const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>(); - const { activeEvent, selectEvent } = useEventContext(); - const slug = slugParam ?? activeEvent?.slug ?? null; - const navigate = useNavigate(); - const location = useLocation(); - const { t } = useTranslation('management'); - - const [photos, setPhotos] = React.useState([]); - const [filter, setFilter] = React.useState('all'); - const [page, setPage] = React.useState(1); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [busyId, setBusyId] = React.useState(null); - const [totalCount, setTotalCount] = React.useState(0); - const [hasMore, setHasMore] = React.useState(false); - const [search, setSearch] = React.useState(''); - const [showFilters, setShowFilters] = React.useState(false); - const [uploaderFilter, setUploaderFilter] = React.useState(''); - const [onlyFeatured, setOnlyFeatured] = React.useState(false); - const [onlyHidden, setOnlyHidden] = React.useState(false); - const [lightboxId, setLightboxId] = React.useState(null); - const [lightboxImageSrc, setLightboxImageSrc] = React.useState(null); - const [pendingPhotoId, setPendingPhotoId] = React.useState(null); - const [syncingQueue, setSyncingQueue] = React.useState(false); - const [selectionMode, setSelectionMode] = React.useState(false); - const [selectedIds, setSelectedIds] = React.useState([]); - const [bulkBusy, setBulkBusy] = React.useState(false); - const [limits, setLimits] = React.useState(null); - const [catalogAddons, setCatalogAddons] = React.useState([]); - const [eventAddons, setEventAddons] = React.useState([]); - const [busyScope, setBusyScope] = React.useState(null); - const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); - 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 [queuedActions, setQueuedActions] = React.useState(() => loadPhotoQueue()); - const online = useOnlineStatus(); - const syncingQueueRef = React.useRef(false); - const { text, muted, border, accentSoft, accent, danger, surface, backdrop, primary } = useAdminTheme(); - const infoBg = accentSoft; - const infoBorder = accent; - - const basePhotosPath = slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/events'); - const photoQuery = React.useMemo(() => { - return new URLSearchParams(location.search).get('photo'); - }, [location.search]); - const sourcePhotoParam = photoQuery ?? photoIdParam ?? null; - const lightboxIndex = React.useMemo(() => { - if (lightboxId === null) { - return -1; - } - return photos.findIndex((photo) => photo.id === lightboxId); - }, [photos, lightboxId]); - const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null; - const parsedPhotoId = React.useMemo(() => { - if (!sourcePhotoParam) { - return null; - } - const parsed = Number(sourcePhotoParam); - return Number.isFinite(parsed) ? parsed : null; - }, [sourcePhotoParam]); - - React.useEffect(() => { - if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) { - setLightboxId(null); - } - }, [lightboxId, lightboxIndex, loading, pendingPhotoId]); - - React.useEffect(() => { - if (lightboxId !== null) { - setSelectionMode(false); - setSelectedIds([]); - } - }, [lightboxId]); - - React.useEffect(() => { - if (!lightbox) { - setLightboxImageSrc(null); - return; - } - const sources = resolveLightboxSources(lightbox); - setLightboxImageSrc(sources.initial); - - if (!sources.full) { - return; - } - - const loader = new Image(); - loader.onload = () => setLightboxImageSrc(sources.full); - loader.src = sources.full; - }, [lightbox]); - - React.useEffect(() => { - if (!sourcePhotoParam) { - setLightboxId(null); - setPendingPhotoId(null); - return; - } - - if (parsedPhotoId === null) { - setPendingPhotoId(null); - return; - } - - setLightboxId(parsedPhotoId); - setPendingPhotoId(parsedPhotoId); - }, [parsedPhotoId, sourcePhotoParam]); - - React.useEffect(() => { - if (slugParam && activeEvent?.slug !== slugParam) { - selectEvent(slugParam); - } - }, [slugParam, activeEvent?.slug, selectEvent]); - - const load = React.useCallback(async () => { - if (!slug) return; - setLoading(true); - setError(null); - try { - const status = - filter === 'hidden' || onlyHidden - ? 'hidden' - : filter === 'pending' - ? 'pending' - : undefined; - const result = await getEventPhotos(slug, { - page, - perPage: 20, - sort: 'desc', - featured: filter === 'featured' || onlyFeatured, - status, - search: search || undefined, - }); - setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos])); - setTotalCount(result.meta?.total ?? result.photos.length); - setLimits(result.limits ?? null); - const lastPage = result.meta?.last_page ?? 1; - setHasMore(page < lastPage); - const [addons, event] = await Promise.all([ - getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]), - getEvent(slug).catch(() => null), - ]); - setCatalogAddons(addons ?? []); - setEventAddons(event?.addons ?? []); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'))); - } - } finally { - setLoading(false); - } - }, [slug, filter, t, page, onlyFeatured, onlyHidden, search]); - - React.useEffect(() => { - void load(); - }, [load]); - - 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.')); - setPage(1); - void load(); - params.delete('addon_success'); - navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); - } - }, [location.search, slug, load, navigate, t, location.pathname]); - - React.useEffect(() => { - setPage(1); - }, [filter, slug]); - - const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { - replacePhotoQueue(queue); - setQueuedActions(queue); - }, []); - - const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { - setPhotos((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' }; - } - - if (action === 'feature') { - return { ...photo, is_featured: true }; - } - - if (action === 'unfeature') { - return { ...photo, is_featured: false }; - } - - 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', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); - triggerHaptic('selection'); - }, - [applyOptimisticUpdate, slug, t], - ); - - const syncQueuedActions = React.useCallback( - async (options?: { silent?: boolean }) => { - 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) { - setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo))); - } - } catch (err) { - if (!options?.silent) { - toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.')); - } - if (isAuthError(err)) { - break; - } - } - } - - updateQueueState(remaining); - setSyncingQueue(false); - syncingQueueRef.current = false; - }, - [online, slug, t, updateQueueState], - ); - - React.useEffect(() => { - if (online) { - void syncQueuedActions({ silent: true }); - } - }, [online, syncQueuedActions]); - - const setLightboxWithUrl = React.useCallback( - (photoId: number | null) => { - setLightboxId(photoId); - if (typeof window === 'undefined' || !slug) { - return; - } - const params = new URLSearchParams(window.location.search); - if (photoId) { - params.set('photo', String(photoId)); - } else { - params.delete('photo'); - } - const nextSearch = params.toString(); - const nextPath = `${basePhotosPath}${nextSearch ? `?${nextSearch}` : ''}`; - if (`${window.location.pathname}${window.location.search}` !== nextPath) { - window.history.replaceState(null, '', nextPath); - } - }, - [basePhotosPath, slug], - ); - - const handleModerationAction = React.useCallback( - async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { - if (!slug) { - return; - } - if (!online) { - enqueueModerationAction(action, photo.id); - return; - } - - setBusyId(photo.id); - - const successMessage = () => { - if (action === 'approve') { - return t('mobilePhotos.approveSuccess', 'Photo approved'); - } - if (action === 'hide') { - return t('mobilePhotos.hideSuccess', 'Photo hidden'); - } - if (action === 'show') { - return t('mobilePhotos.showSuccess', 'Photo shown'); - } - if (action === 'feature') { - return t('mobilePhotos.featureSuccess', 'Als Highlight markiert'); - } - return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'); - }; - - const errorMessage = () => { - if (action === 'approve') { - return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'); - } - if (action === 'hide' || action === 'show') { - return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'); - } - return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'); - }; - - 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 if (action === 'show') { - updated = await updatePhotoVisibility(slug, photo.id, false); - } else if (action === 'feature') { - updated = await featurePhoto(slug, photo.id); - } else { - updated = await unfeaturePhoto(slug, photo.id); - } - - setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); - toast.success(successMessage()); - triggerHaptic(action === 'approve' ? 'success' : 'medium'); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, errorMessage())); - toast.error(errorMessage()); - } - } finally { - setBusyId(null); - } - }, - [enqueueModerationAction, online, slug, t], - ); - - React.useEffect(() => { - if (!slug || pendingPhotoId === null) { - return; - } - - if (photos.some((photo) => photo.id === pendingPhotoId)) { - setPendingPhotoId(null); - return; - } - - if (loading) { - return; - } - - let active = true; - void (async () => { - try { - const fetched = await getEventPhoto(slug, pendingPhotoId); - if (!active) { - return; - } - setPhotos((prev) => { - if (prev.some((photo) => photo.id === fetched.id)) { - return prev; - } - return [fetched, ...prev]; - }); - } catch (err) { - if (!isAuthError(err)) { - toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')); - } - if (active) { - setLightboxWithUrl(null); - } - } finally { - if (active) { - setPendingPhotoId(null); - } - } - })(); - - return () => { - active = false; - }; - }, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]); - - async function toggleVisibility(photo: TenantPhoto) { - const action = photo.status === 'hidden' ? 'show' : 'hide'; - await handleModerationAction(action, photo); - } - - async function toggleFeature(photo: TenantPhoto) { - const action = photo.is_featured ? 'unfeature' : 'feature'; - await handleModerationAction(action, photo); - } - - async function approvePhoto(photo: TenantPhoto) { - await handleModerationAction('approve', photo); - } - - const selectedPhotos = React.useMemo( - () => photos.filter((photo) => selectedIds.includes(photo.id)), - [photos, selectedIds], - ); - const hasPendingSelection = selectedPhotos.some((photo) => photo.status === 'pending'); - const hasHiddenSelection = selectedPhotos.some((photo) => photo.status === 'hidden'); - const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden'); - const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured); - const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured); - const queuedEventCount = React.useMemo(() => { - if (!slug) { - return queuedActions.length; - } - return queuedActions.filter((action) => action.eventSlug === slug).length; - }, [queuedActions, slug]); - - function toggleSelection(id: number) { - setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); - } - - function clearSelection() { - setSelectedIds([]); - setSelectionMode(false); - } - - const handleLightboxDragEnd = React.useCallback( - (_event: PointerEvent, info: { offset: { x: number; y: number } }) => { - if (lightboxIndex < 0) { - return; - } - const { x, y } = info.offset; - const absX = Math.abs(x); - const absY = Math.abs(y); - const swipeThreshold = 80; - const dismissThreshold = 90; - - if (absY > absX && y > dismissThreshold) { - setLightboxWithUrl(null); - return; - } - - if (absX > swipeThreshold) { - const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1; - if (nextIndex >= 0 && nextIndex < photos.length) { - setLightboxWithUrl(photos[nextIndex]?.id ?? null); - } - } - }, - [lightboxIndex, photos, setLightboxWithUrl], - ); - - async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') { - if (!slug || bulkBusy || selectedPhotos.length === 0) return; - setBulkBusy(true); - const targets = selectedPhotos.filter((photo) => { - if (action === 'approve') return photo.status === 'pending'; - if (action === 'hide') return photo.status !== 'hidden'; - if (action === 'show') return photo.status === 'hidden'; - if (action === 'feature') return !photo.is_featured; - if (action === 'unfeature') return photo.is_featured; - return false; - }); - if (targets.length === 0) { - setBulkBusy(false); - return; - } - - if (!online) { - let nextQueue: PhotoModerationAction[] = []; - targets.forEach((photo) => { - nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action }); - applyOptimisticUpdate(photo.id, action); - }); - setQueuedActions(nextQueue); - toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); - triggerHaptic('selection'); - setBulkBusy(false); - return; - } - try { - const results = await Promise.allSettled( - targets.map(async (photo) => { - if (action === 'approve') { - return await updatePhotoStatus(slug, photo.id, 'approved'); - } - if (action === 'hide') { - return await updatePhotoVisibility(slug, photo.id, true); - } - if (action === 'show') { - return await updatePhotoVisibility(slug, photo.id, false); - } - if (action === 'feature') { - return await featurePhoto(slug, photo.id); - } - return await unfeaturePhoto(slug, photo.id); - }), - ); - - const updates = results - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') - .map((result) => result.value); - - if (updates.length) { - setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo)); - toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied')); - triggerHaptic('success'); - } - } catch { - toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed')); - } finally { - setBulkBusy(false); - } - } - - 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 any, 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}/photos`)}` : ''; - 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); - } - } - - return ( - - load()} ariaLabel={t('common.refresh', 'Refresh')}> - - - - } - > - {error ? ( - - - {error} - - load()} - /> - - ) : null} - - {queuedEventCount > 0 ? ( - - - - - {t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')} - - - {online - ? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', { - count: queuedEventCount, - }) - : t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert – offline.', { - count: queuedEventCount, - })} - - - syncQueuedActions()} - tone="ghost" - fullWidth={false} - disabled={!online} - loading={syncingQueue} - /> - - - ) : null} - - - { - if (selectionMode) { - clearSelection(); - } else { - setSelectionMode(true); - } - }} - /> - setShowFilters(true)} - /> - - - { - setSearch(e.target.value); - setPage(1); - }} - placeholder={t('photos.filters.search', 'Search uploads …')} - compact - style={{ marginBottom: 12 }} - /> - - - {(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => ( - setFilter(key)} style={{ flex: 1 }}> - - - {key === 'all' - ? t('common.all', 'All') - : key === 'featured' - ? t('photos.filters.featured', 'Featured') - : key === 'pending' - ? t('photos.filters.pending', 'Pending') - : t('photos.filters.hidden', 'Hidden')} - - - - ))} - - - {!loading ? ( - - ) : null} - - {loading ? ( - - {Array.from({ length: 4 }).map((_, idx) => ( - - ))} - - ) : photos.length === 0 ? ( - - - - {t('mobilePhotos.emptyTitle', 'No uploads yet')} - - - {t('mobilePhotos.emptyBody', 'Share the QR code so guests can start uploading photos.')} - - {slug ? ( - navigate(adminPath(`/mobile/events/${slug}/qr`))} - /> - ) : null} - - ) : ( - - - {t('mobilePhotos.count', '{{count}} photos', { count: totalCount })} - -
- {photos.map((photo) => { - const isSelected = selectedIds.includes(photo.id); - const swipeDisabled = selectionMode || busyId === photo.id; - return ( - (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))} - onModerate={(action) => handleModerationAction(action, photo)} - > - - - - {photo.is_featured ? {t('photos.filters.featured', 'Featured')} : null} - {photo.status === 'pending' ? ( - {t('photos.filters.pending', 'Pending')} - ) : null} - {photo.status === 'hidden' ? {t('photos.filters.hidden', 'Hidden')} : null} - - {selectionMode ? ( - - {isSelected ? : null} - - ) : null} - {!selectionMode ? ( - handleModerationAction(action, photo)} - muted={muted} - surface={surface} - /> - ) : null} - - - ); - })} -
- {hasMore ? ( - setPage((prev) => prev + 1)} /> - ) : null} -
- )} - - {selectionMode ? ( - - - - {t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })} - - clearSelection()}> - - {t('common.clear', 'Clear')} - - - - - {hasPendingSelection ? ( - applyBulkAction('approve')} - fullWidth={false} - disabled={bulkBusy} - loading={bulkBusy} - /> - ) : null} - {hasVisibleSelection ? ( - applyBulkAction('hide')} - tone="ghost" - fullWidth={false} - disabled={bulkBusy} - loading={bulkBusy} - /> - ) : null} - {hasHiddenSelection ? ( - applyBulkAction('show')} - tone="ghost" - fullWidth={false} - disabled={bulkBusy} - loading={bulkBusy} - /> - ) : null} - {hasUnfeaturedSelection ? ( - applyBulkAction('feature')} - tone="ghost" - fullWidth={false} - disabled={bulkBusy} - loading={bulkBusy} - /> - ) : null} - {hasFeaturedSelection ? ( - applyBulkAction('unfeature')} - tone="ghost" - fullWidth={false} - disabled={bulkBusy} - loading={bulkBusy} - /> - ) : null} - - - ) : null} - - - {lightbox ? ( - - - - { - if (lightbox?.thumbnail_url && lightboxImageSrc !== lightbox.thumbnail_url) { - setLightboxImageSrc(lightbox.thumbnail_url); - } - }} - /> - - - - {lightbox.uploader_name || t('events.members.roles.guest', 'Guest')} - ❤️ {lightbox.likes_count ?? 0} - {lightbox.status === 'pending' ? ( - {t('photos.filters.pending', 'Pending')} - ) : null} - {lightbox.status === 'hidden' ? ( - {t('photos.filters.hidden', 'Hidden')} - ) : null} - - - {lightbox.status === 'pending' ? ( - approvePhoto(lightbox)} - style={{ flex: 1, minWidth: 140 }} - /> - ) : null} - toggleFeature(lightbox)} - style={{ flex: 1, minWidth: 140 }} - /> - toggleVisibility(lightbox)} - style={{ flex: 1, minWidth: 140 }} - /> - - setLightboxWithUrl(null)} - /> - - - - ) : null} - - - setShowFilters(false)} - title={t('mobilePhotos.filtersTitle', 'Filter')} - footer={ - { - setPage(1); - setShowFilters(false); - void load(); - }} - /> - } - > - - - setUploaderFilter(e.target.value)} - placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')} - compact - /> - - - - - - { - setUploaderFilter(''); - setOnlyFeatured(false); - setOnlyHidden(false); - }} - /> - - - - {eventAddons.length ? ( - - - {t('events.sections.addons.title', 'Add-ons & Upgrades')} - - - - ) : null} - - { - if (consentBusy) return; - setConsentOpen(false); - setConsentTarget(null); - }} - onConfirm={confirmAddonCheckout} - busy={consentBusy} - t={t} - /> -
- ); -} - -type PhotoSwipeCardProps = { - photo: TenantPhoto; - disabled?: boolean; - onOpen: () => void; - onModerate: (action: PhotoModerationAction['action']) => void; - children: React.ReactNode; -}; - -type SwipeActionConfig = { - label: string; - bg: string; - text: string; - icon: typeof Eye; -}; - -function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) { - const { t } = useTranslation('management'); - const { successBg, successText, dangerBg, dangerText, infoBg, infoText } = useAdminTheme(); - const controls = useAnimationControls(); - const dragged = React.useRef(false); - const leftAction = resolvePhotoSwipeAction(photo, 'left'); - const rightAction = resolvePhotoSwipeAction(photo, 'right'); - const canSwipe = !disabled && (leftAction || rightAction); - - const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => { - if (!action) { - return null; - } - if (action === 'approve') { - return { - label: t('photos.actions.approve', 'Approve'), - bg: successBg, - text: successText, - icon: Check, - }; - } - if (action === 'hide') { - return { - label: t('photos.actions.hide', 'Hide'), - bg: dangerBg, - text: dangerText, - icon: EyeOff, - }; - } - return { - label: t('photos.actions.show', 'Show'), - bg: infoBg, - text: infoText, - icon: Eye, - }; - }; - - const leftConfig = resolveActionConfig(leftAction); - const rightConfig = resolveActionConfig(rightAction); - - const handleDrag = (_event: PointerEvent, info: PanInfo) => { - if (!canSwipe) { - return; - } - dragged.current = Math.abs(info.offset.x) > 6; - }; - - const handleDragEnd = (_event: PointerEvent, info: PanInfo) => { - if (!canSwipe) { - return; - } - const swipeThreshold = 64; - if (info.offset.x > swipeThreshold && rightAction) { - onModerate(rightAction); - } else if (info.offset.x < -swipeThreshold && leftAction) { - onModerate(leftAction); - } - dragged.current = false; - void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } }); - }; - - const handlePress = () => { - if (dragged.current) { - dragged.current = false; - return; - } - onOpen(); - }; - - return ( -
- {leftConfig || rightConfig ? ( - - - {rightConfig ? ( - - - - {rightConfig.label} - - - ) : null} - - - {leftConfig ? ( - - - - {leftConfig.label} - - - ) : null} - - - ) : null} - - {children} - -
- ); -} - -type PhotoQuickActionsProps = { - photo: TenantPhoto; - disabled?: boolean; - muted: string; - surface: string; - onAction: (action: PhotoModerationAction['action']) => void; -}; - -function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }: PhotoQuickActionsProps) { - const { t } = useTranslation('management'); - const actionButtons: Array<{ key: PhotoModerationAction['action']; icon: typeof Check; label: string }> = []; - - if (photo.status === 'pending') { - actionButtons.push({ key: 'approve', icon: Check, label: t('photos.actions.approve', 'Approve') }); - actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') }); - } else if (photo.status === 'hidden') { - actionButtons.push({ key: 'show', icon: Eye, label: t('photos.actions.show', 'Show') }); - } else { - actionButtons.push({ key: 'hide', icon: EyeOff, label: t('photos.actions.hide', 'Hide') }); - } - - if (photo.status !== 'hidden') { - actionButtons.push({ - key: photo.is_featured ? 'unfeature' : 'feature', - icon: Sparkles, - label: photo.is_featured ? t('photos.actions.unfeature', 'Remove highlight') : t('photos.actions.feature', 'Set highlight'), - }); - } - - if (actionButtons.length === 0) { - return null; - } - - return ( - - {actionButtons.map((action) => ( - { - event.stopPropagation(); - if (!disabled) { - onAction(action.key); - } - }} - > - - - - - ))} - - ); -} - -type LimitTranslator = (key: string, options?: Record) => string; - -function translateLimits(t: (key: string, defaultValue?: string, options?: Record) => string): LimitTranslator { - const defaults: Record = { - 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 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 ( - - {warnings.map((warning) => ( - - - {warning.message} - - {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') - && resolveAddonOptions(addons, warning.scope).length ? ( - - ) : ( - onCheckout(warning.scope)} - loading={busyScope === warning.scope} - /> - )} - - ))} - - ); -} - -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(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope)); - - React.useEffect(() => { - if (options[0]?.key) { - setSelected(options[0].key); - } - }, [options]); - - if (!options.length) { - return null; - } - - return ( - - setSelected(event.target.value)} - containerStyle={{ flex: 1, minWidth: 0 }} - compact - > - {options.map((addon) => ( - - ))} - - selected && onCheckout(selected)} - loading={busy} - fullWidth={false} - /> - - ); -} - -function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) { - const { border } = useAdminTheme(); - return ( - - {addons.map((addon) => ( - - - - {addon.label ?? addon.key} - - - {addon.status} - - - - {addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'} - - - {addon.extra_photos ? +{addon.extra_photos} photos : null} - {addon.extra_guests ? +{addon.extra_guests} guests : null} - {addon.extra_gallery_days ? +{addon.extra_gallery_days} days : null} - - - ))} - - ); -} diff --git a/resources/js/admin/mobile/UploadsTabPage.tsx b/resources/js/admin/mobile/UploadsTabPage.tsx index 86c87ca..3c9d41d 100644 --- a/resources/js/admin/mobile/UploadsTabPage.tsx +++ b/resources/js/admin/mobile/UploadsTabPage.tsx @@ -18,7 +18,7 @@ export default function MobileUploadsTabPage() { const { text, muted, border, primary } = useAdminTheme(); if (activeEvent?.slug) { - return ; + return ; } if (!hasEvents) { @@ -54,25 +54,25 @@ export default function MobileUploadsTabPage() { onPress={() => { selectEvent(event.slug ?? null); if (event.slug) { - navigate(adminPath(`/mobile/events/${event.slug}/photos`)); + navigate(adminPath(`/mobile/events/${event.slug}/control-room`)); } }} > - - {resolveEventDisplayName(event)} + + {resolveEventDisplayName(event)} + + + {formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')} + + + + {t('mobileUploads.open', 'Open')} - - {formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')} - - - - {t('mobileUploads.open', 'Open')} - - - + + ))} diff --git a/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx b/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx index 338f102..9d03e5f 100644 --- a/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx +++ b/resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { render } from '@testing-library/react'; -import { LimitWarnings } from '../EventPhotosPage'; +import { LimitWarnings } from '../components/LimitWarnings'; vi.mock('@tamagui/stacks', () => ({ YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/resources/js/admin/mobile/components/LimitWarnings.tsx b/resources/js/admin/mobile/components/LimitWarnings.tsx new file mode 100644 index 0000000..227629c --- /dev/null +++ b/resources/js/admin/mobile/components/LimitWarnings.tsx @@ -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; + +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 ( + + {warnings.map((warning) => ( + + + {warning.message} + + {(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') + && resolveAddonOptions(addons, warning.scope).length ? ( + + ) : ( + onCheckout(warning.scope)} + loading={busyScope === warning.scope} + /> + )} + + ))} + + ); +} + +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(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope)); + + React.useEffect(() => { + if (options[0]?.key) { + setSelected(options[0].key); + } + }, [options]); + + if (!options.length) { + return null; + } + + return ( + + setSelected(event.target.value)} + containerStyle={{ flex: 1, minWidth: 0 }} + compact + > + {options.map((addon) => ( + + ))} + + selected && onCheckout(selected)} + loading={busy} + fullWidth={false} + /> + + ); +} diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 0f6005c..e13ddc5 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -281,10 +281,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head {effectiveActive?.slug ? ( navigate(adminPath(`/mobile/events/${effectiveActive.slug}/photos`))} + onPress={() => navigate(adminPath(`/mobile/events/${effectiveActive.slug}/control-room`))} /> ) : null} diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index 61b13b9..68ad253 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -193,6 +193,15 @@ export function ActionTile({ delayMs?: number; }) { const { textStrong } = useAdminTheme(); + const backgroundColor = `${color}18`; + const borderColor = `${color}40`; + const shadowColor = `${color}2b`; + const iconShadow = `${color}55`; + const tileStyle = { + ...(delayMs ? { animationDelay: `${delayMs}ms` } : {}), + backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`, + boxShadow: `0 10px 24px ${shadowColor}`, + }; return ( - + diff --git a/resources/js/admin/mobile/lib/controlRoom.test.ts b/resources/js/admin/mobile/lib/controlRoom.test.ts new file mode 100644 index 0000000..421350e --- /dev/null +++ b/resources/js/admin/mobile/lib/controlRoom.test.ts @@ -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'); + }); +}); diff --git a/resources/js/admin/mobile/lib/controlRoom.ts b/resources/js/admin/mobile/lib/controlRoom.ts new file mode 100644 index 0000000..431e851 --- /dev/null +++ b/resources/js/admin/mobile/lib/controlRoom.ts @@ -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'; +} diff --git a/resources/js/admin/mobile/lib/tabHistory.test.ts b/resources/js/admin/mobile/lib/tabHistory.test.ts index 7878fb7..75cf683 100644 --- a/resources/js/admin/mobile/lib/tabHistory.test.ts +++ b/resources/js/admin/mobile/lib/tabHistory.test.ts @@ -23,9 +23,9 @@ describe('tabHistory', () => { }); it('reuses stored event route when slug matches', () => { - setTabHistory('uploads', adminPath('/mobile/events/summer/photos')); + setTabHistory('uploads', adminPath('/mobile/events/summer/control-room')); const target = resolveTabTarget('uploads', 'summer'); - expect(target).toBe(adminPath('/mobile/events/summer/photos')); + expect(target).toBe(adminPath('/mobile/events/summer/control-room')); }); it('falls back to active slug when stored slug differs', () => { diff --git a/resources/js/admin/mobile/lib/tabHistory.ts b/resources/js/admin/mobile/lib/tabHistory.ts index fb18e11..b34663b 100644 --- a/resources/js/admin/mobile/lib/tabHistory.ts +++ b/resources/js/admin/mobile/lib/tabHistory.ts @@ -20,7 +20,7 @@ function readHistory(): TabHistory { return { paths: {}, updatedAt: 0 }; } const parsed = JSON.parse(raw) as TabHistory; - + // Check for expiry if (Date.now() - parsed.updatedAt > EXPIRY_MS) { window.localStorage.removeItem(STORAGE_KEY); @@ -61,7 +61,7 @@ export function resolveDefaultTarget(key: NavKey, slug?: string | null): string return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks'); } if (key === 'uploads') { - return slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/uploads'); + return slug ? adminPath(`/mobile/events/${slug}/control-room`) : adminPath('/mobile/uploads'); } if (key === 'profile') { return adminPath('/mobile/profile'); @@ -78,7 +78,7 @@ function resolveEventScopedTarget(path: string, slug: string | null | undefined, return path; } - const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|photos)(?:\/.*)?$/); + const match = path.match(/\/event-admin\/mobile\/events\/([^/]+)\/(tasks|control-room)(?:\/.*)?$/); if (!match) { return resolveDefaultTarget(key, slug); } diff --git a/resources/js/admin/mobile/prefetch.ts b/resources/js/admin/mobile/prefetch.ts index 91594d0..245f4ad 100644 --- a/resources/js/admin/mobile/prefetch.ts +++ b/resources/js/admin/mobile/prefetch.ts @@ -12,7 +12,7 @@ export function prefetchMobileRoutes() { schedule(() => { void import('./DashboardPage'); void import('./EventsPage'); - void import('./EventPhotosPage'); + void import('./EventControlRoomPage'); void import('./EventTasksPage'); void import('./NotificationsPage'); void import('./ProfilePage'); diff --git a/resources/js/admin/mobile/theme.ts b/resources/js/admin/mobile/theme.ts index fa3a4e2..b396d09 100644 --- a/resources/js/admin/mobile/theme.ts +++ b/resources/js/admin/mobile/theme.ts @@ -19,18 +19,20 @@ export const ADMIN_COLORS = { }; export const ADMIN_ACTION_COLORS = { - tasks: '#FF8A8E', - qr: ADMIN_COLORS.warning, - images: ADMIN_COLORS.accent, - guests: ADMIN_COLORS.success, - guestMessages: ADMIN_COLORS.primary, - invites: ADMIN_COLORS.primaryStrong, - branding: ADMIN_COLORS.accent, - photobooth: '#FF8A8E', - recap: ADMIN_COLORS.warning, + settings: '#14B8A6', + tasks: '#F59E0B', + qr: '#3B82F6', + images: '#8B5CF6', + liveShow: '#EC4899', + liveShowSettings: '#0EA5E9', + guests: '#10B981', + guestMessages: '#F97316', + branding: '#6366F1', + photobooth: '#E11D48', + recap: '#64748B', packages: ADMIN_COLORS.primary, - analytics: '#8b5cf6', - settings: ADMIN_COLORS.success, + analytics: '#22C55E', + invites: ADMIN_COLORS.primaryStrong, }; export const ADMIN_GRADIENTS = { diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 699bc91..e089b3c 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -26,8 +26,7 @@ const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage')); const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage')); const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCustomizePage')); const MobileEventGuestNotificationsPage = React.lazy(() => import('./mobile/EventGuestNotificationsPage')); -const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage')); -const MobileEventLiveShowQueuePage = React.lazy(() => import('./mobile/EventLiveShowQueuePage')); +const MobileEventControlRoomPage = React.lazy(() => import('./mobile/EventControlRoomPage')); const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventLiveShowSettingsPage')); const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); @@ -186,7 +185,7 @@ export const router = createBrowserRouter([ { path: 'events/:slug', element: `${ADMIN_EVENTS_PATH}/${slug}`} /> }, { path: 'events/:slug/recap', element: `${ADMIN_EVENTS_PATH}/${slug}`} /> }, { path: 'events/:slug/edit', element: `${ADMIN_EVENTS_PATH}/${slug}/edit`} /> }, - { path: 'events/:slug/photos', element: `${ADMIN_EVENTS_PATH}/${slug}/photos`} /> }, + { path: 'events/:slug/photos', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'events/:slug/members', element: `${ADMIN_EVENTS_PATH}/${slug}/members`} /> }, { path: 'events/:slug/tasks', element: `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> }, { path: 'events/:slug/invites', element: `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> }, @@ -201,8 +200,9 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/edit', element: }, { path: 'mobile/events/:slug/qr', element: }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, - { path: 'mobile/events/:slug/photos/:photoId?', element: }, - { path: 'mobile/events/:slug/live-show', element: }, + { path: 'mobile/events/:slug/control-room', element: }, + { path: 'mobile/events/:slug/photos/:photoId?', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, + { path: 'mobile/events/:slug/live-show', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/live-show/settings', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/analytics', element: }, diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 7386d40..c3e530a 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -46,7 +46,7 @@ return [ 'emotion' => 'Emotion', 'event_type' => 'Event Type', 'last_activity' => 'Last activity', - 'credits' => 'Event kontingent', + 'credits' => 'Event bundle', 'settings' => 'Settings', 'join' => 'Join', 'unnamed' => 'Unnamed', @@ -503,7 +503,7 @@ return [ 'heading' => 'Uploads (14 days)', ], 'credit_alerts' => [ - 'low_balance_label' => 'Tenants with low event kontingent', + 'low_balance_label' => 'Tenants with low event bundle', 'low_balance_desc' => 'May require follow-up', 'monthly_revenue_label' => 'Revenue (month)', 'monthly_revenue_desc' => 'Current month (:month)', @@ -532,7 +532,7 @@ return [ 'name' => 'Tenant name', 'slug' => 'Slug', 'contact_email' => 'Contact email', - 'event_credits_balance' => 'Event kontingent', + 'event_credits_balance' => 'Event bundle', 'features' => 'Features', 'total_revenue' => 'Total revenue', 'active_reseller_package' => 'Active partner / agency package', @@ -560,12 +560,12 @@ return [ 'timeline' => 'Audit timeline', ], 'actions' => [ - 'adjust_credits' => 'Adjust kontingent', - 'adjust_credits_delta' => 'Event kontingent delta (positive/negative)', - 'adjust_credits_delta_hint' => 'Positive values add kontingent, negative values deduct it.', + 'adjust_credits' => 'Adjust bundle', + 'adjust_credits_delta' => 'Event bundle delta (positive/negative)', + 'adjust_credits_delta_hint' => 'Positive values add bundle, negative values deduct it.', 'adjust_credits_reason' => 'Internal note', - 'adjust_credits_success_title' => 'Kontingent updated', - 'adjust_credits_success_body' => 'Kontingent changed by :delta. New balance: :balance.', + 'adjust_credits_success_title' => 'Bundle updated', + 'adjust_credits_success_body' => 'Bundle changed by :delta. New balance: :balance.', 'lifecycle' => 'Lifecycle', 'activate' => 'Activate', 'deactivate' => 'Deactivate', @@ -649,7 +649,7 @@ return [ 'fields' => [ 'tenant' => 'Tenant', 'package' => 'Package', - 'credits' => 'Event kontingent', + 'credits' => 'Event bundle', 'price' => 'Price', 'currency' => 'Currency', 'platform' => 'Platform', diff --git a/resources/lang/en/api.php b/resources/lang/en/api.php index ec438e4..411ef44 100644 --- a/resources/lang/en/api.php +++ b/resources/lang/en/api.php @@ -17,15 +17,15 @@ return [ 'packages' => [ 'event_tier_unavailable' => [ 'title' => 'Selected tier unavailable', - 'message' => 'No Event-Kontingent is available for the selected event tier. Choose a different tier or purchase the matching Event-Kontingent.', + 'message' => 'No Event-Bundle is available for the selected event tier. Choose a different tier or purchase the matching Event-Bundle.', ], 'event_limit_exceeded' => [ - 'title' => 'Event-Kontingent depleted', - 'message' => 'Your current Event-Kontingent has no remaining events. Purchase another Event-Kontingent to create new events.', + 'title' => 'Event-Bundle depleted', + 'message' => 'Your current Event-Bundle has no remaining events. Purchase another Event-Bundle to create new events.', ], 'event_limit_missing' => [ 'title' => 'No package assigned', - 'message' => 'Purchase an Event-Kontingent to create events.', + 'message' => 'Purchase an Event-Bundle to create events.', ], 'event_not_found' => [ 'title' => 'Event not accessible', diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index 822e160..bd4b8cb 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -49,13 +49,13 @@ "tab_endcustomer": "End Customers", "tab_reseller": "Partner / Agency", "section_endcustomer": "Packages for End Customers (One-time purchase per Event)", - "section_reseller": "Packages for Partner / Agencies (Event-Kontingent)", + "section_reseller": "Packages for Partner / Agencies (Event-Bundle)", "free": "Free", "one_time": "One-time purchase", "subscription": "One-time purchase", "year": "Year", "billing_per_event": "per event", - "billing_per_kontingent": "per bundle", + "billing_per_bundle": "per bundle", "available": "Available", "not_available": "Not available", "standard_support": "Standard support", @@ -117,7 +117,7 @@ "no_watermark": "No Watermark", "custom_branding": "Custom Branding", "max_tenants": "Max. Tenants", - "max_events": "Events in kontingent", + "max_events": "Events in bundle", "faq_free": "What is the Free Package?", "faq_upgrade": "Can I upgrade?", "faq_reseller": "What for Partner / Agencies?", diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 5e2e9ce..bf4e62b 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -9,17 +9,17 @@ return [ 'tab_endcustomer' => 'End Customers', 'tab_reseller' => 'Partner / Agencies', 'section_endcustomer' => 'Packages for End Customers (One-time purchase per Event)', - 'section_reseller' => 'Packages for Partner / Agencies (Event kontingent)', + 'section_reseller' => 'Packages for Partner / Agencies (Event bundle)', 'free' => 'Free', 'one_time' => 'One-time purchase', - 'subscription' => 'Event kontingent', + 'subscription' => 'Event bundle', 'year' => 'Year', 'max_photos' => 'Photos', 'max_guests' => 'Guests', 'gallery_days' => 'Gallery Days', 'max_events_year' => 'Events included', 'buy_now' => 'Buy Now', - 'subscribe_now' => 'Buy event kontingent', + 'subscribe_now' => 'Buy event bundle', 'register_buy' => 'Register and Buy', 'register_subscribe' => 'Register and buy', 'faq_title' => 'Frequently Asked Questions about Packages', @@ -57,7 +57,7 @@ return [ 'badge_starter' => 'Perfect Starter', 'billing_per_event' => 'per event', 'billing_per_year' => 'per year', - 'billing_per_kontingent' => 'per bundle', + 'billing_per_bundle' => 'per bundle', 'recommended_usage_window' => 'Recommended to use within 24 months.', 'more_features' => '+:count more features', 'max_photos_label' => 'Max. photos', @@ -111,7 +111,7 @@ return [ 'summary_title' => 'Your order', 'package_label' => 'Selected package', 'billing_type_one_time' => 'One-time purchase (per event)', - 'billing_type_subscription' => 'One-time purchase (kontingent)', + 'billing_type_subscription' => 'One-time purchase (bundle)', 'legal_links_intro' => 'By completing your order you accept our', 'link_terms' => 'Terms & Conditions', 'link_privacy' => 'Privacy Policy', diff --git a/tests/ui/admin/event-addon-upgrade.test.ts b/tests/ui/admin/event-addon-upgrade.test.ts index ede2e92..3f54d00 100644 --- a/tests/ui/admin/event-addon-upgrade.test.ts +++ b/tests/ui/admin/event-addon-upgrade.test.ts @@ -119,14 +119,14 @@ test.describe('Tenant admin add-on upgrades', () => { status: 200, contentType: 'application/json', body: JSON.stringify({ - checkout_url: '/event-admin/events/limit-event/photos?addon_success=1', + checkout_url: '/event-admin/mobile/events/limit-event/control-room?addon_success=1', checkout_id: 'chk_addon_1', expires_at: new Date(Date.now() + 600000).toISOString(), }), }); }); - await page.goto('/event-admin/mobile/events/limit-event/photos'); + await page.goto('/event-admin/mobile/events/limit-event/control-room'); await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible(); diff --git a/tests/ui/admin/event-admin-dashboard.test.ts b/tests/ui/admin/event-admin-dashboard.test.ts index 259c5b7..83787be 100644 --- a/tests/ui/admin/event-admin-dashboard.test.ts +++ b/tests/ui/admin/event-admin-dashboard.test.ts @@ -58,8 +58,8 @@ test.describe('Tenant Admin PWA – end-to-end coverage', () => { await page.waitForLoadState('networkidle'); await expect(page.getByText(eventName, { exact: false })).toBeVisible(); - await page.goto(`/event-admin/mobile/events/${createdSlug}/photos`); - await expect(page.getByText(/Foto-Moderation|Photo moderation/i)).toBeVisible(); + await page.goto(`/event-admin/mobile/events/${createdSlug}/control-room`); + await expect(page.getByText(/Moderation & Live Show|Moderation & Live-Show/i)).toBeVisible(); await page.goto(`/event-admin/mobile/events/${createdSlug}/members`); await expect(page.getByText(/Event-Mitglieder|Event members/i)).toBeVisible();