QR-Codes-UI zu Einladungen umgebaut mit PDF-Export und Druckanzeige + Customizer
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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(),
|
||||
]),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use BackedEnum;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"lucide-react": "^0.475.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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>,
|
||||
@@ -699,6 +702,10 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
||||
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(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<TenantEvent>({
|
||||
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);
|
||||
const name = normalizeName(loadedEvent.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,
|
||||
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(event.slug);
|
||||
setReadOnlyPackageName(event.package?.name ?? null);
|
||||
setEventPackageMeta(event.package
|
||||
setOriginalSlug(loadedEvent.slug);
|
||||
setReadOnlyPackageName(loadedEvent.package?.name ?? null);
|
||||
setEventPackageMeta(loadedEvent.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,
|
||||
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);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
}, [isEdit, loadedEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEdit || !eventLoadError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthError(eventLoadError)) {
|
||||
setError('Event konnte nicht geladen werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [isEdit, eventLoadError]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isEdit, slugParam]);
|
||||
const loading = isEdit ? eventLoading : false;
|
||||
|
||||
function handleNameChange(value: string) {
|
||||
setForm((prev) => ({ ...prev, name: value }));
|
||||
|
||||
@@ -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<number | null>(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<TabKey>(initialTab);
|
||||
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
|
||||
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
|
||||
const [exportError, setExportError] = React.useState<string | null>(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 = (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
|
||||
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-3.5 w-3.5" />
|
||||
{t('invites.actions.backToList', 'Zurück')}
|
||||
</Button>
|
||||
{slug ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{t('invites.actions.backToEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -257,6 +428,19 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||
actions={actions}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
{t('invites.tabs.layout', 'Layout anpassen')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
{t('invites.tabs.export', 'Drucken & Export')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="links" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
{t('invites.tabs.links', 'QR-Codes verwalten')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{state.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
@@ -264,32 +448,206 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<TabsContent value="layout" className="space-y-6 focus-visible:outline-hidden">
|
||||
<section className="rounded-3xl border border-[var(--tenant-border-strong)] bg-gradient-to-br from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] p-6 shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
||||
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('invites.designer.heading', 'Einladungslayout anpassen')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={designerMode}
|
||||
onValueChange={(value) => 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"
|
||||
>
|
||||
<ToggleGroupItem value="standard" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{t('invites.designer.mode.standard', 'Standard-Layoutraster')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="advanced" className="rounded-full px-4 py-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{t('invites.designer.mode.advanced', 'Freier Editor (Beta)')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{state.loading ? (
|
||||
<InviteCustomizerSkeleton />
|
||||
) : designerMode === 'standard' ? (
|
||||
<InviteLayoutCustomizerPanel
|
||||
invite={selectedInvite ?? null}
|
||||
eventName={eventName}
|
||||
saving={customizerSaving}
|
||||
resetting={customizerResetting}
|
||||
onSave={handleSaveCustomization}
|
||||
onReset={handleResetCustomization}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedDesignerPlaceholder onBack={() => setDesignerMode('standard')} />
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-6 focus-visible:outline-hidden">
|
||||
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<QrCode className="h-5 w-5 text-amber-500" />
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
|
||||
<Printer className="h-5 w-5 text-primary" />
|
||||
{t('invites.export.title', 'Drucken & Export')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('invites.export.description', 'Lade druckfertige Dateien herunter oder starte direkt einen Testdruck.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
||||
<Select
|
||||
value={selectedInvite ? String(selectedInvite.id) : ''}
|
||||
onValueChange={(value) => setSelectedInviteId(Number(value))}
|
||||
disabled={state.invites.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-[200px] sm:w-60">
|
||||
<SelectValue placeholder={t('invites.export.selectPlaceholder', 'Einladung auswählen')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{state.invites.map((invite) => (
|
||||
<SelectItem key={invite.id} value={String(invite.id)}>
|
||||
{invite.label || invite.token}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void load()}
|
||||
disabled={state.loading}
|
||||
>
|
||||
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('invites.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{exportError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('invites.export.errorTitle', 'Download fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{exportError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{selectedInvite ? (
|
||||
selectedInvite.layouts.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{selectedInvite.layouts.map((layout) => {
|
||||
const printBusy = exportPrintBusy === layout.id;
|
||||
return (
|
||||
<div
|
||||
key={layout.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-5 shadow-sm transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</h3>
|
||||
{layout.subtitle ? (
|
||||
<p className="text-xs text-muted-foreground">{layout.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{layout.formats?.length ? (
|
||||
<Badge className="bg-amber-500/15 text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{layout.description ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{layout.description}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleExportPrint(layout)}
|
||||
disabled={printBusy || Boolean(exportDownloadBusy)}
|
||||
>
|
||||
{printBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Printer className="mr-1 h-4 w-4" />}
|
||||
{t('invites.export.actions.print', 'Direkt drucken')}
|
||||
</Button>
|
||||
{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 (
|
||||
<Button
|
||||
key={`${layout.id}-${key}`}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={(!!exportDownloadBusy && !isBusy) || printBusy}
|
||||
onClick={() => void handleExportDownload(layout, key, url)}
|
||||
>
|
||||
{isBusy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Download className="mr-1 h-4 w-4" />}
|
||||
{key.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
||||
{t('invites.export.noLayouts', 'Für diese Einladung sind aktuell keine Layouts verfügbar.')}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-8 text-sm text-[var(--tenant-foreground-soft)]">
|
||||
{t('invites.export.noInviteSelected', 'Wähle zunächst eine Einladung aus, um Downloads zu starten.')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="links" className="space-y-6 focus-visible:outline-hidden">
|
||||
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
|
||||
<QrCode className="h-5 w-5 text-primary" />
|
||||
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
||||
</CardDescription>
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50/70 px-3 py-2 text-xs text-amber-700">
|
||||
{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} ·{' '}
|
||||
{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-3 py-1 text-xs text-[var(--tenant-foreground-soft)]">
|
||||
<span>{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active}</span>
|
||||
<span className="text-primary">•</span>
|
||||
<span>{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={() => void load()} disabled={state.loading} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void load()}
|
||||
disabled={state.loading}
|
||||
>
|
||||
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('invites.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creatingInvite}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-amber-500/20"
|
||||
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
||||
>
|
||||
{creatingInvite ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
|
||||
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -317,20 +675,64 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InviteLayoutCustomizerPanel
|
||||
invite={selectedInvite ?? null}
|
||||
eventName={eventName}
|
||||
saving={customizerSaving}
|
||||
resetting={customizerResetting}
|
||||
onSave={handleSaveCustomization}
|
||||
onReset={handleResetCustomization}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-56 animate-pulse rounded-full bg-white/70" />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={`customizer-skeleton-${index}`} className="h-40 animate-pulse rounded-2xl bg-white/70" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[420px] animate-pulse rounded-3xl bg-white/70" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedDesignerPlaceholder({ onBack }: { onBack: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)]/80 p-10 text-sm text-muted-foreground transition-colors">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">Freier Editor – bald verfügbar</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={onBack} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Zurück zum Standard-Layout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)]'}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||
<Badge variant="outline" className={statusBadgeClass(status)}>
|
||||
{status}
|
||||
</Badge>
|
||||
{isAutoGenerated ? (
|
||||
<Badge variant="secondary" className="bg-slate-200 text-slate-700">{t('invites.labels.standard', 'Standard')}</Badge>
|
||||
<Badge variant="secondary" className="bg-muted text-muted-foreground">{t('invites.labels.standard', 'Standard')}</Badge>
|
||||
) : null}
|
||||
{customization ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{invite.qr_code_data_url ? (
|
||||
<img
|
||||
src={invite.qr_code_data_url}
|
||||
alt={t('invites.labels.qrAlt', 'QR-Code Vorschau')}
|
||||
className="h-16 w-16 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-2 shadow-sm"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
<span className="break-all rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
{invite.url}
|
||||
</span>
|
||||
<Button
|
||||
@@ -408,7 +819,7 @@ function InviteListCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span>
|
||||
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
|
||||
</span>
|
||||
@@ -425,7 +836,7 @@ function InviteListCard({
|
||||
{t('invites.labels.selected', 'Aktuell ausgewählt')}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -435,7 +846,7 @@ function InviteListCard({
|
||||
onRevoke();
|
||||
}}
|
||||
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
||||
className="text-slate-500 hover:text-rose-500 disabled:opacity-50"
|
||||
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
|
||||
>
|
||||
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
|
||||
{t('invites.actions.deactivate', 'Deaktivieren')}
|
||||
@@ -458,9 +869,9 @@ function InviteSkeleton() {
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
|
||||
<h3 className="text-base font-semibold text-slate-800">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
||||
<p className="text-sm text-slate-500">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-10 text-center transition-colors">
|
||||
<h3 className="text-base font-semibold text-foreground">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
|
||||
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
|
||||
<Share2 className="mr-1 h-4 w-4" />
|
||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||
@@ -492,12 +903,12 @@ function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abge
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (status === 'Aktiv') {
|
||||
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
||||
return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-200 dark:border-emerald-500/40';
|
||||
}
|
||||
if (status === 'Abgelaufen') {
|
||||
return 'bg-orange-100 text-orange-700 border-orange-200';
|
||||
return 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-200 dark:border-orange-500/40';
|
||||
}
|
||||
return 'bg-slate-200 text-slate-700 border-slate-300';
|
||||
return 'bg-slate-200 text-slate-700 border-slate-300 dark:bg-slate-600/40 dark:text-slate-200 dark:border-slate-500/40';
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
} from '../constants';
|
||||
|
||||
@@ -157,7 +158,7 @@ function EventCard({ event }: { event: TenantEvent }) {
|
||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#qr-invites`}>
|
||||
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
|
||||
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -224,4 +225,3 @@ function renderName(name: TenantEvent['name']): string {
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $eventName }} – Einladungs-QR</title>
|
||||
@php
|
||||
$qrSize = $layout['qr']['size_px'] ?? 500;
|
||||
@endphp
|
||||
<style>
|
||||
:root {
|
||||
--accent: {{ $layout['accent'] }};
|
||||
@@ -10,7 +13,7 @@
|
||||
--text: {{ $layout['text'] }};
|
||||
--badge: {{ $layout['badge'] }};
|
||||
--container-padding: 48px;
|
||||
--qr-size: 340px;
|
||||
--qr-size: {{ $qrSize }}px;
|
||||
--background: {{ $backgroundStyle }};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
$ctaLabel = $layout['cta_label'] ?? 'Scan mich & starte direkt';
|
||||
$linkLabel = $layout['link_label'] ?? $tokenUrl;
|
||||
$logoUrl = $layout['logo_url'] ?? null;
|
||||
$qrSize = $layout['qr']['size_px'] ?? 500;
|
||||
$qrX = max($width - $qrSize - 120, 520);
|
||||
$qrY = 420;
|
||||
$qrPanelX = $qrX - 40;
|
||||
$qrPanelY = $qrY - 40;
|
||||
$qrPanelWidth = $qrSize + 80;
|
||||
$qrPanelHeight = $qrSize + 200;
|
||||
$linkBoxY = $qrY + $qrSize + 30;
|
||||
$ctaTextY = $qrPanelY + $qrPanelHeight - 50;
|
||||
$titleLines = explode("\n", wordwrap($headline, 18, "\n", true));
|
||||
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
|
||||
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : [];
|
||||
@@ -114,8 +123,8 @@
|
||||
|
||||
<rect x="70" y="380" width="500" height="600" rx="46" fill="rgba(255,255,255,0.78)" />
|
||||
|
||||
<rect x="600" y="420" width="380" height="380" rx="36" fill="rgba(255,255,255,0.88)" />
|
||||
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
|
||||
<rect x="{{ $qrPanelX }}" y="{{ $qrPanelY }}" width="{{ $qrPanelWidth }}" height="{{ $qrPanelHeight }}" rx="40" fill="rgba(255,255,255,0.88)" />
|
||||
<rect x="{{ $qrPanelX + 20 }}" y="{{ $linkBoxY + 80 }}" width="{{ $qrPanelWidth - 40 }}" height="8" rx="4" fill="{{ $accent }}" opacity="0.45" />
|
||||
|
||||
<rect x="80" y="120" width="250" height="70" rx="35" fill="{{ $badgeColor }}" />
|
||||
<text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">{{ e($badgeLabel) }}</text>
|
||||
@@ -152,13 +161,13 @@
|
||||
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
|
||||
@endforeach
|
||||
|
||||
<text x="640" y="760" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($linkHeading)) }}</text>
|
||||
<rect x="630" y="790" width="320" height="120" rx="22" fill="rgba(0,0,0,0.08)" />
|
||||
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($linkLabel) }}</text>
|
||||
<text x="{{ $qrPanelX + 40 }}" y="{{ $linkBoxY }}" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($linkHeading)) }}</text>
|
||||
<rect x="{{ $qrPanelX + 30 }}" y="{{ $linkBoxY + 30 }}" width="{{ $qrPanelWidth - 60 }}" height="140" rx="26" fill="rgba(0,0,0,0.08)" />
|
||||
<text x="{{ $qrPanelX + 50 }}" y="{{ $linkBoxY + 118 }}" fill="{{ $textColor }}" class="link-text">{{ e($linkLabel) }}</text>
|
||||
|
||||
<image href="{{ $qrPngDataUri }}" x="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
|
||||
<image href="{{ $qrPngDataUri }}" x="{{ $qrX }}" y="{{ $qrY }}" width="{{ $qrSize }}" height="{{ $qrSize }}" />
|
||||
|
||||
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($ctaLabel)) }}</text>
|
||||
<text x="{{ $qrPanelX + ($qrPanelWidth / 2) }}" y="{{ $ctaTextY }}" text-anchor="middle" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($ctaLabel)) }}</text>
|
||||
|
||||
<text x="120" y="{{ $height - 160 }}" fill="rgba(17,24,39,0.6)" class="footer-text">
|
||||
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>
|
||||
|
||||
35
tests/Feature/Tenant/EventInviteQrCodeTest.php
Normal file
35
tests/Feature/Tenant/EventInviteQrCodeTest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class EventInviteQrCodeTest extends TenantTestCase
|
||||
{
|
||||
public function test_join_token_response_includes_qr_code_data_url(): void
|
||||
{
|
||||
$event = Event::factory()
|
||||
->for($this->tenant)
|
||||
->create([
|
||||
'name' => ['de' => 'QR Einladungstest', 'en' => 'QR Invite Test'],
|
||||
'slug' => 'qr-invite-test',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/join-tokens");
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
$this->assertIsArray($data);
|
||||
$this->assertNotEmpty($data, 'Expected at least one join token to be returned');
|
||||
|
||||
$firstInvite = $data[0];
|
||||
|
||||
$this->assertArrayHasKey('qr_code_data_url', $firstInvite);
|
||||
$this->assertNotNull($firstInvite['qr_code_data_url']);
|
||||
$this->assertIsString($firstInvite['qr_code_data_url']);
|
||||
$this->assertStringStartsWith('data:image/svg+xml;base64,', $firstInvite['qr_code_data_url']);
|
||||
$this->assertSame(url('/e/'.$firstInvite['token']), $firstInvite['url']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user