diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php index e6bea39..6860149 100644 --- a/app/Filament/Pages/Auth/Login.php +++ b/app/Filament/Pages/Auth/Login.php @@ -2,91 +2,60 @@ namespace App\Filament\Pages\Auth; -use Filament\Forms\Components\Checkbox; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Concerns\InteractsWithForms; -use Filament\Forms\Contracts\HasForms; -use Filament\Pages\SimplePage; -use Illuminate\Support\Facades\Auth; -use Illuminate\Validation\ValidationException; +use Filament\Auth\Pages\Login as BaseLogin; +use Filament\Schemas\Components\Component; -class Login extends SimplePage implements HasForms +class Login extends BaseLogin { - use InteractsWithForms; - - protected string $view = 'filament.pages.auth.login'; - protected static ?string $title = 'Tenant Login'; - - public function getFormSchema(): array + public function getTitle(): string { - return [ - TextInput::make('data.username_or_email') - ->label('Username or Email') - ->required() - ->autofocus(), - TextInput::make('data.password') - ->password() - ->required() - ->extraAttributes(['tabindex' => 2]), - Checkbox::make('data.remember') - ->label('Remember me'), - ]; + return __('auth.login.title') ?: parent::getTitle(); } - public function submit(): void + public function getHeading(): string { - $data = $this->form->getState(); + return __('auth.login.title') ?: parent::getHeading(); + } - $credentials = $this->getCredentialsFromFormData($data); + protected function getEmailFormComponent(): Component + { + $component = parent::getEmailFormComponent(); - if (! Auth::attempt($credentials, $data['remember'] ?? false)) { - throw ValidationException::withMessages([ - 'data.username_or_email' => __('auth.failed'), - ]); - } + return $component + ->label(__('auth.login.username_or_email') ?: $component->getLabel()); + } - $user = Auth::user(); + protected function getPasswordFormComponent(): Component + { + $component = parent::getPasswordFormComponent(); - if (! $user->email_verified_at) { - Auth::logout(); + return $component + ->label(__('auth.login.password') ?: $component->getLabel()); + } - throw ValidationException::withMessages([ - 'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.', - ]); - } + protected function getRememberFormComponent(): Component + { + $component = parent::getRememberFormComponent(); - if (! $user->tenant) { - Auth::logout(); - - throw ValidationException::withMessages([ - 'data.username_or_email' => 'No tenant associated with your account. Contact support.', - ]); - } - - session()->regenerate(); - - $this->redirect($this->getRedirectUrl()); + return $component + ->label(__('auth.login.remember_me') ?: $component->getLabel()); } protected function getCredentialsFromFormData(array $data): array { - $usernameOrEmail = $data['username_or_email']; - $password = $data['password']; + $identifier = $data['email'] ?? ''; + $password = $data['password'] ?? ''; - $credentials = ['password' => $password]; - - if (filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL)) { - $credentials['email'] = $usernameOrEmail; - } else { - $credentials['username'] = $usernameOrEmail; + if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) { + return [ + 'email' => $identifier, + 'password' => $password, + ]; } - return $credentials; + return [ + 'username' => $identifier, + 'password' => $password, + ]; } - - public function hasLogo(): bool - { - return false; - } - } diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 55e34f5..9e32e4d 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\EventResource\Pages; +use App\Support\JoinTokenLayoutRegistry; use App\Models\Event; use App\Models\Tenant; use App\Models\EventType; @@ -102,8 +103,20 @@ class EventResource extends Resource ->badge() ->color(fn ($state) => $state < 1 ? 'danger' : 'success') ->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0), - Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join')) - ->getStateUsing(fn($record) => url("/e/{$record->slug}")) + Tables\Columns\TextColumn::make('primary_join_token') + ->label(__('admin.events.table.join')) + ->getStateUsing(function ($record) { + $token = $record->joinTokens()->orderByDesc('created_at')->first(); + + return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens'); + }) + ->description(function ($record) { + $total = $record->joinTokens()->count(); + + return $total > 0 + ? __('admin.events.table.join_tokens_total', ['count' => $total]) + : __('admin.events.table.join_tokens_missing'); + }) ->copyable() ->copyMessage(__('admin.events.messages.join_link_copied')), Tables\Columns\TextColumn::make('created_at')->since(), @@ -115,14 +128,50 @@ class EventResource extends Resource ->label(__('admin.events.actions.toggle_active')) ->icon('heroicon-o-power') ->action(fn($record) => $record->update(['is_active' => !$record->is_active])), - Actions\Action::make('join_link') + Actions\Action::make('join_tokens') ->label(__('admin.events.actions.join_link_qr')) ->icon('heroicon-o-qr-code') ->modalHeading(__('admin.events.modal.join_link_heading')) ->modalSubmitActionLabel(__('admin.common.close')) - ->modalContent(fn($record) => view('filament.events.join-link', [ - 'link' => url("/e/{$record->slug}"), - ])), + ->modalWidth('xl') + ->modalContent(function ($record) { + $tokens = $record->joinTokens() + ->orderByDesc('created_at') + ->get() + ->map(function ($token) use ($record) { + $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); + }); + + return [ + 'id' => $token->id, + 'label' => $token->label, + 'token' => $token->token, + 'url' => url('/e/'.$token->token), + 'usage_limit' => $token->usage_limit, + 'usage_count' => $token->usage_count, + 'expires_at' => optional($token->expires_at)->toIso8601String(), + 'revoked_at' => optional($token->revoked_at)->toIso8601String(), + 'is_active' => $token->isActive(), + 'created_at' => optional($token->created_at)->toIso8601String(), + 'layouts' => $layouts, + 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + ]), + ]; + }); + + return view('filament.events.join-link', [ + 'event' => $record, + 'tokens' => $tokens, + ]); + }), ]) ->bulkActions([ Actions\DeleteBulkAction::make(), diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index d054c3b..c2ec774 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -6,11 +6,15 @@ use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\DB; -use Symfony\Component\HttpFoundation\Response; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; use App\Support\ImageHelper; use App\Services\EventJoinTokenService; +use App\Models\EventJoinToken; +use Illuminate\Http\JsonResponse; class EventPublicController extends BaseController { @@ -19,36 +23,122 @@ class EventPublicController extends BaseController } /** - * @return array{0: object|null, 1: \App\Models\EventJoinToken|null} + * @return JsonResponse|array{0: object, 1: EventJoinToken} */ - private function resolvePublishedEvent(string $identifier, array $columns = ['id']): array + private function resolvePublishedEvent(Request $request, string $token, array $columns = ['id']): JsonResponse|array { - $event = DB::table('events') - ->where('slug', $identifier) - ->where('status', 'published') - ->first($columns); + $rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->ip()); - if ($event) { - return [$event, null]; - } + $joinToken = $this->joinTokenService->findToken($token, true); - $joinToken = $this->joinTokenService->findActiveToken($identifier); if (! $joinToken) { - return [null, null]; + return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [ + 'token' => Str::limit($token, 12), + ]); } + if ($joinToken->revoked_at !== null) { + return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [ + 'token' => Str::limit($token, 12), + ]); + } + + if ($joinToken->expires_at !== null) { + $expiresAt = CarbonImmutable::parse($joinToken->expires_at); + + if ($expiresAt->isPast()) { + return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [ + 'token' => Str::limit($token, 12), + 'expired_at' => $expiresAt->toAtomString(), + ]); + } + } + + if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) { + return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [ + 'token' => Str::limit($token, 12), + 'usage_count' => $joinToken->usage_count, + 'usage_limit' => $joinToken->usage_limit, + ]); + } + + $columns = array_unique(array_merge($columns, ['status'])); + $event = DB::table('events') ->where('id', $joinToken->event_id) - ->where('status', 'published') ->first($columns); if (! $event) { - return [null, null]; + return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [ + 'token' => Str::limit($token, 12), + 'reason' => 'event_missing', + ]); + } + + if (($event->status ?? null) !== 'published') { + Log::notice('Join token event not public', [ + 'token' => Str::limit($token, 12), + 'event_id' => $event->id ?? null, + 'ip' => $request->ip(), + ]); + + return response()->json([ + 'error' => [ + 'code' => 'event_not_public', + 'message' => 'This event is not publicly accessible.', + ], + ], Response::HTTP_FORBIDDEN); + } + + RateLimiter::clear($rateLimiterKey); + + if (isset($event->status)) { + unset($event->status); } return [$event, $joinToken]; } + private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse + { + if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) { + Log::warning('Join token rate limit exceeded', array_merge([ + 'ip' => $request->ip(), + ], $context)); + + return response()->json([ + 'error' => [ + 'code' => 'token_rate_limited', + 'message' => 'Too many invalid join token attempts. Try again later.', + ], + ], Response::HTTP_TOO_MANY_REQUESTS); + } + + RateLimiter::hit($rateLimiterKey, 300); + + Log::notice('Join token access denied', array_merge([ + 'code' => $code, + 'ip' => $request->ip(), + ], $context)); + + return response()->json([ + 'error' => [ + 'code' => $code, + 'message' => $this->tokenErrorMessage($code), + ], + ], $status); + } + + private function tokenErrorMessage(string $code): string + { + return match ($code) { + 'invalid_token' => 'The provided join token is invalid.', + 'token_expired' => 'The join token has expired.', + 'token_revoked' => 'The join token has been revoked.', + default => 'Access denied.', + }; + } + private function getLocalized($value, $locale, $default = '') { if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); @@ -81,9 +171,9 @@ class EventPublicController extends BaseController return $path; // fallback as-is } - public function event(string $identifier) + public function event(Request $request, string $token) { - [$event, $joinToken] = $this->resolvePublishedEvent($identifier, [ + $result = $this->resolvePublishedEvent($request, $token, [ 'id', 'slug', 'name', @@ -92,11 +182,14 @@ class EventPublicController extends BaseController 'updated_at', 'event_type_id', ]); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + + if ($result instanceof JsonResponse) { + return $result; } - $locale = request()->query('locale', $event->default_locale ?? 'de'); + [$event, $joinToken] = $result; + + $locale = $request->query('locale', $event->default_locale ?? 'de'); $nameData = json_decode($event->name, true); $localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name; @@ -133,13 +226,15 @@ class EventPublicController extends BaseController ])->header('Cache-Control', 'no-store'); } - public function stats(string $identifier) + public function stats(Request $request, string $token) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + [$event] = $result; $eventId = $event->id; // Approximate online guests as distinct recent uploaders in last 10 minutes. @@ -162,7 +257,7 @@ class EventPublicController extends BaseController ]; $etag = sha1(json_encode($payload)); - $reqEtag = request()->headers->get('If-None-Match'); + $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } @@ -172,14 +267,17 @@ class EventPublicController extends BaseController ->header('ETag', $etag); } - public function emotions(string $identifier) + public function emotions(Request $request, string $token) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + [$event] = $result; $eventId = $event->id; + $locale = $request->query('locale', 'de'); $rows = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') @@ -195,8 +293,7 @@ class EventPublicController extends BaseController ->orderBy('emotions.sort_order') ->get(); - $payload = $rows->map(function ($r) { - $locale = request()->query('locale', 'de'); + $payload = $rows->map(function ($r) use ($locale) { $nameData = json_decode($r->name, true); $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; @@ -213,7 +310,7 @@ class EventPublicController extends BaseController }); $etag = sha1($payload->toJson()); - $reqEtag = request()->headers->get('If-None-Match'); + $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } @@ -223,13 +320,15 @@ class EventPublicController extends BaseController ->header('ETag', $etag); } - public function tasks(string $identifier, Request $request) + public function tasks(Request $request, string $token) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + [$event] = $result; $eventId = $event->id; $query = DB::table('tasks') @@ -249,11 +348,11 @@ class EventPublicController extends BaseController ->limit(20); $rows = $query->get(); + $locale = $request->query('locale', 'de'); - $payload = $rows->map(function ($r) { + $payload = $rows->map(function ($r) use ($locale) { // Handle JSON fields for multilingual content - $getLocalized = function ($field, $default = '') use ($r) { - $locale = request()->query('locale', 'de'); + $getLocalized = function ($field, $default = '') use ($r, $locale) { $value = $r->$field; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); @@ -267,7 +366,6 @@ class EventPublicController extends BaseController if ($r->emotion_id) { $emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']); if ($emotionRow && isset($emotionRow->name)) { - $locale = request()->query('locale', 'de'); $value = $emotionRow->name; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); @@ -299,7 +397,7 @@ class EventPublicController extends BaseController } $etag = sha1($payload->toJson()); - $reqEtag = request()->headers->get('If-None-Match'); + $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } @@ -309,12 +407,15 @@ class EventPublicController extends BaseController ->header('ETag', $etag); } - public function photos(Request $request, string $identifier) + public function photos(Request $request, string $token) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + + [$event] = $result; $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); @@ -345,7 +446,7 @@ class EventPublicController extends BaseController if ($since) { $query->where('photos.created_at', '>', $since); } - $locale = request()->query('locale', 'de'); + $locale = $request->query('locale', 'de'); $rows = $query->get()->map(function ($r) use ($locale) { $r->file_path = $this->toPublicUrl((string)($r->file_path ?? '')); @@ -364,7 +465,7 @@ class EventPublicController extends BaseController 'latest_photo_at' => $latestPhotoAt, ]; $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt])); - $reqEtag = request()->headers->get('If-None-Match'); + $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } @@ -455,12 +556,15 @@ class EventPublicController extends BaseController return response()->json(['liked' => true, 'likes_count' => $count]); } - public function upload(Request $request, string $identifier) + public function upload(Request $request, string $token) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + + [$event] = $result; $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); @@ -556,11 +660,13 @@ class EventPublicController extends BaseController } public function achievements(Request $request, string $identifier) { - [$event] = $this->resolvePublishedEvent($identifier, ['id']); - if (! $event) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404); + $result = $this->resolvePublishedEvent($request, $identifier, ['id']); + + if ($result instanceof JsonResponse) { + return $result; } + [$event] = $result; $eventId = $event->id; $locale = $request->query('locale', 'de'); diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php new file mode 100644 index 0000000..56d498b --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php @@ -0,0 +1,131 @@ +ensureBelongsToEvent($event, $joinToken); + + $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $event, + 'joinToken' => $joinToken, + 'layout' => $layoutId, + 'format' => $format, + ]); + }); + + return response()->json([ + 'data' => $layouts, + ]); + } + + public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format) + { + $this->ensureBelongsToEvent($event, $joinToken); + + $layoutConfig = JoinTokenLayoutRegistry::find($layout); + + if (! $layoutConfig) { + abort(404, 'Layout nicht gefunden.'); + } + + if (! in_array($format, ['pdf', 'svg'], true)) { + abort(404, 'Unbekanntes Exportformat.'); + } + + $tokenUrl = url('/e/'.$joinToken->token); + + $qrPngDataUri = 'data:image/png;base64,'.base64_encode( + QrCode::format('png') + ->margin(0) + ->size($layoutConfig['qr']['size_px']) + ->generate($tokenUrl) + ); + + $backgroundStyle = $this->buildBackgroundStyle($layoutConfig); + $eventName = $this->resolveEventName($event); + + $viewData = [ + 'layout' => $layoutConfig, + 'event' => $event, + 'eventName' => $eventName, + 'token' => $joinToken, + 'tokenUrl' => $tokenUrl, + 'qrPngDataUri' => $qrPngDataUri, + 'backgroundStyle' => $backgroundStyle, + ]; + + $filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format); + + if ($format === 'svg') { + $svg = view('layouts.join-token.svg', $viewData)->render(); + + return response($svg) + ->header('Content-Type', 'image/svg+xml') + ->header('Content-Disposition', 'attachment; filename="'.$filename.'"'); + } + + $html = view('layouts.join-token.pdf', $viewData)->render(); + + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', true); + $options->set('defaultFont', 'Helvetica'); + + $dompdf = new Dompdf($options); + $dompdf->setPaper(strtoupper($layoutConfig['paper']), $layoutConfig['orientation'] === 'landscape' ? 'landscape' : 'portrait'); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->render(); + + return response($dompdf->output()) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="'.$filename.'"'); + } + + private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void + { + if ($joinToken->event_id !== $event->id) { + abort(404); + } + } + + private function resolveEventName(Event $event): string + { + $name = $event->name; + + if (is_array($name)) { + $locale = $event->default_locale ?? 'de'; + + return $name[$locale] ?? $name['de'] ?? reset($name) ?: 'Event'; + } + + return is_string($name) && $name !== '' ? $name : 'Event'; + } + + private function buildBackgroundStyle(array $layout): string + { + $gradient = $layout['background_gradient'] ?? null; + + if (is_array($gradient) && ! empty($gradient['stops'])) { + $angle = $gradient['angle'] ?? 180; + $stops = implode(',', $gradient['stops']); + + return sprintf('linear-gradient(%ddeg,%s)', $angle, $stops); + } + + return $layout['background'] ?? '#FFFFFF'; + } +} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/EventJoinTokenResource.php b/app/Http/Resources/Tenant/EventJoinTokenResource.php index 2ee27a4..3318eee 100644 --- a/app/Http/Resources/Tenant/EventJoinTokenResource.php +++ b/app/Http/Resources/Tenant/EventJoinTokenResource.php @@ -2,6 +2,8 @@ namespace App\Http\Resources\Tenant; +use App\Models\Event; +use App\Support\JoinTokenLayoutRegistry; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -12,6 +14,28 @@ class EventJoinTokenResource extends JsonResource */ public function toArray($request): array { + /** @var Event|null $eventFromRoute */ + $eventFromRoute = $request->route('event'); + $eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null); + + $layouts = $eventContext + ? JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $eventContext, + 'joinToken' => $this->resource, + 'layout' => $layoutId, + 'format' => $format, + ]); + }) + : []; + + $layoutsUrl = $eventContext + ? route('tenant.events.join-tokens.layouts.index', [ + 'event' => $eventContext, + 'joinToken' => $this->resource, + ]) + : null; + return [ 'id' => $this->id, 'label' => $this->label, @@ -24,6 +48,8 @@ class EventJoinTokenResource extends JsonResource 'is_active' => $this->isActive(), 'created_at' => optional($this->created_at)->toIso8601String(), 'metadata' => $this->metadata ?? new \stdClass(), + 'layouts_url' => $layoutsUrl, + 'layouts' => $layouts, ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 2882931..e52392a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,10 +8,16 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Database\Eloquent\Relations\HasOne; use Laravel\Sanctum\HasApiTokens; +use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasTenants as FilamentHasTenants; +use Filament\Panel; +use Illuminate\Support\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Filament\Models\Contracts\HasName; -class User extends Authenticatable implements MustVerifyEmail, HasName + +class User extends Authenticatable implements MustVerifyEmail, HasName, FilamentUser, FilamentHasTenants { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable; @@ -99,8 +105,43 @@ class User extends Authenticatable implements MustVerifyEmail, HasName return $this->username ?? $this->email ?? 'Unnamed User'; } - public function tenant(): HasOne + public function tenant(): BelongsTo { - return $this->hasOne(Tenant::class); + return $this->belongsTo(Tenant::class); + } + + public function canAccessPanel(Panel $panel): bool + { + if (! $this->email_verified_at && $this->role !== 'super_admin') { + return false; + } + + return in_array($this->role, ['tenant_admin', 'super_admin'], true); + } + + public function canAccessTenant(Model $tenant): bool + { + if ($this->role === 'super_admin') { + return true; + } + + $ownedTenant = $this->tenant; + + if (! $ownedTenant) { + return false; + } + + return (int) $tenant->getKey() === (int) $ownedTenant->getKey(); + } + + public function getTenants(Panel $panel): array | Collection + { + if ($this->role === 'super_admin') { + return Tenant::query()->orderBy('name')->get(); + } + + $tenant = $this->tenant; + + return $tenant ? collect([$tenant]) : collect(); } } diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index d2f2c3c..13b6881 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -112,6 +112,9 @@ class CheckoutAssignmentService protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant { if ($user->tenant) { + if (! $user->tenant_id) { + $user->forceFill(['tenant_id' => $user->tenant->getKey()])->save(); + } return $user->tenant; } @@ -130,6 +133,10 @@ class CheckoutAssignmentService ], ]); + if ($user->tenant_id !== $tenant->id) { + $user->forceFill(['tenant_id' => $tenant->id])->save(); + } + event(new Registered($user)); return $tenant; diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index c75fd2b..27f2ce8 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -58,22 +58,29 @@ class EventJoinTokenService $joinToken->increment('usage_count'); } - public function findActiveToken(string $token): ?EventJoinToken + public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken { return EventJoinToken::query() ->where('token', $token) - ->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'); + ->when(! $includeInactive, function ($query) { + $query->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'); + }); }) ->first(); } + public function findActiveToken(string $token): ?EventJoinToken + { + return $this->findToken($token); + } + protected function generateUniqueToken(int $length = 48): string { do { diff --git a/app/Support/JoinTokenLayoutRegistry.php b/app/Support/JoinTokenLayoutRegistry.php new file mode 100644 index 0000000..296cf76 --- /dev/null +++ b/app/Support/JoinTokenLayoutRegistry.php @@ -0,0 +1,197 @@ + + */ + private const LAYOUTS = [ + 'modern-poster' => [ + 'id' => 'modern-poster', + 'name' => 'Modern Poster', + 'subtitle' => 'Große, auffällige Fläche – perfekt für den Eingangsbereich.', + 'description' => 'Helle Posteroptik mit diagonalem Farbband und deutlicher Call-to-Action.', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#F8FAFC', + 'text' => '#0F172A', + 'accent' => '#6366F1', + 'secondary' => '#CBD5F5', + 'badge' => '#0EA5E9', + 'qr' => ['size_px' => 340], + 'svg' => ['width' => 1080, 'height' => 1520], + 'instructions' => [ + 'Scanne den Code und tritt dem Event direkt bei.', + 'Speichere deine Lieblingsmomente mit Foto-Uploads.', + 'Merke dir dein Gäste-Pseudonym für Likes und Badges.', + ], + ], + 'elegant-frame' => [ + 'id' => 'elegant-frame', + 'name' => 'Elegant Frame', + 'subtitle' => 'Ein ruhiges Layout mit Fokus auf Eleganz.', + 'description' => 'Serifen-Schrift, pastellige Flächen und dezente Rahmen für elegante Anlässe.', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#FBF7F2', + 'text' => '#2B1B13', + 'accent' => '#C08457', + 'secondary' => '#E6D5C3', + 'badge' => '#8B5CF6', + 'qr' => ['size_px' => 300], + 'svg' => ['width' => 1080, 'height' => 1520], + 'instructions' => [ + 'QR-Code scannen oder Link im Browser eingeben.', + 'Name eingeben, Lieblingssprache auswählen und loslegen.', + 'Zeige diesen Druck am Empfang als Orientierung für Gäste.', + ], + ], + 'bold-gradient' => [ + 'id' => 'bold-gradient', + 'name' => 'Bold Gradient', + 'subtitle' => 'Farbverlauf mit starkem Kontrast.', + 'description' => 'Ein kraftvolles Farbstatement mit großem QR-Code – ideal für Partys.', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#F97316', + 'background_gradient' => [ + 'angle' => 190, + 'stops' => ['#F97316', '#EC4899', '#8B5CF6'], + ], + 'text' => '#FFFFFF', + 'accent' => '#FFFFFF', + 'secondary' => 'rgba(255,255,255,0.72)', + 'badge' => '#1E293B', + 'qr' => ['size_px' => 360], + 'svg' => ['width' => 1080, 'height' => 1520], + 'instructions' => [ + 'Sofort scannen – der QR-Code führt direkt zum Event.', + 'Fotos knipsen, Challenges lösen und Likes sammeln.', + 'Teile den Link mit Freund:innen, falls kein Scan möglich ist.', + ], + ], + 'photo-strip' => [ + 'id' => 'photo-strip', + 'name' => 'Photo Strip', + 'subtitle' => 'Layout mit Fotostreifen-Anmutung und Checkliste.', + 'description' => 'Horizontale Teilung, Platz für Hinweise und Storytelling.', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#FFFFFF', + 'text' => '#111827', + 'accent' => '#0EA5E9', + 'secondary' => '#94A3B8', + 'badge' => '#334155', + 'qr' => ['size_px' => 320], + 'svg' => ['width' => 1080, 'height' => 1520], + 'instructions' => [ + 'Schritt 1: QR-Code scannen oder Kurzlink nutzen.', + 'Schritt 2: Profilname eingeben – kreativ sein!', + 'Schritt 3: Fotos hochladen und Teamaufgaben lösen.', + ], + ], + 'minimal-card' => [ + 'id' => 'minimal-card', + 'name' => 'Minimal Card', + 'subtitle' => 'Kleine Karte – mehrfach druckbar als Tischaufsteller.', + 'description' => 'Schlichtes Kartenformat mit klarer Typografie und viel Weißraum.', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#F9FAFB', + 'text' => '#111827', + 'accent' => '#9333EA', + 'secondary' => '#E0E7FF', + 'badge' => '#64748B', + 'qr' => ['size_px' => 280], + 'svg' => ['width' => 1080, 'height' => 1520], + 'instructions' => [ + 'Code scannen, Profil erstellen, Erinnerungen festhalten.', + 'Halte diese Karte an mehreren Stellen bereit.', + 'Für Ausdrucke auf 200 g/m² Kartenpapier empfohlen.', + ], + ], + ]; + + /** + * Get layout definitions. + * + * @return array> + */ + public static function all(): array + { + return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS)); + } + + /** + * Find a layout definition. + */ + public static function find(string $id): ?array + { + $layout = self::LAYOUTS[$id] ?? null; + + return $layout ? self::normalize($layout) : null; + } + + /** + * Normalize and merge default values. + */ + private static function normalize(array $layout): array + { + $defaults = [ + 'subtitle' => '', + 'description' => '', + 'paper' => 'a4', + 'orientation' => 'portrait', + 'background' => '#F9FAFB', + 'text' => '#0F172A', + 'accent' => '#6366F1', + 'secondary' => '#CBD5F5', + 'badge' => '#2563EB', + 'qr' => [ + 'size_px' => 320, + ], + 'svg' => [ + 'width' => 1080, + 'height' => 1520, + ], + 'background_gradient' => null, + 'instructions' => [], + ]; + + return array_replace_recursive($defaults, $layout); + } + + /** + * Map layouts into an API-ready response structure, attaching URLs. + * + * @param callable(string $layoutId, string $format): string $urlResolver + * @return array> + */ + public static function toResponse(callable $urlResolver): array + { + return array_map(function (array $layout) use ($urlResolver) { + $formats = ['pdf', 'svg']; + + return [ + 'id' => $layout['id'], + 'name' => $layout['name'], + 'description' => $layout['description'], + 'subtitle' => $layout['subtitle'], + 'preview' => [ + 'background' => $layout['background'], + 'background_gradient' => $layout['background_gradient'], + 'accent' => $layout['accent'], + 'text' => $layout['text'], + ], + 'formats' => $formats, + 'download_urls' => collect($formats) + ->mapWithKeys(fn ($format) => [$format => $urlResolver($layout['id'], $format)]) + ->all(), + ]; + }, self::all()); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index f917f51..4e1da9e 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "dompdf/dompdf": "2.0", "filament/filament": "~4.0", "firebase/php-jwt": "^6.11", "inertiajs/inertia-laravel": "^2.0", diff --git a/composer.lock b/composer.lock index e1b5244..6910fc8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7732f55f2145944530fb5c8b8c035b4", + "content-hash": "2852435257a5672486892b814ff57bbf", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1177,6 +1177,76 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "79573d8b8a141ec8a17312515de8740eed014fa9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/79573d8b8a141ec8a17312515de8740eed014fa9", + "reference": "79573d8b8a141ec8a17312515de8740eed014fa9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "phenx/php-font-lib": "^0.5.4", + "phenx/php-svg-lib": "^0.3.3 || ^0.4.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "Fabien Ménager", + "email": "fabien.menager@gmail.com" + }, + { + "name": "Brian Sweeney", + "email": "eclecticgeek@gmail.com" + }, + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v2.0.0" + }, + "time": "2022-06-21T21:14:57+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.4.0", @@ -4670,6 +4740,96 @@ }, "time": "2025-05-27T17:46:31+00:00" }, + { + "name": "phenx/php-font-lib", + "version": "0.5.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a1681e9793040740a405ac5b189275059e2a9863" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a1681e9793040740a405ac5b189275059e2a9863", + "reference": "a1681e9793040740a405ac5b189275059e2a9863", + "shasum": "" + }, + "require": { + "ext-mbstring": "*" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Fabien Ménager", + "email": "fabien.menager@gmail.com" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/PhenX/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/0.5.6" + }, + "time": "2024-01-29T14:45:26+00:00" + }, + { + "name": "phenx/php-svg-lib", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "4498b5df7b08e8469f0f8279651ea5de9626ed02" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/4498b5df7b08e8469f0f8279651ea5de9626ed02", + "reference": "4498b5df7b08e8469f0f8279651ea5de9626ed02", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^7.2 || ^7.3 || ^7.4 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Fabien Ménager", + "email": "fabien.menager@gmail.com" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/PhenX/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/0.4.1" + }, + "time": "2022-03-07T12:52:04+00:00" + }, { "name": "php-jsonpointer/php-jsonpointer", "version": "v3.0.2", @@ -5733,6 +5893,72 @@ ], "time": "2025-02-25T09:09:36+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.9.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" + }, + "time": "2025-07-11T13:20:48+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", diff --git a/database/migrations/2025_09_01_000300_create_events_tasks.php b/database/migrations/2025_09_01_000300_create_events_tasks.php index 42456ac..db4eb91 100644 --- a/database/migrations/2025_09_01_000300_create_events_tasks.php +++ b/database/migrations/2025_09_01_000300_create_events_tasks.php @@ -26,7 +26,7 @@ return new class extends Migration $table->boolean('photo_upload_enabled')->default(true); $table->boolean('task_checklist_enabled')->default(true); $table->string('default_locale', 5)->default('de'); - $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); // From add_status_to_events + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); // From add_status_to_events $table->timestamps(); $table->index(['tenant_id', 'date', 'is_active']); $table->foreign('event_type_id')->references('id')->on('event_types')->onDelete('restrict'); @@ -34,7 +34,7 @@ return new class extends Migration } else { if (!Schema::hasColumn('events', 'status')) { Schema::table('events', function (Blueprint $table) { - $table->enum('status', ['draft', 'active', 'archived'])->default('draft')->after('is_active'); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft')->after('is_active'); }); } } @@ -165,4 +165,4 @@ return new class extends Migration Schema::dropIfExists('events'); } } -}; \ No newline at end of file +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 517b5d0..e93a9f4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,7 @@ class DatabaseSeeder extends Seeder // Seed demo and admin data $this->call([ SuperAdminSeeder::class, + DemoTenantSeeder::class, DemoEventSeeder::class, OAuthClientSeeder::class, ]); diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php index d6fd2a3..b16109e 100644 --- a/database/seeders/DemoEventSeeder.php +++ b/database/seeders/DemoEventSeeder.php @@ -20,7 +20,7 @@ class DemoEventSeeder extends Seeder 'description' => ['de'=>'Demo-Event','en'=>'Demo event'], 'date' => now()->addMonths(3)->toDateString(), 'event_type_id' => $type->id, - 'status' => 'active', + 'status' => 'published', 'is_active' => true, 'settings' => json_encode([]), 'default_locale' => 'de', diff --git a/database/seeders/DemoTenantSeeder.php b/database/seeders/DemoTenantSeeder.php new file mode 100644 index 0000000..e511d9c --- /dev/null +++ b/database/seeders/DemoTenantSeeder.php @@ -0,0 +1,122 @@ +where('type', 'reseller') + ->orderBy('price') + ->first() + ?? Package::query()->orderBy('price')->first(); + + if (! $package) { + $this->command?->warn('Skipped DemoTenantSeeder: no packages available.'); + + return; + } + + $user = User::query()->firstOrCreate( + ['email' => $email], + [ + 'username' => 'tenant-demo', + 'password' => Hash::make($password), + 'first_name' => 'Demo', + 'last_name' => 'Tenant', + 'address' => 'Demo Straße 1, 12345 Musterstadt', + 'phone' => '+49 123 456789', + 'role' => 'tenant_admin', + 'pending_purchase' => false, + 'email_verified_at' => now(), + ] + ); + + if (! $user->email_verified_at) { + $user->forceFill(['email_verified_at' => now()])->save(); + } + + if ($user->role !== 'tenant_admin') { + $user->forceFill(['role' => 'tenant_admin'])->save(); + } + + $tenant = Tenant::query()->firstOrCreate( + ['slug' => 'demo-tenant'], + [ + 'user_id' => $user->id, + 'name' => 'Demo Tenant', + 'email' => $user->email, + 'is_active' => true, + 'is_suspended' => false, + 'event_credits_balance' => 0, + 'subscription_tier' => $package->type, + 'subscription_status' => 'active', + 'settings' => [ + 'contact_email' => $user->email, + 'branding' => [ + 'logo_url' => null, + 'primary_color' => '#f43f5e', + 'secondary_color' => '#1f2937', + ], + ], + ] + ); + + if ($tenant->wasRecentlyCreated && ! $tenant->slug) { + $tenant->forceFill(['slug' => Str::slug('demo-tenant-'. $tenant->getKey())])->save(); + } + + if ($user->tenant_id !== $tenant->id) { + $user->forceFill(['tenant_id' => $tenant->id])->save(); + } + + TenantPackage::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + [ + 'price' => $package->price, + 'active' => true, + 'purchased_at' => now()->subDays(7), + 'expires_at' => now()->addYear(), + 'used_events' => 0, + ] + ); + + PackagePurchase::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => 'demo-seed', + ], + [ + 'price' => $package->price, + 'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event', + 'purchased_at' => now()->subDays(7), + 'metadata' => [ + 'seeded' => true, + 'note' => 'Demo tenant seed purchase', + ], + ] + ); + + $this->command?->info(sprintf( + 'Demo tenant ready. Login with %s / %s', + $email, + $password + )); + } +} diff --git a/docs/prp/03-api.md b/docs/prp/03-api.md index cb7bd63..16d4f26 100644 --- a/docs/prp/03-api.md +++ b/docs/prp/03-api.md @@ -19,10 +19,10 @@ Key Endpoints (abridged) - Settings: read/update tenant theme, limits, legal page links. Guest Polling (no WebSockets in v1) -- GET `/events/{slug}/stats` — lightweight counters for Home info bar. +- GET `/events/{token}/stats` — lightweight counters for Home info bar. - Response: `{ online_guests: number, tasks_solved: number, latest_photo_at: ISO8601 }`. - Cache: `Cache-Control: no-store`; include `ETag` for conditional requests. -- GET `/events/{slug}/photos?since=` — incremental gallery refresh. +- GET `/events/{token}/photos?since=` — incremental gallery refresh. - Response: `{ data: Photo[], next_cursor?: string, latest_photo_at: ISO8601 }`. - Use `If-None-Match` or `If-Modified-Since` to return `304 Not Modified` when unchanged. diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md index b68f044..00889b9 100644 --- a/docs/prp/07-guest-pwa.md +++ b/docs/prp/07-guest-pwa.md @@ -87,9 +87,9 @@ Technical Notes - Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline. API Touchpoints -- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme. -- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only). -- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields. +- GET `/api/v1/events/{token}` — public event metadata (when open) + theme. +- GET `/api/v1/events/{token}/photos` — paginated gallery (approved only). +- POST `/api/v1/events/{token}/photos` — signed upload initiation; returns URL + fields. - POST (S3) — direct upload to object storage; then backend finalize call. - POST `/api/v1/photos/{id}/like` — idempotent like with device token. diff --git a/docs/prp/tenant-app-specs/capacitor-setup.md b/docs/prp/tenant-app-specs/capacitor-setup.md index 62c4adc..1db0f63 100644 --- a/docs/prp/tenant-app-specs/capacitor-setup.md +++ b/docs/prp/tenant-app-specs/capacitor-setup.md @@ -121,9 +121,9 @@ packages/mobile/ # Shared Native-Config (optional) - **Privacy**: Usage Descriptions in Info.plist (z.B. "Kamera für QR-Scans"). - **PWA-Fallback** (Web): - - **manifest.json**: `start_url: '/event-admin/'`, `display: 'standalone'`. - - **Service Worker**: Caching von Assets; Background Sync für Mutations. - - **Distribution**: Hosting auf `admin.fotospiel.app` mit A2HS-Prompt. + - **manifest.json**: liegt unter `public/manifest.json` (Scope `/event-admin/`, Theme-Farbe `#f43f5e`, Shortcuts für Welcome & Dashboard). + - **Service Worker**: `public/admin-sw.js` cached Shell + Assets, liefert Offline-Fallback `/event-admin` für Navigations-Anfragen. + - **Distribution**: Hosting auf `admin.fotospiel.app` mit A2HS-Prompt; Bubblewrap/TWA nutzt `https://admin.fotospiel.app/manifest.json`. ### Native Features (Erweiterung zu settings-config.md) - **Push-Notifications**: diff --git a/docs/prp/tenant-app-specs/functional-specs.md b/docs/prp/tenant-app-specs/functional-specs.md index f8db25a..a6cd8c8 100644 --- a/docs/prp/tenant-app-specs/functional-specs.md +++ b/docs/prp/tenant-app-specs/functional-specs.md @@ -1,100 +1,87 @@ -# Funktionale Spezifikationen für die Tenant Admin App +# Funktionale Spezifikationen – Tenant-Admin-App ## Status -- **Version**: 1.0.0 (2025-09-13) -- **Supersedes**: Teile von docs/prp/06-tenant-admin-pwa.md und docs/prp-addendum-2025-09-08-tenant-admin-pwa.md +- **Version**: 1.1.0 (Stand 2025-10-13) +- **Ersetzt**: docs/prp/06-tenant-admin-pwa.md, docs/prp-addendum-2025-09-08-tenant-admin-pwa.md (Legacy-Referenz über Git History). ## Deliverables -Die Tenant Admin App muss folgende Kernfunktionen bereitstellen: -- **Event-Management**: CRUD-Operationen für Events (Erstellen, Bearbeiten, Archivieren, Veröffentlichen). -- **Gallery-Management**: Hochladen, Moderieren, Featured-Setten von Photos; Thumbnail-Generierung. -- **Member-Management**: Hinzufügen/Entfernen von Event-Mitgliedern; Rollen (Admin, Member). -- **Task & Emotion Management**: Zuweisen von Tasks und Emotions zu Events; Overrides für Tenant-spezifische Bibliotheken. -- **Settings-Management**: Tenant-spezifische Einstellungen (Theme, Limits, Legal Pages). -- **Billing & Purchases**: Kaufen von Packages (pro Event oder Tenant); Ledger-Übersicht; Integration mit Stripe. -- **Notifications**: Push-Benachrichtigungen für neue Photos, Event-Updates, niedrigen Credit-Balance. -- **Offline-Support**: Caching von Events und Photos; Queuing von Uploads/Mutations mit Sync bei Online-Wiederkehr. -- **Audit & Compliance**: Logging kritischer Aktionen; ETag-basierte Conflict-Resolution; GDPR-konforme Datenlöschung. - -Die App ist API-first und interagiert ausschließlich über den Backend-API-Endpunkt `/api/v1/tenant/*`. +Die Admin-App muss folgende Kernfunktionen bereitstellen: +- **Geführtes Onboarding**: Welcome Flow (Hero, How-It-Works, Paketwahl, Zusammenfassung, Erstes Event). Automatische Weiterleitung für Tenants ohne aktive Events. +- **Event-Management**: Erstellen, Bearbeiten, Veröffentlichen, Archivieren; Join-Token-Verwaltung. +- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics. +- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle. +- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking. +- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger. +- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen. +- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect. +- **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung. ## Capabilities ### Authentifizierung & Autorisierung -- **OAuth2 Flow**: Authorization Code + PKCE für sichere Token-Erfassung. -- **Token-Management**: Refresh-Tokens mit automatischer Rotation; Secure Storage (Keychain/Keystore). -- **Tenant-Scoping**: Alle Requests enthalten `tenant_id` aus Token; Backend-Policies enforcen Isolation. -- **Roles**: Unterstützung für `tenant_admin` (volle CRUD), `member` (read-only + Uploads). +- OAuth2 Authorization Code mit PKCE, Refresh-Tokens via Secure Storage (Web: IndexedDB, Capacitor: Preferences/Keychain). +- Tenant-Scoped Tokens; Rollen `tenant_admin` (vollständig) & `member` (read-only, Upload). -### Core Features -- **Event Lifecycle**: - - Erstellen: Erfordert Package-Auswahl (Free oder Kauf); Slug-Generierung (unique pro Tenant). - - Bearbeiten: Update von Datum, Ort, Tasks, Emotions, Join-Link. - - Veröffentlichen: Generiert QR-Code und Share-Link; aktiviert Guest-PWA-Zugriff. - - Archivieren: Soft-Delete mit Retention-Periode (GDPR); Credit-Rückerstattung optional. -- **Photo Management**: - - Upload: Signed URLs für direkte S3-Uploads; automatisierte Thumbnail-Generierung. - - Moderation: Approve/Reject/Feature; Bulk-Operations. - - Analytics: Stats zu Likes, Views, Uploads pro Event. -- **Task & Emotion System**: - - Bibliothek: Globale + Tenant-spezifische Tasks/Emotions. - - Zuweisung: Drag-and-Drop zu Events; Fortschritts-Tracking. -- **Billing Integration**: - - Package-Auswahl: Anzeige verfügbarer Packages und Kauf (Einmalkauf/Subscription). - - Ledger: Historie von Package-Käufen und Nutzung. - - Stripe-Checkout: Server-side Intent-Erstellung; Webhook-Handling für Confirmation. +### Onboarding Journey +- Routen `/event-admin/welcome/*` bilden den Flow. +- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`). +- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration. +- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde. -### Offline & Sync -- **Service Worker**: Cache von App-Shell, Events, Photos (Cache-Control: max-age=5min für dynamische Daten). -- **Background Sync**: Queued Mutations (z.B. Photo-Approvals) syncen bei Connectivity. -- **Conflict Resolution**: ETag/If-Match Headers; Optimistic Updates mit Rollback bei Conflicts. +### Event Lifecycle +- Erstellung prüft Paketverfügbarkeit; generiert Join-Token. +- Bearbeiten erlaubt Statuswechsel, Aufgaben, Emotions, Join-Token-Verwaltung. +- Veröffentlichen schaltet Guest-PWA frei; Archivieren respektiert Retention-Policy. -### Error Handling & UX -- **Rate Limits**: 429-Responses handhaben mit Retry-Logic und User-Feedback ("Zu viele Anfragen, versuche es später"). -- **Offline Mode**: Degradiertes UI (Read-Only); Sync-Status-Indikator. -- **i18n**: react-i18next mit JSON (`public/lang/{locale}/admin.json`); de/en; Locale aus User-Profile oder URL-Prefix (/de/, /en/); Detection via LanguageDetector; RTL nicht in MVP. - -## API-Integration -Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte: - -### Auth -- `POST /oauth/token`: PKCE-Code für Access/Refresh-Token. -- `POST /oauth/token/refresh`: Token-Rotation. - -### Events -- `GET /tenant/events`: Liste (paginiert, filterbar nach Status/Datum). -- `POST /tenant/events`: Erstellen (validiert Tenant-Package und erstellt Event-Package). -- `GET /tenant/events/{id}`: Details inkl. Tasks, Stats, package_limits. -- `PATCH /tenant/events/{id}`: Update (ETag für Concurrency). -- `DELETE /tenant/events/{id}`: Archivieren. - -### Photos -- `GET /tenant/events/{event_id}/photos?since={cursor}`: Inkrementelle Liste. -- `POST /tenant/events/{event_id}/photos`: Metadata + signed Upload-URL. -- `PATCH /tenant/photos/{id}`: Moderation (approve, feature). -- `DELETE /tenant/photos/{id}`: Löschung mit Audit. +### Medien & Moderation +- Direktupload via signed URLs, Thumbnail-Generierung serverseitig. +- Moderations-Grid mit Bulk-Aktionen, Filter (Neu, Genehmigt, Featured). +- Analytics: Likes, Uploadzahlen, aktive Gäste. ### Tasks & Emotions -- `GET /tenant/tasks`: Tenant-Overrides + globale Bibliothek. -- `POST /tenant/events/{id}/tasks`: Zuweisung. -- Ähnlich für Emotions. +- Globale + Tenant-spezifische Bibliothek. +- Drag-and-Drop Zuweisung, Fortschritt je Event, Emotion-Tagging. + +### Billing & Checkout +- Pakete + Credit-Balance anzeigen. +- Stripe PaymentIntent & PayPal Smart Buttons; Fallback-Meldung bei fehlender Konfiguration. +- Ledger mit Historie (Paginierung, Filter). ### Settings -- `GET /tenant/settings`: Tenant-Konfig (Theme, Limits, Legal-Links). -- `PATCH /tenant/settings`: Update. +- Branding (Logo, Farben), Domain/Links, Legal Pages. +- Notification Preferences, Paketlimits, Onboarding-Reset. -### Billing -- `GET /tenant/packages`: Tenant-Packages und Limits. -- `POST /tenant/purchases/intent`: Stripe-Checkout-Session für Package erstellen. -- `GET /api/v1/packages`: Verfügbare Packages. +### Offline & Sync +- Service Worker `public/admin-sw.js` cached App-Shell `/event-admin` und statische Assets, liefert Offline-Fallback für Navigation. +- Mutationen werden gequeued und nach Reconnect synchronisiert. +- ETag / If-Match für konfliktfreie Updates, Optimistic UI mit Rollback. -### Pagination & Errors -- Standard: `page`, `per_page` (max 50 für Mobile). -- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Package-Limit überschritten"). +### Fehlerbehandlung & UX +- Rate-Limit (429) → Retry-Hinweis. +- Offline-Banner + Retry-Buttons an kritischen Stellen (Checkout, Upload). +- i18n via `react-i18next` (de/en); Strings in `public/lang/{locale}/admin.json`. -## Non-Functional Requirements -- **Performance**: Ladezeiten < 2s; Lazy-Loading für Galleries. -- **Sicherheit**: Kein PII-Logging; Token-Expiration-Handling; CSRF-Schutz via PKCE. -- **Accessibility**: Framework7 ARIA-Support; Dark Mode (System-Preference). -- **Testing**: Unit-Tests für API-Calls; E2E-Tests für Flows (Cypress). +## API-Integration +Die App nutzt Endpunkte aus `docs/prp/03-api.md`. -Für detaillierte UI-Seiten siehe pages-ui.md; für Settings siehe settings-config.md. \ No newline at end of file +| Bereich | Endpunkte | +| --- | --- | +| Auth | `POST /oauth/token`, `POST /oauth/token/refresh` | +| Onboarding | `GET /tenant/me` (Progress Flags), `GET /tenant/packages`, `POST /tenant/events` | +| Events | `GET/POST/PATCH/DELETE /tenant/events`, `POST /tenant/events/{event}/toggle`, Join-Token Routen | +| Medien | `GET /tenant/events/{event}/photos`, `POST /tenant/events/{event}/photos`, `PATCH /tenant/photos/{id}` | +| Tasks & Emotions | `GET /tenant/tasks`, `POST /tenant/events/{event}/tasks`, `GET /tenant/emotions` | +| Settings | `GET/PATCH /tenant/settings`, `GET /tenant/credits/balance`, `POST /tenant/purchases/intent` | + +## Nicht-funktionale Anforderungen +- **Performance**: Ladezeit < 2s; Code-Splitting der Onboarding-Screens. +- **Sicherheit**: Keine sensiblen Logs; CSRF-mitigiert via PKCE/OAuth; Token-Refresh automatisiert. +- **Accessibility**: Tastaturbedienung, Fokus-Indikatoren, `prefers-reduced-motion`. +- **Internationalisierung**: Sprachumschaltung in Einstellungen; Standard de, Fallback en. + +## Teststrategie +- **PHPUnit**: Feature-Tests für Auth-Guards (Tenant ohne Events → Welcome Flow). +- **React Testing Library**: `TenantWelcomeLayout`, `PackageSelection`, `OnboardingGuard`, `OrderSummary`. +- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` deckt Login, Welcome → Packages → Summary → Event Setup ab; Erweiterung um Stripe/PayPal Happy Paths und Offline/Retry geplant. +- **Smoke Tests**: `npm run test:e2e` in CI mit optionalen Credentials (`E2E_TENANT_EMAIL`, `E2E_TENANT_PASSWORD`, Stripe/PayPal Keys). + +Für UI-Details siehe `docs/prp/tenant-app-specs/pages-ui.md`. Einstellungen werden in `docs/prp/tenant-app-specs/settings-config.md` beschrieben. diff --git a/docs/prp/tenant-app-specs/pages-ui-legacy.md b/docs/prp/tenant-app-specs/pages-ui-legacy.md new file mode 100644 index 0000000..ae3dbf6 --- /dev/null +++ b/docs/prp/tenant-app-specs/pages-ui-legacy.md @@ -0,0 +1,9 @@ +# Legacy-UI-Referenz (Framework7, Stand 2025-09-13) + +Die ursprüngliche Dokumentation zur Framework7-basierten Tenant-Admin-App wurde im Commit-Verlauf abgelegt (`pages-ui.md` bis einschließlich 2025-09-13). + +Sie enthält: +- Wireframes für Login, Dashboard, Events, Fotos, Members, Tasks, Settings, Billing. +- Hinweise zur Nutzung von Framework7-Komponenten (Toolbar, List, Card, Pull-to-Refresh, Infinite Scroll). + +Für Migrations- oder Vergleichszwecke kann die Version über `git show :docs/prp/tenant-app-specs/pages-ui.md` abgerufen werden. Die aktuelle Implementierung basiert dagegen auf React + Tailwind (siehe `pages-ui.md`). diff --git a/docs/prp/tenant-app-specs/pages-ui.md b/docs/prp/tenant-app-specs/pages-ui.md index b875f48..ad25170 100644 --- a/docs/prp/tenant-app-specs/pages-ui.md +++ b/docs/prp/tenant-app-specs/pages-ui.md @@ -1,186 +1,68 @@ -# Seiten und UI-Design für die Tenant Admin App +# Seiten- und UI-Design für die Tenant-Admin-App ## Status -- **Version**: 1.0.0 (2025-09-13) -- **Fokus**: Mobile-First Design mit Framework7 v8+ für native iOS/Android-Look & Feel. +- **Version**: 1.1.0 (Stand 2025-10-13) +- **Technologie**: React 19, TailwindCSS (shadcn/ui), React Router 7, React Query 5. +- **Hinweis**: Die Framework7-Wireframes aus 2025-09 bleiben als historische Referenz erhalten und sind im Anhang dokumentiert. -## Allgemeines UI-Prinzipien -- **Framework7-Komponenten**: Toolbar (Navigation), List (Datenlisten), Card (Karten für Events/Photos), Modal (Details/Actions), Pull-to-Refresh (Sync), Infinite-Scroll (Pagination). -- **Theming**: System-Dark-Mode-Support; Tenant-spezifische Farben (Primary/Secondary aus Settings). -- **Navigation**: Tabbar unten (Dashboard, Events, Photos, Settings); Side-Menu für Profile/Logout. -- **Offline-Indikator**: Banner oben ("Offline-Modus: Änderungen werden synchronisiert"). -- **Loading**: Spinner für API-Calls; Skeleton-Screens für Listen. -- **i18n**: LTR für de/en (react-i18next); alle Strings via `t('admin.key')`; Icons von Lucide React (aktuell, nicht Framework7). +## Design-Grundlagen +- **Design Tokens**: Farbverlauf `#f43f5e → #6366f1`, Slate-Neutrals für Typografie, Primärschrift `Clash Display`, Fließtext `Inter`. +- **Komponentensystem**: shadcn/ui-Basis (Button, Card, Tabs, Sheet, Dialog). Erweiterungen unter `resources/js/admin/components`. +- **Navigation**: Obere App-Bar mit Breadcrumb & Quick Actions, mobile Tabbar (Dashboard, Events, Tasks, Einstellungen). +- **Responsiveness**: Breakpoints `sm` (375px), `md` (768px), `xl` (1280px). Onboarding-Screens nutzen Full-Height Layouts auf Mobile, Split-Grid auf Desktop. +- **Accessibility**: `prefers-reduced-motion`, Fokus-Ringe (Tailwind Plugin), ARIA für Carousel, Tabs, Dialoge. +- **Offline UX**: Banner oben rechts (`Offline – deine Änderungen werden synchronisiert, sobald du wieder online bist`) + Retry-CTA. -## Benötigte Seiten und Komponenten +## Geführtes Onboarding (Welcome Flow) +| Schritt | Route | Komponenten | Besondere Elemente | +| --- | --- | --- | --- | +| Hero | `/event-admin/welcome` | `TenantWelcomeLayout`, `WelcomeStepCard`, `EmblaCarousel` | CTA „Pakete entdecken“, sekundärer Link „Später entscheiden“ | +| How It Works | `/event-admin/welcome` (Carousel Slide) | Icon Cards, Animated Gradients | 3 Vorteile (Fotos festhalten, Aufgaben, Gäste aktivieren) | +| Paketwahl | `/event-admin/welcome/packages` | `PackageCard`, `PricingToggle`, `QueryPackageList` | Stripe/PayPal Pricing, Feature-Badges, Auswahl persistiert im Onboarding-Context | +| Zusammenfassung | `/event-admin/welcome/summary` | `OrderSummaryCard`, Stripe Elements, PayPal Buttons | Hinweise bei fehlender Zahlungs-Konfiguration, CTA „Weiter zum Setup“ | +| Event Setup | `/event-admin/welcome/event` | `FirstEventForm`, `FormStepper`, Toasts | Formular (Name, Datum, Sprache, Feature-Toggles) + Abschluss CTA „Event erstellen“ | -### 1. Auth-Seiten (OAuth-Flow) -#### Login-Seite -- **Zweck**: OAuth-Authorization (PKCE-Challenge generieren, Redirect zu /oauth/authorize). -- **Layout**: - - Zentrale Card mit Logo, App-Name, "Mit Google/Email anmelden"-Buttons. - - Footer: "Noch kein Account? Registrieren" (Redirect zu Register). -- **Framework7-Komponenten**: - - `f7-page` mit `f7-navbar` (Titel: "Willkommen zur Event Photo App"). - - `f7-block` für Content; `f7-button` (large, filled) für OAuth-Start. - - `f7-preloader` während Redirect. -- **Wireframe-Beschreibung**: - ``` - [Navbar: Logo | Willkommen] - [Block: App-Beschreibung] - [Button: Anmelden mit Google] - [Button: Anmelden mit Email] - [Footer: Registrieren-Link] - ``` -- **API**: Kein direkter Call; Browser-Redirect zu Backend. +### Guards & Fortschritt +- `useOnboardingProgress` (Context + localStorage) speichert `activeStep`, ausgewähltes Paket und Event-Entwurf. +- Auth-Guard leitet Tenant ohne Events auf `/event-admin/welcome` um; nach Abschluss setzt Backend `onboarding_completed_at`. +- Dashboard hero banner zeigt CTA „Geführtes Setup fortsetzen“, solange `onboarding_completed_at` fehlt. +- Offlinezustand: Payment-Sektion zeigt Fallback-Karte „Zahlungsdienste offline – bitte erneut versuchen“. -#### Register-Seite (ähnlich Login) -- **Unterschiede**: Form für Email/Password + Terms-Checkbox; Submit zu `/oauth/register`. +### Assets & PWA +- Manifest: `public/manifest.json` (Scope `/event-admin/`, Theme-Farbe `#f43f5e`, Shortcuts für Welcome & Dashboard). +- Service Worker: `public/admin-sw.js` cached Shell `/event-admin` + statische Assets; Navigation requests → Netzwerk-First mit Cache-Fallback. +- Registrierung: erfolgt in `resources/js/admin/main.tsx` beim `window.load` Event. -### 2. Dashboard (Home) -- **Zweck**: Übersicht über aktive Events, Stats, schnelle Actions. -- **Layout**: - - Top: Willkommens-Banner mit Tenant-Name, Credit-Balance. - - Stats-Cards: Aktive Events, Ungeprüfte Photos, Tasks-Fortschritt. - - Quick-Actions: "Neues Event erstellen", "Photos moderieren". -- **Framework7-Komponenten**: - - `f7-toolbar` unten: Tabs (Home, Events, Photos, Settings). - - `f7-card` für Stats (mit `f7-icon` und Zahlen). - - `f7-list` für Quick-Actions (link mit Arrow). - - `f7-pull-to-refresh` für Sync. -- **Wireframe-Beschreibung**: - ``` - [Toolbar: Home | Events | Photos | Settings] - [Banner: Hallo [Name]! 3 Credits übrig] - [Row: Card(Events: 2 aktiv) | Card(Photos: 15 neu) | Card(Tasks: 80%)] - [List: > Neues Event | > Moderieren | > Einstellungen] - ``` -- **API**: `GET /tenant/dashboard` (Stats); `GET /tenant/credits/balance`. +## Kernseiten nach dem Onboarding +- **Dashboard**: Hero-CTA zum Welcome Flow, Statistik-Kacheln (Events aktiv, Uploads, Credits), Quick Actions (Event anlegen, Fotos moderieren, Tasks verwalten). +- **Events**: Suchfeld + Filter Pills, Card-Layout mit Status-Badges (`Draft`, `Live`, `Archived`), Detail-Drawer mit Quick Stats. +- **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer. +- **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben. +- **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos. +- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, PayPal Checkout). -### 3. Events-Übersicht -- **Zweck**: Liste aller Events mit Filter (aktiv/archiviert), Suche. -- **Layout**: - - Navbar: Suche-Feld, Filter-Button (Dropdown: Status, Datum). - - Infinite-Scroll-Liste von Event-Cards (Titel, Datum, Status-Tag, Photo-Count). - - FAB: "+" für Neues Event. -- **Framework7-Komponenten**: - - `f7-searchbar` in Navbar. - - `f7-list` mit `f7-list-item` (Thumbnail, Title, Subtitle: Datum, Badge: Status). - - `f7-fab` (floating action button). - - `f7-segmented` für Filter-Tabs. -- **Wireframe-Beschreibung**: - ``` - [Navbar: Suche | Filter ▼] - [Segmented: Alle | Aktiv | Archiviert] - [List-Item: [Thumb] Hochzeit Müller (15.09.) [Tag: Aktiv] 45 Photos] - [List-Item: ...] - [+ FAB unten rechts] - ``` -- **API**: `GET /tenant/events?page=1&status=active` (paginiert). +## Informationsarchitektur (aktuelle React-Router-Konfiguration) +``` +/event-admin +├── dashboard +├── events +│ ├── :slug (Detailseiten, Tabs: Overview, Tasks, Media, Members, Stats) +│ └── :slug/edit +├── tasks +├── members +├── settings +├── billing +└── welcome + ├── (index) # Hero + How It Works Carousel + ├── packages + ├── summary + └── event +``` -### 4. Event-Details-Seite -- **Zweck**: Vollständige Event-Info, Edit-Modus, zugehörige Tasks/Photos. -- **Layout**: - - Tabs: Details, Tasks, Photos, Members, Stats. - - Edit-Button (öffnet Modal für Update). - - QR-Code-Section für Join-Link. -- **Framework7-Komponenten**: - - `f7-tabs` mit `f7-tab` für Subseiten. - - `f7-card` für Details (Datum, Ort, Beschreibung). - - `f7-qrcode` (via Plugin) für Share-Link. - - `f7-button` (edit-icon) → `f7-modal` mit Form. -- **Wireframe-Beschreibung**: - ``` - [Navbar: Event-Titel | Edit-Icon] - [Tabs: Details | Tasks | Photos | Members | Stats] - [Details-Tab: Card(Datum: 15.09., Ort: Berlin) | QR-Code: Scan zum Beitreten] - [Tasks-Tab: Checklist (Drag-to-reorder)] - [Photos-Tab: Grid von Thumbs] - ``` -- **API**: `GET /tenant/events/{id}`; `PATCH /tenant/events/{id}` (ETag). +## Testabdeckung (UI) +- **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`. +- **Playwright**: `tests/e2e/tenant-onboarding-flow.test.ts` (Login Guard, Welcome → Packages → Summary → Event Setup). Erweiterbar um Stripe/PayPal-Happy-Path sowie Offline-/Retry-Szenarien. -### 5. Photo-Gallery-Seite (pro Event) -- **Zweck**: Moderation von Photos; Grid-View mit Lightbox. -- **Layout**: - - Filter: Neu/Ungeprüft/Featured; Sortierung (Datum, Likes). - - Masonry-Grid von Photo-Cards (Thumb, Timestamp, Like-Count). - - Long-Press: Multi-Select für Bulk-Actions (Approve/Delete). -- **Framework7-Komponenten**: - - `f7-photoset` oder custom Grid mit `f7-card` (small). - - `f7-lightbox` für Fullscreen-View. - - `f7-checkbox` für Multi-Select. - - `f7-popover` für Actions (Approve, Feature, Delete). -- **Wireframe-Beschreibung**: - ``` - [Navbar: Photos (Event) | Filter ▼] - [Grid: 3xN Cards [Thumb | Zeit | Likes] [Checkbox]] - [Lightbox: Fullscreen Photo mit Zoom, Actions] - [Bottom-Bar (bei Select): Approve All | Delete Selected] - ``` -- **API**: `GET /tenant/events/{id}/photos`; `PATCH /tenant/photos/{id}` (Batch). +## Legacy-Referenz (Framework7 Entwurf 2025-09) +Die ursprünglichen Wireframes für Framework7 (Toolbar, FAB, Infinite Scroll) sind weiterhin im Repo historisiert (`docs/prp/tenant-app-specs/pages-ui-legacy.md`). Für Vergleiche bei Regressionen oder Migrationen bitte dort nachsehen. -### 6. Members-Seite -- **Zweck**: Verwalten von Event-Mitgliedern (Hinzufügen per Email, Rollen). -- **Layout**: Liste von User-Cards (Name, Rolle, Joined-At); Invite-Button. -- **Framework7-Komponenten**: - - `f7-list` mit `f7-list-item` (Avatar, Name, Badge: Rolle). - - `f7-modal` für Invite-Form (Email-Input, Send-Button). -- **Wireframe-Beschreibung**: - ``` - [Navbar: Members | + Invite] - [List: Anna (Admin) | Ben (Member) | ...] - [Modal: Email eingeben | Rolle wählen | Senden] - ``` -- **API**: `GET /tenant/events/{id}/members`; `POST /tenant/events/{id}/members`. - -### 7. Tasks-Seite -- **Zweck**: Bibliothek verwalten, zu Events zuweisen, Fortschritt tracken. -- **Layout**: Tabs: Meine Tasks, Bibliothek; Drag-and-Drop zwischen Listen. -- **Framework7-Komponenten**: - - `f7-tabs`; `f7-sortable-list` für Drag-and-Drop. - - `f7-checkbox` für Zuweisung. -- **Wireframe-Beschreibung**: - ``` - [Tabs: Events | Bibliothek] - [Sortable List: Task1 [Checkbox] | Task2 ...] - [Drag: Von Bibliothek zu Event-Liste] - ``` -- **API**: `GET /tenant/tasks`; `POST /tenant/events/{id}/tasks`. - -### 8. Settings-Seite -- **Zweck**: Tenant-Einstellungen bearbeiten (Theme, Limits, Legal). -- **Layout**: Accordion-Sections (Theme, Notifications, Legal, App). -- **Framework7-Komponenten**: - - `f7-accordion` für Sections. - - `f7-toggle`, `f7-select`, `f7-color-picker` für Optionen. -- **Wireframe-Beschreibung**: - ``` - [Navbar: Einstellungen] - [Accordion: Theme ▼ [Color-Picker] | Notifications [Toggle Push]] - [Legal: Impressum-Link bearbeiten] - [App: Logout-Button] - ``` -- **API**: `GET/PATCH /tenant/settings`. Details in settings-config.md. - -### 9. Billing-Seite -- **Zweck**: Credit-Balance anzeigen, Packs kaufen. -- **Layout**: Balance-Card; Liste von Purchase-Options; Ledger-Historie. -- **Framework7-Komponenten**: - - `f7-card` für Balance (mit Warning bei niedrig). - - `f7-list` für Packs (Preis, Events-Count, Buy-Button). - - `f7-infinite-scroll` für Ledger. -- **Wireframe-Beschreibung**: - ``` - [Card: Balance: 3 Credits | [Warning: Niedrig!]] - [List: 5 Events (29€) [Buy] | 10 Events (49€) [Buy]] - [Ledger: Kauf 29.09. +5 | Event-Erstellung -1] - ``` -- **API**: `GET /tenant/credits/balance`; `POST /tenant/purchases/intent`. - -## Zusätzliche UI-Elemente -- **Modals**: Confirm-Delete, Photo-Preview, Error-Alerts. -- **Notifications**: `f7-notification` für Sync-Erfolg, neue Photos. -- **Offline-Handling**: `f7-block` mit "Syncing..." Progress-Bar. -- **Accessibility**: ARIA-Labels für alle interaktiven Elemente; VoiceOver-Support. - -Für Capacitor-spezifische UI-Anpassungen siehe capacitor-setup.md. \ No newline at end of file diff --git a/docs/screenshots/tenant-admin-onboarding/01-welcome-hero.svg b/docs/screenshots/tenant-admin-onboarding/01-welcome-hero.svg new file mode 100644 index 0000000..39701c1 --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/01-welcome-hero.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + Willkommen im Event-Erlebnisstudio + + + Führe Gäste durch Fotochallenges, Likes und Erinnerungen – alles in einer Admin-App. + + + + Pakete entdecken + + + oder Demo überspringen + + diff --git a/docs/screenshots/tenant-admin-onboarding/02-how-it-works.svg b/docs/screenshots/tenant-admin-onboarding/02-how-it-works.svg new file mode 100644 index 0000000..6c1d83a --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/02-how-it-works.svg @@ -0,0 +1,33 @@ + + + + So funktioniert es + + + + + Momente festhalten + + + Gäste laden Fotos direkt über die PWA hoch – du moderierst in Echtzeit. + + + + + + Aufgaben aktivieren + + + Challenges und Badges halten deine Community bei Laune. + + + + + + Gäste begeistern + + + Likes, Slideshow und QR-Einladungen bringen Aufmerksamkeit an jeden Tisch. + + + diff --git a/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg b/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg new file mode 100644 index 0000000..1c78698 --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/03-package-selection.svg @@ -0,0 +1,57 @@ + + + + Wähle dein Eventpaket + + + + + Starter + + + 1 Event, 250 Uploads + + + 29 € + + + + Paket wählen + + + + + + Pro + + + 3 Events, 1000 Uploads, Premium-Support + + + 79 € + + + + Paket wählen + + + + + + Enterprise + + + Unbegrenzte Events, SLA, Custom Branding + + + Auf Anfrage + + + + Beratung anfragen + + + + Stripe & PayPal Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind. + + diff --git a/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg b/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg new file mode 100644 index 0000000..843a06c --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/04-order-summary.svg @@ -0,0 +1,53 @@ + + + + + + Bestellübersicht + + + Paket: Pro – 3 Events, 1000 Uploads + + + Zahlungsart: Stripe oder PayPal + + + + Zwischensumme + + + 79 € + + + Gesamt (inkl. MwSt) + + + 94,01 € + + + + Weiter zum Setup + + + Zahlungsdienste offline? Zeige Hinweis und bitte um erneuten Versuch. + + + + + + Stripe-Elemente + + + Kartennummer, Ablaufdatum, CVC, Kartenhalter + + + + + + PayPal Smart Buttons + + + Automatische Darstellung abhängig vom PayPal Client ID. + + + diff --git a/docs/screenshots/tenant-admin-onboarding/05-event-setup.svg b/docs/screenshots/tenant-admin-onboarding/05-event-setup.svg new file mode 100644 index 0000000..6713881 --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/05-event-setup.svg @@ -0,0 +1,43 @@ + + + + Bereite dein erstes Event vor + + + + + Eventname + + + + Sommerfest Kreativagentur + + + Datum & Uhrzeit + + + + 21.08.2025 – 18:00 Uhr + + + Sprache & Features + + + [x] Deutsche UI, [ ] Englische UI, [x] Aufgaben aktivieren, [x] Join-Token generieren + + + + Event erstellen + + + + + + Success States + + + Zeige „Event erstellt“ Toast und leite ins Dashboard weiter, sobald das Backend + den neuen Join-Token bestätigt hat. + + + diff --git a/docs/screenshots/tenant-admin-onboarding/README.md b/docs/screenshots/tenant-admin-onboarding/README.md new file mode 100644 index 0000000..778fcf8 --- /dev/null +++ b/docs/screenshots/tenant-admin-onboarding/README.md @@ -0,0 +1,11 @@ +# Tenant-Admin-Onboarding – Walkthrough Assets + +| Datei | Beschreibung | +| --- | --- | +| `01-welcome-hero.svg` | Hero-Screen mit CTA „Pakete entdecken“. | +| `02-how-it-works.svg` | Drei Highlight-Karten (Fotos, Aufgaben, Gäste). | +| `03-package-selection.svg` | Paketübersicht inkl. Stripe/PayPal Modulen. | +| `04-order-summary.svg` | Zusammenfassung mit Zahlungsoptionen. | +| `05-event-setup.svg` | Formular für das erste Event. | + +> Die SVGs sind Layout-Platzhalter für Dokumentationszwecke. Für Marketing-/Store-Screenshots bitte native Captures aus der App generieren. diff --git a/docs/todo/event-join-token-hardening.md b/docs/todo/event-join-token-hardening.md index fd89e92..9a3b6f7 100644 --- a/docs/todo/event-join-token-hardening.md +++ b/docs/todo/event-join-token-hardening.md @@ -3,28 +3,33 @@ ## Goal Replace slug-based guest access with opaque, revocable join tokens and provide printable QR layouts tied to those tokens. +## Status (Stand 12.10.2025) +- **Phase 1 – Data & Backend:** vollständig abgeschlossen. +- **Phase 2 – Guest PWA:** Aufgaben zu Fehlerzuständen und Regressionstests noch offen. +- **Phase 3 – Tenant Admin UX:** Layout-Downloads und Abschaltung des alten Slug-QR-Flows noch offen. +- **Phase 4 – Migration & Cleanup:** alle Aufgaben offen. + ## Phase 1 – Data & Backend - [x] Create `event_join_tokens` table (token, event_id, usage_limit/count, expires_at, revoked_at, created_by). - [x] Add Eloquent model + relations (`Event::joinTokens()`), factory, and seed helper. - [x] Implement service for token generation/rotation (secure RNG, audit logging). - [x] Expose tenant API endpoints for listing/creating/revoking tokens. - [x] Introduce middleware/controller updates so guest API resolves `/e/{token}` → event. -- [ ] Add rate limiting + logging for invalid token attempts. +- [x] Add rate limiting + logging for invalid token attempts. ## Phase 2 – Guest PWA - [x] Update router and data loaders to use `:token` paths. - [x] Adjust storage/cache keys to use token identifiers. -- [ ] Display friendly error states for expired/invalid tokens. -- [ ] Regression-test photo upload, likes, and stats flows via token. +- [x] Display friendly error states for expired/invalid tokens. +- [x] Regression-test photo upload, likes, and stats flows via token. ## Phase 3 – Tenant Admin UX - [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke). - [x] Hook Filament action + PWA screens to call new token endpoints. -- [ ] Generate five print-ready layouts (PDF/SVG) per token with download options. +- [x] Generate five print-ready layouts (PDF/SVG) per token with download options. - [ ] Deprecate slug-based QR view; link tenants to new flow. ## Phase 4 – Migration & Cleanup -- [ ] Backfill tokens for existing published events and notify tenants to reprint. - [ ] Remove slug parameters from public endpoints once traffic confirms token usage. - [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process. - [ ] Add feature/integration tests covering expiry, rotation, and guest flows. diff --git a/docs/todo/tenant-admin-onboarding-fusion.md b/docs/todo/tenant-admin-onboarding-fusion.md index 169b906..d8e31a0 100644 --- a/docs/todo/tenant-admin-onboarding-fusion.md +++ b/docs/todo/tenant-admin-onboarding-fusion.md @@ -29,10 +29,10 @@ Owner: Codex (handoff) ## Priority: Later ( polish + delivery ) - [x] Align theming, typography, and transitions with the legacy mobile look (consider porting key styles from fotospiel-tenant-app/tenant-admin-app/src/styles). Tenant admin layout now reuses marketing brand palette, fonts, and gradient utilities; Tailwind variables capture the shared tokens. -- [ ] Review PWA manifest/offline setup so the combined welcome + management flow works for Capacitor/TWA packaging. Note required updates in public/manifest.json and build scripts. -- [ ] Extend docs: update PRP onboarding sections and add a walkthrough video/screencaps under docs/screenshots/tenant-admin-onboarding. Capture test scope for future Playwright/E2E coverage. -- [ ] Add automated coverage (React Testing Library for step flows, feature tests for routing guard) once implementation stabilises. Playwright spec `tests/e2e/tenant-onboarding-flow.test.ts` now executes with seeded creds—extend it to cover Stripe/PayPal happy paths and guard edge cases. -- [ ] Finalise direct checkout in the welcome summary. Stripe + PayPal hooks are live; add mocked/unit coverage and end-to-end assertions before rolling out broadly. +- [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert). +- [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert. +- [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus. +- [x] Finalise direct checkout: Stripe/PayPal-Flows markieren Fortschritt, API-Mocks + Unit-Tests decken Erfolgs- und Fehlerpfade ab. - [x] Lokalisierung ausbauen: Landing-, Packages-, Summary- und Event-Setup-Screens sind nun DE/EN übersetzt; Copy-Review für weitere Module (Tasks/Billing/Members) bleibt offen. ## Risks & Open Questions diff --git a/package-lock.json b/package-lock.json index 65d2c29..013639e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,18 +62,23 @@ "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.7", "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.5.2", "@types/node": "^22.13.5", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-react": "^7.37.3", "eslint-plugin-react-hooks": "^5.1.0", "i18next-scanner": "^4.6.0", + "jsdom": "^25.0.1", "playwright": "^1.55.1", "prettier": "^3.4.2", "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.6.11", "shadcn": "^3.3.1", - "typescript-eslint": "^8.23.0" + "typescript-eslint": "^8.23.0", + "vitest": "^2.1.5" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", @@ -81,6 +86,13 @@ "lightningcss-linux-x64-gnu": "^1.29.1" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/ni": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", @@ -103,6 +115,27 @@ "nup": "bin/nup.mjs" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -588,6 +621,121 @@ "statuses": "^2.0.1" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", @@ -3711,6 +3859,96 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -3739,6 +3977,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4141,6 +4387,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4404,6 +4736,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -4542,6 +4884,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -4782,6 +5134,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4860,6 +5222,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4888,6 +5267,16 @@ "node": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5252,6 +5641,34 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5268,6 +5685,57 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5349,6 +5817,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -5364,6 +5839,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5436,6 +5921,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5474,6 +5969,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -5606,6 +6109,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5748,6 +6264,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6111,6 +6634,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6191,6 +6724,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -7077,6 +7620,19 @@ "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -7119,6 +7675,20 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7297,6 +7867,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7661,6 +8241,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -7932,6 +8519,130 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8432,6 +9143,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8450,6 +9168,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -8579,6 +9308,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8820,6 +9559,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9197,6 +9943,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9437,6 +10200,55 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -9828,6 +10640,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10056,6 +10882,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10172,6 +11005,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -10502,6 +11348,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10554,6 +11407,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10564,6 +11424,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -10803,6 +11670,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10844,6 +11724,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -10950,6 +11837,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -10973,6 +11867,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.16", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", @@ -11617,6 +12541,534 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vite-plugin-full-reload": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", @@ -11653,6 +13105,611 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -11662,6 +13719,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -11678,6 +13748,29 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -11793,6 +13886,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11870,6 +13980,45 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 4a6ed79..8235423 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,16 @@ "format:check": "prettier --check resources/", "lint": "eslint . --fix", "types": "tsc --noEmit", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:unit": "vitest run" }, "devDependencies": { "@eslint/js": "^9.19.0", "@laravel/vite-plugin-wayfinder": "^0.1.7", "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.5.2", "@types/node": "^22.13.5", "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.1", @@ -27,7 +31,9 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-tailwindcss": "^0.6.11", "shadcn": "^3.3.1", - "typescript-eslint": "^8.23.0" + "typescript-eslint": "^8.23.0", + "vitest": "^2.1.5", + "jsdom": "^25.0.1" }, "dependencies": { "@headlessui/react": "^2.2.0", diff --git a/public/admin-sw.js b/public/admin-sw.js new file mode 100644 index 0000000..4b8099b --- /dev/null +++ b/public/admin-sw.js @@ -0,0 +1,99 @@ +const SHELL_CACHE = 'tenant-admin-shell-v1'; +const ASSET_CACHE = 'tenant-admin-assets-v1'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => cache.addAll(['/event-admin'])) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => ![SHELL_CACHE, ASSET_CACHE].includes(key)) + .map((key) => caches.delete(key)) + ) + ) + ); + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // Allow API traffic to bypass the SW + if (url.pathname.startsWith('/api/')) return; + + // Navigation requests for the admin shell + if (request.mode === 'navigate' && url.pathname.startsWith('/event-admin')) { + event.respondWith( + (async () => { + try { + const networkResponse = await fetch(request); + const cache = await caches.open(SHELL_CACHE); + cache.put('/event-admin', networkResponse.clone()); + return networkResponse; + } catch { + const cached = await caches.match('/event-admin'); + if (cached) return cached; + return Response.error(); + } + })() + ); + return; + } + + // Static asset caching (CSS/JS/fonts) + if ( + request.destination === 'style' || + request.destination === 'script' || + request.destination === 'font' + ) { + event.respondWith( + (async () => { + const cache = await caches.open(ASSET_CACHE); + const cached = await cache.match(request); + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + return cached || (await fetchPromise) || Response.error(); + })() + ); + return; + } + + // Images and icons (cache-first) + if ( + request.destination === 'image' || + /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname) + ) { + event.respondWith( + (async () => { + const cache = await caches.open(ASSET_CACHE); + const cached = await cache.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch { + return cached || Response.error(); + } + })() + ); + } +}); diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..7217f01 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "Fotospiel Tenant Admin", + "short_name": "Fotospiel Admin", + "id": "/event-admin", + "start_url": "/event-admin/", + "scope": "/event-admin/", + "display": "standalone", + "lang": "de-DE", + "description": "Verwalte Events, Pakete und Gäste mobil – inklusive geführtem Onboarding und schnellen Management-Tools.", + "background_color": "#0f172a", + "theme_color": "#f43f5e", + "orientation": "portrait", + "categories": ["productivity", "photo-video"], + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" + } + ], + "shortcuts": [ + { + "name": "Neues Event planen", + "short_name": "Event planen", + "url": "/event-admin/welcome", + "description": "Starte den geführten Onboarding-Prozess für dein nächstes Event." + }, + { + "name": "Dashboard", + "short_name": "Dashboard", + "url": "/event-admin/", + "description": "Direkt zur Übersicht deiner Events und Aufgaben." + } + ], + "prefer_related_applications": false +} diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 96d3451..8e26182 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -2,6 +2,21 @@ import { authorizedFetch } from './auth/tokens'; type JsonValue = Record; +export type EventJoinTokenLayout = { + id: string; + name: string; + description: string; + subtitle: string; + preview: { + background: string | null; + background_gradient: { angle: number; stops: string[] } | null; + accent: string | null; + text: string | null; + }; + formats: string[]; + download_urls: Record; +}; + export type TenantEvent = { id: number; name: string | Record; @@ -139,6 +154,8 @@ export type EventJoinToken = { is_active: boolean; created_at: string | null; metadata: Record; + layouts: EventJoinTokenLayout[]; + layouts_url: string | null; }; type CreatedEventResponse = { message: string; data: TenantEvent; balance: number }; type PhotoResponse = { message: string; data: TenantPhoto }; @@ -271,6 +288,30 @@ function normalizeMember(member: JsonValue): EventMember { } function normalizeJoinToken(raw: JsonValue): EventJoinToken { + const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : []; + const layouts: EventJoinTokenLayout[] = rawLayouts + .map((layout: any) => { + const formats = Array.isArray(layout.formats) + ? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0) + : []; + + return { + id: String(layout.id ?? ''), + name: String(layout.name ?? ''), + description: String(layout.description ?? ''), + subtitle: String(layout.subtitle ?? ''), + preview: { + background: layout.preview?.background ?? null, + background_gradient: layout.preview?.background_gradient ?? null, + accent: layout.preview?.accent ?? null, + text: layout.preview?.text ?? null, + }, + formats, + download_urls: (layout.download_urls ?? {}) as Record, + }; + }) + .filter((layout: EventJoinTokenLayout) => layout.id.length > 0); + return { id: Number(raw.id ?? 0), token: String(raw.token ?? ''), @@ -283,6 +324,8 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken { is_active: Boolean(raw.is_active), created_at: raw.created_at ?? null, metadata: (raw.metadata ?? {}) as Record, + layouts, + layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null, }; } diff --git a/resources/js/admin/i18n/index.ts b/resources/js/admin/i18n/index.ts index 8065ef6..9cb3c84 100644 --- a/resources/js/admin/i18n/index.ts +++ b/resources/js/admin/i18n/index.ts @@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json'; import enOnboarding from './locales/en/onboarding.json'; import deManagement from './locales/de/management.json'; import enManagement from './locales/en/management.json'; +import deAuth from './locales/de/auth.json'; +import enAuth from './locales/en/auth.json'; const DEFAULT_NAMESPACE = 'common'; @@ -19,12 +21,14 @@ const resources = { dashboard: deDashboard, onboarding: deOnboarding, management: deManagement, + auth: deAuth, }, en: { common: enCommon, dashboard: enDashboard, onboarding: enOnboarding, management: enManagement, + auth: enAuth, }, } as const; diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json new file mode 100644 index 0000000..cec8a23 --- /dev/null +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -0,0 +1,10 @@ +{ + "login": { + "title": "Tenant-Admin", + "lead": "Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und anschließend zur Admin-Oberfläche zurückgebracht.", + "cta": "Mit Tenant-Account anmelden", + "loading": "Bitte warten …", + "oauth_error": "Anmeldung fehlgeschlagen: {{message}}", + "appearance_label": "Darstellung" + } +} diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index 0a906ff..4b8d1ee 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -163,7 +163,7 @@ "successTitle": "Gratis-Paket aktiviert", "successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.", "failureTitle": "Aktivierung fehlgeschlagen", - "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden.", + "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, "stripe": { "sectionTitle": "Kartenzahlung (Stripe)", diff --git a/resources/js/admin/i18n/locales/en/auth.json b/resources/js/admin/i18n/locales/en/auth.json new file mode 100644 index 0000000..9cba72f --- /dev/null +++ b/resources/js/admin/i18n/locales/en/auth.json @@ -0,0 +1,10 @@ +{ + "login": { + "title": "Tenant Admin", + "lead": "Sign in with your Fotospiel account. We will redirect you to the secure OAuth login and bring you back to the admin dashboard afterwards.", + "cta": "Sign in with tenant account", + "loading": "Please wait …", + "oauth_error": "Sign-in failed: {{message}}", + "appearance_label": "Appearance" + } +} diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx index a9e13c1..6a14b36 100644 --- a/resources/js/admin/main.tsx +++ b/resources/js/admin/main.tsx @@ -13,6 +13,12 @@ initializeTheme(); const rootEl = document.getElementById('root')!; const queryClient = new QueryClient(); +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/admin-sw.js').catch(() => {}); + }); +} + createRoot(rootEl).render( diff --git a/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx b/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx new file mode 100644 index 0000000..5536e81 --- /dev/null +++ b/resources/js/admin/onboarding/__tests__/WelcomeLandingPage.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { describe, expect, beforeEach, afterEach, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import WelcomeLandingPage from '../pages/WelcomeLandingPage'; +import { OnboardingProgressProvider } from '..'; +import { + ADMIN_EVENTS_PATH, + ADMIN_WELCOME_PACKAGES_PATH, +} from '../../constants'; + +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }), + }; +}); + +vi.mock('../../components/LanguageSwitcher', () => ({ + LanguageSwitcher: () =>
, +})); + +describe('WelcomeLandingPage', () => { + beforeEach(() => { + localStorage.clear(); + navigateMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function renderPage() { + return render( + + + + ); + } + + it('marks the welcome step as seen on mount', () => { + renderPage(); + const stored = localStorage.getItem('tenant-admin:onboarding-progress'); + expect(stored).toBeTruthy(); + expect(stored).toContain('"welcomeSeen":true'); + expect(stored).toContain('"lastStep":"landing"'); + }); + + it('navigates to package selection when the primary CTA is clicked', async () => { + renderPage(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /hero.primary.label/i })); + expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_PACKAGES_PATH); + }); + + it('navigates to events when secondary CTA in hero or footer is used', async () => { + renderPage(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /hero.secondary.label/i })); + expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH); + + navigateMock.mockClear(); + await user.click(screen.getByRole('button', { name: /layout.jumpToDashboard/i })); + expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH); + }); +}); diff --git a/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx new file mode 100644 index 0000000..d4bd5a9 --- /dev/null +++ b/resources/js/admin/onboarding/__tests__/WelcomeOrderSummary.checkout.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + StripeCheckoutForm, + PayPalCheckout, +} from '../pages/WelcomeOrderSummaryPage'; + +const stripeRef: { current: any } = { current: null }; +const elementsRef: { current: any } = { current: null }; +const paypalPropsRef: { current: any } = { current: null }; + +const { + confirmPaymentMock, + completePurchaseMock, + createPayPalOrderMock, + capturePayPalOrderMock, +} = vi.hoisted(() => ({ + confirmPaymentMock: vi.fn(), + completePurchaseMock: vi.fn(), + createPayPalOrderMock: vi.fn(), + capturePayPalOrderMock: vi.fn(), +})); + +vi.mock('@stripe/react-stripe-js', () => ({ + useStripe: () => stripeRef.current, + useElements: () => elementsRef.current, + PaymentElement: () =>
, + Elements: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@paypal/react-paypal-js', () => ({ + PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + PayPalButtons: (props: any) => { + paypalPropsRef.current = props; + return ; + }, +})); + +vi.mock('../../api', () => ({ + completeTenantPackagePurchase: completePurchaseMock, + createTenantPackagePaymentIntent: vi.fn(), + assignFreeTenantPackage: vi.fn(), + createTenantPayPalOrder: createPayPalOrderMock, + captureTenantPayPalOrder: capturePayPalOrderMock, +})); + +describe('StripeCheckoutForm', () => { + beforeEach(() => { + confirmPaymentMock.mockReset(); + completePurchaseMock.mockReset(); + stripeRef.current = { confirmPayment: confirmPaymentMock }; + elementsRef.current = {}; + }); + + const renderStripeForm = (overrides?: Partial>) => + render( + key} + {...overrides} + /> + ); + + it('completes the purchase when Stripe reports a successful payment', async () => { + const onSuccess = vi.fn(); + confirmPaymentMock.mockResolvedValue({ + error: null, + paymentIntent: { payment_method: 'pm_123' }, + }); + completePurchaseMock.mockResolvedValue(undefined); + + const { container } = renderStripeForm({ onSuccess }); + const form = container.querySelector('form'); + expect(form).toBeTruthy(); + fireEvent.submit(form!); + + await waitFor(() => { + expect(completePurchaseMock).toHaveBeenCalledWith({ + packageId: 42, + paymentMethodId: 'pm_123', + }); + }); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('shows Stripe errors returned by confirmPayment', async () => { + confirmPaymentMock.mockResolvedValue({ + error: { message: 'Card declined' }, + }); + + const { container } = renderStripeForm(); + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(screen.getByText('Card declined')).toBeInTheDocument(); + }); + expect(completePurchaseMock).not.toHaveBeenCalled(); + }); + + it('reports missing payment method id', async () => { + confirmPaymentMock.mockResolvedValue({ + error: null, + paymentIntent: {}, + }); + + const { container } = renderStripeForm(); + fireEvent.submit(container.querySelector('form')!); + + await waitFor(() => { + expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument(); + }); + expect(completePurchaseMock).not.toHaveBeenCalled(); + }); +}); + +describe('PayPalCheckout', () => { + beforeEach(() => { + paypalPropsRef.current = null; + createPayPalOrderMock.mockReset(); + capturePayPalOrderMock.mockReset(); + }); + + it('creates and captures a PayPal order successfully', async () => { + createPayPalOrderMock.mockResolvedValue('ORDER-123'); + capturePayPalOrderMock.mockResolvedValue(undefined); + const onSuccess = vi.fn(); + + render( + key} + /> + ); + + expect(paypalPropsRef.current).toBeTruthy(); + const { createOrder, onApprove } = paypalPropsRef.current; + await act(async () => { + const orderId = await createOrder(); + expect(orderId).toBe('ORDER-123'); + }); + await act(async () => { + await onApprove({ orderID: 'ORDER-123' }); + }); + + await waitFor(() => { + expect(createPayPalOrderMock).toHaveBeenCalledWith(99); + expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123'); + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('surfaces missing order id errors', async () => { + createPayPalOrderMock.mockResolvedValue('ORDER-123'); + render( + key} + /> + ); + + const { onApprove } = paypalPropsRef.current; + await act(async () => { + await onApprove({ orderID: undefined }); + }); + + await waitFor(() => { + expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument(); + }); + expect(capturePayPalOrderMock).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx index 2bf3539..f40a233 100644 --- a/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx +++ b/resources/js/admin/onboarding/pages/WelcomeOrderSummaryPage.tsx @@ -633,3 +633,5 @@ export default function WelcomeOrderSummaryPage() { ); } + +export { StripeCheckoutForm, PayPalCheckout }; diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index c754f66..c37bcef 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react'; +import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -260,25 +260,41 @@ export default function EventDetailPage() { - + - Einladungen + Einladungen & Drucklayouts - Generiere Links um Gaeste direkt in das Event zu fuehren. + Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und + downloadbare PDF/SVG-Layouts. - + +
+

+ Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen + sich jederzeit rotieren oder deaktivieren. +

+ {tokens.length > 0 && ( +

+ Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '} + {tokens.length} +

+ )} +
+ + {inviteLink && (

{inviteLink}

)} +
{tokens.length > 0 ? ( tokens.map((token) => ( @@ -291,9 +307,10 @@ export default function EventDetailPage() { /> )) ) : ( -

- Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren. -

+
+ Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts + herunterzuladen. +
)}
@@ -366,9 +383,11 @@ function JoinTokenRow({ revoking: boolean; }) { const status = getTokenStatus(token); + const availableLayouts = Array.isArray(token.layouts) ? token.layouts : []; + return ( -
-
+
+
{token.label || `Einladung #${token.id}`} Gültig bis {formatDateTime(token.expires_at)}} {token.created_at && Erstellt {formatDateTime(token.created_at)}}
+ {availableLayouts.length > 0 && ( +
+
Drucklayouts
+
+ {availableLayouts.map((layout) => { + const formatEntries = Array.isArray(layout.formats) + ? layout.formats + .map((format) => { + const normalized = String(format ?? '').toLowerCase(); + const href = + layout.download_urls?.[normalized] ?? + layout.download_urls?.[String(format ?? '')] ?? + null; + + return { + format: normalized, + label: String(format ?? '').toUpperCase(), + href, + }; + }) + .filter((entry) => Boolean(entry.href)) + : []; + + if (formatEntries.length === 0) { + return null; + } + + return ( +
+
+
{layout.name}
+ {layout.subtitle &&
{layout.subtitle}
} +
+
+ {formatEntries.map((entry) => ( + + ))} +
+
+ ); + })} +
+
+ )} + {!availableLayouts.length && token.layouts_url && ( +
+ Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden. +
+ )}
-
+
+ {token.layouts_url && ( + + )} diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 9660078..35424d6 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -197,14 +197,17 @@ export default function EventFormPage() { />
- + handleSlugChange(e.target.value)} /> -

Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.

+

+ Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren + QR-/Layout-Downloads. +

diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index 5ecefbd..2f0287b 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react'; +import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -156,9 +156,9 @@ function EventCard({ event }: { event: TenantEvent }) { Tasks
diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx index da02c71..ac33bd0 100644 --- a/resources/js/admin/pages/LoginPage.tsx +++ b/resources/js/admin/pages/LoginPage.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { useAuth } from '../auth/context'; import { ADMIN_HOME_PATH } from '../constants'; +import { useTranslation } from 'react-i18next'; interface LocationState { from?: Location; @@ -11,6 +12,7 @@ interface LocationState { export default function LoginPage() { const { status, login } = useAuth(); + const { t } = useTranslation('auth'); const location = useLocation(); const navigate = useNavigate(); const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); @@ -36,17 +38,14 @@ export default function LoginPage() { return (
-

Tenant Admin

+

{t('login.title')}

-

- Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach - wieder zur Admin-Oberflaeche gebracht. -

+

{t('login.lead')}

{oauthError && (
- Anmeldung fehlgeschlagen: {oauthError} + {t('login.oauth_error', { message: oauthError })}
)}
diff --git a/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx b/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx new file mode 100644 index 0000000..f568023 --- /dev/null +++ b/resources/js/admin/pages/__tests__/DashboardPage.guard.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import DashboardPage from '../DashboardPage'; +import { ADMIN_WELCOME_BASE_PATH } from '../../constants'; + +const navigateMock = vi.fn(); +const markStepMock = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }), + }; +}); + +vi.mock('../../components/AdminLayout', () => ({ + AdminLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../../auth/context', () => ({ + useAuth: () => ({ status: 'authenticated', user: { name: 'Test Tenant' } }), +})); + +vi.mock('../../onboarding', () => ({ + useOnboardingProgress: () => ({ + progress: { + welcomeSeen: false, + packageSelected: false, + eventCreated: false, + lastStep: null, + selectedPackage: null, + }, + setProgress: vi.fn(), + markStep: markStepMock, + reset: vi.fn(), + }), +})); + +vi.mock('../../api', () => ({ + getDashboardSummary: vi.fn().mockResolvedValue(null), + getEvents: vi.fn().mockResolvedValue([]), + getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }), + getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }), +})); + +describe('DashboardPage onboarding guard', () => { + beforeEach(() => { + navigateMock.mockReset(); + markStepMock.mockReset(); + }); + + it('redirects to the welcome flow when no events exist and onboarding is incomplete', async () => { + render(); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_BASE_PATH, { replace: true }); + }); + expect(markStepMock).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 0c58fee..9b226c1 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { NavLink, useParams, useLocation } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react'; import { useEventData } from '../hooks/useEventData'; function TabLink({ to, children, - isActive + isActive, }: { to: string; children: React.ReactNode; @@ -31,9 +30,11 @@ function TabLink({ export default function BottomNav() { const { token } = useParams(); const location = useLocation(); - const { event } = useEventData(); - - if (!token) return null; // Only show bottom nav within event context + const { event, status } = useEventData(); + + const isReady = status === 'ready' && !!event; + + if (!token || !isReady) return null; // Only show bottom nav within event context const base = `/e/${encodeURIComponent(token)}`; const currentPath = location.pathname; const locale = event?.default_locale || 'de'; diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 59fb1cb..00636a8 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -28,11 +28,11 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st ); } - const { event, loading: eventLoading, error: eventError } = useEventData(); - const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined; - const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null; + const { event, status } = useEventData(); + const guestName = + identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null; - if (eventLoading) { + if (status === 'loading') { return (
Lade Event...
@@ -44,18 +44,13 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st ); } - if (eventError || !event) { - return ( -
-
Event nicht gefunden
-
- - -
-
- ); + if (status !== 'ready' || !event) { + return null; } + const stats = + statsContext && statsContext.eventKey === slug ? statsContext : undefined; + const getEventAvatar = (event: any) => { if (event.type?.icon) { return ( diff --git a/resources/js/guest/hooks/useEventData.ts b/resources/js/guest/hooks/useEventData.ts index 60f6f63..86806ae 100644 --- a/resources/js/guest/hooks/useEventData.ts +++ b/resources/js/guest/hooks/useEventData.ts @@ -1,40 +1,90 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { fetchEvent, EventData } from '../services/eventApi'; +import { + fetchEvent, + EventData, + FetchEventError, + FetchEventErrorCode, +} from '../services/eventApi'; -export function useEventData() { +type EventDataStatus = 'loading' | 'ready' | 'error'; + +interface UseEventDataResult { + event: EventData | null; + status: EventDataStatus; + loading: boolean; + error: string | null; + errorCode: FetchEventErrorCode | null; + token: string | null; +} + +const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.'; + +export function useEventData(): UseEventDataResult { const { token } = useParams<{ token: string }>(); const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [status, setStatus] = useState(token ? 'loading' : 'error'); + const [errorMessage, setErrorMessage] = useState(token ? null : NO_TOKEN_ERROR_MESSAGE); + const [errorCode, setErrorCode] = useState(token ? null : 'invalid_token'); useEffect(() => { if (!token) { - setError('No event token provided'); - setLoading(false); + setEvent(null); + setStatus('error'); + setErrorCode('invalid_token'); + setErrorMessage(NO_TOKEN_ERROR_MESSAGE); return; } + let cancelled = false; + const loadEvent = async () => { + setStatus('loading'); + setErrorCode(null); + setErrorMessage(null); + try { - setLoading(true); - setError(null); const eventData = await fetchEvent(token); + if (cancelled) { + return; + } + setEvent(eventData); + setStatus('ready'); } catch (err) { - console.error('Failed to load event:', err); - setError(err instanceof Error ? err.message : 'Failed to load event'); - } finally { - setLoading(false); + if (cancelled) { + return; + } + + setEvent(null); + setStatus('error'); + + if (err instanceof FetchEventError) { + setErrorCode(err.code); + setErrorMessage(err.message); + } else if (err instanceof Error) { + setErrorCode('unknown'); + setErrorMessage(err.message || 'Event konnte nicht geladen werden.'); + } else { + setErrorCode('unknown'); + setErrorMessage('Event konnte nicht geladen werden.'); + } } }; loadEvent(); + + return () => { + cancelled = true; + }; }, [token]); return { event, - loading, - error, + status, + loading: status === 'loading', + error: errorMessage, + errorCode, + token: token ?? null, }; } diff --git a/resources/js/guest/polling/usePollStats.ts b/resources/js/guest/polling/usePollStats.ts index b48ae20..15d733d 100644 --- a/resources/js/guest/polling/usePollStats.ts +++ b/resources/js/guest/polling/usePollStats.ts @@ -28,6 +28,12 @@ export function usePollStats(eventKey: string | null | undefined) { headers: { 'Cache-Control': 'no-store' }, }); if (res.status === 304) return; + if (!res.ok) { + if (res.status === 404) { + setData({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null }); + } + return; + } const json: StatsResponse = await res.json(); setData({ onlineGuests: json.online_guests ?? 0, diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index fa31039..61b9dcb 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -1,7 +1,11 @@ import React from 'react'; -import { createBrowserRouter, Outlet, useParams } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { createBrowserRouter, Outlet, useParams, Link } from 'react-router-dom'; import Header from './components/Header'; import BottomNav from './components/BottomNav'; +import { useEventData } from './hooks/useEventData'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import type { FetchEventErrorCode } from './services/eventApi'; import { EventStatsProvider } from './context/EventStatsContext'; import { GuestIdentityProvider } from './context/GuestIdentityContext'; import LandingPage from './pages/LandingPage'; @@ -36,15 +40,7 @@ function HomeLayout() { return ( - -
-
-
- -
- -
-
+
); } @@ -78,6 +74,30 @@ export const router = createBrowserRouter([ { path: '*', element: }, ]); +function EventBoundary({ token }: { token: string }) { + const { event, status, error, errorCode } = useEventData(); + + if (status === 'loading') { + return ; + } + + if (status === 'error' || !event) { + return ; + } + + return ( + +
+
+
+ +
+ +
+
+ ); +} + function SetupLayout() { const { token } = useParams<{ token: string }>(); if (!token) return null; @@ -93,6 +113,95 @@ function SetupLayout() { ); } +function EventLoadingView() { + return ( +
+ +
+

Wir prüfen deinen Zugang...

+

Einen Moment bitte.

+
+
+ ); +} + +interface EventErrorViewProps { + code: FetchEventErrorCode | null; + message: string | null; +} + +function EventErrorView({ code, message }: EventErrorViewProps) { + const content = getErrorContent(code, message); + + return ( +
+
+ +
+
+

{content.title}

+

{content.description}

+ {content.hint && ( +

{content.hint}

+ )} +
+ {content.ctaHref && content.ctaLabel && ( + + )} +
+ ); +} + +function getErrorContent( + code: FetchEventErrorCode | null, + message: string | null, +) { + const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({ + title: fallbackTitle, + description: message ?? fallbackDescription, + ctaLabel: options?.ctaLabel, + ctaHref: options?.ctaHref, + hint: options?.hint ?? null, + }); + + switch (code) { + case 'invalid_token': + return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', { + ctaLabel: 'Neuen Code anfordern', + ctaHref: '/event', + }); + case 'token_revoked': + return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', { + ctaLabel: 'Neuen Code anfordern', + ctaHref: '/event', + }); + case 'token_expired': + return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', { + ctaLabel: 'Code aktualisieren', + ctaHref: '/event', + }); + case 'token_rate_limited': + return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', { + hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.', + }); + case 'event_not_public': + return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', { + hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.', + }); + case 'network_error': + return base('Verbindungsproblem', 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.'); + case 'server_error': + return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.'); + default: + return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', { + ctaLabel: 'Zur Code-Eingabe', + ctaHref: '/event', + }); + } +} + function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) { return (
diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index c428927..b80d75a 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -34,10 +34,138 @@ export interface EventStats { latestPhotoAt: string | null; } +export type FetchEventErrorCode = + | 'invalid_token' + | 'token_expired' + | 'token_revoked' + | 'token_rate_limited' + | 'event_not_public' + | 'network_error' + | 'server_error' + | 'unknown'; + +interface FetchEventErrorOptions { + code: FetchEventErrorCode; + message: string; + status?: number; +} + +export class FetchEventError extends Error { + readonly code: FetchEventErrorCode; + readonly status?: number; + + constructor({ code, message, status }: FetchEventErrorOptions) { + super(message); + this.name = 'FetchEventError'; + this.code = code; + this.status = status; + } +} + +const API_ERROR_CODES: FetchEventErrorCode[] = [ + 'invalid_token', + 'token_expired', + 'token_revoked', + 'token_rate_limited', + 'event_not_public', +]; + +function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode { + if (typeof rawCode === 'string') { + const normalized = rawCode.toLowerCase() as FetchEventErrorCode; + if ((API_ERROR_CODES as string[]).includes(normalized)) { + return normalized; + } + } + + if (status === 429) return 'token_rate_limited'; + if (status === 404) return 'event_not_public'; + if (status === 410) return 'token_expired'; + if (status === 401) return 'invalid_token'; + if (status === 403) return 'token_revoked'; + if (status >= 500) return 'server_error'; + + return 'unknown'; +} + +function defaultMessageForCode(code: FetchEventErrorCode): string { + switch (code) { + case 'invalid_token': + return 'Der eingegebene Zugriffscode ist ungültig.'; + case 'token_revoked': + return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.'; + case 'token_expired': + return 'Dieser Zugriffscode ist abgelaufen.'; + case 'token_rate_limited': + return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.'; + case 'event_not_public': + return 'Dieses Event ist nicht öffentlich verfügbar.'; + case 'network_error': + return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.'; + case 'server_error': + return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.'; + case 'unknown': + default: + return 'Event konnte nicht geladen werden.'; + } +} + export async function fetchEvent(eventKey: string): Promise { - const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`); - if (!res.ok) throw new Error('Event fetch failed'); - return await res.json(); + try { + const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`); + if (!res.ok) { + let apiMessage: string | null = null; + let rawCode: unknown; + + try { + const data = await res.json(); + rawCode = data?.error?.code ?? data?.code; + const message = data?.error?.message ?? data?.message; + if (typeof message === 'string' && message.trim() !== '') { + apiMessage = message.trim(); + } + } catch { + // ignore parse errors and fall back to defaults + } + + const code = resolveErrorCode(rawCode, res.status); + const message = apiMessage ?? defaultMessageForCode(code); + + throw new FetchEventError({ + code, + message, + status: res.status, + }); + } + + return await res.json(); + } catch (error) { + if (error instanceof FetchEventError) { + throw error; + } + + if (error instanceof TypeError) { + throw new FetchEventError({ + code: 'network_error', + message: defaultMessageForCode('network_error'), + status: 0, + }); + } + + if (error instanceof Error) { + throw new FetchEventError({ + code: 'unknown', + message: error.message || defaultMessageForCode('unknown'), + status: 0, + }); + } + + throw new FetchEventError({ + code: 'unknown', + message: defaultMessageForCode('unknown'), + status: 0, + }); + } } export async function fetchStats(eventKey: string): Promise { diff --git a/resources/js/layouts/app/Header.tsx b/resources/js/layouts/app/Header.tsx index 5a2a2f5..4d8b6e2 100644 --- a/resources/js/layouts/app/Header.tsx +++ b/resources/js/layouts/app/Header.tsx @@ -11,8 +11,18 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { Separator } from '@/components/ui/separator'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Sun, Moon, Menu, X, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from '@/components/ui/navigation-menu'; const Header: React.FC = () => { const { auth } = usePage().props as any; @@ -111,37 +121,50 @@ const Header: React.FC = () => { Die Fotospiel.App - + + + {navItems.map((item) => ( + + {item.children ? ( + <> + + {item.label} + + +
    + {item.children.map((child) => ( +
  • + + + {child.label} + + + +
  • + ))} +
+
+ + ) : ( + + + {item.label} + + + )} +
+ ))} +
+
- + Profil - + Bestellungen - + Abmelden @@ -200,16 +223,10 @@ const Header: React.FC = () => { <> {t('header.login')} - - {t('header.register')} - )}
@@ -229,31 +246,40 @@ const Header: React.FC = () => { Menü -
)}
diff --git a/resources/js/setupTests.ts b/resources/js/setupTests.ts new file mode 100644 index 0000000..f6da0bb --- /dev/null +++ b/resources/js/setupTests.ts @@ -0,0 +1,22 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options && typeof options.defaultValue === 'string') { + return options.defaultValue; + } + return key; + }, + i18n: { + language: 'de', + changeLanguage: vi.fn(), + }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + }; +}); diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 0c13490..b0eeafb 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -70,6 +70,8 @@ return [ 'table' => [ 'tenant' => 'Mandant', 'join' => 'Beitreten', + 'join_tokens_total' => 'Join-Tokens: :count', + 'join_tokens_missing' => 'Noch keine Join-Tokens erstellt', ], 'actions' => [ 'toggle_active' => 'Aktiv umschalten', @@ -82,9 +84,21 @@ return [ 'join_link_copied' => 'Beitrittslink kopiert', ], 'join_link' => [ + 'event_label' => 'Veranstaltung', + 'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Join-Tokens unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.', + 'open_admin' => 'Admin-App öffnen', 'link_label' => 'Beitrittslink', + 'copy_link' => 'Kopieren', + 'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.', + 'token_default' => 'Einladung #:id', + 'token_usage' => 'Nutzung: :usage / :limit', + 'token_active' => 'Aktiv', + 'token_inactive' => 'Deaktiviert', 'qr_code_label' => 'QR‑Code', 'note_html' => 'Hinweis: Der QR‑Code wird über einen externen QR‑Service generiert. Für eine selbst gehostete Lösung können wir später eine interne QR‑Generierung ergänzen.', + 'layouts_heading' => 'Drucklayouts', + 'layouts_fallback' => 'Layout-Übersicht öffnen', + 'token_expiry' => 'Läuft ab am :date', ], ], @@ -219,4 +233,13 @@ return [ 'shell' => [ 'tenant_admin_title' => 'Tenant‑Admin', ], + + 'errors' => [ + 'forbidden' => [ + 'title' => 'Kein Zugriff', + 'message' => 'Du hast keine Berechtigung, diesen Bereich des Admin-Panels zu öffnen.', + 'hint' => 'Bitte prüfe, ob dein Mandantenpaket aktiv ist oder wende dich an den Support, wenn du Hilfe benötigst.', + 'cta' => 'Zur Startseite', + ], + ], ]; diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php index 55f4d66..ef0d2eb 100644 --- a/resources/lang/de/auth.php +++ b/resources/lang/de/auth.php @@ -12,6 +12,7 @@ return [ 'username_or_email' => 'Username oder E-Mail', 'password' => 'Passwort', 'remember' => 'Angemeldet bleiben', + 'remember_me' => 'Angemeldet bleiben', 'submit' => 'Anmelden', ], @@ -34,6 +35,8 @@ return [ 'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.', 'resend' => 'E-Mail erneut senden', ], + 'verify_email' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse. Wir haben dir eine Bestätigungs-E-Mail geschickt.', + 'no_tenant_associated' => 'Deinem Konto ist kein Tenant zugeordnet. Bitte kontaktiere den Support.', 'header' => [ 'home' => 'Startseite', 'packages' => 'Pakete', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index ee50f34..91c661d 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -70,6 +70,8 @@ return [ 'table' => [ 'tenant' => 'Tenant', 'join' => 'Join', + 'join_tokens_total' => 'Join tokens: :count', + 'join_tokens_missing' => 'No join tokens created yet', ], 'actions' => [ 'toggle_active' => 'Toggle Active', @@ -82,9 +84,20 @@ return [ 'join_link_copied' => 'Join link copied', ], 'join_link' => [ + 'event_label' => 'Event', + 'slug_label' => 'Slug: :slug', 'link_label' => 'Join Link', - 'qr_code_label' => 'QR Code', - 'note_html' => 'Note: The QR code is generated via an external QR service. For a self-hosted option, we can add internal generation later.', + 'copy_link' => 'Copy', + 'no_tokens' => 'No tokens available yet. Create a token in the admin app to share your event.', + 'token_default' => 'Invitation #:id', + 'token_usage' => 'Usage: :usage / :limit', + 'token_active' => 'Active', + 'token_inactive' => 'Inactive', + 'layouts_heading' => 'Printable layouts', + 'layouts_fallback' => 'Open layout overview', + 'token_expiry' => 'Expires at :date', + 'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.', + 'open_admin' => 'Open admin app', ], ], @@ -206,4 +219,13 @@ return [ 'shell' => [ 'tenant_admin_title' => 'Tenant Admin', ], + + 'errors' => [ + 'forbidden' => [ + 'title' => 'Access denied', + 'message' => 'You do not have permission to access this area of the admin panel.', + 'hint' => 'Please verify that your tenant subscription is active or contact support if you believe this is a mistake.', + 'cta' => 'Return to start page', + ], + ], ]; diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 20b8bfa..2e38cce 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -6,4 +6,14 @@ return [ 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'login_success' => 'You are now logged in.', 'login_failed' => 'These credentials do not match our records.', + 'login' => [ + 'title' => 'Sign in', + 'username_or_email' => 'Username or email address', + 'password' => 'Password', + 'remember' => 'Stay signed in', + 'remember_me' => 'Stay signed in', + 'submit' => 'Sign in', + ], + 'verify_email' => 'Your email address is not verified. Please check your inbox for the verification link.', + 'no_tenant_associated' => 'We could not find a tenant for your account. Please contact support.', ]; diff --git a/resources/lang/vendor/filament-panels/de/pages/auth/login.php b/resources/lang/vendor/filament-panels/de/pages/auth/login.php new file mode 100644 index 0000000..e9d6d66 --- /dev/null +++ b/resources/lang/vendor/filament-panels/de/pages/auth/login.php @@ -0,0 +1,12 @@ + 'Tenant-Login', + 'form' => [ + 'actions' => [ + 'authenticate' => [ + 'label' => 'Anmelden', + ], + ], + ], +]; diff --git a/resources/lang/vendor/filament-panels/en/pages/auth/login.php b/resources/lang/vendor/filament-panels/en/pages/auth/login.php new file mode 100644 index 0000000..ceb097c --- /dev/null +++ b/resources/lang/vendor/filament-panels/en/pages/auth/login.php @@ -0,0 +1,12 @@ + 'Tenant Login', + 'form' => [ + 'actions' => [ + 'authenticate' => [ + 'label' => 'Sign in', + ], + ], + ], +]; diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index af2aad7..718c6ae 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -5,6 +5,11 @@ {{ __('admin.shell.tenant_admin_title') }} + + + + + @viteReactRefresh @vite('resources/js/admin/main.tsx') diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..1fb2377 --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,24 @@ + + + + + + {{ __('admin.errors.forbidden.title') }} + @vite('resources/css/app.css') + + +
+
+

403

+

{{ __('admin.errors.forbidden.title') }}

+

{{ __('admin.errors.forbidden.message') }}

+

{{ __('admin.errors.forbidden.hint') }}

+ +
+
+ + diff --git a/resources/views/filament/events/join-link.blade.php b/resources/views/filament/events/join-link.blade.php index 37cc21e..8c3f193 100644 --- a/resources/views/filament/events/join-link.blade.php +++ b/resources/views/filament/events/join-link.blade.php @@ -1,16 +1,125 @@ -
-
{{ __('admin.events.join_link.link_label') }}
-
- - {{ $link }} +
+ -
{{ __('admin.events.join_link.qr_code_label') }}
-
- {!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(300)->generate($link) !!} -
-
- {!! __('admin.events.join_link.note_html') !!} -
-
+ @if ($tokens->isEmpty()) +
+ {{ __('admin.events.join_link.no_tokens') }} +
+ @else +
+ @foreach ($tokens as $token) +
+
+
+
+ {{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }} +
+
+ {{ __('admin.events.join_link.token_usage', [ + 'usage' => $token['usage_count'], + 'limit' => $token['usage_limit'] ?? '∞', + ]) }} +
+
+
+ @if ($token['is_active']) + + {{ __('admin.events.join_link.token_active') }} + + @else + + {{ __('admin.events.join_link.token_inactive') }} + + @endif +
+
+ +
+
+ {{ __('admin.events.join_link.link_label') }} +
+
+ + {{ $token['url'] }} + + +
+
+ + @if (!empty($token['layouts'])) +
+
+ {{ __('admin.events.join_link.layouts_heading') }} +
+
+ @foreach ($token['layouts'] as $layout) +
+
+ {{ $layout['name'] }} +
+ @if (!empty($layout['subtitle'])) +
+ {{ $layout['subtitle'] }} +
+ @endif +
+ @foreach ($layout['download_urls'] as $format => $href) + + {{ strtoupper($format) }} + + @endforeach +
+
+ @endforeach +
+
+ @elseif(!empty($token['layouts_url'])) + + @endif + + @if ($token['expires_at']) +
+ {{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }} +
+ @endif +
+ @endforeach +
+ @endif +
diff --git a/resources/views/layouts/join-token/pdf.blade.php b/resources/views/layouts/join-token/pdf.blade.php new file mode 100644 index 0000000..0254382 --- /dev/null +++ b/resources/views/layouts/join-token/pdf.blade.php @@ -0,0 +1,206 @@ + + + + + {{ $eventName }} – Einladungs-QR + + + +
+
+ Digitale Gästebox +

{{ $eventName }}

+ @if(!empty($layout['subtitle'])) +

{{ $layout['subtitle'] }}

+ @endif +
+ +
+
+

So funktioniert’s

+

{{ $layout['description'] }}

+ @if(!empty($layout['instructions'])) +
    + @foreach($layout['instructions'] as $step) +
  • {{ $step }}
  • + @endforeach +
+ @endif +
+
Alternative zum Einscannen
+ +
+
+ +
+ QR-Code zum Event {{ $eventName }} +
Scan mich & starte direkt
+
+
+ + +
+ + \ No newline at end of file diff --git a/resources/views/layouts/join-token/svg.blade.php b/resources/views/layouts/join-token/svg.blade.php new file mode 100644 index 0000000..32390a1 --- /dev/null +++ b/resources/views/layouts/join-token/svg.blade.php @@ -0,0 +1,159 @@ +@php + $width = $layout['svg']['width'] ?? 1080; + $height = $layout['svg']['height'] ?? 1520; + $background = $layout['background'] ?? '#FFFFFF'; + $gradient = $layout['background_gradient'] ?? null; + $gradientId = $gradient ? 'bg-gradient-'.uniqid() : null; + $accent = $layout['accent'] ?? '#000000'; + $secondary = $layout['secondary'] ?? '#E5E7EB'; + $textColor = $layout['text'] ?? '#111827'; + $badgeColor = $layout['badge'] ?? $accent; + $instructions = $layout['instructions'] ?? []; + $description = $layout['description'] ?? ''; + $subtitle = $layout['subtitle'] ?? ''; + $titleLines = explode("\n", wordwrap($eventName, 18, "\n", true)); + $subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : []; + $descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : []; + $instructionStartY = 870; + $instructionSpacing = 56; + if ($gradient) { + $angle = (float) ($gradient['angle'] ?? 180); + $angleRad = deg2rad($angle); + $x1 = 0.5 - cos($angleRad) / 2; + $y1 = 0.5 - sin($angleRad) / 2; + $x2 = 0.5 + cos($angleRad) / 2; + $y2 = 0.5 + sin($angleRad) / 2; + $x1Attr = number_format($x1, 4, '.', ''); + $y1Attr = number_format($y1, 4, '.', ''); + $x2Attr = number_format($x2, 4, '.', ''); + $y2Attr = number_format($y2, 4, '.', ''); + } +@endphp + + + @if($gradientId) + + @php + $stops = $gradient['stops'] ?? []; + $stopCount = max(count($stops) - 1, 1); + @endphp + @foreach($stops as $index => $stopColor) + @php + $offset = $stopCount > 0 ? ($index / $stopCount) * 100 : 0; + @endphp + + @endforeach + + @endif + + + + + + + + + + + + + Digitale Gästebox + + @foreach($titleLines as $index => $line) + {{ e($line) }} + @endforeach + + @php + $subtitleOffset = 260 + count($titleLines) * 88 + 40; + @endphp + @foreach($subtitleLines as $index => $line) + {{ e($line) }} + @endforeach + + @php + $descriptionOffset = $subtitleOffset + (count($subtitleLines) ? count($subtitleLines) * 44 + 60 : 40); + @endphp + @foreach($descriptionLines as $index => $line) + {{ e($line) }} + @endforeach + + SO FUNKTIONIERT'S + + @foreach($instructions as $index => $step) + @php + $lineY = $instructionStartY + $index * $instructionSpacing; + @endphp + + {{ e($step) }} + @endforeach + + ALTERNATIVER LINK + + {{ e($tokenUrl) }} + + + + JETZT SCANNEN + + + {{ e(config('app.name', 'Fotospiel')) }} +  – Gästebox & Fotochallenges + + + Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }} + + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 05813f0..85f4fda 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController; +use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\TaskController; use App\Http\Controllers\Api\Tenant\PhotoController; @@ -26,15 +27,15 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); Route::middleware('throttle:100,1')->group(function () { - Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show'); - Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); - Route::get('/events/{slug}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements'); - Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); - Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks'); - Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos'); + Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show'); + Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); + Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements'); + Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); + Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks'); + Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos'); Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show'); Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like'); - Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); + Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); }); Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { @@ -57,6 +58,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::prefix('join-tokens')->group(function () { Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index'); Route::post('/', [EventJoinTokenController::class, 'store'])->name('tenant.events.join-tokens.store'); + Route::get('{joinToken}/layouts', [EventJoinTokenLayoutController::class, 'index']) + ->whereNumber('joinToken') + ->name('tenant.events.join-tokens.layouts.index'); + Route::get('{joinToken}/layouts/{layout}.{format}', [EventJoinTokenLayoutController::class, 'download']) + ->whereNumber('joinToken') + ->where('format', 'pdf|svg') + ->name('tenant.events.join-tokens.layouts.download'); Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy']) ->whereNumber('joinToken') ->name('tenant.events.join-tokens.destroy'); diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php new file mode 100644 index 0000000..6934763 --- /dev/null +++ b/tests/Feature/GuestJoinTokenFlowTest.php @@ -0,0 +1,151 @@ +tokenService = app(EventJoinTokenService::class); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function createPublishedEvent(): Event + { + return Event::factory()->create([ + 'status' => 'published', + ]); + } + + public function test_guest_can_access_stats_using_join_token(): void + { + $event = $this->createPublishedEvent(); + + Photo::factory()->count(3)->create([ + 'event_id' => $event->id, + 'guest_name' => 'device-stats', + ]); + + $token = $this->tokenService->createToken($event); + + $response = $this->getJson("/api/v1/events/{$token->token}/stats"); + + $response->assertOk() + ->assertJsonStructure([ + 'online_guests', + 'tasks_solved', + 'latest_photo_at', + ]); + } + + public function test_guest_can_upload_photo_with_join_token(): void + { + Storage::fake('public'); + + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event); + + Mockery::mock('alias:App\Support\ImageHelper') + ->shouldReceive('makeThumbnailOnDisk') + ->andReturn("events/{$event->id}/photos/thumbs/generated_thumb.jpg"); + + $file = UploadedFile::fake()->image('example.jpg', 1200, 800); + + $response = $this->withHeader('X-Device-Id', 'token-device') + ->postJson("/api/v1/events/{$token->token}/upload", [ + 'photo' => $file, + ]); + + $response->assertCreated() + ->assertJsonStructure(['id', 'file_path', 'thumbnail_path']); + + $this->assertDatabaseCount('photos', 1); + + $saved = Photo::first(); + $this->assertNotNull($saved); + $this->assertEquals($event->id, $saved->event_id); + + $storedPath = $saved->file_path + ? ltrim(str_replace('/storage/', '', $saved->file_path), '/') + : null; + + if ($storedPath) { + $this->assertTrue( + Storage::disk('public')->exists($storedPath), + sprintf('Uploaded file [%s] was not stored on the public disk.', $storedPath) + ); + } + } + + public function test_guest_can_like_photo_after_joining_with_token(): void + { + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event); + + $photo = Photo::factory()->create([ + 'event_id' => $event->id, + 'likes_count' => 0, + ]); + + $this->getJson("/api/v1/events/{$token->token}"); + + $response = $this->withHeader('X-Device-Id', 'device-like') + ->postJson("/api/v1/photos/{$photo->id}/like"); + + $response->assertOk() + ->assertJson([ + 'liked' => true, + ]); + + $this->assertDatabaseHas('photo_likes', [ + 'photo_id' => $photo->id, + 'guest_name' => 'device-like', + ]); + + $this->assertEquals(1, $photo->fresh()->likes_count); + } + + public function test_guest_cannot_access_event_with_expired_token(): void + { + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event, [ + 'expires_at' => now()->subDay(), + ]); + + $response = $this->getJson("/api/v1/events/{$token->token}"); + + $response->assertStatus(410) + ->assertJsonPath('error.code', 'token_expired'); + } + + public function test_guest_cannot_access_event_with_revoked_token(): void + { + $event = $this->createPublishedEvent(); + $token = $this->tokenService->createToken($event); + $this->tokenService->revoke($token, 'revoked for test'); + + $response = $this->getJson("/api/v1/events/{$token->token}"); + + $response->assertStatus(410) + ->assertJsonPath('error.code', 'token_revoked'); + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b8c51e6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'resources/js'), + '~': path.resolve(__dirname, 'resources/css'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./resources/js/setupTests.ts'], + css: false, + include: ['resources/js/**/*.test.{ts,tsx}'], + reporters: 'default', + coverage: { + provider: 'v8', + reportsDirectory: './coverage/unit', + include: ['resources/js/admin/**/*.{ts,tsx}'], + }, + }, +});