diff --git a/app/Filament/Resources/InviteLayouts/InviteLayoutResource.php b/app/Filament/Resources/InviteLayouts/InviteLayoutResource.php new file mode 100644 index 0000000..6cb4e8e --- /dev/null +++ b/app/Filament/Resources/InviteLayouts/InviteLayoutResource.php @@ -0,0 +1,129 @@ + ListInviteLayouts::route('/'), + 'create' => CreateInviteLayout::route('/create'), + 'edit' => EditInviteLayout::route('/{record}/edit'), + ]; + } + + public static function normalizePayload(array $data): array + { + $data['slug'] = Str::slug($data['slug'] ?? $data['name'] ?? 'layout'); + + $preview = $data['preview'] ?? []; + $qrSize = Arr::get($preview, 'qr.size_px', Arr::get($preview, 'qr_size_px')); + $svgWidth = Arr::get($preview, 'svg.width', Arr::get($preview, 'svg_width')); + $svgHeight = Arr::get($preview, 'svg.height', Arr::get($preview, 'svg_height')); + + $data['preview'] = array_filter([ + 'background' => $preview['background'] ?? null, + 'background_gradient' => $preview['background_gradient'] ?? null, + 'accent' => $preview['accent'] ?? null, + 'secondary' => $preview['secondary'] ?? null, + 'text' => $preview['text'] ?? null, + 'badge' => $preview['badge'] ?? null, + 'qr' => array_filter([ + 'size_px' => $qrSize !== null ? (int) $qrSize : null, + ]), + 'svg' => array_filter([ + 'width' => $svgWidth !== null ? (int) $svgWidth : null, + 'height' => $svgHeight !== null ? (int) $svgHeight : null, + ]), + ], fn ($value) => $value !== null && (! is_array($value) || ! empty($value))); + + if (empty($data['preview']['qr'])) { + unset($data['preview']['qr']); + } + + if (empty($data['preview']['svg'])) { + unset($data['preview']['svg']); + } + + $layoutOptions = $data['layout_options'] ?? []; + $formats = $layoutOptions['formats'] ?? ['pdf', 'svg']; + if (is_string($formats)) { + $formats = array_values(array_filter(array_map('trim', explode(',', $formats)))); + } + $layoutOptions['formats'] = $formats ?: ['pdf', 'svg']; + + $data['layout_options'] = array_filter([ + 'badge_label' => $layoutOptions['badge_label'] ?? null, + 'instructions_heading' => $layoutOptions['instructions_heading'] ?? null, + 'link_heading' => $layoutOptions['link_heading'] ?? null, + 'cta_label' => $layoutOptions['cta_label'] ?? null, + 'cta_caption' => $layoutOptions['cta_caption'] ?? null, + 'link_label' => $layoutOptions['link_label'] ?? null, + 'logo_url' => $layoutOptions['logo_url'] ?? null, + 'formats' => $layoutOptions['formats'], + ], fn ($value) => $value !== null && $value !== []); + + if (empty($data['layout_options']['logo_url'])) { + unset($data['layout_options']['logo_url']); + } + + $instructions = $data['instructions'] ?? []; + if (is_array($instructions) && isset($instructions[0]) && is_array($instructions[0]) && array_key_exists('value', $instructions[0])) { + $instructions = array_map(fn ($item) => $item['value'] ?? null, $instructions); + } + $data['instructions'] = array_values(array_filter(array_map(fn ($value) => is_string($value) ? trim($value) : null, $instructions))); + + return $data; + } +} diff --git a/app/Filament/Resources/InviteLayouts/Pages/CreateInviteLayout.php b/app/Filament/Resources/InviteLayouts/Pages/CreateInviteLayout.php new file mode 100644 index 0000000..c3498ae --- /dev/null +++ b/app/Filament/Resources/InviteLayouts/Pages/CreateInviteLayout.php @@ -0,0 +1,20 @@ +schema([ + Section::make('Grunddaten') + ->columns(2) + ->schema([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255) + ->afterStateUpdated(fn (callable $set, $state) => $set('slug', Str::slug((string) $state ?? ''))) + ->reactive(), + TextInput::make('slug') + ->label('Slug') + ->required() + ->maxLength(191) + ->unique(ignoreRecord: true), + TextInput::make('subtitle') + ->label('Unterzeile') + ->maxLength(255) + ->columnSpanFull(), + Textarea::make('description') + ->label('Beschreibung') + ->rows(4) + ->columnSpanFull(), + Toggle::make('is_active') + ->label('Aktiv') + ->default(true), + ]), + + Section::make('Papier & Format') + ->columns(3) + ->schema([ + Select::make('paper') + ->label('Papierformat') + ->options([ + 'a4' => 'A4 (210 × 297 mm)', + 'a5' => 'A5 (148 × 210 mm)', + 'a3' => 'A3 (297 × 420 mm)', + 'custom' => 'Benutzerdefiniert', + ]) + ->default('a4'), + Select::make('orientation') + ->label('Ausrichtung') + ->options([ + 'portrait' => 'Hochformat', + 'landscape' => 'Querformat', + ]) + ->default('portrait'), + TextInput::make('layout_options.formats') + ->label('Formate (Komma getrennt)') + ->default('pdf,svg') + ->helperText('Bestimmt, welche Downloads angeboten werden.') + ->dehydrateStateUsing(fn ($state) => collect(explode(',', (string) $state)) + ->map(fn ($value) => trim((string) $value)) + ->filter() + ->values() + ->all()) + ->afterStateHydrated(function (TextInput $component, $state) { + if (is_array($state)) { + $component->state(implode(',', $state)); + } + }), + ]), + + Section::make('Farben') + ->columns(5) + ->schema([ + ColorPicker::make('preview.background') + ->label('Hintergrund') + ->default('#F9FAFB'), + ColorPicker::make('preview.accent') + ->label('Akzent') + ->default('#6366F1'), + ColorPicker::make('preview.text') + ->label('Text') + ->default('#0F172A'), + ColorPicker::make('preview.secondary') + ->label('Sekundär') + ->default('#CBD5F5'), + ColorPicker::make('preview.badge') + ->label('Badge') + ->default('#2563EB'), + TextInput::make('preview.qr.size_px') + ->label('QR-Größe (px)') + ->numeric() + ->default(320) + ->columnSpan(2), + TextInput::make('preview.svg.width') + ->label('SVG Breite') + ->numeric() + ->default(1080), + TextInput::make('preview.svg.height') + ->label('SVG Höhe') + ->numeric() + ->default(1520), + ]), + + Section::make('Texte & Hinweise') + ->columns(2) + ->schema([ + TextInput::make('layout_options.badge_label') + ->label('Badge-Label') + ->default('Digitale Gästebox'), + TextInput::make('layout_options.instructions_heading') + ->label('Anleitungstitel') + ->default("So funktioniert's"), + TextInput::make('layout_options.link_heading') + ->label('Link-Titel') + ->default('Alternative zum Einscannen'), + TextInput::make('layout_options.cta_label') + ->label('CTA Label') + ->default('Scan mich & starte direkt'), + TextInput::make('layout_options.cta_caption') + ->label('CTA Untertitel') + ->default('Scan mich & starte direkt'), + TextInput::make('layout_options.link_label') + ->label('Link Text (optional)') + ->helperText('Überschreibt den standardmäßigen Einladungslink.'), + TextInput::make('layout_options.logo_url') + ->label('Logo URL') + ->columnSpanFull(), + Repeater::make('instructions') + ->label('Hinweise') + ->maxItems(6) + ->schema([ + Textarea::make('value') + ->label('Hinweistext') + ->rows(2) + ->required() + ->maxLength(180), + ]) + ->afterStateHydrated(function (Repeater $component, $state): void { + if (is_array($state) && ! empty($state) && ! is_array(current($state))) { + $component->state(array_map(fn ($value) => ['value' => $value], $state)); + } + }) + ->dehydrateStateUsing(fn ($state) => collect($state)->pluck('value')->filter()->values()->all()), + ]), + ]); + } +} diff --git a/app/Filament/Resources/InviteLayouts/Tables/InviteLayoutsTable.php b/app/Filament/Resources/InviteLayouts/Tables/InviteLayoutsTable.php new file mode 100644 index 0000000..3572c8c --- /dev/null +++ b/app/Filament/Resources/InviteLayouts/Tables/InviteLayoutsTable.php @@ -0,0 +1,73 @@ +columns([ + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('slug') + ->label('Slug') + ->copyable() + ->searchable() + ->sortable(), + TextColumn::make('paper') + ->label('Papier') + ->sortable(), + TextColumn::make('orientation') + ->label('Ausrichtung') + ->sortable(), + BadgeColumn::make('is_active') + ->label('Status') + ->colors([ + 'success' => fn ($state) => $state === true, + 'gray' => fn ($state) => $state === false, + ]) + ->getStateUsing(fn ($record) => $record->is_active) + ->formatStateUsing(fn ($state) => $state ? 'Aktiv' : 'Inaktiv'), + TextColumn::make('updated_at') + ->label('Aktualisiert') + ->dateTime('d.m.Y H:i') + ->sortable(), + ]) + ->filters([ + SelectFilter::make('is_active') + ->label('Status') + ->options([ + '1' => 'Aktiv', + '0' => 'Inaktiv', + ]) + ->query(function ($query, $state) { + if ($state === '1') { + $query->where('is_active', true); + } elseif ($state === '0') { + $query->where('is_active', false); + } + }), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 1ff27b0..b3752ef 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\EventStoreRequest; +use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Http\Resources\Tenant\EventResource; +use App\Http\Resources\Tenant\PhotoResource; use App\Models\Event; use App\Models\EventPackage; use App\Models\Package; @@ -228,6 +230,10 @@ class EventController extends Controller unset($validated[$unused]); } + if (isset($validated['settings']) && is_array($validated['settings'])) { + $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); + } + $event->update($validated); $event->load(['eventType', 'tenant']); @@ -277,6 +283,141 @@ class EventController extends Controller ]); } + public function toolkit(Request $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return response()->json(['error' => 'Event not found'], 404); + } + + $event->load(['eventType', 'eventPackage.package']); + + $photoQuery = Photo::query()->where('event_id', $event->id); + $pendingPhotos = (clone $photoQuery) + ->where('status', 'pending') + ->latest('created_at') + ->take(6) + ->get(); + + $recentUploads = (clone $photoQuery) + ->where('status', 'approved') + ->latest('created_at') + ->take(8) + ->get(); + + $pendingCount = (clone $photoQuery)->where('status', 'pending')->count(); + $uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count(); + $totalUploads = (clone $photoQuery)->count(); + + $tasks = $event->tasks() + ->orderBy('tasks.sort_order') + ->orderBy('tasks.created_at') + ->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']); + + $taskSummary = [ + 'total' => $tasks->count(), + 'completed' => $tasks->where('is_completed', true)->count(), + ]; + $taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']); + + $translate = static function ($value, string $fallback = '') { + if (is_array($value)) { + $locale = app()->getLocale(); + $candidates = array_filter([ + $locale, + $locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null, + 'de', + 'en', + ]); + + foreach ($candidates as $candidate) { + if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') { + return $value[$candidate]; + } + } + + $first = reset($value); + + return $first !== false ? $first : $fallback; + } + + if (is_string($value) && $value !== '') { + return $value; + } + + return $fallback; + }; + + $taskPreview = $tasks + ->take(6) + ->map(fn ($task) => [ + 'id' => $task->id, + 'title' => $translate($task->title, 'Task'), + 'description' => $translate($task->description, null), + 'is_completed' => (bool) $task->is_completed, + 'priority' => $task->priority, + ]) + ->values(); + + $joinTokenQuery = $event->joinTokens(); + $totalInvites = (clone $joinTokenQuery)->count(); + $activeInvites = (clone $joinTokenQuery) + ->whereNull('revoked_at') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->where(function ($query) { + $query->whereNull('usage_limit') + ->orWhereColumn('usage_limit', '>', 'usage_count'); + }) + ->count(); + + $recentInvites = (clone $joinTokenQuery) + ->orderByDesc('created_at') + ->take(3) + ->get(); + + $alerts = []; + if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) { + $alerts[] = 'no_tasks'; + } + if ($activeInvites === 0) { + $alerts[] = 'no_invites'; + } + if ($pendingCount > 0) { + $alerts[] = 'pending_photos'; + } + + return response()->json([ + 'event' => new EventResource($event), + 'metrics' => [ + 'uploads_total' => $totalUploads, + 'uploads_24h' => $uploads24h, + 'pending_photos' => $pendingCount, + 'active_invites' => $activeInvites, + 'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks', + ], + 'tasks' => [ + 'summary' => $taskSummary, + 'items' => $taskPreview, + ], + 'photos' => [ + 'pending' => PhotoResource::collection($pendingPhotos)->resolve($request), + 'recent' => PhotoResource::collection($recentUploads)->resolve($request), + ], + 'invites' => [ + 'summary' => [ + 'total' => $totalInvites, + 'active' => $activeInvites, + ], + 'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request), + ], + 'alerts' => $alerts, + ]); + } + public function toggle(Request $request, Event $event): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php index 0e3969d..7765711 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php @@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Models\Event; use App\Models\EventJoinToken; use App\Services\EventJoinTokenService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Auth; @@ -30,12 +31,7 @@ class EventJoinTokenController extends Controller { $this->authorizeEvent($request, $event); - $validated = $request->validate([ - 'label' => ['nullable', 'string', 'max:255'], - 'expires_at' => ['nullable', 'date', 'after:now'], - 'usage_limit' => ['nullable', 'integer', 'min:1'], - 'metadata' => ['nullable', 'array'], - ]); + $validated = $this->validatePayload($request); $token = $this->joinTokenService->createToken($event, array_merge($validated, [ 'created_by' => Auth::id(), @@ -46,6 +42,50 @@ class EventJoinTokenController extends Controller ->setStatusCode(201); } + public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource + { + $this->authorizeEvent($request, $event); + + if ($joinToken->event_id !== $event->id) { + abort(404); + } + + $validated = $this->validatePayload($request, true); + + $payload = []; + + if (array_key_exists('label', $validated)) { + $payload['label'] = $validated['label']; + } + + if (array_key_exists('expires_at', $validated)) { + $payload['expires_at'] = $validated['expires_at']; + } + + if (array_key_exists('usage_limit', $validated)) { + $payload['usage_limit'] = $validated['usage_limit']; + } + + if (! empty($payload)) { + $joinToken->fill($payload); + } + + if (array_key_exists('metadata', $validated)) { + $current = is_array($joinToken->metadata) ? $joinToken->metadata : []; + $incoming = $validated['metadata']; + + if ($incoming === null) { + $joinToken->metadata = null; + } else { + $joinToken->metadata = array_replace_recursive($current, $incoming); + } + } + + $joinToken->save(); + + return new EventJoinTokenResource($joinToken->fresh()); + } + public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource { $this->authorizeEvent($request, $event); @@ -68,4 +108,54 @@ class EventJoinTokenController extends Controller abort(404, 'Event not found'); } } + + private function validatePayload(Request $request, bool $partial = false): array + { + $rules = [ + 'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'], + 'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'], + 'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'], + 'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'], + 'metadata.layout_customization' => ['nullable', 'array'], + 'metadata.layout_customization.layout_id' => ['nullable', 'string', 'max:100'], + 'metadata.layout_customization.headline' => ['nullable', 'string', 'max:120'], + 'metadata.layout_customization.subtitle' => ['nullable', 'string', 'max:160'], + 'metadata.layout_customization.description' => ['nullable', 'string', 'max:500'], + 'metadata.layout_customization.badge_label' => ['nullable', 'string', 'max:80'], + 'metadata.layout_customization.instructions_heading' => ['nullable', 'string', 'max:120'], + 'metadata.layout_customization.link_heading' => ['nullable', 'string', 'max:120'], + 'metadata.layout_customization.cta_label' => ['nullable', 'string', 'max:120'], + 'metadata.layout_customization.cta_caption' => ['nullable', 'string', 'max:160'], + 'metadata.layout_customization.link_label' => ['nullable', 'string', 'max:160'], + 'metadata.layout_customization.instructions' => ['nullable', 'array', 'max:6'], + 'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'], + 'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'], + 'metadata.layout_customization.logo_data_url' => ['nullable', 'string'], + 'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + 'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + 'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + 'metadata.layout_customization.secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + 'metadata.layout_customization.badge_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + 'metadata.layout_customization.background_gradient' => ['nullable', 'array'], + 'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'], + 'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'], + 'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'], + ]; + + $validated = $request->validate($rules); + + if (isset($validated['metadata']['layout_customization']['instructions'])) { + $validated['metadata']['layout_customization']['instructions'] = array_values(array_filter( + $validated['metadata']['layout_customization']['instructions'], + fn ($value) => is_string($value) && trim($value) !== '' + )); + } + + if (isset($validated['metadata']['layout_customization']['logo_data_url']) + && ! is_string($validated['metadata']['layout_customization']['logo_data_url'])) { + unset($validated['metadata']['layout_customization']['logo_data_url']); + } + + return $validated; + } } diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php index 56d498b..3f75cbe 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php @@ -46,6 +46,8 @@ class EventJoinTokenLayoutController extends Controller abort(404, 'Unbekanntes Exportformat.'); } + $layoutConfig = $this->applyCustomization($layoutConfig, $joinToken); + $tokenUrl = url('/e/'.$joinToken->token); $qrPngDataUri = 'data:image/png;base64,'.base64_encode( @@ -66,6 +68,7 @@ class EventJoinTokenLayoutController extends Controller 'tokenUrl' => $tokenUrl, 'qrPngDataUri' => $qrPngDataUri, 'backgroundStyle' => $backgroundStyle, + 'customization' => $joinToken->metadata['layout_customization'] ?? null, ]; $filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format); @@ -80,7 +83,7 @@ class EventJoinTokenLayoutController extends Controller $html = view('layouts.join-token.pdf', $viewData)->render(); - $options = new Options(); + $options = new Options; $options->set('isHtml5ParserEnabled', true); $options->set('isRemoteEnabled', true); $options->set('defaultFont', 'Helvetica'); @@ -115,6 +118,57 @@ class EventJoinTokenLayoutController extends Controller return is_string($name) && $name !== '' ? $name : 'Event'; } + private function applyCustomization(array $layout, EventJoinToken $joinToken): array + { + $customization = data_get($joinToken->metadata, 'layout_customization'); + + if (! is_array($customization)) { + return $layout; + } + + $layoutId = $customization['layout_id'] ?? null; + if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) { + // Allow customization to target a specific layout; if mismatch, skip style overrides. + // General text overrides are still applied below. + } + + $colorKeys = [ + 'accent' => 'accent_color', + 'text' => 'text_color', + 'background' => 'background_color', + 'secondary' => 'secondary_color', + 'badge' => 'badge_color', + ]; + + foreach ($colorKeys as $layoutKey => $customKey) { + if (isset($customization[$customKey]) && is_string($customization[$customKey])) { + $layout[$layoutKey] = $customization[$customKey]; + } + } + + if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) { + $layout['background_gradient'] = $customization['background_gradient']; + } + + foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) { + if (isset($customization[$customKey]) && is_string($customization[$customKey])) { + $layout[$layoutKey] = $customization[$customKey]; + } + } + + if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) { + $layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== '')); + } + + if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) { + $layout['logo_url'] = $customization['logo_data_url']; + } elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) { + $layout['logo_url'] = $customization['logo_url']; + } + + return $layout; + } + private function buildBackgroundStyle(array $layout): string { $gradient = $layout['background_gradient'] ?? null; @@ -128,4 +182,4 @@ class EventJoinTokenLayoutController extends Controller return $layout['background'] ?? '#FFFFFF'; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php new file mode 100644 index 0000000..792b0bd --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/TenantFeedbackController.php @@ -0,0 +1,63 @@ +attributes->get('tenant_id'); + + if (! $tenantId) { + abort(403, 'Unauthorised'); + } + + $validated = $request->validate([ + 'category' => ['required', 'string', 'max:80'], + 'sentiment' => ['nullable', 'string', Rule::in(['positive', 'neutral', 'negative'])], + 'rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'title' => ['nullable', 'string', 'max:120'], + 'message' => ['nullable', 'string', 'max:2000'], + 'event_slug' => ['nullable', 'string', 'max:255'], + 'metadata' => ['nullable', 'array'], + ]); + + $eventId = null; + if (! empty($validated['event_slug'])) { + $eventSlug = $validated['event_slug']; + $event = Event::query() + ->where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->select('id') + ->first(); + + $eventId = $event?->id; + } + + $feedback = TenantFeedback::create([ + 'tenant_id' => $tenantId, + 'event_id' => $eventId, + 'category' => $validated['category'], + 'sentiment' => $validated['sentiment'] ?? null, + 'rating' => $validated['rating'] ?? null, + 'title' => $validated['title'] ?? null, + 'message' => $validated['message'] ?? null, + 'metadata' => $validated['metadata'] ?? null, + ]); + + return response()->json([ + 'message' => 'Feedback gespeichert', + 'data' => [ + 'id' => $feedback->id, + 'created_at' => $feedback->created_at?->toIso8601String(), + ], + ], 201); + } +} diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index a83f441..6394d09 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -7,22 +7,25 @@ use App\Models\OAuthCode; use App\Models\RefreshToken; use App\Models\Tenant; use App\Models\TenantToken; +use Firebase\JWT\JWT; +use GuzzleHttp\Client; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -use Firebase\JWT\JWT; -use GuzzleHttp\Client; -use Illuminate\Support\Facades\Log; class OAuthController extends Controller { private const AUTH_CODE_TTL_MINUTES = 5; + private const ACCESS_TOKEN_TTL_SECONDS = 3600; + private const REFRESH_TOKEN_TTL_DAYS = 30; + private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt'; /** @@ -104,6 +107,14 @@ class OAuthController extends Controller 'state' => $request->state, ]); + if ($this->shouldReturnJsonAuthorizeResponse($request)) { + return response()->json([ + 'code' => $code, + 'state' => $request->state, + 'redirect_url' => $redirectUrl, + ]); + } + return redirect()->away($redirectUrl); } @@ -402,6 +413,40 @@ class OAuthController extends Controller ]; } + private function shouldReturnJsonAuthorizeResponse(Request $request): bool + { + if ($request->expectsJson() || $request->ajax()) { + return true; + } + + $redirectUri = (string) $request->string('redirect_uri'); + $redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null; + $requestHost = $request->getHost(); + + if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) { + return true; + } + + $origin = $request->headers->get('Origin'); + if ($origin) { + $originHost = parse_url($origin, PHP_URL_HOST); + if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) { + return true; + } + } + + return false; + } + + private function hostsMatch(?string $first, ?string $second): bool + { + if (! $first || ! $second) { + return false; + } + + return strtolower($first) === strtolower($second); + } + private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string { $refreshTokenId = (string) Str::uuid(); @@ -566,6 +611,7 @@ class OAuthController extends Controller File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true); File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); } + private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool { if (empty($requestedScopes)) { @@ -682,7 +728,7 @@ class OAuthController extends Controller return redirect('/event-admin')->with('error', 'Invalid state parameter'); } - $client = new Client(); + $client = new Client; $clientId = config('services.stripe.connect_client_id'); $secret = config('services.stripe.connect_secret'); $redirectUri = url('/api/v1/oauth/stripe-callback'); @@ -710,11 +756,12 @@ class OAuthController extends Controller } session()->forget(['stripe_state', 'tenant_id']); + return redirect('/event-admin')->with('success', 'Stripe account connected successfully'); } catch (\Exception $e) { Log::error('Stripe OAuth error: '.$e->getMessage()); + return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage()); } } } - diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index f4a601f..35dd5e5 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -23,7 +23,7 @@ class EventStoreRequest extends FormRequest public function rules(): array { $tenantId = request()->attributes->get('tenant_id'); - + return [ 'name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], @@ -41,6 +41,8 @@ class EventStoreRequest extends FormRequest 'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])], 'features' => ['nullable', 'array'], 'features.*' => ['string'], + 'settings' => ['nullable', 'array'], + 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], ]; } @@ -67,4 +69,4 @@ class EventStoreRequest extends FormRequest 'password_protected' => $this->boolean('password_protected'), ]); } -} \ No newline at end of file +} diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 1c63736..9dcbae2 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -44,6 +44,8 @@ class EventResource extends JsonResource 'status' => $this->status ?? 'draft', 'is_active' => (bool) ($this->is_active ?? false), 'features' => $settings['features'] ?? [], + 'engagement_mode' => $settings['engagement_mode'] ?? 'tasks', + 'settings' => $settings, 'event_type_id' => $this->event_type_id, 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), diff --git a/app/Models/InviteLayout.php b/app/Models/InviteLayout.php new file mode 100644 index 0000000..680c969 --- /dev/null +++ b/app/Models/InviteLayout.php @@ -0,0 +1,38 @@ + 'array', + 'layout_options' => 'array', + 'instructions' => 'array', + 'is_active' => 'bool', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Models/TenantFeedback.php b/app/Models/TenantFeedback.php new file mode 100644 index 0000000..0ac469c --- /dev/null +++ b/app/Models/TenantFeedback.php @@ -0,0 +1,30 @@ + 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index 4903c8a..ad892e8 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -2,6 +2,7 @@ namespace App\Models; +use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -43,6 +44,10 @@ class TenantPackage extends Model public function isActive(): bool { + if ($this->package && $this->package->isEndcustomer()) { + return (bool) $this->active; + } + return $this->active && (! $this->expires_at || $this->expires_at->isFuture()); } @@ -76,23 +81,43 @@ class TenantPackage extends Model { parent::boot(); - static::creating(function ($tenantPackage) { + static::creating(function (self $tenantPackage) { if (! $tenantPackage->purchased_at) { $tenantPackage->purchased_at = now(); } - if (! $tenantPackage->expires_at && $tenantPackage->package) { - $tenantPackage->expires_at = now()->addYear(); // Standard für Reseller + + $package = $tenantPackage->package; + + if ($package && $package->isReseller()) { + if (! $tenantPackage->expires_at) { + $tenantPackage->expires_at = now()->addYear(); + } + } else { + $tenantPackage->expires_at = now()->addCentury(); + } + + if ($tenantPackage->active === null) { + $tenantPackage->active = true; } - $tenantPackage->active = true; }); - static::updating(function ($tenantPackage) { - if ( - $tenantPackage->isDirty('expires_at') - && $tenantPackage->expires_at instanceof \Carbon\CarbonInterface - && $tenantPackage->expires_at->isPast() - ) { - $tenantPackage->active = false; + static::updating(function (self $tenantPackage) { + $package = $tenantPackage->package; + + if ($package && $package->isReseller()) { + if ( + $tenantPackage->isDirty('expires_at') + && $tenantPackage->expires_at instanceof CarbonInterface + && $tenantPackage->expires_at->isPast() + ) { + $tenantPackage->active = false; + } + + return; + } + + if ($tenantPackage->isDirty('expires_at')) { + $tenantPackage->expires_at = now()->addCentury(); } }); } diff --git a/app/Support/JoinTokenLayoutRegistry.php b/app/Support/JoinTokenLayoutRegistry.php index 296cf76..6f7f69f 100644 --- a/app/Support/JoinTokenLayoutRegistry.php +++ b/app/Support/JoinTokenLayoutRegistry.php @@ -2,6 +2,8 @@ namespace App\Support; +use App\Models\InviteLayout; + class JoinTokenLayoutRegistry { /** @@ -123,6 +125,18 @@ class JoinTokenLayoutRegistry */ public static function all(): array { + $customLayouts = InviteLayout::query() + ->where('is_active', true) + ->orderBy('name') + ->get(); + + if ($customLayouts->isNotEmpty()) { + return $customLayouts + ->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout))) + ->values() + ->all(); + } + return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS)); } @@ -131,6 +145,15 @@ class JoinTokenLayoutRegistry */ public static function find(string $id): ?array { + $custom = InviteLayout::query() + ->where('slug', $id) + ->where('is_active', true) + ->first(); + + if ($custom) { + return self::normalize(self::fromModel($custom)); + } + $layout = self::LAYOUTS[$id] ?? null; return $layout ? self::normalize($layout) : null; @@ -151,6 +174,13 @@ class JoinTokenLayoutRegistry 'accent' => '#6366F1', 'secondary' => '#CBD5F5', 'badge' => '#2563EB', + 'badge_label' => 'Digitale Gästebox', + 'instructions_heading' => "So funktioniert's", + 'link_heading' => 'Alternative zum Einscannen', + 'cta_label' => 'Scan mich & starte direkt', + 'cta_caption' => 'Scan mich & starte direkt', + 'link_label' => null, + 'logo_url' => null, 'qr' => [ 'size_px' => 320, ], @@ -160,11 +190,50 @@ class JoinTokenLayoutRegistry ], 'background_gradient' => null, 'instructions' => [], + 'formats' => ['pdf', 'svg'], ]; return array_replace_recursive($defaults, $layout); } + private static function fromModel(InviteLayout $layout): array + { + $preview = $layout->preview ?? []; + $options = $layout->layout_options ?? []; + $instructions = $layout->instructions ?? []; + + return array_filter([ + 'id' => $layout->slug, + 'name' => $layout->name, + 'subtitle' => $layout->subtitle, + 'description' => $layout->description, + 'paper' => $layout->paper, + 'orientation' => $layout->orientation, + 'background' => $preview['background'] ?? null, + 'background_gradient' => $preview['background_gradient'] ?? null, + 'text' => $preview['text'] ?? null, + 'accent' => $preview['accent'] ?? null, + 'secondary' => $preview['secondary'] ?? null, + 'badge' => $preview['badge'] ?? null, + 'badge_label' => $options['badge_label'] ?? null, + 'instructions_heading' => $options['instructions_heading'] ?? null, + 'link_heading' => $options['link_heading'] ?? null, + 'cta_label' => $options['cta_label'] ?? null, + 'cta_caption' => $options['cta_caption'] ?? null, + 'link_label' => $options['link_label'] ?? null, + 'logo_url' => $options['logo_url'] ?? null, + 'qr' => array_filter([ + 'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null, + ]), + 'svg' => array_filter([ + 'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null, + 'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null, + ]), + 'formats' => $options['formats'] ?? ['pdf', 'svg'], + 'instructions' => $instructions, + ], fn ($value) => $value !== null && $value !== []); + } + /** * Map layouts into an API-ready response structure, attaching URLs. * @@ -174,7 +243,7 @@ class JoinTokenLayoutRegistry public static function toResponse(callable $urlResolver): array { return array_map(function (array $layout) use ($urlResolver) { - $formats = ['pdf', 'svg']; + $formats = $layout['formats'] ?? ['pdf', 'svg']; return [ 'id' => $layout['id'], @@ -194,4 +263,4 @@ class JoinTokenLayoutRegistry ]; }, self::all()); } -} \ No newline at end of file +} diff --git a/database/migrations/2025_10_27_190000_make_endcustomer_packages_non_expiring.php b/database/migrations/2025_10_27_190000_make_endcustomer_packages_non_expiring.php new file mode 100644 index 0000000..205fcae --- /dev/null +++ b/database/migrations/2025_10_27_190000_make_endcustomer_packages_non_expiring.php @@ -0,0 +1,32 @@ +whereIn('package_id', function ($query) { + $query->select('id') + ->from('packages') + ->where('type', 'endcustomer'); + }) + ->update([ + 'expires_at' => now()->addCentury(), + 'active' => true, + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // No rollback – endcustomer packages should remain non-expiring. + } +}; diff --git a/database/migrations/2025_10_28_140104_create_invite_layouts_table.php b/database/migrations/2025_10_28_140104_create_invite_layouts_table.php new file mode 100644 index 0000000..20bbc7d --- /dev/null +++ b/database/migrations/2025_10_28_140104_create_invite_layouts_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->string('subtitle')->nullable(); + $table->text('description')->nullable(); + $table->string('paper')->default('a4'); + $table->string('orientation')->default('portrait'); + $table->json('preview')->nullable(); + $table->json('layout_options')->nullable(); + $table->json('instructions')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + + $table->foreign('created_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invite_layouts'); + } +}; diff --git a/database/migrations/2025_10_30_120000_create_tenant_feedback_table.php b/database/migrations/2025_10_30_120000_create_tenant_feedback_table.php new file mode 100644 index 0000000..2760010 --- /dev/null +++ b/database/migrations/2025_10_30_120000_create_tenant_feedback_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('category', 80); + $table->string('sentiment', 20)->nullable(); + $table->unsignedTinyInteger('rating')->nullable(); + $table->string('title')->nullable(); + $table->text('message')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); + $table->foreign('event_id')->references('id')->on('events')->cascadeOnDelete(); + $table->index(['tenant_id', 'category']); + $table->index(['event_id', 'category']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_feedback'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 70f6993..652a2cf 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder TasksSeeder::class, EventTasksSeeder::class, TaskCollectionsSeeder::class, + InviteLayoutSeeder::class, ]); // Seed demo and admin data diff --git a/database/seeders/InviteLayoutSeeder.php b/database/seeders/InviteLayoutSeeder.php new file mode 100644 index 0000000..c558398 --- /dev/null +++ b/database/seeders/InviteLayoutSeeder.php @@ -0,0 +1,57 @@ +getReflectionConstant('LAYOUTS'); + $fallbackLayouts = $layoutsConst ? $layoutsConst->getValue() : []; + + foreach ($fallbackLayouts as $layout) { + $preview = [ + 'background' => $layout['background'] ?? null, + 'background_gradient' => $layout['background_gradient'] ?? null, + 'accent' => $layout['accent'] ?? null, + 'secondary' => $layout['secondary'] ?? null, + 'text' => $layout['text'] ?? null, + 'badge' => $layout['badge'] ?? null, + 'qr' => $layout['qr'] ?? ['size_px' => 320], + 'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520], + ]; + + $options = [ + 'badge_label' => $layout['badge_label'] ?? 'Digitale Gästebox', + 'instructions_heading' => $layout['instructions_heading'] ?? "So funktioniert's", + 'link_heading' => $layout['link_heading'] ?? 'Alternative zum Einscannen', + 'cta_label' => $layout['cta_label'] ?? 'Scan mich & starte direkt', + 'cta_caption' => $layout['cta_caption'] ?? 'Scan mich & starte direkt', + 'link_label' => $layout['link_label'] ?? null, + 'logo_url' => $layout['logo_url'] ?? null, + 'formats' => $layout['formats'] ?? ['pdf', 'svg'], + ]; + + InviteLayout::updateOrCreate( + ['slug' => $layout['id']], + [ + 'name' => $layout['name'], + 'subtitle' => $layout['subtitle'] ?? null, + 'description' => $layout['description'] ?? null, + 'paper' => $layout['paper'] ?? 'a4', + 'orientation' => $layout['orientation'] ?? 'portrait', + 'preview' => $preview, + 'layout_options' => $options, + 'instructions' => $layout['instructions'] ?? [], + 'is_active' => true, + ] + ); + } + } +} diff --git a/database/seeders/TasksSeeder.php b/database/seeders/TasksSeeder.php index 5328cf3..fda2d0f 100644 --- a/database/seeders/TasksSeeder.php +++ b/database/seeders/TasksSeeder.php @@ -2,9 +2,12 @@ namespace Database\Seeders; +use App\Models\Emotion; +use App\Models\EventType; +use App\Models\Task; +use App\Models\TaskCollection; use Illuminate\Database\Seeder; use Illuminate\Support\Str; -use App\Models\{Emotion, Task, EventType}; class TasksSeeder extends Seeder { @@ -25,40 +28,83 @@ class TasksSeeder extends Seeder $seed = [ 'Liebe' => [ - ['title'=>['de'=>'Kuss-Foto','en'=>'Kiss Photo'], 'description'=>['de'=>'Macht ein romantisches Kuss-Foto','en'=>'Take a romantic kiss photo'], 'difficulty'=>'easy'], + ['title' => ['de' => 'Kuss-Foto', 'en' => 'Kiss Photo'], 'description' => ['de' => 'Macht ein romantisches Kuss-Foto', 'en' => 'Take a romantic kiss photo'], 'difficulty' => 'easy'], ], 'Freude' => [ - ['title'=>['de'=>'Sprung-Foto','en'=>'Jump Photo'], 'description'=>['de'=>'Alle springen gleichzeitig!','en'=>'Everyone jump together!'], 'difficulty'=>'medium'], + ['title' => ['de' => 'Sprung-Foto', 'en' => 'Jump Photo'], 'description' => ['de' => 'Alle springen gleichzeitig!', 'en' => 'Everyone jump together!'], 'difficulty' => 'medium'], ], 'Teamgeist' => [ - ['title'=>['de'=>'High-Five-Runde','en'=>'High-Five Round'], 'description'=>['de'=>'Gebt euch High-Fives!','en'=>'Give each other high-fives!'], 'difficulty'=>'easy', 'event_type'=>'corporate'], + ['title' => ['de' => 'High-Five-Runde', 'en' => 'High-Five Round'], 'description' => ['de' => 'Gebt euch High-Fives!', 'en' => 'Give each other high-fives!'], 'difficulty' => 'easy', 'event_type' => 'corporate'], ], 'Besinnlichkeit' => [ - ['title'=>['de'=>'Lichterglanz','en'=>'Glow of Lights'], 'description'=>['de'=>'Foto mit Lichterkette','en'=>'Photo with string lights'], 'difficulty'=>'easy', 'event_type'=>'christmas'], + ['title' => ['de' => 'Lichterglanz', 'en' => 'Glow of Lights'], 'description' => ['de' => 'Foto mit Lichterkette', 'en' => 'Photo with string lights'], 'difficulty' => 'easy', 'event_type' => 'christmas'], ], ]; - $types = EventType::pluck('id','slug'); + $types = EventType::pluck('id', 'slug'); + $position = 10; foreach ($seed as $emotionNameDe => $tasks) { $emotion = Emotion::where('name->de', $emotionNameDe)->first(); - if (!$emotion) continue; - foreach ($tasks as $t) { - $slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']); - $slug = $slugBase ? $slugBase . '-' . $emotion->id : Str::uuid()->toString(); - Task::updateOrCreate([ - 'slug' => $slug, - ], [ + if (! $emotion) { + continue; + } + + $emotionTranslations = is_array($emotion->name) ? $emotion->name : []; + $emotionNameEn = $emotionTranslations['en'] ?? $emotionNameDe; + + $collection = TaskCollection::updateOrCreate( + [ 'tenant_id' => $demoTenant->id, - 'emotion_id' => $emotion->id, - 'event_type_id' => isset($t['event_type']) && isset($types[$t['event_type']]) ? $types[$t['event_type']] : null, - 'title' => $t['title'], - 'description' => $t['description'], - 'difficulty' => $t['difficulty'], - 'is_active' => true, - 'sort_order' => $t['sort_order'] ?? 0, - ]); + 'slug' => 'demo-'.Str::slug($emotionTranslations['en'] ?? $emotionNameDe), + ], + [ + 'name_translations' => [ + 'de' => $emotionNameDe, + 'en' => $emotionNameEn, + ], + 'description_translations' => [ + 'de' => 'Aufgaben rund um '.$emotionNameDe.'.', + 'en' => 'Prompts inspired by '.$emotionNameEn.'.', + ], + 'event_type_id' => null, + 'is_default' => false, + 'position' => $position, + ] + ); + + $position += 10; + $syncPayload = []; + + foreach ($tasks as $index => $t) { + $slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']); + $slug = $slugBase ? $slugBase.'-'.$emotion->id : Str::uuid()->toString(); + + $sortOrder = $t['sort_order'] ?? (($index + 1) * 10); + + $task = Task::updateOrCreate( + [ + 'slug' => $slug, + ], + [ + 'tenant_id' => $demoTenant->id, + 'emotion_id' => $emotion->id, + 'event_type_id' => isset($t['event_type'], $types[$t['event_type']]) ? $types[$t['event_type']] : null, + 'title' => $t['title'], + 'description' => $t['description'], + 'difficulty' => $t['difficulty'], + 'is_active' => true, + 'sort_order' => $sortOrder, + 'collection_id' => $collection->id, + ] + ); + + $syncPayload[$task->id] = ['sort_order' => $sortOrder]; + } + + if (! empty($syncPayload)) { + $collection->tasks()->syncWithoutDetaching($syncPayload); } } } diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 2b66377..cfb8e74 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -3,7 +3,7 @@ import i18n from './i18n'; type JsonValue = Record; -export type EventJoinTokenLayout = { +export type EventQrInviteLayout = { id: string; name: string; description: string; @@ -41,6 +41,8 @@ export type TenantEvent = { description?: string | null; photo_count?: number; like_count?: number; + engagement_mode?: 'tasks' | 'photo_only'; + settings?: Record & { engagement_mode?: 'tasks' | 'photo_only' }; package?: { id: number | string | null; name: string | null; @@ -246,7 +248,7 @@ export type EventMember = { type EventListResponse = { data?: JsonValue[] }; type EventResponse = { data: JsonValue }; -export type EventJoinToken = { +export type EventQrInvite = { id: number; token: string; url: string; @@ -258,9 +260,48 @@ export type EventJoinToken = { is_active: boolean; created_at: string | null; metadata: Record; - layouts: EventJoinTokenLayout[]; + layouts: EventQrInviteLayout[]; layouts_url: string | null; }; + +export type EventToolkitTask = { + id: number; + title: string; + description: string | null; + is_completed: boolean; + priority?: string | null; +}; + +export type EventToolkit = { + event: TenantEvent; + metrics: { + uploads_total: number; + uploads_24h: number; + pending_photos: number; + active_invites: number; + engagement_mode: 'tasks' | 'photo_only'; + }; + tasks: { + summary: { + total: number; + completed: number; + pending: number; + }; + items: EventToolkitTask[]; + }; + photos: { + pending: TenantPhoto[]; + recent: TenantPhoto[]; + }; + invites: { + summary: { + total: number; + active: number; + }; + items: EventQrInvite[]; + }; + alerts: string[]; +}; type CreatedEventResponse = { message: string; data: JsonValue; balance: number }; type PhotoResponse = { message: string; data: TenantPhoto }; @@ -272,6 +313,7 @@ type EventSavePayload = { status?: 'draft' | 'published' | 'archived'; is_active?: boolean; package_id?: number; + settings?: Record; }; async function jsonOrThrow(response: Response, message: string): Promise { @@ -380,6 +422,10 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven function normalizeEvent(event: JsonValue): TenantEvent { const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null); + const settings = ((event.settings ?? {}) as Record) ?? {}; + const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as + | 'tasks' + | 'photo_only'; const normalized: TenantEvent = { ...(event as Record), id: Number(event.id ?? 0), @@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent { description: event.description ?? null, photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined, like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined, + engagement_mode: engagementMode, + settings, package: event.package ?? null, }; @@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember { }; } -function normalizeJoinToken(raw: JsonValue): EventJoinToken { +function normalizeQrInvite(raw: JsonValue): EventQrInvite { const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : []; - const layouts: EventJoinTokenLayout[] = rawLayouts + const layouts: EventQrInviteLayout[] = rawLayouts .map((layout: any) => { const formats = Array.isArray(layout.formats) ? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0) @@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken { download_urls: (layout.download_urls ?? {}) as Record, }; }) - .filter((layout: EventJoinTokenLayout) => layout.id.length > 0); + .filter((layout: EventQrInviteLayout) => layout.id.length > 0); return { id: Number(raw.id ?? 0), @@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise { }; } -export async function getEventJoinTokens(slug: string): Promise { +export async function getEventQrInvites(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`); const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations'); const list = Array.isArray(payload.data) ? payload.data : []; - return list.map(normalizeJoinToken); + return list.map(normalizeQrInvite); } -export async function createInviteLink( +export async function createQrInvite( slug: string, payload?: { label?: string; usage_limit?: number; expires_at?: string } -): Promise { +): Promise { const body = JSON.stringify(payload ?? {}); const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, { method: 'POST', @@ -739,14 +787,14 @@ export async function createInviteLink( body, }); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation'); - return normalizeJoinToken(data.data ?? {}); + return normalizeQrInvite(data.data ?? {}); } -export async function revokeEventJoinToken( +export async function revokeEventQrInvite( slug: string, tokenId: number, reason?: string -): Promise { +): Promise { const options: RequestInit = { method: 'DELETE' }; if (reason) { options.headers = { 'Content-Type': 'application/json' }; @@ -754,7 +802,107 @@ export async function revokeEventJoinToken( } const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation'); - return normalizeJoinToken(data.data ?? {}); + return normalizeQrInvite(data.data ?? {}); +} + +export async function updateEventQrInvite( + slug: string, + tokenId: number, + payload: { + label?: string | null; + expires_at?: string | null; + usage_limit?: number | null; + metadata?: Record | null; + } +): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation'); + return normalizeQrInvite(data.data ?? {}); +} + +export async function getEventToolkit(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`); + const json = await jsonOrThrow>(response, 'Failed to load toolkit'); + + const metrics = json.metrics ?? {}; + const tasks = json.tasks ?? {}; + const photos = json.photos ?? {}; + const invites = json.invites ?? {}; + + const pendingPhotosRaw = Array.isArray((photos as Record).pending) + ? (photos as Record).pending + : []; + const recentPhotosRaw = Array.isArray((photos as Record).recent) + ? (photos as Record).recent + : []; + + const toolkit: EventToolkit = { + event: normalizeEvent(json.event ?? {}), + metrics: { + uploads_total: Number((metrics as JsonValue).uploads_total ?? 0), + uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0), + pending_photos: Number((metrics as JsonValue).pending_photos ?? 0), + active_invites: Number((metrics as JsonValue).active_invites ?? 0), + engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks', + }, + tasks: { + summary: { + total: Number((tasks as JsonValue)?.summary?.total ?? 0), + completed: Number((tasks as JsonValue)?.summary?.completed ?? 0), + pending: Number((tasks as JsonValue)?.summary?.pending ?? 0), + }, + items: Array.isArray((tasks as JsonValue)?.items) + ? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({ + id: Number(item?.id ?? 0), + title: String(item?.title ?? ''), + description: item?.description !== undefined && item?.description !== null ? String(item.description) : null, + is_completed: Boolean(item?.is_completed ?? false), + priority: item?.priority !== undefined ? String(item.priority) : null, + })) + : [], + }, + photos: { + pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)), + recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)), + }, + invites: { + summary: { + total: Number((invites as JsonValue)?.summary?.total ?? 0), + active: Number((invites as JsonValue)?.summary?.active ?? 0), + }, + items: Array.isArray((invites as JsonValue)?.items) + ? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item)) + : [], + }, + alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [], + }; + + return toolkit; +} + +export async function submitTenantFeedback(payload: { + category: string; + sentiment?: 'positive' | 'neutral' | 'negative'; + rating?: number | null; + title?: string | null; + message?: string | null; + event_slug?: string | null; + metadata?: Record | null; +}): Promise { + const response = await authorizedFetch('/api/v1/tenant/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await safeJson(response); + console.error('[API] Failed to submit feedback', response.status, body); + throw new Error('Failed to submit feedback'); + } } export type Package = { diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index c54b2de..ad6fb17 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -24,3 +24,4 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`); export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`); export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`); +export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`); diff --git a/resources/js/admin/dev-tools.ts b/resources/js/admin/dev-tools.ts index 47a5b20..91ccf54 100644 --- a/resources/js/admin/dev-tools.ts +++ b/resources/js/admin/dev-tools.ts @@ -51,7 +51,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true code_challenge_method: 'S256', }); - const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`); + const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri); verifyState(callbackUrl.searchParams.get('state'), state); const code = callbackUrl.searchParams.get('code'); @@ -115,22 +115,53 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true globalThis.fotospielDemoAuth = api; } -function requestAuthorization(url: string): Promise { +function requestAuthorization(url: string, fallbackRedirect?: string): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.withCredentials = true; + xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.onreadystatechange = () => { if (xhr.readyState !== XMLHttpRequest.DONE) { return; } + const contentType = xhr.getResponseHeader('Content-Type') ?? ''; const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location'); if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) { if (responseUrl) { resolve(new URL(responseUrl, window.location.origin)); return; } + + if (contentType.includes('application/json')) { + try { + const payload = JSON.parse(xhr.responseText ?? '{}') as { + code?: string; + state?: string | null; + redirect_url?: string | null; + }; + const target = payload.redirect_url ?? fallbackRedirect; + if (!target) { + throw new Error('Authorize response missing redirect target'); + } + + const finalUrl = new URL(target, window.location.origin); + if (payload.code && !finalUrl.searchParams.has('code')) { + finalUrl.searchParams.set('code', payload.code); + } + if (payload.state && !finalUrl.searchParams.has('state')) { + finalUrl.searchParams.set('state', payload.state); + } + + resolve(finalUrl); + return; + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + } } reject(new Error(`Authorize failed with ${xhr.status}`)); diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 067bf95..1141909 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -36,6 +36,36 @@ "lowCredits": "Auffüllen empfohlen" } }, + "readiness": { + "title": "Bereit für den Eventstart", + "description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.", + "pending": "Noch offen", + "complete": "Erledigt", + "items": { + "event": { + "title": "Event angelegt", + "hint": "Lege dein erstes Event an oder öffne dein jüngstes Event." + }, + "tasks": { + "title": "Aufgaben kuratiert", + "hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben." + }, + "qr": { + "title": "QR-Einladung erstellt", + "hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter." + }, + "package": { + "title": "Paket aktiv", + "hint": "Wähle ein Paket, das zu eurem Umfang passt." + } + }, + "actions": { + "createEvent": "Event erstellen", + "openTasks": "Tasks öffnen", + "openQr": "QR-Einladungen", + "openPackages": "Pakete ansehen" + } + }, "quickActions": { "title": "Schnellaktionen", "description": "Starte durch mit den wichtigsten Aktionen.", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 67c2655..e47620e 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -147,7 +147,9 @@ "errors": { "missingSlug": "Kein Event-Slug angegeben.", "load": "Event-Tasks konnten nicht geladen werden.", - "assign": "Tasks konnten nicht zugewiesen werden." + "assign": "Tasks konnten nicht zugewiesen werden.", + "photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.", + "photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden." }, "alerts": { "notFoundTitle": "Event nicht gefunden", @@ -169,6 +171,147 @@ "medium": "Mittel", "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.", + "photoOnly": "Foto-Modus", + "tasks": "Aufgaben aktiv", + "switchLabel": "Foto-Modus aktivieren", + "updating": "Einstellung wird gespeichert ..." + }, + "toolkit": { + "titleFallback": "Event-Day Toolkit", + "subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.", + "errors": { + "missingSlug": "Kein Event-Slug angegeben.", + "loadFailed": "Toolkit konnte nicht geladen werden.", + "feedbackFailed": "Feedback konnte nicht gesendet werden." + }, + "actions": { + "backToEvent": "Zurück zum Event", + "moderate": "Fotos moderieren", + "manageTasks": "Tasks öffnen", + "refresh": "Aktualisieren" + }, + "alerts": { + "errorTitle": "Fehler", + "attention": "Achtung", + "noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.", + "noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.", + "pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen." + }, + "metrics": { + "uploadsTotal": "Uploads gesamt", + "uploads24h": "Uploads (24h)", + "pendingPhotos": "Unmoderierte Fotos", + "activeInvites": "Aktive Einladungen", + "engagementMode": "Modus", + "modePhotoOnly": "Foto-Modus", + "modeTasks": "Aufgaben" + }, + "pending": { + "title": "Wartende Fotos", + "subtitle": "Moderationsempfehlung für neue Uploads.", + "cta": "Zur Moderation", + "empty": "Aktuell warten keine Fotos auf Freigabe.", + "unknownUploader": "Unbekannter Gast", + "uploadedAt": "Hochgeladen:", + "statusPending": "Status: Prüfung ausstehend" + }, + "invites": { + "title": "QR-Einladungen", + "subtitle": "Aktive Links und Layouts im Blick behalten.", + "activeCount": "{{count}} aktiv", + "totalCount": "{{count}} gesamt", + "empty": "Noch keine QR-Einladungen erstellt.", + "statusActive": "Aktiv", + "statusInactive": "Inaktiv", + "manage": "Einladungen verwalten" + }, + "tasks": { + "title": "Aktive Aufgaben", + "subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.", + "summary": "{{completed}} von {{total}} erledigt", + "empty": "Noch keine Aufgaben zugewiesen.", + "manage": "Tasks verwalten", + "completed": "Erledigt", + "open": "Offen" + }, + "recent": { + "title": "Neueste Uploads", + "subtitle": "Ein Blick auf die letzten Fotos der Gäste.", + "empty": "Noch keine freigegebenen Fotos vorhanden." + }, + "feedback": { + "title": "Wie hilfreich ist dieses Toolkit?", + "subtitle": "Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.", + "positive": "Hilfreich", + "neutral": "Ganz okay", + "negative": "Verbesserungsbedarf", + "placeholder": "Erzähle uns kurz, was dir gefallen hat oder was fehlt …", + "disclaimer": "Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.", + "submit": "Feedback senden", + "thanksTitle": "Danke!", + "thanksDescription": "Wir haben dein Feedback erhalten.", + "badge": "Angepasst" + } + }, + "customizer": { + "title": "QR-Einladung anpassen", + "description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.", + "layout": "Layout", + "selectLayout": "Layout auswählen", + "headline": "Überschrift", + "subtitle": "Unterzeile", + "descriptionLabel": "Beschreibung", + "badgeLabel": "Badge", + "instructionsHeading": "Anleitungstitel", + "instructionsLabel": "Hinweistexte", + "addInstruction": "Hinweis hinzufügen", + "removeInstruction": "Entfernen", + "linkHeading": "Link-Titel", + "linkLabel": "Link", + "ctaLabel": "Call-to-Action", + "colors": { + "accent": "Akzentfarbe", + "text": "Textfarbe", + "background": "Hintergrund", + "secondary": "Sekundärfarbe", + "badge": "Badge-Farbe" + }, + "logo": { + "label": "Logo", + "hint": "PNG oder SVG, max. 1 MB. Wird oben rechts platziert.", + "remove": "Logo entfernen" + }, + "preview": { + "title": "Vorschau", + "hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten." + }, + "actions": { + "save": "Speichern", + "cancel": "Abbrechen", + "reset": "Zurücksetzen" + }, + "badge": "Angepasst", + "actionLabel": "Layout anpassen", + "errors": { + "logoTooLarge": "Das Logo darf maximal 1 MB groß sein.", + "noLayout": "Bitte wähle ein Layout aus." + }, + "defaults": { + "badgeLabel": "Digitale Gästebox", + "instructionsHeading": "So funktioniert's", + "linkHeading": "Alternative zum Einscannen", + "ctaLabel": "Scan mich & starte direkt", + "instructions": [ + "QR-Code scannen", + "Profil anlegen", + "Fotos teilen" + ] + } } }, "collections": { @@ -316,4 +459,4 @@ } } } -} \ No newline at end of file +} diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index d787652..68bd043 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -36,6 +36,36 @@ "lowCredits": "Top up recommended" } }, + "readiness": { + "title": "Ready for event day", + "description": "Complete these steps so guests can join without friction.", + "pending": "Pending", + "complete": "Done", + "items": { + "event": { + "title": "Event created", + "hint": "Create your first event or open the most recent one." + }, + "tasks": { + "title": "Tasks curated", + "hint": "Assign fitting tasks or enable the photo-only mode." + }, + "qr": { + "title": "QR invite live", + "hint": "Create a QR invite and download the print layouts." + }, + "package": { + "title": "Package active", + "hint": "Pick the package that matches your scope." + } + }, + "actions": { + "createEvent": "Create event", + "openTasks": "Open tasks", + "openQr": "QR invites", + "openPackages": "View packages" + } + }, "quickActions": { "title": "Quick actions", "description": "Jump straight to the most important actions.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 002eff0..b1c4ffd 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -147,7 +147,9 @@ "errors": { "missingSlug": "No event slug provided.", "load": "Event tasks could not be loaded.", - "assign": "Tasks could not be assigned." + "assign": "Tasks could not be assigned.", + "photoOnlyEnable": "Photo-only mode could not be enabled.", + "photoOnlyDisable": "Photo-only mode could not be disabled." }, "alerts": { "notFoundTitle": "Event not found", @@ -169,6 +171,147 @@ "medium": "Medium", "high": "High", "urgent": "Urgent" + }, + "modes": { + "title": "Tasks & photo mode", + "photoOnlyHint": "Photo-only mode is active. Guests can upload photos but won’t see tasks.", + "tasksHint": "Tasks are visible in the guest app. Switch to photo-only for uploads without prompts.", + "photoOnly": "Photo-only", + "tasks": "Tasks active", + "switchLabel": "Enable photo-only mode", + "updating": "Saving setting ..." + }, + "toolkit": { + "titleFallback": "Event-Day Toolkit", + "subtitle": "Stay on top of uploads, tasks, and invites while your event is live.", + "errors": { + "missingSlug": "No event slug provided.", + "loadFailed": "Toolkit could not be loaded.", + "feedbackFailed": "Feedback could not be sent." + }, + "actions": { + "backToEvent": "Back to event", + "moderate": "Moderate photos", + "manageTasks": "Open tasks", + "refresh": "Refresh" + }, + "alerts": { + "errorTitle": "Error", + "attention": "Heads-up", + "noTasks": "No tasks assigned yet – pick a package or curate prompts.", + "noInvites": "There are no active QR invites. Create one to welcome guests.", + "pendingPhotos": "Photos are waiting for moderation. Review uploads before publishing." + }, + "metrics": { + "uploadsTotal": "Total uploads", + "uploads24h": "Uploads (24h)", + "pendingPhotos": "Pending moderation", + "activeInvites": "Active invites", + "engagementMode": "Mode", + "modePhotoOnly": "Photo mode", + "modeTasks": "Tasks" + }, + "pending": { + "title": "Waiting photos", + "subtitle": "Moderation suggestions for new uploads.", + "cta": "Go to moderation", + "empty": "No photos waiting for review right now.", + "unknownUploader": "Unknown guest", + "uploadedAt": "Uploaded:", + "statusPending": "Status: awaiting review" + }, + "invites": { + "title": "QR invites", + "subtitle": "Keep an eye on links and brandable layouts.", + "activeCount": "{{count}} active", + "totalCount": "{{count}} total", + "empty": "No QR invites yet.", + "statusActive": "Active", + "statusInactive": "Inactive", + "manage": "Manage invites" + }, + "tasks": { + "title": "Active tasks", + "subtitle": "Motivate guests with clear prompts and highlights.", + "summary": "{{completed}} of {{total}} done", + "empty": "No tasks assigned yet.", + "manage": "Manage tasks", + "completed": "Done", + "open": "Open" + }, + "recent": { + "title": "Latest uploads", + "subtitle": "A quick glance at freshly approved photos.", + "empty": "No approved photos yet." + }, + "feedback": { + "title": "How helpful is this toolkit?", + "subtitle": "Your input helps us fine-tune the event-day experience.", + "positive": "Helpful", + "neutral": "Okay", + "negative": "Needs work", + "placeholder": "Let us know what worked well or what you’re missing …", + "disclaimer": "We’ll keep your feedback private and use it to improve the product.", + "submit": "Send feedback", + "thanksTitle": "Thank you!", + "thanksDescription": "We’ve received your feedback.", + "badge": "Custom" + } + }, + "customizer": { + "title": "Customize QR invite", + "description": "Adjust layout, texts, colors, and logo for your printable invite.", + "layout": "Layout", + "selectLayout": "Select layout", + "headline": "Headline", + "subtitle": "Sub headline", + "descriptionLabel": "Description", + "badgeLabel": "Badge", + "instructionsHeading": "Instructions heading", + "instructionsLabel": "Hints", + "addInstruction": "Add hint", + "removeInstruction": "Remove", + "linkHeading": "Link title", + "linkLabel": "Link", + "ctaLabel": "Call to action", + "colors": { + "accent": "Accent colour", + "text": "Text colour", + "background": "Background", + "secondary": "Secondary colour", + "badge": "Badge colour" + }, + "logo": { + "label": "Logo", + "hint": "PNG or SVG, max. 1 MB. Appears in the top right corner.", + "remove": "Remove logo" + }, + "preview": { + "title": "Preview", + "hint": "Visual reference for colours and texts. Save to generate new PDFs/SVGs." + }, + "actions": { + "save": "Save", + "cancel": "Cancel", + "reset": "Reset" + }, + "badge": "Custom", + "actionLabel": "Customize layout", + "errors": { + "logoTooLarge": "Logo must not exceed 1 MB.", + "noLayout": "Please select a layout." + }, + "defaults": { + "badgeLabel": "Digital guest box", + "instructionsHeading": "How it works", + "linkHeading": "Alternative link", + "ctaLabel": "Scan and get started", + "instructions": [ + "Scan the QR code", + "Create your profile", + "Share your photos" + ] + } } }, "collections": { @@ -316,4 +459,4 @@ } } } -} \ No newline at end of file +} diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index d1a0fdd..546fe17 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -1,7 +1,20 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react'; +import { + CalendarDays, + Camera, + Sparkles, + Users, + Plus, + Settings, + CheckCircle2, + Circle, + QrCode, + ClipboardList, + Package as PackageIcon, + Loader2, +} from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -14,6 +27,8 @@ import { getDashboardSummary, getEvents, getTenantPackagesOverview, + getEventTasks, + getEventQrInvites, TenantEvent, TenantPackageSummary, } from '../api'; @@ -23,6 +38,7 @@ import { adminPath, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH, + ADMIN_EVENT_TASKS_PATH, ADMIN_TASKS_PATH, ADMIN_BILLING_PATH, ADMIN_SETTINGS_PATH, @@ -39,6 +55,16 @@ interface DashboardState { errorKey: string | null; } +type ReadinessState = { + hasEvent: boolean; + hasTasks: boolean; + hasQrInvites: boolean; + hasPackage: boolean; + primaryEventSlug: string | null; + primaryEventName: string | null; + loading: boolean; +}; + export default function DashboardPage() { const navigate = useNavigate(); const location = useLocation(); @@ -66,6 +92,16 @@ export default function DashboardPage() { errorKey: null, }); + const [readiness, setReadiness] = React.useState({ + hasEvent: false, + hasTasks: false, + hasQrInvites: false, + hasPackage: false, + primaryEventSlug: null, + primaryEventName: null, + loading: false, + }); + React.useEffect(() => { let cancelled = false; (async () => { @@ -81,14 +117,56 @@ export default function DashboardPage() { } const fallbackSummary = buildSummaryFallback(events, packages.activePackage); + const primaryEvent = events[0] ?? null; + const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; - setState({ - summary: summary ?? fallbackSummary, - events, - activePackage: packages.activePackage, + setReadiness({ + hasEvent: events.length > 0, + hasTasks: false, + hasQrInvites: false, + hasPackage: Boolean(packages.activePackage), + primaryEventSlug: primaryEvent?.slug ?? null, + primaryEventName, + loading: Boolean(primaryEvent), + }); + + setState({ + summary: summary ?? fallbackSummary, + events, + activePackage: packages.activePackage, + loading: false, + errorKey: null, + }); + + if (primaryEvent) { + try { + const [eventTasks, qrInvites] = await Promise.all([ + getEventTasks(primaryEvent.id, 1), + getEventQrInvites(primaryEvent.slug), + ]); + + if (!cancelled) { + setReadiness((prev) => ({ + ...prev, + hasTasks: (eventTasks.data ?? []).length > 0, + hasQrInvites: qrInvites.length > 0, + loading: false, + })); + } + } catch (readinessError) { + if (!cancelled) { + console.warn('Failed to load readiness checklist', readinessError); + setReadiness((prev) => ({ ...prev, loading: false })); + } + } + } else if (!cancelled) { + setReadiness((prev) => ({ + ...prev, + hasTasks: false, + hasQrInvites: false, loading: false, - errorKey: null, - }); + })); + } } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ @@ -271,6 +349,52 @@ export default function DashboardPage() { + navigate(ADMIN_EVENT_CREATE_PATH)} + onOpenTasks={() => + readiness.primaryEventSlug + ? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug)) + : navigate(ADMIN_TASKS_PATH) + } + onOpenQr={() => + readiness.primaryEventSlug + ? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`) + : navigate(ADMIN_EVENTS_PATH) + } + onOpenPackages={() => navigate(ADMIN_BILLING_PATH)} + /> +
@@ -315,6 +439,27 @@ export default function DashboardPage() { ); } +function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string { + if (typeof name === 'string' && name.trim().length > 0) { + return name; + } + + if (name && typeof name === 'object') { + if (typeof name.de === 'string' && name.de.trim().length > 0) { + return name.de; + } + if (typeof name.en === 'string' && name.en.trim().length > 0) { + return name.en; + } + const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0); + if (typeof first === 'string') { + return first; + } + } + + return fallbackSlug || 'Event'; +} + function buildSummaryFallback( events: TenantEvent[], activePackage: TenantPackageSummary | null @@ -353,6 +498,170 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] { .slice(0, 4); } +type ReadinessLabels = { + title: string; + description: string; + pending: string; + complete: string; + items: { + event: { title: string; hint: string }; + tasks: { title: string; hint: string }; + qr: { title: string; hint: string }; + package: { title: string; hint: string }; + }; + actions: { + createEvent: string; + openTasks: string; + openQr: string; + openPackages: string; + }; +}; + +function ReadinessCard({ + readiness, + labels, + onCreateEvent, + onOpenTasks, + onOpenQr, + onOpenPackages, +}: { + readiness: ReadinessState; + labels: ReadinessLabels; + onCreateEvent: () => void; + onOpenTasks: () => void; + onOpenQr: () => void; + onOpenPackages: () => void; +}) { + const checklistItems = [ + { + key: 'event', + icon: , + completed: readiness.hasEvent, + label: labels.items.event.title, + hint: labels.items.event.hint, + actionLabel: labels.actions.createEvent, + onAction: onCreateEvent, + showAction: !readiness.hasEvent, + }, + { + key: 'tasks', + icon: , + completed: readiness.hasTasks, + label: labels.items.tasks.title, + hint: labels.items.tasks.hint, + actionLabel: labels.actions.openTasks, + onAction: onOpenTasks, + showAction: readiness.hasEvent && !readiness.hasTasks, + }, + { + key: 'qr', + icon: , + completed: readiness.hasQrInvites, + label: labels.items.qr.title, + hint: labels.items.qr.hint, + actionLabel: labels.actions.openQr, + onAction: onOpenQr, + showAction: readiness.hasEvent && !readiness.hasQrInvites, + }, + { + key: 'package', + icon: , + completed: readiness.hasPackage, + label: labels.items.package.title, + hint: labels.items.package.hint, + actionLabel: labels.actions.openPackages, + onAction: onOpenPackages, + showAction: !readiness.hasPackage, + }, + ] as const; + + const activeEventName = readiness.primaryEventName; + + return ( + + + {labels.title} + {labels.description} + {activeEventName ? ( +

+ {activeEventName} +

+ ) : null} +
+ + {readiness.loading ? ( +
+ + {labels.pending} +
+ ) : ( + checklistItems.map((item) => ( + + )) + )} +
+
+ ); +} + +function ChecklistRow({ + icon, + label, + hint, + completed, + status, + action, +}: { + icon: React.ReactNode; + label: string; + hint: string; + completed: boolean; + status: { complete: string; pending: string }; + action?: { label: string; onClick: () => void; disabled?: boolean }; +}) { + return ( +
+
+
+ {icon} +
+
+

{label}

+

{hint}

+
+
+
+ + {completed ? : } + {completed ? status.complete : status.pending} + + {action ? ( + + ) : null} +
+
+ ); +} + function StatCard({ label, value, diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index e194c86..9b4d792 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -8,17 +9,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { AdminLayout } from '../components/AdminLayout'; import { - createInviteLink, - EventJoinToken, - EventJoinTokenLayout, + createQrInvite, + EventQrInvite, + EventQrInviteLayout, EventStats as TenantEventStats, getEvent, - getEventJoinTokens, + getEventQrInvites, getEventStats, TenantEvent, toggleEvent, - revokeEventJoinToken, + revokeEventQrInvite, + updateEventQrInvite, } from '../api'; +import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog'; import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, @@ -26,12 +29,13 @@ import { ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_TASKS_PATH, + ADMIN_EVENT_TOOLKIT_PATH, } from '../constants'; interface State { event: TenantEvent | null; stats: TenantEventStats | null; - tokens: EventJoinToken[]; + invites: EventQrInvite[]; inviteLink: string | null; error: string | null; loading: boolean; @@ -47,14 +51,16 @@ export default function EventDetailPage() { const [state, setState] = React.useState({ event: null, stats: null, - tokens: [], + invites: [], inviteLink: null, error: null, loading: true, busy: false, }); - const [creatingToken, setCreatingToken] = React.useState(false); + const [creatingInvite, setCreatingInvite] = React.useState(false); const [revokingId, setRevokingId] = React.useState(null); + const [customizingInvite, setCustomizingInvite] = React.useState(null); + const [customizerSaving, setCustomizerSaving] = React.useState(false); const load = React.useCallback(async () => { if (!slug) { @@ -64,22 +70,22 @@ export default function EventDetailPage() { setState((prev) => ({ ...prev, loading: true, error: null })); try { - const [eventData, statsData, joinTokens] = await Promise.all([ + const [eventData, statsData, qrInvites] = await Promise.all([ getEvent(slug), getEventStats(slug), - getEventJoinTokens(slug), + getEventQrInvites(slug), ]); setState((prev) => ({ ...prev, event: eventData, stats: statsData, - tokens: joinTokens, + invites: qrInvites, loading: false, inviteLink: prev.inviteLink, })); } catch (err) { if (isAuthError(err)) return; - setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] })); + setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] })); } }, [slug]); @@ -108,58 +114,131 @@ export default function EventDetailPage() { } async function handleInvite() { - if (!slug || creatingToken) return; - setCreatingToken(true); + if (!slug || creatingInvite) return; + setCreatingInvite(true); setState((prev) => ({ ...prev, error: null })); try { - const token = await createInviteLink(slug); + const invite = await createQrInvite(slug); setState((prev) => ({ ...prev, - inviteLink: token.url, - tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)], + inviteLink: invite.url, + invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)], })); try { - await navigator.clipboard.writeText(token.url); + await navigator.clipboard.writeText(invite.url); } catch { // clipboard may be unavailable, ignore silently } } catch (err) { if (!isAuthError(err)) { - setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' })); + setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' })); } } - setCreatingToken(false); + setCreatingInvite(false); } - async function handleCopy(token: EventJoinToken) { + async function handleCopy(invite: EventQrInvite) { try { - await navigator.clipboard.writeText(token.url); - setState((prev) => ({ ...prev, inviteLink: token.url })); + await navigator.clipboard.writeText(invite.url); + setState((prev) => ({ ...prev, inviteLink: invite.url })); } catch (err) { console.warn('Clipboard copy failed', err); } } - async function handleRevoke(token: EventJoinToken) { - if (!slug || token.revoked_at) return; - setRevokingId(token.id); + async function handleRevoke(invite: EventQrInvite) { + if (!slug || invite.revoked_at) return; + setRevokingId(invite.id); setState((prev) => ({ ...prev, error: null })); try { - const updated = await revokeEventJoinToken(slug, token.id); + const updated = await revokeEventQrInvite(slug, invite.id); setState((prev) => ({ ...prev, - tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)), + invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)), })); } catch (err) { if (!isAuthError(err)) { - setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' })); + setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' })); } } finally { setRevokingId(null); } } - const { event, stats, tokens, inviteLink, error, loading, busy } = state; + function openCustomizer(invite: EventQrInvite) { + setState((prev) => ({ ...prev, error: null })); + setCustomizingInvite(invite); + } + + function closeCustomizer() { + if (customizerSaving) { + return; + } + setCustomizingInvite(null); + } + + async function handleApplyCustomization(customization: QrLayoutCustomization) { + if (!slug || !customizingInvite) { + return; + } + setCustomizerSaving(true); + setState((prev) => ({ ...prev, error: null })); + try { + const updated = await updateEventQrInvite(slug, customizingInvite.id, { + metadata: { + layout_customization: customization, + }, + }); + setState((prev) => ({ + ...prev, + invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)), + })); + setCustomizerSaving(false); + setCustomizingInvite(null); + } catch (err) { + if (!isAuthError(err)) { + setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' })); + } + setCustomizerSaving(false); + } + } + + async function handleResetCustomization() { + if (!slug || !customizingInvite) { + return; + } + setCustomizerSaving(true); + setState((prev) => ({ ...prev, error: null })); + try { + const updated = await updateEventQrInvite(slug, customizingInvite.id, { + metadata: { + layout_customization: null, + }, + }); + setState((prev) => ({ + ...prev, + invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)), + })); + setCustomizerSaving(false); + setCustomizingInvite(null); + } catch (err) { + if (!isAuthError(err)) { + setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' })); + } + setCustomizerSaving(false); + } + } + + const { event, stats, invites, inviteLink, error, loading, busy } = state; + const eventDisplayName = event ? renderName(event.name) : ''; + const currentCustomization = React.useMemo(() => { + if (!customizingInvite) { + return null; + } + const metadata = customizingInvite.metadata as Record | undefined | null; + const raw = metadata?.layout_customization; + return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null; + }, [customizingInvite]); const actions = ( <> @@ -193,6 +272,13 @@ export default function EventDetailPage() { > Tasks + )} @@ -261,33 +347,33 @@ export default function EventDetailPage() { - + - Einladungslinks & QR-Layouts + QR-Einladungen & Drucklayouts - Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches - Vokabular. + Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen – inklusive Branding und Anleitungen – + zum Ausdrucken herunter.

- Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen - kannst du jederzeit erneuern oder deaktivieren. + Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen + jederzeit erneuern oder deaktivieren.

- {tokens.length > 0 && ( + {invites.length > 0 && (

- Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '} - {tokens.length} + Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '} + {invites.length}

)}
- {inviteLink && ( @@ -297,20 +383,22 @@ export default function EventDetailPage() { )}
- {tokens.length > 0 ? ( - tokens.map((token) => ( + {invites.length > 0 ? ( + invites.map((invite) => ( handleCopy(token)} - onRevoke={() => handleRevoke(token)} - revoking={revokingId === token.id} + key={invite.id} + invite={invite} + onCopy={() => handleCopy(invite)} + onRevoke={() => handleRevoke(invite)} + revoking={revokingId === invite.id} + onCustomize={() => openCustomizer(invite)} + eventName={eventDisplayName} /> )) ) : (
- Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code - herunterzuladen und zu teilen. + Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren + und zu teilen.
)}
@@ -340,6 +428,18 @@ export default function EventDetailPage() { Bitte pruefe den Slug und versuche es erneut. )} + + ); } @@ -373,21 +473,29 @@ function StatChip({ label, value }: { label: string; value: string | number }) { } function InvitationCard({ - token, + invite, onCopy, onRevoke, revoking, + onCustomize, + eventName, }: { - token: EventJoinToken; + invite: EventQrInvite; onCopy: () => void; onRevoke: () => void; revoking: boolean; + onCustomize: () => void; + eventName: string; }) { - const status = getTokenStatus(token); - const layouts = Array.isArray(token.layouts) ? token.layouts : []; - const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`; - const metadata = (token.metadata ?? {}) as Record; + const { t } = useTranslation('management'); + const status = getInviteStatus(invite); + const layouts = Array.isArray(invite.layouts) ? invite.layouts : []; + const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`; + const metadata = (invite.metadata ?? {}) as Record; const isAutoGenerated = Boolean(metadata.auto_generated); + const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null; + const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null); + const hasCustomization = customization ? Object.keys(customization).length > 0 : false; const statusClassname = status === 'Aktiv' @@ -401,17 +509,22 @@ function InvitationCard({
- {token.label?.trim() || `Einladung #${token.id}`} + {invite.label?.trim() || `Einladung #${invite.id}`} {status} {isAutoGenerated ? ( Standard ) : null} + {hasCustomization ? ( + + {t('tasks.customizer.badge', 'Angepasst')} + + ) : null}
- {token.url} + {invite.url}
- {token.layouts_url ? ( + + {invite.layouts_url ? ( - + diff --git a/resources/js/admin/pages/EventToolkitPage.tsx b/resources/js/admin/pages/EventToolkitPage.tsx new file mode 100644 index 0000000..d903add --- /dev/null +++ b/resources/js/admin/pages/EventToolkitPage.tsx @@ -0,0 +1,563 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + ArrowLeft, + Camera, + CheckCircle2, + Circle, + Loader2, + MessageSquare, + RefreshCw, + Send, + Sparkles, + ThumbsDown, + ThumbsUp, +} from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; + +import { AdminLayout } from '../components/AdminLayout'; +import { + ADMIN_EVENT_PHOTOS_PATH, + ADMIN_EVENT_TASKS_PATH, + ADMIN_EVENT_VIEW_PATH, +} from '../constants'; +import { + EventToolkit, + EventToolkitTask, + getEventToolkit, + submitTenantFeedback, + TenantPhoto, + TenantEvent, +} from '../api'; +import { isAuthError } from '../auth/tokens'; + +interface ToolkitState { + loading: boolean; + error: string | null; + data: EventToolkit | null; +} + +export default function EventToolkitPage(): JSX.Element { + const { slug } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + const { t, i18n } = useTranslation('management'); + + const [state, setState] = React.useState({ loading: true, error: null, data: null }); + const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null); + const [feedbackMessage, setFeedbackMessage] = React.useState(''); + const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false); + const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); + + const load = React.useCallback(async () => { + if (!slug) { + setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null }); + return; + } + + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const toolkit = await getEventToolkit(slug); + setState({ loading: false, error: null, data: toolkit }); + } catch (error) { + if (!isAuthError(error)) { + setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null }); + } + } + }, [slug, t]); + + React.useEffect(() => { + void load(); + }, [load]); + + const { data, loading } = state; + const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : ''; + + const actions = ( +
+ + + + +
+ ); + + return ( + + {state.error && ( + + {t('toolkit.alerts.errorTitle', 'Fehler')} + {state.error} + + )} + + {loading ? ( + + ) : data ? ( +
+ + + + +
+ navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))} + /> + navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} /> +
+ +
+ navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} /> + +
+ + { + if (!slug) return; + setFeedbackSubmitting(true); + try { + await submitTenantFeedback({ + category: 'event_toolkit', + sentiment: feedbackSentiment ?? undefined, + message: feedbackMessage ? feedbackMessage.trim() : undefined, + event_slug: slug, + }); + setFeedbackSentiment(null); + setFeedbackMessage(''); + setFeedbackSubmitted(true); + } catch (error) { + if (!isAuthError(error)) { + setState((prev) => ({ + ...prev, + error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'), + })); + } + } finally { + setFeedbackSubmitting(false); + } + }} + /> +
+ ) : null} +
+ ); +} + +function resolveEventName(name: TenantEvent['name'], locale?: string): string { + if (typeof name === 'string') { + return name; + } + if (name && typeof name === 'object') { + if (locale && name[locale]) { + return name[locale]; + } + const short = locale && locale.includes('-') ? locale.split('-')[0] : null; + if (short && name[short]) { + return name[short]; + } + return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event'; + } + return 'Event'; +} + +function AlertList({ alerts }: { alerts: string[] }) { + const { t } = useTranslation('management'); + if (!alerts.length) { + return null; + } + + const alertMap: Record = { + no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'), + no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'), + pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'), + }; + + return ( +
+ {alerts.map((code) => ( + + {t('toolkit.alerts.attention', 'Achtung')} + {alertMap[code] ?? code} + + ))} +
+ ); +} + +function MetricsGrid({ + metrics, +}: { + metrics: EventToolkit['metrics']; +}) { + const { t } = useTranslation('management'); + const cards = [ + { + label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'), + value: metrics.uploads_total, + }, + { + label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'), + value: metrics.uploads_24h, + }, + { + label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'), + value: metrics.pending_photos, + }, + { + label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'), + value: metrics.active_invites, + }, + { + label: t('toolkit.metrics.engagementMode', 'Modus'), + value: + metrics.engagement_mode === 'photo_only' + ? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus') + : t('toolkit.metrics.modeTasks', 'Aufgaben'), + }, + ]; + + return ( +
+ {cards.map((card) => ( + + +

{card.label}

+

{card.value}

+
+
+ ))} +
+ ); +} + +function PendingPhotosCard({ + photos, + navigateToModeration, +}: { + photos: TenantPhoto[]; + navigateToModeration: () => void; +}) { + const { t } = useTranslation('management'); + + return ( + + +
+ + + {t('toolkit.pending.title', 'Wartende Fotos')} + + + {t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')} + +
+ +
+ + {photos.length === 0 ? ( +

{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}

+ ) : ( +
+ {photos.map((photo) => ( +
+ {photo.filename} +
+

{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}

+

{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}

+

{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}

+
+
+ ))} +
+ )} +
+
+ ); +} + +function InviteSummary({ + invites, + navigateToEvent, +}: { + invites: EventToolkit['invites']; + navigateToEvent: () => void; +}) { + const { t } = useTranslation('management'); + return ( + + + + + {t('toolkit.invites.title', 'QR-Einladungen')} + + + {t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')} + + + +
+ + {t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })} + + + {t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })} + +
+ {invites.items.length === 0 ? ( +

{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}

+ ) : ( +
    + {invites.items.map((invite) => ( +
  • +

    {invite.label ?? invite.url}

    +

    {invite.url}

    +

    + {invite.is_active + ? t('toolkit.invites.statusActive', 'Aktiv') + : t('toolkit.invites.statusInactive', 'Inaktiv')} +

    +
  • + ))} +
+ )} + +
+
+ ); +} + +function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) { + const { t } = useTranslation('management'); + + return ( + + +
+ + + {t('toolkit.tasks.title', 'Aktive Aufgaben')} + + + {t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')} + +
+ + {t('toolkit.tasks.summary', { + defaultValue: '{{completed}} von {{total}} erledigt', + completed: tasks.summary.completed, + total: tasks.summary.total, + })} + +
+ + {tasks.items.length === 0 ? ( +

{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}

+ ) : ( +
+ {tasks.items.map((task) => ( + + ))} +
+ )} + +
+
+ ); +} + +function TaskRow({ task }: { task: EventToolkitTask }) { + const { t } = useTranslation('management'); + return ( +
+
+

{task.title}

+ {task.description ?

{task.description}

: null} +
+ + {task.is_completed ? : } + {task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')} + +
+ ); +} + +function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) { + const { t } = useTranslation('management'); + return ( + + + + + {t('toolkit.recent.title', 'Neueste Uploads')} + + + {t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')} + + + + {photos.length === 0 ? ( +

{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}

+ ) : ( +
+ {photos.map((photo) => ( + {photo.filename} + ))} +
+ )} +
+
+ ); +} + +function FeedbackCard({ + submitting, + submitted, + sentiment, + message, + onSelectSentiment, + onMessageChange, + onSubmit, +}: { + submitting: boolean; + submitted: boolean; + sentiment: 'positive' | 'neutral' | 'negative' | null; + message: string; + onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void; + onMessageChange: (value: string) => void; + onSubmit: () => Promise; +}) { + const { t } = useTranslation('management'); + + return ( + + + + + {t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')} + + + {t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')} + + + + {submitted ? ( + + {t('toolkit.feedback.thanksTitle', 'Danke!')} + {t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')} + + ) : ( + <> +
+ + + +
+
+