feat: extend event toolkit and polish guest pwa
This commit is contained in:
129
app/Filament/Resources/InviteLayouts/InviteLayoutResource.php
Normal file
129
app/Filament/Resources/InviteLayouts/InviteLayoutResource.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Tenant\EventStoreRequest;
|
use App\Http\Requests\Tenant\EventStoreRequest;
|
||||||
|
use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||||
use App\Http\Resources\Tenant\EventResource;
|
use App\Http\Resources\Tenant\EventResource;
|
||||||
|
use App\Http\Resources\Tenant\PhotoResource;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
@@ -228,6 +230,10 @@ class EventController extends Controller
|
|||||||
unset($validated[$unused]);
|
unset($validated[$unused]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($validated['settings']) && is_array($validated['settings'])) {
|
||||||
|
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
|
||||||
|
}
|
||||||
|
|
||||||
$event->update($validated);
|
$event->update($validated);
|
||||||
$event->load(['eventType', 'tenant']);
|
$event->load(['eventType', 'tenant']);
|
||||||
|
|
||||||
@@ -277,6 +283,141 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toolkit(Request $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
|
if ($event->tenant_id !== $tenantId) {
|
||||||
|
return response()->json(['error' => 'Event not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->load(['eventType', 'eventPackage.package']);
|
||||||
|
|
||||||
|
$photoQuery = Photo::query()->where('event_id', $event->id);
|
||||||
|
$pendingPhotos = (clone $photoQuery)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->latest('created_at')
|
||||||
|
->take(6)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recentUploads = (clone $photoQuery)
|
||||||
|
->where('status', 'approved')
|
||||||
|
->latest('created_at')
|
||||||
|
->take(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$pendingCount = (clone $photoQuery)->where('status', 'pending')->count();
|
||||||
|
$uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count();
|
||||||
|
$totalUploads = (clone $photoQuery)->count();
|
||||||
|
|
||||||
|
$tasks = $event->tasks()
|
||||||
|
->orderBy('tasks.sort_order')
|
||||||
|
->orderBy('tasks.created_at')
|
||||||
|
->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']);
|
||||||
|
|
||||||
|
$taskSummary = [
|
||||||
|
'total' => $tasks->count(),
|
||||||
|
'completed' => $tasks->where('is_completed', true)->count(),
|
||||||
|
];
|
||||||
|
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
|
||||||
|
|
||||||
|
$translate = static function ($value, string $fallback = '') {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$candidates = array_filter([
|
||||||
|
$locale,
|
||||||
|
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
|
||||||
|
'de',
|
||||||
|
'en',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') {
|
||||||
|
return $value[$candidate];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = reset($value);
|
||||||
|
|
||||||
|
return $first !== false ? $first : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
$taskPreview = $tasks
|
||||||
|
->take(6)
|
||||||
|
->map(fn ($task) => [
|
||||||
|
'id' => $task->id,
|
||||||
|
'title' => $translate($task->title, 'Task'),
|
||||||
|
'description' => $translate($task->description, null),
|
||||||
|
'is_completed' => (bool) $task->is_completed,
|
||||||
|
'priority' => $task->priority,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$joinTokenQuery = $event->joinTokens();
|
||||||
|
$totalInvites = (clone $joinTokenQuery)->count();
|
||||||
|
$activeInvites = (clone $joinTokenQuery)
|
||||||
|
->whereNull('revoked_at')
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('usage_limit')
|
||||||
|
->orWhereColumn('usage_limit', '>', 'usage_count');
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$recentInvites = (clone $joinTokenQuery)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->take(3)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$alerts = [];
|
||||||
|
if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) {
|
||||||
|
$alerts[] = 'no_tasks';
|
||||||
|
}
|
||||||
|
if ($activeInvites === 0) {
|
||||||
|
$alerts[] = 'no_invites';
|
||||||
|
}
|
||||||
|
if ($pendingCount > 0) {
|
||||||
|
$alerts[] = 'pending_photos';
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'event' => new EventResource($event),
|
||||||
|
'metrics' => [
|
||||||
|
'uploads_total' => $totalUploads,
|
||||||
|
'uploads_24h' => $uploads24h,
|
||||||
|
'pending_photos' => $pendingCount,
|
||||||
|
'active_invites' => $activeInvites,
|
||||||
|
'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks',
|
||||||
|
],
|
||||||
|
'tasks' => [
|
||||||
|
'summary' => $taskSummary,
|
||||||
|
'items' => $taskPreview,
|
||||||
|
],
|
||||||
|
'photos' => [
|
||||||
|
'pending' => PhotoResource::collection($pendingPhotos)->resolve($request),
|
||||||
|
'recent' => PhotoResource::collection($recentUploads)->resolve($request),
|
||||||
|
],
|
||||||
|
'invites' => [
|
||||||
|
'summary' => [
|
||||||
|
'total' => $totalInvites,
|
||||||
|
'active' => $activeInvites,
|
||||||
|
],
|
||||||
|
'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request),
|
||||||
|
],
|
||||||
|
'alerts' => $alerts,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function toggle(Request $request, Event $event): JsonResponse
|
public function toggle(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -30,12 +31,7 @@ class EventJoinTokenController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $this->validatePayload($request);
|
||||||
'label' => ['nullable', 'string', 'max:255'],
|
|
||||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
|
||||||
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
|
||||||
'metadata' => ['nullable', 'array'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
||||||
'created_by' => Auth::id(),
|
'created_by' => Auth::id(),
|
||||||
@@ -46,6 +42,50 @@ class EventJoinTokenController extends Controller
|
|||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
|
{
|
||||||
|
$this->authorizeEvent($request, $event);
|
||||||
|
|
||||||
|
if ($joinToken->event_id !== $event->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $this->validatePayload($request, true);
|
||||||
|
|
||||||
|
$payload = [];
|
||||||
|
|
||||||
|
if (array_key_exists('label', $validated)) {
|
||||||
|
$payload['label'] = $validated['label'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('expires_at', $validated)) {
|
||||||
|
$payload['expires_at'] = $validated['expires_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('usage_limit', $validated)) {
|
||||||
|
$payload['usage_limit'] = $validated['usage_limit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($payload)) {
|
||||||
|
$joinToken->fill($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('metadata', $validated)) {
|
||||||
|
$current = is_array($joinToken->metadata) ? $joinToken->metadata : [];
|
||||||
|
$incoming = $validated['metadata'];
|
||||||
|
|
||||||
|
if ($incoming === null) {
|
||||||
|
$joinToken->metadata = null;
|
||||||
|
} else {
|
||||||
|
$joinToken->metadata = array_replace_recursive($current, $incoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$joinToken->save();
|
||||||
|
|
||||||
|
return new EventJoinTokenResource($joinToken->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
@@ -68,4 +108,54 @@ class EventJoinTokenController extends Controller
|
|||||||
abort(404, 'Event not found');
|
abort(404, 'Event not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validatePayload(Request $request, bool $partial = false): array
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
||||||
|
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
|
||||||
|
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
||||||
|
'metadata.layout_customization' => ['nullable', 'array'],
|
||||||
|
'metadata.layout_customization.layout_id' => ['nullable', 'string', 'max:100'],
|
||||||
|
'metadata.layout_customization.headline' => ['nullable', 'string', 'max:120'],
|
||||||
|
'metadata.layout_customization.subtitle' => ['nullable', 'string', 'max:160'],
|
||||||
|
'metadata.layout_customization.description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'metadata.layout_customization.badge_label' => ['nullable', 'string', 'max:80'],
|
||||||
|
'metadata.layout_customization.instructions_heading' => ['nullable', 'string', 'max:120'],
|
||||||
|
'metadata.layout_customization.link_heading' => ['nullable', 'string', 'max:120'],
|
||||||
|
'metadata.layout_customization.cta_label' => ['nullable', 'string', 'max:120'],
|
||||||
|
'metadata.layout_customization.cta_caption' => ['nullable', 'string', 'max:160'],
|
||||||
|
'metadata.layout_customization.link_label' => ['nullable', 'string', 'max:160'],
|
||||||
|
'metadata.layout_customization.instructions' => ['nullable', 'array', 'max:6'],
|
||||||
|
'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'],
|
||||||
|
'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'metadata.layout_customization.logo_data_url' => ['nullable', 'string'],
|
||||||
|
'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
'metadata.layout_customization.secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
'metadata.layout_customization.badge_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
'metadata.layout_customization.background_gradient' => ['nullable', 'array'],
|
||||||
|
'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'],
|
||||||
|
'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'],
|
||||||
|
'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
|
if (isset($validated['metadata']['layout_customization']['instructions'])) {
|
||||||
|
$validated['metadata']['layout_customization']['instructions'] = array_values(array_filter(
|
||||||
|
$validated['metadata']['layout_customization']['instructions'],
|
||||||
|
fn ($value) => is_string($value) && trim($value) !== ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($validated['metadata']['layout_customization']['logo_data_url'])
|
||||||
|
&& ! is_string($validated['metadata']['layout_customization']['logo_data_url'])) {
|
||||||
|
unset($validated['metadata']['layout_customization']['logo_data_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
abort(404, 'Unbekanntes Exportformat.');
|
abort(404, 'Unbekanntes Exportformat.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
|
||||||
|
|
||||||
$tokenUrl = url('/e/'.$joinToken->token);
|
$tokenUrl = url('/e/'.$joinToken->token);
|
||||||
|
|
||||||
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
|
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
|
||||||
@@ -66,6 +68,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
'tokenUrl' => $tokenUrl,
|
'tokenUrl' => $tokenUrl,
|
||||||
'qrPngDataUri' => $qrPngDataUri,
|
'qrPngDataUri' => $qrPngDataUri,
|
||||||
'backgroundStyle' => $backgroundStyle,
|
'backgroundStyle' => $backgroundStyle,
|
||||||
|
'customization' => $joinToken->metadata['layout_customization'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
|
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
|
||||||
@@ -80,7 +83,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
|
|
||||||
$html = view('layouts.join-token.pdf', $viewData)->render();
|
$html = view('layouts.join-token.pdf', $viewData)->render();
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options;
|
||||||
$options->set('isHtml5ParserEnabled', true);
|
$options->set('isHtml5ParserEnabled', true);
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
$options->set('defaultFont', 'Helvetica');
|
$options->set('defaultFont', 'Helvetica');
|
||||||
@@ -115,6 +118,57 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
return is_string($name) && $name !== '' ? $name : 'Event';
|
return is_string($name) && $name !== '' ? $name : 'Event';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
|
||||||
|
{
|
||||||
|
$customization = data_get($joinToken->metadata, 'layout_customization');
|
||||||
|
|
||||||
|
if (! is_array($customization)) {
|
||||||
|
return $layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
$layoutId = $customization['layout_id'] ?? null;
|
||||||
|
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
|
||||||
|
// Allow customization to target a specific layout; if mismatch, skip style overrides.
|
||||||
|
// General text overrides are still applied below.
|
||||||
|
}
|
||||||
|
|
||||||
|
$colorKeys = [
|
||||||
|
'accent' => 'accent_color',
|
||||||
|
'text' => 'text_color',
|
||||||
|
'background' => 'background_color',
|
||||||
|
'secondary' => 'secondary_color',
|
||||||
|
'badge' => 'badge_color',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($colorKeys as $layoutKey => $customKey) {
|
||||||
|
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
|
||||||
|
$layout[$layoutKey] = $customization[$customKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
|
||||||
|
$layout['background_gradient'] = $customization['background_gradient'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
|
||||||
|
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
|
||||||
|
$layout[$layoutKey] = $customization[$customKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
|
||||||
|
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
|
||||||
|
$layout['logo_url'] = $customization['logo_data_url'];
|
||||||
|
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
|
||||||
|
$layout['logo_url'] = $customization['logo_url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $layout;
|
||||||
|
}
|
||||||
|
|
||||||
private function buildBackgroundStyle(array $layout): string
|
private function buildBackgroundStyle(array $layout): string
|
||||||
{
|
{
|
||||||
$gradient = $layout['background_gradient'] ?? null;
|
$gradient = $layout['background_gradient'] ?? null;
|
||||||
|
|||||||
63
app/Http/Controllers/Api/Tenant/TenantFeedbackController.php
Normal file
63
app/Http/Controllers/Api/Tenant/TenantFeedbackController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,22 +7,25 @@ use App\Models\OAuthCode;
|
|||||||
use App\Models\RefreshToken;
|
use App\Models\RefreshToken;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantToken;
|
use App\Models\TenantToken;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Firebase\JWT\JWT;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class OAuthController extends Controller
|
class OAuthController extends Controller
|
||||||
{
|
{
|
||||||
private const AUTH_CODE_TTL_MINUTES = 5;
|
private const AUTH_CODE_TTL_MINUTES = 5;
|
||||||
|
|
||||||
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||||
|
|
||||||
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +107,14 @@ class OAuthController extends Controller
|
|||||||
'state' => $request->state,
|
'state' => $request->state,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => $code,
|
||||||
|
'state' => $request->state,
|
||||||
|
'redirect_url' => $redirectUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->away($redirectUrl);
|
return redirect()->away($redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +413,40 @@ class OAuthController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
|
||||||
|
{
|
||||||
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$redirectUri = (string) $request->string('redirect_uri');
|
||||||
|
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
|
||||||
|
$requestHost = $request->getHost();
|
||||||
|
|
||||||
|
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = $request->headers->get('Origin');
|
||||||
|
if ($origin) {
|
||||||
|
$originHost = parse_url($origin, PHP_URL_HOST);
|
||||||
|
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hostsMatch(?string $first, ?string $second): bool
|
||||||
|
{
|
||||||
|
if (! $first || ! $second) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($first) === strtolower($second);
|
||||||
|
}
|
||||||
|
|
||||||
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
|
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
|
||||||
{
|
{
|
||||||
$refreshTokenId = (string) Str::uuid();
|
$refreshTokenId = (string) Str::uuid();
|
||||||
@@ -566,6 +611,7 @@ class OAuthController extends Controller
|
|||||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
||||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
||||||
{
|
{
|
||||||
if (empty($requestedScopes)) {
|
if (empty($requestedScopes)) {
|
||||||
@@ -682,7 +728,7 @@ class OAuthController extends Controller
|
|||||||
return redirect('/event-admin')->with('error', 'Invalid state parameter');
|
return redirect('/event-admin')->with('error', 'Invalid state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
$client = new Client();
|
$client = new Client;
|
||||||
$clientId = config('services.stripe.connect_client_id');
|
$clientId = config('services.stripe.connect_client_id');
|
||||||
$secret = config('services.stripe.connect_secret');
|
$secret = config('services.stripe.connect_secret');
|
||||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||||
@@ -710,11 +756,12 @@ class OAuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
session()->forget(['stripe_state', 'tenant_id']);
|
session()->forget(['stripe_state', 'tenant_id']);
|
||||||
|
|
||||||
return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
|
return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Stripe OAuth error: '.$e->getMessage());
|
Log::error('Stripe OAuth error: '.$e->getMessage());
|
||||||
|
|
||||||
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
|
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class EventStoreRequest extends FormRequest
|
|||||||
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
|
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
|
||||||
'features' => ['nullable', 'array'],
|
'features' => ['nullable', 'array'],
|
||||||
'features.*' => ['string'],
|
'features.*' => ['string'],
|
||||||
|
'settings' => ['nullable', 'array'],
|
||||||
|
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class EventResource extends JsonResource
|
|||||||
'status' => $this->status ?? 'draft',
|
'status' => $this->status ?? 'draft',
|
||||||
'is_active' => (bool) ($this->is_active ?? false),
|
'is_active' => (bool) ($this->is_active ?? false),
|
||||||
'features' => $settings['features'] ?? [],
|
'features' => $settings['features'] ?? [],
|
||||||
|
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
|
||||||
|
'settings' => $settings,
|
||||||
'event_type_id' => $this->event_type_id,
|
'event_type_id' => $this->event_type_id,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
|
|||||||
38
app/Models/InviteLayout.php
Normal file
38
app/Models/InviteLayout.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/TenantFeedback.php
Normal file
30
app/Models/TenantFeedback.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -43,6 +44,10 @@ class TenantPackage extends Model
|
|||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->package && $this->package->isEndcustomer()) {
|
||||||
|
return (bool) $this->active;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
|
return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,24 +81,44 @@ class TenantPackage extends Model
|
|||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::creating(function ($tenantPackage) {
|
static::creating(function (self $tenantPackage) {
|
||||||
if (! $tenantPackage->purchased_at) {
|
if (! $tenantPackage->purchased_at) {
|
||||||
$tenantPackage->purchased_at = now();
|
$tenantPackage->purchased_at = now();
|
||||||
}
|
}
|
||||||
if (! $tenantPackage->expires_at && $tenantPackage->package) {
|
|
||||||
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
|
$package = $tenantPackage->package;
|
||||||
|
|
||||||
|
if ($package && $package->isReseller()) {
|
||||||
|
if (! $tenantPackage->expires_at) {
|
||||||
|
$tenantPackage->expires_at = now()->addYear();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$tenantPackage->expires_at = now()->addCentury();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantPackage->active === null) {
|
||||||
$tenantPackage->active = true;
|
$tenantPackage->active = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
static::updating(function ($tenantPackage) {
|
static::updating(function (self $tenantPackage) {
|
||||||
|
$package = $tenantPackage->package;
|
||||||
|
|
||||||
|
if ($package && $package->isReseller()) {
|
||||||
if (
|
if (
|
||||||
$tenantPackage->isDirty('expires_at')
|
$tenantPackage->isDirty('expires_at')
|
||||||
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface
|
&& $tenantPackage->expires_at instanceof CarbonInterface
|
||||||
&& $tenantPackage->expires_at->isPast()
|
&& $tenantPackage->expires_at->isPast()
|
||||||
) {
|
) {
|
||||||
$tenantPackage->active = false;
|
$tenantPackage->active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantPackage->isDirty('expires_at')) {
|
||||||
|
$tenantPackage->expires_at = now()->addCentury();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\InviteLayout;
|
||||||
|
|
||||||
class JoinTokenLayoutRegistry
|
class JoinTokenLayoutRegistry
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -123,6 +125,18 @@ class JoinTokenLayoutRegistry
|
|||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
|
$customLayouts = InviteLayout::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($customLayouts->isNotEmpty()) {
|
||||||
|
return $customLayouts
|
||||||
|
->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout)))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
|
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +145,15 @@ class JoinTokenLayoutRegistry
|
|||||||
*/
|
*/
|
||||||
public static function find(string $id): ?array
|
public static function find(string $id): ?array
|
||||||
{
|
{
|
||||||
|
$custom = InviteLayout::query()
|
||||||
|
->where('slug', $id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($custom) {
|
||||||
|
return self::normalize(self::fromModel($custom));
|
||||||
|
}
|
||||||
|
|
||||||
$layout = self::LAYOUTS[$id] ?? null;
|
$layout = self::LAYOUTS[$id] ?? null;
|
||||||
|
|
||||||
return $layout ? self::normalize($layout) : null;
|
return $layout ? self::normalize($layout) : null;
|
||||||
@@ -151,6 +174,13 @@ class JoinTokenLayoutRegistry
|
|||||||
'accent' => '#6366F1',
|
'accent' => '#6366F1',
|
||||||
'secondary' => '#CBD5F5',
|
'secondary' => '#CBD5F5',
|
||||||
'badge' => '#2563EB',
|
'badge' => '#2563EB',
|
||||||
|
'badge_label' => 'Digitale Gästebox',
|
||||||
|
'instructions_heading' => "So funktioniert's",
|
||||||
|
'link_heading' => 'Alternative zum Einscannen',
|
||||||
|
'cta_label' => 'Scan mich & starte direkt',
|
||||||
|
'cta_caption' => 'Scan mich & starte direkt',
|
||||||
|
'link_label' => null,
|
||||||
|
'logo_url' => null,
|
||||||
'qr' => [
|
'qr' => [
|
||||||
'size_px' => 320,
|
'size_px' => 320,
|
||||||
],
|
],
|
||||||
@@ -160,11 +190,50 @@ class JoinTokenLayoutRegistry
|
|||||||
],
|
],
|
||||||
'background_gradient' => null,
|
'background_gradient' => null,
|
||||||
'instructions' => [],
|
'instructions' => [],
|
||||||
|
'formats' => ['pdf', 'svg'],
|
||||||
];
|
];
|
||||||
|
|
||||||
return array_replace_recursive($defaults, $layout);
|
return array_replace_recursive($defaults, $layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function fromModel(InviteLayout $layout): array
|
||||||
|
{
|
||||||
|
$preview = $layout->preview ?? [];
|
||||||
|
$options = $layout->layout_options ?? [];
|
||||||
|
$instructions = $layout->instructions ?? [];
|
||||||
|
|
||||||
|
return array_filter([
|
||||||
|
'id' => $layout->slug,
|
||||||
|
'name' => $layout->name,
|
||||||
|
'subtitle' => $layout->subtitle,
|
||||||
|
'description' => $layout->description,
|
||||||
|
'paper' => $layout->paper,
|
||||||
|
'orientation' => $layout->orientation,
|
||||||
|
'background' => $preview['background'] ?? null,
|
||||||
|
'background_gradient' => $preview['background_gradient'] ?? null,
|
||||||
|
'text' => $preview['text'] ?? null,
|
||||||
|
'accent' => $preview['accent'] ?? null,
|
||||||
|
'secondary' => $preview['secondary'] ?? null,
|
||||||
|
'badge' => $preview['badge'] ?? null,
|
||||||
|
'badge_label' => $options['badge_label'] ?? null,
|
||||||
|
'instructions_heading' => $options['instructions_heading'] ?? null,
|
||||||
|
'link_heading' => $options['link_heading'] ?? null,
|
||||||
|
'cta_label' => $options['cta_label'] ?? null,
|
||||||
|
'cta_caption' => $options['cta_caption'] ?? null,
|
||||||
|
'link_label' => $options['link_label'] ?? null,
|
||||||
|
'logo_url' => $options['logo_url'] ?? null,
|
||||||
|
'qr' => array_filter([
|
||||||
|
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
|
||||||
|
]),
|
||||||
|
'svg' => array_filter([
|
||||||
|
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
|
||||||
|
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
|
||||||
|
]),
|
||||||
|
'formats' => $options['formats'] ?? ['pdf', 'svg'],
|
||||||
|
'instructions' => $instructions,
|
||||||
|
], fn ($value) => $value !== null && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map layouts into an API-ready response structure, attaching URLs.
|
* Map layouts into an API-ready response structure, attaching URLs.
|
||||||
*
|
*
|
||||||
@@ -174,7 +243,7 @@ class JoinTokenLayoutRegistry
|
|||||||
public static function toResponse(callable $urlResolver): array
|
public static function toResponse(callable $urlResolver): array
|
||||||
{
|
{
|
||||||
return array_map(function (array $layout) use ($urlResolver) {
|
return array_map(function (array $layout) use ($urlResolver) {
|
||||||
$formats = ['pdf', 'svg'];
|
$formats = $layout['formats'] ?? ['pdf', 'svg'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $layout['id'],
|
'id' => $layout['id'],
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
TasksSeeder::class,
|
TasksSeeder::class,
|
||||||
EventTasksSeeder::class,
|
EventTasksSeeder::class,
|
||||||
TaskCollectionsSeeder::class,
|
TaskCollectionsSeeder::class,
|
||||||
|
InviteLayoutSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seed demo and admin data
|
// Seed demo and admin data
|
||||||
|
|||||||
57
database/seeders/InviteLayoutSeeder.php
Normal file
57
database/seeders/InviteLayoutSeeder.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Emotion;
|
||||||
|
use App\Models\EventType;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TaskCollection;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Models\{Emotion, Task, EventType};
|
|
||||||
|
|
||||||
class TasksSeeder extends Seeder
|
class TasksSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -39,26 +42,69 @@ class TasksSeeder extends Seeder
|
|||||||
];
|
];
|
||||||
|
|
||||||
$types = EventType::pluck('id', 'slug');
|
$types = EventType::pluck('id', 'slug');
|
||||||
|
$position = 10;
|
||||||
|
|
||||||
foreach ($seed as $emotionNameDe => $tasks) {
|
foreach ($seed as $emotionNameDe => $tasks) {
|
||||||
$emotion = Emotion::where('name->de', $emotionNameDe)->first();
|
$emotion = Emotion::where('name->de', $emotionNameDe)->first();
|
||||||
if (!$emotion) continue;
|
|
||||||
foreach ($tasks as $t) {
|
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']);
|
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
|
||||||
$slug = $slugBase ? $slugBase.'-'.$emotion->id : Str::uuid()->toString();
|
$slug = $slugBase ? $slugBase.'-'.$emotion->id : Str::uuid()->toString();
|
||||||
|
|
||||||
Task::updateOrCreate([
|
$sortOrder = $t['sort_order'] ?? (($index + 1) * 10);
|
||||||
|
|
||||||
|
$task = Task::updateOrCreate(
|
||||||
|
[
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
], [
|
],
|
||||||
|
[
|
||||||
'tenant_id' => $demoTenant->id,
|
'tenant_id' => $demoTenant->id,
|
||||||
'emotion_id' => $emotion->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'],
|
'title' => $t['title'],
|
||||||
'description' => $t['description'],
|
'description' => $t['description'],
|
||||||
'difficulty' => $t['difficulty'],
|
'difficulty' => $t['difficulty'],
|
||||||
'is_active' => true,
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import i18n from './i18n';
|
|||||||
|
|
||||||
type JsonValue = Record<string, any>;
|
type JsonValue = Record<string, any>;
|
||||||
|
|
||||||
export type EventJoinTokenLayout = {
|
export type EventQrInviteLayout = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -41,6 +41,8 @@ export type TenantEvent = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
photo_count?: number;
|
photo_count?: number;
|
||||||
like_count?: number;
|
like_count?: number;
|
||||||
|
engagement_mode?: 'tasks' | 'photo_only';
|
||||||
|
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
|
||||||
package?: {
|
package?: {
|
||||||
id: number | string | null;
|
id: number | string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -246,7 +248,7 @@ export type EventMember = {
|
|||||||
type EventListResponse = { data?: JsonValue[] };
|
type EventListResponse = { data?: JsonValue[] };
|
||||||
type EventResponse = { data: JsonValue };
|
type EventResponse = { data: JsonValue };
|
||||||
|
|
||||||
export type EventJoinToken = {
|
export type EventQrInvite = {
|
||||||
id: number;
|
id: number;
|
||||||
token: string;
|
token: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -258,9 +260,48 @@ export type EventJoinToken = {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
layouts: EventJoinTokenLayout[];
|
layouts: EventQrInviteLayout[];
|
||||||
layouts_url: string | null;
|
layouts_url: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EventToolkitTask = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
is_completed: boolean;
|
||||||
|
priority?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventToolkit = {
|
||||||
|
event: TenantEvent;
|
||||||
|
metrics: {
|
||||||
|
uploads_total: number;
|
||||||
|
uploads_24h: number;
|
||||||
|
pending_photos: number;
|
||||||
|
active_invites: number;
|
||||||
|
engagement_mode: 'tasks' | 'photo_only';
|
||||||
|
};
|
||||||
|
tasks: {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
items: EventToolkitTask[];
|
||||||
|
};
|
||||||
|
photos: {
|
||||||
|
pending: TenantPhoto[];
|
||||||
|
recent: TenantPhoto[];
|
||||||
|
};
|
||||||
|
invites: {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
items: EventQrInvite[];
|
||||||
|
};
|
||||||
|
alerts: string[];
|
||||||
|
};
|
||||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||||
|
|
||||||
@@ -272,6 +313,7 @@ type EventSavePayload = {
|
|||||||
status?: 'draft' | 'published' | 'archived';
|
status?: 'draft' | 'published' | 'archived';
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
package_id?: number;
|
package_id?: number;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||||
@@ -380,6 +422,10 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
|
|||||||
|
|
||||||
function normalizeEvent(event: JsonValue): TenantEvent {
|
function normalizeEvent(event: JsonValue): TenantEvent {
|
||||||
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
|
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
|
||||||
|
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
|
||||||
|
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
|
||||||
|
| 'tasks'
|
||||||
|
| 'photo_only';
|
||||||
const normalized: TenantEvent = {
|
const normalized: TenantEvent = {
|
||||||
...(event as Record<string, unknown>),
|
...(event as Record<string, unknown>),
|
||||||
id: Number(event.id ?? 0),
|
id: Number(event.id ?? 0),
|
||||||
@@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
|||||||
description: event.description ?? null,
|
description: event.description ?? null,
|
||||||
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
||||||
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
||||||
|
engagement_mode: engagementMode,
|
||||||
|
settings,
|
||||||
package: event.package ?? null,
|
package: event.package ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
||||||
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
|
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
|
||||||
const layouts: EventJoinTokenLayout[] = rawLayouts
|
const layouts: EventQrInviteLayout[] = rawLayouts
|
||||||
.map((layout: any) => {
|
.map((layout: any) => {
|
||||||
const formats = Array.isArray(layout.formats)
|
const formats = Array.isArray(layout.formats)
|
||||||
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
|
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
|
||||||
@@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
|||||||
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
|
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0);
|
.filter((layout: EventQrInviteLayout) => layout.id.length > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(raw.id ?? 0),
|
id: Number(raw.id ?? 0),
|
||||||
@@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise<EventStats> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> {
|
export async function getEventQrInvites(slug: string): Promise<EventQrInvite[]> {
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
|
||||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
|
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
|
||||||
const list = Array.isArray(payload.data) ? payload.data : [];
|
const list = Array.isArray(payload.data) ? payload.data : [];
|
||||||
return list.map(normalizeJoinToken);
|
return list.map(normalizeQrInvite);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInviteLink(
|
export async function createQrInvite(
|
||||||
slug: string,
|
slug: string,
|
||||||
payload?: { label?: string; usage_limit?: number; expires_at?: string }
|
payload?: { label?: string; usage_limit?: number; expires_at?: string }
|
||||||
): Promise<EventJoinToken> {
|
): Promise<EventQrInvite> {
|
||||||
const body = JSON.stringify(payload ?? {});
|
const body = JSON.stringify(payload ?? {});
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -739,14 +787,14 @@ export async function createInviteLink(
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
|
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
|
||||||
return normalizeJoinToken(data.data ?? {});
|
return normalizeQrInvite(data.data ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeEventJoinToken(
|
export async function revokeEventQrInvite(
|
||||||
slug: string,
|
slug: string,
|
||||||
tokenId: number,
|
tokenId: number,
|
||||||
reason?: string
|
reason?: string
|
||||||
): Promise<EventJoinToken> {
|
): Promise<EventQrInvite> {
|
||||||
const options: RequestInit = { method: 'DELETE' };
|
const options: RequestInit = { method: 'DELETE' };
|
||||||
if (reason) {
|
if (reason) {
|
||||||
options.headers = { 'Content-Type': 'application/json' };
|
options.headers = { 'Content-Type': 'application/json' };
|
||||||
@@ -754,7 +802,107 @@ export async function revokeEventJoinToken(
|
|||||||
}
|
}
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
|
||||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
|
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
|
||||||
return normalizeJoinToken(data.data ?? {});
|
return normalizeQrInvite(data.data ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventQrInvite(
|
||||||
|
slug: string,
|
||||||
|
tokenId: number,
|
||||||
|
payload: {
|
||||||
|
label?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
usage_limit?: number | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
): Promise<EventQrInvite> {
|
||||||
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation');
|
||||||
|
return normalizeQrInvite(data.data ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
||||||
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`);
|
||||||
|
const json = await jsonOrThrow<Record<string, JsonValue>>(response, 'Failed to load toolkit');
|
||||||
|
|
||||||
|
const metrics = json.metrics ?? {};
|
||||||
|
const tasks = json.tasks ?? {};
|
||||||
|
const photos = json.photos ?? {};
|
||||||
|
const invites = json.invites ?? {};
|
||||||
|
|
||||||
|
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
|
||||||
|
? (photos as Record<string, JsonValue>).pending
|
||||||
|
: [];
|
||||||
|
const recentPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).recent)
|
||||||
|
? (photos as Record<string, JsonValue>).recent
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const toolkit: EventToolkit = {
|
||||||
|
event: normalizeEvent(json.event ?? {}),
|
||||||
|
metrics: {
|
||||||
|
uploads_total: Number((metrics as JsonValue).uploads_total ?? 0),
|
||||||
|
uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0),
|
||||||
|
pending_photos: Number((metrics as JsonValue).pending_photos ?? 0),
|
||||||
|
active_invites: Number((metrics as JsonValue).active_invites ?? 0),
|
||||||
|
engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
summary: {
|
||||||
|
total: Number((tasks as JsonValue)?.summary?.total ?? 0),
|
||||||
|
completed: Number((tasks as JsonValue)?.summary?.completed ?? 0),
|
||||||
|
pending: Number((tasks as JsonValue)?.summary?.pending ?? 0),
|
||||||
|
},
|
||||||
|
items: Array.isArray((tasks as JsonValue)?.items)
|
||||||
|
? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({
|
||||||
|
id: Number(item?.id ?? 0),
|
||||||
|
title: String(item?.title ?? ''),
|
||||||
|
description: item?.description !== undefined && item?.description !== null ? String(item.description) : null,
|
||||||
|
is_completed: Boolean(item?.is_completed ?? false),
|
||||||
|
priority: item?.priority !== undefined ? String(item.priority) : null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
photos: {
|
||||||
|
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
||||||
|
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
summary: {
|
||||||
|
total: Number((invites as JsonValue)?.summary?.total ?? 0),
|
||||||
|
active: Number((invites as JsonValue)?.summary?.active ?? 0),
|
||||||
|
},
|
||||||
|
items: Array.isArray((invites as JsonValue)?.items)
|
||||||
|
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return toolkit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTenantFeedback(payload: {
|
||||||
|
category: string;
|
||||||
|
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||||
|
rating?: number | null;
|
||||||
|
title?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
event_slug?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const response = await authorizedFetch('/api/v1/tenant/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await safeJson(response);
|
||||||
|
console.error('[API] Failed to submit feedback', response.status, body);
|
||||||
|
throw new Error('Failed to submit feedback');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Package = {
|
export type Package = {
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event
|
|||||||
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
|
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
|
||||||
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
||||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||||
|
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
|||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
|
|
||||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`);
|
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
|
||||||
verifyState(callbackUrl.searchParams.get('state'), state);
|
verifyState(callbackUrl.searchParams.get('state'), state);
|
||||||
|
|
||||||
const code = callbackUrl.searchParams.get('code');
|
const code = callbackUrl.searchParams.get('code');
|
||||||
@@ -115,22 +115,53 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
|||||||
globalThis.fotospielDemoAuth = api;
|
globalThis.fotospielDemoAuth = api;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestAuthorization(url: string): Promise<URL> {
|
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', url, true);
|
xhr.open('GET', url, true);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
xhr.onreadystatechange = () => {
|
xhr.onreadystatechange = () => {
|
||||||
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
|
||||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||||
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
|
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
|
||||||
if (responseUrl) {
|
if (responseUrl) {
|
||||||
resolve(new URL(responseUrl, window.location.origin));
|
resolve(new URL(responseUrl, window.location.origin));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(xhr.responseText ?? '{}') as {
|
||||||
|
code?: string;
|
||||||
|
state?: string | null;
|
||||||
|
redirect_url?: string | null;
|
||||||
|
};
|
||||||
|
const target = payload.redirect_url ?? fallbackRedirect;
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('Authorize response missing redirect target');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = new URL(target, window.location.origin);
|
||||||
|
if (payload.code && !finalUrl.searchParams.has('code')) {
|
||||||
|
finalUrl.searchParams.set('code', payload.code);
|
||||||
|
}
|
||||||
|
if (payload.state && !finalUrl.searchParams.has('state')) {
|
||||||
|
finalUrl.searchParams.set('state', payload.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(finalUrl);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||||
|
|||||||
@@ -36,6 +36,36 @@
|
|||||||
"lowCredits": "Auffüllen empfohlen"
|
"lowCredits": "Auffüllen empfohlen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"readiness": {
|
||||||
|
"title": "Bereit für den Eventstart",
|
||||||
|
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
|
||||||
|
"pending": "Noch offen",
|
||||||
|
"complete": "Erledigt",
|
||||||
|
"items": {
|
||||||
|
"event": {
|
||||||
|
"title": "Event angelegt",
|
||||||
|
"hint": "Lege dein erstes Event an oder öffne dein jüngstes Event."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "Aufgaben kuratiert",
|
||||||
|
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"title": "QR-Einladung erstellt",
|
||||||
|
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"title": "Paket aktiv",
|
||||||
|
"hint": "Wähle ein Paket, das zu eurem Umfang passt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"createEvent": "Event erstellen",
|
||||||
|
"openTasks": "Tasks öffnen",
|
||||||
|
"openQr": "QR-Einladungen",
|
||||||
|
"openPackages": "Pakete ansehen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"title": "Schnellaktionen",
|
"title": "Schnellaktionen",
|
||||||
"description": "Starte durch mit den wichtigsten Aktionen.",
|
"description": "Starte durch mit den wichtigsten Aktionen.",
|
||||||
|
|||||||
@@ -147,7 +147,9 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"missingSlug": "Kein Event-Slug angegeben.",
|
"missingSlug": "Kein Event-Slug angegeben.",
|
||||||
"load": "Event-Tasks konnten nicht geladen werden.",
|
"load": "Event-Tasks konnten nicht geladen werden.",
|
||||||
"assign": "Tasks konnten nicht zugewiesen werden."
|
"assign": "Tasks konnten nicht zugewiesen werden.",
|
||||||
|
"photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.",
|
||||||
|
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden."
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"notFoundTitle": "Event nicht gefunden",
|
"notFoundTitle": "Event nicht gefunden",
|
||||||
@@ -169,6 +171,147 @@
|
|||||||
"medium": "Mittel",
|
"medium": "Mittel",
|
||||||
"high": "Hoch",
|
"high": "Hoch",
|
||||||
"urgent": "Dringend"
|
"urgent": "Dringend"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"title": "Aufgaben & Foto-Modus",
|
||||||
|
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
|
||||||
|
"tasksHint": "Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.",
|
||||||
|
"photoOnly": "Foto-Modus",
|
||||||
|
"tasks": "Aufgaben aktiv",
|
||||||
|
"switchLabel": "Foto-Modus aktivieren",
|
||||||
|
"updating": "Einstellung wird gespeichert ..."
|
||||||
|
},
|
||||||
|
"toolkit": {
|
||||||
|
"titleFallback": "Event-Day Toolkit",
|
||||||
|
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
|
||||||
|
"errors": {
|
||||||
|
"missingSlug": "Kein Event-Slug angegeben.",
|
||||||
|
"loadFailed": "Toolkit konnte nicht geladen werden.",
|
||||||
|
"feedbackFailed": "Feedback konnte nicht gesendet werden."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"backToEvent": "Zurück zum Event",
|
||||||
|
"moderate": "Fotos moderieren",
|
||||||
|
"manageTasks": "Tasks öffnen",
|
||||||
|
"refresh": "Aktualisieren"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"errorTitle": "Fehler",
|
||||||
|
"attention": "Achtung",
|
||||||
|
"noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.",
|
||||||
|
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.",
|
||||||
|
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"uploadsTotal": "Uploads gesamt",
|
||||||
|
"uploads24h": "Uploads (24h)",
|
||||||
|
"pendingPhotos": "Unmoderierte Fotos",
|
||||||
|
"activeInvites": "Aktive Einladungen",
|
||||||
|
"engagementMode": "Modus",
|
||||||
|
"modePhotoOnly": "Foto-Modus",
|
||||||
|
"modeTasks": "Aufgaben"
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Wartende Fotos",
|
||||||
|
"subtitle": "Moderationsempfehlung für neue Uploads.",
|
||||||
|
"cta": "Zur Moderation",
|
||||||
|
"empty": "Aktuell warten keine Fotos auf Freigabe.",
|
||||||
|
"unknownUploader": "Unbekannter Gast",
|
||||||
|
"uploadedAt": "Hochgeladen:",
|
||||||
|
"statusPending": "Status: Prüfung ausstehend"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"title": "QR-Einladungen",
|
||||||
|
"subtitle": "Aktive Links und Layouts im Blick behalten.",
|
||||||
|
"activeCount": "{{count}} aktiv",
|
||||||
|
"totalCount": "{{count}} gesamt",
|
||||||
|
"empty": "Noch keine QR-Einladungen erstellt.",
|
||||||
|
"statusActive": "Aktiv",
|
||||||
|
"statusInactive": "Inaktiv",
|
||||||
|
"manage": "Einladungen verwalten"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "Aktive Aufgaben",
|
||||||
|
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
|
||||||
|
"summary": "{{completed}} von {{total}} erledigt",
|
||||||
|
"empty": "Noch keine Aufgaben zugewiesen.",
|
||||||
|
"manage": "Tasks verwalten",
|
||||||
|
"completed": "Erledigt",
|
||||||
|
"open": "Offen"
|
||||||
|
},
|
||||||
|
"recent": {
|
||||||
|
"title": "Neueste Uploads",
|
||||||
|
"subtitle": "Ein Blick auf die letzten Fotos der Gäste.",
|
||||||
|
"empty": "Noch keine freigegebenen Fotos vorhanden."
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"title": "Wie hilfreich ist dieses Toolkit?",
|
||||||
|
"subtitle": "Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.",
|
||||||
|
"positive": "Hilfreich",
|
||||||
|
"neutral": "Ganz okay",
|
||||||
|
"negative": "Verbesserungsbedarf",
|
||||||
|
"placeholder": "Erzähle uns kurz, was dir gefallen hat oder was fehlt …",
|
||||||
|
"disclaimer": "Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.",
|
||||||
|
"submit": "Feedback senden",
|
||||||
|
"thanksTitle": "Danke!",
|
||||||
|
"thanksDescription": "Wir haben dein Feedback erhalten.",
|
||||||
|
"badge": "Angepasst"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "QR-Einladung anpassen",
|
||||||
|
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
|
||||||
|
"layout": "Layout",
|
||||||
|
"selectLayout": "Layout auswählen",
|
||||||
|
"headline": "Überschrift",
|
||||||
|
"subtitle": "Unterzeile",
|
||||||
|
"descriptionLabel": "Beschreibung",
|
||||||
|
"badgeLabel": "Badge",
|
||||||
|
"instructionsHeading": "Anleitungstitel",
|
||||||
|
"instructionsLabel": "Hinweistexte",
|
||||||
|
"addInstruction": "Hinweis hinzufügen",
|
||||||
|
"removeInstruction": "Entfernen",
|
||||||
|
"linkHeading": "Link-Titel",
|
||||||
|
"linkLabel": "Link",
|
||||||
|
"ctaLabel": "Call-to-Action",
|
||||||
|
"colors": {
|
||||||
|
"accent": "Akzentfarbe",
|
||||||
|
"text": "Textfarbe",
|
||||||
|
"background": "Hintergrund",
|
||||||
|
"secondary": "Sekundärfarbe",
|
||||||
|
"badge": "Badge-Farbe"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"label": "Logo",
|
||||||
|
"hint": "PNG oder SVG, max. 1 MB. Wird oben rechts platziert.",
|
||||||
|
"remove": "Logo entfernen"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Vorschau",
|
||||||
|
"hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"reset": "Zurücksetzen"
|
||||||
|
},
|
||||||
|
"badge": "Angepasst",
|
||||||
|
"actionLabel": "Layout anpassen",
|
||||||
|
"errors": {
|
||||||
|
"logoTooLarge": "Das Logo darf maximal 1 MB groß sein.",
|
||||||
|
"noLayout": "Bitte wähle ein Layout aus."
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"badgeLabel": "Digitale Gästebox",
|
||||||
|
"instructionsHeading": "So funktioniert's",
|
||||||
|
"linkHeading": "Alternative zum Einscannen",
|
||||||
|
"ctaLabel": "Scan mich & starte direkt",
|
||||||
|
"instructions": [
|
||||||
|
"QR-Code scannen",
|
||||||
|
"Profil anlegen",
|
||||||
|
"Fotos teilen"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
|
|||||||
@@ -36,6 +36,36 @@
|
|||||||
"lowCredits": "Top up recommended"
|
"lowCredits": "Top up recommended"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"readiness": {
|
||||||
|
"title": "Ready for event day",
|
||||||
|
"description": "Complete these steps so guests can join without friction.",
|
||||||
|
"pending": "Pending",
|
||||||
|
"complete": "Done",
|
||||||
|
"items": {
|
||||||
|
"event": {
|
||||||
|
"title": "Event created",
|
||||||
|
"hint": "Create your first event or open the most recent one."
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "Tasks curated",
|
||||||
|
"hint": "Assign fitting tasks or enable the photo-only mode."
|
||||||
|
},
|
||||||
|
"qr": {
|
||||||
|
"title": "QR invite live",
|
||||||
|
"hint": "Create a QR invite and download the print layouts."
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"title": "Package active",
|
||||||
|
"hint": "Pick the package that matches your scope."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"createEvent": "Create event",
|
||||||
|
"openTasks": "Open tasks",
|
||||||
|
"openQr": "QR invites",
|
||||||
|
"openPackages": "View packages"
|
||||||
|
}
|
||||||
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
"title": "Quick actions",
|
"title": "Quick actions",
|
||||||
"description": "Jump straight to the most important actions.",
|
"description": "Jump straight to the most important actions.",
|
||||||
|
|||||||
@@ -147,7 +147,9 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"missingSlug": "No event slug provided.",
|
"missingSlug": "No event slug provided.",
|
||||||
"load": "Event tasks could not be loaded.",
|
"load": "Event tasks could not be loaded.",
|
||||||
"assign": "Tasks could not be assigned."
|
"assign": "Tasks could not be assigned.",
|
||||||
|
"photoOnlyEnable": "Photo-only mode could not be enabled.",
|
||||||
|
"photoOnlyDisable": "Photo-only mode could not be disabled."
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"notFoundTitle": "Event not found",
|
"notFoundTitle": "Event not found",
|
||||||
@@ -169,6 +171,147 @@
|
|||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"high": "High",
|
"high": "High",
|
||||||
"urgent": "Urgent"
|
"urgent": "Urgent"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"title": "Tasks & photo mode",
|
||||||
|
"photoOnlyHint": "Photo-only mode is active. Guests can upload photos but won’t 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 you’re missing …",
|
||||||
|
"disclaimer": "We’ll keep your feedback private and use it to improve the product.",
|
||||||
|
"submit": "Send feedback",
|
||||||
|
"thanksTitle": "Thank you!",
|
||||||
|
"thanksDescription": "We’ve received your feedback.",
|
||||||
|
"badge": "Custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizer": {
|
||||||
|
"title": "Customize QR invite",
|
||||||
|
"description": "Adjust layout, texts, colors, and logo for your printable invite.",
|
||||||
|
"layout": "Layout",
|
||||||
|
"selectLayout": "Select layout",
|
||||||
|
"headline": "Headline",
|
||||||
|
"subtitle": "Sub headline",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"badgeLabel": "Badge",
|
||||||
|
"instructionsHeading": "Instructions heading",
|
||||||
|
"instructionsLabel": "Hints",
|
||||||
|
"addInstruction": "Add hint",
|
||||||
|
"removeInstruction": "Remove",
|
||||||
|
"linkHeading": "Link title",
|
||||||
|
"linkLabel": "Link",
|
||||||
|
"ctaLabel": "Call to action",
|
||||||
|
"colors": {
|
||||||
|
"accent": "Accent colour",
|
||||||
|
"text": "Text colour",
|
||||||
|
"background": "Background",
|
||||||
|
"secondary": "Secondary colour",
|
||||||
|
"badge": "Badge colour"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"label": "Logo",
|
||||||
|
"hint": "PNG or SVG, max. 1 MB. Appears in the top right corner.",
|
||||||
|
"remove": "Remove logo"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Preview",
|
||||||
|
"hint": "Visual reference for colours and texts. Save to generate new PDFs/SVGs."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"reset": "Reset"
|
||||||
|
},
|
||||||
|
"badge": "Custom",
|
||||||
|
"actionLabel": "Customize layout",
|
||||||
|
"errors": {
|
||||||
|
"logoTooLarge": "Logo must not exceed 1 MB.",
|
||||||
|
"noLayout": "Please select a layout."
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"badgeLabel": "Digital guest box",
|
||||||
|
"instructionsHeading": "How it works",
|
||||||
|
"linkHeading": "Alternative link",
|
||||||
|
"ctaLabel": "Scan and get started",
|
||||||
|
"instructions": [
|
||||||
|
"Scan the QR code",
|
||||||
|
"Create your profile",
|
||||||
|
"Share your photos"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
Camera,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
QrCode,
|
||||||
|
ClipboardList,
|
||||||
|
Package as PackageIcon,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -14,6 +27,8 @@ import {
|
|||||||
getDashboardSummary,
|
getDashboardSummary,
|
||||||
getEvents,
|
getEvents,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
|
getEventTasks,
|
||||||
|
getEventQrInvites,
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
@@ -23,6 +38,7 @@ import {
|
|||||||
adminPath,
|
adminPath,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
ADMIN_EVENT_VIEW_PATH,
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
ADMIN_BILLING_PATH,
|
ADMIN_BILLING_PATH,
|
||||||
ADMIN_SETTINGS_PATH,
|
ADMIN_SETTINGS_PATH,
|
||||||
@@ -39,6 +55,16 @@ interface DashboardState {
|
|||||||
errorKey: string | null;
|
errorKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadinessState = {
|
||||||
|
hasEvent: boolean;
|
||||||
|
hasTasks: boolean;
|
||||||
|
hasQrInvites: boolean;
|
||||||
|
hasPackage: boolean;
|
||||||
|
primaryEventSlug: string | null;
|
||||||
|
primaryEventName: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -66,6 +92,16 @@ export default function DashboardPage() {
|
|||||||
errorKey: null,
|
errorKey: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [readiness, setReadiness] = React.useState<ReadinessState>({
|
||||||
|
hasEvent: false,
|
||||||
|
hasTasks: false,
|
||||||
|
hasQrInvites: false,
|
||||||
|
hasPackage: false,
|
||||||
|
primaryEventSlug: null,
|
||||||
|
primaryEventName: null,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -81,6 +117,18 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||||||
|
const primaryEvent = events[0] ?? null;
|
||||||
|
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||||
|
|
||||||
|
setReadiness({
|
||||||
|
hasEvent: events.length > 0,
|
||||||
|
hasTasks: false,
|
||||||
|
hasQrInvites: false,
|
||||||
|
hasPackage: Boolean(packages.activePackage),
|
||||||
|
primaryEventSlug: primaryEvent?.slug ?? null,
|
||||||
|
primaryEventName,
|
||||||
|
loading: Boolean(primaryEvent),
|
||||||
|
});
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
summary: summary ?? fallbackSummary,
|
summary: summary ?? fallbackSummary,
|
||||||
@@ -89,6 +137,36 @@ export default function DashboardPage() {
|
|||||||
loading: false,
|
loading: false,
|
||||||
errorKey: null,
|
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) {
|
} catch (error) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(error)) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
@@ -271,6 +349,52 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ReadinessCard
|
||||||
|
readiness={readiness}
|
||||||
|
labels={{
|
||||||
|
title: translate('readiness.title'),
|
||||||
|
description: translate('readiness.description'),
|
||||||
|
pending: translate('readiness.pending'),
|
||||||
|
complete: translate('readiness.complete'),
|
||||||
|
items: {
|
||||||
|
event: {
|
||||||
|
title: translate('readiness.items.event.title'),
|
||||||
|
hint: translate('readiness.items.event.hint'),
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: translate('readiness.items.tasks.title'),
|
||||||
|
hint: translate('readiness.items.tasks.hint'),
|
||||||
|
},
|
||||||
|
qr: {
|
||||||
|
title: translate('readiness.items.qr.title'),
|
||||||
|
hint: translate('readiness.items.qr.hint'),
|
||||||
|
},
|
||||||
|
package: {
|
||||||
|
title: translate('readiness.items.package.title'),
|
||||||
|
hint: translate('readiness.items.package.hint'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
createEvent: translate('readiness.actions.createEvent'),
|
||||||
|
openTasks: translate('readiness.actions.openTasks'),
|
||||||
|
openQr: translate('readiness.actions.openQr'),
|
||||||
|
openPackages: translate('readiness.actions.openPackages'),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||||
|
onOpenTasks={() =>
|
||||||
|
readiness.primaryEventSlug
|
||||||
|
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
|
||||||
|
: navigate(ADMIN_TASKS_PATH)
|
||||||
|
}
|
||||||
|
onOpenQr={() =>
|
||||||
|
readiness.primaryEventSlug
|
||||||
|
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
|
||||||
|
: navigate(ADMIN_EVENTS_PATH)
|
||||||
|
}
|
||||||
|
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -315,6 +439,27 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||||||
|
if (typeof name === 'string' && name.trim().length > 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name && typeof name === 'object') {
|
||||||
|
if (typeof name.de === 'string' && name.de.trim().length > 0) {
|
||||||
|
return name.de;
|
||||||
|
}
|
||||||
|
if (typeof name.en === 'string' && name.en.trim().length > 0) {
|
||||||
|
return name.en;
|
||||||
|
}
|
||||||
|
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||||
|
if (typeof first === 'string') {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackSlug || 'Event';
|
||||||
|
}
|
||||||
|
|
||||||
function buildSummaryFallback(
|
function buildSummaryFallback(
|
||||||
events: TenantEvent[],
|
events: TenantEvent[],
|
||||||
activePackage: TenantPackageSummary | null
|
activePackage: TenantPackageSummary | null
|
||||||
@@ -353,6 +498,170 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
|||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadinessLabels = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pending: string;
|
||||||
|
complete: string;
|
||||||
|
items: {
|
||||||
|
event: { title: string; hint: string };
|
||||||
|
tasks: { title: string; hint: string };
|
||||||
|
qr: { title: string; hint: string };
|
||||||
|
package: { title: string; hint: string };
|
||||||
|
};
|
||||||
|
actions: {
|
||||||
|
createEvent: string;
|
||||||
|
openTasks: string;
|
||||||
|
openQr: string;
|
||||||
|
openPackages: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReadinessCard({
|
||||||
|
readiness,
|
||||||
|
labels,
|
||||||
|
onCreateEvent,
|
||||||
|
onOpenTasks,
|
||||||
|
onOpenQr,
|
||||||
|
onOpenPackages,
|
||||||
|
}: {
|
||||||
|
readiness: ReadinessState;
|
||||||
|
labels: ReadinessLabels;
|
||||||
|
onCreateEvent: () => void;
|
||||||
|
onOpenTasks: () => void;
|
||||||
|
onOpenQr: () => void;
|
||||||
|
onOpenPackages: () => void;
|
||||||
|
}) {
|
||||||
|
const checklistItems = [
|
||||||
|
{
|
||||||
|
key: 'event',
|
||||||
|
icon: <CalendarDays className="h-5 w-5" />,
|
||||||
|
completed: readiness.hasEvent,
|
||||||
|
label: labels.items.event.title,
|
||||||
|
hint: labels.items.event.hint,
|
||||||
|
actionLabel: labels.actions.createEvent,
|
||||||
|
onAction: onCreateEvent,
|
||||||
|
showAction: !readiness.hasEvent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tasks',
|
||||||
|
icon: <ClipboardList className="h-5 w-5" />,
|
||||||
|
completed: readiness.hasTasks,
|
||||||
|
label: labels.items.tasks.title,
|
||||||
|
hint: labels.items.tasks.hint,
|
||||||
|
actionLabel: labels.actions.openTasks,
|
||||||
|
onAction: onOpenTasks,
|
||||||
|
showAction: readiness.hasEvent && !readiness.hasTasks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qr',
|
||||||
|
icon: <QrCode className="h-5 w-5" />,
|
||||||
|
completed: readiness.hasQrInvites,
|
||||||
|
label: labels.items.qr.title,
|
||||||
|
hint: labels.items.qr.hint,
|
||||||
|
actionLabel: labels.actions.openQr,
|
||||||
|
onAction: onOpenQr,
|
||||||
|
showAction: readiness.hasEvent && !readiness.hasQrInvites,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'package',
|
||||||
|
icon: <PackageIcon className="h-5 w-5" />,
|
||||||
|
completed: readiness.hasPackage,
|
||||||
|
label: labels.items.package.title,
|
||||||
|
hint: labels.items.package.hint,
|
||||||
|
actionLabel: labels.actions.openPackages,
|
||||||
|
onAction: onOpenPackages,
|
||||||
|
showAction: !readiness.hasPackage,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const activeEventName = readiness.primaryEventName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
|
<CardHeader className="space-y-2">
|
||||||
|
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
|
||||||
|
{activeEventName ? (
|
||||||
|
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
|
||||||
|
{activeEventName}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{readiness.loading ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{labels.pending}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
checklistItems.map((item) => (
|
||||||
|
<ChecklistRow
|
||||||
|
key={item.key}
|
||||||
|
icon={item.icon}
|
||||||
|
label={item.label}
|
||||||
|
hint={item.hint}
|
||||||
|
completed={item.completed}
|
||||||
|
status={{ complete: labels.complete, pending: labels.pending }}
|
||||||
|
action={
|
||||||
|
item.showAction
|
||||||
|
? {
|
||||||
|
label: item.actionLabel,
|
||||||
|
onClick: item.onAction,
|
||||||
|
disabled:
|
||||||
|
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
completed,
|
||||||
|
status,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
completed: boolean;
|
||||||
|
status: { complete: string; pending: string };
|
||||||
|
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{label}</p>
|
||||||
|
<p className="text-xs text-slate-600">{hint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||||
|
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||||
|
{completed ? status.complete : status.pending}
|
||||||
|
</span>
|
||||||
|
{action ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -8,17 +9,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import {
|
import {
|
||||||
createInviteLink,
|
createQrInvite,
|
||||||
EventJoinToken,
|
EventQrInvite,
|
||||||
EventJoinTokenLayout,
|
EventQrInviteLayout,
|
||||||
EventStats as TenantEventStats,
|
EventStats as TenantEventStats,
|
||||||
getEvent,
|
getEvent,
|
||||||
getEventJoinTokens,
|
getEventQrInvites,
|
||||||
getEventStats,
|
getEventStats,
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
toggleEvent,
|
toggleEvent,
|
||||||
revokeEventJoinToken,
|
revokeEventQrInvite,
|
||||||
|
updateEventQrInvite,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import {
|
import {
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
@@ -26,12 +29,13 @@ import {
|
|||||||
ADMIN_EVENT_PHOTOS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
ADMIN_EVENT_MEMBERS_PATH,
|
ADMIN_EVENT_MEMBERS_PATH,
|
||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
|
ADMIN_EVENT_TOOLKIT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
stats: TenantEventStats | null;
|
stats: TenantEventStats | null;
|
||||||
tokens: EventJoinToken[];
|
invites: EventQrInvite[];
|
||||||
inviteLink: string | null;
|
inviteLink: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -47,14 +51,16 @@ export default function EventDetailPage() {
|
|||||||
const [state, setState] = React.useState<State>({
|
const [state, setState] = React.useState<State>({
|
||||||
event: null,
|
event: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
tokens: [],
|
invites: [],
|
||||||
inviteLink: null,
|
inviteLink: null,
|
||||||
error: null,
|
error: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
busy: false,
|
busy: false,
|
||||||
});
|
});
|
||||||
const [creatingToken, setCreatingToken] = React.useState(false);
|
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||||
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
||||||
|
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
|
||||||
|
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -64,22 +70,22 @@ export default function EventDetailPage() {
|
|||||||
|
|
||||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||||
try {
|
try {
|
||||||
const [eventData, statsData, joinTokens] = await Promise.all([
|
const [eventData, statsData, qrInvites] = await Promise.all([
|
||||||
getEvent(slug),
|
getEvent(slug),
|
||||||
getEventStats(slug),
|
getEventStats(slug),
|
||||||
getEventJoinTokens(slug),
|
getEventQrInvites(slug),
|
||||||
]);
|
]);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
event: eventData,
|
event: eventData,
|
||||||
stats: statsData,
|
stats: statsData,
|
||||||
tokens: joinTokens,
|
invites: qrInvites,
|
||||||
loading: false,
|
loading: false,
|
||||||
inviteLink: prev.inviteLink,
|
inviteLink: prev.inviteLink,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAuthError(err)) return;
|
if (isAuthError(err)) return;
|
||||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] }));
|
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
|
||||||
}
|
}
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
@@ -108,58 +114,131 @@ export default function EventDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleInvite() {
|
async function handleInvite() {
|
||||||
if (!slug || creatingToken) return;
|
if (!slug || creatingInvite) return;
|
||||||
setCreatingToken(true);
|
setCreatingInvite(true);
|
||||||
setState((prev) => ({ ...prev, error: null }));
|
setState((prev) => ({ ...prev, error: null }));
|
||||||
try {
|
try {
|
||||||
const token = await createInviteLink(slug);
|
const invite = await createQrInvite(slug);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
inviteLink: token.url,
|
inviteLink: invite.url,
|
||||||
tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)],
|
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(token.url);
|
await navigator.clipboard.writeText(invite.url);
|
||||||
} catch {
|
} catch {
|
||||||
// clipboard may be unavailable, ignore silently
|
// clipboard may be unavailable, ignore silently
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' }));
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCreatingToken(false);
|
setCreatingInvite(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy(token: EventJoinToken) {
|
async function handleCopy(invite: EventQrInvite) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(token.url);
|
await navigator.clipboard.writeText(invite.url);
|
||||||
setState((prev) => ({ ...prev, inviteLink: token.url }));
|
setState((prev) => ({ ...prev, inviteLink: invite.url }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Clipboard copy failed', err);
|
console.warn('Clipboard copy failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevoke(token: EventJoinToken) {
|
async function handleRevoke(invite: EventQrInvite) {
|
||||||
if (!slug || token.revoked_at) return;
|
if (!slug || invite.revoked_at) return;
|
||||||
setRevokingId(token.id);
|
setRevokingId(invite.id);
|
||||||
setState((prev) => ({ ...prev, error: null }));
|
setState((prev) => ({ ...prev, error: null }));
|
||||||
try {
|
try {
|
||||||
const updated = await revokeEventJoinToken(slug, token.id);
|
const updated = await revokeEventQrInvite(slug, invite.id);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)),
|
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRevokingId(null);
|
setRevokingId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { event, stats, tokens, inviteLink, error, loading, busy } = state;
|
function openCustomizer(invite: EventQrInvite) {
|
||||||
|
setState((prev) => ({ ...prev, error: null }));
|
||||||
|
setCustomizingInvite(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCustomizer() {
|
||||||
|
if (customizerSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomizingInvite(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApplyCustomization(customization: QrLayoutCustomization) {
|
||||||
|
if (!slug || !customizingInvite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomizerSaving(true);
|
||||||
|
setState((prev) => ({ ...prev, error: null }));
|
||||||
|
try {
|
||||||
|
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
|
||||||
|
metadata: {
|
||||||
|
layout_customization: customization,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||||
|
}));
|
||||||
|
setCustomizerSaving(false);
|
||||||
|
setCustomizingInvite(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' }));
|
||||||
|
}
|
||||||
|
setCustomizerSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetCustomization() {
|
||||||
|
if (!slug || !customizingInvite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomizerSaving(true);
|
||||||
|
setState((prev) => ({ ...prev, error: null }));
|
||||||
|
try {
|
||||||
|
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
|
||||||
|
metadata: {
|
||||||
|
layout_customization: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
|
||||||
|
}));
|
||||||
|
setCustomizerSaving(false);
|
||||||
|
setCustomizingInvite(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
|
||||||
|
}
|
||||||
|
setCustomizerSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, stats, invites, inviteLink, error, loading, busy } = state;
|
||||||
|
const eventDisplayName = event ? renderName(event.name) : '';
|
||||||
|
const currentCustomization = React.useMemo(() => {
|
||||||
|
if (!customizingInvite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
|
||||||
|
const raw = metadata?.layout_customization;
|
||||||
|
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
||||||
|
}, [customizingInvite]);
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<>
|
<>
|
||||||
@@ -193,6 +272,13 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
Tasks
|
Tasks
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
|
||||||
|
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
Event-Day Toolkit
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -261,33 +347,33 @@ export default function EventDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
<Card id="qr-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="space-y-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks & QR-Layouts
|
<Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen & Drucklayouts
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-slate-600">
|
<CardDescription className="text-sm text-slate-600">
|
||||||
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
|
Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen – inklusive Branding und Anleitungen –
|
||||||
Vokabular.
|
zum Ausdrucken herunter.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||||
<p>
|
<p>
|
||||||
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
|
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
|
||||||
kannst du jederzeit erneuern oder deaktivieren.
|
jederzeit erneuern oder deaktivieren.
|
||||||
</p>
|
</p>
|
||||||
{tokens.length > 0 && (
|
{invites.length > 0 && (
|
||||||
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
||||||
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
|
||||||
{tokens.length}
|
{invites.length}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
|
||||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||||
Einladung erstellen
|
QR-Einladung erstellen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
@@ -297,20 +383,22 @@ export default function EventDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{tokens.length > 0 ? (
|
{invites.length > 0 ? (
|
||||||
tokens.map((token) => (
|
invites.map((invite) => (
|
||||||
<InvitationCard
|
<InvitationCard
|
||||||
key={token.id}
|
key={invite.id}
|
||||||
token={token}
|
invite={invite}
|
||||||
onCopy={() => handleCopy(token)}
|
onCopy={() => handleCopy(invite)}
|
||||||
onRevoke={() => handleRevoke(token)}
|
onRevoke={() => handleRevoke(invite)}
|
||||||
revoking={revokingId === token.id}
|
revoking={revokingId === invite.id}
|
||||||
|
onCustomize={() => openCustomizer(invite)}
|
||||||
|
eventName={eventDisplayName}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
||||||
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
|
Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
|
||||||
herunterzuladen und zu teilen.
|
und zu teilen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +428,18 @@ export default function EventDetailPage() {
|
|||||||
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
|
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<QrInviteCustomizationDialog
|
||||||
|
open={Boolean(customizingInvite)}
|
||||||
|
onClose={closeCustomizer}
|
||||||
|
onSubmit={handleApplyCustomization}
|
||||||
|
onReset={handleResetCustomization}
|
||||||
|
saving={customizerSaving}
|
||||||
|
inviteUrl={customizingInvite?.url ?? ''}
|
||||||
|
eventName={eventDisplayName}
|
||||||
|
layouts={customizingInvite?.layouts ?? []}
|
||||||
|
initialCustomization={currentCustomization}
|
||||||
|
/>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -373,21 +473,29 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InvitationCard({
|
function InvitationCard({
|
||||||
token,
|
invite,
|
||||||
onCopy,
|
onCopy,
|
||||||
onRevoke,
|
onRevoke,
|
||||||
revoking,
|
revoking,
|
||||||
|
onCustomize,
|
||||||
|
eventName,
|
||||||
}: {
|
}: {
|
||||||
token: EventJoinToken;
|
invite: EventQrInvite;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onRevoke: () => void;
|
onRevoke: () => void;
|
||||||
revoking: boolean;
|
revoking: boolean;
|
||||||
|
onCustomize: () => void;
|
||||||
|
eventName: string;
|
||||||
}) {
|
}) {
|
||||||
const status = getTokenStatus(token);
|
const { t } = useTranslation('management');
|
||||||
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
|
const status = getInviteStatus(invite);
|
||||||
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
|
const layouts = Array.isArray(invite.layouts) ? invite.layouts : [];
|
||||||
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
|
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
|
||||||
|
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
|
||||||
const isAutoGenerated = Boolean(metadata.auto_generated);
|
const isAutoGenerated = Boolean(metadata.auto_generated);
|
||||||
|
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
|
||||||
|
const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null);
|
||||||
|
const hasCustomization = customization ? Object.keys(customization).length > 0 : false;
|
||||||
|
|
||||||
const statusClassname =
|
const statusClassname =
|
||||||
status === 'Aktiv'
|
status === 'Aktiv'
|
||||||
@@ -401,17 +509,22 @@ function InvitationCard({
|
|||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span>
|
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
|
||||||
{isAutoGenerated ? (
|
{isAutoGenerated ? (
|
||||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||||
Standard
|
Standard
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{hasCustomization ? (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
||||||
|
{t('tasks.customizer.badge', 'Angepasst')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||||
{token.url}
|
{invite.url}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -425,19 +538,28 @@ function InvitationCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||||
<span>Nutzung: {usageLabel}</span>
|
<span>Nutzung: {usageLabel}</span>
|
||||||
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
|
{invite.expires_at ? <span>Gültig bis {formatDateTime(invite.expires_at)}</span> : null}
|
||||||
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
|
{invite.created_at ? <span>Erstellt am {formatDateTime(invite.created_at)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{token.layouts_url ? (
|
<Button
|
||||||
|
variant={hasCustomization ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={onCustomize}
|
||||||
|
className={hasCustomization ? 'bg-amber-500 text-white hover:bg-amber-500/90 border-amber-200' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1 h-3 w-3" />
|
||||||
|
{t('tasks.customizer.actionLabel', 'Layout anpassen')}
|
||||||
|
</Button>
|
||||||
|
{invite.layouts_url ? (
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||||
>
|
>
|
||||||
<a href={token.layouts_url} target="_blank" rel="noreferrer">
|
<a href={invite.layouts_url} target="_blank" rel="noreferrer">
|
||||||
<Download className="mr-1 h-3 w-3" />
|
<Download className="mr-1 h-3 w-3" />
|
||||||
Layout-Übersicht
|
Layout-Übersicht
|
||||||
</a>
|
</a>
|
||||||
@@ -447,7 +569,7 @@ function InvitationCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRevoke}
|
onClick={onRevoke}
|
||||||
disabled={revoking || token.revoked_at !== null || !token.is_active}
|
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
|
||||||
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
||||||
@@ -458,10 +580,16 @@ function InvitationCard({
|
|||||||
{layouts.length > 0 ? (
|
{layouts.length > 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{layouts.map((layout) => (
|
{layouts.map((layout) => (
|
||||||
<LayoutPreviewCard key={layout.id} layout={layout} />
|
<LayoutPreviewCard
|
||||||
|
key={layout.id}
|
||||||
|
layout={layout}
|
||||||
|
customization={layout.id === preferredLayoutId ? customization : null}
|
||||||
|
selected={layout.id === preferredLayoutId}
|
||||||
|
eventName={eventName}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : token.layouts_url ? (
|
) : invite.layouts_url ? (
|
||||||
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
|
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
|
||||||
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
|
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
|
||||||
</div>
|
</div>
|
||||||
@@ -470,38 +598,63 @@ function InvitationCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
|
function LayoutPreviewCard({
|
||||||
const gradient = layout.preview?.background_gradient;
|
layout,
|
||||||
|
customization,
|
||||||
|
selected,
|
||||||
|
eventName,
|
||||||
|
}: {
|
||||||
|
layout: EventQrInviteLayout;
|
||||||
|
customization: QrLayoutCustomization | null;
|
||||||
|
selected: boolean;
|
||||||
|
eventName: string;
|
||||||
|
}) {
|
||||||
|
const gradient = customization?.background_gradient ?? layout.preview?.background_gradient;
|
||||||
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
|
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
|
||||||
const gradientStyle = stops.length
|
const gradientStyle = stops.length
|
||||||
? {
|
? {
|
||||||
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
backgroundColor: layout.preview?.background ?? '#F8FAFC',
|
backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
|
||||||
};
|
};
|
||||||
const textColor = layout.preview?.text ?? '#0F172A';
|
const textColor = customization?.text_color ?? layout.preview?.text ?? '#0F172A';
|
||||||
|
const badgeColor = customization?.badge_color ?? customization?.accent_color ?? layout.preview?.accent ?? '#0EA5E9';
|
||||||
|
|
||||||
const formats = Array.isArray(layout.formats) ? layout.formats : [];
|
const formats = Array.isArray(layout.formats) ? layout.formats : [];
|
||||||
|
const headline = customization?.headline ?? layout.name ?? eventName;
|
||||||
|
const subtitle = customization?.subtitle ?? layout.subtitle ?? '';
|
||||||
|
const description = customization?.description ?? layout.description ?? '';
|
||||||
|
const instructions = customization?.instructions ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
|
<div
|
||||||
|
className={`overflow-hidden rounded-xl border bg-white shadow-sm ${selected ? 'border-amber-300 ring-2 ring-amber-200' : 'border-amber-100'}`}
|
||||||
|
>
|
||||||
<div className="relative h-28">
|
<div className="relative h-28">
|
||||||
<div className="absolute inset-0" style={gradientStyle} />
|
<div className="absolute inset-0" style={gradientStyle} />
|
||||||
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
|
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
|
||||||
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
<span
|
||||||
|
className="w-fit rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
|
||||||
|
style={{ backgroundColor: badgeColor, color: '#ffffff' }}
|
||||||
|
>
|
||||||
QR-Layout
|
QR-Layout
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold leading-tight">{layout.name}</div>
|
<div className="text-sm font-semibold leading-tight">{headline}</div>
|
||||||
{layout.subtitle ? (
|
{subtitle ? <div className="text-[11px] opacity-80">{subtitle}</div> : null}
|
||||||
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 p-3">
|
<div className="space-y-3 p-3">
|
||||||
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
|
{description ? <p className="text-xs text-slate-600">{description}</p> : null}
|
||||||
|
{instructions.length > 0 ? (
|
||||||
|
<ul className="space-y-1 text-[11px] text-slate-500">
|
||||||
|
{instructions.slice(0, 3).map((item, index) => (
|
||||||
|
<li key={`${layout.id}-instruction-${index}`}>• {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formats.map((format) => {
|
{formats.map((format) => {
|
||||||
const key = String(format ?? '').toLowerCase();
|
const key = String(format ?? '').toLowerCase();
|
||||||
@@ -557,15 +710,15 @@ function formatDateTime(iso: string | null): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
|
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
|
||||||
if (token.revoked_at) return 'Deaktiviert';
|
if (invite.revoked_at) return 'Deaktiviert';
|
||||||
if (token.expires_at) {
|
if (invite.expires_at) {
|
||||||
const expiry = new Date(token.expires_at);
|
const expiry = new Date(invite.expires_at);
|
||||||
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
|
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
|
||||||
return 'Abgelaufen';
|
return 'Abgelaufen';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return token.is_active ? 'Aktiv' : 'Deaktiviert';
|
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderName(name: TenantEvent['name']): string {
|
function renderName(name: TenantEvent['name']): string {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
getEvent,
|
getEvent,
|
||||||
getEventTasks,
|
getEventTasks,
|
||||||
getTasks,
|
getTasks,
|
||||||
|
updateEvent,
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
TenantTask,
|
TenantTask,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
@@ -34,6 +36,7 @@ export default function EventTasksPage() {
|
|||||||
const [selected, setSelected] = React.useState<number[]>([]);
|
const [selected, setSelected] = React.useState<number[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [modeSaving, setModeSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const statusLabels = React.useMemo(
|
const statusLabels = React.useMemo(
|
||||||
@@ -101,6 +104,35 @@ export default function EventTasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
|
||||||
|
|
||||||
|
async function handleModeChange(checked: boolean) {
|
||||||
|
if (!event || !slug) return;
|
||||||
|
|
||||||
|
setModeSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextMode = checked ? 'photo_only' : 'tasks';
|
||||||
|
const updated = await updateEvent(slug, {
|
||||||
|
settings: {
|
||||||
|
engagement_mode: nextMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setEvent(updated);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
setError(
|
||||||
|
checked
|
||||||
|
? t('management.tasks.errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
|
||||||
|
: t('management.tasks.errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setModeSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -138,6 +170,45 @@ export default function EventTasksPage() {
|
|||||||
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
||||||
})}
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
{isPhotoOnlyMode
|
||||||
|
? t(
|
||||||
|
'management.tasks.modes.photoOnlyHint',
|
||||||
|
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'management.tasks.modes.tasksHint',
|
||||||
|
'Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
{isPhotoOnlyMode
|
||||||
|
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
|
||||||
|
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={isPhotoOnlyMode}
|
||||||
|
onCheckedChange={handleModeChange}
|
||||||
|
disabled={modeSaving}
|
||||||
|
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{modeSaving ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -182,6 +253,7 @@ export default function EventTasksPage() {
|
|||||||
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disabled={isPhotoOnlyMode}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||||
@@ -191,7 +263,10 @@ export default function EventTasksPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}>
|
<Button
|
||||||
|
onClick={() => void handleAssign()}
|
||||||
|
disabled={saving || selected.length === 0 || isPhotoOnlyMode}
|
||||||
|
>
|
||||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
563
resources/js/admin/pages/EventToolkitPage.tsx
Normal file
563
resources/js/admin/pages/EventToolkitPage.tsx
Normal 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();
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ADMIN_EVENT_PHOTOS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
ADMIN_EVENT_MEMBERS_PATH,
|
ADMIN_EVENT_MEMBERS_PATH,
|
||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_TASKS_PATH,
|
||||||
|
ADMIN_EVENT_TOOLKIT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
@@ -156,10 +157,13 @@ function EventCard({ event }: { event: TenantEvent }) {
|
|||||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||||
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#join-invites`}>
|
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#qr-invites`}>
|
||||||
<Share2 className="h-3.5 w-3.5" /> Einladungen
|
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||||
|
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -9,6 +9,7 @@ import EventPhotosPage from './pages/EventPhotosPage';
|
|||||||
import EventDetailPage from './pages/EventDetailPage';
|
import EventDetailPage from './pages/EventDetailPage';
|
||||||
import EventMembersPage from './pages/EventMembersPage';
|
import EventMembersPage from './pages/EventMembersPage';
|
||||||
import EventTasksPage from './pages/EventTasksPage';
|
import EventTasksPage from './pages/EventTasksPage';
|
||||||
|
import EventToolkitPage from './pages/EventToolkitPage';
|
||||||
import BillingPage from './pages/BillingPage';
|
import BillingPage from './pages/BillingPage';
|
||||||
import TasksPage from './pages/TasksPage';
|
import TasksPage from './pages/TasksPage';
|
||||||
import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
||||||
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||||
|
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||||
{ path: 'tasks', element: <TasksPage /> },
|
{ path: 'tasks', element: <TasksPage /> },
|
||||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||||
{ path: 'emotions', element: <EmotionsPage /> },
|
{ path: 'emotions', element: <EmotionsPage /> },
|
||||||
|
|||||||
@@ -1,53 +1,72 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
||||||
import { useAppearance } from '@/hooks/use-appearance';
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const [prefersDark, setPrefersDark] = useState<boolean>(() => {
|
||||||
const getCurrentIcon = () => {
|
if (typeof window === 'undefined') {
|
||||||
switch (appearance) {
|
return false;
|
||||||
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" />;
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className={className} {...props}>
|
<div className={className} {...props}>
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
size="icon"
|
||||||
{getCurrentIcon()}
|
className="h-9 w-9 rounded-md"
|
||||||
<span className="sr-only">Toggle theme</span>
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
aria-pressed={isDark}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" aria-hidden />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function BottomNav() {
|
|||||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50">
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/30 bg-white/70 px-2 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-gradient-to-t dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
|
||||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
common: {
|
common: {
|
||||||
hi: 'Hi',
|
hi: 'Hi',
|
||||||
actions: {
|
actions: {
|
||||||
close: 'Schliessen',
|
close: 'Schließen',
|
||||||
loading: 'Laedt...',
|
loading: 'Lädt...',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
navigation: {
|
navigation: {
|
||||||
@@ -30,34 +30,34 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
loading: 'Lade Event...',
|
loading: 'Lade Event...',
|
||||||
stats: {
|
stats: {
|
||||||
online: 'online',
|
online: 'online',
|
||||||
tasksSolved: 'Aufgaben geloest',
|
tasksSolved: 'Aufgaben gelöst',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
eventAccess: {
|
eventAccess: {
|
||||||
loading: {
|
loading: {
|
||||||
title: 'Wir pruefen deinen Zugang...',
|
title: 'Wir prüfen deinen Zugang...',
|
||||||
subtitle: 'Einen Moment bitte.',
|
subtitle: 'Einen Moment bitte.',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
invalid_token: {
|
invalid_token: {
|
||||||
title: 'Zugriffscode ungueltig',
|
title: 'Zugriffscode ungültig',
|
||||||
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
|
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
|
||||||
ctaLabel: 'Neuen Code anfordern',
|
ctaLabel: 'Neuen Code anfordern',
|
||||||
},
|
},
|
||||||
token_revoked: {
|
token_revoked: {
|
||||||
title: 'Zugriffscode deaktiviert',
|
title: 'Zugriffscode deaktiviert',
|
||||||
description: 'Dieser Code wurde zurueckgezogen. Bitte fordere einen neuen Code an.',
|
description: 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.',
|
||||||
ctaLabel: 'Neuen Code anfordern',
|
ctaLabel: 'Neuen Code anfordern',
|
||||||
},
|
},
|
||||||
token_expired: {
|
token_expired: {
|
||||||
title: 'Zugriffscode abgelaufen',
|
title: 'Zugriffscode abgelaufen',
|
||||||
description: 'Der Code ist nicht mehr gueltig. Aktualisiere deinen Code, um fortzufahren.',
|
description: 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.',
|
||||||
ctaLabel: 'Code aktualisieren',
|
ctaLabel: 'Code aktualisieren',
|
||||||
},
|
},
|
||||||
token_rate_limited: {
|
token_rate_limited: {
|
||||||
title: 'Zu viele Versuche',
|
title: 'Zu viele Versuche',
|
||||||
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
|
||||||
},
|
},
|
||||||
access_rate_limited: {
|
access_rate_limited: {
|
||||||
title: 'Zu viele Aufrufe',
|
title: 'Zu viele Aufrufe',
|
||||||
@@ -65,22 +65,22 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
|
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
|
||||||
},
|
},
|
||||||
gallery_expired: {
|
gallery_expired: {
|
||||||
title: 'Galerie nicht mehr verfuegbar',
|
title: 'Galerie nicht mehr verfügbar',
|
||||||
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
|
description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
|
||||||
ctaLabel: 'Neuen Code anfordern',
|
ctaLabel: 'Neuen Code anfordern',
|
||||||
},
|
},
|
||||||
event_not_public: {
|
event_not_public: {
|
||||||
title: 'Event nicht oeffentlich',
|
title: 'Event nicht öffentlich',
|
||||||
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
description: 'Dieses Event ist aktuell nicht öffentlich zugänglich.',
|
||||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||||
},
|
},
|
||||||
network_error: {
|
network_error: {
|
||||||
title: 'Verbindungsproblem',
|
title: 'Verbindungsproblem',
|
||||||
description: 'Wir konnten keine Verbindung zum Server herstellen. Pruefe deine Internetverbindung und versuche es erneut.',
|
description: 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.',
|
||||||
},
|
},
|
||||||
server_error: {
|
server_error: {
|
||||||
title: 'Server nicht erreichbar',
|
title: 'Server nicht erreichbar',
|
||||||
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.',
|
description: 'Der Server reagiert derzeit nicht. Versuche es später erneut.',
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
title: 'Event nicht erreichbar',
|
title: 'Event nicht erreichbar',
|
||||||
@@ -93,10 +93,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
loading: 'Lade Event...',
|
loading: 'Lade Event...',
|
||||||
error: {
|
error: {
|
||||||
default: 'Event nicht gefunden.',
|
default: 'Event nicht gefunden.',
|
||||||
backToStart: 'Zurueck zur Startseite',
|
backToStart: 'Zurück zur Startseite',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
description: 'Fange den schoensten Moment ein!',
|
description: 'Fange den schönsten Moment ein!',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
label: 'Dein Name (z.B. Anna)',
|
label: 'Dein Name (z.B. Anna)',
|
||||||
@@ -108,12 +108,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
landing: {
|
landing: {
|
||||||
pageTitle: 'Willkommen bei der Fotobox!',
|
pageTitle: 'Willkommen bei der Fotobox!',
|
||||||
headline: 'Willkommen bei der Fotobox!',
|
headline: 'Willkommen bei der Fotobox!',
|
||||||
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
|
subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
|
||||||
join: {
|
join: {
|
||||||
title: 'Event beitreten',
|
title: 'Event beitreten',
|
||||||
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
||||||
button: 'Event beitreten',
|
button: 'Event beitreten',
|
||||||
buttonLoading: 'Pruefe...',
|
buttonLoading: 'Prüfe...',
|
||||||
},
|
},
|
||||||
scan: {
|
scan: {
|
||||||
start: 'QR-Code scannen',
|
start: 'QR-Code scannen',
|
||||||
@@ -125,7 +125,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
eventClosed: 'Event nicht gefunden oder geschlossen.',
|
eventClosed: 'Event nicht gefunden oder geschlossen.',
|
||||||
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.',
|
network: 'Netzwerkfehler. Bitte später erneut versuchen.',
|
||||||
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
|
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -134,27 +134,27 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
hero: {
|
hero: {
|
||||||
subtitle: 'Willkommen zur Party',
|
subtitle: 'Willkommen zur Party',
|
||||||
title: 'Hey {name}!',
|
title: 'Hey {name}!',
|
||||||
description: 'Du bist bereit fuer "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gaesten.',
|
description: 'Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.',
|
||||||
progress: {
|
progress: {
|
||||||
some: 'Schon {count} Aufgaben erledigt - weiter so!',
|
some: 'Schon {count} Aufgaben erledigt – weiter so!',
|
||||||
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
|
none: 'Starte mit deiner ersten Aufgabe – wir zählen auf dich!',
|
||||||
},
|
},
|
||||||
defaultEventName: 'Dein Event',
|
defaultEventName: 'Dein Event',
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
online: 'Gleichzeitig online',
|
online: 'Gleichzeitig online',
|
||||||
tasksSolved: 'Aufgaben geloest',
|
tasksSolved: 'Aufgaben gelöst',
|
||||||
lastUpload: 'Letzter Upload',
|
lastUpload: 'Letzter Upload',
|
||||||
completedTasks: 'Deine erledigten Aufgaben',
|
completedTasks: 'Deine erledigten Aufgaben',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
title: 'Deine Aktionen',
|
title: 'Deine Aktionen',
|
||||||
subtitle: 'Waehle aus, womit du starten willst',
|
subtitle: 'Wähle aus, womit du starten willst',
|
||||||
queueButton: 'Uploads in Warteschlange ansehen',
|
queueButton: 'Uploads in Warteschlange ansehen',
|
||||||
items: {
|
items: {
|
||||||
tasks: {
|
tasks: {
|
||||||
label: 'Aufgabe ziehen',
|
label: 'Aufgabe ziehen',
|
||||||
description: 'Hol dir deine naechste Challenge',
|
description: 'Hol dir deine nächste Challenge',
|
||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
label: 'Direkt hochladen',
|
label: 'Direkt hochladen',
|
||||||
@@ -168,10 +168,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
checklist: {
|
checklist: {
|
||||||
title: 'Dein Fortschritt',
|
title: 'Dein Fortschritt',
|
||||||
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.',
|
description: 'Halte dich an diese drei kurzen Schritte für die besten Ergebnisse.',
|
||||||
steps: {
|
steps: {
|
||||||
first: 'Aufgabe auswaehlen oder starten',
|
first: 'Aufgabe auswählen oder starten',
|
||||||
second: 'Emotion festhalten und Foto schiessen',
|
second: 'Emotion festhalten und Foto schießen',
|
||||||
third: 'Bild hochladen und Credits sammeln',
|
third: 'Bild hochladen und Credits sammeln',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -225,35 +225,35 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
retry: 'Nochmal versuchen',
|
retry: 'Nochmal versuchen',
|
||||||
},
|
},
|
||||||
primer: {
|
primer: {
|
||||||
title: 'Bereit fuer dein Shooting?',
|
title: 'Bereit für dein Shooting?',
|
||||||
body: {
|
body: {
|
||||||
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
part1: 'Lass uns sicherstellen, dass alles sitzt: prüfe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
||||||
part2: 'Du kannst zwischen Front- und Rueckkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
part2: 'Du kannst zwischen Front- und Rückkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
||||||
},
|
},
|
||||||
dismiss: 'Verstanden',
|
dismiss: 'Verstanden',
|
||||||
},
|
},
|
||||||
cameraUnsupported: {
|
cameraUnsupported: {
|
||||||
title: 'Kamera nicht verfuegbar',
|
title: 'Kamera nicht verfügbar',
|
||||||
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||||
openGallery: 'Foto aus Galerie waehlen',
|
openGallery: 'Foto aus Galerie wählen',
|
||||||
},
|
},
|
||||||
cameraDenied: {
|
cameraDenied: {
|
||||||
title: 'Kamera-Zugriff verweigert',
|
title: 'Kamera-Zugriff verweigert',
|
||||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.',
|
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
|
||||||
reopenPrompt: 'Systemdialog erneut oeffnen',
|
reopenPrompt: 'Systemdialog erneut öffnen',
|
||||||
chooseFile: 'Foto aus Galerie waehlen',
|
chooseFile: 'Foto aus Galerie wählen',
|
||||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.',
|
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
|
||||||
},
|
},
|
||||||
cameraError: {
|
cameraError: {
|
||||||
title: 'Kamera konnte nicht gestartet werden',
|
title: 'Kamera konnte nicht gestartet werden',
|
||||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Pruefe die Berechtigungen oder starte dein Geraet neu.',
|
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
|
||||||
tryAgain: 'Nochmals versuchen',
|
tryAgain: 'Nochmals versuchen',
|
||||||
},
|
},
|
||||||
readyOverlay: {
|
readyOverlay: {
|
||||||
title: 'Kamera bereit',
|
title: 'Kamera bereit',
|
||||||
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.',
|
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto wählen.',
|
||||||
start: 'Countdown starten',
|
start: 'Countdown starten',
|
||||||
chooseFile: 'Foto auswaehlen',
|
chooseFile: 'Foto auswählen',
|
||||||
},
|
},
|
||||||
taskInfo: {
|
taskInfo: {
|
||||||
countdown: 'Countdown',
|
countdown: 'Countdown',
|
||||||
@@ -266,7 +266,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
timeEstimate: '{count} Min',
|
timeEstimate: '{count} Min',
|
||||||
fallbackTitle: 'Aufgabe {id}',
|
fallbackTitle: 'Aufgabe {id}',
|
||||||
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.',
|
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||||
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||||
badge: 'Aufgabe #{id}',
|
badge: 'Aufgabe #{id}',
|
||||||
},
|
},
|
||||||
@@ -276,7 +276,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
review: {
|
review: {
|
||||||
retake: 'Nochmal aufnehmen',
|
retake: 'Nochmal aufnehmen',
|
||||||
keep: 'Foto verwenden',
|
keep: 'Foto verwenden',
|
||||||
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
|
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
saving: 'Speichere Foto...',
|
saving: 'Speichere Foto...',
|
||||||
@@ -289,23 +289,23 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
controls: {
|
controls: {
|
||||||
toggleGrid: 'Raster umschalten',
|
toggleGrid: 'Raster umschalten',
|
||||||
toggleCountdown: 'Countdown umschalten',
|
toggleCountdown: 'Countdown umschalten',
|
||||||
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
|
toggleMirror: 'Spiegelung für Frontkamera umschalten',
|
||||||
toggleFlash: 'Blitzpraeferenz umschalten',
|
toggleFlash: 'Blitzpräferenz umschalten',
|
||||||
capture: 'Foto aufnehmen',
|
capture: 'Foto aufnehmen',
|
||||||
switchCamera: 'Kamera wechseln',
|
switchCamera: 'Kamera wechseln',
|
||||||
chooseFile: 'Foto auswaehlen',
|
chooseFile: 'Foto auswählen',
|
||||||
},
|
},
|
||||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.',
|
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
||||||
limitUnlimited: 'unbegrenzt',
|
limitUnlimited: 'unbegrenzt',
|
||||||
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
|
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
|
||||||
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
|
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
|
||||||
captureError: 'Foto konnte nicht erstellt werden.',
|
captureError: 'Foto konnte nicht erstellt werden.',
|
||||||
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
|
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
|
||||||
canvasError: 'Canvas konnte nicht initialisiert werden.',
|
canvasError: 'Canvas konnte nicht initialisiert werden.',
|
||||||
limitCheckError: 'Fehler beim Pruefen des Upload-Limits. Upload deaktiviert.',
|
limitCheckError: 'Fehler beim Prüfen des Upload-Limits. Upload deaktiviert.',
|
||||||
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
|
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
|
||||||
captureButton: 'Foto aufnehmen',
|
captureButton: 'Foto aufnehmen',
|
||||||
galleryButton: 'Foto aus Galerie waehlen',
|
galleryButton: 'Foto aus Galerie wählen',
|
||||||
switchCamera: 'Kamera wechseln',
|
switchCamera: 'Kamera wechseln',
|
||||||
countdownLabel: 'Countdown: {seconds}s',
|
countdownLabel: 'Countdown: {seconds}s',
|
||||||
countdownReady: 'Bereit machen ...',
|
countdownReady: 'Bereit machen ...',
|
||||||
@@ -319,7 +319,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
|
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
|
||||||
language: {
|
language: {
|
||||||
title: 'Sprache',
|
title: 'Sprache',
|
||||||
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
|
description: 'Wähle deine bevorzugte Sprache für diese Veranstaltung.',
|
||||||
activeBadge: 'aktiv',
|
activeBadge: 'aktiv',
|
||||||
option: {
|
option: {
|
||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
@@ -328,12 +328,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
title: 'Dein Name',
|
title: 'Dein Name',
|
||||||
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.',
|
description: 'Passe an, wie wir dich im Event begrüßen. Der Name wird nur lokal gespeichert.',
|
||||||
label: 'Anzeigename',
|
label: 'Anzeigename',
|
||||||
placeholder: 'z.B. Anna',
|
placeholder: 'z.B. Anna',
|
||||||
save: 'Name speichern',
|
save: 'Name speichern',
|
||||||
saving: 'Speichere...',
|
saving: 'Speichere...',
|
||||||
reset: 'Zuruecksetzen',
|
reset: 'Zurücksetzen',
|
||||||
saved: 'Gespeichert (ok)',
|
saved: 'Gespeichert (ok)',
|
||||||
loading: 'Lade gespeicherten Namen...',
|
loading: 'Lade gespeicherten Namen...',
|
||||||
},
|
},
|
||||||
@@ -341,7 +341,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
title: 'Rechtliches',
|
title: 'Rechtliches',
|
||||||
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
|
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
|
||||||
loading: 'Dokument wird geladen...',
|
loading: 'Dokument wird geladen...',
|
||||||
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.',
|
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es später erneut.',
|
||||||
fallbackTitle: 'Rechtlicher Hinweis',
|
fallbackTitle: 'Rechtlicher Hinweis',
|
||||||
section: {
|
section: {
|
||||||
impressum: 'Impressum',
|
impressum: 'Impressum',
|
||||||
@@ -350,19 +350,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
title: 'Offline Cache',
|
title: 'Offline-Cache',
|
||||||
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
|
description: 'Lösche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads hängen bleiben.',
|
||||||
clear: 'Cache leeren',
|
clear: 'Cache leeren',
|
||||||
clearing: 'Leere Cache...',
|
clearing: 'Leere Cache...',
|
||||||
cleared: 'Cache geloescht.',
|
cleared: 'Cache gelöscht.',
|
||||||
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
|
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
openLabel: 'Einstellungen oeffnen',
|
openLabel: 'Einstellungen öffnen',
|
||||||
backLabel: 'Zurueck',
|
backLabel: 'Zurück',
|
||||||
legalDescription: 'Rechtlicher Hinweis',
|
legalDescription: 'Rechtlicher Hinweis',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import Header from '../components/Header';
|
import Header from '../components/Header';
|
||||||
import BottomNav from '../components/BottomNav';
|
import BottomNav from '../components/BottomNav';
|
||||||
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { uploadPhoto } from '../services/photosApi';
|
import { uploadPhoto } from '../services/photosApi';
|
||||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||||
import { useAppearance } from '../../hooks/use-appearance';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -46,6 +45,39 @@ type CameraPreferences = {
|
|||||||
flashPreferred: boolean;
|
flashPreferred: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TaskPayload = Partial<Task> & { id: number };
|
||||||
|
|
||||||
|
function isTaskPayload(value: unknown): value is TaskPayload {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as { id?: unknown };
|
||||||
|
return typeof candidate.id === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorName(error: unknown): string | undefined {
|
||||||
|
if (typeof error === 'object' && error !== null && 'name' in error) {
|
||||||
|
const name = (error as { name?: unknown }).name;
|
||||||
|
return typeof name === 'string' ? name : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string | undefined {
|
||||||
|
if (error instanceof Error && typeof error.message === 'string') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
|
const message = (error as { message?: unknown }).message;
|
||||||
|
return typeof message === 'string' ? message : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_PREFS: CameraPreferences = {
|
const DEFAULT_PREFS: CameraPreferences = {
|
||||||
facingMode: 'environment',
|
facingMode: 'environment',
|
||||||
countdownSeconds: 3,
|
countdownSeconds: 3,
|
||||||
@@ -60,8 +92,6 @@ export default function UploadPage() {
|
|||||||
const eventKey = token ?? '';
|
const eventKey = token ?? '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { appearance } = useAppearance();
|
|
||||||
const isDarkMode = appearance === 'dark';
|
|
||||||
const { markCompleted } = useGuestTaskProgress(token);
|
const { markCompleted } = useGuestTaskProgress(token);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -75,7 +105,6 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
const [task, setTask] = useState<Task | null>(null);
|
const [task, setTask] = useState<Task | null>(null);
|
||||||
const [loadingTask, setLoadingTask] = useState(true);
|
const [loadingTask, setLoadingTask] = useState(true);
|
||||||
const [taskError, setTaskError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
|
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
|
||||||
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
|
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
|
||||||
@@ -138,8 +167,7 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
// Load task metadata
|
// Load task metadata
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !taskId) {
|
if (!token || taskId === null) {
|
||||||
setTaskError(t('upload.loadError.title'));
|
|
||||||
setLoadingTask(false);
|
setLoadingTask(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,18 +175,19 @@ export default function UploadPage() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
|
const currentTaskId = taskId;
|
||||||
|
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
|
||||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||||
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingTask(true);
|
setLoadingTask(true);
|
||||||
setTaskError(null);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||||
const tasks = await res.json();
|
const payload = (await res.json()) as unknown;
|
||||||
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
|
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
|
||||||
|
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
@@ -174,7 +203,7 @@ export default function UploadPage() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setTask({
|
setTask({
|
||||||
id: taskId!,
|
id: currentTaskId,
|
||||||
title: fallbackTitle,
|
title: fallbackTitle,
|
||||||
description: fallbackDescription,
|
description: fallbackDescription,
|
||||||
instructions: fallbackInstructions,
|
instructions: fallbackInstructions,
|
||||||
@@ -188,9 +217,8 @@ export default function UploadPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch task', error);
|
console.error('Failed to fetch task', error);
|
||||||
if (active) {
|
if (active) {
|
||||||
setTaskError(t('upload.loadError.title'));
|
|
||||||
setTask({
|
setTask({
|
||||||
id: taskId!,
|
id: currentTaskId,
|
||||||
title: fallbackTitle,
|
title: fallbackTitle,
|
||||||
description: fallbackDescription,
|
description: fallbackDescription,
|
||||||
instructions: fallbackInstructions,
|
instructions: fallbackInstructions,
|
||||||
@@ -210,7 +238,7 @@ export default function UploadPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [eventKey, taskId, emotionSlug, t]);
|
}, [eventKey, taskId, emotionSlug, t, token]);
|
||||||
|
|
||||||
// Check upload limits
|
// Check upload limits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -294,14 +322,15 @@ export default function UploadPage() {
|
|||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
attachStreamToVideo(stream);
|
attachStreamToVideo(stream);
|
||||||
setPermissionState('granted');
|
setPermissionState('granted');
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Camera access error', error);
|
console.error('Camera access error', error);
|
||||||
stopStream();
|
stopStream();
|
||||||
|
|
||||||
if (error?.name === 'NotAllowedError') {
|
const errorName = getErrorName(error);
|
||||||
|
if (errorName === 'NotAllowedError') {
|
||||||
setPermissionState('denied');
|
setPermissionState('denied');
|
||||||
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
||||||
} else if (error?.name === 'NotFoundError') {
|
} else if (errorName === 'NotFoundError') {
|
||||||
setPermissionState('error');
|
setPermissionState('error');
|
||||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||||
} else {
|
} else {
|
||||||
@@ -489,9 +518,9 @@ export default function UploadPage() {
|
|||||||
markCompleted(task.id);
|
markCompleted(task.id);
|
||||||
stopStream();
|
stopStream();
|
||||||
navigateAfterUpload(photoId);
|
navigateAfterUpload(photoId);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Upload failed', error);
|
console.error('Upload failed', error);
|
||||||
setUploadError(error?.message || t('upload.status.failed'));
|
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
|
||||||
setMode('review');
|
setMode('review');
|
||||||
} finally {
|
} finally {
|
||||||
if (uploadProgressTimerRef.current) {
|
if (uploadProgressTimerRef.current) {
|
||||||
@@ -533,7 +562,6 @@ export default function UploadPage() {
|
|||||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||||
const showTaskOverlay = task && mode !== 'uploading';
|
const showTaskOverlay = task && mode !== 'uploading';
|
||||||
|
|
||||||
const isUploadDisabled = !canUpload || !task;
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
resetCountdownTimer();
|
resetCountdownTimer();
|
||||||
@@ -542,38 +570,33 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
}, [resetCountdownTimer]);
|
}, [resetCountdownTimer]);
|
||||||
|
|
||||||
if (!supportsCamera && !task) {
|
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||||
return (
|
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
<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>
|
<Alert>
|
||||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</main>
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingTask) {
|
if (loadingTask) {
|
||||||
return (
|
return renderPage(
|
||||||
<div className="pb-16">
|
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
|
||||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
|
||||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||||
</main>
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
return (
|
return renderPage(
|
||||||
<div className="pb-16">
|
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
|
||||||
<main className="px-4 py-6">
|
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -582,9 +605,6 @@ export default function UploadPage() {
|
|||||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</main>
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,10 +656,8 @@ export default function UploadPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return renderPage(
|
||||||
<div className="pb-16">
|
<>
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
|
||||||
<main className="relative flex flex-col gap-4 pb-4">
|
|
||||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||||
{renderPrimer()}
|
{renderPrimer()}
|
||||||
</div>
|
</div>
|
||||||
@@ -863,9 +881,10 @@ export default function UploadPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
|
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
|
||||||
</main>
|
|
||||||
<BottomNav />
|
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
</div>
|
</>
|
||||||
|
,
|
||||||
|
'relative flex flex-col gap-4 pb-4'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,12 @@
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 140px;
|
||||||
|
max-height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.event-title {
|
.event-title {
|
||||||
font-size: 72px;
|
font-size: 72px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -165,8 +171,13 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout-wrapper">
|
<div class="layout-wrapper">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="badge">Digitale Gästebox</span>
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:24px;">
|
||||||
<h1 class="event-title">{{ $eventName }}</h1>
|
<span class="badge">{{ $layout['badge_label'] ?? 'Digitale Gästebox' }}</span>
|
||||||
|
@if(!empty($layout['logo_url']))
|
||||||
|
<img src="{{ $layout['logo_url'] }}" alt="Logo" class="logo" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h1 class="event-title">{{ $layout['headline'] ?? $eventName }}</h1>
|
||||||
@if(!empty($layout['subtitle']))
|
@if(!empty($layout['subtitle']))
|
||||||
<p class="subtitle">{{ $layout['subtitle'] }}</p>
|
<p class="subtitle">{{ $layout['subtitle'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -174,7 +185,7 @@
|
|||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h2>So funktioniert’s</h2>
|
<h2>{{ $layout['instructions_heading'] ?? "So funktioniert's" }}</h2>
|
||||||
<p>{{ $layout['description'] }}</p>
|
<p>{{ $layout['description'] }}</p>
|
||||||
@if(!empty($layout['instructions']))
|
@if(!empty($layout['instructions']))
|
||||||
<ul class="instructions">
|
<ul class="instructions">
|
||||||
@@ -184,14 +195,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
@endif
|
@endif
|
||||||
<div>
|
<div>
|
||||||
<div class="cta">Alternative zum Einscannen</div>
|
<div class="cta">{{ $layout['link_heading'] ?? 'Alternative zum Einscannen' }}</div>
|
||||||
<div class="link-box">{{ $tokenUrl }}</div>
|
<div class="link-box">{{ $layout['link_label'] ?? $tokenUrl }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="qr-wrapper">
|
<div class="qr-wrapper">
|
||||||
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
|
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
|
||||||
<div class="cta">Scan mich & starte direkt</div>
|
<div class="cta">{{ $layout['cta_label'] ?? 'Scan mich & starte direkt' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,14 @@
|
|||||||
$instructions = $layout['instructions'] ?? [];
|
$instructions = $layout['instructions'] ?? [];
|
||||||
$description = $layout['description'] ?? '';
|
$description = $layout['description'] ?? '';
|
||||||
$subtitle = $layout['subtitle'] ?? '';
|
$subtitle = $layout['subtitle'] ?? '';
|
||||||
$titleLines = explode("\n", wordwrap($eventName, 18, "\n", true));
|
$headline = $layout['headline'] ?? $eventName;
|
||||||
|
$badgeLabel = $layout['badge_label'] ?? 'Digitale Gästebox';
|
||||||
|
$instructionsHeading = $layout['instructions_heading'] ?? "So funktioniert's";
|
||||||
|
$linkHeading = $layout['link_heading'] ?? 'Alternative zum Einscannen';
|
||||||
|
$ctaLabel = $layout['cta_label'] ?? 'Scan mich & starte direkt';
|
||||||
|
$linkLabel = $layout['link_label'] ?? $tokenUrl;
|
||||||
|
$logoUrl = $layout['logo_url'] ?? null;
|
||||||
|
$titleLines = explode("\n", wordwrap($headline, 18, "\n", true));
|
||||||
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
|
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
|
||||||
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : [];
|
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : [];
|
||||||
$instructionStartY = 870;
|
$instructionStartY = 870;
|
||||||
@@ -111,7 +118,11 @@
|
|||||||
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
|
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
|
||||||
|
|
||||||
<rect x="80" y="120" width="250" height="70" rx="35" fill="{{ $badgeColor }}" />
|
<rect x="80" y="120" width="250" height="70" rx="35" fill="{{ $badgeColor }}" />
|
||||||
<text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">Digitale Gästebox</text>
|
<text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">{{ e($badgeLabel) }}</text>
|
||||||
|
|
||||||
|
@if($logoUrl)
|
||||||
|
<image href="{{ $logoUrl }}" x="840" y="90" width="180" height="120" preserveAspectRatio="xMidYMid meet" />
|
||||||
|
@endif
|
||||||
|
|
||||||
@foreach($titleLines as $index => $line)
|
@foreach($titleLines as $index => $line)
|
||||||
<text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text>
|
<text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text>
|
||||||
@@ -131,7 +142,7 @@
|
|||||||
<text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text>
|
<text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
<text x="120" y="760" fill="{{ $accent }}" class="small-label">SO FUNKTIONIERT'S</text>
|
<text x="120" y="760" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($instructionsHeading)) }}</text>
|
||||||
|
|
||||||
@foreach($instructions as $index => $step)
|
@foreach($instructions as $index => $step)
|
||||||
@php
|
@php
|
||||||
@@ -141,13 +152,13 @@
|
|||||||
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
|
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
<text x="640" y="760" fill="{{ $accent }}" class="small-label">ALTERNATIVER LINK</text>
|
<text x="640" y="760" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($linkHeading)) }}</text>
|
||||||
<rect x="630" y="790" width="320" height="120" rx="22" fill="rgba(0,0,0,0.08)" />
|
<rect x="630" y="790" width="320" height="120" rx="22" fill="rgba(0,0,0,0.08)" />
|
||||||
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($tokenUrl) }}</text>
|
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($linkLabel) }}</text>
|
||||||
|
|
||||||
<image href="{{ $qrPngDataUri }}" x="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
|
<image href="{{ $qrPngDataUri }}" x="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
|
||||||
|
|
||||||
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">JETZT SCANNEN</text>
|
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">{{ e(mb_strtoupper($ctaLabel)) }}</text>
|
||||||
|
|
||||||
<text x="120" y="{{ $height - 160 }}" fill="rgba(17,24,39,0.6)" class="footer-text">
|
<text x="120" y="{{ $height - 160 }}" fill="rgba(17,24,39,0.6)" class="footer-text">
|
||||||
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>
|
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Http\Controllers\Api\Tenant\PhotoController;
|
|||||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
|
||||||
use App\Http\Controllers\Api\TenantBillingController;
|
use App\Http\Controllers\Api\TenantBillingController;
|
||||||
use App\Http\Controllers\Api\TenantPackageController;
|
use App\Http\Controllers\Api\TenantPackageController;
|
||||||
use App\Http\Controllers\OAuthController;
|
use App\Http\Controllers\OAuthController;
|
||||||
@@ -71,6 +72,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
|
Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats');
|
||||||
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
||||||
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
||||||
|
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
||||||
|
|
||||||
Route::prefix('join-tokens')->group(function () {
|
Route::prefix('join-tokens')->group(function () {
|
||||||
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
|
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
|
||||||
@@ -82,6 +84,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->whereNumber('joinToken')
|
->whereNumber('joinToken')
|
||||||
->where('format', 'pdf|svg')
|
->where('format', 'pdf|svg')
|
||||||
->name('tenant.events.join-tokens.layouts.download');
|
->name('tenant.events.join-tokens.layouts.download');
|
||||||
|
Route::patch('{joinToken}', [EventJoinTokenController::class, 'update'])
|
||||||
|
->whereNumber('joinToken')
|
||||||
|
->name('tenant.events.join-tokens.update');
|
||||||
Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy'])
|
Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy'])
|
||||||
->whereNumber('joinToken')
|
->whereNumber('joinToken')
|
||||||
->name('tenant.events.join-tokens.destroy');
|
->name('tenant.events.join-tokens.destroy');
|
||||||
@@ -158,6 +163,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
|
|
||||||
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
|
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
|
||||||
->name('tenant.billing.transactions');
|
->name('tenant.billing.transactions');
|
||||||
|
|
||||||
|
Route::post('feedback', [TenantFeedbackController::class, 'store'])
|
||||||
|
->name('tenant.feedback.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
50
tests/Unit/TenantPackageTest.php
Normal file
50
tests/Unit/TenantPackageTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user