- Reworked the tenant admin login page

- Updated the User model to implement Filament’s tenancy contracts
- Seeded a ready-to-use demo tenant (user, tenant, active package, purchase)
- Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits
- Removed the public “Register” links from the marketing header
- hardened join event logic and improved error handling in the guest pwa.
This commit is contained in:
Codex Agent
2025-10-13 12:50:46 +02:00
parent 9394c3171e
commit 64a5411fb9
69 changed files with 5447 additions and 588 deletions

View File

@@ -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();
$credentials = $this->getCredentialsFromFormData($data);
if (! Auth::attempt($credentials, $data['remember'] ?? false)) {
throw ValidationException::withMessages([
'data.username_or_email' => __('auth.failed'),
]);
return __('auth.login.title') ?: parent::getHeading();
}
$user = Auth::user();
protected function getEmailFormComponent(): Component
{
$component = parent::getEmailFormComponent();
if (! $user->email_verified_at) {
Auth::logout();
throw ValidationException::withMessages([
'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.',
]);
return $component
->label(__('auth.login.username_or_email') ?: $component->getLabel());
}
if (! $user->tenant) {
Auth::logout();
protected function getPasswordFormComponent(): Component
{
$component = parent::getPasswordFormComponent();
throw ValidationException::withMessages([
'data.username_or_email' => 'No tenant associated with your account. Contact support.',
]);
return $component
->label(__('auth.login.password') ?: $component->getLabel());
}
session()->regenerate();
protected function getRememberFormComponent(): Component
{
$component = parent::getRememberFormComponent();
$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;
}
}

View File

@@ -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(),

View File

@@ -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');

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class EventJoinTokenLayoutController extends Controller
{
public function index(Request $request, Event $event, EventJoinToken $joinToken)
{
$this->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';
}
}

View File

@@ -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,
];
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -58,11 +58,12 @@ 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')
->when(! $includeInactive, function ($query) {
$query->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
@@ -70,10 +71,16 @@ class EventJoinTokenService
->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 {

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Support;
class JoinTokenLayoutRegistry
{
/**
* Layout definitions for printable invite cards.
*
* @var array<string, array>
*/
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 200g/m² Kartenpapier empfohlen.',
],
],
];
/**
* Get layout definitions.
*
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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());
}
}

View File

@@ -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",

228
composer.lock generated
View File

@@ -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",

View File

@@ -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');
});
}
}

View File

@@ -29,6 +29,7 @@ class DatabaseSeeder extends Seeder
// Seed demo and admin data
$this->call([
SuperAdminSeeder::class,
DemoTenantSeeder::class,
DemoEventSeeder::class,
OAuthClientSeeder::class,
]);

View File

@@ -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',

View File

@@ -0,0 +1,122 @@
<?php
namespace Database\Seeders;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class DemoTenantSeeder extends Seeder
{
public function run(): void
{
$email = 'tenant-demo@fotospiel.app';
$password = config('seeding.demo_tenant_password', 'Demo1234!');
$package = Package::query()
->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
));
}
}

View File

@@ -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=<ISO8601|cursor>` — incremental gallery refresh.
- GET `/events/{token}/photos?since=<ISO8601|cursor>` — 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.

View File

@@ -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.

View File

@@ -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**:

View File

@@ -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.
| 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.

View File

@@ -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 <commit>:docs/prp/tenant-app-specs/pages-ui.md` abgerufen werden. Die aktuelle Implementierung basiert dagegen auf React + Tailwind (siehe `pages-ui.md`).

View File

@@ -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**:
## Informationsarchitektur (aktuelle React-Router-Konfiguration)
```
[Navbar: Suche | Filter ▼]
[Segmented: Alle | Aktiv | Archiviert]
[List-Item: [Thumb] Hochzeit Müller (15.09.) [Tag: Aktiv] 45 Photos]
[List-Item: ...]
[+ FAB unten rechts]
/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
```
- **API**: `GET /tenant/events?page=1&status=active` (paginiert).
### 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.

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<defs>
<linearGradient id="heroGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f43f5e"/>
<stop offset="50%" stop-color="#d946ef"/>
<stop offset="100%" stop-color="#6366f1"/>
</linearGradient>
</defs>
<rect width="1280" height="720" fill="url(#heroGradient)"/>
<text x="80" y="180" font-size="64" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#ffffff">
Willkommen im Event-Erlebnisstudio
</text>
<text x="80" y="260" font-size="28" font-family="Inter, Arial, sans-serif" fill="#fdf2f8">
Führe Gäste durch Fotochallenges, Likes und Erinnerungen alles in einer Admin-App.
</text>
<rect x="80" y="320" width="320" height="72" rx="36" fill="#111827" opacity="0.9"/>
<text x="120" y="368" font-size="28" font-family="Inter, Arial, sans-serif" fill="#ffffff">
Pakete entdecken
</text>
<text x="80" y="420" font-size="22" font-family="Inter, Arial, sans-serif" fill="#fef2f2">
oder Demo überspringen
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#0f172a"/>
<text x="80" y="120" font-size="56" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#f8fafc">
So funktioniert es
</text>
<g transform="translate(80,180)">
<rect width="320" height="360" rx="32" fill="#1e293b"/>
<text x="40" y="80" font-size="36" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#f472b6">
Momente festhalten
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#e2e8f0">
Gäste laden Fotos direkt über die PWA hoch du moderierst in Echtzeit.
</text>
</g>
<g transform="translate(460,180)">
<rect width="320" height="360" rx="32" fill="#1e293b"/>
<text x="40" y="80" font-size="36" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#60a5fa">
Aufgaben aktivieren
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#e2e8f0">
Challenges und Badges halten deine Community bei Laune.
</text>
</g>
<g transform="translate(840,180)">
<rect width="320" height="360" rx="32" fill="#1e293b"/>
<text x="40" y="80" font-size="36" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#34d399">
Gäste begeistern
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#e2e8f0">
Likes, Slideshow und QR-Einladungen bringen Aufmerksamkeit an jeden Tisch.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#f1f5f9"/>
<text x="80" y="120" font-size="56" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#0f172a">
Wähle dein Eventpaket
</text>
<g transform="translate(80,180)">
<rect width="320" height="360" rx="24" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
<text x="40" y="80" font-size="32" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#0f172a">
Starter
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#1f2937">
1 Event, 250 Uploads
</text>
<text x="40" y="200" font-size="48" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#f43f5e">
29 €
</text>
<rect x="40" y="260" width="200" height="56" rx="28" fill="#f43f5e"/>
<text x="70" y="298" font-size="24" font-family="Inter, Arial, sans-serif" fill="#ffffff">
Paket wählen
</text>
</g>
<g transform="translate(460,180)">
<rect width="320" height="360" rx="24" fill="#0f172a"/>
<text x="40" y="80" font-size="32" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#f8fafc">
Pro
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#e2e8f0">
3 Events, 1000 Uploads, Premium-Support
</text>
<text x="40" y="200" font-size="48" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#38bdf8">
79 €
</text>
<rect x="40" y="260" width="200" height="56" rx="28" fill="#38bdf8"/>
<text x="70" y="298" font-size="24" font-family="Inter, Arial, sans-serif" fill="#0f172a">
Paket wählen
</text>
</g>
<g transform="translate(840,180)">
<rect width="320" height="360" rx="24" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
<text x="40" y="80" font-size="32" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#0f172a">
Enterprise
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Unbegrenzte Events, SLA, Custom Branding
</text>
<text x="40" y="200" font-size="48" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#0ea5e9">
Auf Anfrage
</text>
<rect x="40" y="260" width="200" height="56" rx="28" fill="#0ea5e9"/>
<text x="60" y="298" font-size="24" font-family="Inter, Arial, sans-serif" fill="#ffffff">
Beratung anfragen
</text>
</g>
<text x="80" y="600" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569">
Stripe & PayPal Widgets erscheinen unterhalb der Karten, sobald Keys konfiguriert sind.
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#0b1120"/>
<g transform="translate(80,80)">
<rect width="520" height="560" rx="32" fill="#111c2e"/>
<text x="40" y="80" font-size="48" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#f8fafc">
Bestellübersicht
</text>
<text x="40" y="140" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
Paket: Pro 3 Events, 1000 Uploads
</text>
<text x="40" y="180" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
Zahlungsart: Stripe oder PayPal
</text>
<line x1="40" y1="220" x2="480" y2="220" stroke="#1f2a3d" stroke-width="2"/>
<text x="40" y="280" font-size="24" font-family="Inter, Arial, sans-serif" fill="#60a5fa">
Zwischensumme
</text>
<text x="360" y="280" font-size="32" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#60a5fa">
79 €
</text>
<text x="40" y="340" font-size="24" font-family="Inter, Arial, sans-serif" fill="#f472b6">
Gesamt (inkl. MwSt)
</text>
<text x="360" y="340" font-size="36" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#f472b6">
94,01 €
</text>
<rect x="40" y="420" width="240" height="64" rx="32" fill="#f472b6"/>
<text x="70" y="462" font-size="26" font-family="Inter, Arial, sans-serif" fill="#ffffff">
Weiter zum Setup
</text>
<text x="40" y="520" font-size="18" font-family="Inter, Arial, sans-serif" fill="#94a3b8">
Zahlungsdienste offline? Zeige Hinweis und bitte um erneuten Versuch.
</text>
</g>
<g transform="translate(660,120)">
<rect width="480" height="220" rx="24" fill="#f8fafc"/>
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#111827">
Stripe-Elemente
</text>
<text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Kartennummer, Ablaufdatum, CVC, Kartenhalter
</text>
</g>
<g transform="translate(660,380)">
<rect width="480" height="220" rx="24" fill="#ffffff"/>
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="600" fill="#0f172a">
PayPal Smart Buttons
</text>
<text x="40" y="130" font-size="20" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Automatische Darstellung abhängig vom PayPal Client ID.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
<rect width="1280" height="720" fill="#f8fafc"/>
<text x="80" y="120" font-size="56" font-family="Inter, Arial, sans-serif" font-weight="700" fill="#0f172a">
Bereite dein erstes Event vor
</text>
<g transform="translate(80,180)">
<rect width="520" height="420" rx="24" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Eventname
</text>
<rect x="40" y="100" width="440" height="56" rx="12" fill="#f1f5f9"/>
<text x="50" y="135" font-size="20" font-family="Inter, Arial, sans-serif" fill="#64748b">
Sommerfest Kreativagentur
</text>
<text x="40" y="180" font-size="28" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Datum & Uhrzeit
</text>
<rect x="40" y="200" width="440" height="56" rx="12" fill="#f1f5f9"/>
<text x="50" y="235" font-size="20" font-family="Inter, Arial, sans-serif" fill="#64748b">
21.08.2025 18:00 Uhr
</text>
<text x="40" y="280" font-size="28" font-family="Inter, Arial, sans-serif" fill="#1f2937">
Sprache & Features
</text>
<text x="40" y="320" font-size="20" font-family="Inter, Arial, sans-serif" fill="#475569">
[x] Deutsche UI, [ ] Englische UI, [x] Aufgaben aktivieren, [x] Join-Token generieren
</text>
<rect x="40" y="360" width="200" height="56" rx="28" fill="#f43f5e"/>
<text x="60" y="398" font-size="24" font-family="Inter, Arial, sans-serif" fill="#ffffff">
Event erstellen
</text>
</g>
<g transform="translate(640,200)">
<rect width="520" height="320" rx="24" fill="#111827"/>
<text x="40" y="80" font-size="28" font-family="Inter, Arial, sans-serif" fill="#f8fafc">
Success States
</text>
<text x="40" y="130" font-size="22" font-family="Inter, Arial, sans-serif" fill="#cbd5f5">
Zeige „Event erstellt“ Toast und leite ins Dashboard weiter, sobald das Backend
den neuen Join-Token bestätigt hat.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

2151
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

99
public/admin-sw.js Normal file
View File

@@ -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();
}
})()
);
}
});

43
public/manifest.json Normal file
View File

@@ -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
}

View File

@@ -2,6 +2,21 @@ import { authorizedFetch } from './auth/tokens';
type JsonValue = Record<string, any>;
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<string, string>;
};
export type TenantEvent = {
id: number;
name: string | Record<string, string>;
@@ -139,6 +154,8 @@ export type EventJoinToken = {
is_active: boolean;
created_at: string | null;
metadata: Record<string, unknown>;
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<string, string>,
};
})
.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<string, unknown>,
layouts,
layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null,
};
}

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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)",

View File

@@ -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"
}
}

View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>

View File

@@ -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<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
describe('WelcomeLandingPage', () => {
beforeEach(() => {
localStorage.clear();
navigateMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
function renderPage() {
return render(
<OnboardingProgressProvider>
<WelcomeLandingPage />
</OnboardingProgressProvider>
);
}
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);
});
});

View File

@@ -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: () => <div data-testid="stripe-payment-element" />,
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 <button type="button" data-testid="paypal-button">PayPal</button>;
},
}));
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<React.ComponentProps<typeof StripeCheckoutForm>>) =>
render(
<StripeCheckoutForm
clientSecret="secret"
packageId={42}
onSuccess={vi.fn()}
t={(key: string) => 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(
<PayPalCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => 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(
<PayPalCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => 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();
});
});

View File

@@ -633,3 +633,5 @@ export default function WelcomeOrderSummaryPage() {
</TenantWelcomeLayout>
);
}
export { StripeCheckoutForm, PayPalCheckout };

View File

@@ -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() {
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
</CardTitle>
<CardDescription className="text-sm text-slate-600">
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
sich jederzeit rotieren oder deaktivieren.
</p>
{tokens.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
</p>
)}
</div>
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladungslink erzeugen
Join-Token erzeugen
</Button>
{inviteLink && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
</p>
)}
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
@@ -291,9 +307,10 @@ export default function EventDetailPage() {
/>
))
) : (
<p className="text-xs text-slate-500">
Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren.
</p>
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
herunterzuladen.
</div>
)}
</div>
</CardContent>
@@ -366,9 +383,11 @@ function JoinTokenRow({
revoking: boolean;
}) {
const status = getTokenStatus(token);
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
return (
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
<span
@@ -392,8 +411,81 @@ function JoinTokenRow({
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
</div>
{availableLayouts.length > 0 && (
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
<div className="grid gap-3 sm:grid-cols-2">
{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 (
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
<div>
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{formatEntries.map((entry) => (
<Button
asChild
key={`${layout.id}-${entry.format}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={entry.href as string} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{entry.label}
</a>
</Button>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{!availableLayouts.length && token.layouts_url && (
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
</div>
)}
</div>
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
{token.layouts_url && (
<Button
asChild
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="h-3 w-3" />
<span className="ml-1">Layouts</span>
</a>
</Button>
)}
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
Kopieren
</Button>

View File

@@ -197,14 +197,17 @@ export default function EventFormPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-slug">Slug / URL-Endung</Label>
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
<Input
id="event-slug"
placeholder="sommerfest-2025"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.</p>
<p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
QR-/Layout-Downloads.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">Datum</Label>

View File

@@ -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 }) {
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<a href={`/e/${slug}`} target="_blank" rel="noreferrer">
Oeffnen im Gastportal
</a>
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#join-invites`}>
<Share2 className="h-3.5 w-3.5" /> Einladungen
</Link>
</Button>
</div>
</div>

View File

@@ -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 (
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">Tenant Admin</h1>
<h1 className="text-lg font-semibold">{t('login.title')}</h1>
<AppearanceToggleDropdown />
</div>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
wieder zur Admin-Oberflaeche gebracht.
</p>
<p>{t('login.lead')}</p>
{oauthError && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
Anmeldung fehlgeschlagen: {oauthError}
{t('login.oauth_error', { message: oauthError })}
</div>
)}
<Button
@@ -54,7 +53,7 @@ export default function LoginPage() {
disabled={status === 'loading'}
onClick={() => login(redirectTarget)}
>
{status === 'loading' ? 'Bitte warten ...' : 'Mit Tenant-Account anmelden'}
{status === 'loading' ? t('login.loading') : t('login.cta')}
</Button>
</div>
</div>

View File

@@ -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<typeof import('react-router-dom')>('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 }) => <div data-testid="admin-layout">{children}</div>,
}));
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(<DashboardPage />);
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_BASE_PATH, { replace: true });
});
expect(markStepMock).not.toHaveBeenCalled();
});
});

View File

@@ -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();
const { event, status } = useEventData();
if (!token) return null; // Only show bottom nav within event context
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';

View File

@@ -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 (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold">Lade Event...</div>
@@ -44,18 +44,13 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
if (eventError || !event) {
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold text-red-600">Event nicht gefunden</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
if (status !== 'ready' || !event) {
return null;
}
const stats =
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const getEventAvatar = (event: any) => {
if (event.type?.icon) {
return (

View File

@@ -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<EventData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<EventDataStatus>(token ? 'loading' : 'error');
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(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,
};
}

View File

@@ -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,

View File

@@ -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 (
<GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
<EventBoundary token={token} />
</GuestIdentityProvider>
);
}
@@ -78,6 +74,30 @@ export const router = createBrowserRouter([
{ path: '*', element: <NotFoundPage /> },
]);
function EventBoundary({ token }: { token: string }) {
const { event, status, error, errorCode } = useEventData();
if (status === 'loading') {
return <EventLoadingView />;
}
if (status === 'error' || !event) {
return <EventErrorView code={errorCode} message={error} />;
}
return (
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
);
}
function SetupLayout() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
@@ -93,6 +113,95 @@ function SetupLayout() {
);
}
function EventLoadingView() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
<div className="space-y-1">
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p>
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p>
</div>
</div>
);
}
interface EventErrorViewProps {
code: FetchEventErrorCode | null;
message: string | null;
}
function EventErrorView({ code, message }: EventErrorViewProps) {
const content = getErrorContent(code, message);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-8 w-8" aria-hidden />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">{content.title}</h2>
<p className="text-sm text-muted-foreground">{content.description}</p>
{content.hint && (
<p className="text-xs text-muted-foreground">{content.hint}</p>
)}
</div>
{content.ctaHref && content.ctaLabel && (
<Button asChild>
<Link to={content.ctaHref}>{content.ctaLabel}</Link>
</Button>
)}
</div>
);
}
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 (
<div className="pb-16">

View File

@@ -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<EventData> {
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) throw new Error('Event fetch failed');
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<EventStats> {

View File

@@ -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 = () => {
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
Die Fotospiel.App
</Link>
<nav className="hidden lg:flex items-center space-x-8">
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
<NavigationMenuList className="gap-2">
{navItems.map((item) => (
item.children ? (
<DropdownMenu key={item.key}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
<NavigationMenuItem key={item.key}>
{item.children ? (
<>
<NavigationMenuTrigger className="bg-transparent text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing !text-lg font-medium">
{item.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
</NavigationMenuTrigger>
<NavigationMenuContent className="min-w-[220px] rounded-md border bg-popover p-3 shadow-lg">
<ul className="flex flex-col gap-1">
{item.children.map((child) => (
<DropdownMenuItem asChild key={child.key}>
<Link href={child.href} className="w-full flex items-center">
{child.label}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
asChild
key={item.key}
variant="ghost"
className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200"
<li key={child.key}>
<NavigationMenuLink asChild>
<Link
href={child.href}
className="flex items-center justify-between rounded-md px-3 py-2 !text-lg font-medium text-gray-700 transition hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
>
<Link href={item.href}>{item.label}</Link>
</Button>
)
{child.label}
<ChevronRight className="h-4 w-4" />
</Link>
</NavigationMenuLink>
</li>
))}
</nav>
</ul>
</NavigationMenuContent>
</>
) : (
<NavigationMenuLink asChild>
<Link
href={item.href}
className={cn(
navigationMenuTriggerStyle(),
"bg-transparent !text-lg font-medium text-gray-700 hover:bg-pink-50 hover:text-pink-600 dark:text-gray-300 dark:hover:bg-pink-950/20 dark:hover:text-pink-400 font-sans-marketing"
)}
>
{item.label}
</Link>
</NavigationMenuLink>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<div className="hidden lg:flex items-center space-x-4">
<Button
variant="ghost"
@@ -180,18 +203,18 @@ const Header: React.FC = () => {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile')}>
Profil
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile/orders')}>
Bestellungen
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<DropdownMenuItem onClick={handleLogout} className="font-sans-marketing">
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
@@ -200,16 +223,10 @@ const Header: React.FC = () => {
<>
<Link
href={localizedPath('/login')}
className="text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400 font-medium transition-colors duration-200"
className="text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400 font-medium transition-colors duration-200 font-sans-marketing"
>
{t('header.login')}
</Link>
<Link
href={localizedPath('/register')}
className="bg-pink-500 text-white px-4 py-2 rounded hover:bg-pink-600 dark:bg-pink-600 dark:hover:bg-pink-700"
>
{t('header.register')}
</Link>
</>
)}
</div>
@@ -229,31 +246,40 @@ const Header: React.FC = () => {
<SheetHeader className="text-left">
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-4">
<nav className="flex flex-col gap-2">
{navItems.map((item) => (
item.children ? (
<div key={item.key} className="space-y-2">
<p className="text-sm font-semibold uppercase text-muted-foreground">{item.label}</p>
<div className="flex flex-col gap-2">
<Accordion
key={item.key}
type="single"
collapsible
className="w-full"
>
<AccordionItem value={`${item.key}-group`}>
<AccordionTrigger className="flex w-full items-center justify-between rounded-md border border-transparent bg-gray-50 px-3 py-2 text-base font-semibold text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:bg-gray-800 font-sans-marketing">
{item.label}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pt-2">
{item.children.map((child) => (
<SheetClose asChild key={child.key}>
<Link
href={child.href}
className="flex items-center justify-between rounded-md border border-transparent bg-gray-50 px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:bg-gray-800"
className="flex items-center justify-between rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
<span>{child.label}</span>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</SheetClose>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
) : (
<SheetClose asChild key={item.key}>
<Link
href={item.href}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
{item.label}
@@ -295,7 +321,7 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/profile')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
Profil
@@ -304,13 +330,13 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/profile/orders')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
Bestellungen
</Link>
</SheetClose>
<Button variant="destructive" onClick={handleLogout}>
<Button variant="destructive" onClick={handleLogout} className="font-sans-marketing">
Abmelden
</Button>
</>
@@ -319,21 +345,12 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/login')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
{t('header.login')}
</Link>
</SheetClose>
<SheetClose asChild>
<Link
href={localizedPath('/register')}
className="rounded-md bg-pink-500 px-3 py-2 text-base font-semibold text-white transition hover:bg-pink-600"
onClick={handleNavSelect}
>
{t('header.register')}
</Link>
</SheetClose>
</div>
)}
</div>

View File

@@ -0,0 +1,22 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options && typeof options.defaultValue === 'string') {
return options.defaultValue;
}
return key;
},
i18n: {
language: 'de',
changeLanguage: vi.fn(),
},
}),
Trans: ({ children }: { children: React.ReactNode }) => children,
};
});

View File

@@ -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' => 'QRCode',
'note_html' => 'Hinweis: Der QRCode wird über einen externen QRService generiert. Für eine selbst gehostete Lösung können wir später eine interne QRGenerierung 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' => 'TenantAdmin',
],
'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',
],
],
];

View File

@@ -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',

View File

@@ -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',
],
],
];

View File

@@ -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.',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => 'Tenant-Login',
'form' => [
'actions' => [
'authenticate' => [
'label' => 'Anmelden',
],
],
],
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => 'Tenant Login',
'form' => [
'actions' => [
'authenticate' => [
'label' => 'Sign in',
],
],
],
];

View File

@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#f43f5e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@viteReactRefresh
@vite('resources/js/admin/main.tsx')
</head>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('admin.errors.forbidden.title') }}</title>
@vite('resources/css/app.css')
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<div class="flex min-h-screen items-center justify-center px-6 py-12">
<div class="max-w-lg rounded-3xl border border-white/10 bg-white/5 p-10 shadow-2xl backdrop-blur">
<p class="text-sm uppercase tracking-widest text-pink-400">403</p>
<h1 class="mt-2 text-3xl font-semibold text-white">{{ __('admin.errors.forbidden.title') }}</h1>
<p class="mt-4 text-base text-slate-200">{{ __('admin.errors.forbidden.message') }}</p>
<p class="mt-2 text-sm text-slate-400">{{ __('admin.errors.forbidden.hint') }}</p>
<div class="mt-8">
<a href="{{ url('/') }}" class="inline-flex items-center rounded-full bg-pink-500 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-500">
{{ __('admin.errors.forbidden.cta') }}
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,16 +1,125 @@
<div class="space-y-3">
<div class="text-sm">{{ __('admin.events.join_link.link_label') }}</div>
<div class="rounded border bg-gray-50 p-2 text-sm dark:bg-gray-900">
<a href="{{ $link }}" target="_blank" class="underline">
{{ $link }}
<div class="space-y-5">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{{ __('admin.events.join_link.event_label') }}</div>
<div class="text-base font-semibold text-amber-900 dark:text-amber-100">{{ $event->name }}</div>
</div>
<p class="mt-3 text-xs leading-relaxed text-amber-700 dark:text-amber-200">
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
</p>
<a
href="{{ url('/event-admin/events/' . $event->slug) }}"
target="_blank"
rel="noreferrer"
class="mt-3 inline-flex items-center gap-2 rounded bg-amber-600 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:hover:bg-amber-500"
>
{{ __('admin.events.join_link.open_admin') }}
</a>
</div>
<div class="text-sm">{{ __('admin.events.join_link.qr_code_label') }}</div>
<div class="flex items-center justify-center">
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(300)->generate($link) !!}
@if ($tokens->isEmpty())
<div class="rounded border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('admin.events.join_link.no_tokens') }}
</div>
<div class="text-xs text-muted-foreground">
{!! __('admin.events.join_link.note_html') !!}
@else
<div class="space-y-4">
@foreach ($tokens as $token)
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-800 dark:text-slate-100">
{{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.token_usage', [
'usage' => $token['usage_count'],
'limit' => $token['usage_limit'] ?? '∞',
]) }}
</div>
</div>
<div>
@if ($token['is_active'])
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200">
{{ __('admin.events.join_link.token_active') }}
</span>
@else
<span class="rounded-full bg-slate-200 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{{ __('admin.events.join_link.token_inactive') }}
</span>
@endif
</div>
</div>
<div class="mt-3 space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.link_label') }}
</div>
<div class="flex flex-wrap items-center gap-3">
<code class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ $token['url'] }}
</code>
<button
x-data
@click.prevent="navigator.clipboard.writeText('{{ $token['url'] }}')"
class="rounded border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{{ __('admin.events.join_link.copy_link') }}
</button>
</div>
</div>
@if (!empty($token['layouts']))
<div class="mt-4 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.layouts_heading') }}
</div>
<div class="grid gap-3 md:grid-cols-2">
@foreach ($token['layouts'] as $layout)
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="font-semibold text-slate-900 dark:text-slate-100">
{{ $layout['name'] }}
</div>
@if (!empty($layout['subtitle']))
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{{ $layout['subtitle'] }}
</div>
@endif
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($layout['download_urls'] as $format => $href)
<a
href="{{ $href }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 rounded border border-amber-300 bg-amber-100 px-2 py-1 text-[11px] font-medium text-amber-800 transition hover:bg-amber-200 dark:border-amber-500/50 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/20"
>
{{ strtoupper($format) }}
</a>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@elseif(!empty($token['layouts_url']))
<div class="mt-4">
<a
href="{{ $token['layouts_url'] }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 underline decoration-dotted hover:text-amber-800 dark:text-amber-300"
>
{{ __('admin.events.join_link.layouts_fallback') }}
</a>
</div>
@endif
@if ($token['expires_at'])
<div class="mt-4 text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>

View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{{ $eventName }} Einladungs-QR</title>
<style>
:root {
--accent: {{ $layout['accent'] }};
--secondary: {{ $layout['secondary'] }};
--text: {{ $layout['text'] }};
--badge: {{ $layout['badge'] }};
--container-padding: 48px;
--qr-size: 340px;
--background: {{ $backgroundStyle }};
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: var(--text);
}
body {
min-height: 100%;
position: relative;
}
.layout-wrapper {
width: 100%;
height: 100%;
padding: var(--container-padding);
display: flex;
flex-direction: column;
justify-content: space-between;
background: var(--background);
}
.header {
display: flex;
flex-direction: column;
gap: 12px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 10px;
background: var(--badge);
color: #fff;
padding: 10px 18px;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
width: fit-content;
}
.event-title {
font-size: 72px;
font-weight: 700;
line-height: 1.05;
margin: 0;
}
.subtitle {
font-size: 24px;
font-weight: 500;
color: rgba(17, 24, 39, 0.7);
margin: 0;
}
.content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 40px;
margin-top: 48px;
align-items: center;
}
.info-card {
background: rgba(255, 255, 255, 0.65);
border-radius: 32px;
padding: 32px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
.info-card h2 {
margin: 0;
font-size: 32px;
font-weight: 700;
}
.info-card p {
margin: 0;
font-size: 18px;
line-height: 1.6;
}
.instructions {
margin: 0;
padding-left: 22px;
display: flex;
flex-direction: column;
gap: 12px;
}
.instructions li {
font-size: 18px;
line-height: 1.5;
}
.link-box {
background: var(--secondary);
color: var(--text);
font-family: "Courier New", Courier, monospace;
border-radius: 16px;
padding: 18px 20px;
font-size: 18px;
word-break: break-all;
}
.qr-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.qr-wrapper img {
width: var(--qr-size);
height: var(--qr-size);
}
.cta {
font-size: 20px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.footer {
margin-top: 48px;
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: 16px;
color: rgba(17, 24, 39, 0.6);
}
.footer strong {
color: var(--accent);
}
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<span class="badge">Digitale Gästebox</span>
<h1 class="event-title">{{ $eventName }}</h1>
@if(!empty($layout['subtitle']))
<p class="subtitle">{{ $layout['subtitle'] }}</p>
@endif
</div>
<div class="content">
<div class="info-card">
<h2>So funktioniert&rsquo;s</h2>
<p>{{ $layout['description'] }}</p>
@if(!empty($layout['instructions']))
<ul class="instructions">
@foreach($layout['instructions'] as $step)
<li>{{ $step }}</li>
@endforeach
</ul>
@endif
<div>
<div class="cta">Alternative zum Einscannen</div>
<div class="link-box">{{ $tokenUrl }}</div>
</div>
</div>
<div class="qr-wrapper">
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
<div class="cta">Scan mich & starte direkt</div>
</div>
</div>
<div class="footer">
<div>
<strong>{{ config('app.name', 'Fotospiel') }}</strong> Gästebox & Fotochallenges
</div>
<div>Einladungsgültigkeit: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}</div>
</div>
</div>
</body>
</html>

View File

@@ -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
<svg width="{{ $width }}" height="{{ $height }}" viewBox="0 0 {{ $width }} {{ $height }}" xmlns="http://www.w3.org/2000/svg">
<defs>
@if($gradientId)
<linearGradient id="{{ $gradientId }}" x1="{{ $x1Attr }}" y1="{{ $y1Attr }}" x2="{{ $x2Attr }}" y2="{{ $y2Attr }}">
@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
<stop offset="{{ number_format($offset, 2, '.', '') }}%" stop-color="{{ $stopColor }}"/>
@endforeach
</linearGradient>
@endif
</defs>
<style>
.title-line {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 82px;
font-weight: 700;
letter-spacing: -1px;
}
.subtitle-line {
font-family: 'Lora', 'Georgia', serif;
font-size: 32px;
font-weight: 500;
}
.description-line {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 400;
line-height: 1.4;
}
.badge-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 28px;
font-weight: 600;
letter-spacing: 4px;
text-transform: uppercase;
}
.instruction-bullet {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 600;
}
.instruction-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 400;
}
.small-label {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 28px;
font-weight: 600;
letter-spacing: 3px;
text-transform: uppercase;
}
.link-text {
font-family: 'Courier New', monospace;
font-size: 30px;
}
.footer-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 26px;
font-weight: 400;
}
.footer-strong {
font-weight: 700;
}
</style>
<rect x="0" y="0" width="{{ $width }}" height="{{ $height }}" fill="{{ $gradientId ? 'url(#'.$gradientId.')' : $background }}" />
<rect x="70" y="380" width="500" height="600" rx="46" fill="rgba(255,255,255,0.78)" />
<rect x="600" y="420" width="380" height="380" rx="36" fill="rgba(255,255,255,0.88)" />
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
<rect x="80" y="120" width="250" height="70" rx="35" fill="{{ $badgeColor }}" />
<text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">Digitale Gästebox</text>
@foreach($titleLines as $index => $line)
<text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text>
@endforeach
@php
$subtitleOffset = 260 + count($titleLines) * 88 + 40;
@endphp
@foreach($subtitleLines as $index => $line)
<text x="80" y="{{ $subtitleOffset + $index * 44 }}" fill="{{ $secondary }}" class="subtitle-line">{{ e($line) }}</text>
@endforeach
@php
$descriptionOffset = $subtitleOffset + (count($subtitleLines) ? count($subtitleLines) * 44 + 60 : 40);
@endphp
@foreach($descriptionLines as $index => $line)
<text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text>
@endforeach
<text x="120" y="760" fill="{{ $accent }}" class="small-label">SO FUNKTIONIERT'S</text>
@foreach($instructions as $index => $step)
@php
$lineY = $instructionStartY + $index * $instructionSpacing;
@endphp
<circle cx="120" cy="{{ $lineY - 18 }}" r="10" fill="{{ $accent }}" />
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
@endforeach
<text x="640" y="760" fill="{{ $accent }}" class="small-label">ALTERNATIVER LINK</text>
<rect x="630" y="790" width="320" height="120" rx="22" fill="rgba(0,0,0,0.08)" />
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($tokenUrl) }}</text>
<image href="{{ $qrPngDataUri }}" x="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">JETZT SCANNEN</text>
<text x="120" y="{{ $height - 160 }}" fill="rgba(17,24,39,0.6)" class="footer-text">
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>
&nbsp; Gästebox & Fotochallenges
</text>
<text x="{{ $width - 120 }}" y="{{ $height - 160 }}" text-anchor="end" fill="rgba(17,24,39,0.6)" class="footer-text">
Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}
</text>
</svg>

View File

@@ -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');

View File

@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature;
use App\Models\Event;
use App\Models\Photo;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Mockery;
use Tests\TestCase;
class GuestJoinTokenFlowTest extends TestCase
{
use RefreshDatabase;
private EventJoinTokenService $tokenService;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}

26
vitest.config.ts Normal file
View File

@@ -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}'],
},
},
});