diff --git a/app/Filament/Resources/TenantFeedbackResource.php b/app/Filament/Resources/TenantFeedbackResource.php new file mode 100644 index 0000000..050da82 --- /dev/null +++ b/app/Filament/Resources/TenantFeedbackResource.php @@ -0,0 +1,65 @@ + ListTenantFeedback::route('/'), + 'view' => ViewTenantFeedback::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/TenantFeedbackResource/Pages/ListTenantFeedback.php b/app/Filament/Resources/TenantFeedbackResource/Pages/ListTenantFeedback.php new file mode 100644 index 0000000..505ebb0 --- /dev/null +++ b/app/Filament/Resources/TenantFeedbackResource/Pages/ListTenantFeedback.php @@ -0,0 +1,16 @@ +components([ + // + ]); + } +} diff --git a/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php b/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php new file mode 100644 index 0000000..cacd55e --- /dev/null +++ b/app/Filament/Resources/TenantFeedbackResource/Schemas/TenantFeedbackInfolist.php @@ -0,0 +1,62 @@ +components([ + Section::make(__('Überblick')) + ->columns(3) + ->schema([ + TextEntry::make('tenant.name')->label(__('Tenant'))->placeholder('—'), + TextEntry::make('event.name')->label(__('Event'))->placeholder('—'), + TextEntry::make('category') + ->label(__('Kategorie')) + ->badge() + ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : '—'), + TextEntry::make('sentiment') + ->label(__('Stimmung')) + ->badge() + ->color(fn (?string $state) => match ($state) { + 'positive' => 'success', + 'neutral' => 'warning', + 'negative' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—'), + TextEntry::make('rating') + ->label(__('Rating')) + ->formatStateUsing(fn (?int $state) => $state ? sprintf('%d/5', $state) : '—'), + TextEntry::make('created_at') + ->label(__('Eingegangen')) + ->since(), + ]), + Section::make(__('Inhalt')) + ->columns(1) + ->schema([ + TextEntry::make('title') + ->label(__('Betreff')) + ->placeholder('—'), + TextEntry::make('message') + ->label(__('Nachricht')) + ->markdown() + ->placeholder('—'), + ]), + Section::make(__('Metadaten')) + ->schema([ + KeyValueEntry::make('metadata') + ->label(__('Metadata')) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php b/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php new file mode 100644 index 0000000..a08feb0 --- /dev/null +++ b/app/Filament/Resources/TenantFeedbackResource/Tables/TenantFeedbackTable.php @@ -0,0 +1,76 @@ +defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('created_at') + ->label(__('Eingegangen')) + ->since() + ->sortable(), + Tables\Columns\TextColumn::make('tenant.name') + ->label(__('Tenant')) + ->searchable() + ->limit(30), + Tables\Columns\TextColumn::make('event.name') + ->label(__('Event')) + ->limit(30) + ->toggleable(), + Tables\Columns\TextColumn::make('category') + ->label(__('Kategorie')) + ->badge() + ->formatStateUsing(fn (string $state) => Str::headline($state)) + ->sortable(), + Tables\Columns\TextColumn::make('sentiment') + ->label(__('Stimmung')) + ->badge() + ->color(fn (?string $state) => match ($state) { + 'positive' => 'success', + 'neutral' => 'warning', + 'negative' => 'danger', + default => 'gray', + }) + ->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—') + ->sortable(), + Tables\Columns\TextColumn::make('rating') + ->label(__('Rating')) + ->formatStateUsing(fn (?int $state) => $state ? sprintf('%d/5', $state) : '—') + ->sortable(), + Tables\Columns\TextColumn::make('message') + ->label(__('Nachricht')) + ->limit(60) + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('sentiment') + ->label(__('Stimmung')) + ->options([ + 'positive' => __('Positiv'), + 'neutral' => __('Neutral'), + 'negative' => __('Negativ'), + ]), + SelectFilter::make('category') + ->label(__('Kategorie')) + ->options(fn () => TenantFeedback::query() + ->whereNotNull('category') + ->orderBy('category') + ->pluck('category', 'category') + ->toArray()), + ]) + ->recordActions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 24211da..6b2df35 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1230,6 +1230,7 @@ class EventPublicController extends BaseController $branding = $this->buildGalleryBranding($event); $expiresAt = optional($event->eventPackage)->gallery_expires_at; + $settings = is_array($event->settings) ? $event->settings : []; return response()->json([ 'event' => [ @@ -1238,6 +1239,8 @@ class EventPublicController extends BaseController 'slug' => $event->slug, 'description' => $this->translateLocalized($event->description, $locale, ''), 'gallery_expires_at' => $expiresAt?->toIso8601String(), + 'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? true), + 'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? true), ], 'branding' => $branding, ]); diff --git a/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php index 792b0bd..fedac7d 100644 --- a/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php +++ b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php @@ -5,9 +5,12 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Models\Event; use App\Models\TenantFeedback; +use App\Models\User; +use App\Notifications\TenantFeedbackSubmitted; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\Notification; class TenantFeedbackController extends Controller { @@ -52,6 +55,15 @@ class TenantFeedbackController extends Controller 'metadata' => $validated['metadata'] ?? null, ]); + $recipients = User::query() + ->where('role', 'super_admin') + ->whereNotNull('email') + ->get(); + + if ($recipients->isNotEmpty()) { + Notification::send($recipients, new TenantFeedbackSubmitted($feedback)); + } + return response()->json([ 'message' => 'Feedback gespeichert', 'data' => [ diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 79516f9..c4fd28a 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -8,6 +8,8 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Arr; +use function app; + class EventResource extends JsonResource { public function toArray(Request $request): array @@ -54,6 +56,9 @@ class EventResource extends JsonResource 'engagement_mode' => $settings['engagement_mode'] ?? 'tasks', 'settings' => $settings, 'event_type_id' => $this->event_type_id, + 'event_type' => $this->whenLoaded('eventType', function () { + return new EventTypeResource($this->eventType); + }), 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), 'photo_count' => (int) ($this->photos_count ?? 0), diff --git a/app/Notifications/TenantFeedbackSubmitted.php b/app/Notifications/TenantFeedbackSubmitted.php new file mode 100644 index 0000000..8d3a67c --- /dev/null +++ b/app/Notifications/TenantFeedbackSubmitted.php @@ -0,0 +1,84 @@ +feedback->loadMissing('tenant', 'event'); + } + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $tenantName = $this->resolveName($this->feedback->tenant?->name) ?: __('emails.tenant_feedback.unknown_tenant'); + $eventName = $this->resolveName($this->feedback->event?->name) ?: ($this->feedback->metadata['event_name'] ?? null); + $sentiment = $this->feedback->sentiment ? Str::headline($this->feedback->sentiment) : __('emails.tenant_feedback.unknown'); + $rating = $this->feedback->rating ? sprintf('%d/5', $this->feedback->rating) : null; + + $subject = __('emails.tenant_feedback.subject', [ + 'tenant' => $tenantName, + 'sentiment' => $sentiment, + ]); + + $mail = (new MailMessage()) + ->subject($subject) + ->line(__('emails.tenant_feedback.tenant', ['tenant' => $tenantName])) + ->line(__('emails.tenant_feedback.category', ['category' => $this->feedback->category ? Str::headline($this->feedback->category) : '—'])) + ->line(__('emails.tenant_feedback.sentiment', ['sentiment' => $sentiment])); + + if ($eventName) { + $mail->line(__('emails.tenant_feedback.event', ['event' => $eventName])); + } + + if ($rating) { + $mail->line(__('emails.tenant_feedback.rating', ['rating' => $rating])); + } + + if ($this->feedback->title) { + $mail->line(__('emails.tenant_feedback.title', ['subject' => $this->feedback->title])); + } + + if ($this->feedback->message) { + $mail->line(__('emails.tenant_feedback.message'))->line($this->feedback->message); + } + + $url = TenantFeedbackResource::getUrl('view', ['record' => $this->feedback], panel: 'superadmin'); + + if ($url) { + $mail->action(__('emails.tenant_feedback.open'), $url); + } + + $mail->line(__('emails.tenant_feedback.received_at', ['date' => $this->feedback->created_at?->toDayDateTimeString()])); + + return $mail; + } + + protected function resolveName(mixed $name): ?string + { + if (is_string($name) && $name !== '') { + return $name; + } + + if (is_array($name)) { + return $name['de'] ?? $name['en'] ?? reset($name) ?: null; + } + + return null; + } +} diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index d5f2008..798c4f0 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -294,12 +294,8 @@ function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: strin type="button" className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200" > - - {activeTab?.label ?? t('navigation.tabs.active', { defaultValue: 'Bereich wählen' })} - - - {t('navigation.tabs.open', { defaultValue: 'Tabs' })} - + {activeTab?.label ?? t('navigation.tabs.active')} + {t('navigation.tabs.open')} - {t('navigation.tabs.title', { defaultValue: 'Bereich auswählen' })} + {t('navigation.tabs.title')} - {t('navigation.tabs.subtitle', { defaultValue: 'Wechsle schnell zwischen Event-Bereichen.' })} + {t('navigation.tabs.subtitle')}
diff --git a/resources/js/admin/components/NotificationCenter.tsx b/resources/js/admin/components/NotificationCenter.tsx index efd7474..da8edb1 100644 --- a/resources/js/admin/components/NotificationCenter.tsx +++ b/resources/js/admin/components/NotificationCenter.tsx @@ -34,7 +34,7 @@ interface TenantNotification { export function NotificationCenter() { const navigate = useNavigate(); - const { t } = useTranslation('dashboard'); + const { t } = useTranslation('management'); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(true); const [notifications, setNotifications] = React.useState([]); @@ -104,7 +104,7 @@ export function NotificationCenter() { variant="ghost" size="icon" className="relative rounded-full border border-transparent text-slate-600 hover:text-rose-600 dark:text-slate-200" - aria-label={t('notifications.trigger', { defaultValue: 'Benachrichtigungen' })} + aria-label={t('notifications.trigger')} > {unreadCount > 0 ? ( @@ -116,9 +116,9 @@ export function NotificationCenter() { - {t('notifications.title', { defaultValue: 'Notifications' })} + {t('notifications.title')} {!loading && unreadCount === 0 ? ( - {t('notifications.empty', { defaultValue: 'Aktuell ruhig' })} + {t('notifications.empty')} ) : null} @@ -131,7 +131,7 @@ export function NotificationCenter() {
{visibleNotifications.length === 0 ? (

- {t('notifications.empty.message', { defaultValue: 'Alles erledigt – wir melden uns bei Neuigkeiten.' })} + {t('notifications.empty.message')}

) : ( visibleNotifications.map((item) => ( @@ -163,7 +163,7 @@ export function NotificationCenter() { className="h-7 rounded-full px-3 text-xs text-slate-500 hover:text-rose-600" onClick={() => handleDismiss(item.id)} > - {t('notifications.action.dismiss', { defaultValue: 'Ausblenden' })} + {t('notifications.action.dismiss')}
@@ -183,7 +183,7 @@ export function NotificationCenter() { }} > - {t('notifications.action.refresh', { defaultValue: 'Neue Hinweise laden' })} + {t('notifications.action.refresh')} @@ -208,13 +208,11 @@ function buildNotifications({ if (events.length === 0) { items.push({ id: 'no-events', - title: t('notifications.noEvents.title', { defaultValue: 'Legen wir los' }), - description: t('notifications.noEvents.description', { - defaultValue: 'Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.', - }), + title: t('notifications.noEvents.title'), + description: t('notifications.noEvents.description'), tone: 'warning', action: { - label: t('notifications.noEvents.cta', { defaultValue: 'Event erstellen' }), + label: t('notifications.noEvents.cta'), onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH), }, }); @@ -226,14 +224,12 @@ function buildNotifications({ if (event.status !== 'published') { items.push({ id: `draft-${event.id}`, - title: t('notifications.draftEvent.title', { defaultValue: 'Event noch als Entwurf' }), - description: t('notifications.draftEvent.description', { - defaultValue: 'Veröffentliche das Event, um Einladungen und Galerie freizugeben.', - }), + title: t('notifications.draftEvent.title'), + description: t('notifications.draftEvent.description'), tone: 'info', action: event.slug ? { - label: t('notifications.draftEvent.cta', { defaultValue: 'Event öffnen' }), + label: t('notifications.draftEvent.cta'), onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)), } : undefined, @@ -244,37 +240,33 @@ function buildNotifications({ if (eventDate && eventDate > now) { const days = Math.round((eventDate - now) / (1000 * 60 * 60 * 24)); if (days <= 7) { - items.push({ - id: `upcoming-${event.id}`, - title: t('notifications.upcomingEvent.title', { defaultValue: 'Event startet bald' }), - description: t('notifications.upcomingEvent.description', { - defaultValue: days === 0 - ? 'Heute findet ein Event statt – checke Uploads und Tasks.' - : `Noch ${days} Tage – bereite Einladungen und Aufgaben vor.`, - }), - tone: 'info', - action: event.slug - ? { - label: t('notifications.upcomingEvent.cta', { defaultValue: 'Zum Event' }), - onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)), - } - : undefined, - }); - } + items.push({ + id: `upcoming-${event.id}`, + title: t('notifications.upcomingEvent.title'), + description: days === 0 + ? t('notifications.upcomingEvent.description_today') + : t('notifications.upcomingEvent.description_days', { count: days }), + tone: 'info', + action: event.slug + ? { + label: t('notifications.upcomingEvent.cta'), + onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)), + } + : undefined, + }); + } } const pendingUploads = Number(event.pending_photo_count ?? 0); if (pendingUploads > 0) { items.push({ id: `pending-uploads-${event.id}`, - title: t('notifications.pendingUploads.title', { defaultValue: 'Uploads warten auf Freigabe' }), - description: t('notifications.pendingUploads.description', { - defaultValue: `${pendingUploads} neue Uploads benötigen Moderation.`, - }), + title: t('notifications.pendingUploads.title'), + description: t('notifications.pendingUploads.description', { count: pendingUploads }), tone: 'warning', action: event.slug ? { - label: t('notifications.pendingUploads.cta', { defaultValue: 'Uploads öffnen' }), + label: t('notifications.pendingUploads.cta'), onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`), } : undefined, @@ -285,18 +277,16 @@ function buildNotifications({ if ((summary?.new_photos ?? 0) > 0) { items.push({ id: 'summary-new-photos', - title: t('notifications.newPhotos.title', { defaultValue: 'Neue Fotos eingetroffen' }), - description: t('notifications.newPhotos.description', { - defaultValue: `${summary?.new_photos ?? 0} Uploads warten auf dich.`, - }), + title: t('notifications.newPhotos.title'), + description: t('notifications.newPhotos.description', { count: summary?.new_photos ?? 0 }), tone: 'success', action: primary?.slug ? { - label: t('notifications.newPhotos.cta', { defaultValue: 'Galerie öffnen' }), + label: t('notifications.newPhotos.cta'), onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)), } : { - label: t('notifications.newPhotos.ctaFallback', { defaultValue: 'Events ansehen' }), + label: t('notifications.newPhotos.ctaFallback'), onSelect: () => navigate(ADMIN_EVENTS_PATH), }, }); diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json index d63911d..376a5a7 100644 --- a/resources/js/admin/i18n/locales/de/auth.json +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -34,5 +34,10 @@ "return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.", "support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.", "appearance_label": "Darstellung" + }, + "redirecting": "Weiterleitung zum Login …", + "processing": { + "title": "Anmeldung wird verarbeitet …", + "copy": "Einen Moment bitte, wir bereiten dein Dashboard vor." } } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index e83912c..e00ad53 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -278,9 +278,18 @@ "untitled": "Unbenanntes Event" } }, - "tasks": { - "title": "Event-Tasks", - "subtitle": "Verwalte Aufgaben, die diesem Event zugeordnet sind.", + "eventMenu": { + "summary": "Übersicht", + "photos": "Uploads", + "tasks": "Aufgaben", + "invites": "Einladungen", + "branding": "Branding", + "photobooth": "Photobooth", + "recap": "Nachbereitung" + }, + "eventTasks": { + "title": "Aufgaben & Missionen", + "subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.", "actions": { "back": "Zurück zur Übersicht", "assign": "Ausgewählte Tasks zuweisen" @@ -290,7 +299,8 @@ "load": "Event-Tasks konnten nicht geladen werden.", "assign": "Tasks konnten nicht zugewiesen werden.", "photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.", - "photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden." + "photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden.", + "collections": "Kollektionen konnten nicht geladen werden." }, "emotions": { "error": "Emotionen konnten nicht geladen werden." @@ -299,10 +309,28 @@ "notFoundTitle": "Event nicht gefunden", "notFoundDescription": "Bitte kehre zur Eventliste zurück." }, + "tabs": { + "tasks": "Aufgaben", + "packs": "Mission Packs" + }, "eventStatus": "Status: {{status}}", + "summary": { + "assigned": "Zugeordnete Tasks", + "library": "Bibliothek", + "mode": "Aktiver Modus", + "tasksMode": "Mission Cards", + "photoOnly": "Nur Fotos" + }, + "library": { + "hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek", + "hintCopy": "Lege Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.", + "open": "Aufgaben-Bibliothek öffnen" + }, "sections": { "assigned": { "title": "Zugeordnete Tasks", + "search": "Aufgaben suchen...", + "noResults": "Keine Aufgaben zum Suchbegriff.", "empty": "Noch keine Tasks zugewiesen." }, "library": { @@ -316,15 +344,32 @@ "high": "Hoch", "urgent": "Dringend" }, - "modes": { - "title": "Aufgaben & Foto-Modus", - "photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.", - "tasksHint": "Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.", + "modes": { + "title": "Aufgaben & Foto-Modus", + "photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.", + "tasksHint": "Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.", "photoOnly": "Foto-Modus", "tasks": "Aufgaben aktiv", - "switchLabel": "Foto-Modus aktivieren", + "switchLabel": "Aufgaben aktivieren/deaktivieren", "updating": "Einstellung wird gespeichert ..." }, + "collections": { + "title": "Mission Packs", + "subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.", + "viewAll": "Alle Kollektionen ansehen", + "errorTitle": "Kollektionen nicht verfügbar", + "empty": "Keine empfohlenen Kollektionen gefunden.", + "tasksCount": "{{count}} Aufgaben", + "genericType": "Allgemein", + "global": "Global", + "custom": "Custom", + "recommended": "Empfohlen", + "optional": "Optional", + "importCta": "Mission Pack importieren", + "imported": "Kollektion erfolgreich importiert", + "importFailed": "Mission Pack konnte nicht importiert werden", + "error": "Kollektionen konnten nicht geladen werden." + }, "toolkit": { "titleFallback": "Event-Day Toolkit", "subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.", @@ -772,6 +817,11 @@ "workspace": { "detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.", "toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.", + "hero": { + "badge": "Event", + "description": "Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.", + "liveBadge": "Live?" + }, "sections": { "statusTitle": "Eventstatus & Sichtbarkeit", "statusSubtitle": "Aktiviere dein Event für Gäste oder verstecke es vorübergehend." @@ -780,6 +830,7 @@ "status": "Status", "active": "Aktiv für Gäste", "date": "Eventdatum", + "noDate": "Kein Datum", "eventType": "Event-Typ", "insights": "Letzte Aktivität", "uploadsTotal": "{{count}} Uploads gesamt", @@ -832,6 +883,7 @@ "activeInvites": "Aktive Einladungen" }, "invites": { + "badge": "Einladungen", "title": "QR-Einladungen", "subtitle": "Behält aktive Einladungen und Layouts im Blick.", "activeCount": "{{count}} aktiv", @@ -840,11 +892,69 @@ "manage": "Layouts & Einladungen verwalten" }, "tasks": { + "badge": "Aufgaben", "title": "Aktive Aufgaben", "subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.", "summary": "{{completed}} von {{total}} erledigt", "empty": "Noch keine Aufgaben zugewiesen.", - "manage": "Aufgabenbereich öffnen" + "manage": "Aufgabenbereich öffnen", + "status": { + "completed": "Erledigt", + "open": "Offen" + } + }, + "recap": { + "badge": "Nachbereitung", + "subtitle": "Abschluss, Export und Galerie-Laufzeit verwalten.", + "galleryTitle": "Galerie-Status", + "galleryCounts": "{{photos}} Fotos, {{pending}} offen, {{likes}} Likes", + "open": "Offen", + "closed": "Geschlossen", + "openGallery": "Galerie öffnen", + "closeGallery": "Galerie schließen", + "moderate": "Uploads ansehen", + "shareGuests": "Gäste-Galerie teilen", + "shareLink": "Gäste-Link", + "noPublicUrl": "Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.", + "copyLink": "Link kopieren", + "copySuccess": "Link kopiert", + "copyError": "Link konnte nicht geteilt werden.", + "qrTitle": "QR-Code teilen", + "qrDownload": "QR-Code herunterladen", + "qrShare": "Link/QR teilen", + "qrAlt": "QR-Code zur Gäste-Galerie", + "allowDownloads": "Downloads erlauben", + "allowDownloadsHint": "Gäste dürfen Fotos speichern", + "allowSharing": "Teilen erlauben", + "allowSharingHint": "Gäste dürfen Links teilen", + "galleryOpen": "Galerie geöffnet", + "galleryClosed": "Galerie geschlossen", + "exportTitle": "Export & Backup", + "exportCopy": "Alle Assets sichern", + "exportHint": "Zip/CSV Export und Backup anstoßen.", + "backup": "Backup", + "downloadAll": "Alles herunterladen", + "downloadHighlights": "Highlights herunterladen", + "highlightsHint": "„Highlights“ = als Highlight markierte Fotos in der Galerie.", + "retentionTitle": "Verlängerung / Archivierung", + "expiresAt": "Läuft ab am {{date}}", + "noExpiry": "Ablaufdatum nicht gesetzt", + "retentionHint": "Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.", + "expiry": "Ablauf", + "archive": "Archivieren/Löschen", + "extendOptions": "Alle Add-ons für dieses Event", + "extendHint": "Verlängerungen addieren sich. Checkout öffnet in einem neuen Tab.", + "priceMissing": "Preis nicht verknüpft", + "noAddons": "Aktuell keine Add-ons verfügbar.", + "archivedSuccess": "Event archiviert. Galerie ist geschlossen.", + "archiveTitle": "Galerie archivieren?", + "archiveDesc": "Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte vorher abschließen.", + "archiveImpact": "Auswirkungen des Archivierens", + "archiveImpactClose": "Gäste-Zugriff endet; Uploads/Downloads werden deaktiviert.", + "archiveImpactLinks": "Öffentliche Links und QR-Codes werden ungültig; Sessions laufen aus.", + "archiveImpactData": "Daten bleiben intern für Compliance/Support und können auf Anfrage gelöscht werden (DSGVO).", + "archiveConfirm": "Ich habe Exporte abgeschlossen und möchte jetzt archivieren.", + "archiveConfirmCta": "Jetzt archivieren" }, "branding": { "badge": "Branding & Story", @@ -891,15 +1001,114 @@ "feedback": { "title": "Wie läuft dein Event?", "subtitle": "Feedback hilft uns, neue Features zu priorisieren.", - "positive": "Super Lauf!", - "neutral": "Läuft", - "negative": "Braucht Support", + "afterEventTitle": "Event beendet – kurzes Feedback?", + "afterEventCopy": "Hat alles geklappt? Deine Antwort hilft uns für kommende Events.", + "privacyHint": "Nur Admin-Feedback, keine Gastdaten", + "positive": "War super", + "neutral": "In Ordnung", + "negative": "Brauch(t)e Unterstützung", + "best": { + "uploads": "Uploads & Geschwindigkeit", + "invites": "QR-Einladungen & Layouts", + "moderation": "Moderation & Export", + "experience": "Allgemeine App-Erfahrung" + }, "placeholder": "Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.", "errorTitle": "Feedback konnte nicht gesendet werden.", "authError": "Deine Session ist abgelaufen. Bitte melde dich erneut an.", "genericError": "Feedback konnte nicht gesendet werden.", "submit": "Feedback senden", - "submitted": "Danke!" + "submitted": "Danke!", + "afterEventThanks": "Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.", + "sendAnother": "Weiteres Feedback senden", + "supportFollowup": "Support anfragen", + "cta": "Feedback geben", + "quickSentiment": "Stimmung auswählbar (positiv/neutral/Support).", + "dialogTitle": "Kurzes After-Event Feedback", + "dialogCopy": "Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.", + "sentiment": "Stimmung", + "bestQuestion": "Was lief am besten?", + "improve": "Was sollen wir verbessern?", + "supportHelp": "Ich hätte gern ein kurzes Follow-up (Support)." + } + }, + "tasks": { + "actions": { + "back": "Zurück zur Übersicht", + "assign": "Ausgewählte Tasks zuweisen" + }, + "title": "Aufgaben & Missionen", + "subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.", + "alerts": { + "notFoundTitle": "Event nicht gefunden", + "notFoundDescription": "Bitte kehre zur Eventliste zurück." + }, + "tabs": { + "tasks": "Aufgaben", + "packs": "Mission Packs" + }, + "eventStatus": "Status: {{status}}", + "modes": { + "title": "Aufgaben & Foto-Modus", + "tasksHint": "Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.", + "photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.", + "tasks": "Aufgaben aktiv", + "photoOnly": "Foto-Modus", + "switchLabel": "Aufgaben aktivieren/deaktivieren", + "updating": "Einstellung wird gespeichert ..." + }, + "summary": { + "assigned": "Zugeordnete Tasks", + "library": "Bibliothek", + "mode": "Aktiver Modus", + "tasksMode": "Mission Cards", + "photoOnly": "Nur Fotos" + }, + "library": { + "hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek", + "hintCopy": "Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.", + "open": "Aufgaben-Bibliothek öffnen" + }, + "sections": { + "assigned": { + "title": "Zugeordnete Tasks", + "search": "Aufgaben suchen...", + "noResults": "Keine Aufgaben zum Suchbegriff.", + "empty": "Noch keine Tasks zugewiesen." + }, + "library": { + "title": "Tasks aus Bibliothek hinzufügen", + "empty": "Keine Tasks in der Bibliothek gefunden." + } + }, + "actionsShort": { + "assign": "Ausgewählte Tasks zuweisen" + }, + "errors": { + "missingSlug": "Kein Event-Slug angegeben.", + "load": "Event-Tasks konnten nicht geladen werden.", + "assign": "Tasks konnten nicht zugewiesen werden.", + "collections": "Kollektionen konnten nicht geladen werden.", + "photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.", + "photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden." + }, + "collections": { + "errorTitle": "Kollektionen nicht verfügbar", + "import": "Kollektion importieren", + "error": "Kollektionen konnten nicht geladen werden.", + "title": "Mission Packs", + "subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.", + "viewAll": "Alle Kollektionen ansehen", + "empty": "Keine empfohlenen Kollektionen gefunden.", + "tasksCount": "{{count}} Aufgaben", + "genericType": "Allgemein", + "global": "Global", + "custom": "Custom", + "recommended": "Empfohlen", + "optional": "Optional", + "importCta": "Mission Pack importieren", + "imported": "Kollektion erfolgreich importiert", + "importFailed": "Mission Pack konnte nicht importiert werden" } }, "collections": { @@ -1188,5 +1397,236 @@ } } } + }, + "branding": { + "title": "Branding & Fonts", + "subtitle": "Passe Farben, Typografie, Logos/Emojis und Buttons für die Gäste-App an.", + "errors": { + "missingSlug": "Kein Event ausgewählt – öffne es über die Eventliste." + }, + "actions": { + "back": "Zurück zum Event" + }, + "sections": { + "mode": "Standard vs. Event-spezifisch", + "toggleTitle": "Branding-Quelle wählen", + "toggleDescription": "Nutze das Standard-Branding des Tenants oder überschreibe es nur für dieses Event.", + "palette": "Palette & Modus", + "colorsTitle": "Farben & Light/Dark", + "colorsDescription": "Primär-, Sekundär-, Hintergrund- und Surface-Farbe festlegen.", + "typography": "Typografie & Logo", + "fonts": "Schriften & Logo/Emoticon", + "fontDescription": "Heading- und Body-Font plus Logo/Emoji und Ausrichtung festlegen.", + "buttons": "Buttons & Links", + "buttonsTitle": "Buttons, Links & Radius", + "buttonsDescription": "Stil, Radius und optionale Link-Farbe wählen.", + "preview": "Preview", + "previewTitle": "Mini-Gastansicht", + "previewCopy": "Header, CTA und Bottom-Navigation nach Branding visualisiert." + }, + "useDefault": "Standard nutzen", + "useCustom": "Event-spezifisch", + "toggleHint": "Standard übernimmt die Tenant-Farben, Event-spezifisch überschreibt sie.", + "standard": "Standard", + "custom": "Event", + "toggleAria": "Event-spezifisches Branding aktivieren", + "mode": "Modus", + "modeAuto": "Auto", + "modeLight": "Hell", + "modeDark": "Dunkel", + "typography": { + "heading": "Heading-Font", + "body": "Body-Font" + }, + "size": "Schriftgröße", + "logo": { + "value": "Emoticon/Logo-URL", + "mode": "Logo-Modus", + "position": "Position" + }, + "emoticon": "Emoticon/Text", + "upload": "Upload/URL", + "left": "Links", + "center": "Zentriert", + "right": "Rechts", + "palette": { + "primary": "Primär", + "secondary": "Sekundär", + "surface": "Surface" + }, + "buttonStyle": "Stil", + "buttons": { + "style": "Stil", + "radius": "Radius", + "primary": "Button Primary", + "secondary": "Button Secondary", + "linkColor": "Link-Farbe" + }, + "filled": "Filled", + "outline": "Outline", + "radius": "Radius", + "linkColor": "Link-Farbe", + "buttonPrimary": "Button Primary", + "buttonSecondary": "Button Secondary", + "reset": "Auf Standard zurücksetzen", + "save": "Branding speichern", + "saving": "Speichern...", + "saved": "Branding gespeichert.", + "saveError": "Branding konnte nicht gespeichert werden.", + "footer": { + "default": "Standard-Farben des Tenants aktiv.", + "custom": "Event-spezifisches Branding aktiv." + }, + "usingDefault": "Tenant-Branding aktiv", + "usingCustom": "Event-Branding aktiv", + "preview": { + "demoTitle": "Demo-Event", + "guestView": "Gastansicht · {{mode}}", + "ctaCopy": "CTA und Buttons spiegeln den gewählten Stil wider.", + "cta": "Fotos jetzt hochladen", + "bottomNav": "Bottom-Navigation" + } + }, + "taskLibrary": { + "titles": { + "default": "Task-Bibliothek", + "embedded": "Aufgaben" + }, + "subtitles": { + "default": "Weise Aufgaben zu und tracke den Fortschritt rund um deine Events.", + "embedded": "Plane Aufgaben, Aktionen und Highlights für deine Gäste." + }, + "errors": { + "title": "Fehler", + "load": "Tasks konnten nicht geladen werden." + }, + "actions": { + "collections": "Collections", + "new": "Neu", + "searchPlaceholder": "Nach Aufgaben suchen …" + }, + "pagination": { + "page": "Seite {{current}} von {{total}} · {{count}} Einträge", + "summary": "Insgesamt {{count}} Tasks · Seite {{current}} von {{total}}", + "prev": "Zurück", + "next": "Weiter" + }, + "form": { + "editTitle": "Task bearbeiten", + "createTitle": "Neue Task erstellen", + "title": "Titel", + "description": "Beschreibung", + "descriptionPlaceholder": "Was sollen Gäste machen?", + "priority": "Priorität", + "priorityPlaceholder": "Priorität wählen", + "dueDate": "Fälligkeitsdatum", + "completedTitle": "Bereits erledigt?", + "completedCopy": "Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.", + "cancel": "Abbrechen", + "save": "Speichern" + }, + "priorities": { + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "urgent": "Dringend" + }, + "list": { + "template": "Vorlage #{{id}}", + "edit": "Bearbeiten", + "delete": "Löschen" + }, + "empty": { + "title": "Noch keine Tasks angelegt", + "description": "Starte mit einer neuen Aufgabe oder importiere Vorlagen, um deine Gäste zu inspirieren.", + "cta": "Erste Task erstellen" + } + }, + "billingWarning": { + "title": "Achtung", + "description": "Paket-Hinweise und Limits, die du im Blick behalten solltest." + }, + "eventForm": { + "errors": { + "nameRequired": "Bitte gib einen Eventnamen ein.", + "typeRequired": "Bitte wähle einen Event-Typ aus." + }, + "titles": { + "create": "Neues Event erstellen", + "edit": "Event bearbeiten" + }, + "subtitle": "Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.", + "sections": { + "details": { + "title": "Eventdetails", + "description": "Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal." + } + }, + "fields": { + "name": { + "label": "Eventname", + "placeholder": "z. B. Sommerfest 2025", + "help": "Die Kennung und Event-URL werden automatisch aus dem Namen generiert." + }, + "date": { + "label": "Datum" + }, + "type": { + "label": "Event-Typ", + "loading": "Event-Typ wird geladen…", + "placeholder": "Event-Typ auswählen", + "empty": "Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an." + }, + "publish": { + "label": "Event sofort veröffentlichen", + "help": "Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern." + } + }, + "actions": { + "backToList": "Zurück zur Liste", + "saving": "Speichert", + "save": "Speichern", + "cancel": "Abbrechen" + }, + "errors": { + "notice": "Hinweis" + } + }, + "notifications": { + "trigger": "Benachrichtigungen", + "title": "Benachrichtigungen", + "empty": "Aktuell ruhig", + "empty.message": "Alles erledigt – wir melden uns bei Neuigkeiten.", + "action": { + "dismiss": "Ausblenden", + "refresh": "Neue Hinweise laden" + }, + "noEvents": { + "title": "Lass uns starten", + "description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.", + "cta": "Event erstellen" + }, + "draftEvent": { + "title": "Event noch als Entwurf", + "description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.", + "cta": "Event öffnen" + }, + "upcomingEvent": { + "title": "Event startet bald", + "description_today": "Heute findet ein Event statt – checke Uploads und Tasks.", + "description_days": "Noch {{count}} Tage – bereite Einladungen und Aufgaben vor.", + "cta": "Zum Event" + }, + "pendingUploads": { + "title": "Uploads warten auf Freigabe", + "description": "{{count}} neue Uploads benötigen Moderation.", + "cta": "Uploads öffnen" + }, + "newPhotos": { + "title": "Neue Fotos eingetroffen", + "description": "{{count}} Uploads warten auf dich.", + "cta": "Galerie öffnen", + "ctaFallback": "Events ansehen" + } } } diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json index b285be6..3c1db7e 100644 --- a/resources/js/admin/i18n/locales/en/auth.json +++ b/resources/js/admin/i18n/locales/en/auth.json @@ -34,5 +34,10 @@ "return_hint": "After signing in you’ll be brought back automatically.", "support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.", "appearance_label": "Appearance" + }, + "redirecting": "Redirecting to login …", + "processing": { + "title": "Signing you in …", + "copy": "One moment please while we prepare your dashboard." } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 4ee0076..a70ce28 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -274,9 +274,18 @@ "untitled": "Untitled event" } }, - "tasks": { - "title": "Event tasks", - "subtitle": "Manage tasks associated with this event.", + "eventMenu": { + "summary": "Overview", + "photos": "Uploads", + "tasks": "Tasks", + "invites": "Invites", + "branding": "Branding", + "photobooth": "Photobooth", + "recap": "Recap" + }, + "eventTasks": { + "title": "Tasks & missions", + "subtitle": "Curate mission cards and tasks for this event.", "actions": { "back": "Back to overview", "assign": "Assign selected tasks" @@ -286,7 +295,8 @@ "load": "Event tasks could not be loaded.", "assign": "Tasks could not be assigned.", "photoOnlyEnable": "Photo-only mode could not be enabled.", - "photoOnlyDisable": "Photo-only mode could not be disabled." + "photoOnlyDisable": "Photo-only mode could not be disabled.", + "collections": "Collections could not be loaded." }, "emotions": { "error": "Could not load emotions." @@ -295,10 +305,28 @@ "notFoundTitle": "Event not found", "notFoundDescription": "Please return to the event list." }, + "tabs": { + "tasks": "Tasks", + "packs": "Mission packs" + }, "eventStatus": "Status: {{status}}", + "summary": { + "assigned": "Assigned tasks", + "library": "Library", + "mode": "Active mode", + "tasksMode": "Mission cards", + "photoOnly": "Photos only" + }, + "library": { + "hintTitle": "More templates in the task library", + "hintCopy": "Create tasks, emotions, or mission packs once and reuse them across events.", + "open": "Open task library" + }, "sections": { "assigned": { "title": "Assigned tasks", + "search": "Search tasks...", + "noResults": "No tasks match this search term.", "empty": "No tasks assigned yet." }, "library": { @@ -321,6 +349,23 @@ "switchLabel": "Enable photo-only mode", "updating": "Saving setting ..." }, + "collections": { + "title": "Mission packs", + "subtitle": "Import task collections that fit your event.", + "viewAll": "View all collections", + "errorTitle": "Collections unavailable", + "empty": "No recommended collections found.", + "tasksCount": "{{count}} tasks", + "genericType": "General", + "global": "Global", + "custom": "Custom", + "recommended": "Recommended", + "optional": "Optional", + "importCta": "Import mission pack", + "imported": "Collection imported successfully", + "importFailed": "Mission pack could not be imported", + "error": "Collections could not be loaded." + }, "toolkit": { "titleFallback": "Event-Day Toolkit", "subtitle": "Stay on top of uploads, tasks, and invites while your event is live.", @@ -768,6 +813,11 @@ "workspace": { "detailSubtitle": "Keep status, tasks, and invites of your event in one view.", "toolkitSubtitle": "Bundle moderation, tasks, and invites for the event day.", + "hero": { + "badge": "Event", + "description": "Focus on tasks, moderation, and invites for this event.", + "liveBadge": "Live?" + }, "sections": { "statusTitle": "Event status & visibility", "statusSubtitle": "Activate the event for guests or hide it temporarily." @@ -776,6 +826,7 @@ "status": "Status", "active": "Active for guests", "date": "Event date", + "noDate": "No date", "eventType": "Event type", "insights": "Recent activity", "uploadsTotal": "{{count}} uploads total", @@ -828,6 +879,7 @@ "activeInvites": "Active invites" }, "invites": { + "badge": "Invites", "title": "QR invites", "subtitle": "Keep an eye on active links and layouts.", "activeCount": "{{count}} active", @@ -836,11 +888,69 @@ "manage": "Manage layouts & invites" }, "tasks": { + "badge": "Tasks", "title": "Active tasks", "subtitle": "Motivate guests with clear prompts & highlights.", "summary": "{{completed}} of {{total}} complete", "empty": "No tasks assigned yet.", - "manage": "Open task workspace" + "manage": "Open task workspace", + "status": { + "completed": "Done", + "open": "Open" + } + }, + "recap": { + "badge": "Recap", + "subtitle": "Wrap up, export, and manage gallery runtime.", + "galleryTitle": "Gallery status", + "galleryCounts": "{{photos}} photos, {{pending}} pending, {{likes}} likes", + "open": "Open", + "closed": "Closed", + "openGallery": "Open gallery", + "closeGallery": "Close gallery", + "moderate": "View uploads", + "shareGuests": "Share guest gallery", + "shareLink": "Guest link", + "noPublicUrl": "No guest link set. Configure the public link in the event setup.", + "copyLink": "Copy link", + "copySuccess": "Link copied", + "copyError": "Link could not be shared.", + "qrTitle": "Share QR code", + "qrDownload": "Download QR code", + "qrShare": "Share link/QR", + "qrAlt": "Guest gallery QR code", + "allowDownloads": "Allow downloads", + "allowDownloadsHint": "Guests may save photos", + "allowSharing": "Allow sharing", + "allowSharingHint": "Guests may share links", + "galleryOpen": "Gallery open", + "galleryClosed": "Gallery closed", + "exportTitle": "Export & backup", + "exportCopy": "Back up all assets", + "exportHint": "Start ZIP/CSV exports and backups.", + "backup": "Backup", + "downloadAll": "Download everything", + "downloadHighlights": "Download highlights", + "highlightsHint": "“Highlights” = photos marked as highlight in the gallery.", + "retentionTitle": "Extend / archive", + "expiresAt": "Expires on {{date}}", + "noExpiry": "No expiry date set", + "retentionHint": "Extend the gallery runtime with an add-on. Extensions add up.", + "expiry": "Expiry", + "archive": "Archive/Delete", + "extendOptions": "All add-ons for this event", + "extendHint": "Extensions add up. Checkout opens in a new tab.", + "priceMissing": "Price not linked", + "noAddons": "No add-ons available right now.", + "archivedSuccess": "Event archived. Gallery is closed.", + "archiveTitle": "Archive gallery?", + "archiveDesc": "Archiving closes the gallery, deactivates guest links, and stops new uploads. Finish exports first.", + "archiveImpact": "Effects of archiving", + "archiveImpactClose": "Guest access ends; uploads/downloads are disabled.", + "archiveImpactLinks": "Public links and QR codes become invalid; existing sessions expire.", + "archiveImpactData": "Data stays internally for compliance/support and can be deleted on request (GDPR).", + "archiveConfirm": "I completed exports and want to archive now.", + "archiveConfirmCta": "Archive now" }, "branding": { "badge": "Branding & story", @@ -887,15 +997,114 @@ "feedback": { "title": "How is your event running?", "subtitle": "Your feedback helps us prioritise improvements.", - "positive": "Going great!", - "neutral": "All right", - "negative": "Needs support", - "placeholder": "Optional: tell us what works well or where you need help.", + "afterEventTitle": "Event wrapped – quick feedback?", + "afterEventCopy": "Did everything work out? Your answer helps us support future events.", + "privacyHint": "Admin-only feedback, no guest data", + "positive": "Was great", + "neutral": "Was okay", + "negative": "Needed support", + "best": { + "uploads": "Uploads & speed", + "invites": "QR invites & layouts", + "moderation": "Moderation & export", + "experience": "Overall app experience" + }, + "placeholder": "Optional: tell us what worked well or what to improve.", "errorTitle": "Feedback could not be sent.", "authError": "Your session expired. Please sign in again.", "genericError": "Feedback could not be sent.", "submit": "Send feedback", - "submitted": "Thanks!" + "submitted": "Thanks!", + "afterEventThanks": "Your feedback arrived. We’ll get back to you if we have questions.", + "sendAnother": "Send another feedback", + "supportFollowup": "Request support", + "cta": "Give feedback", + "quickSentiment": "Pick sentiment (positive/neutral/support).", + "dialogTitle": "Quick after-event feedback", + "dialogCopy": "Pick a sentiment, what worked best, and optionally what to improve.", + "sentiment": "Sentiment", + "bestQuestion": "What worked best?", + "improve": "What should we improve?", + "supportHelp": "I’d like a short follow-up (support)." + } + }, + "tasks": { + "actions": { + "back": "Back to overview", + "assign": "Assign selected tasks" + }, + "title": "Tasks & missions", + "subtitle": "Curate mission cards and tasks for this event.", + "alerts": { + "notFoundTitle": "Event not found", + "notFoundDescription": "Please return to the event list." + }, + "tabs": { + "tasks": "Tasks", + "packs": "Mission packs" + }, + "eventStatus": "Status: {{status}}", + "modes": { + "title": "Tasks & photo mode", + "tasksHint": "Tasks are active. Guests see mission cards in the app.", + "photoOnlyHint": "Photo-only mode is active. Guests can upload photos but won’t see tasks.", + "tasks": "Tasks active", + "photoOnly": "Photo-only", + "switchLabel": "Enable/disable tasks", + "updating": "Saving setting ..." + }, + "summary": { + "assigned": "Assigned tasks", + "library": "Library", + "mode": "Active mode", + "tasksMode": "Mission cards", + "photoOnly": "Photos only" + }, + "library": { + "hintTitle": "More templates in the task library", + "hintCopy": "Create tasks, emotions, or mission packs once and reuse them across events.", + "open": "Open task library" + }, + "sections": { + "assigned": { + "title": "Assigned tasks", + "search": "Search tasks...", + "noResults": "No tasks match this search term.", + "empty": "No tasks assigned yet." + }, + "library": { + "title": "Add tasks from library", + "empty": "No tasks found in the library." + } + }, + "actionsShort": { + "assign": "Assign selected tasks" + }, + "errors": { + "missingSlug": "No event slug provided.", + "load": "Tasks could not be loaded.", + "assign": "Tasks could not be assigned.", + "collections": "Collections could not be loaded.", + "photoOnlyEnable": "Photo-only mode could not be enabled.", + "photoOnlyDisable": "Photo-only mode could not be disabled." + }, + "collections": { + "errorTitle": "Collections unavailable", + "import": "Import collection", + "error": "Collections could not be loaded.", + "title": "Mission packs", + "subtitle": "Import task collections that fit your event.", + "viewAll": "View all collections", + "empty": "No recommended collections found.", + "tasksCount": "{{count}} tasks", + "genericType": "General", + "global": "Global", + "custom": "Custom", + "recommended": "Recommended", + "optional": "Optional", + "importCta": "Import mission pack", + "imported": "Collection imported successfully", + "importFailed": "Mission pack could not be imported" } }, "collections": { @@ -1185,8 +1394,235 @@ } } }, + "branding": { + "title": "Branding & fonts", + "subtitle": "Adjust colors, typography, logos/emoticons, and buttons for the guest app.", + "errors": { + "missingSlug": "No event selected – open it from the event list." + }, + "actions": { + "back": "Back to event" + }, + "sections": { + "mode": "Default vs. event-specific", + "toggleTitle": "Choose branding source", + "toggleDescription": "Use tenant defaults or override only for this event.", + "palette": "Palette & mode", + "colorsTitle": "Colors & light/dark", + "colorsDescription": "Set primary, secondary, background, and surface colors.", + "typography": "Typography & logo", + "fonts": "Fonts & logo/emoticon", + "fontDescription": "Configure heading/body fonts plus logo/emoji and alignment.", + "buttons": "Buttons & links", + "buttonsTitle": "Buttons, links & radius", + "buttonsDescription": "Choose style, radius, and optional link color.", + "preview": "Preview", + "previewTitle": "Mini guest view", + "previewCopy": "Header, CTA, and bottom navigation reflecting your branding." + }, + "useDefault": "Use default", + "useCustom": "Event-specific", + "toggleHint": "Default uses tenant colors; event-specific overrides them.", + "standard": "Default", + "custom": "Event", + "toggleAria": "Toggle event-specific branding", + "mode": "Mode", + "modeAuto": "Auto", + "modeLight": "Light", + "modeDark": "Dark", + "typography": { + "heading": "Heading font", + "body": "Body font" + }, + "size": "Font size", + "logo": { + "value": "Emoticon/Logo URL", + "mode": "Logo mode", + "position": "Position" + }, + "emoticon": "Emoticon/Text", + "upload": "Upload/URL", + "left": "Left", + "center": "Center", + "right": "Right", + "palette": { + "primary": "Primary", + "secondary": "Secondary", + "surface": "Surface" + }, + "buttonStyle": "Style", + "buttons": { + "style": "Style", + "radius": "Radius", + "primary": "Button primary", + "secondary": "Button secondary", + "linkColor": "Link color" + }, + "filled": "Filled", + "outline": "Outline", + "radius": "Radius", + "linkColor": "Link color", + "buttonPrimary": "Button primary", + "buttonSecondary": "Button secondary", + "reset": "Reset to default", + "save": "Save branding", + "saving": "Saving...", + "saved": "Branding saved.", + "saveError": "Branding could not be saved.", + "footer": { + "default": "Tenant default colors active.", + "custom": "Event-specific branding active." + }, + "usingDefault": "Tenant branding active", + "usingCustom": "Event branding active", + "preview": { + "demoTitle": "Demo event", + "guestView": "Guest view · {{mode}}", + "ctaCopy": "CTA & buttons reflect the chosen style.", + "cta": "Upload photos now", + "bottomNav": "Bottom navigation" + } + }, + "taskLibrary": { + "titles": { + "default": "Task library", + "embedded": "Tasks" + }, + "subtitles": { + "default": "Assign tasks and track progress around your events.", + "embedded": "Plan tasks, actions, and highlights for your guests." + }, + "errors": { + "title": "Error", + "load": "Tasks could not be loaded." + }, + "actions": { + "collections": "Collections", + "new": "New", + "searchPlaceholder": "Search tasks …" + }, + "pagination": { + "page": "Page {{current}} of {{total}} · {{count}} entries", + "summary": "Total {{count}} tasks · Page {{current}} of {{total}}", + "prev": "Back", + "next": "Next" + }, + "form": { + "editTitle": "Edit task", + "createTitle": "Create task", + "title": "Title", + "description": "Description", + "descriptionPlaceholder": "What should guests do?", + "priority": "Priority", + "priorityPlaceholder": "Choose priority", + "dueDate": "Due date", + "completedTitle": "Already completed?", + "completedCopy": "Mark tasks as done when they should no longer be visible.", + "cancel": "Cancel", + "save": "Save" + }, + "priorities": { + "low": "Low", + "medium": "Medium", + "high": "High", + "urgent": "Urgent" + }, + "list": { + "template": "Template #{{id}}", + "edit": "Edit", + "delete": "Delete" + }, + "empty": { + "title": "No tasks yet", + "description": "Create a new task or import templates to inspire your guests.", + "cta": "Create first task" + } + }, "billingWarning": { "title": "Needs attention", "description": "Package alerts and limits you should keep an eye on." + }, + "eventForm": { + "errors": { + "nameRequired": "Please enter an event name.", + "typeRequired": "Please select an event type." + }, + "titles": { + "create": "Create event", + "edit": "Edit event" + }, + "subtitle": "Fill in the essentials and share your event with guests.", + "sections": { + "details": { + "title": "Event details", + "description": "Name, URL, and date define how guests see your event." + } + }, + "fields": { + "name": { + "label": "Event name", + "placeholder": "e.g. Summer Party 2025", + "help": "The slug and event URL are generated from the name." + }, + "date": { + "label": "Date" + }, + "type": { + "label": "Event type", + "loading": "Loading event types…", + "placeholder": "Select event type", + "empty": "No event types available yet. Please add one in the admin area." + }, + "publish": { + "label": "Publish immediately", + "help": "Enable if guests should see the event right away. You can change the status later." + } + }, + "actions": { + "backToList": "Back to list", + "saving": "Saving", + "save": "Save", + "cancel": "Cancel" + }, + "errors": { + "notice": "Notice" + } + }, + "notifications": { + "trigger": "Notifications", + "title": "Notifications", + "empty": "All clear", + "empty.message": "All caught up — we’ll notify you about updates.", + "action": { + "dismiss": "Dismiss", + "refresh": "Load new alerts" + }, + "noEvents": { + "title": "Let’s get started", + "description": "Create your first event to unlock uploads, tasks, and invites.", + "cta": "Create event" + }, + "draftEvent": { + "title": "Event still a draft", + "description": "Publish to enable invites and the gallery.", + "cta": "Open event" + }, + "upcomingEvent": { + "title": "Event starts soon", + "description_today": "Today’s event — check uploads and tasks.", + "description_days": "{{count}} days left — prepare invites and tasks.", + "cta": "Open event" + }, + "pendingUploads": { + "title": "Uploads awaiting review", + "description": "{{count}} new uploads need moderation.", + "cta": "Open uploads" + }, + "newPhotos": { + "title": "New photos arrived", + "description": "{{count}} uploads are waiting for you.", + "cta": "Open gallery", + "ctaFallback": "View events" + } } } diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index 0b54893..59a086f 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -22,6 +22,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts return []; } + const eventDate = event.event_date ? new Date(event.event_date) : null; + const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false; + const formatBadge = (value?: number | null): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -63,10 +66,12 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts label: translate('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(event.slug), }, - { - key: 'recap', - label: translate('eventMenu.recap', 'Nachbereitung'), - href: ADMIN_EVENT_RECAP_PATH(event.slug), - }, + ...(hasPassed + ? [{ + key: 'recap', + label: translate('eventMenu.recap', 'Nachbereitung'), + href: ADMIN_EVENT_RECAP_PATH(event.slug), + }] + : []), ]; } diff --git a/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx b/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx index 5536e81..48594b8 100644 --- a/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx +++ b/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx @@ -24,6 +24,15 @@ vi.mock('../../components/LanguageSwitcher', () => ({ LanguageSwitcher: () =>
, })); +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }), +})); + +vi.mock('../../api', () => ({ + fetchOnboardingStatus: vi.fn().mockResolvedValue(null), + trackOnboarding: vi.fn(), +})); + describe('WelcomeLandingPage', () => { beforeEach(() => { localStorage.clear(); diff --git a/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx index e026304..71683e2 100644 --- a/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx +++ b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx @@ -5,6 +5,10 @@ import '@testing-library/jest-dom'; import type { TFunction } from 'i18next'; import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage'; +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }), +})); + const { createPaddleCheckoutMock } = vi.hoisted(() => ({ createPaddleCheckoutMock: vi.fn(), })); diff --git a/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx b/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx new file mode 100644 index 0000000..c0effbf --- /dev/null +++ b/resources/js/admin/onboarding/pages/WelcomeLandingPage.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ADMIN_EVENTS_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants'; + +const STORAGE_KEY = 'tenant-admin:onboarding-progress'; + +export default function WelcomeLandingPage() { + const navigate = useNavigate(); + + React.useEffect(() => { + try { + const prev = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); + const next = { ...prev, welcomeSeen: true, lastStep: 'landing' }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch (error) { + console.warn('Failed to persist onboarding progress', error); + } + }, []); + + return ( +
+
+

Tenant Admin Onboarding

+

Starte mit der Einrichtung deines Workspaces.

+
+ + +
+
+ +
+ +
+
+ ); +} + diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx new file mode 100644 index 0000000..104e5b9 --- /dev/null +++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { TFunction } from 'i18next'; +import { createTenantPaddleCheckout } from '../../api'; + +type PaddleCheckoutProps = { + packageId: number; + onSuccess: () => void; + t: TFunction; +}; + +export function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) { + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const handleClick = async () => { + if (busy) return; + setBusy(true); + setError(null); + + try { + const checkout = await createTenantPaddleCheckout(packageId); + if (checkout?.checkout_url) { + window.open(checkout.checkout_url, '_blank', 'noopener'); + } + onSuccess(); + } catch (err) { + const message = err instanceof Error ? err.message : t('errors.generic', 'Fehler'); + setError(message); + } finally { + setBusy(false); + } + }; + + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} + +
+ ); +} + +export default function WelcomeOrderSummaryPage() { + return ( +
+

Order summary placeholder

+
+ ); +} + diff --git a/resources/js/admin/pages/AuthCallbackPage.tsx b/resources/js/admin/pages/AuthCallbackPage.tsx index 48ffa25..22c14b9 100644 --- a/resources/js/admin/pages/AuthCallbackPage.tsx +++ b/resources/js/admin/pages/AuthCallbackPage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/context'; import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; @@ -7,6 +8,7 @@ import { decodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; export default function AuthCallbackPage(): React.ReactElement { const { status } = useAuth(); const navigate = useNavigate(); + const { t } = useTranslation('auth'); const [redirected, setRedirected] = React.useState(false); const searchParams = React.useMemo(() => new URLSearchParams(window.location.search), []); @@ -35,10 +37,8 @@ export default function AuthCallbackPage(): React.ReactElement { return (
- Anmeldung wird verarbeitet … -

- Bitte warte einen Moment. Wir richten dein Dashboard ein. -

+ {t('processing.title', 'Signing you in …')} +

{t('processing.copy', 'One moment please while we prepare your dashboard.')}

); } diff --git a/resources/js/admin/pages/EventBrandingPage.tsx b/resources/js/admin/pages/EventBrandingPage.tsx index 163d5d8..20478f9 100644 --- a/resources/js/admin/pages/EventBrandingPage.tsx +++ b/resources/js/admin/pages/EventBrandingPage.tsx @@ -681,6 +681,7 @@ export default function EventBrandingPage(): React.ReactElement { } function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: 'light' | 'dark' }) { + const { t } = useTranslation('management'); const textColor = getContrastingTextColor(branding.palette.primary, '#0f172a', '#ffffff'); const headerStyle: React.CSSProperties = { background: `linear-gradient(135deg, ${branding.palette.primary}, ${branding.palette.secondary})`, @@ -709,9 +710,11 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '
- Demo Event + {t('branding.preview.demoTitle', 'Demo Event')} - Gastansicht · {branding.mode} + + {t('branding.preview.guestView', { mode: branding.mode, defaultValue: 'Guest view · {{mode}}' })} +
@@ -719,7 +722,7 @@ function BrandingPreview({ branding, theme }: { branding: BrandingForm; theme: '

- CTA & Buttons spiegeln den gewählten Stil wider. + {t('branding.preview.ctaCopy', 'CTA & buttons reflect the chosen style.')}

- Bottom Navigation + + {t('branding.preview.bottomNav', 'Bottom navigation')} +
diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 87e81bd..179b632 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -59,6 +59,8 @@ import { ADMIN_EVENT_BRANDING_PATH, buildEngagementTabPath, } from '../constants'; +import { buildEventTabs } from '../lib/eventTabs'; +import { formatEventDate } from '../lib/events'; import { SectionCard, SectionHeader, @@ -207,6 +209,18 @@ export default function EventDetailPage() { const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.'); + const currentTabKey = 'overview'; + + const eventTabs = React.useMemo(() => { + if (!event) return []; + const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const counts = { + photos: stats?.uploads_total ?? event.photo_count ?? undefined, + tasks: toolkitData?.tasks?.summary?.total ?? event.tasks_count ?? undefined, + invites: event.active_invites_count ?? event.total_invites_count ?? undefined, + }; + return buildEventTabs(event, translateMenu, counts); + }, [event, stats?.uploads_total, toolkitData?.tasks?.summary?.total, t]); const limitWarnings = React.useMemo( () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), @@ -352,6 +366,8 @@ const shownWarningToasts = React.useRef>(new Set()); {error && ( @@ -538,6 +554,159 @@ function resolveName(name: TenantEvent['name']): string { return 'Event'; } +type RecapContentProps = { + event: TenantEvent; + stats: EventStats; + busy: boolean; + onToggleEvent: () => void; + onExtendGallery: () => void; +}; + +function RecapContent({ event, stats, busy, onToggleEvent, onExtendGallery }: RecapContentProps) { + const { t } = useTranslation('management'); + const navigate = useNavigate(); + + const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null; + const galleryStatusLabel = event.is_active + ? t('events.recap.galleryOpen', 'Galerie geöffnet') + : t('events.recap.galleryClosed', 'Galerie geschlossen'); + + const counts = { + photos: stats.uploads_total ?? stats.total ?? 0, + pending: stats.pending_photos ?? 0, + likes: stats.likes_total ?? stats.likes ?? 0, + }; + + return ( +
+
+
+
+
+

+ {t('events.recap.galleryTitle', 'Galerie-Status')} +

+

{galleryStatusLabel}

+

+ {t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)} +

+
+ + {event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')} + +
+
+ + +
+ {event.public_url ? ( +
+ {event.public_url} + +
+ ) : null} +
+ +
+
+
+

+ {t('events.recap.exportTitle', 'Export & Backup')} +

+

+ {t('events.recap.exportCopy', 'Alle Assets sichern')} +

+

+ {t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')} +

+
+ + {t('events.recap.backup', 'Backup')} + +
+
+ + +
+
+
+ +
+
+
+
+

+ {t('events.recap.retentionTitle', 'Aufbewahrung & Verlängerung')} +

+

+ {galleryExpiresAt + ? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) }) + : t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')} +

+

+ {t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')} +

+
+ + {t('events.recap.expiry', 'Ablauf')} + +
+
+ + +
+
+ +
+
+
+

+ {t('events.recap.commsTitle', 'Kommunikation')} +

+

+ {t('events.recap.commsCopy', 'Kein Gast-Newsletter aktiv')} +

+

+ {t('events.recap.commsHint', 'Push wirkt vor allem live. Teile den Link manuell mit deinem Team oder auf Social Media.')} +

+
+ + {t('events.recap.manual', 'Manuell')} + +
+ {event.public_url ? ( +
+
{event.public_url}
+ +
+ ) : null} +
+
+
+ ); +} + function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType }) { const { t } = useTranslation('management'); @@ -664,6 +833,7 @@ function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tas } function TaskRow({ task }: { task: EventToolkitTask }) { + const { t } = useTranslation('management'); return (
@@ -671,7 +841,7 @@ function TaskRow({ task }: { task: EventToolkitTask }) { {task.description ?

{task.description}

: null}
- {task.is_completed ? 'Erledigt' : 'Offen'} + {task.is_completed ? t('events.tasks.status.completed', 'Done') : t('events.tasks.status.open', 'Open')}
); diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 77e8b03..80ca52c 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -26,7 +26,7 @@ import { TenantEvent, } from '../api'; import { isAuthError } from '../auth/tokens'; -import { isApiError } from '../lib/apiError'; +import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; @@ -68,7 +68,8 @@ export default function EventFormPage() { const isEdit = Boolean(slugParam); const navigate = useNavigate(); - const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' }); + const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' }); + const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' }); const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' }); const [form, setForm] = React.useState({ @@ -195,6 +196,21 @@ export default function EventFormPage() { slugSuffixRef.current = null; }, [isEdit, loadedEvent]); + React.useEffect(() => { + if (!isEdit || !loadedEvent || !eventTypes || eventTypes.length === 0) { + return; + } + + if (loadedEvent.event_type_id) { + return; + } + + setForm((prev) => ({ + ...prev, + eventTypeId: prev.eventTypeId ?? eventTypes[0]!.id, + })); + }, [eventTypes, isEdit, loadedEvent]); + React.useEffect(() => { if (!isEdit || !eventLoadError) { return; @@ -266,7 +282,7 @@ export default function EventFormPage() { const trimmedName = form.name.trim(); if (!trimmedName) { - setError('Bitte gib einen Eventnamen ein.'); + setError(tForm('errors.nameRequired', 'Bitte gib einen Eventnamen ein.')); return; } @@ -276,7 +292,7 @@ export default function EventFormPage() { } if (!form.eventTypeId) { - setError('Bitte wähle einen Event-Typ aus.'); + setError(tForm('errors.typeRequired', 'Bitte wähle einen Event-Typ aus.')); return; } @@ -297,9 +313,7 @@ export default function EventFormPage() { event_type_id: form.eventTypeId, event_date: form.date || undefined, status, - ...(shouldIncludePackage && packageIdForSubmit - ? { package_id: Number(packageIdForSubmit) } - : {}), + ...(packageIdForSubmit ? { package_id: Number(packageIdForSubmit) } : {}), }; try { @@ -324,25 +338,24 @@ export default function EventFormPage() { const limit = Number(err.meta?.limit ?? 0); const used = Number(err.meta?.used ?? 0); const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used)); - const detail = limit > 0 - ? tCommon('eventLimitDetails', { used, limit, remaining }) - : ''; - setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`); + const detail = limit > 0 ? tErrors('eventLimitDetails', { used, limit, remaining }) : ''; + setError(`${tErrors('eventLimit')}${detail ? `\n${detail}` : ''}`); setShowUpgradeHint(true); break; } case 'event_credits_exhausted': { - setError(tCommon('creditsExhausted')); + setError(tErrors('creditsExhausted')); setShowUpgradeHint(true); break; } default: { - setError(err.message || tCommon('generic')); + const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null; + setError(metaErrors || err.message || tErrors('generic')); setShowUpgradeHint(false); } } } else { - setError(tCommon('generic')); + setError(getApiErrorMessage(err, tErrors('generic'))); setShowUpgradeHint(false); } } @@ -439,19 +452,19 @@ export default function EventFormPage() { onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50" > - Zurück zur Liste + {tForm('actions.backToList', 'Zurück zur Liste')} ); return ( {error && ( - Hinweis + {tForm('errors.notice', 'Hinweis')} {error.split('\n').map((line, index) => ( {line} @@ -459,7 +472,7 @@ export default function EventFormPage() { {showUpgradeHint && (
)} @@ -490,10 +503,10 @@ export default function EventFormPage() { - Eventdetails + {tForm('sections.details.title', 'Eventdetails')} - Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal. + {tForm('sections.details.description', 'Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.')} @@ -503,20 +516,20 @@ export default function EventFormPage() {
- + handleNameChange(e.target.value)} autoFocus />

- Die Kennung und Event-URL werden automatisch aus dem Namen generiert. + {tForm('fields.name.help', 'Die Kennung und Event-URL werden automatisch aus dem Namen generiert.')}

- +
- + {!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (

- Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an. + {tForm('fields.type.empty', 'Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.')}

) : null}
@@ -560,10 +577,10 @@ export default function EventFormPage() { />

- Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern. + {tForm('fields.publish.help', 'Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.')}

@@ -576,16 +593,16 @@ export default function EventFormPage() { > {saving ? ( <> - Speichert + {tForm('actions.saving', 'Speichert')} ) : ( <> - Speichern + {tForm('actions.save', 'Speichern')} )}
diff --git a/resources/js/admin/pages/EventRecapPage.tsx b/resources/js/admin/pages/EventRecapPage.tsx new file mode 100644 index 0000000..23513e0 --- /dev/null +++ b/resources/js/admin/pages/EventRecapPage.tsx @@ -0,0 +1,931 @@ +// @ts-nocheck +import React from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeft, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } from 'lucide-react'; +import toast from 'react-hot-toast'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { AdminLayout } from '../components/AdminLayout'; +import { + createEventAddonCheckout, + EventQrInvite, + EventStats, + getEvent, + getEventStats, + getEventQrInvites, + getAddonCatalog, + type EventAddonCatalogItem, + TenantEvent, + toggleEvent, + submitTenantFeedback, +} from '../api'; +import { updateEvent } from '../api'; +import { buildEventTabs } from '../lib/eventTabs'; +import { formatEventDate } from '../lib/events'; +import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage } from '../lib/apiError'; +import { + ADMIN_EVENT_EDIT_PATH, + ADMIN_EVENT_PHOTOS_PATH, + ADMIN_EVENTS_PATH, +} from '../constants'; + +export default function EventRecapPage() { + const { slug: slugParam } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation('management'); + const [searchParams, setSearchParams] = useSearchParams(); + + const slug = slugParam ?? null; + + const [event, setEvent] = React.useState(null); + const [stats, setStats] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [busy, setBusy] = React.useState(false); + const [settingsBusy, setSettingsBusy] = React.useState(false); + const [error, setError] = React.useState(null); + const [joinTokens, setJoinTokens] = React.useState([]); + const [addonsCatalog, setAddonsCatalog] = React.useState([]); + const [addonBusyKey, setAddonBusyKey] = React.useState(null); + + const loadEventData = React.useCallback(async () => { + if (!slug) { + setLoading(false); + setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.')); + return; + } + + setLoading(true); + setError(null); + + try { + const [eventData, statsData, invites, addons] = await Promise.all([ + getEvent(slug), + getEventStats(slug), + getEventQrInvites(slug), + getAddonCatalog(), + ]); + + setEvent(eventData); + setStats(statsData); + setJoinTokens(invites); + setAddonsCatalog(addons); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'))); + } + } finally { + setLoading(false); + } + }, [slug, t]); + + React.useEffect(() => { + void loadEventData(); + }, [loadEventData]); + + React.useEffect(() => { + if (!searchParams.get('addon_success')) { + return; + } + + toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.')); + void loadEventData(); + + const next = new URLSearchParams(searchParams); + next.delete('addon_success'); + setSearchParams(next, { replace: true }); + }, [loadEventData, searchParams, setSearchParams, t]); + + const handleToggleEvent = React.useCallback(async () => { + if (!slug) return; + setBusy(true); + setError(null); + try { + const updated = await toggleEvent(slug); + setEvent(updated); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.'))); + } + } finally { + setBusy(false); + } + }, [slug, t]); + + const handleAddonCheckout = React.useCallback(async (addonKey: string) => { + if (!slug) return; + + setAddonBusyKey(addonKey); + setError(null); + + try { + const currentUrl = window.location.origin + window.location.pathname; + const successUrl = `${currentUrl}?addon_success=1`; + const checkout = await createEventAddonCheckout(slug, { + addon_key: addonKey, + quantity: 1, + success_url: successUrl, + cancel_url: currentUrl, + }); + + if (checkout.checkout_url) { + window.location.href = checkout.checkout_url; + } else { + toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.')); + } + } catch (err) { + if (!isAuthError(err)) { + toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.'))); + } + } finally { + setAddonBusyKey(null); + } + }, [slug, t]); + + const handleArchive = React.useCallback(async () => { + if (!slug || !event) return; + setArchiveBusy(true); + setError(null); + + try { + const updated = await updateEvent(slug, { status: 'archived', is_active: false }); + setEvent(updated); + setArchiveOpen(false); + toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.')); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.'))); + } + } finally { + setArchiveBusy(false); + } + }, [event, slug, t]); + + const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => { + if (!slug || !event) return; + setSettingsBusy(true); + setError(null); + try { + const updated = await updateEvent(slug, { + settings: { + ...(event.settings ?? {}), + [key]: value, + }, + }); + setEvent(updated); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.'))); + } + } finally { + setSettingsBusy(false); + } + }, [event, slug, t]); + + if (!slug) { + return ( + + + {t('events.errors.notFoundTitle', 'Event nicht gefunden')} + {t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')} + + + ); + } + + const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); + const activeInvite = joinTokens.find((token) => token.is_active); + const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null); + const guestLink = buildAbsoluteGuestLink(guestLinkRaw); + const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null; + const eventTabs = event + ? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), { + photos: stats?.uploads_total ?? event.photo_count ?? undefined, + tasks: stats?.uploads_total ?? event.tasks_count ?? undefined, + invites: event.active_invites_count ?? event.total_invites_count ?? undefined, + }) + : []; + + return ( + + {error && ( + + {t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')} + {error} + + )} + + {loading ? ( + + ) : event && stats ? ( + { + navigator.clipboard.writeText(guestLink); + toast.success(t('events.recap.copySuccess', 'Link kopiert')); + } : undefined} + onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} + onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} + onBack={() => navigate(ADMIN_EVENTS_PATH)} + settingsBusy={settingsBusy} + onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)} + onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)} + /> + ) : ( + + {t('events.errors.notFoundTitle', 'Event nicht gefunden')} + {t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')} + + )} + + ); +} + +type RecapContentProps = { + event: TenantEvent; + stats: EventStats; + busy: boolean; + onToggleEvent: () => void; + guestLink: string | null; + guestQrCodeDataUrl: string | null; + addonsCatalog: EventAddonCatalogItem[]; + addonBusyKey: string | null; + onCheckoutAddon: (addonKey: string) => void; + onArchive: () => void; + onCopyLink?: () => void; + onOpenPhotos: () => void; + onEditEvent: () => void; + onBack: () => void; + settingsBusy: boolean; + onToggleDownloads: (value: boolean) => void; + onToggleSharing: (value: boolean) => void; +}; + +function RecapContent({ + event, + stats, + busy, + onToggleEvent, + guestLink, + guestQrCodeDataUrl, + addonsCatalog, + addonBusyKey, + onCheckoutAddon, + onArchive, + onCopyLink, + onOpenPhotos, + onEditEvent, + onBack, + settingsBusy, + onToggleDownloads, + onToggleSharing, +}: RecapContentProps) { + const { t } = useTranslation('management'); + const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null; + const galleryStatusLabel = event.is_active + ? t('events.recap.galleryOpen', 'Galerie geöffnet') + : t('events.recap.galleryClosed', 'Galerie geschlossen'); + + const counts = { + photos: stats.uploads_total ?? stats.total ?? 0, + pending: stats.pending_photos ?? 0, + likes: stats.likes_total ?? stats.likes ?? 0, + }; + + const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null); + const [feedbackMessage, setFeedbackMessage] = React.useState(''); + const [feedbackBusy, setFeedbackBusy] = React.useState(false); + const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); + const [feedbackError, setFeedbackError] = React.useState(undefined); + const [archiveOpen, setArchiveOpen] = React.useState(false); + const [archiveBusy, setArchiveBusy] = React.useState(false); + const [archiveConfirmed, setArchiveConfirmed] = React.useState(false); + const [feedbackOpen, setFeedbackOpen] = React.useState(false); + const [feedbackBestArea, setFeedbackBestArea] = React.useState(null); + const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false); + + const galleryAddons = React.useMemo( + () => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')), + [addonsCatalog], + ); + const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog; + const defaultAddon = addonsToShow[0] ?? null; + + const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => { + const increments = addon.increments ?? {}; + const photos = (increments as Record).photos ?? (increments as Record).extra_photos; + const guests = (increments as Record).guests ?? (increments as Record).extra_guests; + const galleryDays = (increments as Record).gallery_days + ?? (increments as Record).extra_gallery_days; + + const parts: string[] = []; + + if (typeof photos === 'number' && photos > 0) { + parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() })); + } + + if (typeof guests === 'number' && guests > 0) { + parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() })); + } + + if (typeof galleryDays === 'number' && galleryDays > 0) { + parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays })); + } + + return parts.length ? parts.join(' · ') : null; + }, [t]); + + const copy = { + positive: t('events.feedback.positive', 'War super'), + neutral: t('events.feedback.neutral', 'In Ordnung'), + negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'), + }; + + const bestAreaOptions = [ + { key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') }, + { key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') }, + { key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') }, + { key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') }, + ]; + + const handleQrDownload = React.useCallback(() => { + if (!guestQrCodeDataUrl) return; + + const link = document.createElement('a'); + link.href = guestQrCodeDataUrl; + link.download = 'guest-gallery-qr.png'; + link.click(); + }, [guestQrCodeDataUrl]); + + const handleQrShare = React.useCallback(async () => { + if (!guestLink) return; + + if (navigator.share) { + try { + await navigator.share({ + title: resolveName(event.name), + text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'), + url: guestLink, + }); + return; + } catch { + // Ignore share cancellation and fall back to copy. + } + } + + try { + await navigator.clipboard.writeText(guestLink); + toast.success(t('events.recap.copySuccess', 'Link kopiert')); + } catch { + toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.')); + } + }, [event.name, guestLink, t]); + + const handleFeedbackSubmit = React.useCallback(async () => { + if (feedbackBusy) return; + + setFeedbackBusy(true); + setFeedbackError(undefined); + + try { + await submitTenantFeedback({ + category: 'event_workspace_after_event', + event_slug: event.slug, + sentiment: sentiment ?? undefined, + message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined, + metadata: { + best_area: feedbackBestArea, + needs_support: feedbackNeedsSupport, + event_name: resolveName(event.name), + guest_link: guestLink, + }, + }); + + setFeedbackSubmitted(true); + setFeedbackOpen(false); + setFeedbackMessage(''); + setFeedbackNeedsSupport(false); + toast.success(t('events.feedback.submitted', 'Danke!')); + } catch (err) { + setFeedbackError(isAuthError(err) + ? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.') + : t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')); + } finally { + setFeedbackBusy(false); + } + }, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]); + + return ( +
+
+
+

+ {t('events.recap.badge', 'Nachbereitung')} +

+

{resolveName(event.name)}

+

+ {t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')} +

+
+ + + {event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')} + + + + {event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')} + +
+
+
+ + + +
+
+ +
+
+
+
+

+ {t('events.recap.galleryTitle', 'Galerie-Status')} +

+

{galleryStatusLabel}

+

+ {t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)} +

+
+ + {event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')} + +
+
+ + +
+
+
+
+

+ {t('events.recap.shareLink', 'Gäste-Link')} +

+ {guestLink ? ( + {guestLink} + ) : ( +

+ {t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')} +

+ )} +
+ {guestLink && onCopyLink ? ( + + ) : null} +
+
+
+
+

{t('events.recap.allowDownloads', 'Downloads erlauben')}

+

{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}

+
+ onToggleDownloads(Boolean(checked))} + disabled={settingsBusy} + /> +
+
+
+

{t('events.recap.allowSharing', 'Teilen erlauben')}

+

{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}

+
+ onToggleSharing(Boolean(checked))} + disabled={settingsBusy} + /> +
+
+ {guestQrCodeDataUrl ? ( +
+
+ {t('events.recap.qrAlt', +
+
+
+

{t('events.recap.qrTitle', 'QR-Code teilen')}

+ {guestLink ? ( +

{guestLink}

+ ) : null} +
+
+ + +
+
+
+ ) : null} +
+
+ +
+
+
+

+ {t('events.recap.exportTitle', 'Export & Backup')} +

+

+ {t('events.recap.exportCopy', 'Alle Assets sichern')} +

+

+ {t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')} +

+
+ + {t('events.recap.backup', 'Backup')} + +
+
+ + +
+

+ {t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')} +

+
+
+ +
+
+
+
+

+ {t('events.recap.retentionTitle', 'Verlängerung / Archivierung')} +

+

+ {galleryExpiresAt + ? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) }) + : t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')} +

+

+ {t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')} +

+
+ + {t('events.recap.expiry', 'Ablauf')} + +
+
+ + +
+ + {addonsToShow.length ? ( +
+

+ {t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')} +

+ +
+ {addonsToShow.map((addon) => ( +
+
+

+ {addon.label} +

+

+ {describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')} +

+
+ +
+ ))} +
+ +

+ {t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')} +

+
+ ) : ( +

+ {t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')} +

+ )} +
+ +
+
+
+

+ {t('events.feedback.badge', 'Feedback')} +

+

+ {t('events.feedback.afterEventTitle', 'Event beendet – kurzes Feedback?')} +

+

+ {t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}
+ {t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')} +

+
+ + {t('events.feedback.badgeShort', 'Feedback')} + +
+ +
+ + {resolveName(event.name)} + + {event.event_date ? ( + + {formatEventDate(event.event_date, undefined)} + + ) : null} +
+ + {feedbackSubmitted ? ( +
+

{t('events.feedback.submitted', 'Danke!')}

+

{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}

+
+ + +
+
+ ) : ( +
+ +
+ {t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')} +
+
+ )} + + {feedbackError ? ( + + {t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')} + {feedbackError} + + ) : null} +
+
+ + { setFeedbackOpen(open); setFeedbackError(undefined); }}> + + + {t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')} + + {t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')} + + + +
+
+

{t('events.feedback.sentiment', 'Stimmung')}

+
+ {(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => ( + + ))} +
+
+ +
+

{t('events.feedback.bestQuestion', 'Was lief am besten?')}

+
+ {bestAreaOptions.map((option) => ( + + ))} +
+
+ +
+

{t('events.feedback.improve', 'Was sollen wir verbessern?')}

+