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\Pages;
|
||||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Models\EventJoinTokenEvent;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\EventJoinTokenEvent;
|
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use Carbon\Carbon;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@@ -116,7 +115,7 @@ class EventResource extends Resource
|
|||||||
->getStateUsing(function ($record) {
|
->getStateUsing(function ($record) {
|
||||||
$token = $record->joinTokens()->latest()->first();
|
$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) {
|
->description(function ($record) {
|
||||||
$total = $record->joinTokens()->count();
|
$total = $record->joinTokens()->count();
|
||||||
@@ -185,7 +184,7 @@ class EventResource extends Resource
|
|||||||
|
|
||||||
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
$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,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
'layout' => $layoutId,
|
'layout' => $layoutId,
|
||||||
@@ -212,7 +211,7 @@ class EventResource extends Resource
|
|||||||
'id' => $token->id,
|
'id' => $token->id,
|
||||||
'label' => $token->label,
|
'label' => $token->label,
|
||||||
'token' => $token->token,
|
'token' => $token->token,
|
||||||
'url' => url('/e/' . $token->token),
|
'url' => url('/e/'.$token->token),
|
||||||
'usage_limit' => $token->usage_limit,
|
'usage_limit' => $token->usage_limit,
|
||||||
'usage_count' => $token->usage_count,
|
'usage_count' => $token->usage_count,
|
||||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||||
@@ -220,7 +219,7 @@ class EventResource extends Resource
|
|||||||
'is_active' => $token->isActive(),
|
'is_active' => $token->isActive(),
|
||||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||||
'layouts' => $layouts,
|
'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,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Pages;
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use App\Support\TenantOnboardingState;
|
use App\Support\TenantOnboardingState;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use BackedEnum;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
@@ -91,7 +92,7 @@ class InviteStudio extends Page
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = $this->tokenLabel ?: 'Einladung ' . now()->format('d.m.');
|
$label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.');
|
||||||
|
|
||||||
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
|
$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
|
protected function mapToken(Event $event, EventJoinToken $token): array
|
||||||
{
|
{
|
||||||
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
$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,
|
'event' => $event->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
'layout' => $layoutId,
|
'layout' => $layoutId,
|
||||||
@@ -152,7 +153,7 @@ class InviteStudio extends Page
|
|||||||
return [
|
return [
|
||||||
'id' => $token->getKey(),
|
'id' => $token->getKey(),
|
||||||
'label' => $token->label ?? 'Einladungslink',
|
'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'),
|
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
|
||||||
'usage_count' => $token->usage_count,
|
'usage_count' => $token->usage_count,
|
||||||
'usage_limit' => $token->usage_limit,
|
'usage_limit' => $token->usage_limit,
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ use App\Services\EventJoinTokenService;
|
|||||||
use App\Services\Tenant\TaskCollectionImportService;
|
use App\Services\Tenant\TaskCollectionImportService;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use App\Support\TenantOnboardingState;
|
use App\Support\TenantOnboardingState;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use BackedEnum;
|
|
||||||
use UnitEnum;
|
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantOnboarding extends Page
|
class TenantOnboarding extends Page
|
||||||
{
|
{
|
||||||
@@ -234,7 +234,7 @@ class TenantOnboarding extends Page
|
|||||||
protected function buildInviteDownloads(Event $event, $token): array
|
protected function buildInviteDownloads(Event $event, $token): array
|
||||||
{
|
{
|
||||||
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
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,
|
'event' => $event->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
'layout' => $layoutId,
|
'layout' => $layoutId,
|
||||||
@@ -260,7 +260,7 @@ class TenantOnboarding extends Page
|
|||||||
public function getEventTypeOptionsProperty(): array
|
public function getEventTypeOptionsProperty(): array
|
||||||
{
|
{
|
||||||
return EventType::query()
|
return EventType::query()
|
||||||
->orderBy('name->' . app()->getLocale())
|
->orderBy('name->'.app()->getLocale())
|
||||||
->get()
|
->get()
|
||||||
->mapWithKeys(function (EventType $type) {
|
->mapWithKeys(function (EventType $type) {
|
||||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
|
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
|
||||||
@@ -306,6 +306,6 @@ class TenantOnboarding extends Page
|
|||||||
|
|
||||||
protected function getDefaultEventTypeId(): ?int
|
protected function getDefaultEventTypeId(): ?int
|
||||||
{
|
{
|
||||||
return EventType::query()->orderBy('name->' . app()->getLocale())->value('id');
|
return EventType::query()->orderBy('name->'.app()->getLocale())->value('id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,42 @@
|
|||||||
namespace App\Filament\Tenant\Resources;
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource\Pages;
|
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\JoinTokenLayoutRegistry;
|
||||||
use App\Support\TenantOnboardingState;
|
use App\Support\TenantOnboardingState;
|
||||||
use App\Models\Event;
|
use BackedEnum;
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Models\EventJoinTokenEvent;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Actions;
|
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\DatePicker;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Hidden;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Forms\Components\Select;
|
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\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
use BackedEnum;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
|
||||||
|
|
||||||
class EventResource extends Resource
|
class EventResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Event::class;
|
protected static ?string $model = Event::class;
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
|
||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
protected static UnitEnum|string|null $navigationGroup = null;
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
{
|
{
|
||||||
return __('admin.nav.platform');
|
return __('admin.nav.platform');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
@@ -141,7 +141,7 @@ class EventResource extends Resource
|
|||||||
Actions\Action::make('toggle')
|
Actions\Action::make('toggle')
|
||||||
->label(__('admin.events.actions.toggle_active'))
|
->label(__('admin.events.actions.toggle_active'))
|
||||||
->icon('heroicon-o-power')
|
->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')
|
Actions\Action::make('join_tokens')
|
||||||
->label(__('admin.events.actions.join_link_qr'))
|
->label(__('admin.events.actions.join_link_qr'))
|
||||||
->icon('heroicon-o-qr-code')
|
->icon('heroicon-o-qr-code')
|
||||||
@@ -185,7 +185,7 @@ class EventResource extends Resource
|
|||||||
|
|
||||||
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
$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,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
'layout' => $layoutId,
|
'layout' => $layoutId,
|
||||||
@@ -220,7 +220,7 @@ class EventResource extends Resource
|
|||||||
'is_active' => $token->isActive(),
|
'is_active' => $token->isActive(),
|
||||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||||
'layouts' => $layouts,
|
'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,
|
'event' => $record->slug,
|
||||||
'joinToken' => $token->getKey(),
|
'joinToken' => $token->getKey(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
|
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($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,
|
'event' => $event,
|
||||||
'joinToken' => $joinToken,
|
'joinToken' => $joinToken,
|
||||||
'layout' => $layoutId,
|
'layout' => $layoutId,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Support\JoinTokenLayoutRegistry;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
class EventJoinTokenResource extends JsonResource
|
class EventJoinTokenResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -40,13 +41,34 @@ class EventJoinTokenResource extends JsonResource
|
|||||||
}
|
}
|
||||||
|
|
||||||
$plainToken = $this->resource->plain_token ?? $this->token;
|
$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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'token' => $plainToken,
|
'token' => $plainToken,
|
||||||
'token_preview' => $this->token_preview,
|
'token_preview' => $this->token_preview,
|
||||||
'url' => $plainToken ? url('/e/'.$plainToken) : null,
|
'url' => $qrCodeUrl,
|
||||||
|
'qr_code_data_url' => $qrCodeDataUrl,
|
||||||
'usage_limit' => $this->usage_limit,
|
'usage_limit' => $this->usage_limit,
|
||||||
'usage_count' => $this->usage_count,
|
'usage_count' => $this->usage_count,
|
||||||
'expires_at' => optional($this->expires_at)->toIso8601String(),
|
'expires_at' => optional($this->expires_at)->toIso8601String(),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#6366F1',
|
'accent' => '#6366F1',
|
||||||
'secondary' => '#CBD5F5',
|
'secondary' => '#CBD5F5',
|
||||||
'badge' => '#0EA5E9',
|
'badge' => '#0EA5E9',
|
||||||
'qr' => ['size_px' => 340],
|
'qr' => ['size_px' => 500],
|
||||||
'svg' => ['width' => 1080, 'height' => 1520],
|
'svg' => ['width' => 1080, 'height' => 1520],
|
||||||
'instructions' => [
|
'instructions' => [
|
||||||
'Scanne den Code und tritt dem Event direkt bei.',
|
'Scanne den Code und tritt dem Event direkt bei.',
|
||||||
@@ -44,7 +44,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#C08457',
|
'accent' => '#C08457',
|
||||||
'secondary' => '#E6D5C3',
|
'secondary' => '#E6D5C3',
|
||||||
'badge' => '#8B5CF6',
|
'badge' => '#8B5CF6',
|
||||||
'qr' => ['size_px' => 300],
|
'qr' => ['size_px' => 460],
|
||||||
'svg' => ['width' => 1080, 'height' => 1520],
|
'svg' => ['width' => 1080, 'height' => 1520],
|
||||||
'instructions' => [
|
'instructions' => [
|
||||||
'QR-Code scannen oder Link im Browser eingeben.',
|
'QR-Code scannen oder Link im Browser eingeben.',
|
||||||
@@ -68,7 +68,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#FFFFFF',
|
'accent' => '#FFFFFF',
|
||||||
'secondary' => 'rgba(255,255,255,0.72)',
|
'secondary' => 'rgba(255,255,255,0.72)',
|
||||||
'badge' => '#1E293B',
|
'badge' => '#1E293B',
|
||||||
'qr' => ['size_px' => 360],
|
'qr' => ['size_px' => 540],
|
||||||
'svg' => ['width' => 1080, 'height' => 1520],
|
'svg' => ['width' => 1080, 'height' => 1520],
|
||||||
'instructions' => [
|
'instructions' => [
|
||||||
'Sofort scannen – der QR-Code führt direkt zum Event.',
|
'Sofort scannen – der QR-Code führt direkt zum Event.',
|
||||||
@@ -88,7 +88,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#0EA5E9',
|
'accent' => '#0EA5E9',
|
||||||
'secondary' => '#94A3B8',
|
'secondary' => '#94A3B8',
|
||||||
'badge' => '#334155',
|
'badge' => '#334155',
|
||||||
'qr' => ['size_px' => 320],
|
'qr' => ['size_px' => 500],
|
||||||
'svg' => ['width' => 1080, 'height' => 1520],
|
'svg' => ['width' => 1080, 'height' => 1520],
|
||||||
'instructions' => [
|
'instructions' => [
|
||||||
'Schritt 1: QR-Code scannen oder Kurzlink nutzen.',
|
'Schritt 1: QR-Code scannen oder Kurzlink nutzen.',
|
||||||
@@ -108,7 +108,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#9333EA',
|
'accent' => '#9333EA',
|
||||||
'secondary' => '#E0E7FF',
|
'secondary' => '#E0E7FF',
|
||||||
'badge' => '#64748B',
|
'badge' => '#64748B',
|
||||||
'qr' => ['size_px' => 280],
|
'qr' => ['size_px' => 440],
|
||||||
'svg' => ['width' => 1080, 'height' => 1520],
|
'svg' => ['width' => 1080, 'height' => 1520],
|
||||||
'instructions' => [
|
'instructions' => [
|
||||||
'Code scannen, Profil erstellen, Erinnerungen festhalten.',
|
'Code scannen, Profil erstellen, Erinnerungen festhalten.',
|
||||||
@@ -255,6 +255,7 @@ class JoinTokenLayoutRegistry
|
|||||||
'background_gradient' => $layout['background_gradient'],
|
'background_gradient' => $layout['background_gradient'],
|
||||||
'accent' => $layout['accent'],
|
'accent' => $layout['accent'],
|
||||||
'text' => $layout['text'],
|
'text' => $layout['text'],
|
||||||
|
'qr_size_px' => $layout['qr']['size_px'] ?? null,
|
||||||
],
|
],
|
||||||
'formats' => $formats,
|
'formats' => $formats,
|
||||||
'download_urls' => collect($formats)
|
'download_urls' => collect($formats)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class InviteLayoutSeeder extends Seeder
|
|||||||
'secondary' => $layout['secondary'] ?? null,
|
'secondary' => $layout['secondary'] ?? null,
|
||||||
'text' => $layout['text'] ?? null,
|
'text' => $layout['text'] ?? null,
|
||||||
'badge' => $layout['badge'] ?? null,
|
'badge' => $layout['badge'] ?? null,
|
||||||
'qr' => $layout['qr'] ?? ['size_px' => 320],
|
'qr' => $layout['qr'] ?? ['size_px' => 500],
|
||||||
'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520],
|
'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,9 @@
|
|||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-rnd": "^10.4.12",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
"react-router-dom": "^7.8.2",
|
"react-router-dom": "^7.8.2",
|
||||||
|
|||||||
@@ -385,6 +385,14 @@ html.dark {
|
|||||||
--sidebar-accent-foreground: var(--brand-slate);
|
--sidebar-accent-foreground: var(--brand-slate);
|
||||||
--sidebar-border: #f7d9e6;
|
--sidebar-border: #f7d9e6;
|
||||||
--sidebar-ring: var(--brand-rose);
|
--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 {
|
.tenant-admin-welcome-theme {
|
||||||
@@ -393,6 +401,49 @@ html.dark {
|
|||||||
color: var(--brand-slate);
|
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 {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type EventQrInviteLayout = {
|
|||||||
background_gradient: { angle: number; stops: string[] } | null;
|
background_gradient: { angle: number; stops: string[] } | null;
|
||||||
accent: string | null;
|
accent: string | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
|
qr_size_px?: number | null;
|
||||||
};
|
};
|
||||||
formats: string[];
|
formats: string[];
|
||||||
download_urls: Record<string, string>;
|
download_urls: Record<string, string>;
|
||||||
@@ -257,6 +258,7 @@ export type EventQrInvite = {
|
|||||||
token: string;
|
token: string;
|
||||||
url: string;
|
url: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
qr_code_data_url: string | null;
|
||||||
usage_limit: number | null;
|
usage_limit: number | null;
|
||||||
usage_count: number;
|
usage_count: number;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
@@ -678,6 +680,7 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
|||||||
background_gradient: layout.preview?.background_gradient ?? null,
|
background_gradient: layout.preview?.background_gradient ?? null,
|
||||||
accent: layout.preview?.accent ?? null,
|
accent: layout.preview?.accent ?? null,
|
||||||
text: layout.preview?.text ?? null,
|
text: layout.preview?.text ?? null,
|
||||||
|
qr_size_px: layout.preview?.qr_size_px ?? layout.qr?.size_px ?? null,
|
||||||
},
|
},
|
||||||
formats,
|
formats,
|
||||||
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
|
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>,
|
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
|
||||||
layouts,
|
layouts,
|
||||||
layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null,
|
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",
|
"cardTitle": "QR-Einladungen & Layouts",
|
||||||
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
|
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
|
||||||
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
|
"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": {
|
"summary": {
|
||||||
"active": "Aktive Einladungen",
|
"active": "Aktive Einladungen",
|
||||||
"total": "Gesamt"
|
"total": "Gesamt"
|
||||||
@@ -337,13 +342,26 @@
|
|||||||
"layoutFallback": "Standard",
|
"layoutFallback": "Standard",
|
||||||
"selected": "Aktuell ausgewählt",
|
"selected": "Aktuell ausgewählt",
|
||||||
"tapToEdit": "Zum Anpassen auswählen",
|
"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": {
|
"empty": {
|
||||||
"title": "Noch keine Einladungen",
|
"title": "Noch keine Einladungen",
|
||||||
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
|
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
|
||||||
},
|
},
|
||||||
"errorTitle": "Aktion fehlgeschlagen",
|
"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": {
|
"customizer": {
|
||||||
"heading": "Layout anpassen",
|
"heading": "Layout anpassen",
|
||||||
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
|
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
|
||||||
@@ -361,7 +379,7 @@
|
|||||||
"text": "Texte",
|
"text": "Texte",
|
||||||
"instructions": "Schritt-für-Schritt",
|
"instructions": "Schritt-für-Schritt",
|
||||||
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
|
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
|
||||||
"branding": "Branding"
|
"branding": "Farbgebung"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"headline": "Überschrift",
|
"headline": "Überschrift",
|
||||||
@@ -381,7 +399,13 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"title": "Live-Vorschau",
|
"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",
|
"placeholderTitle": "Kein Layout verfügbar",
|
||||||
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
|
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
|
||||||
|
|||||||
@@ -318,6 +318,11 @@
|
|||||||
"cardTitle": "QR invites & layouts",
|
"cardTitle": "QR invites & layouts",
|
||||||
"cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.",
|
"cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.",
|
||||||
"subtitle": "Manage invite links, layouts, and branding for your guests.",
|
"subtitle": "Manage invite links, layouts, and branding for your guests.",
|
||||||
|
"tabs": {
|
||||||
|
"layout": "Customise layout",
|
||||||
|
"export": "Print & export",
|
||||||
|
"links": "Manage invites"
|
||||||
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"active": "Active invites",
|
"active": "Active invites",
|
||||||
"total": "Total"
|
"total": "Total"
|
||||||
@@ -337,13 +342,26 @@
|
|||||||
"layoutFallback": "Default",
|
"layoutFallback": "Default",
|
||||||
"selected": "Currently selected",
|
"selected": "Currently selected",
|
||||||
"tapToEdit": "Select to edit",
|
"tapToEdit": "Select to edit",
|
||||||
"noPrintSource": "No printable version available."
|
"noPrintSource": "No printable version available.",
|
||||||
|
"standard": "Default link",
|
||||||
|
"qrAlt": "QR preview"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No invites yet",
|
"title": "No invites yet",
|
||||||
"copy": "Create an invite to generate ready-to-print QR layouts."
|
"copy": "Create an invite to generate ready-to-print QR layouts."
|
||||||
},
|
},
|
||||||
"errorTitle": "Action failed",
|
"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": {
|
"customizer": {
|
||||||
"heading": "Customise layout",
|
"heading": "Customise layout",
|
||||||
"copy": "Make the invite your own – adjust copy, colours, and logos in real time.",
|
"copy": "Make the invite your own – adjust copy, colours, and logos in real time.",
|
||||||
@@ -361,7 +379,7 @@
|
|||||||
"text": "Text",
|
"text": "Text",
|
||||||
"instructions": "Step-by-step",
|
"instructions": "Step-by-step",
|
||||||
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
|
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
|
||||||
"branding": "Branding"
|
"branding": "Colors"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"headline": "Headline",
|
"headline": "Headline",
|
||||||
@@ -381,7 +399,13 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"title": "Live 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",
|
"placeholderTitle": "No layout available",
|
||||||
"placeholderCopy": "Create an invite first to customise copy, colours, and print layouts.",
|
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
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 { isAuthError } from '../auth/tokens';
|
||||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
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 [autoSlug, setAutoSlug] = React.useState(true);
|
||||||
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
|
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
|
||||||
const [loading, setLoading] = React.useState(isEdit);
|
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [readOnlyPackageName, setReadOnlyPackageName] = 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);
|
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
|
||||||
}, [isEdit, activePackage]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
return;
|
return;
|
||||||
@@ -128,54 +146,45 @@ export default function EventFormPage() {
|
|||||||
}, [eventTypes, isEdit]);
|
}, [eventTypes, isEdit]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
if (!isEdit || !loadedEvent) {
|
||||||
if (!isEdit || !slugParam) {
|
return;
|
||||||
setLoading(false);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
const name = normalizeName(loadedEvent.name);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
setForm((prev) => ({
|
||||||
cancelled = true;
|
...prev,
|
||||||
};
|
name,
|
||||||
}, [isEdit, slugParam]);
|
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) {
|
function handleNameChange(value: string) {
|
||||||
setForm((prev) => ({ ...prev, name: value }));
|
setForm((prev) => ({ ...prev, name: value }));
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
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 { 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { AdminLayout } from '../components/AdminLayout';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +22,7 @@ import {
|
|||||||
updateEventQrInvite,
|
updateEventQrInvite,
|
||||||
EventQrInviteLayout,
|
EventQrInviteLayout,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { authorizedFetch, isAuthError } from '../auth/tokens';
|
||||||
import {
|
import {
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
ADMIN_EVENT_VIEW_PATH,
|
||||||
@@ -35,6 +38,8 @@ interface PageState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabKey = 'layout' | 'export' | 'links';
|
||||||
|
|
||||||
export default function EventInvitesPage(): JSX.Element {
|
export default function EventInvitesPage(): JSX.Element {
|
||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -47,6 +52,14 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
|
||||||
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||||
const [customizerResetting, setCustomizerResetting] = 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 () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -70,6 +83,27 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
void load();
|
void load();
|
||||||
}, [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 event = state.event;
|
||||||
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
||||||
|
|
||||||
@@ -78,6 +112,12 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
[state.invites, selectedInviteId]
|
[state.invites, selectedInviteId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setExportError(null);
|
||||||
|
setExportDownloadBusy(null);
|
||||||
|
setExportPrintBusy(null);
|
||||||
|
}, [selectedInvite?.id]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (state.invites.length === 0) {
|
if (state.invites.length === 0) {
|
||||||
setSelectedInviteId(null);
|
setSelectedInviteId(null);
|
||||||
@@ -101,10 +141,12 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
}, [selectedInvite]);
|
}, [selectedInvite]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selectedInvite) {
|
if (currentCustomization?.mode === 'advanced') {
|
||||||
console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url);
|
setDesignerMode('advanced');
|
||||||
|
} else if (designerMode !== 'standard' && currentCustomization) {
|
||||||
|
setDesignerMode('standard');
|
||||||
}
|
}
|
||||||
}, [selectedInvite]);
|
}, [currentCustomization?.mode]);
|
||||||
|
|
||||||
const inviteCountSummary = React.useMemo(() => {
|
const inviteCountSummary = React.useMemo(() => {
|
||||||
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
|
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 = (
|
const actions = (
|
||||||
<>
|
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
<Button
|
||||||
<ArrowLeft className="h-4 w-4" />
|
size="sm"
|
||||||
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
|
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>
|
</Button>
|
||||||
{slug ? (
|
{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')}
|
{t('invites.actions.backToEvent', 'Event öffnen')}
|
||||||
</Button>
|
</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')}
|
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||||
</Button>
|
</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')}
|
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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.')}
|
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{state.error ? (
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
<Alert variant="destructive">
|
<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">
|
||||||
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
<AlertDescription>{state.error}</AlertDescription>
|
{t('invites.tabs.layout', 'Layout anpassen')}
|
||||||
</Alert>
|
</TabsTrigger>
|
||||||
) : null}
|
<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>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
{state.error ? (
|
||||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<Alert variant="destructive">
|
||||||
<div className="space-y-2">
|
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<AlertDescription>{state.error}</AlertDescription>
|
||||||
<QrCode className="h-5 w-5 text-amber-500" />
|
</Alert>
|
||||||
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
|
) : null}
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-sm text-slate-600">
|
|
||||||
{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>
|
|
||||||
</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">
|
|
||||||
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
|
||||||
{t('invites.actions.refresh', 'Aktualisieren')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{creatingInvite ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
|
|
||||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{state.loading ? (
|
|
||||||
<InviteSkeleton />
|
|
||||||
) : state.invites.length === 0 ? (
|
|
||||||
<EmptyState onCreate={handleCreateInvite} />
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{state.invites.map((invite) => (
|
|
||||||
<InviteListCard
|
|
||||||
key={invite.id}
|
|
||||||
invite={invite}
|
|
||||||
onSelect={() => setSelectedInviteId(invite.id)}
|
|
||||||
onCopy={() => handleCopy(invite)}
|
|
||||||
onRevoke={() => handleRevoke(invite)}
|
|
||||||
selected={invite.id === selectedInvite?.id}
|
|
||||||
revoking={revokingId === invite.id}
|
|
||||||
copied={copiedInviteId === invite.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<InviteLayoutCustomizerPanel
|
<TabsContent value="layout" className="space-y-6 focus-visible:outline-hidden">
|
||||||
invite={selectedInvite ?? null}
|
<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">
|
||||||
eventName={eventName}
|
<div className="mb-6 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
saving={customizerSaving}
|
<div className="space-y-1">
|
||||||
resetting={customizerResetting}
|
<h2 className="text-lg font-semibold text-foreground">{t('invites.designer.heading', 'Einladungslayout anpassen')}</h2>
|
||||||
onSave={handleSaveCustomization}
|
<p className="text-sm text-muted-foreground">
|
||||||
onReset={handleResetCustomization}
|
{t('invites.designer.subheading', 'Standardlayouts sind direkt startklar. Für individuelle Gestaltung kannst du in den freien Editor wechseln.')}
|
||||||
initialCustomization={currentCustomization}
|
</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-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-muted-foreground">
|
||||||
|
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
|
||||||
|
</CardDescription>
|
||||||
|
<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
|
||||||
|
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-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{state.loading ? (
|
||||||
|
<InviteSkeleton />
|
||||||
|
) : state.invites.length === 0 ? (
|
||||||
|
<EmptyState onCreate={handleCreateInvite} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{state.invites.map((invite) => (
|
||||||
|
<InviteListCard
|
||||||
|
key={invite.id}
|
||||||
|
invite={invite}
|
||||||
|
onSelect={() => setSelectedInviteId(invite.id)}
|
||||||
|
onCopy={() => handleCopy(invite)}
|
||||||
|
onRevoke={() => handleRevoke(invite)}
|
||||||
|
selected={invite.id === selectedInvite?.id}
|
||||||
|
revoking={revokingId === invite.id}
|
||||||
|
copied={copiedInviteId === invite.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</AdminLayout>
|
</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({
|
function InviteListCard({
|
||||||
invite,
|
invite,
|
||||||
selected,
|
selected,
|
||||||
@@ -375,23 +777,32 @@ function InviteListCard({
|
|||||||
onSelect();
|
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-wrap items-center gap-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline" className={statusBadgeClass(status)}>
|
<span className="text-sm font-semibold text-foreground">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||||
{status}
|
<Badge variant="outline" className={statusBadgeClass(status)}>
|
||||||
</Badge>
|
{status}
|
||||||
{isAutoGenerated ? (
|
</Badge>
|
||||||
<Badge variant="secondary" className="bg-slate-200 text-slate-700">{t('invites.labels.standard', 'Standard')}</Badge>
|
{isAutoGenerated ? (
|
||||||
) : null}
|
<Badge variant="secondary" className="bg-muted text-muted-foreground">{t('invites.labels.standard', 'Standard')}</Badge>
|
||||||
{customization ? (
|
) : null}
|
||||||
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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}
|
{invite.url}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -408,7 +819,7 @@ function InviteListCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<span>
|
||||||
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
|
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -425,7 +836,7 @@ function InviteListCard({
|
|||||||
{t('invites.labels.selected', 'Aktuell ausgewählt')}
|
{t('invites.labels.selected', 'Aktuell ausgewählt')}
|
||||||
</Badge>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -435,7 +846,7 @@ function InviteListCard({
|
|||||||
onRevoke();
|
onRevoke();
|
||||||
}}
|
}}
|
||||||
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
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" />}
|
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
|
||||||
{t('invites.actions.deactivate', 'Deaktivieren')}
|
{t('invites.actions.deactivate', 'Deaktivieren')}
|
||||||
@@ -458,9 +869,9 @@ function InviteSkeleton() {
|
|||||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
return (
|
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">
|
<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-slate-800">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
|
<h3 className="text-base font-semibold text-foreground">{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>
|
<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">
|
<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" />
|
<Share2 className="mr-1 h-4 w-4" />
|
||||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||||
@@ -492,12 +903,12 @@ function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abge
|
|||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
function statusBadgeClass(status: string): string {
|
||||||
if (status === 'Aktiv') {
|
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') {
|
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 {
|
function formatDateTime(iso: string | null): string {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ADMIN_EVENT_PHOTOS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
ADMIN_EVENT_MEMBERS_PATH,
|
ADMIN_EVENT_MEMBERS_PATH,
|
||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
|
ADMIN_EVENT_INVITES_PATH,
|
||||||
ADMIN_EVENT_TOOLKIT_PATH,
|
ADMIN_EVENT_TOOLKIT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ function EventCard({ event }: { event: TenantEvent }) {
|
|||||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
<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
|
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,4 +225,3 @@ function renderName(name: TenantEvent['name']): string {
|
|||||||
}
|
}
|
||||||
return 'Unbenanntes Event';
|
return 'Unbenanntes Event';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ $eventName }} – Einladungs-QR</title>
|
<title>{{ $eventName }} – Einladungs-QR</title>
|
||||||
|
@php
|
||||||
|
$qrSize = $layout['qr']['size_px'] ?? 500;
|
||||||
|
@endphp
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--accent: {{ $layout['accent'] }};
|
--accent: {{ $layout['accent'] }};
|
||||||
@@ -10,7 +13,7 @@
|
|||||||
--text: {{ $layout['text'] }};
|
--text: {{ $layout['text'] }};
|
||||||
--badge: {{ $layout['badge'] }};
|
--badge: {{ $layout['badge'] }};
|
||||||
--container-padding: 48px;
|
--container-padding: 48px;
|
||||||
--qr-size: 340px;
|
--qr-size: {{ $qrSize }}px;
|
||||||
--background: {{ $backgroundStyle }};
|
--background: {{ $backgroundStyle }};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
$ctaLabel = $layout['cta_label'] ?? 'Scan mich & starte direkt';
|
$ctaLabel = $layout['cta_label'] ?? 'Scan mich & starte direkt';
|
||||||
$linkLabel = $layout['link_label'] ?? $tokenUrl;
|
$linkLabel = $layout['link_label'] ?? $tokenUrl;
|
||||||
$logoUrl = $layout['logo_url'] ?? null;
|
$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));
|
$titleLines = explode("\n", wordwrap($headline, 18, "\n", true));
|
||||||
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
|
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
|
||||||
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\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="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="{{ $qrPanelX }}" y="{{ $qrPanelY }}" width="{{ $qrPanelWidth }}" height="{{ $qrPanelHeight }}" rx="40" 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 + 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 }}" />
|
<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>
|
<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>
|
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
<text x="640" y="760" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($linkHeading)) }}</text>
|
<text x="{{ $qrPanelX + 40 }}" y="{{ $linkBoxY }}" 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)" />
|
<rect x="{{ $qrPanelX + 30 }}" y="{{ $linkBoxY + 30 }}" width="{{ $qrPanelWidth - 60 }}" height="140" rx="26" fill="rgba(0,0,0,0.08)" />
|
||||||
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($linkLabel) }}</text>
|
<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">
|
<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>
|
<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