feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Filament\Resources\InviteLayouts;
use App\Filament\Resources\InviteLayouts\Pages\CreateInviteLayout;
use App\Filament\Resources\InviteLayouts\Pages\EditInviteLayout;
use App\Filament\Resources\InviteLayouts\Pages\ListInviteLayouts;
use App\Filament\Resources\InviteLayouts\Schemas\InviteLayoutForm;
use App\Filament\Resources\InviteLayouts\Tables\InviteLayoutsTable;
use App\Models\InviteLayout;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use UnitEnum;
class InviteLayoutResource extends Resource
{
protected static ?string $model = InviteLayout::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static string|UnitEnum|null $navigationGroup = null;
protected static ?int $navigationSort = 8;
public static function getNavigationLabel(): string
{
return __('admin.nav.invite_layouts') ?? 'Layout-Vorlagen';
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.branding') ?? 'Branding & Assets';
}
public static function form(Schema $schema): Schema
{
return InviteLayoutForm::configure($schema);
}
public static function table(Table $table): Table
{
return InviteLayoutsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListInviteLayouts::route('/'),
'create' => CreateInviteLayout::route('/create'),
'edit' => EditInviteLayout::route('/{record}/edit'),
];
}
public static function normalizePayload(array $data): array
{
$data['slug'] = Str::slug($data['slug'] ?? $data['name'] ?? 'layout');
$preview = $data['preview'] ?? [];
$qrSize = Arr::get($preview, 'qr.size_px', Arr::get($preview, 'qr_size_px'));
$svgWidth = Arr::get($preview, 'svg.width', Arr::get($preview, 'svg_width'));
$svgHeight = Arr::get($preview, 'svg.height', Arr::get($preview, 'svg_height'));
$data['preview'] = array_filter([
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'text' => $preview['text'] ?? null,
'badge' => $preview['badge'] ?? null,
'qr' => array_filter([
'size_px' => $qrSize !== null ? (int) $qrSize : null,
]),
'svg' => array_filter([
'width' => $svgWidth !== null ? (int) $svgWidth : null,
'height' => $svgHeight !== null ? (int) $svgHeight : null,
]),
], fn ($value) => $value !== null && (! is_array($value) || ! empty($value)));
if (empty($data['preview']['qr'])) {
unset($data['preview']['qr']);
}
if (empty($data['preview']['svg'])) {
unset($data['preview']['svg']);
}
$layoutOptions = $data['layout_options'] ?? [];
$formats = $layoutOptions['formats'] ?? ['pdf', 'svg'];
if (is_string($formats)) {
$formats = array_values(array_filter(array_map('trim', explode(',', $formats))));
}
$layoutOptions['formats'] = $formats ?: ['pdf', 'svg'];
$data['layout_options'] = array_filter([
'badge_label' => $layoutOptions['badge_label'] ?? null,
'instructions_heading' => $layoutOptions['instructions_heading'] ?? null,
'link_heading' => $layoutOptions['link_heading'] ?? null,
'cta_label' => $layoutOptions['cta_label'] ?? null,
'cta_caption' => $layoutOptions['cta_caption'] ?? null,
'link_label' => $layoutOptions['link_label'] ?? null,
'logo_url' => $layoutOptions['logo_url'] ?? null,
'formats' => $layoutOptions['formats'],
], fn ($value) => $value !== null && $value !== []);
if (empty($data['layout_options']['logo_url'])) {
unset($data['layout_options']['logo_url']);
}
$instructions = $data['instructions'] ?? [];
if (is_array($instructions) && isset($instructions[0]) && is_array($instructions[0]) && array_key_exists('value', $instructions[0])) {
$instructions = array_map(fn ($item) => $item['value'] ?? null, $instructions);
}
$data['instructions'] = array_values(array_filter(array_map(fn ($value) => is_string($value) ? trim($value) : null, $instructions)));
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Pages;
use App\Filament\Resources\InviteLayouts\InviteLayoutResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateInviteLayout extends CreateRecord
{
protected static string $resource = InviteLayoutResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data = InviteLayoutResource::normalizePayload($data);
$data['created_by'] = Auth::id();
return $data;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Pages;
use App\Filament\Resources\InviteLayouts\InviteLayoutResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditInviteLayout extends EditRecord
{
protected static string $resource = InviteLayoutResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
return InviteLayoutResource::normalizePayload($data);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Pages;
use App\Filament\Resources\InviteLayouts\InviteLayoutResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListInviteLayouts extends ListRecords
{
protected static string $resource = InviteLayoutResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Schemas;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class InviteLayoutForm
{
public static function configure(Schema $schema): Schema
{
return $schema->schema([
Section::make('Grunddaten')
->columns(2)
->schema([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255)
->afterStateUpdated(fn (callable $set, $state) => $set('slug', Str::slug((string) $state ?? '')))
->reactive(),
TextInput::make('slug')
->label('Slug')
->required()
->maxLength(191)
->unique(ignoreRecord: true),
TextInput::make('subtitle')
->label('Unterzeile')
->maxLength(255)
->columnSpanFull(),
Textarea::make('description')
->label('Beschreibung')
->rows(4)
->columnSpanFull(),
Toggle::make('is_active')
->label('Aktiv')
->default(true),
]),
Section::make('Papier & Format')
->columns(3)
->schema([
Select::make('paper')
->label('Papierformat')
->options([
'a4' => 'A4 (210 × 297 mm)',
'a5' => 'A5 (148 × 210 mm)',
'a3' => 'A3 (297 × 420 mm)',
'custom' => 'Benutzerdefiniert',
])
->default('a4'),
Select::make('orientation')
->label('Ausrichtung')
->options([
'portrait' => 'Hochformat',
'landscape' => 'Querformat',
])
->default('portrait'),
TextInput::make('layout_options.formats')
->label('Formate (Komma getrennt)')
->default('pdf,svg')
->helperText('Bestimmt, welche Downloads angeboten werden.')
->dehydrateStateUsing(fn ($state) => collect(explode(',', (string) $state))
->map(fn ($value) => trim((string) $value))
->filter()
->values()
->all())
->afterStateHydrated(function (TextInput $component, $state) {
if (is_array($state)) {
$component->state(implode(',', $state));
}
}),
]),
Section::make('Farben')
->columns(5)
->schema([
ColorPicker::make('preview.background')
->label('Hintergrund')
->default('#F9FAFB'),
ColorPicker::make('preview.accent')
->label('Akzent')
->default('#6366F1'),
ColorPicker::make('preview.text')
->label('Text')
->default('#0F172A'),
ColorPicker::make('preview.secondary')
->label('Sekundär')
->default('#CBD5F5'),
ColorPicker::make('preview.badge')
->label('Badge')
->default('#2563EB'),
TextInput::make('preview.qr.size_px')
->label('QR-Größe (px)')
->numeric()
->default(320)
->columnSpan(2),
TextInput::make('preview.svg.width')
->label('SVG Breite')
->numeric()
->default(1080),
TextInput::make('preview.svg.height')
->label('SVG Höhe')
->numeric()
->default(1520),
]),
Section::make('Texte & Hinweise')
->columns(2)
->schema([
TextInput::make('layout_options.badge_label')
->label('Badge-Label')
->default('Digitale Gästebox'),
TextInput::make('layout_options.instructions_heading')
->label('Anleitungstitel')
->default("So funktioniert's"),
TextInput::make('layout_options.link_heading')
->label('Link-Titel')
->default('Alternative zum Einscannen'),
TextInput::make('layout_options.cta_label')
->label('CTA Label')
->default('Scan mich & starte direkt'),
TextInput::make('layout_options.cta_caption')
->label('CTA Untertitel')
->default('Scan mich & starte direkt'),
TextInput::make('layout_options.link_label')
->label('Link Text (optional)')
->helperText('Überschreibt den standardmäßigen Einladungslink.'),
TextInput::make('layout_options.logo_url')
->label('Logo URL')
->columnSpanFull(),
Repeater::make('instructions')
->label('Hinweise')
->maxItems(6)
->schema([
Textarea::make('value')
->label('Hinweistext')
->rows(2)
->required()
->maxLength(180),
])
->afterStateHydrated(function (Repeater $component, $state): void {
if (is_array($state) && ! empty($state) && ! is_array(current($state))) {
$component->state(array_map(fn ($value) => ['value' => $value], $state));
}
})
->dehydrateStateUsing(fn ($state) => collect($state)->pluck('value')->filter()->values()->all()),
]),
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class InviteLayoutsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('Slug')
->copyable()
->searchable()
->sortable(),
TextColumn::make('paper')
->label('Papier')
->sortable(),
TextColumn::make('orientation')
->label('Ausrichtung')
->sortable(),
BadgeColumn::make('is_active')
->label('Status')
->colors([
'success' => fn ($state) => $state === true,
'gray' => fn ($state) => $state === false,
])
->getStateUsing(fn ($record) => $record->is_active)
->formatStateUsing(fn ($state) => $state ? 'Aktiv' : 'Inaktiv'),
TextColumn::make('updated_at')
->label('Aktualisiert')
->dateTime('d.m.Y H:i')
->sortable(),
])
->filters([
SelectFilter::make('is_active')
->label('Status')
->options([
'1' => 'Aktiv',
'0' => 'Inaktiv',
])
->query(function ($query, $state) {
if ($state === '1') {
$query->where('is_active', true);
} elseif ($state === '0') {
$query->where('is_active', false);
}
}),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Requests\Tenant\EventStoreRequest;
use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Http\Resources\Tenant\EventResource; use App\Http\Resources\Tenant\EventResource;
use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Models\Package; use App\Models\Package;
@@ -228,6 +230,10 @@ class EventController extends Controller
unset($validated[$unused]); unset($validated[$unused]);
} }
if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
}
$event->update($validated); $event->update($validated);
$event->load(['eventType', 'tenant']); $event->load(['eventType', 'tenant']);
@@ -277,6 +283,141 @@ class EventController extends Controller
]); ]);
} }
public function toolkit(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$event->load(['eventType', 'eventPackage.package']);
$photoQuery = Photo::query()->where('event_id', $event->id);
$pendingPhotos = (clone $photoQuery)
->where('status', 'pending')
->latest('created_at')
->take(6)
->get();
$recentUploads = (clone $photoQuery)
->where('status', 'approved')
->latest('created_at')
->take(8)
->get();
$pendingCount = (clone $photoQuery)->where('status', 'pending')->count();
$uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count();
$totalUploads = (clone $photoQuery)->count();
$tasks = $event->tasks()
->orderBy('tasks.sort_order')
->orderBy('tasks.created_at')
->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']);
$taskSummary = [
'total' => $tasks->count(),
'completed' => $tasks->where('is_completed', true)->count(),
];
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
$translate = static function ($value, string $fallback = '') {
if (is_array($value)) {
$locale = app()->getLocale();
$candidates = array_filter([
$locale,
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
'de',
'en',
]);
foreach ($candidates as $candidate) {
if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') {
return $value[$candidate];
}
}
$first = reset($value);
return $first !== false ? $first : $fallback;
}
if (is_string($value) && $value !== '') {
return $value;
}
return $fallback;
};
$taskPreview = $tasks
->take(6)
->map(fn ($task) => [
'id' => $task->id,
'title' => $translate($task->title, 'Task'),
'description' => $translate($task->description, null),
'is_completed' => (bool) $task->is_completed,
'priority' => $task->priority,
])
->values();
$joinTokenQuery = $event->joinTokens();
$totalInvites = (clone $joinTokenQuery)->count();
$activeInvites = (clone $joinTokenQuery)
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
})
->count();
$recentInvites = (clone $joinTokenQuery)
->orderByDesc('created_at')
->take(3)
->get();
$alerts = [];
if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) {
$alerts[] = 'no_tasks';
}
if ($activeInvites === 0) {
$alerts[] = 'no_invites';
}
if ($pendingCount > 0) {
$alerts[] = 'pending_photos';
}
return response()->json([
'event' => new EventResource($event),
'metrics' => [
'uploads_total' => $totalUploads,
'uploads_24h' => $uploads24h,
'pending_photos' => $pendingCount,
'active_invites' => $activeInvites,
'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks',
],
'tasks' => [
'summary' => $taskSummary,
'items' => $taskPreview,
],
'photos' => [
'pending' => PhotoResource::collection($pendingPhotos)->resolve($request),
'recent' => PhotoResource::collection($recentUploads)->resolve($request),
],
'invites' => [
'summary' => [
'total' => $totalInvites,
'active' => $activeInvites,
],
'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request),
],
'alerts' => $alerts,
]);
}
public function toggle(Request $request, Event $event): JsonResponse public function toggle(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -30,12 +31,7 @@ class EventJoinTokenController extends Controller
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
$validated = $request->validate([ $validated = $this->validatePayload($request);
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
]);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [ $token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(), 'created_by' => Auth::id(),
@@ -46,6 +42,50 @@ class EventJoinTokenController extends Controller
->setStatusCode(201); ->setStatusCode(201);
} }
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
if ($joinToken->event_id !== $event->id) {
abort(404);
}
$validated = $this->validatePayload($request, true);
$payload = [];
if (array_key_exists('label', $validated)) {
$payload['label'] = $validated['label'];
}
if (array_key_exists('expires_at', $validated)) {
$payload['expires_at'] = $validated['expires_at'];
}
if (array_key_exists('usage_limit', $validated)) {
$payload['usage_limit'] = $validated['usage_limit'];
}
if (! empty($payload)) {
$joinToken->fill($payload);
}
if (array_key_exists('metadata', $validated)) {
$current = is_array($joinToken->metadata) ? $joinToken->metadata : [];
$incoming = $validated['metadata'];
if ($incoming === null) {
$joinToken->metadata = null;
} else {
$joinToken->metadata = array_replace_recursive($current, $incoming);
}
}
$joinToken->save();
return new EventJoinTokenResource($joinToken->fresh());
}
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
@@ -68,4 +108,54 @@ class EventJoinTokenController extends Controller
abort(404, 'Event not found'); abort(404, 'Event not found');
} }
} }
private function validatePayload(Request $request, bool $partial = false): array
{
$rules = [
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
'metadata.layout_customization' => ['nullable', 'array'],
'metadata.layout_customization.layout_id' => ['nullable', 'string', 'max:100'],
'metadata.layout_customization.headline' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.subtitle' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.description' => ['nullable', 'string', 'max:500'],
'metadata.layout_customization.badge_label' => ['nullable', 'string', 'max:80'],
'metadata.layout_customization.instructions_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.link_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_label' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_caption' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.link_label' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.instructions' => ['nullable', 'array', 'max:6'],
'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'],
'metadata.layout_customization.logo_data_url' => ['nullable', 'string'],
'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.badge_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_gradient' => ['nullable', 'array'],
'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'],
'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'],
'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
];
$validated = $request->validate($rules);
if (isset($validated['metadata']['layout_customization']['instructions'])) {
$validated['metadata']['layout_customization']['instructions'] = array_values(array_filter(
$validated['metadata']['layout_customization']['instructions'],
fn ($value) => is_string($value) && trim($value) !== ''
));
}
if (isset($validated['metadata']['layout_customization']['logo_data_url'])
&& ! is_string($validated['metadata']['layout_customization']['logo_data_url'])) {
unset($validated['metadata']['layout_customization']['logo_data_url']);
}
return $validated;
}
} }

View File

@@ -46,6 +46,8 @@ class EventJoinTokenLayoutController extends Controller
abort(404, 'Unbekanntes Exportformat.'); abort(404, 'Unbekanntes Exportformat.');
} }
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
$tokenUrl = url('/e/'.$joinToken->token); $tokenUrl = url('/e/'.$joinToken->token);
$qrPngDataUri = 'data:image/png;base64,'.base64_encode( $qrPngDataUri = 'data:image/png;base64,'.base64_encode(
@@ -66,6 +68,7 @@ class EventJoinTokenLayoutController extends Controller
'tokenUrl' => $tokenUrl, 'tokenUrl' => $tokenUrl,
'qrPngDataUri' => $qrPngDataUri, 'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle, 'backgroundStyle' => $backgroundStyle,
'customization' => $joinToken->metadata['layout_customization'] ?? null,
]; ];
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format); $filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
@@ -80,7 +83,7 @@ class EventJoinTokenLayoutController extends Controller
$html = view('layouts.join-token.pdf', $viewData)->render(); $html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options(); $options = new Options;
$options->set('isHtml5ParserEnabled', true); $options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica'); $options->set('defaultFont', 'Helvetica');
@@ -115,6 +118,57 @@ class EventJoinTokenLayoutController extends Controller
return is_string($name) && $name !== '' ? $name : 'Event'; return is_string($name) && $name !== '' ? $name : 'Event';
} }
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
{
$customization = data_get($joinToken->metadata, 'layout_customization');
if (! is_array($customization)) {
return $layout;
}
$layoutId = $customization['layout_id'] ?? null;
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
// Allow customization to target a specific layout; if mismatch, skip style overrides.
// General text overrides are still applied below.
}
$colorKeys = [
'accent' => 'accent_color',
'text' => 'text_color',
'background' => 'background_color',
'secondary' => 'secondary_color',
'badge' => 'badge_color',
];
foreach ($colorKeys as $layoutKey => $customKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
$layout['background_gradient'] = $customization['background_gradient'];
}
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
}
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
$layout['logo_url'] = $customization['logo_data_url'];
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
$layout['logo_url'] = $customization['logo_url'];
}
return $layout;
}
private function buildBackgroundStyle(array $layout): string private function buildBackgroundStyle(array $layout): string
{ {
$gradient = $layout['background_gradient'] ?? null; $gradient = $layout['background_gradient'] ?? null;
@@ -128,4 +182,4 @@ class EventJoinTokenLayoutController extends Controller
return $layout['background'] ?? '#FFFFFF'; return $layout['background'] ?? '#FFFFFF';
} }
} }

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\TenantFeedback;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller
{
public function store(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if (! $tenantId) {
abort(403, 'Unauthorised');
}
$validated = $request->validate([
'category' => ['required', 'string', 'max:80'],
'sentiment' => ['nullable', 'string', Rule::in(['positive', 'neutral', 'negative'])],
'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'title' => ['nullable', 'string', 'max:120'],
'message' => ['nullable', 'string', 'max:2000'],
'event_slug' => ['nullable', 'string', 'max:255'],
'metadata' => ['nullable', 'array'],
]);
$eventId = null;
if (! empty($validated['event_slug'])) {
$eventSlug = $validated['event_slug'];
$event = Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->select('id')
->first();
$eventId = $event?->id;
}
$feedback = TenantFeedback::create([
'tenant_id' => $tenantId,
'event_id' => $eventId,
'category' => $validated['category'],
'sentiment' => $validated['sentiment'] ?? null,
'rating' => $validated['rating'] ?? null,
'title' => $validated['title'] ?? null,
'message' => $validated['message'] ?? null,
'metadata' => $validated['metadata'] ?? null,
]);
return response()->json([
'message' => 'Feedback gespeichert',
'data' => [
'id' => $feedback->id,
'created_at' => $feedback->created_at?->toIso8601String(),
],
], 201);
}
}

View File

@@ -7,22 +7,25 @@ use App\Models\OAuthCode;
use App\Models\RefreshToken; use App\Models\RefreshToken;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantToken; use App\Models\TenantToken;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class OAuthController extends Controller class OAuthController extends Controller
{ {
private const AUTH_CODE_TTL_MINUTES = 5; private const AUTH_CODE_TTL_MINUTES = 5;
private const ACCESS_TOKEN_TTL_SECONDS = 3600; private const ACCESS_TOKEN_TTL_SECONDS = 3600;
private const REFRESH_TOKEN_TTL_DAYS = 30; private const REFRESH_TOKEN_TTL_DAYS = 30;
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt'; private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
/** /**
@@ -104,6 +107,14 @@ class OAuthController extends Controller
'state' => $request->state, 'state' => $request->state,
]); ]);
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
return response()->json([
'code' => $code,
'state' => $request->state,
'redirect_url' => $redirectUrl,
]);
}
return redirect()->away($redirectUrl); return redirect()->away($redirectUrl);
} }
@@ -402,6 +413,40 @@ class OAuthController extends Controller
]; ];
} }
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
{
if ($request->expectsJson() || $request->ajax()) {
return true;
}
$redirectUri = (string) $request->string('redirect_uri');
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
$requestHost = $request->getHost();
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
return true;
}
$origin = $request->headers->get('Origin');
if ($origin) {
$originHost = parse_url($origin, PHP_URL_HOST);
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
return true;
}
}
return false;
}
private function hostsMatch(?string $first, ?string $second): bool
{
if (! $first || ! $second) {
return false;
}
return strtolower($first) === strtolower($second);
}
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
{ {
$refreshTokenId = (string) Str::uuid(); $refreshTokenId = (string) Str::uuid();
@@ -566,6 +611,7 @@ class OAuthController extends Controller
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true); File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
} }
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
{ {
if (empty($requestedScopes)) { if (empty($requestedScopes)) {
@@ -682,7 +728,7 @@ class OAuthController extends Controller
return redirect('/event-admin')->with('error', 'Invalid state parameter'); return redirect('/event-admin')->with('error', 'Invalid state parameter');
} }
$client = new Client(); $client = new Client;
$clientId = config('services.stripe.connect_client_id'); $clientId = config('services.stripe.connect_client_id');
$secret = config('services.stripe.connect_secret'); $secret = config('services.stripe.connect_secret');
$redirectUri = url('/api/v1/oauth/stripe-callback'); $redirectUri = url('/api/v1/oauth/stripe-callback');
@@ -710,11 +756,12 @@ class OAuthController extends Controller
} }
session()->forget(['stripe_state', 'tenant_id']); session()->forget(['stripe_state', 'tenant_id']);
return redirect('/event-admin')->with('success', 'Stripe account connected successfully'); return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Stripe OAuth error: '.$e->getMessage()); Log::error('Stripe OAuth error: '.$e->getMessage());
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage()); return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
} }
} }
} }

View File

@@ -23,7 +23,7 @@ class EventStoreRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$tenantId = request()->attributes->get('tenant_id'); $tenantId = request()->attributes->get('tenant_id');
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
@@ -41,6 +41,8 @@ class EventStoreRequest extends FormRequest
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])], 'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
'features' => ['nullable', 'array'], 'features' => ['nullable', 'array'],
'features.*' => ['string'], 'features.*' => ['string'],
'settings' => ['nullable', 'array'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
]; ];
} }
@@ -67,4 +69,4 @@ class EventStoreRequest extends FormRequest
'password_protected' => $this->boolean('password_protected'), 'password_protected' => $this->boolean('password_protected'),
]); ]);
} }
} }

View File

@@ -44,6 +44,8 @@ class EventResource extends JsonResource
'status' => $this->status ?? 'draft', 'status' => $this->status ?? 'draft',
'is_active' => (bool) ($this->is_active ?? false), 'is_active' => (bool) ($this->is_active ?? false),
'features' => $settings['features'] ?? [], 'features' => $settings['features'] ?? [],
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
'settings' => $settings,
'event_type_id' => $this->event_type_id, 'event_type_id' => $this->event_type_id,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InviteLayout extends Model
{
use HasFactory;
protected $fillable = [
'slug',
'name',
'subtitle',
'description',
'paper',
'orientation',
'preview',
'layout_options',
'instructions',
'is_active',
'created_by',
];
protected $casts = [
'preview' => 'array',
'layout_options' => 'array',
'instructions' => 'array',
'is_active' => 'bool',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantFeedback extends Model
{
use HasFactory;
protected $table = 'tenant_feedback';
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,6 +44,10 @@ class TenantPackage extends Model
public function isActive(): bool public function isActive(): bool
{ {
if ($this->package && $this->package->isEndcustomer()) {
return (bool) $this->active;
}
return $this->active && (! $this->expires_at || $this->expires_at->isFuture()); return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
} }
@@ -76,23 +81,43 @@ class TenantPackage extends Model
{ {
parent::boot(); parent::boot();
static::creating(function ($tenantPackage) { static::creating(function (self $tenantPackage) {
if (! $tenantPackage->purchased_at) { if (! $tenantPackage->purchased_at) {
$tenantPackage->purchased_at = now(); $tenantPackage->purchased_at = now();
} }
if (! $tenantPackage->expires_at && $tenantPackage->package) {
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller $package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}
} else {
$tenantPackage->expires_at = now()->addCentury();
}
if ($tenantPackage->active === null) {
$tenantPackage->active = true;
} }
$tenantPackage->active = true;
}); });
static::updating(function ($tenantPackage) { static::updating(function (self $tenantPackage) {
if ( $package = $tenantPackage->package;
$tenantPackage->isDirty('expires_at')
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface if ($package && $package->isReseller()) {
&& $tenantPackage->expires_at->isPast() if (
) { $tenantPackage->isDirty('expires_at')
$tenantPackage->active = false; && $tenantPackage->expires_at instanceof CarbonInterface
&& $tenantPackage->expires_at->isPast()
) {
$tenantPackage->active = false;
}
return;
}
if ($tenantPackage->isDirty('expires_at')) {
$tenantPackage->expires_at = now()->addCentury();
} }
}); });
} }

View File

@@ -2,6 +2,8 @@
namespace App\Support; namespace App\Support;
use App\Models\InviteLayout;
class JoinTokenLayoutRegistry class JoinTokenLayoutRegistry
{ {
/** /**
@@ -123,6 +125,18 @@ class JoinTokenLayoutRegistry
*/ */
public static function all(): array public static function all(): array
{ {
$customLayouts = InviteLayout::query()
->where('is_active', true)
->orderBy('name')
->get();
if ($customLayouts->isNotEmpty()) {
return $customLayouts
->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout)))
->values()
->all();
}
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS)); return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
} }
@@ -131,6 +145,15 @@ class JoinTokenLayoutRegistry
*/ */
public static function find(string $id): ?array public static function find(string $id): ?array
{ {
$custom = InviteLayout::query()
->where('slug', $id)
->where('is_active', true)
->first();
if ($custom) {
return self::normalize(self::fromModel($custom));
}
$layout = self::LAYOUTS[$id] ?? null; $layout = self::LAYOUTS[$id] ?? null;
return $layout ? self::normalize($layout) : null; return $layout ? self::normalize($layout) : null;
@@ -151,6 +174,13 @@ class JoinTokenLayoutRegistry
'accent' => '#6366F1', 'accent' => '#6366F1',
'secondary' => '#CBD5F5', 'secondary' => '#CBD5F5',
'badge' => '#2563EB', 'badge' => '#2563EB',
'badge_label' => 'Digitale Gästebox',
'instructions_heading' => "So funktioniert's",
'link_heading' => 'Alternative zum Einscannen',
'cta_label' => 'Scan mich & starte direkt',
'cta_caption' => 'Scan mich & starte direkt',
'link_label' => null,
'logo_url' => null,
'qr' => [ 'qr' => [
'size_px' => 320, 'size_px' => 320,
], ],
@@ -160,11 +190,50 @@ class JoinTokenLayoutRegistry
], ],
'background_gradient' => null, 'background_gradient' => null,
'instructions' => [], 'instructions' => [],
'formats' => ['pdf', 'svg'],
]; ];
return array_replace_recursive($defaults, $layout); return array_replace_recursive($defaults, $layout);
} }
private static function fromModel(InviteLayout $layout): array
{
$preview = $layout->preview ?? [];
$options = $layout->layout_options ?? [];
$instructions = $layout->instructions ?? [];
return array_filter([
'id' => $layout->slug,
'name' => $layout->name,
'subtitle' => $layout->subtitle,
'description' => $layout->description,
'paper' => $layout->paper,
'orientation' => $layout->orientation,
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'text' => $preview['text'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'badge' => $preview['badge'] ?? null,
'badge_label' => $options['badge_label'] ?? null,
'instructions_heading' => $options['instructions_heading'] ?? null,
'link_heading' => $options['link_heading'] ?? null,
'cta_label' => $options['cta_label'] ?? null,
'cta_caption' => $options['cta_caption'] ?? null,
'link_label' => $options['link_label'] ?? null,
'logo_url' => $options['logo_url'] ?? null,
'qr' => array_filter([
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
]),
'svg' => array_filter([
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
]),
'formats' => $options['formats'] ?? ['pdf', 'svg'],
'instructions' => $instructions,
], fn ($value) => $value !== null && $value !== []);
}
/** /**
* Map layouts into an API-ready response structure, attaching URLs. * Map layouts into an API-ready response structure, attaching URLs.
* *
@@ -174,7 +243,7 @@ class JoinTokenLayoutRegistry
public static function toResponse(callable $urlResolver): array public static function toResponse(callable $urlResolver): array
{ {
return array_map(function (array $layout) use ($urlResolver) { return array_map(function (array $layout) use ($urlResolver) {
$formats = ['pdf', 'svg']; $formats = $layout['formats'] ?? ['pdf', 'svg'];
return [ return [
'id' => $layout['id'], 'id' => $layout['id'],
@@ -194,4 +263,4 @@ class JoinTokenLayoutRegistry
]; ];
}, self::all()); }, self::all());
} }
} }

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('tenant_packages') || ! Schema::hasTable('packages')) {
return;
}
DB::table('tenant_packages')
->whereIn('package_id', function ($query) {
$query->select('id')
->from('packages')
->where('type', 'endcustomer');
})
->update([
'expires_at' => now()->addCentury(),
'active' => true,
'updated_at' => now(),
]);
}
public function down(): void
{
// No rollback endcustomer packages should remain non-expiring.
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('invite_layouts', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('name');
$table->string('subtitle')->nullable();
$table->text('description')->nullable();
$table->string('paper')->default('a4');
$table->string('orientation')->default('portrait');
$table->json('preview')->nullable();
$table->json('layout_options')->nullable();
$table->json('instructions')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->foreign('created_by')->references('id')->on('users')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('invite_layouts');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_feedback', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('event_id')->nullable();
$table->string('category', 80);
$table->string('sentiment', 20)->nullable();
$table->unsignedTinyInteger('rating')->nullable();
$table->string('title')->nullable();
$table->text('message')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
$table->foreign('event_id')->references('id')->on('events')->cascadeOnDelete();
$table->index(['tenant_id', 'category']);
$table->index(['event_id', 'category']);
});
}
public function down(): void
{
Schema::dropIfExists('tenant_feedback');
}
};

View File

@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
TasksSeeder::class, TasksSeeder::class,
EventTasksSeeder::class, EventTasksSeeder::class,
TaskCollectionsSeeder::class, TaskCollectionsSeeder::class,
InviteLayoutSeeder::class,
]); ]);
// Seed demo and admin data // Seed demo and admin data

View File

@@ -0,0 +1,57 @@
<?php
namespace Database\Seeders;
use App\Models\InviteLayout;
use App\Support\JoinTokenLayoutRegistry;
use Illuminate\Database\Seeder;
use ReflectionClass;
class InviteLayoutSeeder extends Seeder
{
public function run(): void
{
$reflection = new ReflectionClass(JoinTokenLayoutRegistry::class);
$layoutsConst = $reflection->getReflectionConstant('LAYOUTS');
$fallbackLayouts = $layoutsConst ? $layoutsConst->getValue() : [];
foreach ($fallbackLayouts as $layout) {
$preview = [
'background' => $layout['background'] ?? null,
'background_gradient' => $layout['background_gradient'] ?? null,
'accent' => $layout['accent'] ?? null,
'secondary' => $layout['secondary'] ?? null,
'text' => $layout['text'] ?? null,
'badge' => $layout['badge'] ?? null,
'qr' => $layout['qr'] ?? ['size_px' => 320],
'svg' => $layout['svg'] ?? ['width' => 1080, 'height' => 1520],
];
$options = [
'badge_label' => $layout['badge_label'] ?? 'Digitale Gästebox',
'instructions_heading' => $layout['instructions_heading'] ?? "So funktioniert's",
'link_heading' => $layout['link_heading'] ?? 'Alternative zum Einscannen',
'cta_label' => $layout['cta_label'] ?? 'Scan mich & starte direkt',
'cta_caption' => $layout['cta_caption'] ?? 'Scan mich & starte direkt',
'link_label' => $layout['link_label'] ?? null,
'logo_url' => $layout['logo_url'] ?? null,
'formats' => $layout['formats'] ?? ['pdf', 'svg'],
];
InviteLayout::updateOrCreate(
['slug' => $layout['id']],
[
'name' => $layout['name'],
'subtitle' => $layout['subtitle'] ?? null,
'description' => $layout['description'] ?? null,
'paper' => $layout['paper'] ?? 'a4',
'orientation' => $layout['orientation'] ?? 'portrait',
'preview' => $preview,
'layout_options' => $options,
'instructions' => $layout['instructions'] ?? [],
'is_active' => true,
]
);
}
}
}

View File

@@ -2,9 +2,12 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\{Emotion, Task, EventType};
class TasksSeeder extends Seeder class TasksSeeder extends Seeder
{ {
@@ -25,40 +28,83 @@ class TasksSeeder extends Seeder
$seed = [ $seed = [
'Liebe' => [ 'Liebe' => [
['title'=>['de'=>'Kuss-Foto','en'=>'Kiss Photo'], 'description'=>['de'=>'Macht ein romantisches Kuss-Foto','en'=>'Take a romantic kiss photo'], 'difficulty'=>'easy'], ['title' => ['de' => 'Kuss-Foto', 'en' => 'Kiss Photo'], 'description' => ['de' => 'Macht ein romantisches Kuss-Foto', 'en' => 'Take a romantic kiss photo'], 'difficulty' => 'easy'],
], ],
'Freude' => [ 'Freude' => [
['title'=>['de'=>'Sprung-Foto','en'=>'Jump Photo'], 'description'=>['de'=>'Alle springen gleichzeitig!','en'=>'Everyone jump together!'], 'difficulty'=>'medium'], ['title' => ['de' => 'Sprung-Foto', 'en' => 'Jump Photo'], 'description' => ['de' => 'Alle springen gleichzeitig!', 'en' => 'Everyone jump together!'], 'difficulty' => 'medium'],
], ],
'Teamgeist' => [ 'Teamgeist' => [
['title'=>['de'=>'High-Five-Runde','en'=>'High-Five Round'], 'description'=>['de'=>'Gebt euch High-Fives!','en'=>'Give each other high-fives!'], 'difficulty'=>'easy', 'event_type'=>'corporate'], ['title' => ['de' => 'High-Five-Runde', 'en' => 'High-Five Round'], 'description' => ['de' => 'Gebt euch High-Fives!', 'en' => 'Give each other high-fives!'], 'difficulty' => 'easy', 'event_type' => 'corporate'],
], ],
'Besinnlichkeit' => [ 'Besinnlichkeit' => [
['title'=>['de'=>'Lichterglanz','en'=>'Glow of Lights'], 'description'=>['de'=>'Foto mit Lichterkette','en'=>'Photo with string lights'], 'difficulty'=>'easy', 'event_type'=>'christmas'], ['title' => ['de' => 'Lichterglanz', 'en' => 'Glow of Lights'], 'description' => ['de' => 'Foto mit Lichterkette', 'en' => 'Photo with string lights'], 'difficulty' => 'easy', 'event_type' => 'christmas'],
], ],
]; ];
$types = EventType::pluck('id','slug'); $types = EventType::pluck('id', 'slug');
$position = 10;
foreach ($seed as $emotionNameDe => $tasks) { foreach ($seed as $emotionNameDe => $tasks) {
$emotion = Emotion::where('name->de', $emotionNameDe)->first(); $emotion = Emotion::where('name->de', $emotionNameDe)->first();
if (!$emotion) continue;
foreach ($tasks as $t) {
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
$slug = $slugBase ? $slugBase . '-' . $emotion->id : Str::uuid()->toString();
Task::updateOrCreate([ if (! $emotion) {
'slug' => $slug, continue;
], [ }
$emotionTranslations = is_array($emotion->name) ? $emotion->name : [];
$emotionNameEn = $emotionTranslations['en'] ?? $emotionNameDe;
$collection = TaskCollection::updateOrCreate(
[
'tenant_id' => $demoTenant->id, 'tenant_id' => $demoTenant->id,
'emotion_id' => $emotion->id, 'slug' => 'demo-'.Str::slug($emotionTranslations['en'] ?? $emotionNameDe),
'event_type_id' => isset($t['event_type']) && isset($types[$t['event_type']]) ? $types[$t['event_type']] : null, ],
'title' => $t['title'], [
'description' => $t['description'], 'name_translations' => [
'difficulty' => $t['difficulty'], 'de' => $emotionNameDe,
'is_active' => true, 'en' => $emotionNameEn,
'sort_order' => $t['sort_order'] ?? 0, ],
]); 'description_translations' => [
'de' => 'Aufgaben rund um '.$emotionNameDe.'.',
'en' => 'Prompts inspired by '.$emotionNameEn.'.',
],
'event_type_id' => null,
'is_default' => false,
'position' => $position,
]
);
$position += 10;
$syncPayload = [];
foreach ($tasks as $index => $t) {
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
$slug = $slugBase ? $slugBase.'-'.$emotion->id : Str::uuid()->toString();
$sortOrder = $t['sort_order'] ?? (($index + 1) * 10);
$task = Task::updateOrCreate(
[
'slug' => $slug,
],
[
'tenant_id' => $demoTenant->id,
'emotion_id' => $emotion->id,
'event_type_id' => isset($t['event_type'], $types[$t['event_type']]) ? $types[$t['event_type']] : null,
'title' => $t['title'],
'description' => $t['description'],
'difficulty' => $t['difficulty'],
'is_active' => true,
'sort_order' => $sortOrder,
'collection_id' => $collection->id,
]
);
$syncPayload[$task->id] = ['sort_order' => $sortOrder];
}
if (! empty($syncPayload)) {
$collection->tasks()->syncWithoutDetaching($syncPayload);
} }
} }
} }

View File

@@ -3,7 +3,7 @@ import i18n from './i18n';
type JsonValue = Record<string, any>; type JsonValue = Record<string, any>;
export type EventJoinTokenLayout = { export type EventQrInviteLayout = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
@@ -41,6 +41,8 @@ export type TenantEvent = {
description?: string | null; description?: string | null;
photo_count?: number; photo_count?: number;
like_count?: number; like_count?: number;
engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
package?: { package?: {
id: number | string | null; id: number | string | null;
name: string | null; name: string | null;
@@ -246,7 +248,7 @@ export type EventMember = {
type EventListResponse = { data?: JsonValue[] }; type EventListResponse = { data?: JsonValue[] };
type EventResponse = { data: JsonValue }; type EventResponse = { data: JsonValue };
export type EventJoinToken = { export type EventQrInvite = {
id: number; id: number;
token: string; token: string;
url: string; url: string;
@@ -258,9 +260,48 @@ export type EventJoinToken = {
is_active: boolean; is_active: boolean;
created_at: string | null; created_at: string | null;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
layouts: EventJoinTokenLayout[]; layouts: EventQrInviteLayout[];
layouts_url: string | null; layouts_url: string | null;
}; };
export type EventToolkitTask = {
id: number;
title: string;
description: string | null;
is_completed: boolean;
priority?: string | null;
};
export type EventToolkit = {
event: TenantEvent;
metrics: {
uploads_total: number;
uploads_24h: number;
pending_photos: number;
active_invites: number;
engagement_mode: 'tasks' | 'photo_only';
};
tasks: {
summary: {
total: number;
completed: number;
pending: number;
};
items: EventToolkitTask[];
};
photos: {
pending: TenantPhoto[];
recent: TenantPhoto[];
};
invites: {
summary: {
total: number;
active: number;
};
items: EventQrInvite[];
};
alerts: string[];
};
type CreatedEventResponse = { message: string; data: JsonValue; balance: number }; type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto }; type PhotoResponse = { message: string; data: TenantPhoto };
@@ -272,6 +313,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived'; status?: 'draft' | 'published' | 'archived';
is_active?: boolean; is_active?: boolean;
package_id?: number; package_id?: number;
settings?: Record<string, unknown>;
}; };
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> { async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
@@ -380,6 +422,10 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
function normalizeEvent(event: JsonValue): TenantEvent { function normalizeEvent(event: JsonValue): TenantEvent {
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null); const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
| 'tasks'
| 'photo_only';
const normalized: TenantEvent = { const normalized: TenantEvent = {
...(event as Record<string, unknown>), ...(event as Record<string, unknown>),
id: Number(event.id ?? 0), id: Number(event.id ?? 0),
@@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent {
description: event.description ?? null, description: event.description ?? null,
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined, photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined, like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null, package: event.package ?? null,
}; };
@@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember {
}; };
} }
function normalizeJoinToken(raw: JsonValue): EventJoinToken { function normalizeQrInvite(raw: JsonValue): EventQrInvite {
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : []; const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
const layouts: EventJoinTokenLayout[] = rawLayouts const layouts: EventQrInviteLayout[] = rawLayouts
.map((layout: any) => { .map((layout: any) => {
const formats = Array.isArray(layout.formats) const formats = Array.isArray(layout.formats)
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0) ? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
@@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
download_urls: (layout.download_urls ?? {}) as Record<string, string>, download_urls: (layout.download_urls ?? {}) as Record<string, string>,
}; };
}) })
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0); .filter((layout: EventQrInviteLayout) => layout.id.length > 0);
return { return {
id: Number(raw.id ?? 0), id: Number(raw.id ?? 0),
@@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise<EventStats> {
}; };
} }
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> { export async function getEventQrInvites(slug: string): Promise<EventQrInvite[]> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`); const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations'); const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
const list = Array.isArray(payload.data) ? payload.data : []; const list = Array.isArray(payload.data) ? payload.data : [];
return list.map(normalizeJoinToken); return list.map(normalizeQrInvite);
} }
export async function createInviteLink( export async function createQrInvite(
slug: string, slug: string,
payload?: { label?: string; usage_limit?: number; expires_at?: string } payload?: { label?: string; usage_limit?: number; expires_at?: string }
): Promise<EventJoinToken> { ): Promise<EventQrInvite> {
const body = JSON.stringify(payload ?? {}); const body = JSON.stringify(payload ?? {});
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, { const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
method: 'POST', method: 'POST',
@@ -739,14 +787,14 @@ export async function createInviteLink(
body, body,
}); });
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation'); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
return normalizeJoinToken(data.data ?? {}); return normalizeQrInvite(data.data ?? {});
} }
export async function revokeEventJoinToken( export async function revokeEventQrInvite(
slug: string, slug: string,
tokenId: number, tokenId: number,
reason?: string reason?: string
): Promise<EventJoinToken> { ): Promise<EventQrInvite> {
const options: RequestInit = { method: 'DELETE' }; const options: RequestInit = { method: 'DELETE' };
if (reason) { if (reason) {
options.headers = { 'Content-Type': 'application/json' }; options.headers = { 'Content-Type': 'application/json' };
@@ -754,7 +802,107 @@ export async function revokeEventJoinToken(
} }
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options); const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation'); const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
return normalizeJoinToken(data.data ?? {}); return normalizeQrInvite(data.data ?? {});
}
export async function updateEventQrInvite(
slug: string,
tokenId: number,
payload: {
label?: string | null;
expires_at?: string | null;
usage_limit?: number | null;
metadata?: Record<string, unknown> | null;
}
): Promise<EventQrInvite> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation');
return normalizeQrInvite(data.data ?? {});
}
export async function getEventToolkit(slug: string): Promise<EventToolkit> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`);
const json = await jsonOrThrow<Record<string, JsonValue>>(response, 'Failed to load toolkit');
const metrics = json.metrics ?? {};
const tasks = json.tasks ?? {};
const photos = json.photos ?? {};
const invites = json.invites ?? {};
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
? (photos as Record<string, JsonValue>).pending
: [];
const recentPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).recent)
? (photos as Record<string, JsonValue>).recent
: [];
const toolkit: EventToolkit = {
event: normalizeEvent(json.event ?? {}),
metrics: {
uploads_total: Number((metrics as JsonValue).uploads_total ?? 0),
uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0),
pending_photos: Number((metrics as JsonValue).pending_photos ?? 0),
active_invites: Number((metrics as JsonValue).active_invites ?? 0),
engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks',
},
tasks: {
summary: {
total: Number((tasks as JsonValue)?.summary?.total ?? 0),
completed: Number((tasks as JsonValue)?.summary?.completed ?? 0),
pending: Number((tasks as JsonValue)?.summary?.pending ?? 0),
},
items: Array.isArray((tasks as JsonValue)?.items)
? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({
id: Number(item?.id ?? 0),
title: String(item?.title ?? ''),
description: item?.description !== undefined && item?.description !== null ? String(item.description) : null,
is_completed: Boolean(item?.is_completed ?? false),
priority: item?.priority !== undefined ? String(item.priority) : null,
}))
: [],
},
photos: {
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
},
invites: {
summary: {
total: Number((invites as JsonValue)?.summary?.total ?? 0),
active: Number((invites as JsonValue)?.summary?.active ?? 0),
},
items: Array.isArray((invites as JsonValue)?.items)
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
: [],
},
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
};
return toolkit;
}
export async function submitTenantFeedback(payload: {
category: string;
sentiment?: 'positive' | 'neutral' | 'negative';
rating?: number | null;
title?: string | null;
message?: string | null;
event_slug?: string | null;
metadata?: Record<string, unknown> | null;
}): Promise<void> {
const response = await authorizedFetch('/api/v1/tenant/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await safeJson(response);
console.error('[API] Failed to submit feedback', response.status, body);
throw new Error('Failed to submit feedback');
}
} }
export type Package = { export type Package = {

View File

@@ -24,3 +24,4 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`); export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`); export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`); export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);

View File

@@ -51,7 +51,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`); const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
verifyState(callbackUrl.searchParams.get('state'), state); verifyState(callbackUrl.searchParams.get('state'), state);
const code = callbackUrl.searchParams.get('code'); const code = callbackUrl.searchParams.get('code');
@@ -115,22 +115,53 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
globalThis.fotospielDemoAuth = api; globalThis.fotospielDemoAuth = api;
} }
function requestAuthorization(url: string): Promise<URL> { function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); xhr.open('GET', url, true);
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = () => { xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) { if (xhr.readyState !== XMLHttpRequest.DONE) {
return; return;
} }
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location'); const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) { if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
if (responseUrl) { if (responseUrl) {
resolve(new URL(responseUrl, window.location.origin)); resolve(new URL(responseUrl, window.location.origin));
return; return;
} }
if (contentType.includes('application/json')) {
try {
const payload = JSON.parse(xhr.responseText ?? '{}') as {
code?: string;
state?: string | null;
redirect_url?: string | null;
};
const target = payload.redirect_url ?? fallbackRedirect;
if (!target) {
throw new Error('Authorize response missing redirect target');
}
const finalUrl = new URL(target, window.location.origin);
if (payload.code && !finalUrl.searchParams.has('code')) {
finalUrl.searchParams.set('code', payload.code);
}
if (payload.state && !finalUrl.searchParams.has('state')) {
finalUrl.searchParams.set('state', payload.state);
}
resolve(finalUrl);
return;
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
}
} }
reject(new Error(`Authorize failed with ${xhr.status}`)); reject(new Error(`Authorize failed with ${xhr.status}`));

View File

@@ -36,6 +36,36 @@
"lowCredits": "Auffüllen empfohlen" "lowCredits": "Auffüllen empfohlen"
} }
}, },
"readiness": {
"title": "Bereit für den Eventstart",
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
"pending": "Noch offen",
"complete": "Erledigt",
"items": {
"event": {
"title": "Event angelegt",
"hint": "Lege dein erstes Event an oder öffne dein jüngstes Event."
},
"tasks": {
"title": "Aufgaben kuratiert",
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
},
"qr": {
"title": "QR-Einladung erstellt",
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
},
"package": {
"title": "Paket aktiv",
"hint": "Wähle ein Paket, das zu eurem Umfang passt."
}
},
"actions": {
"createEvent": "Event erstellen",
"openTasks": "Tasks öffnen",
"openQr": "QR-Einladungen",
"openPackages": "Pakete ansehen"
}
},
"quickActions": { "quickActions": {
"title": "Schnellaktionen", "title": "Schnellaktionen",
"description": "Starte durch mit den wichtigsten Aktionen.", "description": "Starte durch mit den wichtigsten Aktionen.",

View File

@@ -147,7 +147,9 @@
"errors": { "errors": {
"missingSlug": "Kein Event-Slug angegeben.", "missingSlug": "Kein Event-Slug angegeben.",
"load": "Event-Tasks konnten nicht geladen werden.", "load": "Event-Tasks konnten nicht geladen werden.",
"assign": "Tasks konnten nicht zugewiesen werden." "assign": "Tasks konnten nicht zugewiesen werden.",
"photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.",
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden."
}, },
"alerts": { "alerts": {
"notFoundTitle": "Event nicht gefunden", "notFoundTitle": "Event nicht gefunden",
@@ -169,6 +171,147 @@
"medium": "Mittel", "medium": "Mittel",
"high": "Hoch", "high": "Hoch",
"urgent": "Dringend" "urgent": "Dringend"
},
"modes": {
"title": "Aufgaben & Foto-Modus",
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
"tasksHint": "Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.",
"photoOnly": "Foto-Modus",
"tasks": "Aufgaben aktiv",
"switchLabel": "Foto-Modus aktivieren",
"updating": "Einstellung wird gespeichert ..."
},
"toolkit": {
"titleFallback": "Event-Day Toolkit",
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Toolkit konnte nicht geladen werden.",
"feedbackFailed": "Feedback konnte nicht gesendet werden."
},
"actions": {
"backToEvent": "Zurück zum Event",
"moderate": "Fotos moderieren",
"manageTasks": "Tasks öffnen",
"refresh": "Aktualisieren"
},
"alerts": {
"errorTitle": "Fehler",
"attention": "Achtung",
"noTasks": "Noch keine Aufgaben zugewiesen aktiviere ein Paket oder lege Aufgaben fest.",
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.",
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pendingPhotos": "Unmoderierte Fotos",
"activeInvites": "Aktive Einladungen",
"engagementMode": "Modus",
"modePhotoOnly": "Foto-Modus",
"modeTasks": "Aufgaben"
},
"pending": {
"title": "Wartende Fotos",
"subtitle": "Moderationsempfehlung für neue Uploads.",
"cta": "Zur Moderation",
"empty": "Aktuell warten keine Fotos auf Freigabe.",
"unknownUploader": "Unbekannter Gast",
"uploadedAt": "Hochgeladen:",
"statusPending": "Status: Prüfung ausstehend"
},
"invites": {
"title": "QR-Einladungen",
"subtitle": "Aktive Links und Layouts im Blick behalten.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Einladungen erstellt.",
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"manage": "Einladungen verwalten"
},
"tasks": {
"title": "Aktive Aufgaben",
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
"summary": "{{completed}} von {{total}} erledigt",
"empty": "Noch keine Aufgaben zugewiesen.",
"manage": "Tasks verwalten",
"completed": "Erledigt",
"open": "Offen"
},
"recent": {
"title": "Neueste Uploads",
"subtitle": "Ein Blick auf die letzten Fotos der Gäste.",
"empty": "Noch keine freigegebenen Fotos vorhanden."
},
"feedback": {
"title": "Wie hilfreich ist dieses Toolkit?",
"subtitle": "Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.",
"positive": "Hilfreich",
"neutral": "Ganz okay",
"negative": "Verbesserungsbedarf",
"placeholder": "Erzähle uns kurz, was dir gefallen hat oder was fehlt …",
"disclaimer": "Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.",
"submit": "Feedback senden",
"thanksTitle": "Danke!",
"thanksDescription": "Wir haben dein Feedback erhalten.",
"badge": "Angepasst"
}
},
"customizer": {
"title": "QR-Einladung anpassen",
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
"layout": "Layout",
"selectLayout": "Layout auswählen",
"headline": "Überschrift",
"subtitle": "Unterzeile",
"descriptionLabel": "Beschreibung",
"badgeLabel": "Badge",
"instructionsHeading": "Anleitungstitel",
"instructionsLabel": "Hinweistexte",
"addInstruction": "Hinweis hinzufügen",
"removeInstruction": "Entfernen",
"linkHeading": "Link-Titel",
"linkLabel": "Link",
"ctaLabel": "Call-to-Action",
"colors": {
"accent": "Akzentfarbe",
"text": "Textfarbe",
"background": "Hintergrund",
"secondary": "Sekundärfarbe",
"badge": "Badge-Farbe"
},
"logo": {
"label": "Logo",
"hint": "PNG oder SVG, max. 1 MB. Wird oben rechts platziert.",
"remove": "Logo entfernen"
},
"preview": {
"title": "Vorschau",
"hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten."
},
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
"reset": "Zurücksetzen"
},
"badge": "Angepasst",
"actionLabel": "Layout anpassen",
"errors": {
"logoTooLarge": "Das Logo darf maximal 1 MB groß sein.",
"noLayout": "Bitte wähle ein Layout aus."
},
"defaults": {
"badgeLabel": "Digitale Gästebox",
"instructionsHeading": "So funktioniert's",
"linkHeading": "Alternative zum Einscannen",
"ctaLabel": "Scan mich & starte direkt",
"instructions": [
"QR-Code scannen",
"Profil anlegen",
"Fotos teilen"
]
}
} }
}, },
"collections": { "collections": {
@@ -316,4 +459,4 @@
} }
} }
} }
} }

View File

@@ -36,6 +36,36 @@
"lowCredits": "Top up recommended" "lowCredits": "Top up recommended"
} }
}, },
"readiness": {
"title": "Ready for event day",
"description": "Complete these steps so guests can join without friction.",
"pending": "Pending",
"complete": "Done",
"items": {
"event": {
"title": "Event created",
"hint": "Create your first event or open the most recent one."
},
"tasks": {
"title": "Tasks curated",
"hint": "Assign fitting tasks or enable the photo-only mode."
},
"qr": {
"title": "QR invite live",
"hint": "Create a QR invite and download the print layouts."
},
"package": {
"title": "Package active",
"hint": "Pick the package that matches your scope."
}
},
"actions": {
"createEvent": "Create event",
"openTasks": "Open tasks",
"openQr": "QR invites",
"openPackages": "View packages"
}
},
"quickActions": { "quickActions": {
"title": "Quick actions", "title": "Quick actions",
"description": "Jump straight to the most important actions.", "description": "Jump straight to the most important actions.",

View File

@@ -147,7 +147,9 @@
"errors": { "errors": {
"missingSlug": "No event slug provided.", "missingSlug": "No event slug provided.",
"load": "Event tasks could not be loaded.", "load": "Event tasks could not be loaded.",
"assign": "Tasks could not be assigned." "assign": "Tasks could not be assigned.",
"photoOnlyEnable": "Photo-only mode could not be enabled.",
"photoOnlyDisable": "Photo-only mode could not be disabled."
}, },
"alerts": { "alerts": {
"notFoundTitle": "Event not found", "notFoundTitle": "Event not found",
@@ -169,6 +171,147 @@
"medium": "Medium", "medium": "Medium",
"high": "High", "high": "High",
"urgent": "Urgent" "urgent": "Urgent"
},
"modes": {
"title": "Tasks & photo mode",
"photoOnlyHint": "Photo-only mode is active. Guests can upload photos but wont see tasks.",
"tasksHint": "Tasks are visible in the guest app. Switch to photo-only for uploads without prompts.",
"photoOnly": "Photo-only",
"tasks": "Tasks active",
"switchLabel": "Enable photo-only mode",
"updating": "Saving setting ..."
},
"toolkit": {
"titleFallback": "Event-Day Toolkit",
"subtitle": "Stay on top of uploads, tasks, and invites while your event is live.",
"errors": {
"missingSlug": "No event slug provided.",
"loadFailed": "Toolkit could not be loaded.",
"feedbackFailed": "Feedback could not be sent."
},
"actions": {
"backToEvent": "Back to event",
"moderate": "Moderate photos",
"manageTasks": "Open tasks",
"refresh": "Refresh"
},
"alerts": {
"errorTitle": "Error",
"attention": "Heads-up",
"noTasks": "No tasks assigned yet pick a package or curate prompts.",
"noInvites": "There are no active QR invites. Create one to welcome guests.",
"pendingPhotos": "Photos are waiting for moderation. Review uploads before publishing."
},
"metrics": {
"uploadsTotal": "Total uploads",
"uploads24h": "Uploads (24h)",
"pendingPhotos": "Pending moderation",
"activeInvites": "Active invites",
"engagementMode": "Mode",
"modePhotoOnly": "Photo mode",
"modeTasks": "Tasks"
},
"pending": {
"title": "Waiting photos",
"subtitle": "Moderation suggestions for new uploads.",
"cta": "Go to moderation",
"empty": "No photos waiting for review right now.",
"unknownUploader": "Unknown guest",
"uploadedAt": "Uploaded:",
"statusPending": "Status: awaiting review"
},
"invites": {
"title": "QR invites",
"subtitle": "Keep an eye on links and brandable layouts.",
"activeCount": "{{count}} active",
"totalCount": "{{count}} total",
"empty": "No QR invites yet.",
"statusActive": "Active",
"statusInactive": "Inactive",
"manage": "Manage invites"
},
"tasks": {
"title": "Active tasks",
"subtitle": "Motivate guests with clear prompts and highlights.",
"summary": "{{completed}} of {{total}} done",
"empty": "No tasks assigned yet.",
"manage": "Manage tasks",
"completed": "Done",
"open": "Open"
},
"recent": {
"title": "Latest uploads",
"subtitle": "A quick glance at freshly approved photos.",
"empty": "No approved photos yet."
},
"feedback": {
"title": "How helpful is this toolkit?",
"subtitle": "Your input helps us fine-tune the event-day experience.",
"positive": "Helpful",
"neutral": "Okay",
"negative": "Needs work",
"placeholder": "Let us know what worked well or what youre missing …",
"disclaimer": "Well keep your feedback private and use it to improve the product.",
"submit": "Send feedback",
"thanksTitle": "Thank you!",
"thanksDescription": "Weve received your feedback.",
"badge": "Custom"
}
},
"customizer": {
"title": "Customize QR invite",
"description": "Adjust layout, texts, colors, and logo for your printable invite.",
"layout": "Layout",
"selectLayout": "Select layout",
"headline": "Headline",
"subtitle": "Sub headline",
"descriptionLabel": "Description",
"badgeLabel": "Badge",
"instructionsHeading": "Instructions heading",
"instructionsLabel": "Hints",
"addInstruction": "Add hint",
"removeInstruction": "Remove",
"linkHeading": "Link title",
"linkLabel": "Link",
"ctaLabel": "Call to action",
"colors": {
"accent": "Accent colour",
"text": "Text colour",
"background": "Background",
"secondary": "Secondary colour",
"badge": "Badge colour"
},
"logo": {
"label": "Logo",
"hint": "PNG or SVG, max. 1 MB. Appears in the top right corner.",
"remove": "Remove logo"
},
"preview": {
"title": "Preview",
"hint": "Visual reference for colours and texts. Save to generate new PDFs/SVGs."
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"reset": "Reset"
},
"badge": "Custom",
"actionLabel": "Customize layout",
"errors": {
"logoTooLarge": "Logo must not exceed 1 MB.",
"noLayout": "Please select a layout."
},
"defaults": {
"badgeLabel": "Digital guest box",
"instructionsHeading": "How it works",
"linkHeading": "Alternative link",
"ctaLabel": "Scan and get started",
"instructions": [
"Scan the QR code",
"Create your profile",
"Share your photos"
]
}
} }
}, },
"collections": { "collections": {
@@ -316,4 +459,4 @@
} }
} }
} }
} }

View File

@@ -1,7 +1,20 @@
import React from 'react'; import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react'; import {
CalendarDays,
Camera,
Sparkles,
Users,
Plus,
Settings,
CheckCircle2,
Circle,
QrCode,
ClipboardList,
Package as PackageIcon,
Loader2,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -14,6 +27,8 @@ import {
getDashboardSummary, getDashboardSummary,
getEvents, getEvents,
getTenantPackagesOverview, getTenantPackagesOverview,
getEventTasks,
getEventQrInvites,
TenantEvent, TenantEvent,
TenantPackageSummary, TenantPackageSummary,
} from '../api'; } from '../api';
@@ -23,6 +38,7 @@ import {
adminPath, adminPath,
ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_TASKS_PATH, ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH, ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH, ADMIN_SETTINGS_PATH,
@@ -39,6 +55,16 @@ interface DashboardState {
errorKey: string | null; errorKey: string | null;
} }
type ReadinessState = {
hasEvent: boolean;
hasTasks: boolean;
hasQrInvites: boolean;
hasPackage: boolean;
primaryEventSlug: string | null;
primaryEventName: string | null;
loading: boolean;
};
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -66,6 +92,16 @@ export default function DashboardPage() {
errorKey: null, errorKey: null,
}); });
const [readiness, setReadiness] = React.useState<ReadinessState>({
hasEvent: false,
hasTasks: false,
hasQrInvites: false,
hasPackage: false,
primaryEventSlug: null,
primaryEventName: null,
loading: false,
});
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@@ -81,14 +117,56 @@ export default function DashboardPage() {
} }
const fallbackSummary = buildSummaryFallback(events, packages.activePackage); const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
setState({ setReadiness({
summary: summary ?? fallbackSummary, hasEvent: events.length > 0,
events, hasTasks: false,
activePackage: packages.activePackage, hasQrInvites: false,
hasPackage: Boolean(packages.activePackage),
primaryEventSlug: primaryEvent?.slug ?? null,
primaryEventName,
loading: Boolean(primaryEvent),
});
setState({
summary: summary ?? fallbackSummary,
events,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
});
if (primaryEvent) {
try {
const [eventTasks, qrInvites] = await Promise.all([
getEventTasks(primaryEvent.id, 1),
getEventQrInvites(primaryEvent.slug),
]);
if (!cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: (eventTasks.data ?? []).length > 0,
hasQrInvites: qrInvites.length > 0,
loading: false,
}));
}
} catch (readinessError) {
if (!cancelled) {
console.warn('Failed to load readiness checklist', readinessError);
setReadiness((prev) => ({ ...prev, loading: false }));
}
}
} else if (!cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: false,
hasQrInvites: false,
loading: false, loading: false,
errorKey: null, }));
}); }
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState((prev) => ({ setState((prev) => ({
@@ -271,6 +349,52 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
<ReadinessCard
readiness={readiness}
labels={{
title: translate('readiness.title'),
description: translate('readiness.description'),
pending: translate('readiness.pending'),
complete: translate('readiness.complete'),
items: {
event: {
title: translate('readiness.items.event.title'),
hint: translate('readiness.items.event.hint'),
},
tasks: {
title: translate('readiness.items.tasks.title'),
hint: translate('readiness.items.tasks.hint'),
},
qr: {
title: translate('readiness.items.qr.title'),
hint: translate('readiness.items.qr.hint'),
},
package: {
title: translate('readiness.items.package.title'),
hint: translate('readiness.items.package.hint'),
},
},
actions: {
createEvent: translate('readiness.actions.createEvent'),
openTasks: translate('readiness.actions.openTasks'),
openQr: translate('readiness.actions.openQr'),
openPackages: translate('readiness.actions.openPackages'),
},
}}
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
onOpenTasks={() =>
readiness.primaryEventSlug
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
: navigate(ADMIN_TASKS_PATH)
}
onOpenQr={() =>
readiness.primaryEventSlug
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
: navigate(ADMIN_EVENTS_PATH)
}
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
/>
<Card className="border-0 bg-brand-card shadow-brand-primary"> <Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div> <div>
@@ -315,6 +439,27 @@ export default function DashboardPage() {
); );
} }
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
if (typeof name.de === 'string' && name.de.trim().length > 0) {
return name.de;
}
if (typeof name.en === 'string' && name.en.trim().length > 0) {
return name.en;
}
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (typeof first === 'string') {
return first;
}
}
return fallbackSlug || 'Event';
}
function buildSummaryFallback( function buildSummaryFallback(
events: TenantEvent[], events: TenantEvent[],
activePackage: TenantPackageSummary | null activePackage: TenantPackageSummary | null
@@ -353,6 +498,170 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
.slice(0, 4); .slice(0, 4);
} }
type ReadinessLabels = {
title: string;
description: string;
pending: string;
complete: string;
items: {
event: { title: string; hint: string };
tasks: { title: string; hint: string };
qr: { title: string; hint: string };
package: { title: string; hint: string };
};
actions: {
createEvent: string;
openTasks: string;
openQr: string;
openPackages: string;
};
};
function ReadinessCard({
readiness,
labels,
onCreateEvent,
onOpenTasks,
onOpenQr,
onOpenPackages,
}: {
readiness: ReadinessState;
labels: ReadinessLabels;
onCreateEvent: () => void;
onOpenTasks: () => void;
onOpenQr: () => void;
onOpenPackages: () => void;
}) {
const checklistItems = [
{
key: 'event',
icon: <CalendarDays className="h-5 w-5" />,
completed: readiness.hasEvent,
label: labels.items.event.title,
hint: labels.items.event.hint,
actionLabel: labels.actions.createEvent,
onAction: onCreateEvent,
showAction: !readiness.hasEvent,
},
{
key: 'tasks',
icon: <ClipboardList className="h-5 w-5" />,
completed: readiness.hasTasks,
label: labels.items.tasks.title,
hint: labels.items.tasks.hint,
actionLabel: labels.actions.openTasks,
onAction: onOpenTasks,
showAction: readiness.hasEvent && !readiness.hasTasks,
},
{
key: 'qr',
icon: <QrCode className="h-5 w-5" />,
completed: readiness.hasQrInvites,
label: labels.items.qr.title,
hint: labels.items.qr.hint,
actionLabel: labels.actions.openQr,
onAction: onOpenQr,
showAction: readiness.hasEvent && !readiness.hasQrInvites,
},
{
key: 'package',
icon: <PackageIcon className="h-5 w-5" />,
completed: readiness.hasPackage,
label: labels.items.package.title,
hint: labels.items.package.hint,
actionLabel: labels.actions.openPackages,
onAction: onOpenPackages,
showAction: !readiness.hasPackage,
},
] as const;
const activeEventName = readiness.primaryEventName;
return (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-2">
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
{activeEventName ? (
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
{activeEventName}
</p>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{readiness.loading ? (
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.pending}
</div>
) : (
checklistItems.map((item) => (
<ChecklistRow
key={item.key}
icon={item.icon}
label={item.label}
hint={item.hint}
completed={item.completed}
status={{ complete: labels.complete, pending: labels.pending }}
action={
item.showAction
? {
label: item.actionLabel,
onClick: item.onAction,
disabled:
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
}
: undefined
}
/>
))
)}
</CardContent>
</Card>
);
}
function ChecklistRow({
icon,
label,
hint,
completed,
status,
action,
}: {
icon: React.ReactNode;
label: string;
hint: string;
completed: boolean;
status: { complete: string; pending: string };
action?: { label: string; onClick: () => void; disabled?: boolean };
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{label}</p>
<p className="text-xs text-slate-600">{hint}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{completed ? status.complete : status.pending}
</span>
{action ? (
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
{action.label}
</Button>
) : null}
</div>
</div>
);
}
function StatCard({ function StatCard({
label, label,
value, value,

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react'; import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -8,17 +9,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
createInviteLink, createQrInvite,
EventJoinToken, EventQrInvite,
EventJoinTokenLayout, EventQrInviteLayout,
EventStats as TenantEventStats, EventStats as TenantEventStats,
getEvent, getEvent,
getEventJoinTokens, getEventQrInvites,
getEventStats, getEventStats,
TenantEvent, TenantEvent,
toggleEvent, toggleEvent,
revokeEventJoinToken, revokeEventQrInvite,
updateEventQrInvite,
} from '../api'; } from '../api';
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { import {
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
@@ -26,12 +29,13 @@ 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_TOOLKIT_PATH,
} from '../constants'; } from '../constants';
interface State { interface State {
event: TenantEvent | null; event: TenantEvent | null;
stats: TenantEventStats | null; stats: TenantEventStats | null;
tokens: EventJoinToken[]; invites: EventQrInvite[];
inviteLink: string | null; inviteLink: string | null;
error: string | null; error: string | null;
loading: boolean; loading: boolean;
@@ -47,14 +51,16 @@ export default function EventDetailPage() {
const [state, setState] = React.useState<State>({ const [state, setState] = React.useState<State>({
event: null, event: null,
stats: null, stats: null,
tokens: [], invites: [],
inviteLink: null, inviteLink: null,
error: null, error: null,
loading: true, loading: true,
busy: false, busy: false,
}); });
const [creatingToken, setCreatingToken] = React.useState(false); const [creatingInvite, setCreatingInvite] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null); const [revokingId, setRevokingId] = React.useState<number | null>(null);
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -64,22 +70,22 @@ export default function EventDetailPage() {
setState((prev) => ({ ...prev, loading: true, error: null })); setState((prev) => ({ ...prev, loading: true, error: null }));
try { try {
const [eventData, statsData, joinTokens] = await Promise.all([ const [eventData, statsData, qrInvites] = await Promise.all([
getEvent(slug), getEvent(slug),
getEventStats(slug), getEventStats(slug),
getEventJoinTokens(slug), getEventQrInvites(slug),
]); ]);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
event: eventData, event: eventData,
stats: statsData, stats: statsData,
tokens: joinTokens, invites: qrInvites,
loading: false, loading: false,
inviteLink: prev.inviteLink, inviteLink: prev.inviteLink,
})); }));
} catch (err) { } catch (err) {
if (isAuthError(err)) return; if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] })); setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
} }
}, [slug]); }, [slug]);
@@ -108,58 +114,131 @@ export default function EventDetailPage() {
} }
async function handleInvite() { async function handleInvite() {
if (!slug || creatingToken) return; if (!slug || creatingInvite) return;
setCreatingToken(true); setCreatingInvite(true);
setState((prev) => ({ ...prev, error: null })); setState((prev) => ({ ...prev, error: null }));
try { try {
const token = await createInviteLink(slug); const invite = await createQrInvite(slug);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
inviteLink: token.url, inviteLink: invite.url,
tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)], invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
})); }));
try { try {
await navigator.clipboard.writeText(token.url); await navigator.clipboard.writeText(invite.url);
} catch { } catch {
// clipboard may be unavailable, ignore silently // clipboard may be unavailable, ignore silently
} }
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' })); setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
} }
} }
setCreatingToken(false); setCreatingInvite(false);
} }
async function handleCopy(token: EventJoinToken) { async function handleCopy(invite: EventQrInvite) {
try { try {
await navigator.clipboard.writeText(token.url); await navigator.clipboard.writeText(invite.url);
setState((prev) => ({ ...prev, inviteLink: token.url })); setState((prev) => ({ ...prev, inviteLink: invite.url }));
} catch (err) { } catch (err) {
console.warn('Clipboard copy failed', err); console.warn('Clipboard copy failed', err);
} }
} }
async function handleRevoke(token: EventJoinToken) { async function handleRevoke(invite: EventQrInvite) {
if (!slug || token.revoked_at) return; if (!slug || invite.revoked_at) return;
setRevokingId(token.id); setRevokingId(invite.id);
setState((prev) => ({ ...prev, error: null })); setState((prev) => ({ ...prev, error: null }));
try { try {
const updated = await revokeEventJoinToken(slug, token.id); const updated = await revokeEventQrInvite(slug, invite.id);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)), invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
})); }));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' })); setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
} }
} finally { } finally {
setRevokingId(null); setRevokingId(null);
} }
} }
const { event, stats, tokens, inviteLink, error, loading, busy } = state; function openCustomizer(invite: EventQrInvite) {
setState((prev) => ({ ...prev, error: null }));
setCustomizingInvite(invite);
}
function closeCustomizer() {
if (customizerSaving) {
return;
}
setCustomizingInvite(null);
}
async function handleApplyCustomization(customization: QrLayoutCustomization) {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: customization,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' }));
}
setCustomizerSaving(false);
}
}
async function handleResetCustomization() {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: null,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
}
setCustomizerSaving(false);
}
}
const { event, stats, invites, inviteLink, error, loading, busy } = state;
const eventDisplayName = event ? renderName(event.name) : '';
const currentCustomization = React.useMemo(() => {
if (!customizingInvite) {
return null;
}
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
const raw = metadata?.layout_customization;
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [customizingInvite]);
const actions = ( const actions = (
<> <>
@@ -193,6 +272,13 @@ export default function EventDetailPage() {
> >
Tasks Tasks
</Button> </Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
>
Event-Day Toolkit
</Button>
</> </>
)} )}
</> </>
@@ -261,33 +347,33 @@ export default function EventDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60"> <Card id="qr-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900"> <CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks &amp; QR-Layouts <Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen &amp; Drucklayouts
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <CardDescription className="text-sm text-slate-600">
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen inklusive Branding und Anleitungen
Vokabular. zum Ausdrucken herunter.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700"> <CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800"> <div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p> <p>
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
kannst du jederzeit erneuern oder deaktivieren. jederzeit erneuern oder deaktivieren.
</p> </p>
{tokens.length > 0 && ( {invites.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600"> <p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '} Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
{tokens.length} {invites.length}
</p> </p>
)} )}
</div> </div>
<Button onClick={handleInvite} disabled={creatingToken} className="w-full"> <Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />} {creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladung erstellen QR-Einladung erstellen
</Button> </Button>
{inviteLink && ( {inviteLink && (
@@ -297,20 +383,22 @@ export default function EventDetailPage() {
)} )}
<div className="space-y-3"> <div className="space-y-3">
{tokens.length > 0 ? ( {invites.length > 0 ? (
tokens.map((token) => ( invites.map((invite) => (
<InvitationCard <InvitationCard
key={token.id} key={invite.id}
token={token} invite={invite}
onCopy={() => handleCopy(token)} onCopy={() => handleCopy(invite)}
onRevoke={() => handleRevoke(token)} onRevoke={() => handleRevoke(invite)}
revoking={revokingId === token.id} revoking={revokingId === invite.id}
onCustomize={() => openCustomizer(invite)}
eventName={eventDisplayName}
/> />
)) ))
) : ( ) : (
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500"> <div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
herunterzuladen und zu teilen. und zu teilen.
</div> </div>
)} )}
</div> </div>
@@ -340,6 +428,18 @@ export default function EventDetailPage() {
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription> <AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
</Alert> </Alert>
)} )}
<QrInviteCustomizationDialog
open={Boolean(customizingInvite)}
onClose={closeCustomizer}
onSubmit={handleApplyCustomization}
onReset={handleResetCustomization}
saving={customizerSaving}
inviteUrl={customizingInvite?.url ?? ''}
eventName={eventDisplayName}
layouts={customizingInvite?.layouts ?? []}
initialCustomization={currentCustomization}
/>
</AdminLayout> </AdminLayout>
); );
} }
@@ -373,21 +473,29 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
} }
function InvitationCard({ function InvitationCard({
token, invite,
onCopy, onCopy,
onRevoke, onRevoke,
revoking, revoking,
onCustomize,
eventName,
}: { }: {
token: EventJoinToken; invite: EventQrInvite;
onCopy: () => void; onCopy: () => void;
onRevoke: () => void; onRevoke: () => void;
revoking: boolean; revoking: boolean;
onCustomize: () => void;
eventName: string;
}) { }) {
const status = getTokenStatus(token); const { t } = useTranslation('management');
const layouts = Array.isArray(token.layouts) ? token.layouts : []; const status = getInviteStatus(invite);
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`; const layouts = Array.isArray(invite.layouts) ? invite.layouts : [];
const metadata = (token.metadata ?? {}) as Record<string, unknown>; const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
const isAutoGenerated = Boolean(metadata.auto_generated); const isAutoGenerated = Boolean(metadata.auto_generated);
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null);
const hasCustomization = customization ? Object.keys(customization).length > 0 : false;
const statusClassname = const statusClassname =
status === 'Aktiv' status === 'Aktiv'
@@ -401,17 +509,22 @@ function InvitationCard({
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span> <span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span> <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
{isAutoGenerated ? ( {isAutoGenerated ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700"> <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Standard Standard
</span> </span>
) : null} ) : null}
{hasCustomization ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
{t('tasks.customizer.badge', 'Angepasst')}
</span>
) : 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-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700"> <span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
{token.url} {invite.url}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@@ -425,19 +538,28 @@ function InvitationCard({
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Nutzung: {usageLabel}</span> <span>Nutzung: {usageLabel}</span>
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null} {invite.expires_at ? <span>Gültig bis {formatDateTime(invite.expires_at)}</span> : null}
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null} {invite.created_at ? <span>Erstellt am {formatDateTime(invite.created_at)}</span> : null}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{token.layouts_url ? ( <Button
variant={hasCustomization ? 'default' : 'outline'}
size="sm"
onClick={onCustomize}
className={hasCustomization ? 'bg-amber-500 text-white hover:bg-amber-500/90 border-amber-200' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
>
<Sparkles className="mr-1 h-3 w-3" />
{t('tasks.customizer.actionLabel', 'Layout anpassen')}
</Button>
{invite.layouts_url ? (
<Button <Button
asChild asChild
size="sm" size="sm"
variant="outline" variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100" className="border-amber-200 text-amber-700 hover:bg-amber-100"
> >
<a href={token.layouts_url} target="_blank" rel="noreferrer"> <a href={invite.layouts_url} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" /> <Download className="mr-1 h-3 w-3" />
Layout-Übersicht Layout-Übersicht
</a> </a>
@@ -447,7 +569,7 @@ function InvitationCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onRevoke} onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active} disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50" className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
> >
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'} {revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
@@ -458,10 +580,16 @@ function InvitationCard({
{layouts.length > 0 ? ( {layouts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{layouts.map((layout) => ( {layouts.map((layout) => (
<LayoutPreviewCard key={layout.id} layout={layout} /> <LayoutPreviewCard
key={layout.id}
layout={layout}
customization={layout.id === preferredLayoutId ? customization : null}
selected={layout.id === preferredLayoutId}
eventName={eventName}
/>
))} ))}
</div> </div>
) : token.layouts_url ? ( ) : invite.layouts_url ? (
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800"> <div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden. Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
</div> </div>
@@ -470,38 +598,63 @@ function InvitationCard({
); );
} }
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) { function LayoutPreviewCard({
const gradient = layout.preview?.background_gradient; layout,
customization,
selected,
eventName,
}: {
layout: EventQrInviteLayout;
customization: QrLayoutCustomization | null;
selected: boolean;
eventName: string;
}) {
const gradient = customization?.background_gradient ?? layout.preview?.background_gradient;
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : []; const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
const gradientStyle = stops.length const gradientStyle = stops.length
? { ? {
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`, backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
} }
: { : {
backgroundColor: layout.preview?.background ?? '#F8FAFC', backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
}; };
const textColor = layout.preview?.text ?? '#0F172A'; const textColor = customization?.text_color ?? layout.preview?.text ?? '#0F172A';
const badgeColor = customization?.badge_color ?? customization?.accent_color ?? layout.preview?.accent ?? '#0EA5E9';
const formats = Array.isArray(layout.formats) ? layout.formats : []; const formats = Array.isArray(layout.formats) ? layout.formats : [];
const headline = customization?.headline ?? layout.name ?? eventName;
const subtitle = customization?.subtitle ?? layout.subtitle ?? '';
const description = customization?.description ?? layout.description ?? '';
const instructions = customization?.instructions ?? [];
return ( return (
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm"> <div
className={`overflow-hidden rounded-xl border bg-white shadow-sm ${selected ? 'border-amber-300 ring-2 ring-amber-200' : 'border-amber-100'}`}
>
<div className="relative h-28"> <div className="relative h-28">
<div className="absolute inset-0" style={gradientStyle} /> <div className="absolute inset-0" style={gradientStyle} />
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}> <div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"> <span
className="w-fit rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
style={{ backgroundColor: badgeColor, color: '#ffffff' }}
>
QR-Layout QR-Layout
</span> </span>
<div> <div>
<div className="text-sm font-semibold leading-tight">{layout.name}</div> <div className="text-sm font-semibold leading-tight">{headline}</div>
{layout.subtitle ? ( {subtitle ? <div className="text-[11px] opacity-80">{subtitle}</div> : null}
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
) : null}
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-3 p-3"> <div className="space-y-3 p-3">
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null} {description ? <p className="text-xs text-slate-600">{description}</p> : null}
{instructions.length > 0 ? (
<ul className="space-y-1 text-[11px] text-slate-500">
{instructions.slice(0, 3).map((item, index) => (
<li key={`${layout.id}-instruction-${index}`}> {item}</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{formats.map((format) => { {formats.map((format) => {
const key = String(format ?? '').toLowerCase(); const key = String(format ?? '').toLowerCase();
@@ -557,15 +710,15 @@ function formatDateTime(iso: string | null): string {
}); });
} }
function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' { function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (token.revoked_at) return 'Deaktiviert'; if (invite.revoked_at) return 'Deaktiviert';
if (token.expires_at) { if (invite.expires_at) {
const expiry = new Date(token.expires_at); const expiry = new Date(invite.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) { if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen'; return 'Abgelaufen';
} }
} }
return token.is_active ? 'Aktiv' : 'Deaktiviert'; return invite.is_active ? 'Aktiv' : 'Deaktiviert';
} }
function renderName(name: TenantEvent['name']): string { function renderName(name: TenantEvent['name']): string {

View File

@@ -8,6 +8,7 @@ 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 { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
@@ -15,6 +16,7 @@ import {
getEvent, getEvent,
getEventTasks, getEventTasks,
getTasks, getTasks,
updateEvent,
TenantEvent, TenantEvent,
TenantTask, TenantTask,
} from '../api'; } from '../api';
@@ -34,6 +36,7 @@ export default function EventTasksPage() {
const [selected, setSelected] = React.useState<number[]>([]); const [selected, setSelected] = React.useState<number[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [modeSaving, setModeSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const statusLabels = React.useMemo( const statusLabels = React.useMemo(
@@ -101,6 +104,35 @@ export default function EventTasksPage() {
} }
} }
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
async function handleModeChange(checked: boolean) {
if (!event || !slug) return;
setModeSaving(true);
setError(null);
try {
const nextMode = checked ? 'photo_only' : 'tasks';
const updated = await updateEvent(slug, {
settings: {
engagement_mode: nextMode,
},
});
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(
checked
? t('management.tasks.errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
: t('management.tasks.errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
);
}
} finally {
setModeSaving(false);
}
}
const actions = ( const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600"> <Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -138,6 +170,45 @@ export default function EventTasksPage() {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status, status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})} })}
</CardDescription> </CardDescription>
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
</p>
<p className="text-xs text-slate-600">
{isPhotoOnlyMode
? t(
'management.tasks.modes.photoOnlyHint',
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
)
: t(
'management.tasks.modes.tasksHint',
'Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.',
)}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs uppercase tracking-wide text-slate-500">
{isPhotoOnlyMode
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
</span>
<Switch
checked={isPhotoOnlyMode}
onCheckedChange={handleModeChange}
disabled={modeSaving}
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
/>
</div>
</div>
{modeSaving ? (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
</div>
) : null}
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2"> <CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3"> <section className="space-y-3">
@@ -182,6 +253,7 @@ export default function EventTasksPage() {
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id) checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
) )
} }
disabled={isPhotoOnlyMode}
/> />
<div> <div>
<p className="text-sm font-medium text-slate-900">{task.title}</p> <p className="text-sm font-medium text-slate-900">{task.title}</p>
@@ -191,10 +263,13 @@ export default function EventTasksPage() {
)) ))
)} )}
</div> </div>
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}> <Button
onClick={() => void handleAssign()}
disabled={saving || selected.length === 0 || isPhotoOnlyMode}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
</Button> </Button>
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
</> </>

View File

@@ -0,0 +1,563 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Camera,
CheckCircle2,
Circle,
Loader2,
MessageSquare,
RefreshCw,
Send,
Sparkles,
ThumbsDown,
ThumbsUp,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { AdminLayout } from '../components/AdminLayout';
import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
import {
EventToolkit,
EventToolkitTask,
getEventToolkit,
submitTenantFeedback,
TenantPhoto,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
interface ToolkitState {
loading: boolean;
error: string | null;
data: EventToolkit | null;
}
export default function EventToolkitPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const toolkit = await getEventToolkit(slug);
setState({ loading: false, error: null, data: toolkit });
} catch (error) {
if (!isAuthError(error)) {
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const { data, loading } = state;
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
const actions = (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
<ArrowLeft className="h-4 w-4" />
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
<Camera className="h-4 w-4" />
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
<Sparkles className="h-4 w-4" />
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('toolkit.actions.refresh', 'Aktualisieren')}
</Button>
</div>
);
return (
<AdminLayout
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
actions={actions}
>
{state.error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
{loading ? (
<ToolkitSkeleton />
) : data ? (
<div className="space-y-6">
<AlertList alerts={data.alerts} />
<MetricsGrid metrics={data.metrics} />
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<PendingPhotosCard
photos={data.photos.pending}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
/>
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
<RecentUploadsCard photos={data.photos.recent} />
</div>
<FeedbackCard
submitting={feedbackSubmitting}
submitted={feedbackSubmitted}
sentiment={feedbackSentiment}
message={feedbackMessage}
onSelectSentiment={setFeedbackSentiment}
onMessageChange={setFeedbackMessage}
onSubmit={async () => {
if (!slug) return;
setFeedbackSubmitting(true);
try {
await submitTenantFeedback({
category: 'event_toolkit',
sentiment: feedbackSentiment ?? undefined,
message: feedbackMessage ? feedbackMessage.trim() : undefined,
event_slug: slug,
});
setFeedbackSentiment(null);
setFeedbackMessage('');
setFeedbackSubmitted(true);
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
}));
}
} finally {
setFeedbackSubmitting(false);
}
}}
/>
</div>
) : null}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
if (locale && name[locale]) {
return name[locale];
}
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
if (short && name[short]) {
return name[short];
}
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function AlertList({ alerts }: { alerts: string[] }) {
const { t } = useTranslation('management');
if (!alerts.length) {
return null;
}
const alertMap: Record<string, string> = {
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
};
return (
<div className="space-y-2">
{alerts.map((code) => (
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
</Alert>
))}
</div>
);
}
function MetricsGrid({
metrics,
}: {
metrics: EventToolkit['metrics'];
}) {
const { t } = useTranslation('management');
const cards = [
{
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics.uploads_total,
},
{
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
value: metrics.uploads_24h,
},
{
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
value: metrics.pending_photos,
},
{
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics.active_invites,
},
{
label: t('toolkit.metrics.engagementMode', 'Modus'),
value:
metrics.engagement_mode === 'photo_only'
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
},
];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{cards.map((card) => (
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
<CardContent className="space-y-1 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
</CardContent>
</Card>
))}
</div>
);
}
function PendingPhotosCard({
photos,
navigateToModeration,
}: {
photos: TenantPhoto[];
navigateToModeration: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Camera className="h-5 w-5 text-amber-500" />
{t('toolkit.pending.title', 'Wartende Fotos')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
</CardDescription>
</div>
<Button variant="outline" onClick={navigateToModeration}>
{t('toolkit.pending.cta', 'Zur Moderation')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{photos.map((photo) => (
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<img
src={photo.thumbnail_url}
alt={photo.filename}
className="h-16 w-16 rounded-lg object-cover"
/>
<div className="space-y-1 text-xs text-slate-600">
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function InviteSummary({
invites,
navigateToEvent,
}: {
invites: EventToolkit['invites'];
navigateToEvent: () => void;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('toolkit.invites.title', 'QR-Einladungen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-xs text-slate-600">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
</Badge>
</div>
{invites.items.length === 0 ? (
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
) : (
<ul className="space-y-2">
{invites.items.map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-xs text-slate-500">{invite.url}</p>
<p className="text-[11px] text-amber-700">
{invite.is_active
? t('toolkit.invites.statusActive', 'Aktiv')
: t('toolkit.invites.statusInactive', 'Inaktiv')}
</p>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={navigateToEvent}>
{t('toolkit.invites.manage', 'Einladungen verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{t('toolkit.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks.summary.completed,
total: tasks.summary.total,
})}
</Badge>
</CardHeader>
<CardContent className="space-y-2">
{tasks.items.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
) : (
<div className="space-y-2">
{tasks.items.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
)}
<Button variant="outline" onClick={navigateToTasks}>
{t('toolkit.tasks.manage', 'Tasks verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
const { t } = useTranslation('management');
return (
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
<div>
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
</div>
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
</span>
</div>
);
}
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-sky-500" />
{t('toolkit.recent.title', 'Neueste Uploads')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
</CardDescription>
</CardHeader>
<CardContent>
{photos.length === 0 ? (
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{photos.map((photo) => (
<img
key={photo.id}
src={photo.thumbnail_url}
alt={photo.filename}
className="h-24 w-full rounded-lg object-cover"
/>
))}
</div>
)}
</CardContent>
</Card>
);
}
function FeedbackCard({
submitting,
submitted,
sentiment,
message,
onSelectSentiment,
onMessageChange,
onSubmit,
}: {
submitting: boolean;
submitted: boolean;
sentiment: 'positive' | 'neutral' | 'negative' | null;
message: string;
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
onMessageChange: (value: string) => void;
onSubmit: () => Promise<void>;
}) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<MessageSquare className="h-5 w-5 text-amber-500" />
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{submitted ? (
<Alert variant="success">
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
</Alert>
) : (
<>
<div className="flex flex-wrap gap-3">
<Button
type="button"
variant={sentiment === 'positive' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('positive')}
disabled={submitting}
>
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
</Button>
<Button
type="button"
variant={sentiment === 'neutral' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('neutral')}
disabled={submitting}
>
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
</Button>
<Button
type="button"
variant={sentiment === 'negative' ? 'default' : 'outline'}
onClick={() => onSelectSentiment('negative')}
disabled={submitting}
>
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
</Button>
</div>
<div className="space-y-2">
<Textarea
rows={3}
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
disabled={submitting}
/>
<p className="text-[11px] text-slate-500">
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
</p>
</div>
<div className="flex justify-end">
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{t('toolkit.feedback.submit', 'Feedback senden')}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
function ToolkitSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function formatDateTime(value: string | null): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString();
}

View File

@@ -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_TOOLKIT_PATH,
} from '../constants'; } from '../constants';
export default function EventsPage() { export default function EventsPage() {
@@ -156,10 +157,13 @@ 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)}#join-invites`}> <Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#qr-invites`}>
<Share2 className="h-3.5 w-3.5" /> Einladungen <Share2 className="h-3.5 w-3.5" /> QR-Einladungen
</Link> </Link>
</Button> </Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,514 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { EventQrInviteLayout } from '../../api';
export type QrLayoutCustomization = {
layout_id?: string;
headline?: string;
subtitle?: string;
description?: string;
badge_label?: string;
instructions_heading?: string;
instructions?: string[];
link_heading?: string;
link_label?: string;
cta_label?: string;
accent_color?: string;
text_color?: string;
background_color?: string;
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
logo_data_url?: string | null;
logo_url?: string | null;
};
const MAX_INSTRUCTIONS = 5;
type Props = {
open: boolean;
onClose: () => void;
onSubmit: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
saving: boolean;
inviteUrl: string;
eventName: string;
layouts: EventQrInviteLayout[];
initialCustomization: QrLayoutCustomization | null;
};
export function QrInviteCustomizationDialog({
open,
onClose,
onSubmit,
onReset,
saving,
inviteUrl,
eventName,
layouts,
initialCustomization,
}: Props) {
const { t } = useTranslation('management');
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>();
const [form, setForm] = React.useState<QrLayoutCustomization>({});
const [instructions, setInstructions] = React.useState<string[]>([]);
const [error, setError] = React.useState<string | null>(null);
const defaultInstructions = React.useMemo(() => {
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
}, [t]);
const selectedLayout = React.useMemo(() => {
if (layouts.length === 0) {
return undefined;
}
const fallback = layouts[0];
if (!selectedLayoutId) {
return fallback;
}
return layouts.find((layout) => layout.id === selectedLayoutId) ?? fallback;
}, [layouts, selectedLayoutId]);
React.useEffect(() => {
if (!open) {
return;
}
const defaultLayout = initialCustomization?.layout_id
? layouts.find((layout) => layout.id === initialCustomization.layout_id)
: undefined;
const layout = defaultLayout ?? layouts[0];
setSelectedLayoutId(layout?.id);
const nextInstructions = Array.isArray(initialCustomization?.instructions)
? initialCustomization!.instructions!
: [];
setInstructions(nextInstructions.length > 0 ? nextInstructions : defaultInstructions);
setForm({
layout_id: layout?.id,
headline: initialCustomization?.headline ?? eventName,
subtitle: initialCustomization?.subtitle ?? layout?.subtitle ?? '',
description: initialCustomization?.description ?? layout?.description ?? '',
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
instructions_heading:
initialCustomization?.instructions_heading ?? t("tasks.customizer.defaults.instructionsHeading"),
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
link_label: initialCustomization?.link_label ?? inviteUrl,
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
accent_color: initialCustomization?.accent_color ?? layout?.preview?.accent ?? '#6366F1',
text_color: initialCustomization?.text_color ?? layout?.preview?.text ?? '#111827',
background_color: initialCustomization?.background_color ?? layout?.preview?.background ?? '#FFFFFF',
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
badge_color: initialCustomization?.badge_color ?? layout?.preview?.accent ?? '#2563EB',
background_gradient: initialCustomization?.background_gradient ?? layout?.preview?.background_gradient ?? null,
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
});
setError(null);
}, [open, layouts, initialCustomization, inviteUrl, eventName, t]);
React.useEffect(() => {
if (!selectedLayout) {
return;
}
setForm((prev) => ({
...prev,
layout_id: selectedLayout.id,
accent_color: prev.accent_color ?? selectedLayout.preview?.accent ?? '#6366F1',
text_color: prev.text_color ?? selectedLayout.preview?.text ?? '#111827',
background_color: prev.background_color ?? selectedLayout.preview?.background ?? '#FFFFFF',
background_gradient: prev.background_gradient ?? selectedLayout.preview?.background_gradient ?? null,
}));
}, [selectedLayout]);
const handleColorChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInputChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInstructionChange = (index: number, value: string) => {
setInstructions((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
};
const handleAddInstruction = () => {
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
};
const handleRemoveInstruction = (index: number) => {
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 1024 * 1024) {
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
return;
}
const reader = new FileReader();
reader.onload = () => {
setForm((prev) => ({ ...prev, logo_data_url: typeof reader.result === 'string' ? reader.result : null }));
setError(null);
};
reader.readAsDataURL(file);
};
const handleLogoRemove = () => {
setForm((prev) => ({ ...prev, logo_data_url: null }));
};
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
const preview = React.useMemo(() => {
const backgroundStyle = form.background_gradient?.stops && form.background_gradient.stops.length > 0
? `linear-gradient(${form.background_gradient.angle ?? 180}deg, ${form.background_gradient.stops.join(',')})`
: form.background_color ?? selectedLayout?.preview?.background ?? '#FFFFFF';
return {
background: backgroundStyle,
accent: form.accent_color ?? selectedLayout?.preview?.accent ?? '#6366F1',
text: form.text_color ?? selectedLayout?.preview?.text ?? '#111827',
secondary: form.secondary_color ?? 'rgba(15,23,42,0.08)',
};
}, [form, selectedLayout]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedLayout) {
setError(t('tasks.customizer.errors.noLayout', 'Bitte wähle ein Layout aus.'));
return;
}
setError(null);
await onSubmit({
...form,
layout_id: selectedLayout.id,
instructions: effectiveInstructions,
});
};
const handleReset = async () => {
setError(null);
await onReset();
};
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('tasks.customizer.title', 'QR-Einladung anpassen')}</DialogTitle>
<DialogDescription>{t('tasks.customizer.description', 'Passe Layout, Texte und Farben deiner QR-Einladung an.')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-6 md:grid-cols-[2fr,1fr]">
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="qr-layout">{t('tasks.customizer.layout', 'Layout')}</Label>
<Select
value={selectedLayout?.id}
onValueChange={(value) => setSelectedLayoutId(value)}
disabled={layouts.length === 0 || saving}
>
<SelectTrigger id="qr-layout">
<SelectValue placeholder={t('tasks.customizer.selectLayout', 'Layout auswählen')} />
</SelectTrigger>
<SelectContent>
{layouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
{layout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="headline">{t('tasks.customizer.headline', 'Überschrift')}</Label>
<Input
id="headline"
value={form.headline ?? ''}
onChange={handleInputChange('headline')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle">{t('tasks.customizer.subtitle', 'Unterzeile')}</Label>
<Input
id="subtitle"
value={form.subtitle ?? ''}
onChange={handleInputChange('subtitle')}
maxLength={160}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="badgeLabel">{t('tasks.customizer.badgeLabel', 'Badge')}</Label>
<Input
id="badgeLabel"
value={form.badge_label ?? ''}
onChange={handleInputChange('badge_label')}
maxLength={80}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t('tasks.customizer.descriptionLabel', 'Beschreibung')}</Label>
<Textarea
id="description"
rows={3}
value={form.description ?? ''}
onChange={handleInputChange('description')}
maxLength={500}
disabled={saving}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="instructionsHeading">{t('tasks.customizer.instructionsHeading', "Anleitungstitel")}</Label>
<Input
id="instructionsHeading"
value={form.instructions_heading ?? ''}
onChange={handleInputChange('instructions_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ctaLabel">{t('tasks.customizer.ctaLabel', 'CTA')}</Label>
<Input
id="ctaLabel"
value={form.cta_label ?? ''}
onChange={handleInputChange('cta_label')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkHeading">{t('tasks.customizer.linkHeading', 'Link-Titel')}</Label>
<Input
id="linkHeading"
value={form.link_heading ?? ''}
onChange={handleInputChange('link_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkLabel">{t('tasks.customizer.linkLabel', 'Link')}</Label>
<Input
id="linkLabel"
value={form.link_label ?? ''}
onChange={handleInputChange('link_label')}
maxLength={160}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.instructionsLabel', 'Hinweise')}</Label>
<div className="space-y-2">
{instructions.map((instruction, index) => (
<div key={`instruction-${index}`} className="flex items-start gap-2">
<Textarea
rows={2}
value={instruction}
onChange={(event) => handleInstructionChange(index, event.target.value)}
maxLength={160}
disabled={saving}
/>
<Button type="button" variant="outline" onClick={() => handleRemoveInstruction(index)} disabled={saving}>
{t('tasks.customizer.removeInstruction', 'Entfernen')}
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={handleAddInstruction}
disabled={instructions.length >= MAX_INSTRUCTIONS || saving}
>
{t('tasks.customizer.addInstruction', 'Hinweis hinzufügen')}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ColorField
label={t('tasks.customizer.colors.accent', 'Akzentfarbe')}
value={form.accent_color ?? '#6366F1'}
onChange={handleColorChange('accent_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.text', 'Textfarbe')}
value={form.text_color ?? '#111827'}
onChange={handleColorChange('text_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.background', 'Hintergrund')}
value={form.background_color ?? '#FFFFFF'}
onChange={handleColorChange('background_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.secondary', 'Sekundärfarbe')}
value={form.secondary_color ?? '#CBD5F5'}
onChange={handleColorChange('secondary_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.badge', 'Badge-Farbe')}
value={form.badge_color ?? '#2563EB'}
onChange={handleColorChange('badge_color')}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.logo.label', 'Logo')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Input type="file" accept="image/png,image/jpeg,image/svg+xml" onChange={handleLogoUpload} disabled={saving} />
{form.logo_data_url ? (
<Button type="button" variant="outline" onClick={handleLogoRemove} disabled={saving}>
{t('tasks.customizer.logo.remove', 'Logo entfernen')}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t('tasks.customizer.logo.hint', 'PNG oder SVG, max. 1 MB. Wird oben rechts platziert.')}
</p>
</div>
</div>
<aside className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h4 className="text-sm font-semibold text-slate-900">
{t('tasks.customizer.preview.title', 'Vorschau')}
</h4>
<p className="text-xs text-slate-600">
{t('tasks.customizer.preview.hint', 'Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten.')}
</p>
</div>
<div
className="space-y-3 rounded-3xl border border-slate-200 p-4 text-xs text-slate-700 shadow-sm"
style={{
background: preview.background,
color: preview.text,
}}
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-[var(--badge-color,#1f2937)] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
style={{ background: form.badge_color ?? preview.accent }}
>
{form.badge_label ?? t('tasks.customizer.defaults.badgeLabel')}
</span>
{form.logo_data_url ? (
<img src={form.logo_data_url} alt="Logo" className="h-12 w-auto object-contain" />
) : null}
</div>
<div className="space-y-1">
<p className="text-base font-semibold">{form.headline ?? eventName}</p>
{form.subtitle ? <p className="text-sm opacity-80">{form.subtitle}</p> : null}
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading')}
</p>
<ul className="space-y-1 text-xs">
{(effectiveInstructions.length > 0 ? effectiveInstructions : defaultInstructions).map((item, index) => (
<li key={`preview-instruction-${index}`}> {item}</li>
))}
</ul>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.link_heading ?? t('tasks.customizer.defaults.linkHeading')}
</p>
<div className="rounded-lg border border-white/40 bg-white/80 p-2 text-[11px]" style={{ color: preview.text }}>
{form.link_label ?? inviteUrl}
</div>
</div>
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.cta_label ?? t('tasks.customizer.defaults.ctaLabel')}
</div>
</div>
</aside>
<input type="hidden" value={form.layout_id ?? ''} />
<DialogFooter className="md:col-span-2">
<div className="flex flex-1 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={handleReset} disabled={saving}>
{t('tasks.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
{t('tasks.customizer.actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="flex items-center gap-3">
{error ? <span className="text-sm text-destructive">{error}</span> : null}
<Button type="submit" disabled={saving}>
{t('tasks.customizer.actions.save', 'Speichern')}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function ColorField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
disabled: boolean;
}) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<Input type="color" value={value} onChange={onChange} disabled={disabled} className="h-10 w-14 p-1" />
<Input value={value} onChange={onChange} disabled={disabled} pattern="^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$" />
</div>
</div>
);
}
export default QrInviteCustomizationDialog;

View File

@@ -9,6 +9,7 @@ import EventPhotosPage from './pages/EventPhotosPage';
import EventDetailPage from './pages/EventDetailPage'; import EventDetailPage from './pages/EventDetailPage';
import EventMembersPage from './pages/EventMembersPage'; import EventMembersPage from './pages/EventMembersPage';
import EventTasksPage from './pages/EventTasksPage'; import EventTasksPage from './pages/EventTasksPage';
import EventToolkitPage from './pages/EventToolkitPage';
import BillingPage from './pages/BillingPage'; import BillingPage from './pages/BillingPage';
import TasksPage from './pages/TasksPage'; import TasksPage from './pages/TasksPage';
import TaskCollectionsPage from './pages/TaskCollectionsPage'; import TaskCollectionsPage from './pages/TaskCollectionsPage';
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/photos', element: <EventPhotosPage /> }, { path: 'events/:slug/photos', element: <EventPhotosPage /> },
{ path: 'events/:slug/members', element: <EventMembersPage /> }, { path: 'events/:slug/members', element: <EventMembersPage /> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> }, { path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
{ path: 'tasks', element: <TasksPage /> }, { path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> }, { path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> }, { path: 'emotions', element: <EmotionsPage /> },

View File

@@ -1,53 +1,72 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useAppearance } from '@/hooks/use-appearance'; import { useAppearance } from '@/hooks/use-appearance';
import { Monitor, Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { HTMLAttributes } from 'react'; import { HTMLAttributes, useEffect, useMemo, useState } from 'react';
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) { export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const [prefersDark, setPrefersDark] = useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const [documentLang, setDocumentLang] = useState<string>(() => (typeof document !== 'undefined' ? document.documentElement.lang ?? '' : ''));
const getCurrentIcon = () => { useEffect(() => {
switch (appearance) { if (typeof window === 'undefined') return;
case 'dark':
return <Moon className="h-5 w-5" />; const media = window.matchMedia('(prefers-color-scheme: dark)');
case 'light': const handleChange = (event: MediaQueryListEvent) => setPrefersDark(event.matches);
return <Sun className="h-5 w-5" />; setPrefersDark(media.matches);
default: media.addEventListener('change', handleChange);
return <Monitor className="h-5 w-5" />;
} return () => media.removeEventListener('change', handleChange);
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
const observer = new MutationObserver(() => setDocumentLang(document.documentElement.lang ?? ''));
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
return () => observer.disconnect();
}, []);
const resolvedAppearance = appearance === 'system' ? (prefersDark ? 'dark' : 'light') : appearance;
const isDark = resolvedAppearance === 'dark';
const Icon = isDark ? Moon : Sun;
const { ariaLabel, title } = useMemo(() => {
const isGerman = documentLang.toLowerCase().startsWith('de');
if (isDark) {
return {
ariaLabel: isGerman ? 'Zum hellen Modus wechseln' : 'Switch to light mode',
title: isGerman ? 'Zum hellen Modus wechseln' : 'Switch to light mode',
};
}
return {
ariaLabel: isGerman ? 'Zum dunklen Modus wechseln' : 'Switch to dark mode',
title: isGerman ? 'Zum dunklen Modus wechseln' : 'Switch to dark mode',
}; };
}, [documentLang, isDark]);
return ( const handleToggle = () => {
<div className={className} {...props}> updateAppearance(isDark ? 'light' : 'dark');
<DropdownMenu> };
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md"> return (
{getCurrentIcon()} <div className={className} {...props}>
<span className="sr-only">Toggle theme</span> <Button
</Button> variant="ghost"
</DropdownMenuTrigger> size="icon"
<DropdownMenuContent align="end"> className="h-9 w-9 rounded-md"
<DropdownMenuItem onClick={() => updateAppearance('light')}> type="button"
<span className="flex items-center gap-2"> onClick={handleToggle}
<Sun className="h-5 w-5" /> aria-pressed={isDark}
Light aria-label={ariaLabel}
</span> title={title}
</DropdownMenuItem> >
<DropdownMenuItem onClick={() => updateAppearance('dark')}> <Icon className="h-5 w-5" aria-hidden />
<span className="flex items-center gap-2"> </Button>
<Moon className="h-5 w-5" /> </div>
Dark );
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateAppearance('system')}>
<span className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
System
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
} }

View File

@@ -54,7 +54,7 @@ export default function BottomNav() {
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`); const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
return ( return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50"> <div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/30 bg-white/70 px-2 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-gradient-to-t dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
<div className="mx-auto flex max-w-sm items-center justify-around"> <div className="mx-auto flex max-w-sm items-center justify-around">
<TabLink to={`${base}`} isActive={isHomeActive}> <TabLink to={`${base}`} isActive={isHomeActive}>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">

View File

@@ -16,8 +16,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
common: { common: {
hi: 'Hi', hi: 'Hi',
actions: { actions: {
close: 'Schliessen', close: 'Schließen',
loading: 'Laedt...', loading: 'Lädt...',
}, },
}, },
navigation: { navigation: {
@@ -30,34 +30,34 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...', loading: 'Lade Event...',
stats: { stats: {
online: 'online', online: 'online',
tasksSolved: 'Aufgaben geloest', tasksSolved: 'Aufgaben gelöst',
}, },
}, },
eventAccess: { eventAccess: {
loading: { loading: {
title: 'Wir pruefen deinen Zugang...', title: 'Wir prüfen deinen Zugang...',
subtitle: 'Einen Moment bitte.', subtitle: 'Einen Moment bitte.',
}, },
error: { error: {
invalid_token: { invalid_token: {
title: 'Zugriffscode ungueltig', title: 'Zugriffscode ungültig',
description: 'Der eingegebene Code konnte nicht verifiziert werden.', description: 'Der eingegebene Code konnte nicht verifiziert werden.',
ctaLabel: 'Neuen Code anfordern', ctaLabel: 'Neuen Code anfordern',
}, },
token_revoked: { token_revoked: {
title: 'Zugriffscode deaktiviert', title: 'Zugriffscode deaktiviert',
description: 'Dieser Code wurde zurueckgezogen. Bitte fordere einen neuen Code an.', description: 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.',
ctaLabel: 'Neuen Code anfordern', ctaLabel: 'Neuen Code anfordern',
}, },
token_expired: { token_expired: {
title: 'Zugriffscode abgelaufen', title: 'Zugriffscode abgelaufen',
description: 'Der Code ist nicht mehr gueltig. Aktualisiere deinen Code, um fortzufahren.', description: 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.',
ctaLabel: 'Code aktualisieren', ctaLabel: 'Code aktualisieren',
}, },
token_rate_limited: { token_rate_limited: {
title: 'Zu viele Versuche', title: 'Zu viele Versuche',
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.', hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
}, },
access_rate_limited: { access_rate_limited: {
title: 'Zu viele Aufrufe', title: 'Zu viele Aufrufe',
@@ -65,22 +65,22 @@ export const messages: Record<LocaleCode, NestedMessages> = {
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.', hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
}, },
gallery_expired: { gallery_expired: {
title: 'Galerie nicht mehr verfuegbar', title: 'Galerie nicht mehr verfügbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.', description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
ctaLabel: 'Neuen Code anfordern', ctaLabel: 'Neuen Code anfordern',
}, },
event_not_public: { event_not_public: {
title: 'Event nicht oeffentlich', title: 'Event nicht öffentlich',
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.', description: 'Dieses Event ist aktuell nicht öffentlich zugänglich.',
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.', hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
}, },
network_error: { network_error: {
title: 'Verbindungsproblem', title: 'Verbindungsproblem',
description: 'Wir konnten keine Verbindung zum Server herstellen. Pruefe deine Internetverbindung und versuche es erneut.', description: 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.',
}, },
server_error: { server_error: {
title: 'Server nicht erreichbar', title: 'Server nicht erreichbar',
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.', description: 'Der Server reagiert derzeit nicht. Versuche es später erneut.',
}, },
default: { default: {
title: 'Event nicht erreichbar', title: 'Event nicht erreichbar',
@@ -93,10 +93,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...', loading: 'Lade Event...',
error: { error: {
default: 'Event nicht gefunden.', default: 'Event nicht gefunden.',
backToStart: 'Zurueck zur Startseite', backToStart: 'Zurück zur Startseite',
}, },
card: { card: {
description: 'Fange den schoensten Moment ein!', description: 'Fange den schönsten Moment ein!',
}, },
form: { form: {
label: 'Dein Name (z.B. Anna)', label: 'Dein Name (z.B. Anna)',
@@ -108,12 +108,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
landing: { landing: {
pageTitle: 'Willkommen bei der Fotobox!', pageTitle: 'Willkommen bei der Fotobox!',
headline: 'Willkommen bei der Fotobox!', headline: 'Willkommen bei der Fotobox!',
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.', subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
join: { join: {
title: 'Event beitreten', title: 'Event beitreten',
description: 'Scanne den QR-Code oder gib den Code manuell ein.', description: 'Scanne den QR-Code oder gib den Code manuell ein.',
button: 'Event beitreten', button: 'Event beitreten',
buttonLoading: 'Pruefe...', buttonLoading: 'Prüfe...',
}, },
scan: { scan: {
start: 'QR-Code scannen', start: 'QR-Code scannen',
@@ -125,7 +125,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
errors: { errors: {
eventClosed: 'Event nicht gefunden oder geschlossen.', eventClosed: 'Event nicht gefunden oder geschlossen.',
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.', network: 'Netzwerkfehler. Bitte später erneut versuchen.',
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.', camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
}, },
}, },
@@ -134,27 +134,27 @@ export const messages: Record<LocaleCode, NestedMessages> = {
hero: { hero: {
subtitle: 'Willkommen zur Party', subtitle: 'Willkommen zur Party',
title: 'Hey {name}!', title: 'Hey {name}!',
description: 'Du bist bereit fuer "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gaesten.', description: 'Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.',
progress: { progress: {
some: 'Schon {count} Aufgaben erledigt - weiter so!', some: 'Schon {count} Aufgaben erledigt weiter so!',
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!', none: 'Starte mit deiner ersten Aufgabe wir zählen auf dich!',
}, },
defaultEventName: 'Dein Event', defaultEventName: 'Dein Event',
}, },
stats: { stats: {
online: 'Gleichzeitig online', online: 'Gleichzeitig online',
tasksSolved: 'Aufgaben geloest', tasksSolved: 'Aufgaben gelöst',
lastUpload: 'Letzter Upload', lastUpload: 'Letzter Upload',
completedTasks: 'Deine erledigten Aufgaben', completedTasks: 'Deine erledigten Aufgaben',
}, },
actions: { actions: {
title: 'Deine Aktionen', title: 'Deine Aktionen',
subtitle: 'Waehle aus, womit du starten willst', subtitle: 'Wähle aus, womit du starten willst',
queueButton: 'Uploads in Warteschlange ansehen', queueButton: 'Uploads in Warteschlange ansehen',
items: { items: {
tasks: { tasks: {
label: 'Aufgabe ziehen', label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge', description: 'Hol dir deine nächste Challenge',
}, },
upload: { upload: {
label: 'Direkt hochladen', label: 'Direkt hochladen',
@@ -168,10 +168,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
checklist: { checklist: {
title: 'Dein Fortschritt', title: 'Dein Fortschritt',
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.', description: 'Halte dich an diese drei kurzen Schritte für die besten Ergebnisse.',
steps: { steps: {
first: 'Aufgabe auswaehlen oder starten', first: 'Aufgabe auswählen oder starten',
second: 'Emotion festhalten und Foto schiessen', second: 'Emotion festhalten und Foto schießen',
third: 'Bild hochladen und Credits sammeln', third: 'Bild hochladen und Credits sammeln',
}, },
}, },
@@ -225,35 +225,35 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retry: 'Nochmal versuchen', retry: 'Nochmal versuchen',
}, },
primer: { primer: {
title: 'Bereit fuer dein Shooting?', title: 'Bereit für dein Shooting?',
body: { body: {
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.', part1: 'Lass uns sicherstellen, dass alles sitzt: prüfe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
part2: 'Du kannst zwischen Front- und Rueckkamera wechseln und bei Bedarf ein Raster aktivieren.', part2: 'Du kannst zwischen Front- und Rückkamera wechseln und bei Bedarf ein Raster aktivieren.',
}, },
dismiss: 'Verstanden', dismiss: 'Verstanden',
}, },
cameraUnsupported: { cameraUnsupported: {
title: 'Kamera nicht verfuegbar', title: 'Kamera nicht verfügbar',
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.', message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
openGallery: 'Foto aus Galerie waehlen', openGallery: 'Foto aus Galerie wählen',
}, },
cameraDenied: { cameraDenied: {
title: 'Kamera-Zugriff verweigert', title: 'Kamera-Zugriff verweigert',
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.', explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
reopenPrompt: 'Systemdialog erneut oeffnen', reopenPrompt: 'Systemdialog erneut öffnen',
chooseFile: 'Foto aus Galerie waehlen', chooseFile: 'Foto aus Galerie wählen',
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.', prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
}, },
cameraError: { cameraError: {
title: 'Kamera konnte nicht gestartet werden', title: 'Kamera konnte nicht gestartet werden',
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Pruefe die Berechtigungen oder starte dein Geraet neu.', explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
tryAgain: 'Nochmals versuchen', tryAgain: 'Nochmals versuchen',
}, },
readyOverlay: { readyOverlay: {
title: 'Kamera bereit', title: 'Kamera bereit',
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.', message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto wählen.',
start: 'Countdown starten', start: 'Countdown starten',
chooseFile: 'Foto auswaehlen', chooseFile: 'Foto auswählen',
}, },
taskInfo: { taskInfo: {
countdown: 'Countdown', countdown: 'Countdown',
@@ -266,7 +266,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
timeEstimate: '{count} Min', timeEstimate: '{count} Min',
fallbackTitle: 'Aufgabe {id}', fallbackTitle: 'Aufgabe {id}',
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.', fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gästen.',
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.', fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
badge: 'Aufgabe #{id}', badge: 'Aufgabe #{id}',
}, },
@@ -276,7 +276,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
review: { review: {
retake: 'Nochmal aufnehmen', retake: 'Nochmal aufnehmen',
keep: 'Foto verwenden', keep: 'Foto verwenden',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.', readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
}, },
status: { status: {
saving: 'Speichere Foto...', saving: 'Speichere Foto...',
@@ -289,23 +289,23 @@ export const messages: Record<LocaleCode, NestedMessages> = {
controls: { controls: {
toggleGrid: 'Raster umschalten', toggleGrid: 'Raster umschalten',
toggleCountdown: 'Countdown umschalten', toggleCountdown: 'Countdown umschalten',
toggleMirror: 'Spiegelung fuer Frontkamera umschalten', toggleMirror: 'Spiegelung für Frontkamera umschalten',
toggleFlash: 'Blitzpraeferenz umschalten', toggleFlash: 'Blitzpräferenz umschalten',
capture: 'Foto aufnehmen', capture: 'Foto aufnehmen',
switchCamera: 'Kamera wechseln', switchCamera: 'Kamera wechseln',
chooseFile: 'Foto auswaehlen', chooseFile: 'Foto auswählen',
}, },
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.', limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
limitUnlimited: 'unbegrenzt', limitUnlimited: 'unbegrenzt',
cameraInactive: 'Kamera ist nicht aktiv. {hint}', cameraInactive: 'Kamera ist nicht aktiv. {hint}',
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.', cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
captureError: 'Foto konnte nicht erstellt werden.', captureError: 'Foto konnte nicht erstellt werden.',
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.', feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
canvasError: 'Canvas konnte nicht initialisiert werden.', canvasError: 'Canvas konnte nicht initialisiert werden.',
limitCheckError: 'Fehler beim Pruefen des Upload-Limits. Upload deaktiviert.', limitCheckError: 'Fehler beim Prüfen des Upload-Limits. Upload deaktiviert.',
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.', galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
captureButton: 'Foto aufnehmen', captureButton: 'Foto aufnehmen',
galleryButton: 'Foto aus Galerie waehlen', galleryButton: 'Foto aus Galerie wählen',
switchCamera: 'Kamera wechseln', switchCamera: 'Kamera wechseln',
countdownLabel: 'Countdown: {seconds}s', countdownLabel: 'Countdown: {seconds}s',
countdownReady: 'Bereit machen ...', countdownReady: 'Bereit machen ...',
@@ -319,7 +319,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.', subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
language: { language: {
title: 'Sprache', title: 'Sprache',
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.', description: 'Wähle deine bevorzugte Sprache für diese Veranstaltung.',
activeBadge: 'aktiv', activeBadge: 'aktiv',
option: { option: {
de: 'Deutsch', de: 'Deutsch',
@@ -328,12 +328,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
name: { name: {
title: 'Dein Name', title: 'Dein Name',
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.', description: 'Passe an, wie wir dich im Event begrüßen. Der Name wird nur lokal gespeichert.',
label: 'Anzeigename', label: 'Anzeigename',
placeholder: 'z.B. Anna', placeholder: 'z.B. Anna',
save: 'Name speichern', save: 'Name speichern',
saving: 'Speichere...', saving: 'Speichere...',
reset: 'Zuruecksetzen', reset: 'Zurücksetzen',
saved: 'Gespeichert (ok)', saved: 'Gespeichert (ok)',
loading: 'Lade gespeicherten Namen...', loading: 'Lade gespeicherten Namen...',
}, },
@@ -341,7 +341,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Rechtliches', title: 'Rechtliches',
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.', description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
loading: 'Dokument wird geladen...', loading: 'Dokument wird geladen...',
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.', error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es später erneut.',
fallbackTitle: 'Rechtlicher Hinweis', fallbackTitle: 'Rechtlicher Hinweis',
section: { section: {
impressum: 'Impressum', impressum: 'Impressum',
@@ -350,19 +350,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
}, },
cache: { cache: {
title: 'Offline Cache', title: 'Offline-Cache',
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.', description: 'Lösche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads hängen bleiben.',
clear: 'Cache leeren', clear: 'Cache leeren',
clearing: 'Leere Cache...', clearing: 'Leere Cache...',
cleared: 'Cache geloescht.', cleared: 'Cache gelöscht.',
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.', note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
}, },
footer: { footer: {
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.', notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
}, },
sheet: { sheet: {
openLabel: 'Einstellungen oeffnen', openLabel: 'Einstellungen öffnen',
backLabel: 'Zurueck', backLabel: 'Zurück',
legalDescription: 'Rechtlicher Hinweis', legalDescription: 'Rechtlicher Hinweis',
}, },
}, },

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Header from '../components/Header'; import Header from '../components/Header';
import BottomNav from '../components/BottomNav'; import BottomNav from '../components/BottomNav';
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi'; import { uploadPhoto } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { useAppearance } from '../../hooks/use-appearance';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
AlertTriangle, AlertTriangle,
@@ -46,6 +45,39 @@ type CameraPreferences = {
flashPreferred: boolean; flashPreferred: boolean;
}; };
type TaskPayload = Partial<Task> & { id: number };
function isTaskPayload(value: unknown): value is TaskPayload {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as { id?: unknown };
return typeof candidate.id === 'number';
}
function getErrorName(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'name' in error) {
const name = (error as { name?: unknown }).name;
return typeof name === 'string' ? name : undefined;
}
return undefined;
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message;
}
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message;
return typeof message === 'string' ? message : undefined;
}
return undefined;
}
const DEFAULT_PREFS: CameraPreferences = { const DEFAULT_PREFS: CameraPreferences = {
facingMode: 'environment', facingMode: 'environment',
countdownSeconds: 3, countdownSeconds: 3,
@@ -60,8 +92,6 @@ export default function UploadPage() {
const eventKey = token ?? ''; const eventKey = token ?? '';
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(token); const { markCompleted } = useGuestTaskProgress(token);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -75,7 +105,6 @@ export default function UploadPage() {
const [task, setTask] = useState<Task | null>(null); const [task, setTask] = useState<Task | null>(null);
const [loadingTask, setLoadingTask] = useState(true); const [loadingTask, setLoadingTask] = useState(true);
const [taskError, setTaskError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<PermissionState>('idle'); const [permissionState, setPermissionState] = useState<PermissionState>('idle');
const [permissionMessage, setPermissionMessage] = useState<string | null>(null); const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
@@ -138,8 +167,7 @@ export default function UploadPage() {
// Load task metadata // Load task metadata
useEffect(() => { useEffect(() => {
if (!token || !taskId) { if (!token || taskId === null) {
setTaskError(t('upload.loadError.title'));
setLoadingTask(false); setLoadingTask(false);
return; return;
} }
@@ -147,18 +175,19 @@ export default function UploadPage() {
let active = true; let active = true;
async function loadTask() { async function loadTask() {
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`); const currentTaskId = taskId;
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
const fallbackDescription = t('upload.taskInfo.fallbackDescription'); const fallbackDescription = t('upload.taskInfo.fallbackDescription');
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions'); const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
try { try {
setLoadingTask(true); setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`); const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json(); const payload = (await res.json()) as unknown;
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null; const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return; if (!active) return;
@@ -174,7 +203,7 @@ export default function UploadPage() {
}); });
} else { } else {
setTask({ setTask({
id: taskId!, id: currentTaskId,
title: fallbackTitle, title: fallbackTitle,
description: fallbackDescription, description: fallbackDescription,
instructions: fallbackInstructions, instructions: fallbackInstructions,
@@ -188,9 +217,8 @@ export default function UploadPage() {
} catch (error) { } catch (error) {
console.error('Failed to fetch task', error); console.error('Failed to fetch task', error);
if (active) { if (active) {
setTaskError(t('upload.loadError.title'));
setTask({ setTask({
id: taskId!, id: currentTaskId,
title: fallbackTitle, title: fallbackTitle,
description: fallbackDescription, description: fallbackDescription,
instructions: fallbackInstructions, instructions: fallbackInstructions,
@@ -210,7 +238,7 @@ export default function UploadPage() {
return () => { return () => {
active = false; active = false;
}; };
}, [eventKey, taskId, emotionSlug, t]); }, [eventKey, taskId, emotionSlug, t, token]);
// Check upload limits // Check upload limits
useEffect(() => { useEffect(() => {
@@ -294,14 +322,15 @@ export default function UploadPage() {
streamRef.current = stream; streamRef.current = stream;
attachStreamToVideo(stream); attachStreamToVideo(stream);
setPermissionState('granted'); setPermissionState('granted');
} catch (error: any) { } catch (error: unknown) {
console.error('Camera access error', error); console.error('Camera access error', error);
stopStream(); stopStream();
if (error?.name === 'NotAllowedError') { const errorName = getErrorName(error);
if (errorName === 'NotAllowedError') {
setPermissionState('denied'); setPermissionState('denied');
setPermissionMessage(t('upload.cameraDenied.explanation')); setPermissionMessage(t('upload.cameraDenied.explanation'));
} else if (error?.name === 'NotFoundError') { } else if (errorName === 'NotFoundError') {
setPermissionState('error'); setPermissionState('error');
setPermissionMessage(t('upload.cameraUnsupported.message')); setPermissionMessage(t('upload.cameraUnsupported.message'));
} else { } else {
@@ -489,9 +518,9 @@ export default function UploadPage() {
markCompleted(task.id); markCompleted(task.id);
stopStream(); stopStream();
navigateAfterUpload(photoId); navigateAfterUpload(photoId);
} catch (error: any) { } catch (error: unknown) {
console.error('Upload failed', error); console.error('Upload failed', error);
setUploadError(error?.message || t('upload.status.failed')); setUploadError(getErrorMessage(error) || t('upload.status.failed'));
setMode('review'); setMode('review');
} finally { } finally {
if (uploadProgressTimerRef.current) { if (uploadProgressTimerRef.current) {
@@ -533,7 +562,6 @@ export default function UploadPage() {
const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading'; const showTaskOverlay = task && mode !== 'uploading';
const isUploadDisabled = !canUpload || !task;
useEffect(() => () => { useEffect(() => () => {
resetCountdownTimer(); resetCountdownTimer();
@@ -542,49 +570,41 @@ export default function UploadPage() {
} }
}, [resetCountdownTimer]); }, [resetCountdownTimer]);
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className={mainClassName}>{content}</main>
<BottomNav />
</div>
);
if (!supportsCamera && !task) { if (!supportsCamera && !task) {
return ( return renderPage(
<div className="pb-16"> <Alert>
<Header eventToken={eventKey} title={t('upload.cameraTitle')} /> <AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
<main className="px-4 py-6"> </Alert>
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
); );
} }
if (loadingTask) { if (loadingTask) {
return ( return renderPage(
<div className="pb-16"> <div className="flex flex-col items-center justify-center gap-4 text-center">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} /> <Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<main className="px-4 py-6 flex flex-col items-center justify-center text-center"> <p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</main>
<BottomNav />
</div> </div>
); );
} }
if (!canUpload) { if (!canUpload) {
return ( return renderPage(
<div className="pb-16"> <Alert variant="destructive">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} /> <AlertTriangle className="h-4 w-4" />
<main className="px-4 py-6"> <AlertDescription>
<Alert variant="destructive"> {t('upload.limitReached')
<AlertTriangle className="h-4 w-4" /> .replace('{used}', `${eventPackage?.used_photos || 0}`)
<AlertDescription> .replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
{t('upload.limitReached') </AlertDescription>
.replace('{used}', `${eventPackage?.used_photos || 0}`) </Alert>
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
); );
} }
@@ -636,18 +656,16 @@ export default function UploadPage() {
); );
}; };
return ( return renderPage(
<div className="pb-16"> <>
<Header eventToken={eventKey} title={t('upload.cameraTitle')} /> <div className="absolute left-0 right-0 top-0" aria-hidden="true">
<main className="relative flex flex-col gap-4 pb-4"> {renderPrimer()}
<div className="absolute left-0 right-0 top-0" aria-hidden="true"> </div>
{renderPrimer()} <div className="pt-32" />
</div> {permissionState !== 'granted' && renderPermissionNotice()}
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl"> <section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video"> <div className="relative aspect-[3/4] sm:aspect-video">
<video <video
ref={videoRef} ref={videoRef}
className={cn( className={cn(
@@ -863,9 +881,10 @@ export default function UploadPage() {
/> />
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" /> <div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
</main>
<BottomNav /> <canvas ref={canvasRef} className="hidden" />
<canvas ref={canvasRef} className="hidden" /> </>
</div> ,
'relative flex flex-col gap-4 pb-4'
); );
} }

View File

@@ -61,6 +61,12 @@
width: fit-content; width: fit-content;
} }
.logo {
max-width: 140px;
max-height: 80px;
object-fit: contain;
}
.event-title { .event-title {
font-size: 72px; font-size: 72px;
font-weight: 700; font-weight: 700;
@@ -165,8 +171,13 @@
<body> <body>
<div class="layout-wrapper"> <div class="layout-wrapper">
<div class="header"> <div class="header">
<span class="badge">Digitale Gästebox</span> <div style="display:flex; align-items:center; justify-content:space-between; gap:24px;">
<h1 class="event-title">{{ $eventName }}</h1> <span class="badge">{{ $layout['badge_label'] ?? 'Digitale Gästebox' }}</span>
@if(!empty($layout['logo_url']))
<img src="{{ $layout['logo_url'] }}" alt="Logo" class="logo" />
@endif
</div>
<h1 class="event-title">{{ $layout['headline'] ?? $eventName }}</h1>
@if(!empty($layout['subtitle'])) @if(!empty($layout['subtitle']))
<p class="subtitle">{{ $layout['subtitle'] }}</p> <p class="subtitle">{{ $layout['subtitle'] }}</p>
@endif @endif
@@ -174,7 +185,7 @@
<div class="content"> <div class="content">
<div class="info-card"> <div class="info-card">
<h2>So funktioniert&rsquo;s</h2> <h2>{{ $layout['instructions_heading'] ?? "So funktioniert's" }}</h2>
<p>{{ $layout['description'] }}</p> <p>{{ $layout['description'] }}</p>
@if(!empty($layout['instructions'])) @if(!empty($layout['instructions']))
<ul class="instructions"> <ul class="instructions">
@@ -184,14 +195,14 @@
</ul> </ul>
@endif @endif
<div> <div>
<div class="cta">Alternative zum Einscannen</div> <div class="cta">{{ $layout['link_heading'] ?? 'Alternative zum Einscannen' }}</div>
<div class="link-box">{{ $tokenUrl }}</div> <div class="link-box">{{ $layout['link_label'] ?? $tokenUrl }}</div>
</div> </div>
</div> </div>
<div class="qr-wrapper"> <div class="qr-wrapper">
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}"> <img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
<div class="cta">Scan mich & starte direkt</div> <div class="cta">{{ $layout['cta_label'] ?? 'Scan mich & starte direkt' }}</div>
</div> </div>
</div> </div>
@@ -203,4 +214,4 @@
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -11,7 +11,14 @@
$instructions = $layout['instructions'] ?? []; $instructions = $layout['instructions'] ?? [];
$description = $layout['description'] ?? ''; $description = $layout['description'] ?? '';
$subtitle = $layout['subtitle'] ?? ''; $subtitle = $layout['subtitle'] ?? '';
$titleLines = explode("\n", wordwrap($eventName, 18, "\n", true)); $headline = $layout['headline'] ?? $eventName;
$badgeLabel = $layout['badge_label'] ?? 'Digitale Gästebox';
$instructionsHeading = $layout['instructions_heading'] ?? "So funktioniert's";
$linkHeading = $layout['link_heading'] ?? 'Alternative zum Einscannen';
$ctaLabel = $layout['cta_label'] ?? 'Scan mich & starte direkt';
$linkLabel = $layout['link_label'] ?? $tokenUrl;
$logoUrl = $layout['logo_url'] ?? null;
$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)) : [];
$instructionStartY = 870; $instructionStartY = 870;
@@ -111,7 +118,11 @@
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" /> <rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
<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">Digitale Gästebox</text> <text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">{{ e($badgeLabel) }}</text>
@if($logoUrl)
<image href="{{ $logoUrl }}" x="840" y="90" width="180" height="120" preserveAspectRatio="xMidYMid meet" />
@endif
@foreach($titleLines as $index => $line) @foreach($titleLines as $index => $line)
<text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text> <text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text>
@@ -131,7 +142,7 @@
<text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text> <text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text>
@endforeach @endforeach
<text x="120" y="760" fill="{{ $accent }}" class="small-label">SO FUNKTIONIERT'S</text> <text x="120" y="760" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($instructionsHeading)) }}</text>
@foreach($instructions as $index => $step) @foreach($instructions as $index => $step)
@php @php
@@ -141,13 +152,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">ALTERNATIVER LINK</text> <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)" /> <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($tokenUrl) }}</text> <text x="650" y="850" 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="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">JETZT SCANNEN</text> <text x="820" y="820" 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>
@@ -156,4 +167,4 @@
<text x="{{ $width - 120 }}" y="{{ $height - 160 }}" text-anchor="end" fill="rgba(17,24,39,0.6)" class="footer-text"> <text x="{{ $width - 120 }}" y="{{ $height - 160 }}" text-anchor="end" fill="rgba(17,24,39,0.6)" class="footer-text">
Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }} Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}
</text> </text>
</svg> </svg>

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\SettingsController;
use App\Http\Controllers\Api\Tenant\TaskCollectionController; use App\Http\Controllers\Api\Tenant\TaskCollectionController;
use App\Http\Controllers\Api\Tenant\TaskController; use App\Http\Controllers\Api\Tenant\TaskController;
use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
use App\Http\Controllers\Api\TenantBillingController; use App\Http\Controllers\Api\TenantBillingController;
use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\Api\TenantPackageController;
use App\Http\Controllers\OAuthController; use App\Http\Controllers\OAuthController;
@@ -71,6 +72,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
Route::prefix('join-tokens')->group(function () { Route::prefix('join-tokens')->group(function () {
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index'); Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
@@ -82,6 +84,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->whereNumber('joinToken') ->whereNumber('joinToken')
->where('format', 'pdf|svg') ->where('format', 'pdf|svg')
->name('tenant.events.join-tokens.layouts.download'); ->name('tenant.events.join-tokens.layouts.download');
Route::patch('{joinToken}', [EventJoinTokenController::class, 'update'])
->whereNumber('joinToken')
->name('tenant.events.join-tokens.update');
Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy']) Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy'])
->whereNumber('joinToken') ->whereNumber('joinToken')
->name('tenant.events.join-tokens.destroy'); ->name('tenant.events.join-tokens.destroy');
@@ -158,6 +163,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
->name('tenant.billing.transactions'); ->name('tenant.billing.transactions');
Route::post('feedback', [TenantFeedbackController::class, 'store'])
->name('tenant.feedback.store');
}); });
}); });

View File

@@ -0,0 +1,50 @@
<?php
namespace Tests\Unit;
use App\Models\Package;
use App\Models\TenantPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantPackageTest extends TestCase
{
use RefreshDatabase;
public function test_endcustomer_packages_remain_active_without_expiry(): void
{
$package = Package::factory()->endcustomer()->create();
$tenantPackage = TenantPackage::factory()->create([
'package_id' => $package->id,
'expires_at' => now()->subYear(),
'active' => true,
]);
$tenantPackage->refresh();
$this->assertTrue($tenantPackage->isActive());
$this->assertTrue($tenantPackage->active);
$this->assertTrue($tenantPackage->expires_at->greaterThan(now()->addYears(50)));
}
public function test_reseller_packages_still_expire(): void
{
$package = Package::factory()->reseller()->create(['max_events_per_year' => 5]);
$tenantPackage = TenantPackage::factory()->create([
'package_id' => $package->id,
'expires_at' => null,
]);
$tenantPackage->refresh();
$this->assertNotNull($tenantPackage->expires_at);
$this->assertTrue($tenantPackage->expires_at->isFuture());
$tenantPackage->forceFill(['expires_at' => now()->subDay()])->save();
$this->assertFalse($tenantPackage->fresh()->isActive());
$this->assertFalse($tenantPackage->fresh()->active);
}
}