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

View File

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

View File

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

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

View File

@@ -41,6 +41,8 @@ class EventStoreRequest extends FormRequest
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
'features' => ['nullable', 'array'],
'features.*' => ['string'],
'settings' => ['nullable', 'array'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
];
}

View File

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

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;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,6 +44,10 @@ class TenantPackage extends Model
public function isActive(): bool
{
if ($this->package && $this->package->isEndcustomer()) {
return (bool) $this->active;
}
return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
}
@@ -76,24 +81,44 @@ class TenantPackage extends Model
{
parent::boot();
static::creating(function ($tenantPackage) {
static::creating(function (self $tenantPackage) {
if (! $tenantPackage->purchased_at) {
$tenantPackage->purchased_at = now();
}
if (! $tenantPackage->expires_at && $tenantPackage->package) {
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}
} else {
$tenantPackage->expires_at = now()->addCentury();
}
if ($tenantPackage->active === null) {
$tenantPackage->active = true;
}
});
static::updating(function ($tenantPackage) {
static::updating(function (self $tenantPackage) {
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (
$tenantPackage->isDirty('expires_at')
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface
&& $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;
use App\Models\InviteLayout;
class JoinTokenLayoutRegistry
{
/**
@@ -123,6 +125,18 @@ class JoinTokenLayoutRegistry
*/
public static function all(): array
{
$customLayouts = InviteLayout::query()
->where('is_active', true)
->orderBy('name')
->get();
if ($customLayouts->isNotEmpty()) {
return $customLayouts
->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout)))
->values()
->all();
}
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
}
@@ -131,6 +145,15 @@ class JoinTokenLayoutRegistry
*/
public static function find(string $id): ?array
{
$custom = InviteLayout::query()
->where('slug', $id)
->where('is_active', true)
->first();
if ($custom) {
return self::normalize(self::fromModel($custom));
}
$layout = self::LAYOUTS[$id] ?? null;
return $layout ? self::normalize($layout) : null;
@@ -151,6 +174,13 @@ class JoinTokenLayoutRegistry
'accent' => '#6366F1',
'secondary' => '#CBD5F5',
'badge' => '#2563EB',
'badge_label' => 'Digitale Gästebox',
'instructions_heading' => "So funktioniert's",
'link_heading' => 'Alternative zum Einscannen',
'cta_label' => 'Scan mich & starte direkt',
'cta_caption' => 'Scan mich & starte direkt',
'link_label' => null,
'logo_url' => null,
'qr' => [
'size_px' => 320,
],
@@ -160,11 +190,50 @@ class JoinTokenLayoutRegistry
],
'background_gradient' => null,
'instructions' => [],
'formats' => ['pdf', 'svg'],
];
return array_replace_recursive($defaults, $layout);
}
private static function fromModel(InviteLayout $layout): array
{
$preview = $layout->preview ?? [];
$options = $layout->layout_options ?? [];
$instructions = $layout->instructions ?? [];
return array_filter([
'id' => $layout->slug,
'name' => $layout->name,
'subtitle' => $layout->subtitle,
'description' => $layout->description,
'paper' => $layout->paper,
'orientation' => $layout->orientation,
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'text' => $preview['text'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'badge' => $preview['badge'] ?? null,
'badge_label' => $options['badge_label'] ?? null,
'instructions_heading' => $options['instructions_heading'] ?? null,
'link_heading' => $options['link_heading'] ?? null,
'cta_label' => $options['cta_label'] ?? null,
'cta_caption' => $options['cta_caption'] ?? null,
'link_label' => $options['link_label'] ?? null,
'logo_url' => $options['logo_url'] ?? null,
'qr' => array_filter([
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
]),
'svg' => array_filter([
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
]),
'formats' => $options['formats'] ?? ['pdf', 'svg'],
'instructions' => $instructions,
], fn ($value) => $value !== null && $value !== []);
}
/**
* Map layouts into an API-ready response structure, attaching URLs.
*
@@ -174,7 +243,7 @@ class JoinTokenLayoutRegistry
public static function toResponse(callable $urlResolver): array
{
return array_map(function (array $layout) use ($urlResolver) {
$formats = ['pdf', 'svg'];
$formats = $layout['formats'] ?? ['pdf', 'svg'];
return [
'id' => $layout['id'],

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,
EventTasksSeeder::class,
TaskCollectionsSeeder::class,
InviteLayoutSeeder::class,
]);
// 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;
use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Emotion, Task, EventType};
class TasksSeeder extends Seeder
{
@@ -25,40 +28,83 @@ class TasksSeeder extends Seeder
$seed = [
'Liebe' => [
['title'=>['de'=>'Kuss-Foto','en'=>'Kiss Photo'], 'description'=>['de'=>'Macht ein romantisches Kuss-Foto','en'=>'Take a romantic kiss photo'], 'difficulty'=>'easy'],
['title' => ['de' => 'Kuss-Foto', 'en' => 'Kiss Photo'], 'description' => ['de' => 'Macht ein romantisches Kuss-Foto', 'en' => 'Take a romantic kiss photo'], 'difficulty' => 'easy'],
],
'Freude' => [
['title'=>['de'=>'Sprung-Foto','en'=>'Jump Photo'], 'description'=>['de'=>'Alle springen gleichzeitig!','en'=>'Everyone jump together!'], 'difficulty'=>'medium'],
['title' => ['de' => 'Sprung-Foto', 'en' => 'Jump Photo'], 'description' => ['de' => 'Alle springen gleichzeitig!', 'en' => 'Everyone jump together!'], 'difficulty' => 'medium'],
],
'Teamgeist' => [
['title'=>['de'=>'High-Five-Runde','en'=>'High-Five Round'], 'description'=>['de'=>'Gebt euch High-Fives!','en'=>'Give each other high-fives!'], 'difficulty'=>'easy', 'event_type'=>'corporate'],
['title' => ['de' => 'High-Five-Runde', 'en' => 'High-Five Round'], 'description' => ['de' => 'Gebt euch High-Fives!', 'en' => 'Give each other high-fives!'], 'difficulty' => 'easy', 'event_type' => 'corporate'],
],
'Besinnlichkeit' => [
['title'=>['de'=>'Lichterglanz','en'=>'Glow of Lights'], 'description'=>['de'=>'Foto mit Lichterkette','en'=>'Photo with string lights'], 'difficulty'=>'easy', 'event_type'=>'christmas'],
['title' => ['de' => 'Lichterglanz', 'en' => 'Glow of Lights'], 'description' => ['de' => 'Foto mit Lichterkette', 'en' => 'Photo with string lights'], 'difficulty' => 'easy', 'event_type' => 'christmas'],
],
];
$types = EventType::pluck('id','slug');
$types = EventType::pluck('id', 'slug');
$position = 10;
foreach ($seed as $emotionNameDe => $tasks) {
$emotion = Emotion::where('name->de', $emotionNameDe)->first();
if (!$emotion) continue;
foreach ($tasks as $t) {
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
$slug = $slugBase ? $slugBase . '-' . $emotion->id : Str::uuid()->toString();
Task::updateOrCreate([
if (! $emotion) {
continue;
}
$emotionTranslations = is_array($emotion->name) ? $emotion->name : [];
$emotionNameEn = $emotionTranslations['en'] ?? $emotionNameDe;
$collection = TaskCollection::updateOrCreate(
[
'tenant_id' => $demoTenant->id,
'slug' => 'demo-'.Str::slug($emotionTranslations['en'] ?? $emotionNameDe),
],
[
'name_translations' => [
'de' => $emotionNameDe,
'en' => $emotionNameEn,
],
'description_translations' => [
'de' => 'Aufgaben rund um '.$emotionNameDe.'.',
'en' => 'Prompts inspired by '.$emotionNameEn.'.',
],
'event_type_id' => null,
'is_default' => false,
'position' => $position,
]
);
$position += 10;
$syncPayload = [];
foreach ($tasks as $index => $t) {
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
$slug = $slugBase ? $slugBase.'-'.$emotion->id : Str::uuid()->toString();
$sortOrder = $t['sort_order'] ?? (($index + 1) * 10);
$task = Task::updateOrCreate(
[
'slug' => $slug,
], [
],
[
'tenant_id' => $demoTenant->id,
'emotion_id' => $emotion->id,
'event_type_id' => isset($t['event_type']) && isset($types[$t['event_type']]) ? $types[$t['event_type']] : null,
'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' => $t['sort_order'] ?? 0,
]);
'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>;
export type EventJoinTokenLayout = {
export type EventQrInviteLayout = {
id: string;
name: string;
description: string;
@@ -41,6 +41,8 @@ export type TenantEvent = {
description?: string | null;
photo_count?: number;
like_count?: number;
engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
package?: {
id: number | string | null;
name: string | null;
@@ -246,7 +248,7 @@ export type EventMember = {
type EventListResponse = { data?: JsonValue[] };
type EventResponse = { data: JsonValue };
export type EventJoinToken = {
export type EventQrInvite = {
id: number;
token: string;
url: string;
@@ -258,9 +260,48 @@ export type EventJoinToken = {
is_active: boolean;
created_at: string | null;
metadata: Record<string, unknown>;
layouts: EventJoinTokenLayout[];
layouts: EventQrInviteLayout[];
layouts_url: string | null;
};
export type EventToolkitTask = {
id: number;
title: string;
description: string | null;
is_completed: boolean;
priority?: string | null;
};
export type EventToolkit = {
event: TenantEvent;
metrics: {
uploads_total: number;
uploads_24h: number;
pending_photos: number;
active_invites: number;
engagement_mode: 'tasks' | 'photo_only';
};
tasks: {
summary: {
total: number;
completed: number;
pending: number;
};
items: EventToolkitTask[];
};
photos: {
pending: TenantPhoto[];
recent: TenantPhoto[];
};
invites: {
summary: {
total: number;
active: number;
};
items: EventQrInvite[];
};
alerts: string[];
};
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -272,6 +313,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived';
is_active?: boolean;
package_id?: number;
settings?: Record<string, unknown>;
};
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 {
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 = {
...(event as Record<string, unknown>),
id: Number(event.id ?? 0),
@@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent {
description: event.description ?? null,
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null,
};
@@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember {
};
}
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
const layouts: EventJoinTokenLayout[] = rawLayouts
const layouts: EventQrInviteLayout[] = rawLayouts
.map((layout: any) => {
const formats = Array.isArray(layout.formats)
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
@@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
};
})
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0);
.filter((layout: EventQrInviteLayout) => layout.id.length > 0);
return {
id: Number(raw.id ?? 0),
@@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise<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 payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
const list = Array.isArray(payload.data) ? payload.data : [];
return list.map(normalizeJoinToken);
return list.map(normalizeQrInvite);
}
export async function createInviteLink(
export async function createQrInvite(
slug: string,
payload?: { label?: string; usage_limit?: number; expires_at?: string }
): Promise<EventJoinToken> {
): Promise<EventQrInvite> {
const body = JSON.stringify(payload ?? {});
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
method: 'POST',
@@ -739,14 +787,14 @@ export async function createInviteLink(
body,
});
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
return normalizeJoinToken(data.data ?? {});
return normalizeQrInvite(data.data ?? {});
}
export async function revokeEventJoinToken(
export async function revokeEventQrInvite(
slug: string,
tokenId: number,
reason?: string
): Promise<EventJoinToken> {
): Promise<EventQrInvite> {
const options: RequestInit = { method: 'DELETE' };
if (reason) {
options.headers = { 'Content-Type': 'application/json' };
@@ -754,7 +802,107 @@ export async function revokeEventJoinToken(
}
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
return normalizeJoinToken(data.data ?? {});
return normalizeQrInvite(data.data ?? {});
}
export async function updateEventQrInvite(
slug: string,
tokenId: number,
payload: {
label?: string | null;
expires_at?: string | null;
usage_limit?: number | null;
metadata?: Record<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 = {

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_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);

View File

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

View File

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

View File

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

View File

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

View File

@@ -147,7 +147,9 @@
"errors": {
"missingSlug": "No event slug provided.",
"load": "Event tasks could not be loaded.",
"assign": "Tasks could not be assigned."
"assign": "Tasks could not be assigned.",
"photoOnlyEnable": "Photo-only mode could not be enabled.",
"photoOnlyDisable": "Photo-only mode could not be disabled."
},
"alerts": {
"notFoundTitle": "Event not found",
@@ -169,6 +171,147 @@
"medium": "Medium",
"high": "High",
"urgent": "Urgent"
},
"modes": {
"title": "Tasks & photo mode",
"photoOnlyHint": "Photo-only mode is active. Guests can upload photos but 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": {

View File

@@ -1,7 +1,20 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
import {
CalendarDays,
Camera,
Sparkles,
Users,
Plus,
Settings,
CheckCircle2,
Circle,
QrCode,
ClipboardList,
Package as PackageIcon,
Loader2,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -14,6 +27,8 @@ import {
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
getEventTasks,
getEventQrInvites,
TenantEvent,
TenantPackageSummary,
} from '../api';
@@ -23,6 +38,7 @@ import {
adminPath,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
@@ -39,6 +55,16 @@ interface DashboardState {
errorKey: string | null;
}
type ReadinessState = {
hasEvent: boolean;
hasTasks: boolean;
hasQrInvites: boolean;
hasPackage: boolean;
primaryEventSlug: string | null;
primaryEventName: string | null;
loading: boolean;
};
export default function DashboardPage() {
const navigate = useNavigate();
const location = useLocation();
@@ -66,6 +92,16 @@ export default function DashboardPage() {
errorKey: null,
});
const [readiness, setReadiness] = React.useState<ReadinessState>({
hasEvent: false,
hasTasks: false,
hasQrInvites: false,
hasPackage: false,
primaryEventSlug: null,
primaryEventName: null,
loading: false,
});
React.useEffect(() => {
let cancelled = false;
(async () => {
@@ -81,6 +117,18 @@ export default function DashboardPage() {
}
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
setReadiness({
hasEvent: events.length > 0,
hasTasks: false,
hasQrInvites: false,
hasPackage: Boolean(packages.activePackage),
primaryEventSlug: primaryEvent?.slug ?? null,
primaryEventName,
loading: Boolean(primaryEvent),
});
setState({
summary: summary ?? fallbackSummary,
@@ -89,6 +137,36 @@ export default function DashboardPage() {
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,
}));
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
@@ -271,6 +349,52 @@ export default function DashboardPage() {
</CardContent>
</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">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<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(
events: TenantEvent[],
activePackage: TenantPackageSummary | null
@@ -353,6 +498,170 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
.slice(0, 4);
}
type ReadinessLabels = {
title: string;
description: string;
pending: string;
complete: string;
items: {
event: { title: string; hint: string };
tasks: { title: string; hint: string };
qr: { title: string; hint: string };
package: { title: string; hint: string };
};
actions: {
createEvent: string;
openTasks: string;
openQr: string;
openPackages: string;
};
};
function ReadinessCard({
readiness,
labels,
onCreateEvent,
onOpenTasks,
onOpenQr,
onOpenPackages,
}: {
readiness: ReadinessState;
labels: ReadinessLabels;
onCreateEvent: () => void;
onOpenTasks: () => void;
onOpenQr: () => void;
onOpenPackages: () => void;
}) {
const checklistItems = [
{
key: 'event',
icon: <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({
label,
value,

View File

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

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

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

View File

@@ -1,53 +1,72 @@
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useAppearance } from '@/hooks/use-appearance';
import { Monitor, Moon, Sun } from 'lucide-react';
import { HTMLAttributes } from 'react';
import { Moon, Sun } from 'lucide-react';
import { HTMLAttributes, useEffect, useMemo, useState } from 'react';
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const getCurrentIcon = () => {
switch (appearance) {
case 'dark':
return <Moon className="h-5 w-5" />;
case 'light':
return <Sun className="h-5 w-5" />;
default:
return <Monitor className="h-5 w-5" />;
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 ?? '' : ''));
useEffect(() => {
if (typeof window === 'undefined') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => setPrefersDark(event.matches);
setPrefersDark(media.matches);
media.addEventListener('change', handleChange);
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]);
const handleToggle = () => {
updateAppearance(isDark ? 'light' : 'dark');
};
return (
<div className={className} {...props}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
{getCurrentIcon()}
<span className="sr-only">Toggle theme</span>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-md"
type="button"
onClick={handleToggle}
aria-pressed={isDark}
aria-label={ariaLabel}
title={title}
>
<Icon className="h-5 w-5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateAppearance('light')}>
<span className="flex items-center gap-2">
<Sun className="h-5 w-5" />
Light
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
<span className="flex items-center gap-2">
<Moon className="h-5 w-5" />
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`);
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">
<TabLink to={`${base}`} isActive={isHomeActive}>
<div className="flex flex-col items-center gap-1">

View File

@@ -16,8 +16,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
common: {
hi: 'Hi',
actions: {
close: 'Schliessen',
loading: 'Laedt...',
close: 'Schließen',
loading: 'Lädt...',
},
},
navigation: {
@@ -30,34 +30,34 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...',
stats: {
online: 'online',
tasksSolved: 'Aufgaben geloest',
tasksSolved: 'Aufgaben gelöst',
},
},
eventAccess: {
loading: {
title: 'Wir pruefen deinen Zugang...',
title: 'Wir prüfen deinen Zugang...',
subtitle: 'Einen Moment bitte.',
},
error: {
invalid_token: {
title: 'Zugriffscode ungueltig',
title: 'Zugriffscode ungültig',
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
ctaLabel: 'Neuen Code anfordern',
},
token_revoked: {
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',
},
token_expired: {
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',
},
token_rate_limited: {
title: 'Zu viele Versuche',
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: {
title: 'Zu viele Aufrufe',
@@ -65,22 +65,22 @@ export const messages: Record<LocaleCode, NestedMessages> = {
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
},
gallery_expired: {
title: 'Galerie nicht mehr verfuegbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
title: 'Galerie nicht mehr verfügbar',
description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
ctaLabel: 'Neuen Code anfordern',
},
event_not_public: {
title: 'Event nicht oeffentlich',
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
title: 'Event nicht öffentlich',
description: 'Dieses Event ist aktuell nicht öffentlich zugänglich.',
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
},
network_error: {
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: {
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: {
title: 'Event nicht erreichbar',
@@ -93,10 +93,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lade Event...',
error: {
default: 'Event nicht gefunden.',
backToStart: 'Zurueck zur Startseite',
backToStart: 'Zurück zur Startseite',
},
card: {
description: 'Fange den schoensten Moment ein!',
description: 'Fange den schönsten Moment ein!',
},
form: {
label: 'Dein Name (z.B. Anna)',
@@ -108,12 +108,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
landing: {
pageTitle: 'Willkommen bei der Fotobox!',
headline: 'Willkommen bei der Fotobox!',
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
join: {
title: 'Event beitreten',
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
button: 'Event beitreten',
buttonLoading: 'Pruefe...',
buttonLoading: 'Prüfe...',
},
scan: {
start: 'QR-Code scannen',
@@ -125,7 +125,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
errors: {
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.',
},
},
@@ -134,27 +134,27 @@ export const messages: Record<LocaleCode, NestedMessages> = {
hero: {
subtitle: 'Willkommen zur Party',
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: {
some: 'Schon {count} Aufgaben erledigt - weiter so!',
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
some: 'Schon {count} Aufgaben erledigt weiter so!',
none: 'Starte mit deiner ersten Aufgabe wir zählen auf dich!',
},
defaultEventName: 'Dein Event',
},
stats: {
online: 'Gleichzeitig online',
tasksSolved: 'Aufgaben geloest',
tasksSolved: 'Aufgaben gelöst',
lastUpload: 'Letzter Upload',
completedTasks: 'Deine erledigten Aufgaben',
},
actions: {
title: 'Deine Aktionen',
subtitle: 'Waehle aus, womit du starten willst',
subtitle: 'Wähle aus, womit du starten willst',
queueButton: 'Uploads in Warteschlange ansehen',
items: {
tasks: {
label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge',
description: 'Hol dir deine nächste Challenge',
},
upload: {
label: 'Direkt hochladen',
@@ -168,10 +168,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
checklist: {
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: {
first: 'Aufgabe auswaehlen oder starten',
second: 'Emotion festhalten und Foto schiessen',
first: 'Aufgabe auswählen oder starten',
second: 'Emotion festhalten und Foto schießen',
third: 'Bild hochladen und Credits sammeln',
},
},
@@ -225,35 +225,35 @@ export const messages: Record<LocaleCode, NestedMessages> = {
retry: 'Nochmal versuchen',
},
primer: {
title: 'Bereit fuer dein Shooting?',
title: 'Bereit für dein Shooting?',
body: {
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe 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.',
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 Rückkamera wechseln und bei Bedarf ein Raster aktivieren.',
},
dismiss: 'Verstanden',
},
cameraUnsupported: {
title: 'Kamera nicht verfuegbar',
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
openGallery: 'Foto aus Galerie waehlen',
title: 'Kamera nicht verfügbar',
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
openGallery: 'Foto aus Galerie wählen',
},
cameraDenied: {
title: 'Kamera-Zugriff verweigert',
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.',
reopenPrompt: 'Systemdialog erneut oeffnen',
chooseFile: 'Foto aus Galerie waehlen',
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.',
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
reopenPrompt: 'Systemdialog erneut öffnen',
chooseFile: 'Foto aus Galerie wählen',
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
},
cameraError: {
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',
},
readyOverlay: {
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',
chooseFile: 'Foto auswaehlen',
chooseFile: 'Foto auswählen',
},
taskInfo: {
countdown: 'Countdown',
@@ -266,7 +266,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
timeEstimate: '{count} Min',
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.',
badge: 'Aufgabe #{id}',
},
@@ -276,7 +276,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
review: {
retake: 'Nochmal aufnehmen',
keep: 'Foto verwenden',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
},
status: {
saving: 'Speichere Foto...',
@@ -289,23 +289,23 @@ export const messages: Record<LocaleCode, NestedMessages> = {
controls: {
toggleGrid: 'Raster umschalten',
toggleCountdown: 'Countdown umschalten',
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
toggleFlash: 'Blitzpraeferenz umschalten',
toggleMirror: 'Spiegelung für Frontkamera umschalten',
toggleFlash: 'Blitzpräferenz umschalten',
capture: 'Foto aufnehmen',
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',
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
captureError: 'Foto konnte nicht erstellt werden.',
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
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.',
captureButton: 'Foto aufnehmen',
galleryButton: 'Foto aus Galerie waehlen',
galleryButton: 'Foto aus Galerie wählen',
switchCamera: 'Kamera wechseln',
countdownLabel: 'Countdown: {seconds}s',
countdownReady: 'Bereit machen ...',
@@ -319,7 +319,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
language: {
title: 'Sprache',
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
description: 'Wähle deine bevorzugte Sprache für diese Veranstaltung.',
activeBadge: 'aktiv',
option: {
de: 'Deutsch',
@@ -328,12 +328,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
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',
placeholder: 'z.B. Anna',
save: 'Name speichern',
saving: 'Speichere...',
reset: 'Zuruecksetzen',
reset: 'Zurücksetzen',
saved: 'Gespeichert (ok)',
loading: 'Lade gespeicherten Namen...',
},
@@ -341,7 +341,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Rechtliches',
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
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',
section: {
impressum: 'Impressum',
@@ -350,19 +350,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
},
cache: {
title: 'Offline Cache',
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
title: 'Offline-Cache',
description: 'Lösche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads hängen bleiben.',
clear: 'Cache leeren',
clearing: 'Leere Cache...',
cleared: 'Cache geloescht.',
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
cleared: 'Cache gelöscht.',
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
},
footer: {
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
},
sheet: {
openLabel: 'Einstellungen oeffnen',
backLabel: 'Zurueck',
openLabel: 'Einstellungen öffnen',
backLabel: 'Zurück',
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 Header from '../components/Header';
import BottomNav from '../components/BottomNav';
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { useAppearance } from '../../hooks/use-appearance';
import { cn } from '@/lib/utils';
import {
AlertTriangle,
@@ -46,6 +45,39 @@ type CameraPreferences = {
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 = {
facingMode: 'environment',
countdownSeconds: 3,
@@ -60,8 +92,6 @@ export default function UploadPage() {
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(token);
const { t } = useTranslation();
@@ -75,7 +105,6 @@ export default function UploadPage() {
const [task, setTask] = useState<Task | null>(null);
const [loadingTask, setLoadingTask] = useState(true);
const [taskError, setTaskError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
@@ -138,8 +167,7 @@ export default function UploadPage() {
// Load task metadata
useEffect(() => {
if (!token || !taskId) {
setTaskError(t('upload.loadError.title'));
if (!token || taskId === null) {
setLoadingTask(false);
return;
}
@@ -147,18 +175,19 @@ export default function UploadPage() {
let active = true;
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 fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
try {
setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json();
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
const payload = (await res.json()) as unknown;
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return;
@@ -174,7 +203,7 @@ export default function UploadPage() {
});
} else {
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -188,9 +217,8 @@ export default function UploadPage() {
} catch (error) {
console.error('Failed to fetch task', error);
if (active) {
setTaskError(t('upload.loadError.title'));
setTask({
id: taskId!,
id: currentTaskId,
title: fallbackTitle,
description: fallbackDescription,
instructions: fallbackInstructions,
@@ -210,7 +238,7 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [eventKey, taskId, emotionSlug, t]);
}, [eventKey, taskId, emotionSlug, t, token]);
// Check upload limits
useEffect(() => {
@@ -294,14 +322,15 @@ export default function UploadPage() {
streamRef.current = stream;
attachStreamToVideo(stream);
setPermissionState('granted');
} catch (error: any) {
} catch (error: unknown) {
console.error('Camera access error', error);
stopStream();
if (error?.name === 'NotAllowedError') {
const errorName = getErrorName(error);
if (errorName === 'NotAllowedError') {
setPermissionState('denied');
setPermissionMessage(t('upload.cameraDenied.explanation'));
} else if (error?.name === 'NotFoundError') {
} else if (errorName === 'NotFoundError') {
setPermissionState('error');
setPermissionMessage(t('upload.cameraUnsupported.message'));
} else {
@@ -489,9 +518,9 @@ export default function UploadPage() {
markCompleted(task.id);
stopStream();
navigateAfterUpload(photoId);
} catch (error: any) {
} catch (error: unknown) {
console.error('Upload failed', error);
setUploadError(error?.message || t('upload.status.failed'));
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -533,7 +562,6 @@ export default function UploadPage() {
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading';
const isUploadDisabled = !canUpload || !task;
useEffect(() => () => {
resetCountdownTimer();
@@ -542,38 +570,33 @@ export default function UploadPage() {
}
}, [resetCountdownTimer]);
if (!supportsCamera && !task) {
return (
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<main className={mainClassName}>{content}</main>
<BottomNav />
</div>
);
if (!supportsCamera && !task) {
return renderPage(
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
);
}
if (loadingTask) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
return renderPage(
<div className="flex flex-col items-center justify-center gap-4 text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
</main>
<BottomNav />
</div>
);
}
if (!canUpload) {
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
return renderPage(
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
@@ -582,9 +605,6 @@ export default function UploadPage() {
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
);
}
@@ -636,10 +656,8 @@ export default function UploadPage() {
);
};
return (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="relative flex flex-col gap-4 pb-4">
return renderPage(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
@@ -863,9 +881,10 @@ export default function UploadPage() {
/>
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
</main>
<BottomNav />
<canvas ref={canvasRef} className="hidden" />
</div>
</>
,
'relative flex flex-col gap-4 pb-4'
);
}

View File

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

View File

@@ -11,7 +11,14 @@
$instructions = $layout['instructions'] ?? [];
$description = $layout['description'] ?? '';
$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)) : [];
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : [];
$instructionStartY = 870;
@@ -111,7 +118,11 @@
<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 }}" />
<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)
<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>
@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)
@php
@@ -141,13 +152,13 @@
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
@endforeach
<text x="640" y="760" fill="{{ $accent }}" class="small-label">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)" />
<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 }}" />
<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">
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>

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\TaskCollectionController;
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\TenantPackageController;
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::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
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::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
@@ -82,6 +84,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->whereNumber('joinToken')
->where('format', 'pdf|svg')
->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'])
->whereNumber('joinToken')
->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'])
->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);
}
}