From a35f81705d854f67e7b7c87b3f0c21183144ee17 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 12 Dec 2025 21:47:34 +0100 Subject: [PATCH] further improvements for the mobile admin --- .../Commands/SeedDemoSwitcherTenants.php | 4 - .../Api/Tenant/EventController.php | 15 +- resources/js/admin/DevTenantSwitcher.tsx | 212 ++++++++++-------- resources/js/admin/api.ts | 2 + .../js/admin/i18n/locales/de/common.json | 4 + .../js/admin/i18n/locales/de/management.json | 178 +++++++++++++-- .../js/admin/i18n/locales/en/common.json | 4 + .../js/admin/i18n/locales/en/management.json | 180 +++++++++++++-- resources/js/admin/mobile/DashboardPage.tsx | 206 +++++++++++++++-- resources/js/admin/mobile/EventDetailPage.tsx | 41 ++-- resources/js/admin/mobile/EventFormPage.tsx | 64 +++--- .../js/admin/mobile/EventPhotoboothPage.tsx | 198 +++++++++++++--- resources/js/admin/mobile/SettingsPage.tsx | 73 ++++-- .../admin/mobile/components/MobileShell.tsx | 13 +- .../js/admin/mobile/components/Primitives.tsx | 10 +- 15 files changed, 914 insertions(+), 290 deletions(-) diff --git a/app/Console/Commands/SeedDemoSwitcherTenants.php b/app/Console/Commands/SeedDemoSwitcherTenants.php index 2406162..d6fa9a5 100644 --- a/app/Console/Commands/SeedDemoSwitcherTenants.php +++ b/app/Console/Commands/SeedDemoSwitcherTenants.php @@ -169,7 +169,6 @@ class SeedDemoSwitcherTenants extends Command attributes: [ 'subscription_tier' => 'standard', 'subscription_status' => 'active', - 'event_credits_balance' => 1, ], ); @@ -198,7 +197,6 @@ class SeedDemoSwitcherTenants extends Command attributes: [ 'subscription_tier' => 'starter', 'subscription_status' => 'active', - 'event_credits_balance' => 0, ], ); @@ -229,7 +227,6 @@ class SeedDemoSwitcherTenants extends Command attributes: [ 'subscription_tier' => 'reseller', 'subscription_status' => 'active', - 'event_credits_balance' => 2, ], ); @@ -294,7 +291,6 @@ class SeedDemoSwitcherTenants extends Command attributes: [ 'subscription_tier' => 'reseller', 'subscription_status' => 'active', - 'event_credits_balance' => 0, ], ); diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index c03b7f5..4ec2287 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -139,11 +139,9 @@ class EventController extends Controller unset($eventData['features']); } - if ($settings === [] || $settings === null) { - unset($eventData['settings']); - } else { - $eventData['settings'] = $settings; - } + $settings['branding_allowed'] = $package->branding_allowed !== false; + + $eventData['settings'] = $settings; foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) { unset($eventData[$unused]); @@ -233,6 +231,7 @@ class EventController extends Controller public function update(EventStoreRequest $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); + $event->loadMissing('eventPackage.package'); if ($event->tenant_id !== $tenantId) { return ApiError::response( @@ -259,10 +258,16 @@ class EventController extends Controller unset($validated[$unused]); } + $brandingAllowed = optional($event->eventPackage?->package)->branding_allowed !== false; + if (isset($validated['settings']) && is_array($validated['settings'])) { $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); + } else { + $validated['settings'] = $event->settings ?? []; } + $validated['settings']['branding_allowed'] = $brandingAllowed; + $event->update($validated); $event->load(['eventType', 'tenant']); diff --git a/resources/js/admin/DevTenantSwitcher.tsx b/resources/js/admin/DevTenantSwitcher.tsx index 9786f76..4848b01 100644 --- a/resources/js/admin/DevTenantSwitcher.tsx +++ b/resources/js/admin/DevTenantSwitcher.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; +import { XStack, YStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Button } from '@tamagui/button'; +import { useTheme } from '@tamagui/core'; const DEV_TENANT_KEYS = [ { key: 'cust-standard-empty', label: 'Endkunde – Standard (kein Event)' }, @@ -26,6 +28,7 @@ type DevTenantSwitcherProps = { export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) { const helper = window.fotospielDemoAuth; + const theme = useTheme(); const [loggingIn, setLoggingIn] = React.useState(null); const [collapsed, setCollapsed] = React.useState(() => { if (typeof window === 'undefined') { @@ -59,72 +62,86 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D if (variant === 'inline') { if (collapsed) { return ( - + ); } return ( -
-
-
-
- Demo tenants - Dev mode -
- -
-
- {DEV_TENANT_KEYS.map(({ key, label }) => ( - - ))} -
-
-
+ {loggingIn === key ? 'Verbinde...' : label} + + ))} + + ); } if (collapsed) { return ( - - ); - } + + ); +} async function handleLogin(key: string) { if (!helper) return; @@ -138,51 +155,64 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D } return ( -
-
-
- Demo tenants - Dev mode -
- -
-

+ /> + + Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds. -

-
+ + {DEV_TENANT_KEYS.map(({ key, label }) => ( ))} -
-

- Console: fotospielDemoAuth.loginAs('lumen') -

-
+ + + Console: fotospielDemoAuth.loginAs('lumen') + + ); } diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index d2ee5a7..3ee5630 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -149,6 +149,7 @@ export type PhotoboothStatusMetrics = { uploads_last_hour?: number | null; uploads_today?: number | null; uploads_total?: number | null; + uploads_24h?: number | null; last_upload_at?: string | null; }; @@ -1235,6 +1236,7 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus { uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'), uploads_today: readNumber('uploads_today') ?? readNumber('today'), uploads_total: readNumber('uploads_total') ?? readNumber('total'), + uploads_24h: readNumber('uploads_24h') ?? readNumber('uploads_today') ?? readNumber('today'), last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null, }; } diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index a9e8e8f..7bb7482 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -57,6 +57,10 @@ "de": "Deutsch", "en": "Englisch" }, + "states": { + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + }, "actions": { "open": "Öffnen", "viewAll": "Alle anzeigen", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index b63780f..a3e46ee 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -191,9 +191,36 @@ } }, "events": { + "detail": { + "kpi": { + "tasks": "Aktive Aufgaben", + "guests": "Registrierte Gäste", + "photos": "Hochgeladene Bilder" + }, + "pickEvent": "Event auswählen", + "active": "Aktiv", + "managementTitle": "Event-Verwaltung", + "dateTbd": "Datum folgt", + "locationPlaceholder": "Ort" + }, + "quick": { + "tasks": "Aufgaben & Checklisten", + "qr": "QR-Code-Layouts", + "images": "Bildverwaltung", + "guests": "Gästeverwaltung", + "branding": "Branding & Design", + "photobooth": "Photobooth", + "recap": "Recap & Archiv" + }, + "status": { + "published": "Live", + "draft": "Entwurf", + "archived": "Archiviert" + }, "errors": { "missingSlug": "Kein Event ausgewählt.", "loadFailed": "Event konnte nicht geladen werden.", + "saveFailed": "Event konnte nicht gespeichert werden.", "notFoundTitle": "Event nicht gefunden", "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.", "toggleFailed": "Status konnte nicht angepasst werden.", @@ -221,6 +248,22 @@ "buyMoreGuests": "Mehr Gäste freischalten", "extendGallery": "Galerie verlängern" }, + "form": { + "editTitle": "Event bearbeiten", + "createTitle": "Neues Event erstellen", + "name": "Eventname", + "date": "Datum & Uhrzeit", + "description": "Optionale Details", + "descriptionPlaceholder": "Beschreibung", + "location": "Ort", + "locationPlaceholder": "Ort", + "enableBranding": "Branding & Moderation aktivieren", + "fallbackName": "Event", + "saveDraft": "Als Entwurf speichern", + "saving": "Speichere…", + "update": "Event aktualisieren", + "create": "Event erstellen" + }, "workspace": { "detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.", "toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.", @@ -1531,7 +1574,9 @@ }, "eventForm": { "errors": { - "notice": "Hinweis" + "notice": "Hinweis", + "loadFailed": "Event konnte nicht geladen werden.", + "saveFailed": "Event konnte nicht gespeichert werden." }, "titles": { "create": "Neues Event erstellen", @@ -1548,11 +1593,24 @@ "name": { "label": "Eventname", "placeholder": "z. B. Sommerfest 2025", - "help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert." + "help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert.", + "fallback": "Event" }, "date": { "label": "Datum" }, + "description": { + "label": "Optionale Details", + "placeholder": "Beschreibung" + }, + "location": { + "label": "Ort", + "placeholder": "Ort" + }, + "enableBranding": { + "label": "Branding & Moderation aktivieren", + "locked": "Branding ist in höheren Paketen enthalten. Upgrade, um eigenes Branding zu aktivieren." + }, "type": { "label": "Event-Typ", "loading": "Event-Typ wird geladen…", @@ -1566,9 +1624,12 @@ }, "actions": { "backToList": "Zurück zur Liste", - "saving": "Speichert", + "saving": "Speichert…", "save": "Speichern", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "saveDraft": "Als Entwurf speichern", + "update": "Event aktualisieren", + "create": "Event erstellen" } }, "notifications": { @@ -1609,6 +1670,32 @@ } }, "events": { + "detail": { + "kpi": { + "tasks": "Aktive Aufgaben", + "guests": "Registrierte Gäste", + "photos": "Hochgeladene Bilder" + }, + "pickEvent": "Event auswählen", + "active": "Aktiv", + "managementTitle": "Event-Verwaltung", + "dateTbd": "Datum folgt", + "locationPlaceholder": "Ort" + }, + "quick": { + "tasks": "Aufgaben & Checklisten", + "qr": "QR-Code-Layouts", + "images": "Bildverwaltung", + "guests": "Gästeverwaltung", + "branding": "Branding & Design", + "moderation": "Foto-Moderation", + "recap": "Recap & Archiv" + }, + "status": { + "published": "Live", + "draft": "Entwurf", + "archived": "Archiviert" + }, "errors": { "missingSlug": "Kein Event-Slug angegeben.", "loadFailed": "Tasks konnten nicht geladen werden.", @@ -1664,15 +1751,26 @@ "mobileDashboard": { "title": "Dashboard", "selectEvent": "Wähle ein Event, um fortzufahren", - "emptyTitle": "Erstelle dein erstes Event", - "emptyBody": "Starte ein Event, um Tasks, QR-Poster und Uploads zu verwalten.", + "emptyBadge": "Willkommen!", + "emptyTitle": "Willkommen! Lass uns dein erstes Event starten", + "emptyBody": "Drucke einen QR, sammle Uploads und moderiere in Minuten.", "ctaCreate": "Event erstellen", - "ctaDemo": "Demo ansehen", - "highlightsTitle": "Das kannst du tun", - "highlightImages": "Fotos & Uploads prüfen", - "highlightTasks": "Tasks & Challenges zuweisen", - "highlightQr": "QR-Poster teilen", - "highlightGuests": "Helfer & Gäste einladen", + "emptyChecklistTitle": "Schnelle Schritte bis live", + "emptyChecklistProgress": "{{done}}/{{total}} Schritte", + "emptyStepDetails": "Name & Datum ergänzen", + "emptyStepQr": "QR-Poster teilen", + "emptyStepReview": "Erste Uploads prüfen", + "emptyPreviewTitle": "Darauf kannst du dich freuen", + "emptyPreviewQr": "QR-Poster teilen", + "emptyPreviewQrDesc": "Druckfertige Codes für Gäste und Team.", + "emptyPreviewGallery": "Galerie & Highlights", + "emptyPreviewGalleryDesc": "Uploads moderieren, die besten Momente featuren.", + "emptyPreviewTasks": "Tasks & Challenges", + "emptyPreviewTasksDesc": "Gäste mit spielerischen Prompts führen.", + "emptySupportTitle": "Brauchst du Hilfe?", + "emptySupportBody": "Wir unterstützen dich gern beim Start.", + "emptySupportDocs": "Docs: Erste Schritte", + "emptySupportEmail": "E-Mail an Support", "pickEvent": "Event auswählen", "status": { "published": "Live", @@ -1689,6 +1787,7 @@ "shortcutPrints": "Drucke & Poster-Downloads", "shortcutInvites": "Team-/Helfer-Einladungen", "shortcutSettings": "Event-Einstellungen", + "shortcutBranding": "Branding & Moderation", "kpiTitle": "Wichtigste Kennzahlen", "kpiTasks": "Offene Tasks", "kpiPhotos": "Fotos", @@ -1741,15 +1840,52 @@ "tenantBadge": "Tenant #{{id}}", "notificationsTitle": "Benachrichtigungen", "notificationsLoading": "Lade Einstellungen ...", - "pref": { - "task_updates": "Task-Updates", - "photo_limits": "Foto-Limits", - "photo_thresholds": "Foto-Schwellen", - "guest_limits": "Gäste-Limits", - "guest_thresholds": "Gäste-Schwellen", - "purchase_limits": "Kauf-Limits", - "billing": "Abrechnung & Rechnungen", - "alerts": "Warnungen" + "pref": {} + }, + "settings": { + "notifications": { + "keys": { + "photo_thresholds": { + "label": "Foto-Schwellen", + "description": "Benachrichtigt, wenn Foto-Uploads die Paketgrenzen annähern." + }, + "photo_limits": { + "label": "Foto-Limits erreicht", + "description": "Hinweis, wenn das Foto-Upload-Kontingent überschritten wurde." + }, + "guest_thresholds": { + "label": "Gäste-Schwellen", + "description": "Warnung, wenn die Gästezahl das Limit fast erreicht." + }, + "guest_limits": { + "label": "Gäste-Limits erreicht", + "description": "Hinweis, wenn das Gäste-Limit überschritten wurde." + }, + "gallery_warnings": { + "label": "Galerie-Warnungen", + "description": "Vorwarnung, bevor Galerien offline gehen." + }, + "gallery_expired": { + "label": "Galerie abgelaufen", + "description": "Info, wenn eine Galerie nicht mehr verfügbar ist." + }, + "event_thresholds": { + "label": "Event-Schwellen", + "description": "Warnung, wenn Event-Nutzung sich dem Limit nähert." + }, + "event_limits": { + "label": "Event-Limits erreicht", + "description": "Hinweis, wenn Event-Kontingente überschritten sind." + }, + "package_expiring": { + "label": "Paket läuft ab", + "description": "Erinnerungen, bevor dein Paket abläuft." + }, + "package_expired": { + "label": "Paket abgelaufen", + "description": "Hinweis, wenn dein Paket abgelaufen ist." + } + } } }, "mobileBilling": { diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index a007a2c..ea8acc9 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -57,6 +57,10 @@ "de": "German", "en": "English" }, + "states": { + "enabled": "Enabled", + "disabled": "Disabled" + }, "actions": { "open": "Open", "viewAll": "View all", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 9379b84..93574df 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -187,6 +187,32 @@ } }, "events": { + "detail": { + "kpi": { + "tasks": "Active tasks", + "guests": "Guests registered", + "photos": "Images uploaded" + }, + "pickEvent": "Select event", + "active": "Active", + "managementTitle": "Event management", + "dateTbd": "Date tbd", + "locationPlaceholder": "Location" + }, + "quick": { + "tasks": "Tasks & checklists", + "qr": "QR code layouts", + "images": "Image management", + "guests": "Guest management", + "branding": "Branding & theme", + "photobooth": "Photobooth", + "recap": "Recap & archive" + }, + "status": { + "published": "Live", + "draft": "Draft", + "archived": "Archived" + }, "list": { "title": "Your events", "subtitle": "Plan memorable moments. Manage everything around your events here.", @@ -793,6 +819,7 @@ "errors": { "missingSlug": "No event selected.", "loadFailed": "Event could not be loaded.", + "saveFailed": "Event could not be saved.", "notFoundTitle": "Event not found", "notFoundBody": "Without a valid identifier we can’t load the data. Return to the list and choose an event.", "toggleFailed": "Status could not be updated.", @@ -820,6 +847,22 @@ "buyMoreGuests": "Unlock more guests", "extendGallery": "Extend gallery" }, + "form": { + "editTitle": "Edit event", + "createTitle": "Create new event", + "name": "Event name", + "date": "Date & time", + "description": "Optional details", + "descriptionPlaceholder": "Description", + "location": "Location", + "locationPlaceholder": "Location", + "enableBranding": "Enable branding & moderation", + "fallbackName": "Event", + "saveDraft": "Save as draft", + "saving": "Saving…", + "update": "Update event", + "create": "Create event" + }, "workspace": { "detailSubtitle": "Keep status, tasks, and invites of your event in one view.", "toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.", @@ -1550,6 +1593,9 @@ }, "eventForm": { "errors": { + "notice": "Notice", + "loadFailed": "Event could not be loaded.", + "saveFailed": "Event could not be saved.", "nameRequired": "Please enter an event name.", "typeRequired": "Please select an event type." }, @@ -1568,11 +1614,24 @@ "name": { "label": "Event name", "placeholder": "e.g. Summer Party 2025", - "help": "The slug and event URL are generated from the name." + "help": "The slug and event URL are generated from the name.", + "fallback": "Event" }, "date": { "label": "Date" }, + "description": { + "label": "Optional details", + "placeholder": "Description" + }, + "location": { + "label": "Location", + "placeholder": "Location" + }, + "enableBranding": { + "label": "Enable branding & moderation", + "locked": "Branding is available on higher packages. Upgrade to enable custom branding." + }, "type": { "label": "Event type", "loading": "Loading event types…", @@ -1586,12 +1645,12 @@ }, "actions": { "backToList": "Back to list", - "saving": "Saving", + "saving": "Saving…", "save": "Save", - "cancel": "Cancel" - }, - "errors": { - "notice": "Notice" + "cancel": "Cancel", + "saveDraft": "Save as draft", + "update": "Update event", + "create": "Create event" } }, "notifications": { @@ -1632,6 +1691,32 @@ } }, "events": { + "detail": { + "kpi": { + "tasks": "Active tasks", + "guests": "Guests registered", + "photos": "Images uploaded" + }, + "pickEvent": "Select event", + "active": "Active", + "managementTitle": "Event management", + "dateTbd": "Date tbd", + "locationPlaceholder": "Location" + }, + "quick": { + "tasks": "Tasks & checklists", + "qr": "QR code layouts", + "images": "Image management", + "guests": "Guest management", + "branding": "Branding & theme", + "moderation": "Photo moderation", + "recap": "Recap & archive" + }, + "status": { + "published": "Live", + "draft": "Draft", + "archived": "Archived" + }, "errors": { "missingSlug": "No event slug provided.", "loadFailed": "Tasks could not be loaded.", @@ -1687,15 +1772,26 @@ "mobileDashboard": { "title": "Dashboard", "selectEvent": "Select an event to continue", - "emptyTitle": "Create your first event", - "emptyBody": "Start an event to manage tasks, QR posters and uploads.", + "emptyBadge": "Welcome aboard", + "emptyTitle": "Welcome! Let's launch your first event", + "emptyBody": "Print a QR, collect uploads, and start moderating in minutes.", "ctaCreate": "Create event", - "ctaDemo": "View demo", - "highlightsTitle": "What you can do", - "highlightImages": "Review photos & uploads", - "highlightTasks": "Assign tasks & challenges", - "highlightQr": "Share QR posters", - "highlightGuests": "Invite helpers & guests", + "emptyChecklistTitle": "Quick steps to go live", + "emptyChecklistProgress": "{{done}}/{{total}} steps", + "emptyStepDetails": "Add name & date", + "emptyStepQr": "Share your QR poster", + "emptyStepReview": "Review first uploads", + "emptyPreviewTitle": "Here's what awaits", + "emptyPreviewQr": "Share QR poster", + "emptyPreviewQrDesc": "Print-ready codes for guests and crew.", + "emptyPreviewGallery": "Gallery & highlights", + "emptyPreviewGalleryDesc": "Moderate uploads, feature the best moments.", + "emptyPreviewTasks": "Tasks & challenges", + "emptyPreviewTasksDesc": "Guide guests with playful prompts.", + "emptySupportTitle": "Need help?", + "emptySupportBody": "We are here if you need a hand getting started.", + "emptySupportDocs": "Docs: Getting started", + "emptySupportEmail": "Email support", "pickEvent": "Select an event", "status": { "published": "Live", @@ -1712,6 +1808,7 @@ "shortcutPrints": "Print & poster downloads", "shortcutInvites": "Team / helper invites", "shortcutSettings": "Event settings", + "shortcutBranding": "Branding & moderation", "kpiTitle": "Key performance indicators", "kpiTasks": "Open tasks", "kpiPhotos": "Photos", @@ -1764,15 +1861,52 @@ "tenantBadge": "Tenant #{{id}}", "notificationsTitle": "Notifications", "notificationsLoading": "Loading settings ...", - "pref": { - "task_updates": "Task updates", - "photo_limits": "Photo limits", - "photo_thresholds": "Photo thresholds", - "guest_limits": "Guest limits", - "guest_thresholds": "Guest thresholds", - "purchase_limits": "Purchase limits", - "billing": "Billing & invoices", - "alerts": "Alerts" + "pref": {} + }, + "settings": { + "notifications": { + "keys": { + "photo_thresholds": { + "label": "Photo thresholds", + "description": "Get notified as photo uploads approach package limits." + }, + "photo_limits": { + "label": "Photo limits reached", + "description": "Alert when photo upload quota is exceeded." + }, + "guest_thresholds": { + "label": "Guest thresholds", + "description": "Warn when guest count nears the limit." + }, + "guest_limits": { + "label": "Guest limits reached", + "description": "Alert when guest limit is exceeded." + }, + "gallery_warnings": { + "label": "Gallery warnings", + "description": "Heads-up before galleries go offline." + }, + "gallery_expired": { + "label": "Gallery expired", + "description": "Notify when a gallery is no longer available." + }, + "event_thresholds": { + "label": "Event thresholds", + "description": "Warn as event usage approaches limits." + }, + "event_limits": { + "label": "Event limits reached", + "description": "Alert when event quotas are exceeded." + }, + "package_expiring": { + "label": "Package expiring", + "description": "Reminders before your package expires." + }, + "package_expired": { + "label": "Package expired", + "description": "Alert when your package has expired." + } + } } }, "mobileBilling": { diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 8539104..4c3ee72 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; -import { CalendarDays, Image as ImageIcon, ListTodo, QrCode, Settings, Users, Sparkles } from 'lucide-react'; +import { CheckCircle2, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, Users, 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'; @@ -133,35 +133,178 @@ function OnboardingEmptyState() { const theme = useTheme(); const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1'); + const border = String(theme.borderColor?.val ?? '#cbd5e1'); + const accent = String(theme.primary?.val ?? '#2563eb'); + const accentSoft = String(theme.blue3?.val ?? '#e0f2fe'); + const accentStrong = String(theme.blue10?.val ?? '#1d4ed8'); + const stepBg = String(theme.gray2?.val ?? '#f8fafc'); + const stepBorder = String(theme.gray5?.val ?? '#e2e8f0'); + const supportBg = String(theme.gray2?.val ?? '#f8fafc'); + const supportBorder = String(theme.gray5?.val ?? '#e2e8f0'); + + const steps = [ + t('mobileDashboard.emptyStepDetails', 'Add name & date'), + t('mobileDashboard.emptyStepQr', 'Share your QR poster'), + t('mobileDashboard.emptyStepReview', 'Review first uploads'), + ]; + + const previews = [ + { + icon: QrCode, + title: t('mobileDashboard.emptyPreviewQr', 'Share QR poster'), + desc: t('mobileDashboard.emptyPreviewQrDesc', 'Print-ready codes for guests and crew.'), + }, + { + icon: ImageIcon, + title: t('mobileDashboard.emptyPreviewGallery', 'Gallery & highlights'), + desc: t('mobileDashboard.emptyPreviewGalleryDesc', 'Moderate uploads, feature the best moments.'), + }, + { + icon: ListTodo, + title: t('mobileDashboard.emptyPreviewTasks', 'Tasks & challenges'), + desc: t('mobileDashboard.emptyPreviewTasksDesc', 'Guide guests with playful prompts.'), + }, + ]; + return ( - - - {t('mobileDashboard.emptyTitle', 'Create your first event')} - - - {t('mobileDashboard.emptyBody', 'Start an event to manage tasks, QR posters and uploads.')} - - navigate(adminPath('/mobile/events/new'))} /> - navigate(adminPath('/mobile/events'))} /> + + + + + + + + + {t('mobileDashboard.emptyBadge', 'Welcome aboard')} + + {t('mobileDashboard.emptyTitle', "Welcome! Let's launch your first event")} + + + {t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')} + + navigate(adminPath('/mobile/events/new'))} /> + - - - {t('mobileDashboard.highlightsTitle', 'What you can do')} - - - {[ - t('mobileDashboard.highlightImages', 'Review photos & uploads'), - t('mobileDashboard.highlightTasks', 'Assign tasks & challenges'), - t('mobileDashboard.highlightQr', 'Share QR posters'), - t('mobileDashboard.highlightGuests', 'Invite helpers & guests'), - ].map((item) => ( - - {item} + + + + + {t('mobileDashboard.emptyChecklistTitle', 'Quick steps to go live')} + + + {t('mobileDashboard.emptyChecklistProgress', '{{done}}/{{total}} steps', { done: 0, total: steps.length })} + + + + {steps.map((label) => ( + + + + + + {label} + ))} + + + + {t('mobileDashboard.emptyPreviewTitle', "Here's what awaits")} + + + {previews.map(({ icon: Icon, title, desc }) => ( + + + + + + {title} + + + {desc} + + + ))} + + + + + + + + + + + {t('mobileDashboard.emptySupportTitle', 'Need help?')} + + + {t('mobileDashboard.emptySupportBody', 'We are here if you need a hand getting started.')} + + + + + + {t('mobileDashboard.emptySupportDocs', 'Docs: Getting started')} + + + {t('mobileDashboard.emptySupportEmail', 'Email support')} + + + ); } @@ -313,6 +456,7 @@ function SecondaryGrid({ const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1'); const border = String(theme.borderColor?.val ?? '#334155'); const surface = String(theme.surface?.val ?? '#0b1220'); + const brandingAllowed = Boolean((event?.package as any)?.branding_allowed ?? true); const tiles = [ { icon: Users, @@ -338,6 +482,13 @@ function SecondaryGrid({ color: '#10b981', action: onSettings, }, + { + icon: Sparkles, + label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'), + color: '#22d3ee', + action: brandingAllowed ? onSettings : undefined, + disabled: !brandingAllowed, + }, ]; return ( @@ -347,7 +498,14 @@ function SecondaryGrid({ {tiles.map((tile) => ( - + ))} {event ? ( diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index 5d71a47..0e7c654 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown, Pencil } from 'lucide-react'; +import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; @@ -90,7 +90,7 @@ export default function MobileEventDetailPage() { title={resolveEventDisplayName(event ?? activeEvent ?? undefined)} subtitle={ event?.event_date || activeEvent?.event_date - ? formatDate(event?.event_date ?? activeEvent?.event_date) + ? formatDate(event?.event_date ?? activeEvent?.event_date, t) : undefined } onBack={() => navigate(-1)} @@ -115,16 +115,16 @@ export default function MobileEventDetailPage() { - {event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')} + {event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')} - {formatDate(event?.event_date)} + {formatDate(event?.event_date, t)} - {resolveLocation(event)} + {resolveLocation(event, t)} @@ -191,12 +191,12 @@ export default function MobileEventDetailPage() { - {renderName(ev.name)} + {renderName(ev.name, t)} - {formatDate(ev.event_date)} + {formatDate(ev.event_date, t)} @@ -246,10 +246,10 @@ export default function MobileEventDetailPage() { onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`))} /> navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))} + onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))} /> {isPastEvent(event?.event_date) ? ( string): string { + const fallback = t('events.placeholders.untitled', 'Untitled event'); + if (typeof name === 'string' && name.trim()) return name; if (name && typeof name === 'object') { - return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event'; + return name.de ?? name.en ?? Object.values(name)[0] ?? fallback; } - return 'Unbenanntes Event'; + return fallback; } -function formatDate(iso?: string | null): string { - if (!iso) return 'Date tbd'; +function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string { + if (!iso) return t('events.detail.dateTbd', 'Date tbd'); const date = new Date(iso); - if (Number.isNaN(date.getTime())) return 'Date tbd'; + if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd'); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } -function resolveLocation(event: TenantEvent | null): string { - if (!event) return 'Location'; +function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string { + if (!event) return t('events.detail.locationPlaceholder', 'Location'); const settings = (event.settings ?? {}) as Record; const candidate = (settings.location as string | undefined) ?? @@ -290,5 +291,5 @@ function resolveLocation(event: TenantEvent | null): string { if (candidate && candidate.trim()) { return candidate; } - return 'Location'; + return t('events.detail.locationPlaceholder', 'Location'); } diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 8c91679..408705a 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -18,7 +18,6 @@ type FormState = { eventTypeId: number | null; description: string; location: string; - enableBranding: boolean; published: boolean; }; @@ -27,7 +26,7 @@ export default function MobileEventFormPage() { const slug = slugParam ?? null; const isEdit = Boolean(slug); const navigate = useNavigate(); - const { t } = useTranslation('management'); + const { t } = useTranslation(['management', 'common']); const [form, setForm] = React.useState({ name: '', @@ -35,7 +34,6 @@ export default function MobileEventFormPage() { eventTypeId: null, description: '', location: '', - enableBranding: false, published: false, }); const [eventTypes, setEventTypes] = React.useState([]); @@ -56,7 +54,6 @@ export default function MobileEventFormPage() { eventTypeId: data.event_type_id ?? data.event_type?.id ?? null, description: typeof data.description === 'string' ? data.description : '', location: resolveLocation(data), - enableBranding: Boolean((data.settings as Record)?.branding_allowed ?? true), published: data.status === 'published', }); setError(null); @@ -98,24 +95,24 @@ export default function MobileEventFormPage() { event_date: form.date || undefined, event_type_id: form.eventTypeId ?? undefined, status: form.published ? 'published' : 'draft', - settings: { branding_allowed: form.enableBranding, location: form.location }, + settings: { location: form.location }, }); navigate(adminPath(`/mobile/events/${slug}`)); } else { const payload = { - name: form.name || 'Event', + name: form.name || t('eventForm.fields.name.fallback', 'Event'), slug: `${Date.now()}`, event_type_id: form.eventTypeId ?? undefined, event_date: form.date || undefined, - status: form.published ? 'published' : 'draft' as const, - settings: { branding_allowed: form.enableBranding, location: form.location }, + status: (form.published ? 'published' : 'draft') as const, + settings: { location: form.location }, }; const { event } = await createEvent(payload as any); navigate(adminPath(`/mobile/events/${event.slug}`)); } } catch (err) { if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Event konnte nicht gespeichert werden.'))); + setError(getApiErrorMessage(err, t('eventForm.errors.saveFailed', 'Event could not be saved.'))); } } finally { setSaving(false); @@ -125,7 +122,7 @@ export default function MobileEventFormPage() { return ( navigate(-1)} > {error ? ( @@ -137,17 +134,17 @@ export default function MobileEventFormPage() { ) : null} - + setForm((prev) => ({ ...prev, name: e.target.value }))} - placeholder="e.g., Smith Wedding" + placeholder={t('eventForm.fields.name.placeholder', 'e.g. Summer Party 2025')} style={inputStyle} /> - + - +