- 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:
@@ -2,91 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Auth;
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
use Filament\Forms\Components\Checkbox;
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Schemas\Components\Component;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Filament\Pages\SimplePage;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class Login extends SimplePage implements HasForms
|
class Login extends BaseLogin
|
||||||
{
|
{
|
||||||
use InteractsWithForms;
|
public function getTitle(): string
|
||||||
|
|
||||||
protected string $view = 'filament.pages.auth.login';
|
|
||||||
protected static ?string $title = 'Tenant Login';
|
|
||||||
|
|
||||||
public function getFormSchema(): array
|
|
||||||
{
|
{
|
||||||
return [
|
return __('auth.login.title') ?: parent::getTitle();
|
||||||
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'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit(): void
|
public function getHeading(): string
|
||||||
{
|
{
|
||||||
$data = $this->form->getState();
|
return __('auth.login.title') ?: parent::getHeading();
|
||||||
|
}
|
||||||
|
|
||||||
$credentials = $this->getCredentialsFromFormData($data);
|
protected function getEmailFormComponent(): Component
|
||||||
|
{
|
||||||
|
$component = parent::getEmailFormComponent();
|
||||||
|
|
||||||
if (! Auth::attempt($credentials, $data['remember'] ?? false)) {
|
return $component
|
||||||
throw ValidationException::withMessages([
|
->label(__('auth.login.username_or_email') ?: $component->getLabel());
|
||||||
'data.username_or_email' => __('auth.failed'),
|
}
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = Auth::user();
|
protected function getPasswordFormComponent(): Component
|
||||||
|
{
|
||||||
|
$component = parent::getPasswordFormComponent();
|
||||||
|
|
||||||
if (! $user->email_verified_at) {
|
return $component
|
||||||
Auth::logout();
|
->label(__('auth.login.password') ?: $component->getLabel());
|
||||||
|
}
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
protected function getRememberFormComponent(): Component
|
||||||
'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.',
|
{
|
||||||
]);
|
$component = parent::getRememberFormComponent();
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->tenant) {
|
return $component
|
||||||
Auth::logout();
|
->label(__('auth.login.remember_me') ?: $component->getLabel());
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'data.username_or_email' => 'No tenant associated with your account. Contact support.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->regenerate();
|
|
||||||
|
|
||||||
$this->redirect($this->getRedirectUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getCredentialsFromFormData(array $data): array
|
protected function getCredentialsFromFormData(array $data): array
|
||||||
{
|
{
|
||||||
$usernameOrEmail = $data['username_or_email'];
|
$identifier = $data['email'] ?? '';
|
||||||
$password = $data['password'];
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
$credentials = ['password' => $password];
|
if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [
|
||||||
if (filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL)) {
|
'email' => $identifier,
|
||||||
$credentials['email'] = $usernameOrEmail;
|
'password' => $password,
|
||||||
} else {
|
];
|
||||||
$credentials['username'] = $usernameOrEmail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $credentials;
|
return [
|
||||||
|
'username' => $identifier,
|
||||||
|
'password' => $password,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasLogo(): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\EventResource\Pages;
|
use App\Filament\Resources\EventResource\Pages;
|
||||||
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
@@ -102,8 +103,20 @@ class EventResource extends Resource
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||||
Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join'))
|
Tables\Columns\TextColumn::make('primary_join_token')
|
||||||
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
|
->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()
|
->copyable()
|
||||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
@@ -115,14 +128,50 @@ class EventResource extends Resource
|
|||||||
->label(__('admin.events.actions.toggle_active'))
|
->label(__('admin.events.actions.toggle_active'))
|
||||||
->icon('heroicon-o-power')
|
->icon('heroicon-o-power')
|
||||||
->action(fn($record) => $record->update(['is_active' => !$record->is_active])),
|
->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'))
|
->label(__('admin.events.actions.join_link_qr'))
|
||||||
->icon('heroicon-o-qr-code')
|
->icon('heroicon-o-qr-code')
|
||||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||||
->modalSubmitActionLabel(__('admin.common.close'))
|
->modalSubmitActionLabel(__('admin.common.close'))
|
||||||
->modalContent(fn($record) => view('filament.events.join-link', [
|
->modalWidth('xl')
|
||||||
'link' => url("/e/{$record->slug}"),
|
->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([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make(),
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ use Carbon\CarbonImmutable;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use App\Models\EventJoinToken;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class EventPublicController extends BaseController
|
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')
|
$rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->ip());
|
||||||
->where('slug', $identifier)
|
|
||||||
->where('status', 'published')
|
|
||||||
->first($columns);
|
|
||||||
|
|
||||||
if ($event) {
|
$joinToken = $this->joinTokenService->findToken($token, true);
|
||||||
return [$event, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$joinToken = $this->joinTokenService->findActiveToken($identifier);
|
|
||||||
if (! $joinToken) {
|
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')
|
$event = DB::table('events')
|
||||||
->where('id', $joinToken->event_id)
|
->where('id', $joinToken->event_id)
|
||||||
->where('status', 'published')
|
|
||||||
->first($columns);
|
->first($columns);
|
||||||
|
|
||||||
if (! $event) {
|
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];
|
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 = '') {
|
private function getLocalized($value, $locale, $default = '') {
|
||||||
if (is_string($value) && json_decode($value) !== null) {
|
if (is_string($value) && json_decode($value) !== null) {
|
||||||
$data = json_decode($value, true);
|
$data = json_decode($value, true);
|
||||||
@@ -81,9 +171,9 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
return $path; // fallback as-is
|
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',
|
'id',
|
||||||
'slug',
|
'slug',
|
||||||
'name',
|
'name',
|
||||||
@@ -92,11 +182,14 @@ class EventPublicController extends BaseController
|
|||||||
'updated_at',
|
'updated_at',
|
||||||
'event_type_id',
|
'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);
|
$nameData = json_decode($event->name, true);
|
||||||
$localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name;
|
$localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name;
|
||||||
|
|
||||||
@@ -133,13 +226,15 @@ class EventPublicController extends BaseController
|
|||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function stats(string $identifier)
|
public function stats(Request $request, string $token)
|
||||||
{
|
{
|
||||||
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
|
||||||
// Approximate online guests as distinct recent uploaders in last 10 minutes.
|
// Approximate online guests as distinct recent uploaders in last 10 minutes.
|
||||||
@@ -162,7 +257,7 @@ class EventPublicController extends BaseController
|
|||||||
];
|
];
|
||||||
|
|
||||||
$etag = sha1(json_encode($payload));
|
$etag = sha1(json_encode($payload));
|
||||||
$reqEtag = request()->headers->get('If-None-Match');
|
$reqEtag = $request->headers->get('If-None-Match');
|
||||||
if ($reqEtag && $reqEtag === $etag) {
|
if ($reqEtag && $reqEtag === $etag) {
|
||||||
return response('', 304);
|
return response('', 304);
|
||||||
}
|
}
|
||||||
@@ -172,14 +267,17 @@ class EventPublicController extends BaseController
|
|||||||
->header('ETag', $etag);
|
->header('ETag', $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function emotions(string $identifier)
|
public function emotions(Request $request, string $token)
|
||||||
{
|
{
|
||||||
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
$locale = $request->query('locale', 'de');
|
||||||
|
|
||||||
$rows = DB::table('emotions')
|
$rows = DB::table('emotions')
|
||||||
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
|
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
|
||||||
@@ -195,8 +293,7 @@ class EventPublicController extends BaseController
|
|||||||
->orderBy('emotions.sort_order')
|
->orderBy('emotions.sort_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$payload = $rows->map(function ($r) {
|
$payload = $rows->map(function ($r) use ($locale) {
|
||||||
$locale = request()->query('locale', 'de');
|
|
||||||
$nameData = json_decode($r->name, true);
|
$nameData = json_decode($r->name, true);
|
||||||
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
|
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
|
||||||
|
|
||||||
@@ -213,7 +310,7 @@ class EventPublicController extends BaseController
|
|||||||
});
|
});
|
||||||
|
|
||||||
$etag = sha1($payload->toJson());
|
$etag = sha1($payload->toJson());
|
||||||
$reqEtag = request()->headers->get('If-None-Match');
|
$reqEtag = $request->headers->get('If-None-Match');
|
||||||
if ($reqEtag && $reqEtag === $etag) {
|
if ($reqEtag && $reqEtag === $etag) {
|
||||||
return response('', 304);
|
return response('', 304);
|
||||||
}
|
}
|
||||||
@@ -223,13 +320,15 @@ class EventPublicController extends BaseController
|
|||||||
->header('ETag', $etag);
|
->header('ETag', $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tasks(string $identifier, Request $request)
|
public function tasks(Request $request, string $token)
|
||||||
{
|
{
|
||||||
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
|
||||||
$query = DB::table('tasks')
|
$query = DB::table('tasks')
|
||||||
@@ -249,11 +348,11 @@ class EventPublicController extends BaseController
|
|||||||
->limit(20);
|
->limit(20);
|
||||||
|
|
||||||
$rows = $query->get();
|
$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
|
// Handle JSON fields for multilingual content
|
||||||
$getLocalized = function ($field, $default = '') use ($r) {
|
$getLocalized = function ($field, $default = '') use ($r, $locale) {
|
||||||
$locale = request()->query('locale', 'de');
|
|
||||||
$value = $r->$field;
|
$value = $r->$field;
|
||||||
if (is_string($value) && json_decode($value) !== null) {
|
if (is_string($value) && json_decode($value) !== null) {
|
||||||
$data = json_decode($value, true);
|
$data = json_decode($value, true);
|
||||||
@@ -267,7 +366,6 @@ class EventPublicController extends BaseController
|
|||||||
if ($r->emotion_id) {
|
if ($r->emotion_id) {
|
||||||
$emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']);
|
$emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']);
|
||||||
if ($emotionRow && isset($emotionRow->name)) {
|
if ($emotionRow && isset($emotionRow->name)) {
|
||||||
$locale = request()->query('locale', 'de');
|
|
||||||
$value = $emotionRow->name;
|
$value = $emotionRow->name;
|
||||||
if (is_string($value) && json_decode($value) !== null) {
|
if (is_string($value) && json_decode($value) !== null) {
|
||||||
$data = json_decode($value, true);
|
$data = json_decode($value, true);
|
||||||
@@ -299,7 +397,7 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$etag = sha1($payload->toJson());
|
$etag = sha1($payload->toJson());
|
||||||
$reqEtag = request()->headers->get('If-None-Match');
|
$reqEtag = $request->headers->get('If-None-Match');
|
||||||
if ($reqEtag && $reqEtag === $etag) {
|
if ($reqEtag && $reqEtag === $etag) {
|
||||||
return response('', 304);
|
return response('', 304);
|
||||||
}
|
}
|
||||||
@@ -309,12 +407,15 @@ class EventPublicController extends BaseController
|
|||||||
->header('ETag', $etag);
|
->header('ETag', $etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function photos(Request $request, string $identifier)
|
public function photos(Request $request, string $token)
|
||||||
{
|
{
|
||||||
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||||
@@ -345,7 +446,7 @@ class EventPublicController extends BaseController
|
|||||||
if ($since) {
|
if ($since) {
|
||||||
$query->where('photos.created_at', '>', $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) {
|
$rows = $query->get()->map(function ($r) use ($locale) {
|
||||||
$r->file_path = $this->toPublicUrl((string)($r->file_path ?? ''));
|
$r->file_path = $this->toPublicUrl((string)($r->file_path ?? ''));
|
||||||
@@ -364,7 +465,7 @@ class EventPublicController extends BaseController
|
|||||||
'latest_photo_at' => $latestPhotoAt,
|
'latest_photo_at' => $latestPhotoAt,
|
||||||
];
|
];
|
||||||
$etag = sha1(json_encode([$since, $filter, $deviceId, $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) {
|
if ($reqEtag && $reqEtag === $etag) {
|
||||||
return response('', 304);
|
return response('', 304);
|
||||||
}
|
}
|
||||||
@@ -455,12 +556,15 @@ class EventPublicController extends BaseController
|
|||||||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
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']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||||
@@ -556,11 +660,13 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
public function achievements(Request $request, string $identifier)
|
public function achievements(Request $request, string $identifier)
|
||||||
{
|
{
|
||||||
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
|
$result = $this->resolvePublishedEvent($request, $identifier, ['id']);
|
||||||
if (! $event) {
|
|
||||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
if ($result instanceof JsonResponse) {
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$event] = $result;
|
||||||
$eventId = $event->id;
|
$eventId = $event->id;
|
||||||
$locale = $request->query('locale', 'de');
|
$locale = $request->query('locale', 'de');
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources\Tenant;
|
namespace App\Http\Resources\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -12,6 +14,28 @@ class EventJoinTokenResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray($request): array
|
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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
@@ -24,6 +48,8 @@ class EventJoinTokenResource extends JsonResource
|
|||||||
'is_active' => $this->isActive(),
|
'is_active' => $this->isActive(),
|
||||||
'created_at' => optional($this->created_at)->toIso8601String(),
|
'created_at' => optional($this->created_at)->toIso8601String(),
|
||||||
'metadata' => $this->metadata ?? new \stdClass(),
|
'metadata' => $this->metadata ?? new \stdClass(),
|
||||||
|
'layouts_url' => $layoutsUrl,
|
||||||
|
'layouts' => $layouts,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
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;
|
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 HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
@@ -99,8 +105,43 @@ class User extends Authenticatable implements MustVerifyEmail, HasName
|
|||||||
return $this->username ?? $this->email ?? 'Unnamed User';
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class CheckoutAssignmentService
|
|||||||
protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant
|
protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant
|
||||||
{
|
{
|
||||||
if ($user->tenant) {
|
if ($user->tenant) {
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
$user->forceFill(['tenant_id' => $user->tenant->getKey()])->save();
|
||||||
|
}
|
||||||
return $user->tenant;
|
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));
|
event(new Registered($user));
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
|
|||||||
@@ -58,22 +58,29 @@ class EventJoinTokenService
|
|||||||
$joinToken->increment('usage_count');
|
$joinToken->increment('usage_count');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findActiveToken(string $token): ?EventJoinToken
|
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||||
{
|
{
|
||||||
return EventJoinToken::query()
|
return EventJoinToken::query()
|
||||||
->where('token', $token)
|
->where('token', $token)
|
||||||
->whereNull('revoked_at')
|
->when(! $includeInactive, function ($query) {
|
||||||
->where(function ($query) {
|
$query->whereNull('revoked_at')
|
||||||
$query->whereNull('expires_at')
|
->where(function ($query) {
|
||||||
->orWhere('expires_at', '>', now());
|
$query->whereNull('expires_at')
|
||||||
})
|
->orWhere('expires_at', '>', now());
|
||||||
->where(function ($query) {
|
})
|
||||||
$query->whereNull('usage_limit')
|
->where(function ($query) {
|
||||||
->orWhereColumn('usage_limit', '>', 'usage_count');
|
$query->whereNull('usage_limit')
|
||||||
|
->orWhereColumn('usage_limit', '>', 'usage_count');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findActiveToken(string $token): ?EventJoinToken
|
||||||
|
{
|
||||||
|
return $this->findToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
protected function generateUniqueToken(int $length = 48): string
|
protected function generateUniqueToken(int $length = 48): string
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
|
|||||||
197
app/Support/JoinTokenLayoutRegistry.php
Normal file
197
app/Support/JoinTokenLayoutRegistry.php
Normal 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 200 g/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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
|
"dompdf/dompdf": "2.0",
|
||||||
"filament/filament": "~4.0",
|
"filament/filament": "~4.0",
|
||||||
"firebase/php-jwt": "^6.11",
|
"firebase/php-jwt": "^6.11",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
|||||||
228
composer.lock
generated
228
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "b7732f55f2145944530fb5c8b8c035b4",
|
"content-hash": "2852435257a5672486892b814ff57bbf",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -1177,6 +1177,76 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"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",
|
"name": "dragonmantank/cron-expression",
|
||||||
"version": "v3.4.0",
|
"version": "v3.4.0",
|
||||||
@@ -4670,6 +4740,96 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-05-27T17:46:31+00:00"
|
"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",
|
"name": "php-jsonpointer/php-jsonpointer",
|
||||||
"version": "v3.0.2",
|
"version": "v3.0.2",
|
||||||
@@ -5733,6 +5893,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-02-25T09:09:36+00:00"
|
"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",
|
"name": "scrivo/highlight.php",
|
||||||
"version": "v9.18.1.10",
|
"version": "v9.18.1.10",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ return new class extends Migration
|
|||||||
$table->boolean('photo_upload_enabled')->default(true);
|
$table->boolean('photo_upload_enabled')->default(true);
|
||||||
$table->boolean('task_checklist_enabled')->default(true);
|
$table->boolean('task_checklist_enabled')->default(true);
|
||||||
$table->string('default_locale', 5)->default('de');
|
$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->timestamps();
|
||||||
$table->index(['tenant_id', 'date', 'is_active']);
|
$table->index(['tenant_id', 'date', 'is_active']);
|
||||||
$table->foreign('event_type_id')->references('id')->on('event_types')->onDelete('restrict');
|
$table->foreign('event_type_id')->references('id')->on('event_types')->onDelete('restrict');
|
||||||
@@ -34,7 +34,7 @@ return new class extends Migration
|
|||||||
} else {
|
} else {
|
||||||
if (!Schema::hasColumn('events', 'status')) {
|
if (!Schema::hasColumn('events', 'status')) {
|
||||||
Schema::table('events', function (Blueprint $table) {
|
Schema::table('events', function (Blueprint $table) {
|
||||||
$table->enum('status', ['draft', 'active', 'archived'])->default('draft')->after('is_active');
|
$table->enum('status', ['draft', 'published', 'archived'])->default('draft')->after('is_active');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,4 +165,4 @@ return new class extends Migration
|
|||||||
Schema::dropIfExists('events');
|
Schema::dropIfExists('events');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
// Seed demo and admin data
|
// Seed demo and admin data
|
||||||
$this->call([
|
$this->call([
|
||||||
SuperAdminSeeder::class,
|
SuperAdminSeeder::class,
|
||||||
|
DemoTenantSeeder::class,
|
||||||
DemoEventSeeder::class,
|
DemoEventSeeder::class,
|
||||||
OAuthClientSeeder::class,
|
OAuthClientSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class DemoEventSeeder extends Seeder
|
|||||||
'description' => ['de'=>'Demo-Event','en'=>'Demo event'],
|
'description' => ['de'=>'Demo-Event','en'=>'Demo event'],
|
||||||
'date' => now()->addMonths(3)->toDateString(),
|
'date' => now()->addMonths(3)->toDateString(),
|
||||||
'event_type_id' => $type->id,
|
'event_type_id' => $type->id,
|
||||||
'status' => 'active',
|
'status' => 'published',
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'settings' => json_encode([]),
|
'settings' => json_encode([]),
|
||||||
'default_locale' => 'de',
|
'default_locale' => 'de',
|
||||||
|
|||||||
122
database/seeders/DemoTenantSeeder.php
Normal file
122
database/seeders/DemoTenantSeeder.php
Normal 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,10 @@ Key Endpoints (abridged)
|
|||||||
- Settings: read/update tenant theme, limits, legal page links.
|
- Settings: read/update tenant theme, limits, legal page links.
|
||||||
|
|
||||||
Guest Polling (no WebSockets in v1)
|
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 }`.
|
- Response: `{ online_guests: number, tasks_solved: number, latest_photo_at: ISO8601 }`.
|
||||||
- Cache: `Cache-Control: no-store`; include `ETag` for conditional requests.
|
- 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 }`.
|
- 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.
|
- Use `If-None-Match` or `If-Modified-Since` to return `304 Not Modified` when unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.
|
||||||
|
|
||||||
API Touchpoints
|
API Touchpoints
|
||||||
- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme.
|
- GET `/api/v1/events/{token}` — public event metadata (when open) + theme.
|
||||||
- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only).
|
- GET `/api/v1/events/{token}/photos` — paginated gallery (approved only).
|
||||||
- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields.
|
- POST `/api/v1/events/{token}/photos` — signed upload initiation; returns URL + fields.
|
||||||
- POST (S3) — direct upload to object storage; then backend finalize call.
|
- POST (S3) — direct upload to object storage; then backend finalize call.
|
||||||
- POST `/api/v1/photos/{id}/like` — idempotent like with device token.
|
- POST `/api/v1/photos/{id}/like` — idempotent like with device token.
|
||||||
|
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ packages/mobile/ # Shared Native-Config (optional)
|
|||||||
- **Privacy**: Usage Descriptions in Info.plist (z.B. "Kamera für QR-Scans").
|
- **Privacy**: Usage Descriptions in Info.plist (z.B. "Kamera für QR-Scans").
|
||||||
|
|
||||||
- **PWA-Fallback** (Web):
|
- **PWA-Fallback** (Web):
|
||||||
- **manifest.json**: `start_url: '/event-admin/'`, `display: 'standalone'`.
|
- **manifest.json**: liegt unter `public/manifest.json` (Scope `/event-admin/`, Theme-Farbe `#f43f5e`, Shortcuts für Welcome & Dashboard).
|
||||||
- **Service Worker**: Caching von Assets; Background Sync für Mutations.
|
- **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.
|
- **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)
|
### Native Features (Erweiterung zu settings-config.md)
|
||||||
- **Push-Notifications**:
|
- **Push-Notifications**:
|
||||||
|
|||||||
@@ -1,100 +1,87 @@
|
|||||||
# Funktionale Spezifikationen für die Tenant Admin App
|
# Funktionale Spezifikationen – Tenant-Admin-App
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
- **Version**: 1.0.0 (2025-09-13)
|
- **Version**: 1.1.0 (Stand 2025-10-13)
|
||||||
- **Supersedes**: Teile von docs/prp/06-tenant-admin-pwa.md und docs/prp-addendum-2025-09-08-tenant-admin-pwa.md
|
- **Ersetzt**: docs/prp/06-tenant-admin-pwa.md, docs/prp-addendum-2025-09-08-tenant-admin-pwa.md (Legacy-Referenz über Git History).
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
Die Tenant Admin App muss folgende Kernfunktionen bereitstellen:
|
Die Admin-App muss folgende Kernfunktionen bereitstellen:
|
||||||
- **Event-Management**: CRUD-Operationen für Events (Erstellen, Bearbeiten, Archivieren, Veröffentlichen).
|
- **Geführtes Onboarding**: Welcome Flow (Hero, How-It-Works, Paketwahl, Zusammenfassung, Erstes Event). Automatische Weiterleitung für Tenants ohne aktive Events.
|
||||||
- **Gallery-Management**: Hochladen, Moderieren, Featured-Setten von Photos; Thumbnail-Generierung.
|
- **Event-Management**: Erstellen, Bearbeiten, Veröffentlichen, Archivieren; Join-Token-Verwaltung.
|
||||||
- **Member-Management**: Hinzufügen/Entfernen von Event-Mitgliedern; Rollen (Admin, Member).
|
- **Galerie-Management**: Upload, Moderation, Feature-Flags, Analytics.
|
||||||
- **Task & Emotion Management**: Zuweisen von Tasks und Emotions zu Events; Overrides für Tenant-spezifische Bibliotheken.
|
- **Mitglieder-Verwaltung**: Einladungen, Rollen, Zugriffskontrolle.
|
||||||
- **Settings-Management**: Tenant-spezifische Einstellungen (Theme, Limits, Legal Pages).
|
- **Tasks & Emotions**: Bibliothek, Zuweisung, Fortschritts-Tracking.
|
||||||
- **Billing & Purchases**: Kaufen von Packages (pro Event oder Tenant); Ledger-Übersicht; Integration mit Stripe.
|
- **Abrechnung**: Paketübersicht, Stripe/PayPal Checkout, Ledger.
|
||||||
- **Notifications**: Push-Benachrichtigungen für neue Photos, Event-Updates, niedrigen Credit-Balance.
|
- **Einstellungen**: Branding, Limits, Rechtstexte, Benachrichtigungen.
|
||||||
- **Offline-Support**: Caching von Events und Photos; Queuing von Uploads/Mutations mit Sync bei Online-Wiederkehr.
|
- **Offline-Support**: App-Shell-Caching, Queueing von Mutationen, Sync bei Reconnect.
|
||||||
- **Audit & Compliance**: Logging kritischer Aktionen; ETag-basierte Conflict-Resolution; GDPR-konforme Datenlöschung.
|
- **Compliance**: Audit-Logging, GDPR-konforme Löschung, ETag-basierte Konfliktlösung.
|
||||||
|
|
||||||
Die App ist API-first und interagiert ausschließlich über den Backend-API-Endpunkt `/api/v1/tenant/*`.
|
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
### Authentifizierung & Autorisierung
|
### Authentifizierung & Autorisierung
|
||||||
- **OAuth2 Flow**: Authorization Code + PKCE für sichere Token-Erfassung.
|
- OAuth2 Authorization Code mit PKCE, Refresh-Tokens via Secure Storage (Web: IndexedDB, Capacitor: Preferences/Keychain).
|
||||||
- **Token-Management**: Refresh-Tokens mit automatischer Rotation; Secure Storage (Keychain/Keystore).
|
- Tenant-Scoped Tokens; Rollen `tenant_admin` (vollständig) & `member` (read-only, Upload).
|
||||||
- **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).
|
|
||||||
|
|
||||||
### Core Features
|
### Onboarding Journey
|
||||||
- **Event Lifecycle**:
|
- Routen `/event-admin/welcome/*` bilden den Flow.
|
||||||
- Erstellen: Erfordert Package-Auswahl (Free oder Kauf); Slug-Generierung (unique pro Tenant).
|
- `useOnboardingProgress` persistiert Fortschritt (localStorage) und synchronisiert mit Backend (`onboarding_completed_at`).
|
||||||
- Bearbeiten: Update von Datum, Ort, Tasks, Emotions, Join-Link.
|
- Paketwahl nutzt `GET /tenant/packages`; Stripe/PayPal-Fallbacks informieren bei fehlender Konfiguration.
|
||||||
- Veröffentlichen: Generiert QR-Code und Share-Link; aktiviert Guest-PWA-Zugriff.
|
- Dashboard weist per CTA auf offenes Onboarding hin, bis ein erstes Event erstellt wurde.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Offline & Sync
|
### Event Lifecycle
|
||||||
- **Service Worker**: Cache von App-Shell, Events, Photos (Cache-Control: max-age=5min für dynamische Daten).
|
- Erstellung prüft Paketverfügbarkeit; generiert Join-Token.
|
||||||
- **Background Sync**: Queued Mutations (z.B. Photo-Approvals) syncen bei Connectivity.
|
- Bearbeiten erlaubt Statuswechsel, Aufgaben, Emotions, Join-Token-Verwaltung.
|
||||||
- **Conflict Resolution**: ETag/If-Match Headers; Optimistic Updates mit Rollback bei Conflicts.
|
- Veröffentlichen schaltet Guest-PWA frei; Archivieren respektiert Retention-Policy.
|
||||||
|
|
||||||
### Error Handling & UX
|
### Medien & Moderation
|
||||||
- **Rate Limits**: 429-Responses handhaben mit Retry-Logic und User-Feedback ("Zu viele Anfragen, versuche es später").
|
- Direktupload via signed URLs, Thumbnail-Generierung serverseitig.
|
||||||
- **Offline Mode**: Degradiertes UI (Read-Only); Sync-Status-Indikator.
|
- Moderations-Grid mit Bulk-Aktionen, Filter (Neu, Genehmigt, Featured).
|
||||||
- **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.
|
- Analytics: Likes, Uploadzahlen, aktive Gäste.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
### Tasks & Emotions
|
### Tasks & Emotions
|
||||||
- `GET /tenant/tasks`: Tenant-Overrides + globale Bibliothek.
|
- Globale + Tenant-spezifische Bibliothek.
|
||||||
- `POST /tenant/events/{id}/tasks`: Zuweisung.
|
- Drag-and-Drop Zuweisung, Fortschritt je Event, Emotion-Tagging.
|
||||||
- Ähnlich für Emotions.
|
|
||||||
|
### Billing & Checkout
|
||||||
|
- Pakete + Credit-Balance anzeigen.
|
||||||
|
- Stripe PaymentIntent & PayPal Smart Buttons; Fallback-Meldung bei fehlender Konfiguration.
|
||||||
|
- Ledger mit Historie (Paginierung, Filter).
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
- `GET /tenant/settings`: Tenant-Konfig (Theme, Limits, Legal-Links).
|
- Branding (Logo, Farben), Domain/Links, Legal Pages.
|
||||||
- `PATCH /tenant/settings`: Update.
|
- Notification Preferences, Paketlimits, Onboarding-Reset.
|
||||||
|
|
||||||
### Billing
|
### Offline & Sync
|
||||||
- `GET /tenant/packages`: Tenant-Packages und Limits.
|
- Service Worker `public/admin-sw.js` cached App-Shell `/event-admin` und statische Assets, liefert Offline-Fallback für Navigation.
|
||||||
- `POST /tenant/purchases/intent`: Stripe-Checkout-Session für Package erstellen.
|
- Mutationen werden gequeued und nach Reconnect synchronisiert.
|
||||||
- `GET /api/v1/packages`: Verfügbare Packages.
|
- ETag / If-Match für konfliktfreie Updates, Optimistic UI mit Rollback.
|
||||||
|
|
||||||
### Pagination & Errors
|
### Fehlerbehandlung & UX
|
||||||
- Standard: `page`, `per_page` (max 50 für Mobile).
|
- Rate-Limit (429) → Retry-Hinweis.
|
||||||
- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Package-Limit überschritten").
|
- 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
|
## API-Integration
|
||||||
- **Performance**: Ladezeiten < 2s; Lazy-Loading für Galleries.
|
Die App nutzt Endpunkte aus `docs/prp/03-api.md`.
|
||||||
- **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).
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
9
docs/prp/tenant-app-specs/pages-ui-legacy.md
Normal file
9
docs/prp/tenant-app-specs/pages-ui-legacy.md
Normal 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`).
|
||||||
@@ -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
|
## Status
|
||||||
- **Version**: 1.0.0 (2025-09-13)
|
- **Version**: 1.1.0 (Stand 2025-10-13)
|
||||||
- **Fokus**: Mobile-First Design mit Framework7 v8+ für native iOS/Android-Look & Feel.
|
- **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
|
## Design-Grundlagen
|
||||||
- **Framework7-Komponenten**: Toolbar (Navigation), List (Datenlisten), Card (Karten für Events/Photos), Modal (Details/Actions), Pull-to-Refresh (Sync), Infinite-Scroll (Pagination).
|
- **Design Tokens**: Farbverlauf `#f43f5e → #6366f1`, Slate-Neutrals für Typografie, Primärschrift `Clash Display`, Fließtext `Inter`.
|
||||||
- **Theming**: System-Dark-Mode-Support; Tenant-spezifische Farben (Primary/Secondary aus Settings).
|
- **Komponentensystem**: shadcn/ui-Basis (Button, Card, Tabs, Sheet, Dialog). Erweiterungen unter `resources/js/admin/components`.
|
||||||
- **Navigation**: Tabbar unten (Dashboard, Events, Photos, Settings); Side-Menu für Profile/Logout.
|
- **Navigation**: Obere App-Bar mit Breadcrumb & Quick Actions, mobile Tabbar (Dashboard, Events, Tasks, Einstellungen).
|
||||||
- **Offline-Indikator**: Banner oben ("Offline-Modus: Änderungen werden synchronisiert").
|
- **Responsiveness**: Breakpoints `sm` (375px), `md` (768px), `xl` (1280px). Onboarding-Screens nutzen Full-Height Layouts auf Mobile, Split-Grid auf Desktop.
|
||||||
- **Loading**: Spinner für API-Calls; Skeleton-Screens für Listen.
|
- **Accessibility**: `prefers-reduced-motion`, Fokus-Ringe (Tailwind Plugin), ARIA für Carousel, Tabs, Dialoge.
|
||||||
- **i18n**: LTR für de/en (react-i18next); alle Strings via `t('admin.key')`; Icons von Lucide React (aktuell, nicht Framework7).
|
- **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)
|
### Guards & Fortschritt
|
||||||
#### Login-Seite
|
- `useOnboardingProgress` (Context + localStorage) speichert `activeStep`, ausgewähltes Paket und Event-Entwurf.
|
||||||
- **Zweck**: OAuth-Authorization (PKCE-Challenge generieren, Redirect zu /oauth/authorize).
|
- Auth-Guard leitet Tenant ohne Events auf `/event-admin/welcome` um; nach Abschluss setzt Backend `onboarding_completed_at`.
|
||||||
- **Layout**:
|
- Dashboard hero banner zeigt CTA „Geführtes Setup fortsetzen“, solange `onboarding_completed_at` fehlt.
|
||||||
- Zentrale Card mit Logo, App-Name, "Mit Google/Email anmelden"-Buttons.
|
- Offlinezustand: Payment-Sektion zeigt Fallback-Karte „Zahlungsdienste offline – bitte erneut versuchen“.
|
||||||
- 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.
|
|
||||||
|
|
||||||
#### Register-Seite (ähnlich Login)
|
### Assets & PWA
|
||||||
- **Unterschiede**: Form für Email/Password + Terms-Checkbox; Submit zu `/oauth/register`.
|
- 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)
|
## Kernseiten nach dem Onboarding
|
||||||
- **Zweck**: Übersicht über aktive Events, Stats, schnelle Actions.
|
- **Dashboard**: Hero-CTA zum Welcome Flow, Statistik-Kacheln (Events aktiv, Uploads, Credits), Quick Actions (Event anlegen, Fotos moderieren, Tasks verwalten).
|
||||||
- **Layout**:
|
- **Events**: Suchfeld + Filter Pills, Card-Layout mit Status-Badges (`Draft`, `Live`, `Archived`), Detail-Drawer mit Quick Stats.
|
||||||
- Top: Willkommens-Banner mit Tenant-Name, Credit-Balance.
|
- **Fotos**: Moderationsgrid (Masonry), Filter (Neu, Genehmigt, Featured), Bulk-Aktionen in Sticky-Footer.
|
||||||
- Stats-Cards: Aktive Events, Ungeprüfte Photos, Tasks-Fortschritt.
|
- **Tasks**: Tabs (Bibliothek, Zuweisungen), Drag-and-Drop (React Beautiful DnD), Inline-Editor für Aufgaben.
|
||||||
- Quick-Actions: "Neues Event erstellen", "Photos moderieren".
|
- **Einstellungen**: Accordion-Struktur (Branding, Legal Pages, Benachrichtigungen, Abrechnung). Preview-Panel für Farben und Logos.
|
||||||
- **Framework7-Komponenten**:
|
- **Abrechnung**: Kreditübersicht, Kauflog (infinite-scroll), Zahlungsoptionen (Stripe Karte, PayPal Checkout).
|
||||||
- `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`.
|
|
||||||
|
|
||||||
### 3. Events-Übersicht
|
## Informationsarchitektur (aktuelle React-Router-Konfiguration)
|
||||||
- **Zweck**: Liste aller Events mit Filter (aktiv/archiviert), Suche.
|
```
|
||||||
- **Layout**:
|
/event-admin
|
||||||
- Navbar: Suche-Feld, Filter-Button (Dropdown: Status, Datum).
|
├── dashboard
|
||||||
- Infinite-Scroll-Liste von Event-Cards (Titel, Datum, Status-Tag, Photo-Count).
|
├── events
|
||||||
- FAB: "+" für Neues Event.
|
│ ├── :slug (Detailseiten, Tabs: Overview, Tasks, Media, Members, Stats)
|
||||||
- **Framework7-Komponenten**:
|
│ └── :slug/edit
|
||||||
- `f7-searchbar` in Navbar.
|
├── tasks
|
||||||
- `f7-list` mit `f7-list-item` (Thumbnail, Title, Subtitle: Datum, Badge: Status).
|
├── members
|
||||||
- `f7-fab` (floating action button).
|
├── settings
|
||||||
- `f7-segmented` für Filter-Tabs.
|
├── billing
|
||||||
- **Wireframe-Beschreibung**:
|
└── welcome
|
||||||
```
|
├── (index) # Hero + How It Works Carousel
|
||||||
[Navbar: Suche | Filter ▼]
|
├── packages
|
||||||
[Segmented: Alle | Aktiv | Archiviert]
|
├── summary
|
||||||
[List-Item: [Thumb] Hochzeit Müller (15.09.) [Tag: Aktiv] 45 Photos]
|
└── event
|
||||||
[List-Item: ...]
|
```
|
||||||
[+ FAB unten rechts]
|
|
||||||
```
|
|
||||||
- **API**: `GET /tenant/events?page=1&status=active` (paginiert).
|
|
||||||
|
|
||||||
### 4. Event-Details-Seite
|
## Testabdeckung (UI)
|
||||||
- **Zweck**: Vollständige Event-Info, Edit-Modus, zugehörige Tasks/Photos.
|
- **Jest/RTL**: `TenantWelcomeLayout`, `WelcomeStepCard`, `PackageSelection`, `OnboardingGuard`.
|
||||||
- **Layout**:
|
- **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.
|
||||||
- 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).
|
|
||||||
|
|
||||||
### 5. Photo-Gallery-Seite (pro Event)
|
## Legacy-Referenz (Framework7 Entwurf 2025-09)
|
||||||
- **Zweck**: Moderation von Photos; Grid-View mit Lightbox.
|
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.
|
||||||
- **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).
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
23
docs/screenshots/tenant-admin-onboarding/01-welcome-hero.svg
Normal file
23
docs/screenshots/tenant-admin-onboarding/01-welcome-hero.svg
Normal 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 |
33
docs/screenshots/tenant-admin-onboarding/02-how-it-works.svg
Normal file
33
docs/screenshots/tenant-admin-onboarding/02-how-it-works.svg
Normal 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 |
@@ -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 |
@@ -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 |
43
docs/screenshots/tenant-admin-onboarding/05-event-setup.svg
Normal file
43
docs/screenshots/tenant-admin-onboarding/05-event-setup.svg
Normal 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 |
11
docs/screenshots/tenant-admin-onboarding/README.md
Normal file
11
docs/screenshots/tenant-admin-onboarding/README.md
Normal 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.
|
||||||
@@ -3,28 +3,33 @@
|
|||||||
## Goal
|
## Goal
|
||||||
Replace slug-based guest access with opaque, revocable join tokens and provide printable QR layouts tied to those tokens.
|
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
|
## Phase 1 – Data & Backend
|
||||||
- [x] Create `event_join_tokens` table (token, event_id, usage_limit/count, expires_at, revoked_at, created_by).
|
- [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] Add Eloquent model + relations (`Event::joinTokens()`), factory, and seed helper.
|
||||||
- [x] Implement service for token generation/rotation (secure RNG, audit logging).
|
- [x] Implement service for token generation/rotation (secure RNG, audit logging).
|
||||||
- [x] Expose tenant API endpoints for listing/creating/revoking tokens.
|
- [x] Expose tenant API endpoints for listing/creating/revoking tokens.
|
||||||
- [x] Introduce middleware/controller updates so guest API resolves `/e/{token}` → event.
|
- [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
|
## Phase 2 – Guest PWA
|
||||||
- [x] Update router and data loaders to use `:token` paths.
|
- [x] Update router and data loaders to use `:token` paths.
|
||||||
- [x] Adjust storage/cache keys to use token identifiers.
|
- [x] Adjust storage/cache keys to use token identifiers.
|
||||||
- [ ] Display friendly error states for expired/invalid tokens.
|
- [x] Display friendly error states for expired/invalid tokens.
|
||||||
- [ ] Regression-test photo upload, likes, and stats flows via token.
|
- [x] Regression-test photo upload, likes, and stats flows via token.
|
||||||
|
|
||||||
## Phase 3 – Tenant Admin UX
|
## Phase 3 – Tenant Admin UX
|
||||||
- [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke).
|
- [x] Build “QR & Invites” management UI (list tokens, usage stats, rotate/revoke).
|
||||||
- [x] Hook Filament action + PWA screens to call new token endpoints.
|
- [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.
|
- [ ] Deprecate slug-based QR view; link tenants to new flow.
|
||||||
|
|
||||||
## Phase 4 – Migration & Cleanup
|
## 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.
|
- [ ] Remove slug parameters from public endpoints once traffic confirms token usage.
|
||||||
- [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process.
|
- [ ] Update documentation (PRP, onboarding guides, runbooks) to reflect token process.
|
||||||
- [ ] Add feature/integration tests covering expiry, rotation, and guest flows.
|
- [ ] Add feature/integration tests covering expiry, rotation, and guest flows.
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ Owner: Codex (handoff)
|
|||||||
|
|
||||||
## Priority: Later ( polish + delivery )
|
## 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.
|
- [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.
|
- [x] Review PWA manifest/offline setup so die kombinierte Welcome+Management-Experience TWA-/Capacitor-ready ist (Manifest + `admin-sw.js` dokumentiert).
|
||||||
- [ ] 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.
|
- [x] Extend docs: PRP-Onboarding-Abschnitte aktualisiert, Screenshots unter `docs/screenshots/tenant-admin-onboarding/` ergänzt, Testscope notiert.
|
||||||
- [ ] 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.
|
- [x] Add automated coverage: Vitest + Testing Library für Welcome Landing, Dashboard-Guard und Checkout-Komponenten; `npm run test:unit` führt Suite aus.
|
||||||
- [ ] 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] 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.
|
- [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
|
## Risks & Open Questions
|
||||||
|
|||||||
2151
package-lock.json
generated
2151
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -10,12 +10,16 @@
|
|||||||
"format:check": "prettier --check resources/",
|
"format:check": "prettier --check resources/",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"types": "tsc --noEmit",
|
"types": "tsc --noEmit",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
"@laravel/vite-plugin-wayfinder": "^0.1.7",
|
||||||
"@playwright/test": "^1.55.0",
|
"@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",
|
"@types/node": "^22.13.5",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -27,7 +31,9 @@
|
|||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"shadcn": "^3.3.1",
|
"shadcn": "^3.3.1",
|
||||||
"typescript-eslint": "^8.23.0"
|
"typescript-eslint": "^8.23.0",
|
||||||
|
"vitest": "^2.1.5",
|
||||||
|
"jsdom": "^25.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
|
|||||||
99
public/admin-sw.js
Normal file
99
public/admin-sw.js
Normal 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
43
public/manifest.json
Normal 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
|
||||||
|
}
|
||||||
@@ -2,6 +2,21 @@ import { authorizedFetch } from './auth/tokens';
|
|||||||
|
|
||||||
type JsonValue = Record<string, any>;
|
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 = {
|
export type TenantEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | Record<string, string>;
|
name: string | Record<string, string>;
|
||||||
@@ -139,6 +154,8 @@ export type EventJoinToken = {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
|
layouts: EventJoinTokenLayout[];
|
||||||
|
layouts_url: string | null;
|
||||||
};
|
};
|
||||||
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
|
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
|
||||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||||
@@ -271,6 +288,30 @@ function normalizeMember(member: JsonValue): EventMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
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 {
|
return {
|
||||||
id: Number(raw.id ?? 0),
|
id: Number(raw.id ?? 0),
|
||||||
token: String(raw.token ?? ''),
|
token: String(raw.token ?? ''),
|
||||||
@@ -283,6 +324,8 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
|||||||
is_active: Boolean(raw.is_active),
|
is_active: Boolean(raw.is_active),
|
||||||
created_at: raw.created_at ?? null,
|
created_at: raw.created_at ?? null,
|
||||||
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
|
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
|
||||||
|
layouts,
|
||||||
|
layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json';
|
|||||||
import enOnboarding from './locales/en/onboarding.json';
|
import enOnboarding from './locales/en/onboarding.json';
|
||||||
import deManagement from './locales/de/management.json';
|
import deManagement from './locales/de/management.json';
|
||||||
import enManagement from './locales/en/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';
|
const DEFAULT_NAMESPACE = 'common';
|
||||||
|
|
||||||
@@ -19,12 +21,14 @@ const resources = {
|
|||||||
dashboard: deDashboard,
|
dashboard: deDashboard,
|
||||||
onboarding: deOnboarding,
|
onboarding: deOnboarding,
|
||||||
management: deManagement,
|
management: deManagement,
|
||||||
|
auth: deAuth,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
common: enCommon,
|
common: enCommon,
|
||||||
dashboard: enDashboard,
|
dashboard: enDashboard,
|
||||||
onboarding: enOnboarding,
|
onboarding: enOnboarding,
|
||||||
management: enManagement,
|
management: enManagement,
|
||||||
|
auth: enAuth,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
10
resources/js/admin/i18n/locales/de/auth.json
Normal file
10
resources/js/admin/i18n/locales/de/auth.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
"successTitle": "Gratis-Paket aktiviert",
|
"successTitle": "Gratis-Paket aktiviert",
|
||||||
"successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
|
"successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
|
||||||
"failureTitle": "Aktivierung fehlgeschlagen",
|
"failureTitle": "Aktivierung fehlgeschlagen",
|
||||||
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden.",
|
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
||||||
},
|
},
|
||||||
"stripe": {
|
"stripe": {
|
||||||
"sectionTitle": "Kartenzahlung (Stripe)",
|
"sectionTitle": "Kartenzahlung (Stripe)",
|
||||||
|
|||||||
10
resources/js/admin/i18n/locales/en/auth.json
Normal file
10
resources/js/admin/i18n/locales/en/auth.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ initializeTheme();
|
|||||||
const rootEl = document.getElementById('root')!;
|
const rootEl = document.getElementById('root')!;
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/admin-sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -633,3 +633,5 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
</TenantWelcomeLayout>
|
</TenantWelcomeLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { StripeCheckoutForm, PayPalCheckout };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -260,25 +260,41 @@ export default function EventDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardHeader className="space-y-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen
|
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-slate-600">
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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">
|
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
||||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||||
Einladungslink erzeugen
|
Join-Token erzeugen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
|
||||||
{inviteLink}
|
{inviteLink}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{tokens.length > 0 ? (
|
{tokens.length > 0 ? (
|
||||||
tokens.map((token) => (
|
tokens.map((token) => (
|
||||||
@@ -291,9 +307,10 @@ export default function EventDetailPage() {
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-slate-500">
|
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
||||||
Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren.
|
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
|
||||||
</p>
|
herunterzuladen.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -366,9 +383,11 @@ function JoinTokenRow({
|
|||||||
revoking: boolean;
|
revoking: boolean;
|
||||||
}) {
|
}) {
|
||||||
const status = getTokenStatus(token);
|
const status = getTokenStatus(token);
|
||||||
|
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||||
|
|
||||||
return (
|
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="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
|
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
|
||||||
<span
|
<span
|
||||||
@@ -392,8 +411,81 @@ function JoinTokenRow({
|
|||||||
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
|
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
|
||||||
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
|
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
|
||||||
</div>
|
</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 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>
|
||||||
<div className="flex gap-2">
|
<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">
|
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||||
Kopieren
|
Kopieren
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -197,14 +197,17 @@ export default function EventFormPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="event-slug">Slug / URL-Endung</Label>
|
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
|
||||||
<Input
|
<Input
|
||||||
id="event-slug"
|
id="event-slug"
|
||||||
placeholder="sommerfest-2025"
|
placeholder="sommerfest-2025"
|
||||||
value={form.slug}
|
value={form.slug}
|
||||||
onChange={(e) => handleSlugChange(e.target.value)}
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="event-date">Datum</Label>
|
<Label htmlFor="event-date">Datum</Label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -156,9 +156,9 @@ function EventCard({ event }: { event: TenantEvent }) {
|
|||||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||||
<a href={`/e/${slug}`} target="_blank" rel="noreferrer">
|
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#join-invites`}>
|
||||||
Oeffnen im Gastportal
|
<Share2 className="h-3.5 w-3.5" /> Einladungen
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_HOME_PATH } from '../constants';
|
import { ADMIN_HOME_PATH } from '../constants';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
from?: Location;
|
from?: Location;
|
||||||
@@ -11,6 +12,7 @@ interface LocationState {
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { status, login } = useAuth();
|
const { status, login } = useAuth();
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||||
@@ -36,17 +38,14 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
|
<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">
|
<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 />
|
<AppearanceToggleDropdown />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 text-sm text-muted-foreground">
|
<div className="space-y-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>{t('login.lead')}</p>
|
||||||
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
|
|
||||||
wieder zur Admin-Oberflaeche gebracht.
|
|
||||||
</p>
|
|
||||||
{oauthError && (
|
{oauthError && (
|
||||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -54,7 +53,7 @@ export default function LoginPage() {
|
|||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
onClick={() => login(redirectTarget)}
|
onClick={() => login(redirectTarget)}
|
||||||
>
|
>
|
||||||
{status === 'loading' ? 'Bitte warten ...' : 'Mit Tenant-Account anmelden'}
|
{status === 'loading' ? t('login.loading') : t('login.cta')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
import { NavLink, useParams, useLocation } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
|
||||||
import { useEventData } from '../hooks/useEventData';
|
import { useEventData } from '../hooks/useEventData';
|
||||||
|
|
||||||
function TabLink({
|
function TabLink({
|
||||||
to,
|
to,
|
||||||
children,
|
children,
|
||||||
isActive
|
isActive,
|
||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -31,9 +30,11 @@ function TabLink({
|
|||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const location = useLocation();
|
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 base = `/e/${encodeURIComponent(token)}`;
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
const locale = event?.default_locale || 'de';
|
const locale = event?.default_locale || 'de';
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { event, loading: eventLoading, error: eventError } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined;
|
const guestName =
|
||||||
const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
|
identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
|
||||||
|
|
||||||
if (eventLoading) {
|
if (status === 'loading') {
|
||||||
return (
|
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="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>
|
<div className="font-semibold">Lade Event...</div>
|
||||||
@@ -44,18 +44,13 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventError || !event) {
|
if (status !== 'ready' || !event) {
|
||||||
return (
|
return null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats =
|
||||||
|
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
|
||||||
|
|
||||||
const getEventAvatar = (event: any) => {
|
const getEventAvatar = (event: any) => {
|
||||||
if (event.type?.icon) {
|
if (event.type?.icon) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,40 +1,90 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
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 { token } = useParams<{ token: string }>();
|
||||||
const [event, setEvent] = useState<EventData | null>(null);
|
const [event, setEvent] = useState<EventData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [status, setStatus] = useState<EventDataStatus>(token ? 'loading' : 'error');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
|
||||||
|
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('No event token provided');
|
setEvent(null);
|
||||||
setLoading(false);
|
setStatus('error');
|
||||||
|
setErrorCode('invalid_token');
|
||||||
|
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const loadEvent = async () => {
|
const loadEvent = async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setErrorCode(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const eventData = await fetchEvent(token);
|
const eventData = await fetchEvent(token);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
|
setStatus('ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load event:', err);
|
if (cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load event');
|
return;
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
|
||||||
|
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();
|
loadEvent();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
loading,
|
status,
|
||||||
error,
|
loading: status === 'loading',
|
||||||
|
error: errorMessage,
|
||||||
|
errorCode,
|
||||||
|
token: token ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export function usePollStats(eventKey: string | null | undefined) {
|
|||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
if (res.status === 304) return;
|
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();
|
const json: StatsResponse = await res.json();
|
||||||
setData({
|
setData({
|
||||||
onlineGuests: json.online_guests ?? 0,
|
onlineGuests: json.online_guests ?? 0,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
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 Header from './components/Header';
|
||||||
import BottomNav from './components/BottomNav';
|
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 { EventStatsProvider } from './context/EventStatsContext';
|
||||||
import { GuestIdentityProvider } from './context/GuestIdentityContext';
|
import { GuestIdentityProvider } from './context/GuestIdentityContext';
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
@@ -36,15 +40,7 @@ function HomeLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GuestIdentityProvider eventKey={token}>
|
<GuestIdentityProvider eventKey={token}>
|
||||||
<EventStatsProvider eventKey={token}>
|
<EventBoundary token={token} />
|
||||||
<div className="pb-16">
|
|
||||||
<Header slug={token} />
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
|
||||||
</EventStatsProvider>
|
|
||||||
</GuestIdentityProvider>
|
</GuestIdentityProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,6 +74,30 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '*', element: <NotFoundPage /> },
|
{ 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() {
|
function SetupLayout() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
if (!token) return null;
|
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 }) {
|
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
|
|||||||
@@ -34,10 +34,138 @@ export interface EventStats {
|
|||||||
latestPhotoAt: string | null;
|
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> {
|
export async function fetchEvent(eventKey: string): Promise<EventData> {
|
||||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
|
try {
|
||||||
if (!res.ok) throw new Error('Event fetch failed');
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
|
||||||
return await res.json();
|
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> {
|
export async function fetchStats(eventKey: string): Promise<EventStats> {
|
||||||
|
|||||||
@@ -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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 { Sun, Moon, Menu, X, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
} from '@/components/ui/navigation-menu';
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const { auth } = usePage().props as any;
|
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">
|
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
Die Fotospiel.App
|
Die Fotospiel.App
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden lg:flex items-center space-x-8">
|
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
|
||||||
{navItems.map((item) => (
|
<NavigationMenuList className="gap-2">
|
||||||
item.children ? (
|
{navItems.map((item) => (
|
||||||
<DropdownMenu key={item.key}>
|
<NavigationMenuItem key={item.key}>
|
||||||
<DropdownMenuTrigger asChild>
|
{item.children ? (
|
||||||
<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">
|
<>
|
||||||
{item.label}
|
<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">
|
||||||
</Button>
|
{item.label}
|
||||||
</DropdownMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<NavigationMenuContent className="min-w-[220px] rounded-md border bg-popover p-3 shadow-lg">
|
||||||
{item.children.map((child) => (
|
<ul className="flex flex-col gap-1">
|
||||||
<DropdownMenuItem asChild key={child.key}>
|
{item.children.map((child) => (
|
||||||
<Link href={child.href} className="w-full flex items-center">
|
<li key={child.key}>
|
||||||
{child.label}
|
<NavigationMenuLink asChild>
|
||||||
</Link>
|
<Link
|
||||||
</DropdownMenuItem>
|
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"
|
||||||
</DropdownMenuContent>
|
>
|
||||||
</DropdownMenu>
|
{child.label}
|
||||||
) : (
|
<ChevronRight className="h-4 w-4" />
|
||||||
<Button
|
</Link>
|
||||||
asChild
|
</NavigationMenuLink>
|
||||||
key={item.key}
|
</li>
|
||||||
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"
|
</ul>
|
||||||
>
|
</NavigationMenuContent>
|
||||||
<Link href={item.href}>{item.label}</Link>
|
</>
|
||||||
</Button>
|
) : (
|
||||||
)
|
<NavigationMenuLink asChild>
|
||||||
))}
|
<Link
|
||||||
</nav>
|
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">
|
<div className="hidden lg:flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -180,18 +203,18 @@ const Header: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild className="font-sans-marketing">
|
||||||
<Link href={localizedPath('/profile')}>
|
<Link href={localizedPath('/profile')}>
|
||||||
Profil
|
Profil
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild className="font-sans-marketing">
|
||||||
<Link href={localizedPath('/profile/orders')}>
|
<Link href={localizedPath('/profile/orders')}>
|
||||||
Bestellungen
|
Bestellungen
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout} className="font-sans-marketing">
|
||||||
Abmelden
|
Abmelden
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -200,16 +223,10 @@ const Header: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={localizedPath('/login')}
|
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')}
|
{t('header.login')}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
@@ -229,31 +246,40 @@ const Header: React.FC = () => {
|
|||||||
<SheetHeader className="text-left">
|
<SheetHeader className="text-left">
|
||||||
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
|
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="flex flex-col gap-4">
|
<nav className="flex flex-col gap-2">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
item.children ? (
|
item.children ? (
|
||||||
<div key={item.key} className="space-y-2">
|
<Accordion
|
||||||
<p className="text-sm font-semibold uppercase text-muted-foreground">{item.label}</p>
|
key={item.key}
|
||||||
<div className="flex flex-col gap-2">
|
type="single"
|
||||||
{item.children.map((child) => (
|
collapsible
|
||||||
<SheetClose asChild key={child.key}>
|
className="w-full"
|
||||||
<Link
|
>
|
||||||
href={child.href}
|
<AccordionItem value={`${item.key}-group`}>
|
||||||
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"
|
<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">
|
||||||
onClick={handleNavSelect}
|
{item.label}
|
||||||
>
|
</AccordionTrigger>
|
||||||
<span>{child.label}</span>
|
<AccordionContent className="flex flex-col gap-2 pt-2">
|
||||||
<ChevronRight className="h-4 w-4" />
|
{item.children.map((child) => (
|
||||||
</Link>
|
<SheetClose asChild key={child.key}>
|
||||||
</SheetClose>
|
<Link
|
||||||
))}
|
href={child.href}
|
||||||
</div>
|
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"
|
||||||
</div>
|
onClick={handleNavSelect}
|
||||||
|
>
|
||||||
|
<span>{child.label}</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</SheetClose>
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
) : (
|
) : (
|
||||||
<SheetClose asChild key={item.key}>
|
<SheetClose asChild key={item.key}>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
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}
|
onClick={handleNavSelect}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -295,7 +321,7 @@ const Header: React.FC = () => {
|
|||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Link
|
<Link
|
||||||
href={localizedPath('/profile')}
|
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}
|
onClick={handleNavSelect}
|
||||||
>
|
>
|
||||||
Profil
|
Profil
|
||||||
@@ -304,13 +330,13 @@ const Header: React.FC = () => {
|
|||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Link
|
<Link
|
||||||
href={localizedPath('/profile/orders')}
|
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}
|
onClick={handleNavSelect}
|
||||||
>
|
>
|
||||||
Bestellungen
|
Bestellungen
|
||||||
</Link>
|
</Link>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
<Button variant="destructive" onClick={handleLogout}>
|
<Button variant="destructive" onClick={handleLogout} className="font-sans-marketing">
|
||||||
Abmelden
|
Abmelden
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -319,21 +345,12 @@ const Header: React.FC = () => {
|
|||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Link
|
<Link
|
||||||
href={localizedPath('/login')}
|
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}
|
onClick={handleNavSelect}
|
||||||
>
|
>
|
||||||
{t('header.login')}
|
{t('header.login')}
|
||||||
</Link>
|
</Link>
|
||||||
</SheetClose>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
22
resources/js/setupTests.ts
Normal file
22
resources/js/setupTests.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@ return [
|
|||||||
'table' => [
|
'table' => [
|
||||||
'tenant' => 'Mandant',
|
'tenant' => 'Mandant',
|
||||||
'join' => 'Beitreten',
|
'join' => 'Beitreten',
|
||||||
|
'join_tokens_total' => 'Join-Tokens: :count',
|
||||||
|
'join_tokens_missing' => 'Noch keine Join-Tokens erstellt',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
'toggle_active' => 'Aktiv umschalten',
|
'toggle_active' => 'Aktiv umschalten',
|
||||||
@@ -82,9 +84,21 @@ return [
|
|||||||
'join_link_copied' => 'Beitrittslink kopiert',
|
'join_link_copied' => 'Beitrittslink kopiert',
|
||||||
],
|
],
|
||||||
'join_link' => [
|
'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',
|
'link_label' => 'Beitrittslink',
|
||||||
|
'copy_link' => 'Kopieren',
|
||||||
|
'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.',
|
||||||
|
'token_default' => 'Einladung #:id',
|
||||||
|
'token_usage' => 'Nutzung: :usage / :limit',
|
||||||
|
'token_active' => 'Aktiv',
|
||||||
|
'token_inactive' => 'Deaktiviert',
|
||||||
'qr_code_label' => 'QR‑Code',
|
'qr_code_label' => 'QR‑Code',
|
||||||
'note_html' => 'Hinweis: Der QR‑Code wird über einen externen QR‑Service generiert. Für eine selbst gehostete Lösung können wir später eine interne QR‑Generierung ergänzen.',
|
'note_html' => 'Hinweis: Der QR‑Code wird über einen externen QR‑Service generiert. Für eine selbst gehostete Lösung können wir später eine interne QR‑Generierung ergänzen.',
|
||||||
|
'layouts_heading' => 'Drucklayouts',
|
||||||
|
'layouts_fallback' => 'Layout-Übersicht öffnen',
|
||||||
|
'token_expiry' => 'Läuft ab am :date',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -219,4 +233,13 @@ return [
|
|||||||
'shell' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant‑Admin',
|
'tenant_admin_title' => 'Tenant‑Admin',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'errors' => [
|
||||||
|
'forbidden' => [
|
||||||
|
'title' => 'Kein Zugriff',
|
||||||
|
'message' => 'Du hast keine Berechtigung, diesen Bereich des Admin-Panels zu öffnen.',
|
||||||
|
'hint' => 'Bitte prüfe, ob dein Mandantenpaket aktiv ist oder wende dich an den Support, wenn du Hilfe benötigst.',
|
||||||
|
'cta' => 'Zur Startseite',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'username_or_email' => 'Username oder E-Mail',
|
'username_or_email' => 'Username oder E-Mail',
|
||||||
'password' => 'Passwort',
|
'password' => 'Passwort',
|
||||||
'remember' => 'Angemeldet bleiben',
|
'remember' => 'Angemeldet bleiben',
|
||||||
|
'remember_me' => 'Angemeldet bleiben',
|
||||||
'submit' => 'Anmelden',
|
'submit' => 'Anmelden',
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ return [
|
|||||||
'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.',
|
'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.',
|
||||||
'resend' => 'E-Mail erneut senden',
|
'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' => [
|
'header' => [
|
||||||
'home' => 'Startseite',
|
'home' => 'Startseite',
|
||||||
'packages' => 'Pakete',
|
'packages' => 'Pakete',
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ return [
|
|||||||
'table' => [
|
'table' => [
|
||||||
'tenant' => 'Tenant',
|
'tenant' => 'Tenant',
|
||||||
'join' => 'Join',
|
'join' => 'Join',
|
||||||
|
'join_tokens_total' => 'Join tokens: :count',
|
||||||
|
'join_tokens_missing' => 'No join tokens created yet',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
'toggle_active' => 'Toggle Active',
|
'toggle_active' => 'Toggle Active',
|
||||||
@@ -82,9 +84,20 @@ return [
|
|||||||
'join_link_copied' => 'Join link copied',
|
'join_link_copied' => 'Join link copied',
|
||||||
],
|
],
|
||||||
'join_link' => [
|
'join_link' => [
|
||||||
|
'event_label' => 'Event',
|
||||||
|
'slug_label' => 'Slug: :slug',
|
||||||
'link_label' => 'Join Link',
|
'link_label' => 'Join Link',
|
||||||
'qr_code_label' => 'QR Code',
|
'copy_link' => 'Copy',
|
||||||
'note_html' => 'Note: The QR code is generated via an external QR service. For a self-hosted option, we can add internal generation later.',
|
'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' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant Admin',
|
'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',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,4 +6,14 @@ return [
|
|||||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||||
'login_success' => 'You are now logged in.',
|
'login_success' => 'You are now logged in.',
|
||||||
'login_failed' => 'These credentials do not match our records.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|||||||
12
resources/lang/vendor/filament-panels/de/pages/auth/login.php
vendored
Normal file
12
resources/lang/vendor/filament-panels/de/pages/auth/login.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'Tenant-Login',
|
||||||
|
'form' => [
|
||||||
|
'actions' => [
|
||||||
|
'authenticate' => [
|
||||||
|
'label' => 'Anmelden',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
12
resources/lang/vendor/filament-panels/en/pages/auth/login.php
vendored
Normal file
12
resources/lang/vendor/filament-panels/en/pages/auth/login.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'Tenant Login',
|
||||||
|
'form' => [
|
||||||
|
'actions' => [
|
||||||
|
'authenticate' => [
|
||||||
|
'label' => 'Sign in',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>{{ __('admin.shell.tenant_admin_title') }}</title>
|
<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
|
@viteReactRefresh
|
||||||
@vite('resources/js/admin/main.tsx')
|
@vite('resources/js/admin/main.tsx')
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
24
resources/views/errors/403.blade.php
Normal file
24
resources/views/errors/403.blade.php
Normal 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>
|
||||||
@@ -1,16 +1,125 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-5">
|
||||||
<div class="text-sm">{{ __('admin.events.join_link.link_label') }}</div>
|
<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="rounded border bg-gray-50 p-2 text-sm dark:bg-gray-900">
|
<div class="flex flex-col gap-1">
|
||||||
<a href="{{ $link }}" target="_blank" class="underline">
|
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{{ __('admin.events.join_link.event_label') }}</div>
|
||||||
{{ $link }}
|
<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>
|
</a>
|
||||||
</div>
|
</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) !!}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
{!! __('admin.events.join_link.note_html') !!}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
@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>
|
||||||
|
@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>
|
||||||
|
|||||||
206
resources/views/layouts/join-token/pdf.blade.php
Normal file
206
resources/views/layouts/join-token/pdf.blade.php
Normal 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’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>
|
||||||
159
resources/views/layouts/join-token/svg.blade.php
Normal file
159
resources/views/layouts/join-token/svg.blade.php
Normal 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>
|
||||||
|
– 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>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\Api\EventPublicController;
|
use App\Http\Controllers\Api\EventPublicController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventController;
|
use App\Http\Controllers\Api\Tenant\EventController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
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\SettingsController;
|
||||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
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::middleware('throttle:100,1')->group(function () {
|
||||||
Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
|
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
||||||
Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||||
Route::get('/events/{slug}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
|
Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
|
||||||
Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
||||||
Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
||||||
Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
|
Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
|
||||||
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
|
||||||
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
|
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 () {
|
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::prefix('join-tokens')->group(function () {
|
||||||
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
|
Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index');
|
||||||
Route::post('/', [EventJoinTokenController::class, 'store'])->name('tenant.events.join-tokens.store');
|
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'])
|
Route::delete('{joinToken}', [EventJoinTokenController::class, 'destroy'])
|
||||||
->whereNumber('joinToken')
|
->whereNumber('joinToken')
|
||||||
->name('tenant.events.join-tokens.destroy');
|
->name('tenant.events.join-tokens.destroy');
|
||||||
|
|||||||
151
tests/Feature/GuestJoinTokenFlowTest.php
Normal file
151
tests/Feature/GuestJoinTokenFlowTest.php
Normal 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
26
vitest.config.ts
Normal 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}'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user