From 06df61f70626b0cba3bfb3c92c49e9428de32d70 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 30 Oct 2025 07:12:27 +0100 Subject: [PATCH] QR-Codes-UI zu Einladungen umgebaut mit PDF-Export und Druckanzeige + Customizer --- app/Filament/Resources/EventResource.php | 13 +- app/Filament/Tenant/Pages/InviteStudio.php | 9 +- .../Tenant/Pages/TenantOnboarding.php | 10 +- .../Tenant/Resources/EventResource.php | 36 +- .../Tenant/EventJoinTokenLayoutController.php | 2 +- .../Tenant/EventJoinTokenResource.php | 24 +- app/Support/JoinTokenLayoutRegistry.php | 11 +- database/seeders/InviteLayoutSeeder.php | 2 +- package.json | 5 +- resources/css/app.css | 51 + resources/js/admin/api.ts | 7 + .../js/admin/i18n/locales/de/management.json | 30 +- .../js/admin/i18n/locales/en/management.json | 30 +- resources/js/admin/pages/EventFormPage.tsx | 103 +- resources/js/admin/pages/EventInvitesPage.tsx | 617 ++++++-- resources/js/admin/pages/EventsPage.tsx | 4 +- .../InviteLayoutCustomizerPanel.tsx | 1244 ++++++++++++----- .../views/layouts/join-token/pdf.blade.php | 5 +- .../views/layouts/join-token/svg.blade.php | 23 +- .../Feature/Tenant/EventInviteQrCodeTest.php | 35 + 20 files changed, 1724 insertions(+), 537 deletions(-) create mode 100644 tests/Feature/Tenant/EventInviteQrCodeTest.php diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 84dec3c..597c2f7 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -5,19 +5,18 @@ namespace App\Filament\Resources; use App\Filament\Resources\EventResource\Pages; use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager; use App\Models\Event; +use App\Models\EventJoinTokenEvent; use App\Models\EventType; use App\Models\Tenant; -use App\Models\EventJoinTokenEvent; use App\Support\JoinTokenLayoutRegistry; -use Carbon\Carbon; use BackedEnum; +use Carbon\Carbon; use Filament\Actions; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables; @@ -116,7 +115,7 @@ class EventResource extends Resource ->getStateUsing(function ($record) { $token = $record->joinTokens()->latest()->first(); - return $token ? url('/e/' . $token->token) : __('admin.events.table.no_join_tokens'); + return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens'); }) ->description(function ($record) { $total = $record->joinTokens()->count(); @@ -185,7 +184,7 @@ class EventResource extends Resource $tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) { $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { - return route('tenant.events.join-tokens.layouts.download', [ + return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $record->slug, 'joinToken' => $token->getKey(), 'layout' => $layoutId, @@ -212,7 +211,7 @@ class EventResource extends Resource 'id' => $token->id, 'label' => $token->label, 'token' => $token->token, - 'url' => url('/e/' . $token->token), + 'url' => url('/e/'.$token->token), 'usage_limit' => $token->usage_limit, 'usage_count' => $token->usage_count, 'expires_at' => optional($token->expires_at)->toIso8601String(), @@ -220,7 +219,7 @@ class EventResource extends Resource 'is_active' => $token->isActive(), 'created_at' => optional($token->created_at)->toIso8601String(), 'layouts' => $layouts, - 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [ 'event' => $record->slug, 'joinToken' => $token->getKey(), ]), diff --git a/app/Filament/Tenant/Pages/InviteStudio.php b/app/Filament/Tenant/Pages/InviteStudio.php index e673fce..990af00 100644 --- a/app/Filament/Tenant/Pages/InviteStudio.php +++ b/app/Filament/Tenant/Pages/InviteStudio.php @@ -1,14 +1,15 @@ tokenLabel ?: 'Einladung ' . now()->format('d.m.'); + $label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.'); $layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout'); @@ -141,7 +142,7 @@ class InviteStudio extends Page protected function mapToken(Event $event, EventJoinToken $token): array { $downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) { - return route('tenant.events.join-tokens.layouts.download', [ + return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $event->slug, 'joinToken' => $token->getKey(), 'layout' => $layoutId, @@ -152,7 +153,7 @@ class InviteStudio extends Page return [ 'id' => $token->getKey(), 'label' => $token->label ?? 'Einladungslink', - 'url' => URL::to('/e/' . $token->token), + 'url' => URL::to('/e/'.$token->token), 'created_at' => optional($token->created_at)->format('d.m.Y H:i'), 'usage_count' => $token->usage_count, 'usage_limit' => $token->usage_limit, diff --git a/app/Filament/Tenant/Pages/TenantOnboarding.php b/app/Filament/Tenant/Pages/TenantOnboarding.php index a1aff3a..e61b1c0 100644 --- a/app/Filament/Tenant/Pages/TenantOnboarding.php +++ b/app/Filament/Tenant/Pages/TenantOnboarding.php @@ -10,16 +10,16 @@ use App\Services\EventJoinTokenService; use App\Services\Tenant\TaskCollectionImportService; use App\Support\JoinTokenLayoutRegistry; use App\Support\TenantOnboardingState; +use BackedEnum; use Filament\Notifications\Notification; use Filament\Pages\Page; -use BackedEnum; -use UnitEnum; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Throwable; +use UnitEnum; class TenantOnboarding extends Page { @@ -234,7 +234,7 @@ class TenantOnboarding extends Page protected function buildInviteDownloads(Event $event, $token): array { return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) { - return route('tenant.events.join-tokens.layouts.download', [ + return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $event->slug, 'joinToken' => $token->getKey(), 'layout' => $layoutId, @@ -260,7 +260,7 @@ class TenantOnboarding extends Page public function getEventTypeOptionsProperty(): array { return EventType::query() - ->orderBy('name->' . app()->getLocale()) + ->orderBy('name->'.app()->getLocale()) ->get() ->mapWithKeys(function (EventType $type) { $name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name); @@ -306,6 +306,6 @@ class TenantOnboarding extends Page protected function getDefaultEventTypeId(): ?int { - return EventType::query()->orderBy('name->' . app()->getLocale())->value('id'); + return EventType::query()->orderBy('name->'.app()->getLocale())->value('id'); } } diff --git a/app/Filament/Tenant/Resources/EventResource.php b/app/Filament/Tenant/Resources/EventResource.php index 3005e5d..73135c7 100644 --- a/app/Filament/Tenant/Resources/EventResource.php +++ b/app/Filament/Tenant/Resources/EventResource.php @@ -3,42 +3,42 @@ namespace App\Filament\Tenant\Resources; use App\Filament\Tenant\Resources\EventResource\Pages; +use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager; +use App\Models\Event; +use App\Models\EventJoinTokenEvent; +use App\Models\EventType; use App\Support\JoinTokenLayoutRegistry; use App\Support\TenantOnboardingState; -use App\Models\Event; -use App\Models\EventType; -use App\Models\EventJoinTokenEvent; +use BackedEnum; use Carbon\Carbon; -use Filament\Resources\Resource; -use Filament\Tables; -use Filament\Tables\Table; use Filament\Actions; -use Filament\Forms; -use Filament\Forms\Form; -use Filament\Schemas\Schema; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\DatePicker; -use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Select; -use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; +use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use UnitEnum; -use BackedEnum; - -use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager; class EventResource extends Resource { protected static ?string $model = Event::class; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar'; + protected static UnitEnum|string|null $navigationGroup = null; public static function getNavigationGroup(): UnitEnum|string|null { return __('admin.nav.platform'); } + protected static ?int $navigationSort = 20; public static function shouldRegisterNavigation(): bool @@ -141,7 +141,7 @@ class EventResource extends Resource Actions\Action::make('toggle') ->label(__('admin.events.actions.toggle_active')) ->icon('heroicon-o-power') - ->action(fn($record) => $record->update(['is_active' => !$record->is_active])), + ->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])), Actions\Action::make('join_tokens') ->label(__('admin.events.actions.join_link_qr')) ->icon('heroicon-o-qr-code') @@ -185,7 +185,7 @@ class EventResource extends Resource $tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) { $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { - return route('tenant.events.join-tokens.layouts.download', [ + return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $record->slug, 'joinToken' => $token->getKey(), 'layout' => $layoutId, @@ -220,7 +220,7 @@ class EventResource extends Resource 'is_active' => $token->isActive(), 'created_at' => optional($token->created_at)->toIso8601String(), 'layouts' => $layouts, - 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [ 'event' => $record->slug, 'joinToken' => $token->getKey(), ]), diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php index 3f75cbe..5f2457b 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php @@ -19,7 +19,7 @@ class EventJoinTokenLayoutController extends Controller $this->ensureBelongsToEvent($event, $joinToken); $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { - return route('tenant.events.join-tokens.layouts.download', [ + return route('api.v1.tenant.events.join-tokens.layouts.download', [ 'event' => $event, 'joinToken' => $joinToken, 'layout' => $layoutId, diff --git a/app/Http/Resources/Tenant/EventJoinTokenResource.php b/app/Http/Resources/Tenant/EventJoinTokenResource.php index 84844ba..3c8f921 100644 --- a/app/Http/Resources/Tenant/EventJoinTokenResource.php +++ b/app/Http/Resources/Tenant/EventJoinTokenResource.php @@ -7,6 +7,7 @@ use App\Support\JoinTokenLayoutRegistry; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Route; +use SimpleSoftwareIO\QrCode\Facades\QrCode; class EventJoinTokenResource extends JsonResource { @@ -40,13 +41,34 @@ class EventJoinTokenResource extends JsonResource } $plainToken = $this->resource->plain_token ?? $this->token; + $qrCodeUrl = $plainToken ? url('/e/'.$plainToken) : null; + $qrCodeDataUrl = null; + + if ($qrCodeUrl) { + try { + $svg = QrCode::format('svg') + ->size(360) + ->margin(1) + ->errorCorrection('M') + ->generate($qrCodeUrl); + + $svgString = (string) $svg; + + if ($svgString !== '') { + $qrCodeDataUrl = 'data:image/svg+xml;base64,'.base64_encode($svgString); + } + } catch (\Throwable $exception) { + report($exception); + } + } return [ 'id' => $this->id, 'label' => $this->label, 'token' => $plainToken, 'token_preview' => $this->token_preview, - 'url' => $plainToken ? url('/e/'.$plainToken) : null, + 'url' => $qrCodeUrl, + 'qr_code_data_url' => $qrCodeDataUrl, 'usage_limit' => $this->usage_limit, 'usage_count' => $this->usage_count, 'expires_at' => optional($this->expires_at)->toIso8601String(), diff --git a/app/Support/JoinTokenLayoutRegistry.php b/app/Support/JoinTokenLayoutRegistry.php index 6f7f69f..8f87a32 100644 --- a/app/Support/JoinTokenLayoutRegistry.php +++ b/app/Support/JoinTokenLayoutRegistry.php @@ -24,7 +24,7 @@ class JoinTokenLayoutRegistry 'accent' => '#6366F1', 'secondary' => '#CBD5F5', 'badge' => '#0EA5E9', - 'qr' => ['size_px' => 340], + 'qr' => ['size_px' => 500], 'svg' => ['width' => 1080, 'height' => 1520], 'instructions' => [ 'Scanne den Code und tritt dem Event direkt bei.', @@ -44,7 +44,7 @@ class JoinTokenLayoutRegistry 'accent' => '#C08457', 'secondary' => '#E6D5C3', 'badge' => '#8B5CF6', - 'qr' => ['size_px' => 300], + 'qr' => ['size_px' => 460], 'svg' => ['width' => 1080, 'height' => 1520], 'instructions' => [ 'QR-Code scannen oder Link im Browser eingeben.', @@ -68,7 +68,7 @@ class JoinTokenLayoutRegistry 'accent' => '#FFFFFF', 'secondary' => 'rgba(255,255,255,0.72)', 'badge' => '#1E293B', - 'qr' => ['size_px' => 360], + 'qr' => ['size_px' => 540], 'svg' => ['width' => 1080, 'height' => 1520], 'instructions' => [ 'Sofort scannen – der QR-Code führt direkt zum Event.', @@ -88,7 +88,7 @@ class JoinTokenLayoutRegistry 'accent' => '#0EA5E9', 'secondary' => '#94A3B8', 'badge' => '#334155', - 'qr' => ['size_px' => 320], + 'qr' => ['size_px' => 500], 'svg' => ['width' => 1080, 'height' => 1520], 'instructions' => [ 'Schritt 1: QR-Code scannen oder Kurzlink nutzen.', @@ -108,7 +108,7 @@ class JoinTokenLayoutRegistry 'accent' => '#9333EA', 'secondary' => '#E0E7FF', 'badge' => '#64748B', - 'qr' => ['size_px' => 280], + 'qr' => ['size_px' => 440], 'svg' => ['width' => 1080, 'height' => 1520], 'instructions' => [ 'Code scannen, Profil erstellen, Erinnerungen festhalten.', @@ -255,6 +255,7 @@ class JoinTokenLayoutRegistry 'background_gradient' => $layout['background_gradient'], 'accent' => $layout['accent'], 'text' => $layout['text'], + 'qr_size_px' => $layout['qr']['size_px'] ?? null, ], 'formats' => $formats, 'download_urls' => collect($formats) diff --git a/database/seeders/InviteLayoutSeeder.php b/database/seeders/InviteLayoutSeeder.php index c558398..984ac00 100644 --- a/database/seeders/InviteLayoutSeeder.php +++ b/database/seeders/InviteLayoutSeeder.php @@ -23,7 +23,7 @@ class InviteLayoutSeeder extends Seeder 'secondary' => $layout['secondary'] ?? null, 'text' => $layout['text'] ?? null, 'badge' => $layout['badge'] ?? null, - 'qr' => $layout['qr'] ?? ['size_px' => 320], + 'qr' => $layout['qr'] ?? ['size_px' => 500], 'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520], ]; diff --git a/package.json b/package.json index a94b9c8..0f14e71 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,9 @@ "i18next-http-backend": "^3.0.2", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-rnd": "^10.4.12", "react-hot-toast": "^2.6.0", "react-i18next": "^16.0.0", "react-router-dom": "^7.8.2", diff --git a/resources/css/app.css b/resources/css/app.css index ab74627..3a03199 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -385,6 +385,14 @@ html.dark { --sidebar-accent-foreground: var(--brand-slate); --sidebar-border: #f7d9e6; --sidebar-ring: var(--brand-rose); + + --tenant-surface: rgba(255, 255, 255, 0.92); + --tenant-surface-muted: rgba(255, 231, 240, 0.82); + --tenant-surface-strong: #ffffff; + --tenant-border-strong: #f5d2e3; + --tenant-foreground-soft: #51344d; + --tenant-layer: rgba(255, 255, 255, 0.9); + --tenant-layer-strong: rgba(255, 255, 255, 0.96); } .tenant-admin-welcome-theme { @@ -393,6 +401,49 @@ html.dark { color: var(--brand-slate); } +.dark .tenant-admin-theme { + --background: #0f172a; + --foreground: #f8fafc; + --card: #16223a; + --card-foreground: #f1f5f9; + --popover: #16223a; + --popover-foreground: #f8fafc; + --primary: #f472b6; + --primary-foreground: #2e0f1f; + --secondary: #fbbf24; + --secondary-foreground: #1f1300; + --muted: #1f2937; + --muted-foreground: #d1d5db; + --accent: #1f2937; + --accent-foreground: #f1f5f9; + --destructive: #f87171; + --destructive-foreground: #7f1d1d; + --border: rgba(148, 163, 184, 0.25); + --input: rgba(148, 163, 184, 0.25); + --ring: #f472b6; + --chart-1: #f472b6; + --chart-2: #fbbf24; + --chart-3: #38bdf8; + --chart-4: #34d399; + --chart-5: #fb7185; + --sidebar: #111827; + --sidebar-foreground: #f8fafc; + --sidebar-primary: #f472b6; + --sidebar-primary-foreground: #2e0f1f; + --sidebar-accent: rgba(248, 113, 169, 0.12); + --sidebar-accent-foreground: #f8fafc; + --sidebar-border: rgba(148, 163, 184, 0.2); + --sidebar-ring: #f472b6; + + --tenant-surface: rgba(21, 34, 58, 0.92); + --tenant-surface-muted: rgba(15, 23, 42, 0.85); + --tenant-surface-strong: #1e293b; + --tenant-border-strong: rgba(148, 163, 184, 0.35); + --tenant-foreground-soft: #e2e8f0; + --tenant-layer: rgba(30, 41, 59, 0.88); + --tenant-layer-strong: rgba(30, 41, 59, 0.94); +} + .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 61a789d..b6962ab 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -13,6 +13,7 @@ export type EventQrInviteLayout = { background_gradient: { angle: number; stops: string[] } | null; accent: string | null; text: string | null; + qr_size_px?: number | null; }; formats: string[]; download_urls: Record; @@ -257,6 +258,7 @@ export type EventQrInvite = { token: string; url: string; label: string | null; + qr_code_data_url: string | null; usage_limit: number | null; usage_count: number; expires_at: string | null; @@ -678,6 +680,7 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite { background_gradient: layout.preview?.background_gradient ?? null, accent: layout.preview?.accent ?? null, text: layout.preview?.text ?? null, + qr_size_px: layout.preview?.qr_size_px ?? layout.qr?.size_px ?? null, }, formats, download_urls: (layout.download_urls ?? {}) as Record, @@ -699,6 +702,10 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite { metadata: (raw.metadata ?? {}) as Record, layouts, layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null, + qr_code_data_url: + typeof raw.qr_code_data_url === 'string' && raw.qr_code_data_url.length > 0 + ? String(raw.qr_code_data_url) + : null, }; } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 9e5505a..b876b04 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -318,6 +318,11 @@ "cardTitle": "QR-Einladungen & Layouts", "cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.", "subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.", + "tabs": { + "layout": "QR-Code-Layout anpassen", + "export": "Drucken & Export", + "links": "QR-Codes verwalten" + }, "summary": { "active": "Aktive Einladungen", "total": "Gesamt" @@ -337,13 +342,26 @@ "layoutFallback": "Standard", "selected": "Aktuell ausgewählt", "tapToEdit": "Zum Anpassen auswählen", - "noPrintSource": "Keine druckbare Version verfügbar." + "noPrintSource": "Keine druckbare Version verfügbar.", + "standard": "Standard-Link", + "qrAlt": "QR-Code Vorschau" }, "empty": { "title": "Noch keine Einladungen", "copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten." }, "errorTitle": "Aktion fehlgeschlagen", + "export": { + "title": "Drucken & Export", + "description": "Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.", + "selectPlaceholder": "Einladung auswählen", + "noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.", + "noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.", + "actions": { + "print": "Direkt drucken" + }, + "errorTitle": "Download fehlgeschlagen" + }, "customizer": { "heading": "Layout anpassen", "copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.", @@ -361,7 +379,7 @@ "text": "Texte", "instructions": "Schritt-für-Schritt", "instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.", - "branding": "Branding" + "branding": "Farbgebung" }, "fields": { "headline": "Überschrift", @@ -381,7 +399,13 @@ }, "preview": { "title": "Live-Vorschau", - "subtitle": "So sieht dein Layout beim Export aus." + "subtitle": "So sieht dein Layout beim Export aus.", + "mobileOpen": "Vorschau anzeigen", + "mobileTitle": "Einladungsvorschau", + "mobileHint": "Öffnet eine Vorschau in einem Overlay", + "readyForGuests": "Bereit für Gäste", + "instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.", + "qrAlt": "QR-Code der Einladung" }, "placeholderTitle": "Kein Layout verfügbar", "placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index eb4b4b0..6c00a8c 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -318,6 +318,11 @@ "cardTitle": "QR invites & layouts", "cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.", "subtitle": "Manage invite links, layouts, and branding for your guests.", + "tabs": { + "layout": "Customise layout", + "export": "Print & export", + "links": "Manage invites" + }, "summary": { "active": "Active invites", "total": "Total" @@ -337,13 +342,26 @@ "layoutFallback": "Default", "selected": "Currently selected", "tapToEdit": "Select to edit", - "noPrintSource": "No printable version available." + "noPrintSource": "No printable version available.", + "standard": "Default link", + "qrAlt": "QR preview" }, "empty": { "title": "No invites yet", "copy": "Create an invite to generate ready-to-print QR layouts." }, "errorTitle": "Action failed", + "export": { + "title": "Print & export", + "description": "Download print-ready files or launch a test print right away.", + "selectPlaceholder": "Select invite", + "noInviteSelected": "Select an invite first to start downloads.", + "noLayouts": "There are currently no layouts available for this invite.", + "actions": { + "print": "Print now" + }, + "errorTitle": "Download failed" + }, "customizer": { "heading": "Customise layout", "copy": "Make the invite your own – adjust copy, colours, and logos in real time.", @@ -361,7 +379,7 @@ "text": "Text", "instructions": "Step-by-step", "instructionsHint": "Guide guests with clear steps. Maximum of five.", - "branding": "Branding" + "branding": "Colors" }, "fields": { "headline": "Headline", @@ -381,7 +399,13 @@ }, "preview": { "title": "Live preview", - "subtitle": "See the export-ready version instantly." + "subtitle": "See the export-ready version instantly.", + "mobileOpen": "Show preview", + "mobileTitle": "Invite preview", + "mobileHint": "Opens a preview overlay", + "readyForGuests": "Ready for guests", + "instructions": "This link takes guests directly to the gallery and works together with the printed QR code.", + "qrAlt": "Invite QR code" }, "placeholderTitle": "No layout available", "placeholderCopy": "Create an invite first to customise copy, colours, and print layouts.", diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index fb71722..ab4eea1 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -13,7 +13,15 @@ import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { AdminLayout } from '../components/AdminLayout'; -import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api'; +import { + createEvent, + getEvent, + getTenantPackagesOverview, + updateEvent, + getPackages, + getEventTypes, + TenantEvent, +} from '../api'; import { isAuthError } from '../auth/tokens'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; @@ -65,7 +73,6 @@ export default function EventFormPage() { }); const [autoSlug, setAutoSlug] = React.useState(true); const [originalSlug, setOriginalSlug] = React.useState(null); - const [loading, setLoading] = React.useState(isEdit); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const [readOnlyPackageName, setReadOnlyPackageName] = React.useState(null); @@ -107,6 +114,17 @@ export default function EventFormPage() { setReadOnlyPackageName((prev) => prev ?? activePackage.package_name); }, [isEdit, activePackage]); + const { + data: loadedEvent, + isLoading: eventLoading, + error: eventLoadError, + } = useQuery({ + queryKey: ['tenant', 'events', slugParam], + queryFn: () => getEvent(slugParam!), + enabled: Boolean(isEdit && slugParam), + staleTime: 60_000, + }); + React.useEffect(() => { if (isEdit) { return; @@ -128,54 +146,45 @@ export default function EventFormPage() { }, [eventTypes, isEdit]); React.useEffect(() => { - let cancelled = false; - if (!isEdit || !slugParam) { - setLoading(false); - return () => { - cancelled = true; - }; + if (!isEdit || !loadedEvent) { + return; } - (async () => { - try { - const event = await getEvent(slugParam); - if (cancelled) return; - const name = normalizeName(event.name); - setForm((prev) => ({ - ...prev, - name, - slug: event.slug, - date: event.event_date ? event.event_date.slice(0, 10) : '', - eventTypeId: event.event_type_id ?? prev.eventTypeId, - isPublished: event.status === 'published', - package_id: event.package?.id ? Number(event.package.id) : prev.package_id, - })); - setOriginalSlug(event.slug); - setReadOnlyPackageName(event.package?.name ?? null); - setEventPackageMeta(event.package - ? { - id: Number(event.package.id), - name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''), - purchasedAt: event.package.purchased_at ?? null, - expiresAt: event.package.expires_at ?? null, - } - : null); - setAutoSlug(false); - } catch (err) { - if (!isAuthError(err)) { - setError('Event konnte nicht geladen werden.'); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - })(); + const name = normalizeName(loadedEvent.name); - return () => { - cancelled = true; - }; - }, [isEdit, slugParam]); + setForm((prev) => ({ + ...prev, + name, + slug: loadedEvent.slug, + date: loadedEvent.event_date ? loadedEvent.event_date.slice(0, 10) : '', + eventTypeId: loadedEvent.event_type_id ?? prev.eventTypeId, + isPublished: loadedEvent.status === 'published', + package_id: loadedEvent.package?.id ? Number(loadedEvent.package.id) : prev.package_id, + })); + setOriginalSlug(loadedEvent.slug); + setReadOnlyPackageName(loadedEvent.package?.name ?? null); + setEventPackageMeta(loadedEvent.package + ? { + id: Number(loadedEvent.package.id), + name: loadedEvent.package.name ?? (typeof loadedEvent.package === 'string' ? loadedEvent.package : ''), + purchasedAt: loadedEvent.package.purchased_at ?? null, + expiresAt: loadedEvent.package.expires_at ?? null, + } + : null); + setAutoSlug(false); + }, [isEdit, loadedEvent]); + + React.useEffect(() => { + if (!isEdit || !eventLoadError) { + return; + } + + if (!isAuthError(eventLoadError)) { + setError('Event konnte nicht geladen werden.'); + } + }, [isEdit, eventLoadError]); + + const loading = isEdit ? eventLoading : false; function handleNameChange(value: string) { setForm((prev) => ({ ...prev, name: value })); diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 601c558..48c2698 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -1,12 +1,15 @@ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, Copy, Loader2, QrCode, RefreshCw, Share2, Sparkles, X } from 'lucide-react'; +import { ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { AdminLayout } from '../components/AdminLayout'; import { @@ -19,7 +22,7 @@ import { updateEventQrInvite, EventQrInviteLayout, } from '../api'; -import { isAuthError } from '../auth/tokens'; +import { authorizedFetch, isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, @@ -35,6 +38,8 @@ interface PageState { error: string | null; } +type TabKey = 'layout' | 'export' | 'links'; + export default function EventInvitesPage(): JSX.Element { const { slug } = useParams<{ slug?: string }>(); const navigate = useNavigate(); @@ -47,6 +52,14 @@ export default function EventInvitesPage(): JSX.Element { const [copiedInviteId, setCopiedInviteId] = React.useState(null); const [customizerSaving, setCustomizerSaving] = React.useState(false); const [customizerResetting, setCustomizerResetting] = React.useState(false); + const [designerMode, setDesignerMode] = React.useState<'standard' | 'advanced'>('standard'); + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout'; + const [activeTab, setActiveTab] = React.useState(initialTab); + const [exportDownloadBusy, setExportDownloadBusy] = React.useState(null); + const [exportPrintBusy, setExportPrintBusy] = React.useState(null); + const [exportError, setExportError] = React.useState(null); const load = React.useCallback(async () => { if (!slug) { @@ -70,6 +83,27 @@ export default function EventInvitesPage(): JSX.Element { void load(); }, [load]); + React.useEffect(() => { + const param = searchParams.get('tab'); + const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout'; + setActiveTab((current) => (current === nextTab ? current : nextTab)); + }, [searchParams]); + + const handleTabChange = React.useCallback( + (value: string) => { + const nextTab = value === 'export' || value === 'links' ? (value as TabKey) : 'layout'; + setActiveTab(nextTab); + const nextParams = new URLSearchParams(searchParams); + if (nextTab === 'layout') { + nextParams.delete('tab'); + } else { + nextParams.set('tab', nextTab); + } + setSearchParams(nextParams, { replace: true }); + }, + [searchParams, setSearchParams] + ); + const event = state.event; const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event'); @@ -78,6 +112,12 @@ export default function EventInvitesPage(): JSX.Element { [state.invites, selectedInviteId] ); + React.useEffect(() => { + setExportError(null); + setExportDownloadBusy(null); + setExportPrintBusy(null); + }, [selectedInvite?.id]); + React.useEffect(() => { if (state.invites.length === 0) { setSelectedInviteId(null); @@ -101,10 +141,12 @@ export default function EventInvitesPage(): JSX.Element { }, [selectedInvite]); React.useEffect(() => { - if (selectedInvite) { - console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url); + if (currentCustomization?.mode === 'advanced') { + setDesignerMode('advanced'); + } else if (designerMode !== 'standard' && currentCustomization) { + setDesignerMode('standard'); } - }, [selectedInvite]); + }, [currentCustomization?.mode]); const inviteCountSummary = React.useMemo(() => { const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length; @@ -229,26 +271,155 @@ export default function EventInvitesPage(): JSX.Element { } } + const handleExportDownload = React.useCallback( + async (layout: EventQrInviteLayout, format: string, rawUrl?: string | null) => { + if (!selectedInvite) { + return; + } + + const normalizedFormat = format.toLowerCase(); + const sourceUrl = rawUrl ?? layout.download_urls?.[normalizedFormat]; + + if (!sourceUrl) { + setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.')); + return; + } + + const busyKey = `${layout.id}-${normalizedFormat}`; + setExportDownloadBusy(busyKey); + setExportError(null); + + try { + const response = await authorizedFetch(resolveInternalUrl(sourceUrl), { + headers: { + Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml', + }, + }); + + if (!response.ok) { + throw new Error(`Unexpected status ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + const filenameStem = `${selectedInvite.token || 'invite'}-${layout.id}`; + link.href = objectUrl; + link.download = `${filenameStem}.${normalizedFormat}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } catch (error) { + console.error('[Invites] Export download failed', error); + setExportError( + isAuthError(error) + ? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.') + : t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'), + ); + } finally { + setExportDownloadBusy(null); + } + }, + [selectedInvite, t] + ); + + const handleExportPrint = React.useCallback( + async (layout: EventQrInviteLayout) => { + if (!selectedInvite) { + return; + } + + const rawUrl = layout.download_urls?.pdf ?? layout.download_urls?.a4 ?? null; + if (!rawUrl) { + setExportError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.')); + return; + } + + setExportPrintBusy(layout.id); + setExportError(null); + + try { + const response = await authorizedFetch(resolveInternalUrl(rawUrl), { + headers: { Accept: 'application/pdf' }, + }); + + if (!response.ok) { + throw new Error(`Unexpected status ${response.status}`); + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer'); + + if (!printWindow) { + throw new Error('window-blocked'); + } + + printWindow.onload = () => { + try { + printWindow.focus(); + printWindow.print(); + } catch (printError) { + console.error('[Invites] Export print window failed', printError); + } + }; + + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + } catch (error) { + console.error('[Invites] Export print failed', error); + setExportError( + isAuthError(error) + ? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.') + : t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'), + ); + } finally { + setExportPrintBusy(null); + } + }, + [selectedInvite, t] + ); + const actions = ( - <> - {slug ? ( <> - - - ) : null} - + ); return ( @@ -257,80 +428,311 @@ export default function EventInvitesPage(): JSX.Element { subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')} actions={actions} > - {state.error ? ( - - {t('invites.errorTitle', 'Aktion fehlgeschlagen')} - {state.error} - - ) : null} + + + + {t('invites.tabs.layout', 'Layout anpassen')} + + + {t('invites.tabs.export', 'Drucken & Export')} + + + {t('invites.tabs.links', 'QR-Codes verwalten')} + + - - -
- - - {t('invites.cardTitle', 'QR-Einladungen & Layouts')} - - - {t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')} - -
- {t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} ·{' '} - {t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total} -
-
-
- - -
-
- - {state.loading ? ( - - ) : state.invites.length === 0 ? ( - - ) : ( -
- {state.invites.map((invite) => ( - setSelectedInviteId(invite.id)} - onCopy={() => handleCopy(invite)} - onRevoke={() => handleRevoke(invite)} - selected={invite.id === selectedInvite?.id} - revoking={revokingId === invite.id} - copied={copiedInviteId === invite.id} - /> - ))} -
- )} -
-
+ {state.error ? ( + + {t('invites.errorTitle', 'Aktion fehlgeschlagen')} + {state.error} + + ) : null} - + +
+
+
+

{t('invites.designer.heading', 'Einladungslayout anpassen')}

+

+ {t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')} +

+
+ value && setDesignerMode(value as 'standard' | 'advanced')} + className="self-start rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm" + > + + {t('invites.designer.mode.standard', 'Standard-Layoutraster')} + + + {t('invites.designer.mode.advanced', 'Freier Editor (Beta)')} + + +
+ + {state.loading ? ( + + ) : designerMode === 'standard' ? ( + + ) : ( + setDesignerMode('standard')} /> + )} +
+
+ + + + +
+ + + {t('invites.export.title', 'Drucken & Export')} + + + {t('invites.export.description', 'Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.')} + +
+
+ + +
+
+ + {exportError ? ( + + {t('invites.export.errorTitle', 'Download fehlgeschlagen')} + {exportError} + + ) : null} + + {selectedInvite ? ( + selectedInvite.layouts.length ? ( +
+ {selectedInvite.layouts.map((layout) => { + const printBusy = exportPrintBusy === layout.id; + return ( +
+
+
+

{layout.name || t('invites.customizer.layoutFallback', 'Layout')}

+ {layout.subtitle ? ( +

{layout.subtitle}

+ ) : null} +
+ {layout.formats?.length ? ( + + {layout.formats.map((format) => String(format).toUpperCase()).join(' · ')} + + ) : null} +
+ {layout.description ? ( +

{layout.description}

+ ) : null} +
+ + {layout.formats?.map((format) => { + const key = String(format ?? '').toLowerCase(); + const url = layout.download_urls?.[key]; + if (!url) return null; + const busyKey = `${layout.id}-${key}`; + const isBusy = exportDownloadBusy === busyKey; + return ( + + ); + })} +
+
+ ); + })} +
+ ) : ( +
+ {t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')} +
+ ) + ) : ( +
+ {t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')} +
+ )} +
+
+
+ + + + +
+ + + {t('invites.cardTitle', 'QR-Einladungen & Layouts')} + + + {t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')} + +
+ {t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} + + {t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total} +
+
+
+ + +
+
+ + {state.loading ? ( + + ) : state.invites.length === 0 ? ( + + ) : ( +
+ {state.invites.map((invite) => ( + setSelectedInviteId(invite.id)} + onCopy={() => handleCopy(invite)} + onRevoke={() => handleRevoke(invite)} + selected={invite.id === selectedInvite?.id} + revoking={revokingId === invite.id} + copied={copiedInviteId === invite.id} + /> + ))} +
+ )} +
+
+
+
); } +function resolveInternalUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl, window.location.origin); + if (parsed.origin === window.location.origin) { + return parsed.pathname + parsed.search; + } + } catch (error) { + console.warn('[Invites] Unable to resolve download url', error); + } + + return rawUrl; +} + +function InviteCustomizerSkeleton(): JSX.Element { + return ( +
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+
+
+ ); +} + +function AdvancedDesignerPlaceholder({ onBack }: { onBack: () => void }): JSX.Element { + return ( +
+
+

Freier Editor – bald verfügbar

+

+ Wir arbeiten gerade an einem drag-&-drop-Designer, mit dem du Elemente wie QR-Code, Texte und Logos frei platzieren + kannst. In der Zwischenzeit kannst du unsere optimierten Standardlayouts mit vergrößertem QR-Code nutzen. +

+

+ Wenn du Vorschläge für zusätzliche Layouts oder Funktionen hast, schreib uns gern über den Support – wir sammeln Feedback + für die nächste Ausbaustufe. +

+
+
+ +
+
+ ); +} + function InviteListCard({ invite, selected, @@ -375,23 +777,32 @@ function InviteListCard({ onSelect(); } }} - className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-amber-400 bg-amber-50/70 shadow-lg shadow-amber-200/30' : 'border-slate-200 bg-white/80 hover:border-amber-200'}`} + className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-lg shadow-primary/20' : 'border-border bg-[var(--tenant-surface)] hover:border-[var(--tenant-border-strong)]'}`} > -
- {invite.label?.trim() || `Einladung #${invite.id}`} - - {status} - - {isAutoGenerated ? ( - {t('invites.labels.standard', 'Standard')} - ) : null} - {customization ? ( - {t('tasks.customizer.badge', 'Angepasst')} +
+
+ {invite.label?.trim() || `Einladung #${invite.id}`} + + {status} + + {isAutoGenerated ? ( + {t('invites.labels.standard', 'Standard')} + ) : null} + {customization ? ( + {t('tasks.customizer.badge', 'Angepasst')} + ) : null} +
+ {invite.qr_code_data_url ? ( + {t('invites.labels.qrAlt', ) : null}
- + {invite.url}
-
+
{t('invites.labels.usage', 'Nutzung')}: {usageLabel} @@ -425,7 +836,7 @@ function InviteListCard({ {t('invites.labels.selected', 'Aktuell ausgewählt')} ) : ( -
{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}
+
{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}
)} @@ -224,4 +225,3 @@ function renderName(name: TenantEvent['name']): string { } return 'Unbenanntes Event'; } - diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index 0a29abb..d89152a 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -1,14 +1,21 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react'; +import { Rnd } from 'react-rnd'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { Textarea } from '@/components/ui/textarea'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; import type { EventQrInvite, EventQrInviteLayout } from '../../api'; -import { authorizedFetch } from '../../auth/tokens'; +import { authorizedFetch, isAuthError } from '../../auth/tokens'; export type QrLayoutCustomization = { layout_id?: string; @@ -29,8 +36,208 @@ export type QrLayoutCustomization = { background_gradient?: { angle?: number; stops?: string[] } | null; logo_data_url?: string | null; logo_url?: string | null; + mode?: 'standard' | 'advanced'; + elements?: AdvancedLayoutElementPayload[]; }; +type AdvancedLayoutElement = { + id: string; + type: 'qr' | 'headline' | 'subtitle' | 'description' | 'link' | 'badge' | 'logo'; + x: number; + y: number; + width: number; + height: number; + fontSize?: number; + align?: 'left' | 'center' | 'right'; + content?: string | null; +}; + +type AdvancedLayoutElementPayload = { + id: string; + type: AdvancedLayoutElement['type']; + x: number; + y: number; + width: number; + height: number; + font_size?: number; + align?: 'left' | 'center' | 'right'; + content?: string | null; +}; + +type AdvancedSerializationContext = { + form: QrLayoutCustomization; + eventName: string; + inviteUrl: string; + instructions: string[]; + qrSize: number; + badgeFallback: string; + logoUrl: string | null; +}; + +const ADVANCED_CANVAS_WIDTH = 1080; +const ADVANCED_CANVAS_HEIGHT = 1520; +const ADVANCED_MIN_QR = 240; +const ADVANCED_MAX_QR = 720; +const ADVANCED_MIN_TEXT_WIDTH = 160; +const ADVANCED_MIN_TEXT_HEIGHT = 80; + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) { + return min; + } + return Math.min(Math.max(value, min), max); +} + +function buildDefaultAdvancedElements( + layout: EventQrInviteLayout | null, + form: QrLayoutCustomization, + eventName: string, + qrSize: number +): AdvancedLayoutElement[] { + const resolvedQrSize = Math.min(Math.max(qrSize, ADVANCED_MIN_QR), ADVANCED_MAX_QR); + return [ + { + id: 'headline', + type: 'headline', + x: 80, + y: 120, + width: 520, + height: 160, + fontSize: 60, + content: form.headline ?? eventName, + }, + { + id: 'description', + type: 'description', + x: 80, + y: 320, + width: 520, + height: 240, + fontSize: 28, + content: form.description ?? layout?.description ?? '', + }, + { + id: 'qr', + type: 'qr', + x: 640, + y: 320, + width: resolvedQrSize, + height: resolvedQrSize, + }, + { + id: 'link', + type: 'link', + x: 640, + y: 320 + resolvedQrSize + 40, + width: 360, + height: 140, + fontSize: 26, + content: form.link_label ?? '', + }, + { + id: 'badge', + type: 'badge', + x: 80, + y: 60, + width: 260, + height: 70, + fontSize: 28, + content: form.badge_label ?? 'Digitale Gästebox', + }, + ]; +} + +function normalizeAdvancedElements(elements: AdvancedLayoutElement[]): AdvancedLayoutElement[] { + return (elements ?? []).map((element) => ({ + ...element, + x: Number(element.x ?? 0), + y: Number(element.y ?? 0), + width: Number(element.width ?? 0), + height: Number(element.height ?? 0), + fontSize: element.fontSize ? Number(element.fontSize) : undefined, + })); +} + +function convertPayloadToElements(payload: AdvancedLayoutElementPayload[] | undefined | null): AdvancedLayoutElement[] { + if (!Array.isArray(payload)) { + return []; + } + + return payload.map((item) => ({ + id: item.id, + type: item.type, + x: Number(item.x ?? 0), + y: Number(item.y ?? 0), + width: Number(item.width ?? 0), + height: Number(item.height ?? 0), + fontSize: item.font_size ? Number(item.font_size) : undefined, + align: item.align ?? 'left', + content: item.content ?? null, + })); +} + +function clampElementToCanvas(element: AdvancedLayoutElement): AdvancedLayoutElement { + const minWidth = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_WIDTH; + const minHeight = element.type === 'qr' ? ADVANCED_MIN_QR : ADVANCED_MIN_TEXT_HEIGHT; + const width = clamp(element.width, minWidth, ADVANCED_CANVAS_WIDTH); + const height = clamp(element.height, minHeight, ADVANCED_CANVAS_HEIGHT); + const maxX = Math.max(ADVANCED_CANVAS_WIDTH - width, 0); + const maxY = Math.max(ADVANCED_CANVAS_HEIGHT - height, 0); + + return { + ...element, + width, + height, + x: clamp(element.x, 0, maxX), + y: clamp(element.y, 0, maxY), + }; +} + +function serializeAdvancedElements( + elements: AdvancedLayoutElement[], + context: AdvancedSerializationContext +): AdvancedLayoutElementPayload[] { + return normalizeAdvancedElements(elements).map((element) => { + const base = clampElementToCanvas(element); + let content: string | null = base.content ?? null; + + switch (base.type) { + case 'headline': + content = context.form.headline ?? context.eventName; + break; + case 'subtitle': + content = context.form.subtitle ?? ''; + break; + case 'description': + content = context.form.description ?? ''; + break; + case 'link': + content = context.form.link_label ?? context.inviteUrl; + break; + case 'badge': + content = context.form.badge_label ?? context.badgeFallback; + break; + case 'logo': + content = context.logoUrl ?? context.form.logo_url ?? null; + break; + default: + break; + } + + return { + id: base.id, + type: base.type, + x: Math.round(base.x), + y: Math.round(base.y), + width: Math.round(base.width), + height: Math.round(base.height), + font_size: base.fontSize ? Math.round(base.fontSize) : undefined, + align: base.align, + content, + }; + }); +} + type InviteLayoutCustomizerPanelProps = { invite: EventQrInvite | null; eventName: string; @@ -39,6 +246,7 @@ type InviteLayoutCustomizerPanelProps = { onSave: (customization: QrLayoutCustomization) => Promise; onReset: () => Promise; initialCustomization: QrLayoutCustomization | null; + mode: 'standard' | 'advanced'; }; const MAX_INSTRUCTIONS = 5; @@ -51,10 +259,12 @@ export function InviteLayoutCustomizerPanel({ onSave, onReset, initialCustomization, -}: InviteLayoutCustomizerPanelProps): JSX.Element { + mode, +}: InviteLayoutCustomizerPanelProps): React.JSX.Element { const { t } = useTranslation('management'); const inviteUrl = invite?.url ?? ''; + const qrCodeDataUrl = invite?.qr_code_data_url ?? null; const defaultInstructions = React.useMemo(() => { const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown; return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen']; @@ -68,6 +278,76 @@ export function InviteLayoutCustomizerPanel({ const [instructions, setInstructions] = React.useState([]); const [error, setError] = React.useState(null); const formRef = React.useRef(null); + const [downloadBusy, setDownloadBusy] = React.useState(null); + const [printBusy, setPrintBusy] = React.useState(false); + const [activeLayoutIndex, setActiveLayoutIndex] = React.useState(() => { + if (!availableLayouts.length) { + return 0; + } + const initialIndex = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId); + return initialIndex >= 0 ? initialIndex : 0; + }); + const isAdvanced = mode === 'advanced'; + const [elements, setElements] = React.useState([]); + const [activeElementId, setActiveElementId] = React.useState(null); + const [canvasScale, setCanvasScale] = React.useState(0.52); + const [mobilePreviewOpen, setMobilePreviewOpen] = React.useState(false); + const [showFloatingActions, setShowFloatingActions] = React.useState(false); + const actionsSentinelRef = React.useRef(null); + + const activeLayout = React.useMemo(() => { + if (!availableLayouts.length) { + return null; + } + + if (selectedLayoutId) { + const match = availableLayouts.find((layout) => layout.id === selectedLayoutId); + if (match) { + return match; + } + } + + return availableLayouts[activeLayoutIndex] ?? availableLayouts[0]; + }, [availableLayouts, selectedLayoutId, activeLayoutIndex]); + + const activeLayoutQrSize = React.useMemo(() => { + if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements)) { + const qrElement = initialCustomization.elements.find((element) => element?.type === 'qr'); + if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) { + return qrElement.width; + } + } + + return activeLayout?.preview?.qr_size_px ?? 500; + }, [initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]); + + const updateElement = React.useCallback( + ( + id: string, + updater: Partial | ((element: AdvancedLayoutElement) => Partial) + ) => { + setElements((current) => + current.map((element) => { + if (element.id !== id) { + return element; + } + const patch = typeof updater === 'function' ? updater(element) : updater; + return clampElementToCanvas({ ...element, ...patch }); + }) + ); + }, + [] + ); + + const handleResetAdvanced = React.useCallback(() => { + if (!activeLayout) { + setElements([]); + return; + } + + setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize)); + setActiveElementId(null); + }, [activeLayout, form, eventName, activeLayoutQrSize]); React.useEffect(() => { if (!invite) { @@ -108,7 +388,6 @@ export function InviteLayoutCustomizerPanel({ } return url; })(); - console.debug('[Invites] Fetching layouts', target); const response = await authorizedFetch(target, { method: 'GET', headers: { Accept: 'application/json' }, @@ -121,7 +400,6 @@ export function InviteLayoutCustomizerPanel({ const json = await response.json(); const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : []; - console.debug('[Invites] Layout response items', items); if (!cancelled) { setAvailableLayouts(items); @@ -176,21 +454,6 @@ export function InviteLayoutCustomizerPanel({ }); }, [availableLayouts, initialCustomization?.layout_id]); - const activeLayout = React.useMemo(() => { - if (!availableLayouts.length) { - return null; - } - - if (selectedLayoutId) { - const match = availableLayouts.find((layout) => layout.id === selectedLayoutId); - if (match) { - return match; - } - } - - return availableLayouts[0]; - }, [availableLayouts, selectedLayoutId]); - React.useEffect(() => { if (!invite || !activeLayout) { setForm({}); @@ -225,8 +488,229 @@ export function InviteLayoutCustomizerPanel({ setError(null); }, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]); + React.useEffect(() => { + if (!isAdvanced) { + setActiveElementId(null); + return; + } + + if (!activeLayout) { + setElements([]); + return; + } + + if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) { + setElements(normalizeAdvancedElements(convertPayloadToElements(initialCustomization.elements))); + return; + } + + setElements(buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize)); + }, [isAdvanced, activeLayout?.id, invite?.id, activeLayoutQrSize]); + + React.useEffect(() => { + if (typeof IntersectionObserver === 'undefined') { + setShowFloatingActions(false); + return; + } + + const node = actionsSentinelRef.current; + if (!node) { + setShowFloatingActions(false); + return; + } + + const observer = new IntersectionObserver(([entry]) => { + setShowFloatingActions(!entry.isIntersecting); + }); + + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, [invite?.id, activeLayout?.id]); + const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0); + const advancedPreview = React.useMemo(() => ({ + headline: form.headline ?? eventName, + subtitle: form.subtitle ?? '', + description: form.description ?? activeLayout?.description ?? '', + link: form.link_label ?? inviteUrl, + badge: form.badge_label ?? t('tasks.customizer.defaults.badgeLabel'), + background: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF', + text: form.text_color ?? activeLayout?.preview?.text ?? '#111827', + accent: form.accent_color ?? activeLayout?.preview?.accent ?? '#6366F1', + logo: form.logo_data_url ?? form.logo_url ?? null, + instructions: effectiveInstructions, + }), [form.headline, form.subtitle, form.description, form.link_label, form.badge_label, form.background_color, form.text_color, form.accent_color, form.logo_data_url, form.logo_url, eventName, activeLayout?.description, activeLayout?.preview?.background, activeLayout?.preview?.text, activeLayout?.preview?.accent, effectiveInstructions, inviteUrl, t]); + + const renderActionButtons = (mode: 'inline' | 'floating') => ( + <> + + + + ); + + const previewStyles = React.useMemo(() => { + const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null; + if (gradient?.stops && gradient.stops.length > 0) { + const angle = gradient.angle ?? 180; + const stops = gradient.stops.join(', '); + return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` }; + } + return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' }; + }, [form.background_color, form.background_gradient, activeLayout]); + + const previewStack = ( +
+
+
+

{t('invites.customizer.preview.title', 'Live-Vorschau')}

+

{t('invites.customizer.preview.subtitle', 'So sieht dein Layout beim Export aus.')}

+
+
+ + {activeLayout?.formats?.map((format) => { + const key = String(format ?? '').toLowerCase(); + const url = activeLayout.download_urls?.[key]; + if (!url) return null; + return ( + + ); + })} +
+
+ +
+
+
+ + {form.badge_label || t('tasks.customizer.defaults.badgeLabel')} + +
+ +
+
+
+

+ {form.headline || eventName} +

+ {form.subtitle ? ( +

+ {form.subtitle} +

+ ) : null} +
+ {form.description ? ( +

+ {form.description} +

+ ) : null} +
+
{form.instructions_heading}
+
    + {effectiveInstructions.slice(0, 4).map((item, index) => ( +
  1. + {index + 1}. + {item} +
  2. + ))} +
+
+
+
+ {form.link_heading} + + {t('invites.customizer.preview.readyForGuests', 'Bereit für Gäste')} + +
+
+
+ {qrCodeDataUrl ? ( + {t('invites.customizer.preview.qrAlt', + ) : ( +
+ {t('invites.customizer.preview.qrPlaceholder', 'QR-Code folgt nach dem Speichern')} +
+ )} +
+
+
+ + {form.link_label || inviteUrl} + +
+

+ {t('invites.customizer.preview.instructions', 'Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.')} +

+ +
+
+
+
+ {form.logo_data_url ? ( +
+ Logo preview +
+ ) : null} +
+
+ ); + function updateForm(key: T, value: QrLayoutCustomization[T]) { setForm((prev) => ({ ...prev, [key]: value })); } @@ -241,6 +725,37 @@ export function InviteLayoutCustomizerPanel({ background_color: prev.background_color ?? layout.preview?.background ?? '#FFFFFF', background_gradient: prev.background_gradient ?? layout.preview?.background_gradient ?? null, })); + if (isAdvanced) { + setElements(buildDefaultAdvancedElements(layout, form, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize)); + setActiveElementId(null); + } + } + + React.useEffect(() => { + if (!availableLayouts.length) { + return; + } + const index = availableLayouts.findIndex((layout) => layout.id === selectedLayoutId); + if (index >= 0 && index !== activeLayoutIndex) { + setActiveLayoutIndex(index); + } + }, [availableLayouts, selectedLayoutId, activeLayoutIndex]); + + function rotateLayout(delta: number) { + if (!availableLayouts.length) { + return; + } + const nextIndex = (activeLayoutIndex + delta + availableLayouts.length) % availableLayouts.length; + setActiveLayoutIndex(nextIndex); + handleLayoutSelect(availableLayouts[nextIndex]!); + } + + function selectLayoutAt(index: number) { + if (index < 0 || index >= availableLayouts.length) { + return; + } + setActiveLayoutIndex(index); + handleLayoutSelect(availableLayouts[index]!); } function handleInstructionChange(index: number, value: string) { @@ -294,6 +809,23 @@ export function InviteLayoutCustomizerPanel({ instructions: effectiveInstructions, }; + if (isAdvanced) { + const serializationContext: AdvancedSerializationContext = { + form, + eventName, + inviteUrl, + instructions: effectiveInstructions, + qrSize: activeLayoutQrSize, + badgeFallback: t('tasks.customizer.defaults.badgeLabel'), + logoUrl: form.logo_url ?? null, + }; + payload.mode = 'advanced'; + payload.elements = serializeAdvancedElements(elements.length ? elements : buildDefaultAdvancedElements(activeLayout, form, eventName, activeLayoutQrSize), serializationContext); + } else { + payload.mode = 'standard'; + payload.elements = undefined; + } + await onSave(payload); } @@ -301,34 +833,106 @@ export function InviteLayoutCustomizerPanel({ await onReset(); } - function handleDownload(format: string, url: string) { - const link = document.createElement('a'); - link.href = url; - link.download = `${invite?.token ?? 'invite'}-${format.toLowerCase()}`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + function resolveInternalUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl, window.location.origin); + if (parsed.origin === window.location.origin) { + return parsed.pathname + parsed.search; + } + } catch (resolveError) { + console.warn('[Invites] Unable to resolve download url', resolveError); + } + return rawUrl; } - function handlePrint(preferredUrl?: string | null) { - const url = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null; - if (!url) { + async function handleDownload(format: string, rawUrl: string): Promise { + if (!rawUrl || !invite) { + return; + } + + const normalizedFormat = format.toLowerCase(); + const filenameStem = invite.token || 'invite'; + setDownloadBusy(normalizedFormat); + setError(null); + + try { + const response = await authorizedFetch(resolveInternalUrl(rawUrl), { + headers: { + Accept: normalizedFormat === 'pdf' ? 'application/pdf' : 'image/svg+xml', + }, + }); + + if (!response.ok) { + throw new Error(`Unexpected status ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = `${filenameStem}-${normalizedFormat}.${normalizedFormat}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } catch (downloadError) { + console.error('[Invites] Download failed', downloadError); + const message = isAuthError(downloadError) + ? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.') + : t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'); + setError(message); + } finally { + setDownloadBusy(null); + } + } + + async function handlePrint(preferredUrl?: string | null): Promise { + const rawUrl = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null; + if (!rawUrl) { setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.')); return; } - const printWindow = window.open(url, '_blank', 'noopener,noreferrer'); - printWindow?.focus(); - } - const previewStyles = React.useMemo(() => { - const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null; - if (gradient?.stops && gradient.stops.length > 0) { - const angle = gradient.angle ?? 180; - const stops = gradient.stops.join(', '); - return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` }; + setPrintBusy(true); + setError(null); + + try { + const response = await authorizedFetch(resolveInternalUrl(rawUrl), { + headers: { Accept: 'application/pdf' }, + }); + + if (!response.ok) { + throw new Error(`Unexpected status ${response.status}`); + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer'); + + if (!printWindow) { + throw new Error('window-blocked'); + } + + printWindow.onload = () => { + try { + printWindow.focus(); + printWindow.print(); + } catch (printError) { + console.error('[Invites] Browser print failed', printError); + } + }; + + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + } catch (printError) { + console.error('[Invites] Print failed', printError); + const message = isAuthError(printError) + ? t('invites.customizer.errors.auth', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.') + : t('invites.customizer.errors.printFailed', 'Druck konnte nicht gestartet werden.'); + setError(message); + } finally { + setPrintBusy(false); } - return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' }; - }, [form.background_color, form.background_gradient, activeLayout]); + } if (!invite) { return ( @@ -370,8 +974,8 @@ export function InviteLayoutCustomizerPanel({
-

{t('invites.customizer.heading', 'Layout anpassen')}

-

{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}

+

{t('invites.customizer.heading', 'Layout anpassen')}

+

{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}

+ + + + {t('invites.customizer.preview.mobileTitle', 'Einladungsvorschau')} + +
+ {previewStack} +
+
+ +
+ ) : null} +
-
-
-

{t('invites.customizer.sections.layouts', 'Layouts')}

-

{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}

+
+
+
+

{t('invites.customizer.sections.layouts', 'Layouts')}

+

{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}

+
+
+ {availableLayouts.length > 1 + ? t('invites.customizer.carousel.hint', { + defaultValue: 'Nutze die Pfeile oder Pfeiltasten, um weitere Layouts zu entdecken.', + }) + : null} +
-
- {availableLayouts.map((layout) => ( -
+ +
+ + + {t('invites.customizer.sections.text', 'Texte')} + {t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')} + {t('invites.customizer.sections.branding', 'Farbgebung')} + + + +
+
+ + updateForm('headline', event.target.value)} + />
- - ))} -
-
+
+ + updateForm('subtitle', event.target.value)} + /> +
+
+ +