diff --git a/.gitignore b/.gitignore
index d8e47a8..945e31c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,4 @@ yarn-error.log
tools/git-askpass.ps1
docker
podman-compose.dev.yml
+test-results
diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php
index 430317b..7e308a7 100644
--- a/app/Http/Controllers/Api/EventPublicController.php
+++ b/app/Http/Controllers/Api/EventPublicController.php
@@ -279,7 +279,6 @@ class EventPublicController extends BaseController
'photos.likes_count',
'photos.emotion_id',
'photos.task_id',
- 'photos.created_at',
'photos.guest_name',
'tasks.title as task_title'
])
@@ -503,4 +502,220 @@ class EventPublicController extends BaseController
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
}
+ public function achievements(Request $request, string $slug)
+ {
+ $event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
+ if (! $event) {
+ return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
+ }
+
+ $eventId = $event->id;
+ $locale = $request->query('locale', 'de');
+
+ $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
+ $uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name');
+ $tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
+ $likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count');
+
+ $summary = [
+ 'total_photos' => $totalPhotos,
+ 'unique_guests' => $uniqueGuests,
+ 'tasks_solved' => $tasksSolved,
+ 'likes_total' => $likesTotal,
+ ];
+
+ $guestNameParam = trim((string) $request->query('guest_name', ''));
+ $deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
+ $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120);
+ $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId;
+ $guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null;
+
+ $personal = null;
+ if ($guestIdentifier) {
+ $personalPhotos = (int) DB::table('photos')
+ ->where('event_id', $eventId)
+ ->where('guest_name', $guestIdentifier)
+ ->count();
+
+ $personalTasks = (int) DB::table('photos')
+ ->where('event_id', $eventId)
+ ->where('guest_name', $guestIdentifier)
+ ->whereNotNull('task_id')
+ ->distinct('task_id')
+ ->count('task_id');
+
+ $personalLikes = (int) DB::table('photos')
+ ->where('event_id', $eventId)
+ ->where('guest_name', $guestIdentifier)
+ ->sum('likes_count');
+
+ $badgeBlueprints = [
+ ['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'],
+ ['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'],
+ ['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'],
+ ['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'],
+ ['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'],
+ ];
+
+ $personalBadges = [];
+ foreach ($badgeBlueprints as $badge) {
+ $value = 0;
+ if ($badge['metric'] === 'photos') {
+ $value = $personalPhotos;
+ } elseif ($badge['metric'] === 'tasks') {
+ $value = $personalTasks;
+ } else {
+ $value = $personalLikes;
+ }
+
+ $personalBadges[] = [
+ 'id' => $badge['id'],
+ 'title' => $badge['title'],
+ 'description' => $badge['description'],
+ 'earned' => $value >= $badge['target'],
+ 'progress' => $value,
+ 'target' => $badge['target'],
+ ];
+ }
+
+ $personal = [
+ 'guest_name' => $guestIdentifier,
+ 'photos' => $personalPhotos,
+ 'tasks' => $personalTasks,
+ 'likes' => $personalLikes,
+ 'badges' => $personalBadges,
+ ];
+ }
+
+ $topUploads = DB::table('photos')
+ ->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes'))
+ ->where('event_id', $eventId)
+ ->groupBy('guest_name')
+ ->orderByDesc('total')
+ ->limit(5)
+ ->get()
+ ->map(fn ($row) => [
+ 'guest' => $row->guest_name,
+ 'photos' => (int) $row->total,
+ 'likes' => (int) $row->likes,
+ ])
+ ->values();
+
+ $topLikes = DB::table('photos')
+ ->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total'))
+ ->where('event_id', $eventId)
+ ->groupBy('guest_name')
+ ->orderByDesc('likes')
+ ->limit(5)
+ ->get()
+ ->map(fn ($row) => [
+ 'guest' => $row->guest_name,
+ 'likes' => (int) $row->likes,
+ 'photos' => (int) $row->total,
+ ])
+ ->values();
+
+ $topPhotoRow = DB::table('photos')
+ ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
+ ->where('photos.event_id', $eventId)
+ ->orderByDesc('photos.likes_count')
+ ->orderByDesc('photos.created_at')
+ ->select([
+ 'photos.id',
+ 'photos.guest_name',
+ 'photos.likes_count',
+ 'photos.file_path',
+ 'photos.thumbnail_path',
+ 'photos.created_at',
+ 'tasks.title as task_title',
+ ])
+ ->first();
+
+ $topPhoto = $topPhotoRow ? [
+ 'photo_id' => (int) $topPhotoRow->id,
+ 'guest' => $topPhotoRow->guest_name,
+ 'likes' => (int) $topPhotoRow->likes_count,
+ 'task' => $topPhotoRow->task_title,
+ 'created_at' => $topPhotoRow->created_at,
+ 'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
+ ] : null;
+
+ $trendingEmotionRow = DB::table('photos')
+ ->join('emotions', 'photos.emotion_id', '=', 'emotions.id')
+ ->where('photos.event_id', $eventId)
+ ->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total'))
+ ->groupBy('emotions.id', 'emotions.name')
+ ->orderByDesc('total')
+ ->first();
+
+ $trendingEmotion = $trendingEmotionRow ? [
+ 'emotion_id' => (int) $trendingEmotionRow->id,
+ 'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name),
+ 'count' => (int) $trendingEmotionRow->total,
+ ] : null;
+
+ $timeline = DB::table('photos')
+ ->where('event_id', $eventId)
+ ->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests')
+ ->groupBy('day')
+ ->orderBy('day')
+ ->limit(14)
+ ->get()
+ ->map(fn ($row) => [
+ 'date' => $row->day,
+ 'photos' => (int) $row->photos,
+ 'guests' => (int) $row->guests,
+ ])
+ ->values();
+
+ $feed = DB::table('photos')
+ ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
+ ->where('photos.event_id', $eventId)
+ ->orderByDesc('photos.created_at')
+ ->limit(12)
+ ->get([
+ 'photos.id',
+ 'photos.guest_name',
+ 'photos.thumbnail_path',
+ 'photos.file_path',
+ 'photos.likes_count',
+ 'photos.created_at',
+ 'tasks.title as task_title',
+ ])
+ ->map(fn ($row) => [
+ 'photo_id' => (int) $row->id,
+ 'guest' => $row->guest_name,
+ 'task' => $row->task_title,
+ 'likes' => (int) $row->likes_count,
+ 'created_at' => $row->created_at,
+ 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path),
+ ])
+ ->values();
+
+ $payload = [
+ 'summary' => $summary,
+ 'personal' => $personal,
+ 'leaderboards' => [
+ 'uploads' => $topUploads,
+ 'likes' => $topLikes,
+ ],
+ 'highlights' => [
+ 'top_photo' => $topPhoto,
+ 'trending_emotion' => $trendingEmotion,
+ 'timeline' => $timeline,
+ ],
+ 'feed' => $feed,
+ ];
+
+ $etag = sha1(json_encode($payload));
+ $ifNoneMatch = $request->headers->get('If-None-Match');
+ if ($ifNoneMatch && $ifNoneMatch === $etag) {
+ return response('', 304);
+ }
+
+ return response()->json($payload)
+ ->header('Cache-Control', 'no-store')
+ ->header('ETag', $etag);
+ }
}
+
diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php
index 201166f..651e742 100644
--- a/app/Http/Controllers/Api/Tenant/PhotoController.php
+++ b/app/Http/Controllers/Api/Tenant/PhotoController.php
@@ -108,7 +108,7 @@ class PhotoController extends Controller
'width' => null, // To be filled by image processing
'height' => null,
'status' => 'pending', // Requires moderation
- 'uploader_id' => $request->user()->id ?? null,
+ 'uploader_id' => null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
@@ -170,8 +170,8 @@ class PhotoController extends Controller
]);
// Only tenant admins can moderate
- if (isset($validated['status']) && $request->user()->role !== 'admin') {
- return response()->json(['error' => 'Insufficient permissions for moderation'], 403);
+ if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
+ return response()->json(['error' => 'Insufficient scopes'], 403);
}
$photo->update($validated);
@@ -243,7 +243,7 @@ class PhotoController extends Controller
'status' => 'approved',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
- 'moderated_by' => $request->user()->id,
+ 'moderated_by' => null,
]);
// Load approved photos for response
@@ -288,7 +288,7 @@ class PhotoController extends Controller
'status' => 'rejected',
'moderation_notes' => $request->moderation_notes,
'moderated_at' => now(),
- 'moderated_by' => $request->user()->id,
+ 'moderated_by' => null,
]);
// Optionally delete rejected photos from storage
@@ -369,6 +369,17 @@ class PhotoController extends Controller
]);
}
+ private function tokenHasScope(Request $request, string $scope): bool
+ {
+ $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
+
+ if (! is_array($scopes)) {
+ $scopes = array_values(array_filter(explode(' ', (string) $scopes)));
+ }
+
+ return in_array($scope, $scopes, true);
+ }
+
/**
* Generate presigned S3 URL for direct upload (alternative to local storage)
*/
@@ -467,4 +478,10 @@ class PhotoController extends Controller
'status' => 'pending',
], 201);
}
-}
\ No newline at end of file
+}
+
+
+
+
+
+
diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php
index 0150d91..a2d6308 100644
--- a/app/Http/Controllers/OAuthController.php
+++ b/app/Http/Controllers/OAuthController.php
@@ -8,18 +8,22 @@ use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use Illuminate\Http\Request;
+use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-use Illuminate\Validation\ValidationException;
use Firebase\JWT\JWT;
-use Firebase\JWT\Key;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class OAuthController extends Controller
{
+ private const AUTH_CODE_TTL_MINUTES = 5;
+ private const ACCESS_TOKEN_TTL_SECONDS = 3600;
+ private const REFRESH_TOKEN_TTL_DAYS = 30;
+ private const TOKEN_HEADER_KID = 'fotospiel-jwt';
+
/**
* Authorize endpoint - PKCE flow
*/
@@ -28,6 +32,7 @@ class OAuthController extends Controller
$validator = Validator::make($request->all(), [
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
+ 'response_type' => 'required|in:code',
'scope' => 'required|string',
'state' => 'nullable|string',
'code_challenge' => 'required|string',
@@ -38,244 +43,425 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
}
- $client = OAuthClient::where('client_id', $request->client_id)
+ /** @var OAuthClient|null $client */
+ $client = OAuthClient::query()
+ ->where('client_id', $request->string('client_id'))
->where('is_active', true)
->first();
- if (!$client) {
+ if (! $client) {
return $this->errorResponse('Invalid client', 401);
}
- // Validate redirect URI
- $redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true);
- if (!in_array($request->redirect_uri, $redirectUris)) {
+ $allowedRedirects = (array) $client->redirect_uris;
+ if (! in_array($request->redirect_uri, $allowedRedirects, true)) {
return $this->errorResponse('Invalid redirect URI', 400);
}
- // Validate scopes
- $requestedScopes = explode(' ', $request->scope);
- $availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : [];
- $validScopes = array_intersect($requestedScopes, $availableScopes);
-
- if (count($validScopes) !== count($requestedScopes)) {
+ $requestedScopes = $this->parseScopes($request->string('scope'));
+ $availableScopes = (array) $client->scopes;
+ if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) {
return $this->errorResponse('Invalid scopes requested', 400);
}
- // Generate authorization code (PKCE validated later)
- $code = Str::random(40);
- $codeId = Str::uuid();
+ $tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id');
+ if (! $tenantId) {
+ return $this->errorResponse('Unable to resolve tenant for client', 500);
+ }
- // Store code in Redis with 5min TTL
- Cache::put("oauth_code:{$codeId}", [
- 'code' => $code,
- 'client_id' => $request->client_id,
+ $code = Str::random(64);
+ $codeId = (string) Str::uuid();
+ $expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES);
+ $cacheKey = $this->cacheKeyForCode($code);
+
+ Cache::put($cacheKey, [
+ 'id' => $codeId,
+ 'client_id' => $client->client_id,
+ 'tenant_id' => $tenantId,
'redirect_uri' => $request->redirect_uri,
- 'scopes' => $validScopes,
- 'state' => $request->state ?? null,
+ 'scopes' => $requestedScopes,
+ 'state' => $request->state,
'code_challenge' => $request->code_challenge,
'code_challenge_method' => $request->code_challenge_method,
- 'expires_at' => now()->addMinutes(5),
- ], now()->addMinutes(5));
+ 'expires_at' => $expiresAt,
+ ], $expiresAt);
- // Save to DB for persistence
OAuthCode::create([
'id' => $codeId,
+ 'client_id' => $client->client_id,
+ 'user_id' => (string) $tenantId,
'code' => Hash::make($code),
- 'client_id' => $request->client_id,
- 'scopes' => json_encode($validScopes),
- 'state' => $request->state ?? null,
- 'expires_at' => now()->addMinutes(5),
+ 'code_challenge' => $request->code_challenge,
+ 'state' => $request->state,
+ 'redirect_uri' => $request->redirect_uri,
+ 'scope' => implode(' ', $requestedScopes),
+ 'expires_at' => $expiresAt,
]);
- $redirectUrl = $request->redirect_uri . '?' . http_build_query([
+ $redirectUrl = $request->redirect_uri.'?'.http_build_query([
'code' => $code,
- 'state' => $request->state ?? '',
+ 'state' => $request->state,
]);
- return redirect($redirectUrl);
+ return redirect()->away($redirectUrl);
}
/**
- * Token endpoint - Code exchange for access/refresh tokens
+ * Token endpoint - Code exchange & refresh
*/
public function token(Request $request)
+ {
+ $grantType = (string) $request->string('grant_type');
+
+ if ($grantType === 'authorization_code') {
+ return $this->handleAuthorizationCodeGrant($request);
+ }
+
+ if ($grantType === 'refresh_token') {
+ return $this->handleRefreshTokenGrant($request);
+ }
+
+ return $this->errorResponse('Unsupported grant type', 400);
+ }
+
+ /**
+ * Get tenant info based on decoded token
+ */
+ public function me(Request $request)
+ {
+ $decoded = $request->attributes->get('decoded_token');
+ $tenantId = Arr::get($decoded, 'tenant_id');
+
+ if (! $tenantId) {
+ return $this->errorResponse('Unauthenticated', 401);
+ }
+
+ $tenant = Tenant::query()->find($tenantId);
+ if (! $tenant) {
+ Log::error('[OAuth] Tenant not found during token issuance', [
+ 'client_id' => $request->client_id,
+ 'code_id' => $cachedCode['id'] ?? null,
+ 'tenant_id' => $tenantId,
+ ]);
+
+ return $this->errorResponse('Tenant not found', 404);
+ }
+
+ return response()->json([
+ 'id' => $tenant->id,
+ 'tenant_id' => $tenant->id,
+ 'name' => $tenant->name,
+ 'slug' => $tenant->slug,
+ 'email' => $tenant->contact_email,
+ 'event_credits_balance' => $tenant->event_credits_balance,
+ 'subscription_tier' => $tenant->subscription_tier,
+ 'subscription_expires_at' => $tenant->subscription_expires_at,
+ 'features' => $tenant->features,
+ 'scopes' => Arr::get($decoded, 'scopes', []),
+ ]);
+ }
+
+ private function handleAuthorizationCodeGrant(Request $request)
{
$validator = Validator::make($request->all(), [
'grant_type' => 'required|in:authorization_code',
'code' => 'required|string',
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
- 'code_verifier' => 'required|string', // PKCE
+ 'code_verifier' => 'required|string',
]);
if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
}
- // Find the code in cache/DB
- $cachedCode = Cache::get($request->code);
- if (!$cachedCode) {
+ $cacheKey = $this->cacheKeyForCode($request->code);
+ $cachedCode = Cache::get($cacheKey);
+ if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
+ Log::warning('[OAuth] Authorization code missing or expired', [
+ 'client_id' => $request->client_id,
+ 'code_id' => $cachedCode['id'] ?? null,
+ ]);
+
+ return $this->errorResponse('Invalid or expired authorization code', 400);
+ }
+
+ /** @var OAuthCode|null $oauthCode */
+ $oauthCode = OAuthCode::query()->find($cachedCode['id']);
+ if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
+ Log::warning('[OAuth] Authorization code validation failed', [
+ 'client_id' => $request->client_id,
+ 'code_id' => $cachedCode['id'] ?? null,
+ ]);
+
return $this->errorResponse('Invalid authorization code', 400);
}
- $codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []);
- if (!$codeId) {
- $oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first();
- if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) {
- return $this->errorResponse('Invalid authorization code', 400);
- }
- $cachedCode = $oauthCode->toArray();
- $codeId = $oauthCode->id;
- }
-
- // Validate client
- $client = OAuthClient::where('client_id', $request->client_id)->first();
- if (!$client || !$client->is_active) {
+ /** @var OAuthClient|null $client */
+ $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
+ if (! $client) {
return $this->errorResponse('Invalid client', 401);
}
- // PKCE validation
- if ($cachedCode['code_challenge_method'] === 'S256') {
- $expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true));
- if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) {
- return $this->errorResponse('Invalid code verifier', 400);
- }
- } else {
- if (!hash_equals($request->code_verifier, $cachedCode['code'])) {
- return $this->errorResponse('Invalid code verifier', 400);
- }
- }
-
- // Validate redirect URI
- if ($request->redirect_uri !== $cachedCode['redirect_uri']) {
+ if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) {
return $this->errorResponse('Invalid redirect URI', 400);
}
- // Code used - delete/invalidate
- Cache::forget("oauth_code:{$codeId}");
- OAuthCode::where('id', $codeId)->delete();
+ $codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256');
+ $expectedChallenge = $codeChallengeMethod === 'S256'
+ ? $this->base64urlEncode(hash('sha256', $request->code_verifier, true))
+ : $request->code_verifier;
+
+ if (! hash_equals($expectedChallenge, Arr::get($cachedCode, 'code_challenge'))) {
+ return $this->errorResponse('Invalid code verifier', 400);
+ }
+
+ $tenantId = Arr::get($cachedCode, 'tenant_id') ?? $client->tenant_id;
+ $tenant = $tenantId ? Tenant::query()->find($tenantId) : null;
+ if (! $tenant) {
+ Log::error('[OAuth] Tenant not found during token issuance', [
+ 'client_id' => $request->client_id,
+ 'code_id' => $cachedCode['id'] ?? null,
+ 'tenant_id' => $tenantId,
+ ]);
- // Create tenant token (assuming client is tied to tenant - adjust as needed)
- $tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client
- if (!$tenant) {
return $this->errorResponse('Tenant not found', 404);
}
- // Generate JWT Access Token (RS256)
- $privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist
- if (!$privateKey) {
- $this->generateKeyPair();
- $privateKey = file_get_contents(storage_path('app/private.key'));
+ $scopes = Arr::get($cachedCode, 'scopes', []);
+ if (empty($scopes)) {
+ $scopes = $this->parseScopes($oauthCode->scope);
}
- $accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h
- $refreshTokenId = Str::uuid();
- $refreshToken = Str::random(60);
+ Cache::forget($cacheKey);
+ $oauthCode->delete();
- // Store refresh token
- RefreshToken::create([
- 'id' => $refreshTokenId,
- 'token' => Hash::make($refreshToken),
- 'tenant_id' => $tenant->id,
- 'client_id' => $request->client_id,
- 'scopes' => json_encode($cachedCode['scopes']),
- 'expires_at' => now()->addDays(30),
- 'ip_address' => $request->ip(),
+ $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
+
+ return response()->json($tokenResponse);
+ }
+
+ private function handleRefreshTokenGrant(Request $request)
+ {
+ $validator = Validator::make($request->all(), [
+ 'grant_type' => 'required|in:refresh_token',
+ 'refresh_token' => 'required|string',
+ 'client_id' => 'required|string',
]);
- return response()->json([
+ if ($validator->fails()) {
+ return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
+ }
+
+ $tokenParts = explode('|', $request->refresh_token, 2);
+ if (count($tokenParts) !== 2) {
+ return $this->errorResponse('Malformed refresh token', 400);
+ }
+
+ [$refreshTokenId, $refreshTokenSecret] = $tokenParts;
+
+ /** @var RefreshToken|null $storedRefreshToken */
+ $storedRefreshToken = RefreshToken::query()
+ ->where('id', $refreshTokenId)
+ ->whereNull('revoked_at')
+ ->first();
+
+ if (! $storedRefreshToken) {
+ return $this->errorResponse('Invalid refresh token', 400);
+ }
+
+ if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
+ return $this->errorResponse('Refresh token does not match client', 400);
+ }
+
+ if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
+ $storedRefreshToken->update(['revoked_at' => now()]);
+ return $this->errorResponse('Refresh token expired', 400);
+ }
+
+ if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
+ return $this->errorResponse('Invalid refresh token', 400);
+ }
+
+ $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
+ if (! $client) {
+ return $this->errorResponse('Invalid client', 401);
+ }
+
+ $tenant = Tenant::query()->find($storedRefreshToken->tenant_id);
+ if (! $tenant) {
+ Log::error('[OAuth] Tenant not found during token issuance', [
+ 'client_id' => $request->client_id,
+ 'code_id' => $cachedCode['id'] ?? null,
+ 'tenant_id' => $storedRefreshToken->tenant_id,
+ ]);
+
+ return $this->errorResponse('Tenant not found', 404);
+ }
+
+ $scopes = $this->parseScopes($storedRefreshToken->scope);
+
+ $storedRefreshToken->update(['revoked_at' => now()]);
+
+ $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
+
+ return response()->json($tokenResponse);
+ }
+
+ private function issueTokenPair(Tenant $tenant, OAuthClient $client, array $scopes, Request $request): array
+ {
+ $scopes = array_values(array_unique($scopes));
+ $expiresIn = self::ACCESS_TOKEN_TTL_SECONDS;
+ $issuedAt = now();
+ $jti = (string) Str::uuid();
+ $expiresAt = $issuedAt->copy()->addSeconds($expiresIn);
+
+ $accessToken = $this->generateJWT(
+ $tenant->id,
+ $client->client_id,
+ $scopes,
+ 'access',
+ $expiresIn,
+ $jti,
+ $issuedAt->timestamp,
+ $expiresAt->timestamp
+ );
+
+ TenantToken::create([
+ 'id' => (string) Str::uuid(),
+ 'tenant_id' => $tenant->id,
+ 'jti' => $jti,
+ 'token_type' => 'access',
+ 'expires_at' => $expiresAt,
+ ]);
+
+ $refreshToken = $this->createRefreshToken($tenant, $client, $scopes, $jti, $request);
+
+ return [
'token_type' => 'Bearer',
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
- 'expires_in' => 3600,
- 'scope' => implode(' ', $cachedCode['scopes']),
- ]);
+ 'expires_in' => $expiresIn,
+ 'scope' => implode(' ', $scopes),
+ ];
}
- /**
- * Get tenant info
- */
- public function me(Request $request)
+ private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
{
- $user = $request->user();
- if (!$user) {
- return $this->errorResponse('Unauthenticated', 401);
- }
+ $refreshTokenId = (string) Str::uuid();
+ $secret = Str::random(64);
+ $composite = $refreshTokenId.'|'.$secret;
+ $expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
- $tenant = $user->tenant;
- if (!$tenant) {
- return $this->errorResponse('Tenant not found', 404);
- }
-
- return response()->json([
+ RefreshToken::create([
+ 'id' => $refreshTokenId,
'tenant_id' => $tenant->id,
- 'name' => $tenant->name,
- 'slug' => $tenant->slug,
- 'event_credits_balance' => $tenant->event_credits_balance,
- 'subscription_tier' => $tenant->subscription_tier,
- 'subscription_expires_at' => $tenant->subscription_expires_at,
- 'features' => $tenant->features,
- 'scopes' => $request->user()->token()->abilities,
+ 'client_id' => $client->client_id,
+ 'token' => Hash::make($secret),
+ 'access_token' => $accessTokenJti,
+ 'expires_at' => $expiresAt,
+ 'scope' => implode(' ', $scopes),
+ 'ip_address' => $request->ip(),
+ 'user_agent' => $request->userAgent(),
]);
+
+ return $composite;
}
- /**
- * Generate JWT token
- */
- private function generateJWT($tenantId, $scopes, $type, $expiresIn)
- {
+ private function generateJWT(
+ int $tenantId,
+ string $clientId,
+ array $scopes,
+ string $type,
+ int $expiresIn,
+ string $jti,
+ int $issuedAt,
+ int $expiresAt
+ ): string {
+ [$publicKey, $privateKey] = $this->ensureKeysExist();
+
$payload = [
'iss' => url('/'),
- 'aud' => 'fotospiel-api',
- 'iat' => time(),
- 'nbf' => time(),
- 'exp' => time() + $expiresIn,
+ 'aud' => $clientId,
+ 'iat' => $issuedAt,
+ 'nbf' => $issuedAt,
+ 'exp' => $expiresAt,
'sub' => $tenantId,
+ 'tenant_id' => $tenantId,
'scopes' => $scopes,
'type' => $type,
+ 'client_id' => $clientId,
+ 'jti' => $jti,
];
- $publicKey = file_get_contents(storage_path('app/public.key'));
- $privateKey = file_get_contents(storage_path('app/private.key'));
-
- if (!$publicKey || !$privateKey) {
- $this->generateKeyPair();
- $publicKey = file_get_contents(storage_path('app/public.key'));
- $privateKey = file_get_contents(storage_path('app/private.key'));
- }
-
- return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']);
+ return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
}
- /**
- * Generate RSA key pair for JWT
- */
- private function generateKeyPair()
+ private function ensureKeysExist(): array
+ {
+ $publicKeyPath = storage_path('app/public.key');
+ $privateKeyPath = storage_path('app/private.key');
+
+ $publicKey = @file_get_contents($publicKeyPath);
+ $privateKey = @file_get_contents($privateKeyPath);
+
+ if ($publicKey && $privateKey) {
+ return [$publicKey, $privateKey];
+ }
+
+ $this->generateKeyPair();
+
+ return [
+ file_get_contents($publicKeyPath),
+ file_get_contents($privateKeyPath),
+ ];
+ }
+
+ private function generateKeyPair(): void
{
$config = [
- "digest_alg" => OPENSSL_ALGO_SHA256,
- "private_key_bits" => 2048,
- "private_key_type" => OPENSSL_KEYTYPE_RSA,
+ 'digest_alg' => OPENSSL_ALGO_SHA256,
+ 'private_key_bits' => 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
- if (!$res) {
- throw new \Exception('Failed to generate key pair');
+ if (! $res) {
+ throw new \RuntimeException('Failed to generate key pair');
}
- // Get private key
- openssl_pkey_export($res, $privateKey);
- file_put_contents(storage_path('app/private.key'), $privateKey);
-
- // Get public key
+ openssl_pkey_export($res, $privKey);
$pubKey = openssl_pkey_get_details($res);
- file_put_contents(storage_path('app/public.key'), $pubKey["key"]);
+
+ file_put_contents(storage_path('app/private.key'), $privKey);
+ file_put_contents(storage_path('app/public.key'), $pubKey['key']);
+ }
+ private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
+ {
+ if (empty($requestedScopes)) {
+ return false;
+ }
+
+ $available = array_flip($availableScopes);
+ foreach ($requestedScopes as $scope) {
+ if (! isset($available[$scope])) {
+ return false;
+ }
+ }
+
+ return true;
}
- /**
- * Error response helper
- */
- private function errorResponse($message, $status = 400, $errors = null)
+ private function parseScopes(?string $scopeString): array
+ {
+ if (! $scopeString) {
+ return [];
+ }
+
+ return array_values(array_filter(explode(' ', trim($scopeString))));
+ }
+
+ private function errorResponse(string $message, int $status = 400, $errors = null)
{
$response = ['error' => $message];
if ($errors) {
@@ -285,21 +471,23 @@ class OAuthController extends Controller
return response()->json($response, $status);
}
- /**
- * Helper function for base64url encoding
- */
- private function base64url_encode($data)
+ private function base64urlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
+ private function cacheKeyForCode(string $code): string
+ {
+ return 'oauth:code:'.hash('sha256', $code);
+ }
+
/**
* Stripe Connect OAuth - Start connection
*/
public function stripeConnect(Request $request)
{
- $tenant = $request->user()->tenant;
- if (!$tenant) {
+ $tenant = $request->user()->tenant ?? null;
+ if (! $tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
}
@@ -311,7 +499,7 @@ class OAuthController extends Controller
$redirectUri = url('/api/v1/oauth/stripe-callback');
$scopes = 'read_write_payments transfers';
- $authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=" . urlencode($redirectUri);
+ $authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=".urlencode($redirectUri);
return redirect($authUrl);
}
@@ -326,16 +514,15 @@ class OAuthController extends Controller
$error = $request->get('error');
if ($error) {
- return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error);
+ return redirect('/admin')->with('error', 'Stripe connection failed: '.$error);
}
- if (!$code || !$state) {
+ if (! $code || ! $state) {
return redirect('/admin')->with('error', 'Invalid callback parameters');
}
- // Validate state
$sessionState = session('stripe_state');
- if (!hash_equals($state, $sessionState)) {
+ if (! hash_equals($state, (string) $sessionState)) {
return redirect('/admin')->with('error', 'Invalid state parameter');
}
@@ -355,9 +542,9 @@ class OAuthController extends Controller
],
]);
- $tokenData = json_decode($response->getBody(), true);
+ $tokenData = json_decode($response->getBody()->getContents(), true);
- if (!isset($tokenData['stripe_user_id'])) {
+ if (! isset($tokenData['stripe_user_id'])) {
return redirect('/admin')->with('error', 'Failed to connect Stripe account');
}
@@ -369,8 +556,8 @@ class OAuthController extends Controller
session()->forget(['stripe_state', 'tenant_id']);
return redirect('/admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) {
- Log::error('Stripe OAuth error: ' . $e->getMessage());
- return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage());
+ Log::error('Stripe OAuth error: '.$e->getMessage());
+ return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage());
}
}
-}
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Tenant/CreditController.php b/app/Http/Controllers/Tenant/CreditController.php
index 95504c8..c87927d 100644
--- a/app/Http/Controllers/Tenant/CreditController.php
+++ b/app/Http/Controllers/Tenant/CreditController.php
@@ -8,18 +8,15 @@ use App\Http\Resources\Tenant\EventPurchaseResource;
use App\Models\EventCreditsLedger;
use App\Models\EventPurchase;
use App\Models\Tenant;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Symfony\Component\HttpKernel\Exception\HttpException;
class CreditController extends Controller
{
- public function balance(Request $request)
+ public function balance(Request $request): JsonResponse
{
- $user = $request->user();
- if (!$user) {
- return response()->json(['message' => 'Unauthenticated'], 401);
- }
-
- $tenant = Tenant::findOrFail($user->tenant_id);
+ $tenant = $this->resolveTenant($request);
return response()->json([
'balance' => $tenant->event_credits_balance,
@@ -29,14 +26,10 @@ class CreditController extends Controller
public function ledger(Request $request)
{
- $user = $request->user();
- if (!$user) {
- return response()->json(['message' => 'Unauthenticated'], 401);
- }
+ $tenant = $this->resolveTenant($request);
- $tenant = Tenant::findOrFail($user->tenant_id);
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
- ->orderBy('created_at', 'desc')
+ ->orderByDesc('created_at')
->paginate(20);
return CreditLedgerResource::collection($ledgers);
@@ -44,16 +37,100 @@ class CreditController extends Controller
public function history(Request $request)
{
- $user = $request->user();
- if (!$user) {
- return response()->json(['message' => 'Unauthenticated'], 401);
- }
+ $tenant = $this->resolveTenant($request);
- $tenant = Tenant::findOrFail($user->tenant_id);
$purchases = EventPurchase::where('tenant_id', $tenant->id)
- ->orderBy('purchased_at', 'desc')
+ ->orderByDesc('purchased_at')
->paginate(20);
return EventPurchaseResource::collection($purchases);
}
-}
\ No newline at end of file
+
+ public function purchase(Request $request): JsonResponse
+ {
+ $tenant = $this->resolveTenant($request);
+
+ $data = $request->validate([
+ 'package_id' => ['required', 'string', 'max:255'],
+ 'credits_added' => ['required', 'integer', 'min:1'],
+ 'platform' => ['nullable', 'string', 'max:32'],
+ 'transaction_id' => ['nullable', 'string', 'max:255'],
+ 'subscription_active' => ['sometimes', 'boolean'],
+ ]);
+
+ $purchase = EventPurchase::create([
+ 'tenant_id' => $tenant->id,
+ 'events_purchased' => $data['credits_added'],
+ 'amount' => 0,
+ 'currency' => 'EUR',
+ 'provider' => $data['platform'] ?? 'tenant-app',
+ 'external_receipt_id' => $data['transaction_id'] ?? null,
+ 'status' => 'completed',
+ 'purchased_at' => now(),
+ ]);
+
+ $note = 'Package: '.$data['package_id'];
+ $incremented = $tenant->incrementCredits($data['credits_added'], 'purchase', $note, $purchase->id);
+
+ if (! $incremented) {
+ throw new HttpException(500, 'Unable to record credit purchase');
+ }
+
+ if (array_key_exists('subscription_active', $data)) {
+ $tenant->update([
+ 'subscription_tier' => $data['subscription_active'] ? 'pro' : $tenant->subscription_tier,
+ ]);
+ }
+
+ $tenant->refresh();
+
+ return response()->json([
+ 'message' => 'Purchase recorded',
+ 'balance' => $tenant->event_credits_balance,
+ 'subscription_active' => (bool) ($data['subscription_active'] ?? false),
+ ], 201);
+ }
+
+ public function sync(Request $request): JsonResponse
+ {
+ $tenant = $this->resolveTenant($request);
+
+ $data = $request->validate([
+ 'balance' => ['nullable', 'integer', 'min:0'],
+ 'subscription_active' => ['sometimes', 'boolean'],
+ 'last_sync' => ['nullable', 'date'],
+ ]);
+
+ if (array_key_exists('subscription_active', $data)) {
+ $tenant->update([
+ 'subscription_tier' => $data['subscription_active'] ? 'pro' : ($tenant->subscription_tier ?? 'free'),
+ ]);
+ }
+
+ // Server remains source of truth for balance; echo current state back to client
+ return response()->json([
+ 'balance' => $tenant->event_credits_balance,
+ 'subscription_active' => (bool) ($tenant->active_subscription ?? false),
+ 'server_time' => now()->toIso8601String(),
+ ]);
+ }
+
+ private function resolveTenant(Request $request): Tenant
+ {
+ $user = $request->user();
+ if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) {
+ return $user->tenant;
+ }
+
+ $tenantId = $request->attributes->get('tenant_id');
+ if (! $tenantId && $user && isset($user->tenant_id)) {
+ $tenantId = $user->tenant_id;
+ }
+
+ if (! $tenantId) {
+ throw new HttpException(401, 'Unauthenticated');
+ }
+
+ return Tenant::findOrFail($tenantId);
+ }
+}
diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php
index 4b7ac59..b0d5fec 100644
--- a/app/Http/Middleware/CreditCheckMiddleware.php
+++ b/app/Http/Middleware/CreditCheckMiddleware.php
@@ -2,23 +2,18 @@
namespace App\Http\Middleware;
+use App\Models\Event;
+use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
-use App\Models\Tenant;
-use App\Models\Event;
class CreditCheckMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
- $user = $request->user();
- if (!$user) {
- return response()->json(['message' => 'Unauthenticated'], 401);
- }
-
- $tenant = Tenant::findOrFail($user->tenant_id);
+ $tenant = $this->resolveTenant($request);
if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422);
@@ -30,10 +25,28 @@ class CreditCheckMiddleware
$event = Event::where('slug', $eventSlug)->firstOrFail();
$tenant = $event->tenant;
- // For update, no credit check needed (already consumed on create)
$request->merge(['tenant' => $tenant]);
}
return $next($request);
}
-}
\ No newline at end of file
+
+ private function resolveTenant(Request $request): Tenant
+ {
+ $user = $request->user();
+ if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) {
+ return $user->tenant;
+ }
+
+ $tenantId = $request->attributes->get('tenant_id');
+ if (! $tenantId && $user && isset($user->tenant_id)) {
+ $tenantId = $user->tenant_id;
+ }
+
+ if (! $tenantId) {
+ abort(401, 'Unauthenticated');
+ }
+
+ return Tenant::findOrFail($tenantId);
+ }
+}
diff --git a/app/Http/Middleware/TenantIsolation.php b/app/Http/Middleware/TenantIsolation.php
index b4a7e86..9452ea1 100644
--- a/app/Http/Middleware/TenantIsolation.php
+++ b/app/Http/Middleware/TenantIsolation.php
@@ -27,8 +27,12 @@ class TenantIsolation
}
// Set tenant context for query scoping
- DB::statement("SET @tenant_id = ?", [$tenantId]);
-
+ $connection = DB::connection();
+ if (in_array($connection->getDriverName(), ['mysql', 'mariadb'])) {
+ $connection->statement('SET @tenant_id = ?', [$tenantId]);
+ }
+
+
// Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId);
@@ -58,4 +62,4 @@ class TenantIsolation
// 4. For tenant-specific resources, use token tenant_id
return null;
}
-}
\ No newline at end of file
+}
diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php
index f434199..e80d791 100644
--- a/app/Http/Middleware/TenantTokenGuard.php
+++ b/app/Http/Middleware/TenantTokenGuard.php
@@ -2,14 +2,16 @@
namespace App\Http\Middleware;
+use App\Models\Tenant;
use App\Models\TenantToken;
use Closure;
-use Firebase\JWT\Exceptions\TokenExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
+use Illuminate\Auth\GenericUser;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
-use Illuminate\Validation\ValidationException;
+use Illuminate\Support\Str;
class TenantTokenGuard
{
@@ -20,7 +22,7 @@ class TenantTokenGuard
{
$token = $this->getTokenFromRequest($request);
- if (!$token) {
+ if (! $token) {
return response()->json(['error' => 'Token not provided'], 401);
}
@@ -30,25 +32,46 @@ class TenantTokenGuard
return response()->json(['error' => 'Invalid token'], 401);
}
- // Check token blacklist
if ($this->isTokenBlacklisted($decoded)) {
return response()->json(['error' => 'Token has been revoked'], 401);
}
- // Validate scopes if specified
- if (!empty($scopes) && !$this->hasScopes($decoded, $scopes)) {
+ if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
return response()->json(['error' => 'Insufficient scopes'], 403);
}
- // Check expiration
- if ($decoded['exp'] < time()) {
- // Add to blacklist on expiry
+ if (($decoded['exp'] ?? 0) < time()) {
$this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401);
}
- // Set tenant ID on request
- $request->merge(['tenant_id' => $decoded['sub']]);
+ $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
+ if (! $tenantId) {
+ return response()->json(['error' => 'Invalid token payload'], 401);
+ }
+
+ $tenant = Tenant::query()->find($tenantId);
+ if (! $tenant) {
+ return response()->json(['error' => 'Tenant not found'], 404);
+ }
+
+ $scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
+
+ $principal = new GenericUser([
+ 'id' => $tenant->id,
+ 'tenant_id' => $tenant->id,
+ 'tenant' => $tenant,
+ 'scopes' => $scopesFromToken,
+ 'client_id' => $decoded['client_id'] ?? null,
+ 'jti' => $decoded['jti'] ?? null,
+ 'token_type' => $decoded['type'] ?? 'access',
+ ]);
+
+ Auth::setUser($principal);
+ $request->setUserResolver(fn () => $principal);
+
+ $request->merge(['tenant_id' => $tenant->id]);
+ $request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('decoded_token', $decoded);
return $next($request);
@@ -61,7 +84,7 @@ class TenantTokenGuard
{
$header = $request->header('Authorization');
- if (str_starts_with($header, 'Bearer ')) {
+ if (is_string($header) && str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
@@ -78,11 +101,12 @@ class TenantTokenGuard
private function decodeToken(string $token): array
{
$publicKey = file_get_contents(storage_path('app/public.key'));
- if (!$publicKey) {
+ if (! $publicKey) {
throw new \Exception('JWT public key not found');
}
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
+
return (array) $decoded;
}
@@ -91,23 +115,29 @@ class TenantTokenGuard
*/
private function isTokenBlacklisted(array $decoded): bool
{
- $jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']);
+ $jti = $decoded['jti'] ?? null;
+ if (! $jti) {
+ return false;
+ }
+
$cacheKey = "blacklisted_token:{$jti}";
-
- // Check cache first (faster)
if (Cache::has($cacheKey)) {
return true;
}
- // Check DB blacklist
- $dbJti = $decoded['jti'] ?? null;
- $blacklisted = TenantToken::where('jti', $dbJti)
- ->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat']))
- ->where('expires_at', '>', now())
- ->exists();
+ $tokenRecord = TenantToken::query()->where('jti', $jti)->first();
+ if (! $tokenRecord) {
+ return false;
+ }
- if ($blacklisted) {
- Cache::put($cacheKey, true, now()->addMinutes(5));
+ if ($tokenRecord->revoked_at) {
+ Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
+ return true;
+ }
+
+ if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
+ $tokenRecord->update(['revoked_at' => now()]);
+ Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
return true;
}
@@ -119,25 +149,35 @@ class TenantTokenGuard
*/
private function blacklistToken(array $decoded): void
{
- $jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']);
+ $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? ''));
$cacheKey = "blacklisted_token:{$jti}";
- // Cache for immediate effect
- Cache::put($cacheKey, true, $decoded['exp'] - time());
+ Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
- // Store in DB for persistence
- TenantToken::updateOrCreate(
- [
- 'jti' => $jti,
- 'tenant_id' => $decoded['sub'],
- ],
- [
- 'token_hash' => md5(json_encode($decoded)),
- 'ip_address' => request()->ip(),
- 'user_agent' => request()->userAgent(),
- 'expires_at' => now()->addHours(24), // Keep for 24h after expiry
- ]
- );
+ $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
+ $tokenType = $decoded['type'] ?? 'access';
+
+ $record = TenantToken::query()->where('jti', $jti)->first();
+ if ($record) {
+ $record->update([
+ 'revoked_at' => now(),
+ 'expires_at' => $record->expires_at ?? now(),
+ ]);
+ return;
+ }
+
+ if ($tenantId === null) {
+ return;
+ }
+
+ TenantToken::create([
+ 'id' => (string) Str::uuid(),
+ 'tenant_id' => $tenantId,
+ 'jti' => $jti,
+ 'token_type' => $tokenType,
+ 'expires_at' => now(),
+ 'revoked_at' => now(),
+ ]);
}
/**
@@ -145,18 +185,36 @@ class TenantTokenGuard
*/
private function hasScopes(array $decoded, array $requiredScopes): bool
{
- $tokenScopes = $decoded['scopes'] ?? [];
-
- if (!is_array($tokenScopes)) {
- $tokenScopes = explode(' ', $tokenScopes);
- }
+ $tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
foreach ($requiredScopes as $scope) {
- if (!in_array($scope, $tokenScopes)) {
+ if (! in_array($scope, $tokenScopes, true)) {
return false;
}
}
return true;
}
-}
\ No newline at end of file
+
+ private function normaliseScopes(mixed $scopes): array
+ {
+ if (is_array($scopes)) {
+ return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== ''));
+ }
+
+ if (is_string($scopes)) {
+ return array_values(array_filter(explode(' ', $scopes)));
+ }
+
+ return [];
+ }
+
+ private function cacheTtlFromDecoded(array $decoded): int
+ {
+ $exp = $decoded['exp'] ?? time();
+ $ttl = max($exp - time(), 60);
+
+ return $ttl;
+ }
+}
+
diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php
index 5cd851d..3768c30 100644
--- a/app/Models/OAuthClient.php
+++ b/app/Models/OAuthClient.php
@@ -14,13 +14,18 @@ class OAuthClient extends Model
'id',
'client_id',
'client_secret',
+ 'tenant_id',
'redirect_uris',
'scopes',
+ 'is_active',
];
protected $casts = [
+ 'id' => 'string',
+ 'tenant_id' => 'integer',
'scopes' => 'array',
'redirect_uris' => 'array',
+ 'is_active' => 'bool',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
diff --git a/app/Models/OAuthCode.php b/app/Models/OAuthCode.php
index ee47922..18c9b13 100644
--- a/app/Models/OAuthCode.php
+++ b/app/Models/OAuthCode.php
@@ -8,9 +8,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OAuthCode extends Model
{
protected $table = 'oauth_codes';
-
+
+ public $timestamps = false;
+
protected $guarded = [];
-
+
protected $fillable = [
'id',
'client_id',
@@ -22,22 +24,22 @@ class OAuthCode extends Model
'scope',
'expires_at',
];
-
+
protected $casts = [
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
-
+
public function client(): BelongsTo
{
return $this->belongsTo(OAuthClient::class, 'client_id', 'client_id');
}
-
+
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
-
+
public function isExpired(): bool
{
return $this->expires_at < now();
diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php
index aa3980f..2cd05b4 100644
--- a/app/Models/RefreshToken.php
+++ b/app/Models/RefreshToken.php
@@ -7,6 +7,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshToken extends Model
{
+ public $incrementing = false;
+ protected $keyType = 'string';
+ public $timestamps = false;
+
protected $table = 'refresh_tokens';
protected $guarded = [];
@@ -14,6 +18,7 @@ class RefreshToken extends Model
protected $fillable = [
'id',
'tenant_id',
+ 'client_id',
'token',
'access_token',
'expires_at',
diff --git a/app/Models/TenantToken.php b/app/Models/TenantToken.php
index b419a3a..0531a45 100644
--- a/app/Models/TenantToken.php
+++ b/app/Models/TenantToken.php
@@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model;
class TenantToken extends Model
{
+ public $incrementing = false;
+ protected $keyType = 'string';
+ public $timestamps = false;
+
protected $table = 'tenant_tokens';
protected $guarded = [];
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 53a355a..9911a78 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -1,8 +1,11 @@
withMiddleware(function (Middleware $middleware) {
+ $middleware->alias([
+ 'tenant.token' => TenantTokenGuard::class,
+ 'tenant.isolation' => TenantIsolation::class,
+ 'credit.check' => CreditCheckMiddleware::class,
+ ]);
+
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
diff --git a/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php b/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php
index 0749dcf..d6a60a3 100644
--- a/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php
+++ b/database/migrations/2025_09_17_184450_add_tenant_id_to_photos_table_new.php
@@ -12,9 +12,11 @@ return new class extends Migration
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
- $table->unsignedBigInteger('tenant_id')->nullable()->after('event_id');
- $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
- $table->index('tenant_id');
+ if (!Schema::hasColumn('photos', 'tenant_id')) {
+ $table->unsignedBigInteger('tenant_id')->nullable()->after('event_id');
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
+ $table->index('tenant_id');
+ }
});
}
@@ -24,8 +26,10 @@ return new class extends Migration
public function down(): void
{
Schema::table('photos', function (Blueprint $table) {
- $table->dropForeign(['tenant_id']);
- $table->dropColumn('tenant_id');
+ if (Schema::hasColumn('photos', 'tenant_id')) {
+ $table->dropForeign(['tenant_id']);
+ $table->dropColumn('tenant_id');
+ }
});
}
-};
+};
\ No newline at end of file
diff --git a/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php b/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php
new file mode 100644
index 0000000..9860d4c
--- /dev/null
+++ b/database/migrations/2025_09_18_170042_add_is_active_to_oauth_clients_table.php
@@ -0,0 +1,79 @@
+boolean('is_active')->default(true);
+ });
+ }
+
+ $clients = DB::table('oauth_clients')->get(['id', 'scopes', 'redirect_uris', 'is_active']);
+
+ foreach ($clients as $client) {
+ $scopes = $this->normaliseValue($client->scopes, ['tenant:read', 'tenant:write']);
+ $redirects = $this->normaliseValue($client->redirect_uris);
+
+ DB::table('oauth_clients')
+ ->where('id', $client->id)
+ ->update([
+ 'scopes' => $scopes === null ? null : json_encode($scopes),
+ 'redirect_uris' => $redirects === null ? null : json_encode($redirects),
+ 'is_active' => $client->is_active ?? true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (Schema::hasColumn('oauth_clients', 'is_active')) {
+ Schema::table('oauth_clients', function (Blueprint $table) {
+ $table->dropColumn('is_active');
+ });
+ }
+ }
+
+ private function normaliseValue(mixed $value, ?array $fallback = null): ?array
+ {
+ if ($value === null) {
+ return $fallback;
+ }
+
+ if (is_array($value)) {
+ return $this->cleanArray($value) ?: $fallback;
+ }
+
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+ return $this->cleanArray($decoded) ?: $fallback;
+ }
+
+ $parts = preg_split('/[\r\n,]+/', $value) ?: [];
+ return $this->cleanArray($parts) ?: $fallback;
+ }
+
+ return $fallback;
+ }
+
+ private function cleanArray(array $items): array
+ {
+ $items = array_map(fn ($item) => is_string($item) ? trim($item) : $item, $items);
+ $items = array_filter($items, fn ($item) => ! ($item === null || $item === ''));
+
+ return array_values($items);
+ }
+};
diff --git a/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php b/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php
new file mode 100644
index 0000000..d5f54a9
--- /dev/null
+++ b/database/migrations/2025_09_18_180407_add_tenant_id_to_oauth_clients_table.php
@@ -0,0 +1,36 @@
+foreignId('tenant_id')
+ ->nullable()
+ ->after('client_secret')
+ ->constrained('tenants')
+ ->nullOnDelete();
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('oauth_clients', function (Blueprint $table) {
+ if (Schema::hasColumn('oauth_clients', 'tenant_id')) {
+ $table->dropConstrainedForeignId('tenant_id');
+ }
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php b/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php
new file mode 100644
index 0000000..1f78783
--- /dev/null
+++ b/database/migrations/2025_09_18_180414_add_client_id_to_refresh_tokens_table.php
@@ -0,0 +1,32 @@
+string('client_id', 255)->nullable()->after('tenant_id')->index();
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('refresh_tokens', function (Blueprint $table) {
+ if (Schema::hasColumn('refresh_tokens', 'client_id')) {
+ $table->dropColumn('client_id');
+ }
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index a09436e..d8813f2 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -1,9 +1,7 @@
-call([
SuperAdminSeeder::class,
DemoEventSeeder::class,
+ OAuthClientSeeder::class,
]);
+
+ if (app()->environment(['local', 'development', 'demo'])) {
+ $this->call([
+ DemoPhotosSeeder::class,
+ DemoAchievementsSeeder::class,
+ ]);
+ }
}
}
diff --git a/database/seeders/DemoAchievementsSeeder.php b/database/seeders/DemoAchievementsSeeder.php
new file mode 100644
index 0000000..914e4bf
--- /dev/null
+++ b/database/seeders/DemoAchievementsSeeder.php
@@ -0,0 +1,117 @@
+first();
+ $tenant = Tenant::where('slug', 'demo')->first();
+
+ if (! $event || ! $tenant) {
+ $this->command?->warn('Demo event/tenant missing – skipping DemoAchievementsSeeder');
+ return;
+ }
+
+ $tasks = Task::where('tenant_id', $tenant->id)->pluck('id')->all();
+ $emotions = Emotion::pluck('id')->all();
+
+ if ($tasks === [] || $emotions === []) {
+ $this->command?->warn('Tasks or emotions missing – skipping DemoAchievementsSeeder');
+ return;
+ }
+
+ $sourceFiles = collect(Storage::disk('public')->files('photos'))
+ ->filter(fn ($path) => Str::endsWith(Str::lower($path), '.jpg'))
+ ->values();
+
+ if ($sourceFiles->isEmpty()) {
+ $this->command?->warn('No demo photo files found – skipping DemoAchievementsSeeder');
+ return;
+ }
+
+ $blueprints = [
+ ['guest' => 'Anna Mueller', 'photos' => 6, 'likes' => [12, 8, 5, 4, 2, 1], 'withTasks' => true],
+ ['guest' => 'Max Schmidt', 'photos' => 4, 'likes' => [9, 7, 4, 2], 'withTasks' => true],
+ ['guest' => 'Lisa Weber', 'photos' => 2, 'likes' => [3, 1], 'withTasks' => false],
+ ['guest' => 'Tom Fischer', 'photos' => 1, 'likes' => [14], 'withTasks' => true],
+ ['guest' => 'Team Brautparty', 'photos' => 5, 'likes' => [5, 4, 3, 3, 2], 'withTasks' => true],
+ ];
+
+ $eventDate = $event->date ? CarbonImmutable::parse($event->date) : CarbonImmutable::now();
+ $baseDir = "events/{$event->id}/achievements";
+ Storage::disk('public')->makeDirectory($baseDir);
+ Storage::disk('public')->makeDirectory("{$baseDir}/thumbs");
+
+ $photoIndex = 0;
+
+ foreach ($blueprints as $groupIndex => $blueprint) {
+ for ($i = 0; $i < $blueprint['photos']; $i++) {
+ $source = $sourceFiles[$photoIndex % $sourceFiles->count()];
+ $photoIndex++;
+
+ $filename = Str::slug($blueprint['guest'] . '-' . $groupIndex . '-' . $i) . '.jpg';
+ $destPath = "{$baseDir}/{$filename}";
+ if (! Storage::disk('public')->exists($destPath)) {
+ Storage::disk('public')->copy($source, $destPath);
+ }
+
+ $thumbSource = str_replace('photos/', 'thumbnails/', $source);
+ $thumbDest = "{$baseDir}/thumbs/{$filename}";
+ if (Storage::disk('public')->exists($thumbSource)) {
+ Storage::disk('public')->copy($thumbSource, $thumbDest);
+ } else {
+ Storage::disk('public')->copy($source, $thumbDest);
+ }
+
+ $taskId = $blueprint['withTasks'] ? $tasks[($groupIndex + $i) % count($tasks)] : null;
+ $emotionId = $emotions[($groupIndex * 3 + $i) % count($emotions)];
+ $createdAt = $eventDate->addHours($groupIndex * 2 + $i);
+ $likes = $blueprint['likes'][$i] ?? 0;
+
+ $photo = Photo::updateOrCreate(
+ [
+ 'tenant_id' => $tenant->id,
+ 'event_id' => $event->id,
+ 'guest_name' => $blueprint['guest'],
+ 'file_path' => $destPath,
+ ],
+ [
+ 'task_id' => $taskId,
+ 'emotion_id' => $emotionId,
+ 'thumbnail_path' => $thumbDest,
+ 'likes_count' => $likes,
+ 'is_featured' => $i === 0,
+ 'metadata' => ['demo' => true],
+ 'created_at' => $createdAt,
+ 'updated_at' => $createdAt,
+ ]
+ );
+
+ PhotoLike::where('photo_id', $photo->id)->delete();
+ for ($like = 0; $like < min($likes, 15); $like++) {
+ PhotoLike::create([
+ 'photo_id' => $photo->id,
+ 'guest_name' => 'Guest_' . Str::random(6),
+ 'ip_address' => '10.0.' . rand(0, 254) . '.' . rand(0, 254),
+ 'created_at' => $createdAt->addMinutes($like * 3),
+ ]);
+ }
+ }
+ }
+
+ $this->command?->info('Demo achievements seeded.');
+ }
+}
diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php
new file mode 100644
index 0000000..6fc99e0
--- /dev/null
+++ b/database/seeders/OAuthClientSeeder.php
@@ -0,0 +1,47 @@
+value('id')
+ ?? Tenant::query()->orderBy('id')->value('id');
+
+ $redirectUris = [
+ 'http://localhost:5174/auth/callback',
+ 'http://localhost:8000/auth/callback',
+ ];
+
+ $scopes = [
+ 'tenant:read',
+ 'tenant:write',
+ ];
+
+ $client = OAuthClient::firstOrNew(['client_id' => $clientId]);
+
+ if (!$client->exists) {
+ $client->id = (string) Str::uuid();
+ }
+
+ $client->fill([
+ 'client_secret' => null, // Public client, no secret needed for PKCE
+ 'tenant_id' => $tenantId,
+ 'redirect_uris' => $redirectUris,
+ 'scopes' => $scopes,
+ 'is_active' => true,
+ ]);
+
+ $client->save();
+ }
+}
diff --git a/docs/prp/07-guest-pwa-routes-components.md b/docs/prp/07-guest-pwa-routes-components.md
index 1de27f6..0f36e99 100644
--- a/docs/prp/07-guest-pwa-routes-components.md
+++ b/docs/prp/07-guest-pwa-routes-components.md
@@ -1,4 +1,4 @@
-# 07a — Guest PWA Routes & Components
+# 07a — Guest PWA Routes & Components
This scaffold describes recommended routes, guards, directories, and components for the Guest PWA. It is framework-leaning (React Router v6 + Vite), but adaptable.
@@ -8,25 +8,26 @@ Routing Principles
- Prefer modal routes (photo detail) layered over the gallery.
Route Map (proposed)
-- `/` — Landing (QR/PIN input; deep-link handler)
-- `/setup` — Profile Setup (name/avatar; skippable)
-- `/e/:slug` — Home/Feed (default gallery view + info bar)
-- `/e/:slug/tasks` — Task Picker (random/emotion)
-- `/e/:slug/tasks/:taskId` — Task Detail (card)
-- `/e/:slug/upload` — Upload Picker (camera/library + tagging)
-- `/e/:slug/queue` — Upload Queue (progress/retry)
-- `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page)
-- `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery)
-- `/e/:slug/achievements` — Achievements (optional)
-- `/e/:slug/slideshow` — Slideshow (optional, read-only)
-- `/settings` — Settings (language/theme/cache/legal)
-- `/legal/:page` — Legal pages (imprint/privacy/terms)
-- `*` — NotFound
+- `/` — Landing (QR/PIN input; deep-link handler)
+- `/setup` — Profile Setup (name/avatar; skippable)
+- `/e/:slug` — Home/Feed (default gallery view + info bar)
+- `/e/:slug/tasks` — Task Picker (random/emotion)
+- `/e/:slug/tasks/:taskId` — Task Detail (card)
+- `/e/:slug/upload` — Upload Picker (camera/library + tagging)
+- `/e/:slug/queue` — Upload Queue (progress/retry)
+- `/e/:slug/gallery` — Gallery index (alias of Home or dedicated page)
+- `/e/:slug/photo/:photoId` — Photo Lightbox (modal over gallery)
+- `/e/:slug/achievements` — Achievements (optional)
+- `/e/:slug/slideshow` — Slideshow (optional, read-only)
+- `/legal/:page` — Legal pages (imprint/privacy/terms)
+- `*` — NotFound
+
+Note: The settings experience is handled via the header sheet (no dedicated route; legal pages stay routable under /legal/:page).
Guards & Loaders
-- `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`.
-- `PrefetchEvent` — loads event metadata/theme on `:slug` routes.
-- `OfflineFallback` — surfaces offline banner and queues mutations.
+- `EventGuard` — verifies event token in storage; attempts refresh; otherwise redirects to `/`.
+- `PrefetchEvent` — loads event metadata/theme on `:slug` routes.
+- `OfflineFallback` — surfaces offline banner and queues mutations.
Suggested Directory Structure
```
@@ -46,7 +47,7 @@ apps/guest-pwa/
PhotoLightbox.tsx // modal route
AchievementsPage.tsx
SlideshowPage.tsx
- SettingsPage.tsx
+ SettingsSheet.tsx
LegalPage.tsx
NotFoundPage.tsx
components/
@@ -116,7 +117,7 @@ export const router = createBrowserRouter([
{ path: 'slideshow', element: },
],
},
- { path: '/settings', element: },
+ // Settings sheet is rendered inside Header; no standalone route.
{ path: '/legal/:page', element: },
{ path: '*', element: },
]);
@@ -124,11 +125,13 @@ export const router = createBrowserRouter([
Component Checklist
- Layout
- - `Header`, `InfoBar` (X Gäste online • Y Aufgaben gelöst), `BottomNav`, `Toast`.
+ - `Header`, `InfoBar` (X Gäste online • Y Aufgaben gelöst), `BottomNav`, `Toast`.
- Entry
- `QRPinForm` (QR deep link or PIN fallback), `ProfileForm` (name/avatar).
- Home/Feed
- - `CTAButtons` (Random Task, Emotion Picker, Quick Photo), `GalleryMasonry`, `FiltersBar`, `PhotoCard`.
+ - `HeroCard` (Willkommensgruess + Eventtitel) und `StatTiles` (online Gaeste, geloeste Aufgaben, letztes Upload).
+ - `CTAButtons` (Aufgabe ziehen, Direkt-Upload, Galerie) + `UploadQueueLink` fuer Warteschlange.
+ - `EmotionPickerGrid` und `GalleryPreview` als inhaltlicher Einstieg.
- Tasks
- `EmotionPickerGrid`, `TaskCard` (shows duration, group size, actions).
- Capture/Upload (photos only)
@@ -136,7 +139,7 @@ Component Checklist
- Photo View
- `PhotoLightbox` (modal), like/share controls, emotion tags.
- Settings & Legal
- - `SettingsPage` sections, `LegalPage` renderer.
+ - `SettingsSheet` (Header-Overlay mit Namenseditor, eingebetteten Rechtsdokumenten, Cache-Leeren), `LegalPage` Renderer.
State & Data
- TanStack Query for server data (events, photos); optimistic updates for likes.
diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md
index 64705fc..df2dc46 100644
--- a/docs/prp/07-guest-pwa.md
+++ b/docs/prp/07-guest-pwa.md
@@ -1,4 +1,4 @@
-# 07 — Guest PWA
+# 07 — Guest PWA
Goal
- Delight guests with a frictionless, installable photo experience that works offline, respects privacy, and requires no account.
@@ -7,13 +7,13 @@ Non-Goals (MVP)
- No comments or chat. No facial recognition. No public profiles. No videos.
Personas
-- Guest (attendee) — scans QR, uploads photos, browses and likes.
-- Host (tenant) — optionally shares event PIN with guests; moderates via Tenant Admin PWA.
+- Guest (attendee) — scans QR, uploads photos, browses and likes.
+- Host (tenant) — optionally shares event PIN with guests; moderates via Tenant Admin PWA.
Top Journeys
-- Join: Scan QR → Open event → Accept terms → Optional PIN → Land on Gallery.
-- Upload: Add photos → Review → Submit → Background upload → Success/Retry.
-- Browse: Infinite gallery → Filter (emotion/task) → Open photo → Like/Share → Back.
+- Join: Scan QR → Open event → Accept terms → Optional PIN → Land on Gallery.
+- Upload: Add photos → Review → Submit → Background upload → Success/Retry.
+- Browse: Infinite gallery → Filter (emotion/task) → Open photo → Like/Share → Back.
Core Features
- Event access
@@ -25,14 +25,14 @@ Core Features
- Capture & upload
- Choose from camera or library; limit file size; show remaining upload cap.
- Client-side resize to sane max (e.g., 2560px longest edge); EXIF stripped client-side if available.
- - Assign optional emotion/task before submit; default to “Uncategorized”.
+ - Assign optional emotion/task before submit; default to “Uncategorizedâ€.
- Gallery
- Masonry grid, lazy-load, pull-to-refresh; open photo lightbox with swipe.
- Like (heart) with optimistic UI; share system sheet (URL to CDN variant).
- Filters: emotion, featured, mine (local-only tag for items uploaded from this device).
- Safety & abuse controls
- Rate limits per device and IP; content-length checks; mime/type sniffing.
- - Upload moderation state: pending → approved/hidden; show local status.
+ - Upload moderation state: pending → approved/hidden; show local status.
- Privacy & legal
- First run shows legal links (imprint/privacy); consent for push if enabled.
- No PII stored; guest name is optional free text and not required by default.
@@ -44,7 +44,7 @@ Screens
- Upload Picker: camera/library, selection preview, emotion/task tagging.
- Upload Queue: items with progress, retry, remove; background sync toggle.
- Photo Lightbox: zoom, like, share; show emotion tags.
-- Settings: language, theme (system), clear cache, legal pages.
+- Settings Sheet: Gear-Icon im Header oeffnet eine Sheet-Ansicht mit editierbarem Gastnamen, eingebetteten Rechtsdokumenten (inkl. Zurueck-Navigation) und Cache-Leeren; Theme-Umschalter bleibt im Header.
Wireframes
- See wireframes file at docs/wireframes/guest-pwa.md for low-fidelity layouts and flows.
@@ -55,28 +55,27 @@ Core Pages (Pflichtseiten)
- UI: Single input (QR deep link preferred, fallback PIN field) and Join button.
- States: invalid/expired token, event closed, offline (allow PIN entry and queue attempt).
- Profil-Setup (Name/Avatar)
- - Purpose: Optional personalization for likes/attribution.
- - UI: Name text field, optional avatar picker; one-time before first entry to event.
- - Behavior: Skippable; editable later in Settings.
+ - Purpose: Optional personalisation fuer Likes/Statistiken; Name wird lokal im Browser gespeichert.
+ - UI: Name-Feld mit Sofort-Validierung; Avatar folgt spaeter.
+ - Behavior: Nicht verpflichtend, aber empfohlen; Name kann jederzeit im Settings Sheet angepasst oder geloescht werden.
- Startseite (Home/Feed)
- - Purpose: Central hub; show event title/subheadline and CTAs.
- - Header: Event name + subheadline (e.g., “Dein Fotospiel zur Hochzeit”).
- - Info bar: “X Gäste online • Y Aufgaben gelöst”.
- - CTAs: „Aufgabe ziehen“ (random), „Wie fühlst du dich?“ (emotion picker), small link “Einfach ein Foto machen”.
- - Content: Gallery/Feed with photos + likes.
+ - Purpose: Central hub; begruesst Gaeste mit ihrem hinterlegten Namen und fuehrt zu den wichtigsten Aktionen.
+ - Header: Eventtitel plus Live-Kennzahlen (online Gaeste, geloeste Aufgaben); hero-card zeigt "Hey {Name}!".
+ - Highlights: Drei CTA-Karten fuer Aufgabe ziehen, Direkt-Upload und Galerie sowie ein Button fuer die Upload-Warteschlange.
+ - Content: EmotionPicker und GalleryPreview bilden weiterhin den Einstieg in Spielstimmung und aktuelle Fotos.
- Aufgaben-Flow
- Aufgaben-Picker: Choose random task or emotion mood.
- Aufgaben-Detail (Karte): Task text, emoji tag, estimated duration, suggested group size; actions: take photo, new task (same mood), change mood.
- Foto-Interaktion
- Kamera/Upload: Capture or pick; progress + success message on completion; background sync.
- - Galerie/Übersicht: Grid/Feed; filters: Neueste, Beliebt, Meine; Like hearts.
+ - Galerie/Übersicht: Grid/Feed; filters: Neueste, Beliebt, Meine; Like hearts.
- Foto-Detailansicht: Fullscreen; likes/reactions; linked task + (optional) uploader name.
- Motivation & Spiel
- Achievements/Erfolge: Badges (e.g., Erstes Foto, 5 Aufgaben, Beliebtestes Foto); personal progress.
-- Optionale Ergänzungen
- - Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay.
- - Onboarding: 1–2 “So funktioniert das Spiel” hints the first time.
- - Event-Abschluss: “Danke fürs Mitmachen”, summary stats, link/QR to online gallery.
+- Optionale Ergänzungen
+ - Slideshow/Präsentationsmodus: Auto-rotating gallery for TV/Beamer with likes/task overlay.
+ - Onboarding: 1–2 “So funktioniert das Spiel†hints the first time.
+ - Event-Abschluss: “Danke fürs Mitmachenâ€, summary stats, link/QR to online gallery.
Technical Notes
- Installability: PWA manifest + service worker; prompt A2HS on supported browsers.
@@ -84,15 +83,15 @@ Technical Notes
- Background Sync: use Background Sync API when available; fallback to retry on app open.
- Accessibility: large tap targets, high contrast, keyboard support, reduced motion.
- i18n: default `de`, fallback `en`; all strings in locale files; RTL not in MVP.
-- Media types: Photos only (no videos) — decision locked for MVP and v1.
+- Media types: Photos only (no videos) — decision locked for MVP and v1.
- Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.
API Touchpoints
-- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme.
-- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only).
-- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields.
-- POST (S3) — direct upload to object storage; then backend finalize call.
-- POST `/api/v1/photos/{id}/like` — idempotent like with device token.
+- GET `/api/v1/events/{slug}` — public event metadata (when open) + theme.
+- GET `/api/v1/events/{slug}/photos` — paginated gallery (approved only).
+- POST `/api/v1/events/{slug}/photos` — signed upload initiation; returns URL + fields.
+- POST (S3) — direct upload to object storage; then backend finalize call.
+- POST `/api/v1/photos/{id}/like` — idempotent like with device token.
Limits (MVP defaults)
- Max uploads per device per event: 50
@@ -100,10 +99,10 @@ Limits (MVP defaults)
- Max resolution: 2560px longest edge per photo
Edge Cases
-- Token expired/invalid → Show “Event closed/invalid link”; link to retry.
-- No connectivity → Queue actions; show badge; retry policy with backoff.
-- Storage full → Offer to clear cache or deselect files.
-- Permission denied (camera/photos) → Explain and offer system shortcut.
+- Token expired/invalid → Show “Event closed/invalid linkâ€; link to retry.
+- No connectivity → Queue actions; show badge; retry policy with backoff.
+- Storage full → Offer to clear cache or deselect files.
+- Permission denied (camera/photos) → Explain and offer system shortcut.
Decisions
- Videos are not supported (capture/upload strictly photos).
diff --git a/docs/prp/13-backend-authentication.md b/docs/prp/13-backend-authentication.md
index baeda85..1c8337b 100644
--- a/docs/prp/13-backend-authentication.md
+++ b/docs/prp/13-backend-authentication.md
@@ -7,7 +7,7 @@ This document outlines the authentication requirements and implementation detail
## Authentication Flow
### 1. Authorization Request
-- **Endpoint**: `POST /oauth/authorize`
+- **Endpoint**: `GET /api/v1/oauth/authorize`
- **Method**: GET (redirect from frontend)
- **Parameters**:
- `client_id`: Fixed client ID for tenant-admin-app (`tenant-admin-app`)
@@ -21,7 +21,7 @@ This document outlines the authentication requirements and implementation detail
**Response**: Redirect to frontend with authorization code and state parameters.
### 2. Token Exchange
-- **Endpoint**: `POST /oauth/token`
+- **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**:
@@ -44,7 +44,7 @@ This document outlines the authentication requirements and implementation detail
```
### 3. Token Refresh
-- **Endpoint**: `POST /oauth/token`
+- **Endpoint**: `POST /api/v1/oauth/token`
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded`
- **Parameters**:
@@ -102,6 +102,19 @@ This document outlines the authentication requirements and implementation detail
### oauth_clients Table
```sql
+CREATE TABLE oauth_clients (
+ id VARCHAR(255) PRIMARY KEY,
+ client_id VARCHAR(255) UNIQUE NOT NULL,
+ client_secret VARCHAR(255),
+ tenant_id BIGINT UNSIGNED NULL,
+ redirect_uris JSON NULL,
+ scopes JSON NULL,
+ is_active TINYINT(1) DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT oauth_clients_tenant_id_foreign FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL
+);
+```sql
CREATE TABLE oauth_clients (
id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) UNIQUE NOT NULL,
@@ -133,6 +146,20 @@ CREATE TABLE oauth_codes (
### refresh_tokens Table
```sql
+CREATE TABLE refresh_tokens (
+ id VARCHAR(255) PRIMARY KEY,
+ tenant_id VARCHAR(255) NOT NULL,
+ client_id VARCHAR(255),
+ token VARCHAR(255) UNIQUE NOT NULL,
+ access_token VARCHAR(255),
+ scope TEXT,
+ ip_address VARCHAR(45),
+ user_agent TEXT,
+ expires_at TIMESTAMP,
+ revoked_at TIMESTAMP NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+```sql
CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
@@ -261,18 +288,22 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Revoke old refresh token immediately
- Limit refresh tokens per tenant to 5 active
-### 3. Rate Limiting
+### 3. Key Management
+- RSA key pairs for signing are generated on demand and stored in storage/app/private.key (private) and storage/app/public.key (public).
+- Treat the private key as a secret; rotate it alongside deploys that invalidate tenant tokens.
+
+### 4. Rate Limiting
- Authorization requests: 10/minute per IP
- Token exchanges: 5/minute per IP
- Token validation: 100/minute per tenant
-### 4. Logging and Monitoring
+### 5. Logging and Monitoring
- Log all authentication attempts (success/failure)
- Monitor token usage patterns
- Alert on unusual activity (multiple failed attempts, token anomalies)
- Track refresh token usage for security analysis
-### 5. Database Cleanup
+### 6. Database Cleanup
- Cron job to remove expired authorization codes (daily)
- Remove expired refresh tokens (weekly)
- Clean blacklisted tokens after expiry (daily)
@@ -315,4 +346,10 @@ VITE_OAUTH_CLIENT_ID=tenant-admin-app
- Alert on PKCE validation failures
- Log all security-related events
-This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients.
\ No newline at end of file
+This implementation provides secure, scalable authentication for the Fotospiel tenant system, following OAuth2 best practices with PKCE for public clients.
+
+
+
+
+
+
diff --git a/docs/prp/tenant-app-specs/api-usage.md b/docs/prp/tenant-app-specs/api-usage.md
index 913091c..6ce73d9 100644
--- a/docs/prp/tenant-app-specs/api-usage.md
+++ b/docs/prp/tenant-app-specs/api-usage.md
@@ -1,4 +1,4 @@
-# API-Nutzung der Tenant Admin App
+# API-Nutzung der Tenant Admin App
Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit der Backend-Hauptapp kommuniziert. Alle Requests sind tenant-scoped und erfordern JWT-Authentifizierung.
@@ -19,6 +19,7 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Response**: Neuer Access/Refresh-Token
- **Token Validation**: `GET /api/v1/tenant/me`
+ - **Redirect URI**: Standardmaessig `${origin}/admin/auth/callback` (per Vite-Env anpassbar)
- **Headers**: `Authorization: Bearer {access_token}`
- **Response**: `{ id, email, tenant_id, role, name }`
@@ -51,18 +52,18 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Validierung**: Prüft Credit-Balance (1 Credit pro Event)
### Event-Details
-- **GET /api/v1/tenant/events/{id}**
+- **GET /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`
- **Response**: Erweitertes Event mit `{ tasks[], members, stats { likes, views, uploads } }`
### Event updaten
-- **PATCH /api/v1/tenant/events/{id}**
+- **PATCH /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`, `If-Match: {etag}`
- **Body**: Partial Event-Daten (title, date, location, description)
- **Response**: Updated Event
### Event archivieren
-- **DELETE /api/v1/tenant/events/{id}**
+- **DELETE /api/v1/tenant/events/{slug}**
- **Headers**: `Authorization: Bearer {token}`, `If-Match: {etag}`
- **Response**: 204 No Content (soft-delete)
@@ -156,12 +157,26 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
- **Response**: `{ balance: number }`
### Ledger-Verlauf
-- **GET /api/v1/tenant/ledger**
+- **GET /api/v1/tenant/credits/ledger**
- **Headers**: `Authorization: Bearer {token}`
- **Params**: `page`, `per_page` (Pagination)
- **Response**: `{ data: LedgerEntry[], current_page, last_page }`
- **LedgerEntry**: `{ id, type, amount, credits, date, description, transactionId? }`
+### Credits kaufen (In-App)
+- **POST /api/v1/tenant/credits/purchase**
+ - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
+ - **Body**: `{ package_id: string, credits_added: number, platform?: 'capacitor'|'web', transaction_id?: string, subscription_active?: boolean }`
+ - **Response**: `{ message, balance, subscription_active }`
+ - **Hinweis**: Wird nach erfolgreichen In-App-Kuferfolgen aufgerufen, aktualisiert Balance & Ledger.
+
+### Credits synchronisieren
+- **POST /api/v1/tenant/credits/sync**
+ - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
+ - **Body**: `{ balance: number, subscription_active: boolean, last_sync: ISODateString }`
+ - **Response**: `{ balance, subscription_active, server_time }`
+ - **Hinweis**: Client meldet lokalen Stand; Server gibt Quelle-der-Wahrheit zurcck.
+
### Kauf-Intent erstellen
- **POST /api/v1/tenant/purchases/intent**
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
@@ -242,8 +257,9 @@ curl -H "Authorization: Bearer {token}" \
## Deployment
### Environment-Variablen
-- **REACT_APP_API_URL**: Backend-API-URL (Pflicht)
-- **REACT_APP_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
+- **VITE_API_URL**: Backend-API-URL (Pflicht)
+- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
+- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
### Build & Deploy
1. **Development**: `npm run dev`
@@ -254,4 +270,5 @@ curl -H "Authorization: Bearer {token}" \
- App ist PWA-fähig (Manifest, Service Worker).
- Installierbar auf Desktop/Mobile via "Zum Startbildschirm hinzufügen".
-Für weitere Details siehe die spezifischen Dokumentationsdateien.
\ No newline at end of file
+Für weitere Details siehe die spezifischen Dokumentationsdateien.
+
diff --git a/docs/screenshots/1start.png b/docs/screenshots/1start.png
new file mode 100644
index 0000000..8c9eb0f
Binary files /dev/null and b/docs/screenshots/1start.png differ
diff --git a/docs/screenshots/2start.png b/docs/screenshots/2start.png
new file mode 100644
index 0000000..782c381
Binary files /dev/null and b/docs/screenshots/2start.png differ
diff --git a/docs/screenshots/3emotionpicker.png b/docs/screenshots/3emotionpicker.png
new file mode 100644
index 0000000..8701eac
Binary files /dev/null and b/docs/screenshots/3emotionpicker.png differ
diff --git a/docs/screenshots/4-taskscreen.png b/docs/screenshots/4-taskscreen.png
new file mode 100644
index 0000000..bac3ce3
Binary files /dev/null and b/docs/screenshots/4-taskscreen.png differ
diff --git a/docs/screenshots/5-camerapage.png b/docs/screenshots/5-camerapage.png
new file mode 100644
index 0000000..40bf76c
Binary files /dev/null and b/docs/screenshots/5-camerapage.png differ
diff --git a/docs/screenshots/6-general-landing-page.png b/docs/screenshots/6-general-landing-page.png
new file mode 100644
index 0000000..2669ab1
Binary files /dev/null and b/docs/screenshots/6-general-landing-page.png differ
diff --git a/docs/screenshots/7-event-landing-page.png b/docs/screenshots/7-event-landing-page.png
new file mode 100644
index 0000000..fa0554e
Binary files /dev/null and b/docs/screenshots/7-event-landing-page.png differ
diff --git a/docs/wireframes/guest-pwa.md b/docs/wireframes/guest-pwa.md
index 17221fc..69ff88c 100644
--- a/docs/wireframes/guest-pwa.md
+++ b/docs/wireframes/guest-pwa.md
@@ -12,13 +12,15 @@ flowchart TD
E --> F[Upload Queue]
D --> G[Photo Lightbox]
D --> H[Filters]
- D --> I[Settings]
+ D --> I[Settings Sheet]
F --> D
G --> D
```
Gallery (mobile)
+Settings open in-place via a sheet launched from the header gear; the sheet focuses on legal text and cache management while the theme toggle remains in the header.
+
```
+--------------------------------------------------+
| Toolbar: [Scan?] [Filter] [Upload] [Menu] |
diff --git a/package-lock.json b/package-lock.json
index 3f70703..e282a84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "html",
+ "name": "fotospiel-app",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,6 +7,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
+ "@playwright/mcp": "^0.0.37",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@@ -28,6 +29,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
+ "html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0",
"playwright": "^1.55.0",
@@ -43,6 +45,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
+ "@playwright/test": "^1.55.0",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
@@ -1773,6 +1776,78 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@playwright/mcp": {
+ "version": "0.0.37",
+ "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.37.tgz",
+ "integrity": "sha512-BnI2Ijim1rhIGhoFKJRCa+MaWtNr7M2lnLeDldDsR0n+ZB2G7zjt+MAMqy5eRD/mMiWsTaQsXlzZmXeixqBdsA==",
+ "dependencies": {
+ "playwright": "1.56.0-alpha-2025-09-06",
+ "playwright-core": "1.56.0-alpha-2025-09-06"
+ },
+ "bin": {
+ "mcp-server-playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@playwright/mcp/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/@playwright/mcp/node_modules/playwright": {
+ "version": "1.56.0-alpha-2025-09-06",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-2025-09-06.tgz",
+ "integrity": "sha512-suVjiF5eeUtIqFq5E/5LGgkV0/bRSik87N+M7uLsjPQrKln9QHbZt3cy7Zybicj3ZqTBWWHvpN9b4cnpg6hS0g==",
+ "dependencies": {
+ "playwright-core": "1.56.0-alpha-2025-09-06"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/@playwright/mcp/node_modules/playwright-core": {
+ "version": "1.56.0-alpha-2025-09-06",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-2025-09-06.tgz",
+ "integrity": "sha512-B2s/cuqYuu+mT4hIHG8gIOXjCSKh0Np1gJNCp0CrDk/UTLB74gThwXiyPAJU0fADIQH6Dv1glv8ZvKTDVT8Fng==",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
+ "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.55.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5991,6 +6066,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html5-qrcode": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
+ "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -8818,7 +8899,6 @@
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.3.1.tgz",
"integrity": "sha512-sgai5gahy/TiyTiqJEwIFpAuPhmkpt7sGVdRfcmNH53Yc3yI57+zFVmIaqbTST0jP/7tSqZuI0aSllXL2HIw5w==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@antfu/ni": "^25.0.0",
"@babel/core": "^7.28.0",
diff --git a/package.json b/package.json
index 69da078..5c8b141 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
+ "@playwright/test": "^1.55.0",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.1",
@@ -27,6 +28,7 @@
"dependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
+ "@playwright/mcp": "^0.0.37",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@@ -48,6 +50,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
+ "html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.475.0",
"playwright": "^1.55.0",
diff --git a/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm b/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm
new file mode 100644
index 0000000..4fe4093
Binary files /dev/null and b/playwright-report/data/346c13d5b5efe55fbc31bac706e3819995351c5a.webm differ
diff --git a/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md b/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md
new file mode 100644
index 0000000..48fb67e
--- /dev/null
+++ b/playwright-report/data/3f9362498fef5095ac418b481173c877d2003abf.md
@@ -0,0 +1,36 @@
+# Page snapshot
+
+```yaml
+- generic [ref=e4]:
+ - generic [ref=e5]:
+ - link [ref=e6] [cursor=pointer]:
+ - /url: https://laravel.com
+ - img [ref=e7] [cursor=pointer]
+ - img [ref=e9]
+ - link [ref=e11] [cursor=pointer]:
+ - /url: https://vitejs.dev
+ - img [ref=e12] [cursor=pointer]
+ - generic [ref=e15]:
+ - generic [ref=e16]:
+ - paragraph [ref=e17]: This is the Vite development server that provides Hot Module Replacement for your Laravel application.
+ - paragraph [ref=e18]: To access your Laravel application, you will need to run a local development server.
+ - heading "Artisan Serve" [level=2] [ref=e19]:
+ - link "Artisan Serve" [ref=e20] [cursor=pointer]:
+ - /url: https://laravel.com/docs/installation#your-first-laravel-project
+ - paragraph [ref=e21]: Laravel's local development server powered by PHP's built-in web server.
+ - heading "Laravel Sail" [level=2] [ref=e22]:
+ - link "Laravel Sail" [ref=e23] [cursor=pointer]:
+ - /url: https://laravel.com/docs/sail
+ - paragraph [ref=e24]: A light-weight command-line interface for interacting with Laravel's default Docker development environment.
+ - generic [ref=e25]:
+ - paragraph [ref=e26]:
+ - text: Your Laravel application's configured
+ - code [ref=e27]: APP_URL
+ - text: "is:"
+ - link "http://localhost:8000" [ref=e28] [cursor=pointer]:
+ - /url: http://localhost:8000
+ - paragraph [ref=e29]: Want more information on Laravel's Vite integration?
+ - paragraph [ref=e30]:
+ - link "Read the docs →" [ref=e31] [cursor=pointer]:
+ - /url: https://laravel.com/docs/vite
+```
\ No newline at end of file
diff --git a/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm b/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm
new file mode 100644
index 0000000..ce133f9
Binary files /dev/null and b/playwright-report/data/85e55eb23cc05de7d445e0361a6021c61dae8afe.webm differ
diff --git a/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png b/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png
new file mode 100644
index 0000000..a665ba0
Binary files /dev/null and b/playwright-report/data/ab8955fe4c74a2c8835d79a54de138dea7be6493.png differ
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..463556d
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..94e1ad8
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Read environment variables from file.
+ * https://playwright.dev/docs/test-configuration#launching-the-player
+ */
+function getDisplayValue(value: string | undefined) {
+ return value === undefined ? '1' : value;
+}
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:8000',
+ trace: 'on-first-retry',
+ headless: true,
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
\ No newline at end of file
diff --git a/public/guest-sw.js b/public/guest-sw.js
index 30aef4d..033080f 100644
--- a/public/guest-sw.js
+++ b/public/guest-sw.js
@@ -7,25 +7,52 @@ self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
+const ASSETS_CACHE = 'guest-assets-v1';
+const IMAGES_CACHE = 'guest-images-v1';
+
self.addEventListener('fetch', (event) => {
const req = event.request;
- if (req.method !== 'GET' || !req.url.startsWith(self.location.origin)) return;
- event.respondWith((async () => {
- const cache = await caches.open('guest-runtime');
- const cached = await cache.match(req);
- if (cached) return cached;
- try {
- const res = await fetch(req);
- // Cache static assets and images
- const ct = res.headers.get('content-type') || '';
- if (res.ok && (ct.includes('text/css') || ct.includes('javascript') || ct.startsWith('image/'))) {
- cache.put(req, res.clone());
+ if (req.method !== 'GET') return;
+
+ const url = new URL(req.url);
+ // Only handle same-origin requests
+ if (url.origin !== self.location.origin) return;
+
+ // Never cache API calls; let them hit network directly
+ if (url.pathname.startsWith('/api/')) return;
+
+ // Cache-first for images
+ if (req.destination === 'image' || /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname)) {
+ event.respondWith((async () => {
+ const cache = await caches.open(IMAGES_CACHE);
+ const cached = await cache.match(req);
+ if (cached) return cached;
+ try {
+ const res = await fetch(req, { credentials: 'same-origin' });
+ if (res.ok) cache.put(req, res.clone());
+ return res;
+ } catch (e) {
+ return cached || Response.error();
}
- return res;
- } catch (e) {
- return cached || new Response('Offline', { status: 503 });
- }
- })());
+ })());
+ return;
+ }
+
+ // Stale-while-revalidate for CSS/JS assets
+ if (req.destination === 'style' || req.destination === 'script') {
+ event.respondWith((async () => {
+ const cache = await caches.open(ASSETS_CACHE);
+ const cached = await cache.match(req);
+ const networkPromise = fetch(req, { credentials: 'same-origin' })
+ .then((res) => {
+ if (res.ok) cache.put(req, res.clone());
+ return res;
+ })
+ .catch(() => null);
+ return cached || (await networkPromise) || Response.error();
+ })());
+ return;
+ }
});
self.addEventListener('sync', (event) => {
diff --git a/redirect_uris b/redirect_uris
new file mode 100644
index 0000000..e69de29
diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts
index f316e0e..bff136f 100644
--- a/resources/js/admin/api.ts
+++ b/resources/js/admin/api.ts
@@ -1,98 +1,103 @@
-export async function login(email: string, password: string): Promise<{ token: string }> {
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
- const res = await fetch('/api/v1/tenant/login', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken || '',
- },
- body: JSON.stringify({ email, password }),
- });
- if (!res.ok) throw new Error('Login failed');
- return res.json();
+import { authorizedFetch } from './auth/tokens';
+
+type JsonValue = Record;
+
+async function jsonOrThrow(response: Response, message: string): Promise {
+ if (!response.ok) {
+ const body = await safeJson(response);
+ console.error('[API]', message, response.status, body);
+ throw new Error(message);
+ }
+ return (await response.json()) as T;
+}
+
+async function safeJson(response: Response): Promise {
+ try {
+ return (await response.clone().json()) as JsonValue;
+ } catch {
+ return null;
+ }
}
export async function getEvents(): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch('/api/v1/tenant/events', {
- headers: { Authorization: `Bearer ${token}` },
- });
- if (!res.ok) throw new Error('Failed to load events');
- const json = await res.json();
- return json.data ?? [];
+ const response = await authorizedFetch('/api/v1/tenant/events');
+ const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
+ return data.data ?? [];
}
export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch('/api/v1/tenant/events', {
+ const response = await authorizedFetch('/api/v1/tenant/events', {
method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
- if (!res.ok) throw new Error('Failed to create event');
- const json = await res.json();
- return json.id;
+ const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
+ return data.id;
}
-export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}`, {
+export async function updateEvent(
+ id: number,
+ payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
+): Promise {
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
method: 'PUT',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
- if (!res.ok) throw new Error('Failed to update event');
+ if (!response.ok) {
+ await safeJson(response);
+ throw new Error('Failed to update event');
+ }
}
export async function getEventPhotos(id: number): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } });
- if (!res.ok) throw new Error('Failed to load photos');
- const json = await res.json();
- return json.data ?? [];
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
+ const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
+ return data.data ?? [];
}
-export async function featurePhoto(id: number) {
- const token = localStorage.getItem('ta_token') || '';
- await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
+export async function featurePhoto(id: number): Promise {
+ const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
+ if (!response.ok) {
+ await safeJson(response);
+ throw new Error('Failed to feature photo');
+ }
}
-export async function unfeaturePhoto(id: number) {
- const token = localStorage.getItem('ta_token') || '';
- await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
+export async function unfeaturePhoto(id: number): Promise {
+ const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
+ if (!response.ok) {
+ await safeJson(response);
+ throw new Error('Failed to unfeature photo');
+ }
}
export async function getEvent(id: number): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } });
- if (!res.ok) throw new Error('Failed to load event');
- return res.json();
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
+ return jsonOrThrow(response, 'Failed to load event');
}
export async function toggleEvent(id: number): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
- if (!res.ok) throw new Error('Failed to toggle');
- const json = await res.json();
- return !!json.is_active;
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
+ const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event');
+ return !!data.is_active;
}
export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } });
- if (!res.ok) throw new Error('Failed to load stats');
- return res.json();
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`);
+ return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats');
}
export async function createInviteLink(id: number): Promise {
- const token = localStorage.getItem('ta_token') || '';
- const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
- if (!res.ok) throw new Error('Failed to create invite');
- const json = await res.json();
- return json.link as string;
+ const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' });
+ const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite');
+ return data.link;
}
-export async function deletePhoto(id: number) {
- const token = localStorage.getItem('ta_token') || '';
- await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
+export async function deletePhoto(id: number): Promise {
+ const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
+ if (!response.ok) {
+ await safeJson(response);
+ throw new Error('Failed to delete photo');
+ }
}
diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx
new file mode 100644
index 0000000..11446e6
--- /dev/null
+++ b/resources/js/admin/auth/context.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import {
+ authorizedFetch,
+ clearOAuthSession,
+ clearTokens,
+ completeOAuthCallback,
+ isAuthError,
+ loadTokens,
+ registerAuthFailureHandler,
+ startOAuthFlow,
+} from './tokens';
+
+export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
+
+export interface TenantProfile {
+ id: number;
+ tenant_id: number;
+ name?: string;
+ slug?: string;
+ email?: string | null;
+ event_credits_balance?: number;
+ [key: string]: unknown;
+}
+
+interface AuthContextValue {
+ status: AuthStatus;
+ user: TenantProfile | null;
+ login: (redirectPath?: string) => void;
+ logout: (options?: { redirect?: string }) => void;
+ completeLogin: (params: URLSearchParams) => Promise;
+ refreshProfile: () => Promise;
+}
+
+const AuthContext = React.createContext(undefined);
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [status, setStatus] = React.useState('loading');
+ const [user, setUser] = React.useState(null);
+
+ const handleAuthFailure = React.useCallback(() => {
+ clearTokens();
+ setUser(null);
+ setStatus('unauthenticated');
+ }, []);
+
+ React.useEffect(() => {
+ const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
+ return unsubscribe;
+ }, [handleAuthFailure]);
+
+ const refreshProfile = React.useCallback(async () => {
+ try {
+ const response = await authorizedFetch('/api/v1/tenant/me');
+ if (!response.ok) {
+ throw new Error('Failed to load profile');
+ }
+ const profile = (await response.json()) as TenantProfile;
+ setUser(profile);
+ setStatus('authenticated');
+ } catch (error) {
+ if (isAuthError(error)) {
+ handleAuthFailure();
+ } else {
+ console.error('[Auth] Failed to refresh profile', error);
+ }
+ throw error;
+ }
+ }, [handleAuthFailure]);
+
+ React.useEffect(() => {
+ const tokens = loadTokens();
+ if (!tokens) {
+ setStatus('unauthenticated');
+ return;
+ }
+
+ refreshProfile().catch(() => {
+ // refreshProfile already handled failures.
+ });
+ }, [refreshProfile]);
+
+ const login = React.useCallback((redirectPath?: string) => {
+ const target = redirectPath ?? window.location.pathname + window.location.search;
+ startOAuthFlow(target);
+ }, []);
+
+ const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
+ clearTokens();
+ clearOAuthSession();
+ setUser(null);
+ setStatus('unauthenticated');
+ if (redirect) {
+ window.location.href = redirect;
+ }
+ }, []);
+
+ const completeLogin = React.useCallback(
+ async (params: URLSearchParams) => {
+ setStatus('loading');
+ try {
+ const redirectTarget = await completeOAuthCallback(params);
+ await refreshProfile();
+ return redirectTarget;
+ } catch (error) {
+ handleAuthFailure();
+ throw error;
+ }
+ },
+ [handleAuthFailure, refreshProfile]
+ );
+
+ const value = React.useMemo(
+ () => ({ status, user, login, logout, completeLogin, refreshProfile }),
+ [status, user, login, logout, completeLogin, refreshProfile]
+ );
+
+ return {children};
+};
+
+export function useAuth(): AuthContextValue {
+ const context = React.useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+}
diff --git a/resources/js/admin/auth/pkce.ts b/resources/js/admin/auth/pkce.ts
new file mode 100644
index 0000000..af6ea37
--- /dev/null
+++ b/resources/js/admin/auth/pkce.ts
@@ -0,0 +1,16 @@
+import { base64UrlEncode } from './utils';
+
+export function generateState(): string {
+ return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(32)));
+}
+
+export function generateCodeVerifier(): string {
+ // RFC 7636 recommends a length between 43 and 128 characters.
+ return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(64)));
+}
+
+export async function generateCodeChallenge(verifier: string): Promise {
+ const data = new TextEncoder().encode(verifier);
+ const digest = await window.crypto.subtle.digest('SHA-256', data);
+ return base64UrlEncode(new Uint8Array(digest));
+}
diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts
new file mode 100644
index 0000000..aa794ca
--- /dev/null
+++ b/resources/js/admin/auth/tokens.ts
@@ -0,0 +1,238 @@
+import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
+import { decodeStoredTokens } from './utils';
+
+const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
+const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
+const STATE_KEY = 'tenant_oauth_state';
+const REDIRECT_KEY = 'tenant_oauth_redirect';
+const TOKEN_ENDPOINT = '/api/v1/oauth/token';
+const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
+const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
+
+function getClientId(): string {
+ const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
+ if (!clientId) {
+ throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
+ }
+ return clientId;
+}
+
+function buildRedirectUri(): string {
+ return new URL('/admin/auth/callback', window.location.origin).toString();
+}
+
+export class AuthError extends Error {
+ constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
+ super(message ?? code);
+ this.name = 'AuthError';
+ }
+}
+
+export function isAuthError(value: unknown): value is AuthError {
+ return value instanceof AuthError;
+}
+
+type AuthFailureHandler = () => void;
+const authFailureHandlers = new Set();
+
+function notifyAuthFailure() {
+ authFailureHandlers.forEach((handler) => {
+ try {
+ handler();
+ } catch (error) {
+ console.error('[Auth] Failure handler threw', error);
+ }
+ });
+}
+
+export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void {
+ authFailureHandlers.add(handler);
+ return () => {
+ authFailureHandlers.delete(handler);
+ };
+}
+
+export interface StoredTokens {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+ scope?: string;
+}
+
+export interface TokenResponse {
+ access_token: string;
+ refresh_token: string;
+ expires_in: number;
+ token_type: string;
+ scope?: string;
+}
+
+export function loadTokens(): StoredTokens | null {
+ const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
+ const stored = decodeStoredTokens(raw);
+ if (!stored) {
+ return null;
+ }
+
+ if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
+ clearTokens();
+ return null;
+ }
+
+ return stored;
+}
+
+export function saveTokens(response: TokenResponse): StoredTokens {
+ const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
+ const stored: StoredTokens = {
+ accessToken: response.access_token,
+ refreshToken: response.refresh_token,
+ expiresAt,
+ scope: response.scope,
+ };
+ localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
+ return stored;
+}
+
+export function clearTokens(): void {
+ localStorage.removeItem(TOKEN_STORAGE_KEY);
+}
+
+export async function ensureAccessToken(): Promise {
+ const tokens = loadTokens();
+ if (!tokens) {
+ notifyAuthFailure();
+ throw new AuthError('unauthenticated', 'No tokens available');
+ }
+
+ if (Date.now() < tokens.expiresAt) {
+ return tokens.accessToken;
+ }
+
+ return refreshAccessToken(tokens.refreshToken);
+}
+
+async function refreshAccessToken(refreshToken: string): Promise {
+ if (!refreshToken) {
+ notifyAuthFailure();
+ throw new AuthError('unauthenticated', 'Missing refresh token');
+ }
+
+ const params = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ client_id: getClientId(),
+ });
+
+ const response = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params,
+ });
+
+ if (!response.ok) {
+ console.warn('[Auth] Refresh token request failed', response.status);
+ notifyAuthFailure();
+ throw new AuthError('unauthenticated', 'Refresh token invalid');
+ }
+
+ const data = (await response.json()) as TokenResponse;
+ const stored = saveTokens(data);
+ return stored.accessToken;
+}
+
+export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise {
+ const token = await ensureAccessToken();
+ const headers = new Headers(init.headers);
+ headers.set('Authorization', `Bearer ${token}`);
+ if (!headers.has('Accept')) {
+ headers.set('Accept', 'application/json');
+ }
+
+ const response = await fetch(input, { ...init, headers });
+ if (response.status === 401) {
+ notifyAuthFailure();
+ throw new AuthError('unauthorized', 'Access token rejected');
+ }
+
+ return response;
+}
+
+export async function startOAuthFlow(redirectPath?: string): Promise {
+ const verifier = generateCodeVerifier();
+ const state = generateState();
+ const challenge = await generateCodeChallenge(verifier);
+
+ sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
+ sessionStorage.setItem(STATE_KEY, state);
+ if (redirectPath) {
+ sessionStorage.setItem(REDIRECT_KEY, redirectPath);
+ }
+
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: getClientId(),
+ redirect_uri: buildRedirectUri(),
+ scope: SCOPES,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+
+ window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
+}
+
+export async function completeOAuthCallback(params: URLSearchParams): Promise {
+ if (params.get('error')) {
+ throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
+ }
+
+ const code = params.get('code');
+ const returnedState = params.get('state');
+ const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
+ const expectedState = sessionStorage.getItem(STATE_KEY);
+
+ if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
+ notifyAuthFailure();
+ throw new AuthError('invalid_state', 'PKCE state mismatch');
+ }
+
+ sessionStorage.removeItem(CODE_VERIFIER_KEY);
+ sessionStorage.removeItem(STATE_KEY);
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ client_id: getClientId(),
+ redirect_uri: buildRedirectUri(),
+ code_verifier: verifier,
+ });
+
+ const response = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+
+ if (!response.ok) {
+ console.error('[Auth] Authorization code exchange failed', response.status);
+ notifyAuthFailure();
+ throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
+ }
+
+ const data = (await response.json()) as TokenResponse;
+ saveTokens(data);
+
+ const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
+ if (redirectTarget) {
+ sessionStorage.removeItem(REDIRECT_KEY);
+ }
+
+ return redirectTarget;
+}
+
+export function clearOAuthSession(): void {
+ sessionStorage.removeItem(CODE_VERIFIER_KEY);
+ sessionStorage.removeItem(STATE_KEY);
+ sessionStorage.removeItem(REDIRECT_KEY);
+}
diff --git a/resources/js/admin/auth/utils.ts b/resources/js/admin/auth/utils.ts
new file mode 100644
index 0000000..23614fa
--- /dev/null
+++ b/resources/js/admin/auth/utils.ts
@@ -0,0 +1,20 @@
+export function base64UrlEncode(buffer: Uint8Array): string {
+ let binary = '';
+ buffer.forEach((byte) => {
+ binary += String.fromCharCode(byte);
+ });
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+}
+
+export function decodeStoredTokens(value: string | null): T | null {
+ if (!value) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(value) as T;
+ } catch (error) {
+ console.warn('[Auth] Failed to parse stored tokens', error);
+ return null;
+ }
+}
diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx
index 3b7df0f..3e760b4 100644
--- a/resources/js/admin/main.tsx
+++ b/resources/js/admin/main.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
+import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
@@ -9,7 +10,8 @@ initializeTheme();
const rootEl = document.getElementById('root')!;
createRoot(rootEl).render(
-
+
+
+
);
-
diff --git a/resources/js/admin/pages/AuthCallbackPage.tsx b/resources/js/admin/pages/AuthCallbackPage.tsx
new file mode 100644
index 0000000..f32a3f5
--- /dev/null
+++ b/resources/js/admin/pages/AuthCallbackPage.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../auth/context';
+import { isAuthError } from '../auth/tokens';
+
+export default function AuthCallbackPage() {
+ const { completeLogin } = useAuth();
+ const navigate = useNavigate();
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ completeLogin(params)
+ .then((redirectTo) => {
+ navigate(redirectTo ?? '/admin', { replace: true });
+ })
+ .catch((err) => {
+ console.error('[Auth] Callback processing failed', err);
+ if (isAuthError(err) && err.code === 'token_exchange_failed') {
+ setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
+ } else if (isAuthError(err) && err.code === 'invalid_state') {
+ setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
+ } else {
+ setError('Unbekannter Fehler beim Login.');
+ }
+ });
+ }, [completeLogin, navigate]);
+
+ return (
+
+
Anmeldung wird verarbeitet ...
+ {error &&
{error}
}
+
+ );
+}
diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx
index aa912a8..ddecc1d 100644
--- a/resources/js/admin/pages/EventDetailPage.tsx
+++ b/resources/js/admin/pages/EventDetailPage.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { useNavigate, useSearchParams } from 'react-router-dom';
-import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api';
import { Button } from '@/components/ui/button';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api';
+import { isAuthError } from '../auth/tokens';
export default function EventDetailPage() {
const [sp] = useSearchParams();
@@ -11,35 +12,67 @@ export default function EventDetailPage() {
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
const [invite, setInvite] = React.useState(null);
- async function load() {
- const e = await getEvent(id);
- setEv(e);
- setStats(await getEventStats(id));
- }
- React.useEffect(() => { load(); }, [id]);
+ const load = React.useCallback(async () => {
+ try {
+ const event = await getEvent(id);
+ setEv(event);
+ setStats(await getEventStats(id));
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
+ }, [id]);
+
+ React.useEffect(() => {
+ load();
+ }, [load]);
async function onToggle() {
- const isActive = await toggleEvent(id);
- setEv((o: any) => ({ ...(o || {}), is_active: isActive }));
+ try {
+ const isActive = await toggleEvent(id);
+ setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
}
async function onInvite() {
- const link = await createInviteLink(id);
- setInvite(link);
- try { await navigator.clipboard.writeText(link); } catch {}
+ try {
+ const link = await createInviteLink(id);
+ setInvite(link);
+ try {
+ await navigator.clipboard.writeText(link);
+ } catch {
+ // clipboard may be unavailable
+ }
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
}
- if (!ev) return Lade…
;
- const joinLink = `${location.origin}/e/${ev.slug}`;
+ if (!ev) {
+ return Lade ...
;
+ }
+
+ const joinLink = `${window.location.origin}/e/${ev.slug}`;
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
return (
-
+
Event: {renderName(ev.name)}
-
-
+
+
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
Datum: {ev.date ?? '-'}
Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}
-
-
-
{stats?.featured ?? 0}
Featured
-
{stats?.likes ?? 0}
Likes gesamt
+
+
+
+
-
-
Join-Link
+
+
Join-Link
-
+
-
QR
+
QR
-
- {invite &&
Erzeugt und kopiert: {invite}
}
+
+ {invite && (
+
Erzeugt und kopiert: {invite}
+ )}
);
}
+function StatCard({ label, value }: { label: string; value: number }) {
+ return (
+
+ );
+}
+
function renderName(name: any): string {
if (typeof name === 'string') return name;
if (name && (name.de || name.en)) return name.de || name.en;
return JSON.stringify(name);
}
-
diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx
index a4e6a39..a01731a 100644
--- a/resources/js/admin/pages/EventFormPage.tsx
+++ b/resources/js/admin/pages/EventFormPage.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { createEvent, updateEvent } from '../api';
+import { isAuthError } from '../auth/tokens';
import { useNavigate, useSearchParams } from 'react-router-dom';
export default function EventFormPage() {
@@ -13,10 +14,12 @@ export default function EventFormPage() {
const [date, setDate] = React.useState('');
const [active, setActive] = React.useState(true);
const [saving, setSaving] = React.useState(false);
+ const [error, setError] = React.useState
(null);
const isEdit = !!id;
async function save() {
setSaving(true);
+ setError(null);
try {
if (isEdit) {
await updateEvent(Number(id), { name, slug, date, is_active: active });
@@ -24,21 +27,31 @@ export default function EventFormPage() {
await createEvent({ name, slug, date, is_active: active });
}
nav('/admin/events');
- } finally { setSaving(false); }
+ } catch (e) {
+ if (!isAuthError(e)) {
+ setError('Speichern fehlgeschlagen');
+ }
+ } finally {
+ setSaving(false);
+ }
}
return (
-
+
);
}
-
diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx
index 6907978..f5abd2f 100644
--- a/resources/js/admin/pages/EventPhotosPage.tsx
+++ b/resources/js/admin/pages/EventPhotosPage.tsx
@@ -1,7 +1,8 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
-import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { Button } from '@/components/ui/button';
+import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
+import { isAuthError } from '../auth/tokens';
export default function EventPhotosPage() {
const [sp] = useSearchParams();
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
const [rows, setRows] = React.useState
([]);
const [loading, setLoading] = React.useState(true);
- async function load() {
+ const load = React.useCallback(async () => {
setLoading(true);
- try { setRows(await getEventPhotos(id)); } finally { setLoading(false); }
- }
- React.useEffect(() => { load(); }, [id]);
+ try {
+ setRows(await getEventPhotos(id));
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [id]);
- async function onFeature(p: any) { await featurePhoto(p.id); load(); }
- async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); }
- async function onDelete(p: any) { await deletePhoto(p.id); load(); }
+ React.useEffect(() => {
+ load();
+ }, [load]);
+
+ async function onFeature(photo: any) {
+ try {
+ await featurePhoto(photo.id);
+ await load();
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
+ }
+
+ async function onUnfeature(photo: any) {
+ try {
+ await unfeaturePhoto(photo.id);
+ await load();
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
+ }
+
+ async function onDelete(photo: any) {
+ try {
+ await deletePhoto(photo.id);
+ await load();
+ } catch (error) {
+ if (!isAuthError(error)) {
+ console.error(error);
+ }
+ }
+ }
return (
Fotos moderieren
- {loading &&
Lade…
}
+ {loading &&
Lade ...
}
{rows.map((p) => (
-

+
-
❤ {p.likes_count}
+
?? {p.likes_count}
{p.is_featured ? (
-
+
) : (
-
+
)}
-
+
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
);
}
-
diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx
index 748e3d6..2330fbd 100644
--- a/resources/js/admin/pages/EventsPage.tsx
+++ b/resources/js/admin/pages/EventsPage.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { getEvents } from '../api';
import { Button } from '@/components/ui/button';
import { Link, useNavigate } from 'react-router-dom';
+import { getEvents } from '../api';
+import { isAuthError } from '../auth/tokens';
export default function EventsPage() {
const [rows, setRows] = React.useState
([]);
@@ -11,7 +12,15 @@ export default function EventsPage() {
React.useEffect(() => {
(async () => {
- try { setRows(await getEvents()); } catch (e) { setError('Laden fehlgeschlagen'); } finally { setLoading(false); }
+ try {
+ setRows(await getEvents());
+ } catch (err) {
+ if (!isAuthError(err)) {
+ setError('Laden fehlgeschlagen');
+ }
+ } finally {
+ setLoading(false);
+ }
})();
}, []);
@@ -20,24 +29,32 @@ export default function EventsPage() {
Meine Events
-
-
+
+
+
+
- {loading && Lade…
}
- {error && {error}
}
+ {loading && Lade ...
}
+ {error && (
+ {error}
+ )}
- {rows.map((e) => (
-
+ {rows.map((event) => (
+
-
{renderName(e.name)}
-
Slug: {e.slug} · Datum: {e.date ?? '-'}
+
{renderName(event.name)}
+
Slug: {event.slug} � Datum: {event.date ?? '-'}
-
-
details
-
bearbeiten
-
fotos
-
öffnen
+
))}
diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx
index afd37c7..c37c133 100644
--- a/resources/js/admin/pages/LoginPage.tsx
+++ b/resources/js/admin/pages/LoginPage.tsx
@@ -1,43 +1,61 @@
import React from 'react';
-import { login } from '../api';
-import { useNavigate } from 'react-router-dom';
-import { Input } from '@/components/ui/input';
+import { Location, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
+import { useAuth } from '../auth/context';
+
+interface LocationState {
+ from?: Location;
+}
export default function LoginPage() {
- const nav = useNavigate();
- const [email, setEmail] = React.useState('');
- const [password, setPassword] = React.useState('');
- const [error, setError] = React.useState
(null);
- const [loading, setLoading] = React.useState(false);
+ const { status, login } = useAuth();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
+ const oauthError = searchParams.get('error');
- async function submit(e: React.FormEvent) {
- e.preventDefault();
- setError(null);
- setLoading(true);
- try {
- const { token } = await login(email, password);
- localStorage.setItem('ta_token', token);
- nav('/admin', { replace: true });
- } catch (err: any) {
- setError('Login fehlgeschlagen');
- } finally { setLoading(false); }
- }
+ React.useEffect(() => {
+ if (status === 'authenticated') {
+ navigate('/admin', { replace: true });
+ }
+ }, [status, navigate]);
+
+ const redirectTarget = React.useMemo(() => {
+ const state = location.state as LocationState | null;
+ if (state?.from) {
+ const from = state.from;
+ const search = from.search ?? '';
+ const hash = from.hash ?? '';
+ return `${from.pathname}${search}${hash}`;
+ }
+ return '/admin';
+ }, [location.state]);
return (
-
-
+
+
-
+
+
+ Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
+ wieder zur Admin-Oberflche gebracht.
+
+ {oauthError && (
+
+ Anmeldung fehlgeschlagen: {oauthError}
+
+ )}
+
+
);
}
-
diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx
index 39318fa..c9bee06 100644
--- a/resources/js/admin/pages/SettingsPage.tsx
+++ b/resources/js/admin/pages/SettingsPage.tsx
@@ -2,22 +2,34 @@ import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../auth/context';
export default function SettingsPage() {
const nav = useNavigate();
- function logout() {
- localStorage.removeItem('ta_token');
- nav('/admin/login', { replace: true });
+ const { user, logout } = useAuth();
+
+ function handleLogout() {
+ logout({ redirect: '/admin/login' });
}
+
return (
-
-
Einstellungen
-
+
+
+
Einstellungen
+ {user && (
+
+ Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
+
+ )}
+
+
-
+
+
+
+
);
}
-
diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx
index 9a8f55e..318b5cc 100644
--- a/resources/js/admin/router.tsx
+++ b/resources/js/admin/router.tsx
@@ -1,20 +1,36 @@
import React from 'react';
-import { createBrowserRouter, Outlet, Navigate } from 'react-router-dom';
+import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import EventsPage from './pages/EventsPage';
import SettingsPage from './pages/SettingsPage';
import EventFormPage from './pages/EventFormPage';
import EventPhotosPage from './pages/EventPhotosPage';
import EventDetailPage from './pages/EventDetailPage';
+import AuthCallbackPage from './pages/AuthCallbackPage';
+import { useAuth } from './auth/context';
function RequireAuth() {
- const token = localStorage.getItem('ta_token');
- if (!token) return
;
+ const { status } = useAuth();
+ const location = useLocation();
+
+ if (status === 'loading') {
+ return (
+
+ Bitte warten
+
+ );
+ }
+
+ if (status === 'unauthenticated') {
+ return
;
+ }
+
return
;
}
export const router = createBrowserRouter([
{ path: '/admin/login', element:
},
+ { path: '/admin/auth/callback', element:
},
{
path: '/admin',
element:
,
diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx
index c2e1784..cc0bf81 100644
--- a/resources/js/guest/components/Header.tsx
+++ b/resources/js/guest/components/Header.tsx
@@ -1,18 +1,25 @@
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { Link } from 'react-router-dom';
-import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
+import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
-import { Settings, ChevronDown, User } from 'lucide-react';
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { User } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
-import { usePollStats } from '../polling/usePollStats';
+import { useOptionalEventStats } from '../context/EventStatsContext';
+import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
+import { SettingsSheet } from './settings-sheet';
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
+ const statsContext = useOptionalEventStats();
+ const identity = useOptionalGuestIdentity();
+
if (!slug) {
+ const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return (
-
{title}
+
+
{title}
+ {guestName && (
+
Hi {guestName}
+ )}
+
@@ -22,7 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
}
const { event, loading: eventLoading, error: eventError } = useEventData();
- const stats = usePollStats(slug);
+ const stats = statsContext && statsContext.slug === slug ? statsContext : undefined;
+ const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null;
if (eventLoading) {
return (
@@ -48,7 +56,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
- // Get event icon or generate initials
const getEventAvatar = (event: any) => {
if (event.type?.icon) {
return (
@@ -57,8 +64,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
-
- // Fallback to initials
+
const getInitials = (name: string) => {
const words = name.split(' ');
if (words.length >= 2) {
@@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
{getEventAvatar(event)}
{event.name}
+ {guestName && (
+
Hi {guestName}
+ )}
{stats && (
<>
@@ -87,9 +96,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
{stats.onlineGuests} online
-
•
+
|
- {stats.tasksSolved} Aufgaben gelöst
+ {stats.tasksSolved} Aufgaben geloest
>
)}
@@ -104,88 +113,4 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
-function SettingsSheet() {
- return (
-
-
-
-
-
-
- Einstellungen
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Rechtliches
-
-
-
-
-
-
- - Impressum
- - Datenschutz
- - AGB
-
-
-
-
-
-
- );
-}
-
-function ClearCacheButton() {
- const [busy, setBusy] = React.useState(false);
- const [done, setDone] = React.useState(false);
-
- async function clearAll() {
- setBusy(true); setDone(false);
- try {
- // Clear CacheStorage
- if ('caches' in window) {
- const keys = await caches.keys();
- await Promise.all(keys.map((k) => caches.delete(k)));
- }
- // Clear known IndexedDB dbs (best-effort)
- if ('indexedDB' in window) {
- try { await new Promise((res, rej) => { const r = indexedDB.deleteDatabase('upload-queue'); r.onsuccess=()=>res(null); r.onerror=()=>res(null); }); } catch {}
- }
- setDone(true);
- } finally {
- setBusy(false);
- setTimeout(() => setDone(false), 2500);
- }
- }
-
- return (
-
-
- {done &&
Cache gelöscht.
}
-
- );
-}
+export {}
diff --git a/resources/js/guest/components/legal-markdown.tsx b/resources/js/guest/components/legal-markdown.tsx
new file mode 100644
index 0000000..0b9df4d
--- /dev/null
+++ b/resources/js/guest/components/legal-markdown.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+
+type Props = {
+ markdown: string;
+};
+
+export function LegalMarkdown({ markdown }: Props) {
+ const html = React.useMemo(() => {
+ let safe = markdown
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ safe = safe.replace(/\*\*(.+?)\*\*/g, '
$1');
+ safe = safe.replace(/(?$1');
+ safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '
$1');
+ safe = safe
+ .split(/\n{2,}/)
+ .map((block) => `
${block.replace(/\n/g, '
')}
`)
+ .join('\n');
+ return safe;
+ }, [markdown]);
+
+ return
;
+}
\ No newline at end of file
diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx
new file mode 100644
index 0000000..46b672c
--- /dev/null
+++ b/resources/js/guest/components/settings-sheet.tsx
@@ -0,0 +1,401 @@
+import React from "react";
+import { Button } from '@/components/ui/button';
+import {
+ Sheet,
+ SheetTrigger,
+ SheetContent,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+} from '@/components/ui/sheet';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
+import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
+import { LegalMarkdown } from './legal-markdown';
+
+const legalPages = [
+ { slug: 'impressum', label: 'Impressum' },
+ { slug: 'datenschutz', label: 'Datenschutz' },
+ { slug: 'agb', label: 'AGB' },
+] as const;
+
+type ViewState =
+ | { mode: 'home' }
+ | { mode: 'legal'; slug: (typeof legalPages)[number]['slug']; label: string };
+
+type LegalDocumentState =
+ | { phase: 'idle'; title: string; body: string }
+ | { phase: 'loading'; title: string; body: string }
+ | { phase: 'ready'; title: string; body: string }
+ | { phase: 'error'; title: string; body: string };
+
+type NameStatus = 'idle' | 'saved';
+
+export function SettingsSheet() {
+ const [open, setOpen] = React.useState(false);
+ const [view, setView] = React.useState
({ mode: 'home' });
+ const identity = useOptionalGuestIdentity();
+ const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
+ const [nameStatus, setNameStatus] = React.useState('idle');
+ const [savingName, setSavingName] = React.useState(false);
+ const isLegal = view.mode === 'legal';
+ const legalDocument = useLegalDocument(isLegal ? view.slug : null);
+
+ React.useEffect(() => {
+ if (open && identity?.hydrated) {
+ setNameDraft(identity.name ?? '');
+ setNameStatus('idle');
+ }
+ }, [open, identity?.hydrated, identity?.name]);
+
+ const handleBack = React.useCallback(() => {
+ setView({ mode: 'home' });
+ }, []);
+
+ const handleOpenLegal = React.useCallback(
+ (slug: (typeof legalPages)[number]['slug'], label: string) => {
+ setView({ mode: 'legal', slug, label });
+ },
+ []
+ );
+
+ const handleOpenChange = React.useCallback((next: boolean) => {
+ setOpen(next);
+ if (!next) {
+ setView({ mode: 'home' });
+ setNameStatus('idle');
+ }
+ }, []);
+
+ const canSaveName = Boolean(
+ identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
+ );
+
+ const handleSaveName = React.useCallback(() => {
+ if (!identity || !canSaveName) {
+ return;
+ }
+ setSavingName(true);
+ try {
+ identity.setName(nameDraft);
+ setNameStatus('saved');
+ window.setTimeout(() => setNameStatus('idle'), 2000);
+ } finally {
+ setSavingName(false);
+ }
+ }, [identity, nameDraft, canSaveName]);
+
+ const handleResetName = React.useCallback(() => {
+ if (!identity) return;
+ identity.clearName();
+ setNameDraft('');
+ setNameStatus('idle');
+ }, [identity]);
+
+ return (
+
+
+
+
+
+
+
+ {isLegal ? (
+
+
+
+
+ {legalDocument.phase === 'ready' && legalDocument.title
+ ? legalDocument.title
+ : view.label}
+
+
+ {legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
+
+
+
+ ) : (
+
+ Einstellungen
+
+ Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
+
+
+ )}
+
+
+
+ {isLegal ? (
+ handleOpenChange(false)} />
+ ) : (
+
+ )}
+
+
+
+ Gastbereich - Daten werden lokal im Browser gespeichert.
+
+
+
+
+ );
+}
+
+function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
+ if (document.phase === 'error') {
+ return (
+
+
+
+ Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
+
+
+
+
+ );
+ }
+
+ if (document.phase === 'loading' || document.phase === 'idle') {
+ return Dokument wird geladen...
;
+ }
+
+ return (
+
+
+
+ {document.title || 'Rechtlicher Hinweis'}
+
+
+
+
+
+
+ );
+}
+
+interface HomeViewProps {
+ identity: ReturnType;
+ nameDraft: string;
+ onNameChange: (value: string) => void;
+ onSaveName: () => void;
+ onResetName: () => void;
+ canSaveName: boolean;
+ savingName: boolean;
+ nameStatus: NameStatus;
+ onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void;
+}
+
+function HomeView({
+ identity,
+ nameDraft,
+ onNameChange,
+ onSaveName,
+ onResetName,
+ canSaveName,
+ savingName,
+ nameStatus,
+ onOpenLegal,
+}: HomeViewProps) {
+ return (
+
+ {identity && (
+
+
+ Dein Name
+
+ Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
+
+
+
+
+
+
+
+
+
+ onNameChange(event.target.value)}
+ autoComplete="name"
+ disabled={!identity.hydrated || savingName}
+ />
+
+
+
+
+
+ {nameStatus === 'saved' && (
+ Gespeichert (ok)
+ )}
+ {!identity.hydrated && (
+ Lade gespeicherten Namen...
+ )}
+
+
+
+ )}
+
+
+
+
+
+
+ Rechtliches
+
+
+
+ Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
+
+
+
+ {legalPages.map((page) => (
+
+ ))}
+
+
+
+
+
+ Offline Cache
+
+ Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
+
+
+
+
+
+
+ Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.
+
+
+
+
+ );
+}
+
+function useLegalDocument(slug: string | null): LegalDocumentState {
+ const [state, setState] = React.useState({
+ phase: 'idle',
+ title: '',
+ body: '',
+ });
+
+ React.useEffect(() => {
+ if (!slug) {
+ setState({ phase: 'idle', title: '', body: '' });
+ return;
+ }
+
+ const controller = new AbortController();
+ setState({ phase: 'loading', title: '', body: '' });
+
+ fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
+ headers: { 'Cache-Control': 'no-store' },
+ signal: controller.signal,
+ })
+ .then(async (res) => {
+ if (!res.ok) {
+ throw new Error('failed');
+ }
+ const payload = await res.json();
+ setState({
+ phase: 'ready',
+ title: payload.title ?? '',
+ body: payload.body_markdown ?? '',
+ });
+ })
+ .catch((error) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ console.error('Failed to load legal page', error);
+ setState({ phase: 'error', title: '', body: '' });
+ });
+
+ return () => controller.abort();
+ }, [slug]);
+
+ return state;
+}
+
+function ClearCacheButton() {
+ const [busy, setBusy] = React.useState(false);
+ const [done, setDone] = React.useState(false);
+
+ async function clearAll() {
+ setBusy(true);
+ setDone(false);
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((key) => caches.delete(key)));
+ }
+ if ('indexedDB' in window) {
+ try {
+ await new Promise((resolve) => {
+ const request = indexedDB.deleteDatabase('upload-queue');
+ request.onsuccess = () => resolve(null);
+ request.onerror = () => resolve(null);
+ });
+ } catch (error) {
+ console.warn('IndexedDB cleanup failed', error);
+ }
+ }
+ setDone(true);
+ } finally {
+ setBusy(false);
+ window.setTimeout(() => setDone(false), 2500);
+ }
+ }
+
+ return (
+
+
+ {done &&
Cache geloescht.
}
+
+ );
+}
diff --git a/resources/js/guest/context/EventStatsContext.tsx b/resources/js/guest/context/EventStatsContext.tsx
new file mode 100644
index 0000000..dd76d06
--- /dev/null
+++ b/resources/js/guest/context/EventStatsContext.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { usePollStats } from '../polling/usePollStats';
+
+type EventStatsContextValue = ReturnType & {
+ slug: string;
+};
+
+const EventStatsContext = React.createContext(undefined);
+
+export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
+ const stats = usePollStats(slug);
+ const value = React.useMemo(
+ () => ({ slug, ...stats }),
+ [slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
+ );
+ return {children};
+}
+
+export function useEventStats() {
+ const ctx = React.useContext(EventStatsContext);
+ if (!ctx) {
+ throw new Error('useEventStats must be used within an EventStatsProvider');
+ }
+ return ctx;
+}
+
+export function useOptionalEventStats() {
+ return React.useContext(EventStatsContext);
+}
diff --git a/resources/js/guest/context/GuestIdentityContext.tsx b/resources/js/guest/context/GuestIdentityContext.tsx
new file mode 100644
index 0000000..e4c301e
--- /dev/null
+++ b/resources/js/guest/context/GuestIdentityContext.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+
+type GuestIdentityContextValue = {
+ slug: string;
+ name: string;
+ hydrated: boolean;
+ setName: (nextName: string) => void;
+ clearName: () => void;
+ reload: () => void;
+};
+
+const GuestIdentityContext = React.createContext(undefined);
+
+function storageKey(slug: string) {
+ return `guestName_${slug}`;
+}
+
+export function readGuestName(slug: string) {
+ if (!slug || typeof window === 'undefined') {
+ return '';
+ }
+
+ try {
+ return window.localStorage.getItem(storageKey(slug)) ?? '';
+ } catch (error) {
+ console.warn('Failed to read guest name', error);
+ return '';
+ }
+}
+
+export function GuestIdentityProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
+ const [name, setNameState] = React.useState('');
+ const [hydrated, setHydrated] = React.useState(false);
+
+ const loadFromStorage = React.useCallback(() => {
+ if (!slug) {
+ setHydrated(true);
+ setNameState('');
+ return;
+ }
+
+ try {
+ const stored = window.localStorage.getItem(storageKey(slug));
+ setNameState(stored ?? '');
+ } catch (error) {
+ console.warn('Failed to read guest name from storage', error);
+ setNameState('');
+ } finally {
+ setHydrated(true);
+ }
+ }, [slug]);
+
+ React.useEffect(() => {
+ setHydrated(false);
+ loadFromStorage();
+ }, [loadFromStorage]);
+
+ const persistName = React.useCallback(
+ (nextName: string) => {
+ const trimmed = nextName.trim();
+ setNameState(trimmed);
+ try {
+ if (trimmed) {
+ window.localStorage.setItem(storageKey(slug), trimmed);
+ } else {
+ window.localStorage.removeItem(storageKey(slug));
+ }
+ } catch (error) {
+ console.warn('Failed to persist guest name', error);
+ }
+ },
+ [slug]
+ );
+
+ const clearName = React.useCallback(() => {
+ setNameState('');
+ try {
+ window.localStorage.removeItem(storageKey(slug));
+ } catch (error) {
+ console.warn('Failed to clear guest name', error);
+ }
+ }, [slug]);
+
+ const value = React.useMemo(
+ () => ({
+ slug,
+ name,
+ hydrated,
+ setName: persistName,
+ clearName,
+ reload: loadFromStorage,
+ }),
+ [slug, name, hydrated, persistName, clearName, loadFromStorage]
+ );
+
+ return {children};
+}
+
+export function useGuestIdentity() {
+ const ctx = React.useContext(GuestIdentityContext);
+ if (!ctx) {
+ throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
+ }
+ return ctx;
+}
+
+export function useOptionalGuestIdentity() {
+ return React.useContext(GuestIdentityContext);
+}
diff --git a/resources/js/guest/hooks/useGuestTaskProgress.ts b/resources/js/guest/hooks/useGuestTaskProgress.ts
new file mode 100644
index 0000000..02a1782
--- /dev/null
+++ b/resources/js/guest/hooks/useGuestTaskProgress.ts
@@ -0,0 +1,104 @@
+import React from 'react';
+
+function storageKey(slug: string) {
+ return `guestTasks_${slug}`;
+}
+
+function parseStored(value: string | null) {
+ if (!value) {
+ return [] as number[];
+ }
+ try {
+ const parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) {
+ return parsed.filter((item) => Number.isInteger(item)) as number[];
+ }
+ return [];
+ } catch (error) {
+ console.warn('Failed to parse task progress from storage', error);
+ return [];
+ }
+}
+
+export function useGuestTaskProgress(slug: string | undefined) {
+ const [completed, setCompleted] = React.useState([]);
+ const [hydrated, setHydrated] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!slug) {
+ setCompleted([]);
+ setHydrated(true);
+ return;
+ }
+ try {
+ const stored = window.localStorage.getItem(storageKey(slug));
+ setCompleted(parseStored(stored));
+ } catch (error) {
+ console.warn('Failed to read task progress', error);
+ setCompleted([]);
+ } finally {
+ setHydrated(true);
+ }
+ }, [slug]);
+
+ const persist = React.useCallback(
+ (next: number[]) => {
+ if (!slug) return;
+ setCompleted(next);
+ try {
+ window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
+ } catch (error) {
+ console.warn('Failed to persist task progress', error);
+ }
+ },
+ [slug]
+ );
+
+ const markCompleted = React.useCallback(
+ (taskId: number) => {
+ if (!slug || !Number.isInteger(taskId)) {
+ return;
+ }
+ setCompleted((prev) => {
+ if (prev.includes(taskId)) {
+ return prev;
+ }
+ const next = [...prev, taskId];
+ try {
+ window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
+ } catch (error) {
+ console.warn('Failed to persist task progress', error);
+ }
+ return next;
+ });
+ },
+ [slug]
+ );
+
+ const clearProgress = React.useCallback(() => {
+ if (!slug) return;
+ setCompleted([]);
+ try {
+ window.localStorage.removeItem(storageKey(slug));
+ } catch (error) {
+ console.warn('Failed to clear task progress', error);
+ }
+ }, [slug]);
+
+ const isCompleted = React.useCallback(
+ (taskId: number | null | undefined) => {
+ if (!Number.isInteger(taskId)) return false;
+ return completed.includes(taskId as number);
+ },
+ [completed]
+ );
+
+ return {
+ hydrated,
+ completed,
+ completedCount: completed.length,
+ markCompleted,
+ clearProgress,
+ isCompleted,
+ };
+}
diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx
index d781d59..700e9c0 100644
--- a/resources/js/guest/pages/AchievementsPage.tsx
+++ b/resources/js/guest/pages/AchievementsPage.tsx
@@ -1,11 +1,444 @@
-import React from 'react';
-import { Page } from './_util';
+import React, { useEffect, useMemo, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Separator } from '@/components/ui/separator';
+import {
+ AchievementBadge,
+ AchievementsPayload,
+ FeedEntry,
+ LeaderboardEntry,
+ TimelinePoint,
+ TopPhotoHighlight,
+ TrendingEmotionHighlight,
+ fetchAchievements,
+} from '../services/achievementApi';
+import { useGuestIdentity } from '../context/GuestIdentityContext';
+import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
-export default function AchievementsPage() {
+function formatNumber(value: number): string {
+ return new Intl.NumberFormat('de-DE').format(value);
+}
+
+function formatRelativeTime(input: string): string {
+ const date = new Date(input);
+ if (Number.isNaN(date.getTime())) return '';
+ const diff = Date.now() - date.getTime();
+ const minute = 60_000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+ if (diff < minute) return 'gerade eben';
+ if (diff < hour) {
+ const minutes = Math.round(diff / minute);
+ return `vor ${minutes} Min`;
+ }
+ if (diff < day) {
+ const hours = Math.round(diff / hour);
+ return `vor ${hours} Std`;
+ }
+ const days = Math.round(diff / day);
+ return `vor ${days} Tagen`;
+}
+
+function badgeVariant(earned: boolean): string {
+ return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
+}
+
+function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
return (
-
- Badges and progress placeholder.
-
+
+
+
+
+
+
+ {title}
+ Top 5 Teilnehmer dieses Events
+
+
+
+ {entries.length === 0 ? (
+ {emptyCopy}
+ ) : (
+
+ {entries.map((entry, index) => (
+ -
+
+ #{index + 1}
+ {entry.guest || 'Gast'}
+
+
+ {entry.photos} Fotos
+ {entry.likes} Likes
+
+
+ ))}
+
+ )}
+
+
);
}
+function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
+ if (badges.length === 0) {
+ return (
+
+
+ Badges
+ Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.
+
+
+ Noch keine Badges verfuegbar.
+
+
+ );
+ }
+
+ return (
+
+
+ Badges
+ Dein Fortschritt bei den verfuegbaren Erfolgen.
+
+
+ {badges.map((badge) => (
+
+
+
+
{badge.title}
+
{badge.description}
+
+
+
+
+ {badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
+
+
+ ))}
+
+
+ );
+}
+
+function Timeline({ points }: { points: TimelinePoint[] }) {
+ if (points.length === 0) {
+ return null;
+ }
+ return (
+
+
+ Timeline
+ Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.
+
+
+ {points.map((point) => (
+
+ {point.date}
+ {point.photos} Fotos | {point.guests} Gaeste
+
+ ))}
+
+
+ );
+}
+
+function Feed({ feed }: { feed: FeedEntry[] }) {
+ if (feed.length === 0) {
+ return (
+
+
+ Live Feed
+ Neue Uploads erscheinen hier in Echtzeit.
+
+
+ Noch keine Uploads - starte die Kamera und lege los!
+
+
+ );
+ }
+
+ return (
+
+
+ Live Feed
+ Die neuesten Momente aus deinem Event.
+
+
+ {feed.map((item) => (
+
+ {item.thumbnail ? (
+

+ ) : (
+
+ )}
+
+
{item.guest || 'Gast'}
+ {item.task &&
Aufgabe: {item.task}
}
+
+ {formatRelativeTime(item.createdAt)}
+ {item.likes} Likes
+
+
+
+ ))}
+
+
+ );
+}
+
+function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) {
+ if (!topPhoto && !trendingEmotion) {
+ return null;
+ }
+
+ return (
+
+ {topPhoto && (
+
+
+
+ Publikumsliebling
+ Das Foto mit den meisten Likes.
+
+
+
+
+
+ {topPhoto.thumbnail ? (
+

+ ) : (
+
Kein Vorschau-Bild
+ )}
+
+ {topPhoto.guest || 'Gast'} � {topPhoto.likes} Likes
+ {topPhoto.task && Aufgabe: {topPhoto.task}
}
+ {formatRelativeTime(topPhoto.createdAt)}
+
+
+ )}
+
+ {trendingEmotion && (
+
+
+
+ Trend-Emotion
+ Diese Stimmung taucht gerade besonders oft auf.
+
+
+
+
+ {trendingEmotion.name}
+ {trendingEmotion.count} Fotos mit dieser Stimmung
+
+
+ )}
+
+ );
+}
+
+function SummaryCards({ data }: { data: AchievementsPayload }) {
+ return (
+
+
+
+ Fotos gesamt
+ {formatNumber(data.summary.totalPhotos)}
+
+
+
+
+ Aktive Gaeste
+ {formatNumber(data.summary.uniqueGuests)}
+
+
+
+
+ Erfuellte Aufgaben
+ {formatNumber(data.summary.tasksSolved)}
+
+
+
+
+ Likes insgesamt
+ {formatNumber(data.summary.likesTotal)}
+
+
+
+ );
+}
+
+function PersonalActions({ slug }: { slug: string }) {
+ return (
+
+
+
+
+ );
+}
+
+export default function AchievementsPage() {
+ const { slug } = useParams<{ slug: string }>();
+ const identity = useGuestIdentity();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal');
+
+ const personalName = identity.hydrated && identity.name ? identity.name : undefined;
+
+ useEffect(() => {
+ if (!slug) return;
+ const controller = new AbortController();
+ setLoading(true);
+ setError(null);
+
+ fetchAchievements(slug, personalName, controller.signal)
+ .then((payload) => {
+ setData(payload);
+ if (!payload.personal) {
+ setActiveTab('event');
+ }
+ })
+ .catch((err) => {
+ if (err.name === 'AbortError') return;
+ console.error('Failed to load achievements', err);
+ setError(err.message || 'Erfolge konnten nicht geladen werden.');
+ })
+ .finally(() => setLoading(false));
+
+ return () => controller.abort();
+ }, [slug, personalName]);
+
+ const hasPersonal = Boolean(data?.personal);
+
+ if (!slug) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
Erfolge
+
Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.
+
+
+
+
+ {loading && (
+
+
+
+
+
+ )}
+
+ {!loading && error && (
+
+
+ {error}
+
+
+
+ )}
+
+ {!loading && !error && data && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'personal' && hasPersonal && data.personal && (
+
+
+
+
+ Hi {data.personal.guestName || identity.name || 'Gast'}!
+
+ {data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
+
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'event' && (
+
+ )}
+
+ {activeTab === 'feed' &&
}
+ >
+ )}
+
+ );
+}
diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx
index 87a0ae9..cf3bda8 100644
--- a/resources/js/guest/pages/GalleryPage.tsx
+++ b/resources/js/guest/pages/GalleryPage.tsx
@@ -106,17 +106,7 @@ export default function GalleryPage() {
imageUrl = imageUrl.replace(/\/+/g, '/');
}
- // Extended debug logging
- console.log(`Photo ${p.id} URL processing:`, {
- id: p.id,
- original: imgSrc,
- thumbnail_path: p.thumbnail_path,
- file_path: p.file_path,
- cleanPath,
- finalUrl: imageUrl,
- isHttp: imageUrl?.startsWith('http'),
- startsWithStorage: imageUrl?.startsWith('/storage/')
- });
+ // Production: avoid heavy console logging for each image
return (
@@ -133,11 +123,8 @@ export default function GalleryPage() {
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="aspect-square w-full object-cover bg-gray-200"
onError={(e) => {
- console.error(`❌ Failed to load image ${p.id}:`, imageUrl);
- console.error('Error details:', e);
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
- onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
loading="lazy"
/>
diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx
index 088168e..0cdd6ed 100644
--- a/resources/js/guest/pages/HomePage.tsx
+++ b/resources/js/guest/pages/HomePage.tsx
@@ -1,37 +1,186 @@
-import React from 'react';
-import { Page } from './_util';
-import { useParams, Link } from 'react-router-dom';
-import { usePollStats } from '../polling/usePollStats';
+import React from 'react';
+import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
-import Header from '../components/Header';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
-import BottomNav from '../components/BottomNav';
+import { useGuestIdentity } from '../context/GuestIdentityContext';
+import { useEventStats } from '../context/EventStatsContext';
+import { useEventData } from '../hooks/useEventData';
+import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
+import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
export default function HomePage() {
- const { slug } = useParams();
- const stats = usePollStats(slug!);
+ const { slug } = useParams<{ slug: string }>();
+ const { name, hydrated } = useGuestIdentity();
+ const stats = useEventStats();
+ const { event } = useEventData();
+ const { completedCount } = useGuestTaskProgress(slug);
+
+ if (!slug) return null;
+
+ const displayName = hydrated && name ? name : 'Gast';
+ const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
+
+ const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
+ {
+ to: 'tasks',
+ label: 'Aufgabe ziehen',
+ description: 'Hol dir deine naechste Challenge',
+ icon:
,
+ },
+ {
+ to: 'upload',
+ label: 'Direkt hochladen',
+ description: 'Teile deine neuesten Fotos',
+ icon:
,
+ },
+ {
+ to: 'gallery',
+ label: 'Galerie ansehen',
+ description: 'Lass dich von anderen inspirieren',
+ icon:
,
+ },
+ ];
+
+ const checklistItems = [
+ 'Aufgabe auswaehlen oder starten',
+ 'Emotion festhalten und Foto schiessen',
+ 'Bild hochladen und Credits sammeln',
+ ];
+
return (
-
-
- {/* Consistent spacing */}
- {/* Prominent Draw Task Button */}
-
-
-
+
+
- {/* How do you feel? Section */}
-
+
+
+ }
+ label="Gleichzeitig online"
+ value={`${stats.onlineGuests}`}
+ />
+ }
+ label="Aufgaben gelöst"
+ value={`${stats.tasksSolved}`}
+ />
+ }
+ label="Letzter Upload"
+ value={latestUploadText}
+ />
+ }
+ label="Deine erledigten Aufgaben"
+ value={`${completedCount}`}
+ />
+
+
-
-
-
- {/* Bottom Navigation */}
-
-
+
+
+
Deine Aktionen
+ Waehle aus, womit du starten willst
+
+
+ {primaryActions.map((action) => (
+
+
+
+
+ {action.icon}
+
+
+ {action.label}
+ {action.description}
+
+
+
+
+ ))}
+
+
+
+
+
+
+ Dein Fortschritt
+ Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.
+
+
+ {checklistItems.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+
+
+
);
}
+
+function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
+ const progressMessage = tasksCompleted > 0
+ ? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
+ : 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
+
+ return (
+
+
+ Willkommen zur Party
+ Hey {name}!
+ Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.
+ {progressMessage}
+
+
+ );
+}
+
+function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
+ return (
+
+
+ {icon}
+
+
+ {label}
+ {value}
+
+
+ );
+}
+
+function formatLatestUpload(isoDate: string | null) {
+ if (!isoDate) {
+ return 'Noch kein Upload';
+ }
+ const date = new Date(isoDate);
+ if (Number.isNaN(date.getTime())) {
+ return 'Noch kein Upload';
+ }
+ const diffMs = Date.now() - date.getTime();
+ const diffMinutes = Math.round(diffMs / 60000);
+ if (diffMinutes < 1) {
+ return 'Gerade eben';
+ }
+ if (diffMinutes < 60) {
+ return `vor ${diffMinutes} Min`;
+ }
+ const diffHours = Math.round(diffMinutes / 60);
+ if (diffHours < 24) {
+ return `vor ${diffHours} Std`;
+ }
+ const diffDays = Math.round(diffHours / 24);
+ return `vor ${diffDays} Tagen`;
+}
diff --git a/resources/js/guest/pages/LandingPage.tsx b/resources/js/guest/pages/LandingPage.tsx
index 4a42ac8..0e74207 100644
--- a/resources/js/guest/pages/LandingPage.tsx
+++ b/resources/js/guest/pages/LandingPage.tsx
@@ -1,18 +1,23 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Html5Qrcode } from 'html5-qrcode';
+import { readGuestName } from '../context/GuestIdentityContext';
export default function LandingPage() {
const nav = useNavigate();
- const [slug, setSlug] = React.useState('');
- const [loading, setLoading] = React.useState(false);
- const [error, setError] = React.useState(null);
+ const [slug, setSlug] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isScanning, setIsScanning] = useState(false);
+ const [scanner, setScanner] = useState(null);
- async function join() {
- const s = slug.trim();
+ async function join(eventSlug?: string) {
+ const s = (eventSlug ?? slug).trim();
if (!s) return;
setLoading(true);
setError(null);
@@ -22,30 +27,131 @@ export default function LandingPage() {
setError('Event nicht gefunden oder geschlossen.');
return;
}
- nav(`/e/${encodeURIComponent(s)}`);
+ const storedName = readGuestName(s);
+ if (!storedName) {
+ nav(`/setup/${encodeURIComponent(s)}`);
+ } else {
+ nav(`/e/${encodeURIComponent(s)}`);
+ }
} catch (e) {
- setError('Netzwerkfehler. Bitte später erneut versuchen.');
+ console.error('Join request failed', e);
+ setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
} finally {
setLoading(false);
}
}
+ const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
+
+ async function startScanner() {
+ if (scanner) {
+ try {
+ await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
+ setIsScanning(true);
+ } catch (err) {
+ console.error('Scanner start failed', err);
+ setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
+ }
+ return;
+ }
+
+ try {
+ const newScanner = new Html5Qrcode('qr-reader');
+ setScanner(newScanner);
+ await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
+ setIsScanning(true);
+ } catch (err) {
+ console.error('Scanner initialisation failed', err);
+ setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
+ }
+ }
+
+ function stopScanner() {
+ if (!scanner) {
+ setIsScanning(false);
+ return;
+ }
+ scanner
+ .stop()
+ .then(() => {
+ setIsScanning(false);
+ })
+ .catch((err) => console.error('Scanner stop failed', err));
+ }
+
+ async function onScanSuccess(decodedText: string) {
+ const value = decodedText.trim();
+ if (!value) return;
+ await join(value);
+ stopScanner();
+ }
+
+ useEffect(() => () => {
+ if (scanner) {
+ scanner.stop().catch(() => undefined);
+ }
+ }, [scanner]);
+
return (
-
+
{error && (
{error}
)}
- setSlug(e.target.value)}
- placeholder="QR/PIN oder Event-Slug eingeben"
- />
-
-
+
+
+
Willkommen bei der Fotobox!
+
Dein Schluessel zu unvergesslichen Momenten.
+
+
+
+
+ Event beitreten
+ Scanne den QR-Code oder gib den Code manuell ein.
+
+
+
+
+
+
+
+
+
+
+
+ Oder manuell eingeben
+
+
+
+ setSlug(event.target.value)}
+ placeholder="Event-Code eingeben"
+ disabled={loading}
+ />
+
+
+
+
+
);
}
diff --git a/resources/js/guest/pages/LegalPage.tsx b/resources/js/guest/pages/LegalPage.tsx
index 275f828..0a7c4aa 100644
--- a/resources/js/guest/pages/LegalPage.tsx
+++ b/resources/js/guest/pages/LegalPage.tsx
@@ -1,6 +1,7 @@
-import React from 'react';
+import React from "react";
import { Page } from './_util';
import { useParams } from 'react-router-dom';
+import { LegalMarkdown } from '../components/legal-markdown';
export default function LegalPage() {
const { page } = useParams();
@@ -9,42 +10,44 @@ export default function LegalPage() {
const [body, setBody] = React.useState('');
React.useEffect(() => {
- async function load() {
- setLoading(true);
- const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }});
- if (res.ok) {
- const j = await res.json();
- setTitle(j.title || '');
- setBody(j.body_markdown || '');
- }
- setLoading(false);
+ if (!page) {
+ return;
}
- if (page) load();
+ const controller = new AbortController();
+
+ async function loadLegal() {
+ try {
+ setLoading(true);
+ const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
+ headers: { 'Cache-Control': 'no-store' },
+ signal: controller.signal,
+ });
+ if (!res.ok) {
+ throw new Error('failed');
+ }
+ const data = await res.json();
+ setTitle(data.title || '');
+ setBody(data.body_markdown || '');
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ console.error('Failed to load legal page', error);
+ setTitle('');
+ setBody('');
+ }
+ } finally {
+ if (!controller.signal.aborted) {
+ setLoading(false);
+ }
+ }
+ }
+
+ loadLegal();
+ return () => controller.abort();
}, [page]);
return (
- {loading ? Lädt…
: }
+ {loading ? Laedt...
: }
);
}
-
-function Markdown({ md }: { md: string }) {
- // Tiny, safe Markdown: paragraphs + basic bold/italic + links; no external dependency
- const html = React.useMemo(() => {
- let s = md
- .replace(/&/g, '&')
- .replace(//g, '>');
- // bold **text**
- s = s.replace(/\*\*(.+?)\*\*/g, '$1');
- // italic *text*
- s = s.replace(/(?$1');
- // links [text](url)
- s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '$1<\/a>');
- // paragraphs
- s = s.split(/\n{2,}/).map(p => `${p.replace(/\n/g, '
')}<\/p>`).join('\n');
- return s;
- }, [md]);
- return
;
-}
diff --git a/resources/js/guest/pages/ProfileSetupPage.tsx b/resources/js/guest/pages/ProfileSetupPage.tsx
index cc3dbca..6c43481 100644
--- a/resources/js/guest/pages/ProfileSetupPage.tsx
+++ b/resources/js/guest/pages/ProfileSetupPage.tsx
@@ -1,13 +1,104 @@
-import React from 'react';
-import { Page } from './_util';
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useEventData } from '../hooks/useEventData';
+import { useGuestIdentity } from '../context/GuestIdentityContext';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import Header from '../components/Header';
export default function ProfileSetupPage() {
+ const { slug } = useParams<{ slug: string }>();
+ const nav = useNavigate();
+ const { event, loading, error } = useEventData();
+ const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
+ const [name, setName] = useState(storedName);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (!slug) {
+ nav('/');
+ return;
+ }
+ }, [slug, nav]);
+
+ useEffect(() => {
+ if (hydrated) {
+ setName(storedName);
+ }
+ }, [hydrated, storedName]);
+
+ function handleChange(value: string) {
+ setName(value);
+ }
+
+ function submitName() {
+ if (!slug) return;
+ const trimmedName = name.trim();
+ if (!trimmedName) return;
+
+ setSubmitting(true);
+ try {
+ persistName(trimmedName);
+ nav(`/e/${slug}`);
+ } catch (e) {
+ console.error('Fehler beim Speichern des Namens:', e);
+ setSubmitting(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !event) {
+ return (
+
+
{error || 'Event nicht gefunden.'}
+
+
+ );
+ }
+
return (
-
-
-
-
-
+
+
+
+
+
+ {event.name}
+
+ Fange den schoensten Moment ein!
+
+
+
+
+
+ handleChange(e.target.value)}
+ placeholder="Dein Name"
+ className="text-lg"
+ disabled={submitting || !hydrated}
+ autoComplete="name"
+ />
+
+
+
+
+
+
);
}
-
diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx
index 5644cf2..8831077 100644
--- a/resources/js/guest/pages/TaskPickerPage.tsx
+++ b/resources/js/guest/pages/TaskPickerPage.tsx
@@ -1,19 +1,17 @@
-import React, { useState, useEffect } from 'react';
-import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
-import { Page } from './_util';
+import React from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
-import { useAppearance } from '../../hooks/use-appearance';
-import { Clock, RefreshCw, Smile } from 'lucide-react';
-import BottomNav from '../components/BottomNav';
-import { useEventData } from '../hooks/useEventData';
-import { EventData } from '../services/eventApi';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
+import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
interface Task {
id: number;
title: string;
description: string;
instructions: string;
- duration: number; // in minutes
+ duration: number; // minutes
emotion?: {
slug: string;
name: string;
@@ -21,244 +19,508 @@ interface Task {
is_completed: boolean;
}
+type EmotionOption = {
+ slug: string;
+ name: string;
+};
+
+const TASK_PROGRESS_TARGET = 5;
+const TIMER_VIBRATION = [0, 60, 120, 60];
+
export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>();
- const [searchParams] = useSearchParams();
- // emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
const navigate = useNavigate();
- const { appearance } = useAppearance();
- const isDark = appearance === 'dark';
-
- const [tasks, setTasks] = useState([]);
- const [currentTask, setCurrentTask] = useState(null);
- const [timeLeft, setTimeLeft] = useState(0);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [searchParams, setSearchParams] = useSearchParams();
- // Timer state
- useEffect(() => {
- if (!currentTask) return;
-
- const durationMs = currentTask.duration * 60 * 1000;
- setTimeLeft(durationMs / 1000);
-
- const interval = setInterval(() => {
- setTimeLeft(prev => {
+ const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
+
+ const [tasks, setTasks] = React.useState([]);
+ const [currentTask, setCurrentTask] = React.useState(null);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+ const [selectedEmotion, setSelectedEmotion] = React.useState('all');
+ const [timeLeft, setTimeLeft] = React.useState(0);
+ const [timerRunning, setTimerRunning] = React.useState(false);
+ const [timeUp, setTimeUp] = React.useState(false);
+ const [isFetching, setIsFetching] = React.useState(false);
+
+ const recentTaskIdsRef = React.useRef([]);
+ const initialEmotionRef = React.useRef(false);
+
+ const fetchTasks = React.useCallback(async () => {
+ if (!slug) return;
+ setIsFetching(true);
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
+ if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
+ const payload = await response.json();
+ if (Array.isArray(payload)) {
+ setTasks(payload);
+ } else {
+ setTasks([]);
+ }
+ } catch (err) {
+ console.error('Failed to load tasks', err);
+ setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
+ setTasks([]);
+ } finally {
+ setIsFetching(false);
+ setLoading(false);
+ }
+ }, [slug]);
+
+ React.useEffect(() => {
+ fetchTasks();
+ }, [fetchTasks]);
+
+ React.useEffect(() => {
+ if (initialEmotionRef.current) return;
+ const queryEmotion = searchParams.get('emotion');
+ if (queryEmotion) {
+ setSelectedEmotion(queryEmotion);
+ }
+ initialEmotionRef.current = true;
+ }, [searchParams]);
+
+ const emotionOptions = React.useMemo(() => {
+ const map = new Map();
+ tasks.forEach((task) => {
+ if (task.emotion?.slug) {
+ map.set(task.emotion.slug, task.emotion.name);
+ }
+ });
+ return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
+ }, [tasks]);
+
+ const filteredTasks = React.useMemo(() => {
+ if (selectedEmotion === 'all') return tasks;
+ return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
+ }, [tasks, selectedEmotion]);
+
+ const selectRandomTask = React.useCallback(
+ (list: Task[]) => {
+ if (!list.length) {
+ setCurrentTask(null);
+ return;
+ }
+ const avoidIds = recentTaskIdsRef.current;
+ const available = list.filter((task) => !isCompleted(task.id));
+ const base = available.length ? available : list;
+ let candidates = base.filter((task) => !avoidIds.includes(task.id));
+ if (!candidates.length) {
+ candidates = base;
+ }
+ const chosen = candidates[Math.floor(Math.random() * candidates.length)];
+ setCurrentTask(chosen);
+ recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
+ },
+ [isCompleted]
+ );
+
+ React.useEffect(() => {
+ if (!filteredTasks.length) {
+ setCurrentTask(null);
+ return;
+ }
+ if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
+ selectRandomTask(filteredTasks);
+ return;
+ }
+ const matchingTask = filteredTasks.find((task) => task.id === currentTask.id);
+ const durationMinutes = matchingTask?.duration ?? currentTask.duration;
+ setTimeLeft(durationMinutes * 60);
+ setTimerRunning(false);
+ setTimeUp(false);
+ }, [filteredTasks, currentTask, selectRandomTask]);
+
+ React.useEffect(() => {
+ if (!currentTask) {
+ setTimeLeft(0);
+ setTimerRunning(false);
+ setTimeUp(false);
+ return;
+ }
+ setTimeLeft(currentTask.duration * 60);
+ setTimerRunning(false);
+ setTimeUp(false);
+ }, [currentTask]);
+
+ React.useEffect(() => {
+ if (!timerRunning) return;
+ if (timeLeft <= 0) {
+ setTimerRunning(false);
+ triggerTimeUp();
+ return;
+ }
+ const tick = window.setInterval(() => {
+ setTimeLeft((prev) => {
if (prev <= 1) {
- clearInterval(interval);
+ window.clearInterval(tick);
+ triggerTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
+ return () => window.clearInterval(tick);
+ }, [timerRunning, timeLeft]);
- return () => clearInterval(interval);
- }, [currentTask]);
-
- // Load tasks
- useEffect(() => {
- if (!slug) return;
-
- async function fetchTasks() {
- try {
- setLoading(true);
- setError(null);
-
- const url = `/api/v1/events/${slug}/tasks`;
- console.log('Fetching tasks from:', url); // Debug
-
- const response = await fetch(url);
- if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
-
- const data = await response.json();
- setTasks(Array.isArray(data) ? data : []);
-
- console.log('Loaded tasks:', data); // Debug
-
- // Select random task
- if (data.length > 0) {
- const randomIndex = Math.floor(Math.random() * data.length);
- setCurrentTask(data[randomIndex]);
- console.log('Selected random task:', data[randomIndex]); // Debug
- }
- } catch (err) {
- console.error('Fetch tasks error:', err);
- setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
- } finally {
- setLoading(false);
- }
+ function triggerTimeUp() {
+ const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
+ setTimerRunning(false);
+ setTimeUp(true);
+ if (supportsVibration) {
+ try {
+ navigator.vibrate(TIMER_VIBRATION);
+ } catch (error) {
+ console.warn('Vibration not permitted', error);
}
+ }
+ window.setTimeout(() => setTimeUp(false), 4000);
+ return;
+}
- fetchTasks();
- }, [slug]);
-
- const formatTime = (seconds: number) => {
+ const formatTime = React.useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
+ const secs = Math.max(0, seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
+ }, []);
+
+ const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0;
+
+ const handleSelectEmotion = (slugValue: string) => {
+ setSelectedEmotion(slugValue);
+ const next = new URLSearchParams(searchParams.toString());
+ if (slugValue === 'all') {
+ next.delete('emotion');
+ } else {
+ next.set('emotion', slugValue);
+ }
+ setSearchParams(next, { replace: true });
};
const handleNewTask = () => {
- if (tasks.length === 0) return;
- const randomIndex = Math.floor(Math.random() * tasks.length);
- setCurrentTask(tasks[randomIndex]);
- setTimeLeft(tasks[randomIndex].duration * 60);
+ selectRandomTask(filteredTasks);
};
- const handleStartTask = () => {
+ const handleStartUpload = () => {
+ if (!currentTask || !slug) return;
+ navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
+ };
+
+ const handleMarkCompleted = () => {
if (!currentTask) return;
- // Navigate to upload with task context
- navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
+ markCompleted(currentTask.id);
+ selectRandomTask(filteredTasks);
};
- const handleChangeMood = () => {
- navigate(`/e/${slug}`);
+ const handleRetryFetch = () => {
+ fetchTasks();
};
- if (loading) {
- return (
-
-
-
-
- );
- }
+ const handleTimerToggle = () => {
+ if (!currentTask) return;
+ if (timerRunning) {
+ setTimerRunning(false);
+ setTimeLeft(currentTask.duration * 60);
+ setTimeUp(false);
+ } else {
+ if (timeLeft <= 0) {
+ setTimeLeft(currentTask.duration * 60);
+ }
+ setTimerRunning(true);
+ setTimeUp(false);
+ }
+ };
- if (error || !currentTask) {
- return (
-
-
-
-
-
Keine passende Aufgabe gefunden
-
- {error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
-
-
-
-
-
-
- );
- }
+ const emptyState = !loading && (!filteredTasks.length || !currentTask);
return (
-
-
- {/* Task Header with Selfie Overlay */}
-
-
- {/* Selfie Placeholder */}
-
-
-
- 📸
-
-
- Selfie-Vorschau
-
-
-
-
- {/* Timer */}
-
-
60
- ? 'bg-green-500/20 text-green-400 border-green-500/30'
- : timeLeft > 30
- ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
- : 'bg-red-500/20 text-red-400 border-red-500/30'
- } border`}>
-
- {formatTime(timeLeft)}
-
-
+
+
+
+
Aufgabe auswaehlen
+
+ Schon {completedCount} Aufgaben erledigt
+
+
+
+
+ Auf dem Weg zum naechsten Erfolg
+
+ {completedCount >= TASK_PROGRESS_TARGET
+ ? 'Stark!'
+ : `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
+
+
+
+
+ {emotionOptions.length > 0 && (
+
+ handleSelectEmotion('all')}
+ />
+ {emotionOptions.map((emotion) => (
+ handleSelectEmotion(emotion.slug)}
+ />
+ ))}
+
+ )}
+
- {/* Task Description Overlay */}
-
-
-
- {currentTask.title}
-
-
- {currentTask.description}
-
- {currentTask.instructions && (
-
-
💡 {currentTask.instructions}
-
+ {loading && (
+
+
+
+
+ )}
+
+ {error && !loading && (
+
+
+ {error}
+
+
+
+ )}
+
+ {emptyState && (
+
+ )}
+
+ {!emptyState && currentTask && (
+
+
+
+
+
+
+
Deine Mission
+
{currentTask.title}
+
+
+
+
+ {timeUp && (
+
+
+ Zeit abgelaufen!
+
)}
-
-
- {/* Action Buttons */}
-
-
+
+
+
+
+ {currentTask.duration} Min
+
+ {currentTask.emotion?.name && (
+
+
+ {currentTask.emotion.name}
+
+ )}
+ {isCompleted(currentTask.id) && (
+
+
+ Bereits erledigt
+
+ )}
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+ )}
- {/* Bottom Navigation */}
-
-
-
+ {!loading && !tasks.length && !error && (
+
+ Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.
+
+ )}
+
+ );
+}
+
+function timerTone(timeLeft: number, durationMinutes: number) {
+ const totalSeconds = Math.max(1, durationMinutes * 60);
+ const ratio = timeLeft / totalSeconds;
+ if (ratio > 0.5) return 'okay';
+ if (ratio > 0.25) return 'warm';
+ return 'hot';
+}
+
+function EmotionChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
+ return (
+
+ );
+}
+
+function ChecklistItem({ text }: { text: string }) {
+ return (
+
+
+ {text}
+
+ );
+}
+
+function BadgeTimer({ label, value, tone }: { label: string; value: string; tone: 'okay' | 'warm' | 'hot' }) {
+ const toneClasses = {
+ okay: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30',
+ warm: 'bg-amber-500/15 text-amber-500 border-amber-500/30',
+ hot: 'bg-rose-500/15 text-rose-500 border-rose-500/30',
+ }[tone];
+ return (
+
+
+ {label}
+ {value}
+
+ );
+}
+
+function SkeletonBlock() {
+ return
;
+}
+
+function EmptyState({
+ hasTasks,
+ onRetry,
+ emotionOptions,
+ onEmotionSelect,
+}: {
+ hasTasks: boolean;
+ onRetry: () => void;
+ emotionOptions: EmotionOption[];
+ onEmotionSelect: (slug: string) => void;
+}) {
+ return (
+
+
+
+
Keine passende Aufgabe gefunden
+
+ {hasTasks
+ ? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
+ : 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
+
+
+ {hasTasks && emotionOptions.length > 0 && (
+
+ {emotionOptions.map((emotion) => (
+ onEmotionSelect(emotion.slug)}
+ />
+ ))}
+
+ )}
+
+
);
}
diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx
index 14637df..2f25b59 100644
--- a/resources/js/guest/pages/UploadPage.tsx
+++ b/resources/js/guest/pages/UploadPage.tsx
@@ -1,11 +1,26 @@
-import React, { useState, useRef, useEffect, useCallback } from 'react';
-import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
-import { Page } from './_util';
-import { Button } from '@/components/ui/button';
-import { useAppearance } from '../../hooks/use-appearance';
-import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi';
+import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
+import { useAppearance } from '../../hooks/use-appearance';
+import { cn } from '@/lib/utils';
+import {
+ AlertTriangle,
+ Camera,
+ Grid3X3,
+ ImagePlus,
+ Info,
+ Loader2,
+ RotateCcw,
+ Sparkles,
+ Zap,
+ ZapOff,
+} from 'lucide-react';
interface Task {
id: number;
@@ -17,455 +32,777 @@ interface Task {
difficulty?: 'easy' | 'medium' | 'hard';
}
+type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported';
+type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading';
+
+type CameraPreferences = {
+ facingMode: 'user' | 'environment';
+ countdownSeconds: number;
+ countdownEnabled: boolean;
+ gridEnabled: boolean;
+ mirrorFrontPreview: boolean;
+ flashPreferred: boolean;
+};
+
+const DEFAULT_PREFS: CameraPreferences = {
+ facingMode: 'environment',
+ countdownSeconds: 3,
+ countdownEnabled: true,
+ gridEnabled: true,
+ mirrorFrontPreview: true,
+ flashPreferred: false,
+};
+
export default function UploadPage() {
const { slug } = useParams<{ slug: string }>();
- const [searchParams] = useSearchParams();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
- const isDark = appearance === 'dark';
+ const isDarkMode = appearance === 'dark';
+ const { markCompleted } = useGuestTaskProgress(slug);
- // Task data from URL params
- const taskId = searchParams.get('task');
+ const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
+
+ const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed';
+ const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs';
+
+ const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
+
const [task, setTask] = useState
(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [uploading, setUploading] = useState(false);
+ const [loadingTask, setLoadingTask] = useState(true);
+ const [taskError, setTaskError] = useState(null);
- // Camera state
- const videoRef = useRef(null);
- const canvasRef = useRef(null);
- const [stream, setStream] = useState(null);
- const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); // front = user, back = environment
- const [flashOn, setFlashOn] = useState(false);
- const [isPulsing, setIsPulsing] = useState(false);
- const [countdown, setCountdown] = useState(3);
- const [capturing, setCapturing] = useState(false);
+ const [permissionState, setPermissionState] = useState('idle');
+ const [permissionMessage, setPermissionMessage] = useState(null);
- // Load task data from API
+ const [preferences, setPreferences] = useState(DEFAULT_PREFS);
+ const [mode, setMode] = useState('preview');
+ const [countdownValue, setCountdownValue] = useState(DEFAULT_PREFS.countdownSeconds);
+ const [statusMessage, setStatusMessage] = useState('');
+
+ const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [uploadError, setUploadError] = useState(null);
+
+ const [showPrimer, setShowPrimer] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.localStorage.getItem(primerStorageKey) !== '1';
+ });
+
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const liveRegionRef = useRef(null);
+
+ const streamRef = useRef(null);
+ const countdownTimerRef = useRef(null);
+ const uploadProgressTimerRef = useRef(null);
+
+ const taskId = useMemo(() => {
+ if (!taskIdParam) return null;
+ const parsed = parseInt(taskIdParam, 10);
+ return Number.isFinite(parsed) ? parsed : null;
+ }, [taskIdParam]);
+
+ // Load preferences from storage
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ try {
+ const stored = window.localStorage.getItem(prefsStorageKey);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Partial;
+ setPreferences((prev) => ({ ...prev, ...parsed }));
+ }
+ } catch (error) {
+ console.warn('Failed to parse camera preferences', error);
+ }
+ }, [prefsStorageKey]);
+
+ // Persist preferences when they change
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(prefsStorageKey, JSON.stringify(preferences));
+ } catch (error) {
+ console.warn('Failed to persist camera preferences', error);
+ }
+ }, [preferences, prefsStorageKey]);
+
+ // Load task metadata
useEffect(() => {
if (!slug || !taskId) {
- setError('Keine Aufgabendaten gefunden');
- setLoading(false);
+ setTaskError('Keine Aufgabeninformationen gefunden.');
+ setLoadingTask(false);
return;
}
- const taskIdNum = parseInt(taskId);
- if (isNaN(taskIdNum)) {
- setError('Ungültige Aufgaben-ID');
- setLoading(false);
- return;
- }
+ let active = true;
- async function fetchTask() {
+ async function loadTask() {
try {
- setLoading(true);
- setError(null);
-
- const response = await fetch(`/api/v1/events/${slug}/tasks`);
- if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
-
- const tasks = await response.json();
- const foundTask = tasks.find((t: any) => t.id === taskIdNum);
-
- if (foundTask) {
+ setLoadingTask(true);
+ setTaskError(null);
+
+ const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`);
+ if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
+ const tasks = await res.json();
+ const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
+
+ if (!active) return;
+
+ if (found) {
setTask({
- id: foundTask.id,
- title: foundTask.title || `Aufgabe ${taskIdNum}`,
- description: foundTask.description || 'Stelle dich für das Foto auf und lächle in die Kamera.',
- instructions: foundTask.instructions,
- duration: foundTask.duration || 2,
- emotion: foundTask.emotion,
- difficulty: 'medium' as const
+ id: found.id,
+ title: found.title || `Aufgabe ${taskId!}`,
+ description: found.description || 'Halte den Moment fest und teile ihn mit allen Gästen.',
+ instructions: found.instructions,
+ duration: found.duration || 2,
+ emotion: found.emotion,
+ difficulty: found.difficulty ?? 'medium',
});
} else {
- // Fallback for unknown task ID
setTask({
- id: taskIdNum,
- title: `Unbekannte Aufgabe ${taskIdNum}`,
- description: 'Stelle dich für das Foto auf und lächle in die Kamera.',
- instructions: 'Positioniere dich gut und warte auf den Countdown.',
+ id: taskId!,
+ title: `Aufgabe ${taskId!}`,
+ description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
+ instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
duration: 2,
- emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined,
- difficulty: 'medium' as const
+ emotion: emotionSlug
+ ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
+ : undefined,
+ difficulty: 'medium',
+ });
+ }
+ } catch (error) {
+ console.error('Failed to fetch task', error);
+ if (active) {
+ setTaskError('Aufgabe konnte nicht geladen werden. Du kannst trotzdem ein Foto machen.');
+ setTask({
+ id: taskId!,
+ title: `Aufgabe ${taskId!}`,
+ description: 'Halte den Moment fest und teile ihn mit allen Gästen.',
+ instructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
+ duration: 2,
+ emotion: emotionSlug
+ ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, (l) => l.toUpperCase()) }
+ : undefined,
+ difficulty: 'medium',
});
}
- } catch (err) {
- console.error('Failed to fetch task:', err);
- setError('Aufgabe konnte nicht geladen werden');
- // Set fallback task
- setTask({
- id: taskIdNum,
- title: `Unbekannte Aufgabe ${taskIdNum}`,
- description: 'Stelle dich für das Foto auf und lächle in die Kamera.',
- instructions: 'Positioniere dich gut und warte auf den Countdown.',
- duration: 2,
- emotion: emotionSlug ? { slug: emotionSlug, name: emotionSlug.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()) } : undefined,
- difficulty: 'medium' as const
- });
} finally {
- setLoading(false);
+ if (active) setLoadingTask(false);
}
}
- fetchTask();
+ loadTask();
+ return () => {
+ active = false;
+ };
}, [slug, taskId, emotionSlug]);
- // Camera setup
- useEffect(() => {
- if (!slug || loading || !task) return;
+ const stopStream = useCallback(() => {
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ }
+ }, []);
- const setupCamera = async () => {
- try {
- const constraints: MediaStreamConstraints = {
- video: {
- width: { ideal: 1280 },
- height: { ideal: 720 },
- facingMode: facingMode ? { ideal: facingMode } : undefined
- }
- };
-
- console.log('Requesting camera with constraints:', constraints);
-
- const newStream = await navigator.mediaDevices.getUserMedia(constraints);
+ const attachStreamToVideo = useCallback((stream: MediaStream) => {
+ if (!videoRef.current) return;
+ videoRef.current.srcObject = stream;
+ videoRef.current
+ .play()
+ .then(() => {
if (videoRef.current) {
- videoRef.current.srcObject = newStream;
- videoRef.current.play().catch(e => console.error('Video play error:', e));
- // Set video dimensions after metadata is loaded
- videoRef.current.onloadedmetadata = () => {
- if (videoRef.current) {
- videoRef.current.style.display = 'block';
- }
- };
+ videoRef.current.muted = true;
}
- setStream(newStream);
- setError(null); // Clear any previous errors
- } catch (err: any) {
- console.error('Camera access error:', err.name, err.message);
-
- let errorMessage = 'Kamera konnte nicht gestartet werden.';
-
- switch (err.name) {
- case 'NotAllowedError':
- errorMessage = 'Kamera-Zugriff verweigert.\n\n' +
- '• Chrome: Adressleiste klicken → Kamera-Symbol → "Zulassen"\n' +
- '• Safari: Einstellungen → Website-Einstellungen → Kamera → "Erlauben"\n' +
- '• Firefox: Adressleiste → Berechtigungen → Kamera → "Erlauben"\n\n' +
- 'Danach Seite neu laden.';
- break;
- case 'NotFoundError':
- errorMessage = 'Keine Kamera gefunden. Bitte überprüfen Sie:\n' +
- '• Ob eine Kamera am Gerät verfügbar ist\n' +
- '• Ob andere Apps die Kamera verwenden\n' +
- '• Gerätekonfiguration in den Browser-Einstellungen';
- break;
- case 'NotSupportedError':
- errorMessage = 'Kamera nicht unterstützt. Bitte verwenden Sie:\n' +
- '• Chrome, Firefox oder Safari (neueste Version)\n' +
- '• HTTPS-Verbindung (nicht HTTP)';
- break;
- case 'OverconstrainedError':
- errorMessage = 'Kamera-Einstellungen nicht verfügbar. Versuche mit Standard-Einstellungen...';
- // Fallback to basic constraints
- try {
- const fallbackConstraints = { video: true };
- const fallbackStream = await navigator.mediaDevices.getUserMedia(fallbackConstraints);
- if (videoRef.current) {
- videoRef.current.srcObject = fallbackStream;
- videoRef.current.play();
- }
- setStream(fallbackStream);
- setError(null);
- return;
- } catch (fallbackErr) {
- console.error('Fallback camera failed:', fallbackErr);
- }
- break;
- default:
- errorMessage = `Kamera-Fehler (${err.name}): ${err.message}\n\nBitte versuchen Sie:\n• Seite neu laden\n• Browser neu starten\n• Anderen Browser verwenden`;
- }
-
- setError(errorMessage);
+ })
+ .catch((error) => console.error('Video play error', error));
+ }, []);
+
+ const createConstraint = useCallback(
+ (mode: 'user' | 'environment'): MediaStreamConstraints => ({
+ video: {
+ width: { ideal: 1920 },
+ height: { ideal: 1080 },
+ facingMode: { ideal: mode },
+ },
+ audio: false,
+ }),
+ []
+ );
+
+ const startCamera = useCallback(async () => {
+ if (!supportsCamera) {
+ setPermissionState('unsupported');
+ setPermissionMessage('Dieses Gerät oder der Browser unterstützt keine Kamera-Zugriffe.');
+ return;
+ }
+
+ if (!task || mode === 'uploading') return;
+
+ try {
+ setPermissionState('prompt');
+ setPermissionMessage(null);
+
+ const stream = await navigator.mediaDevices.getUserMedia(createConstraint(preferences.facingMode));
+ stopStream();
+ streamRef.current = stream;
+ attachStreamToVideo(stream);
+ setPermissionState('granted');
+ } catch (error: any) {
+ console.error('Camera access error', error);
+ stopStream();
+
+ if (error?.name === 'NotAllowedError') {
+ setPermissionState('denied');
+ setPermissionMessage(
+ 'Kamera-Zugriff wurde blockiert. Prüfe die Berechtigungen deines Browsers und versuche es erneut.'
+ );
+ } else if (error?.name === 'NotFoundError') {
+ setPermissionState('error');
+ setPermissionMessage('Keine Kamera gefunden. Du kannst stattdessen ein Foto aus deiner Galerie wählen.');
+ } else {
+ setPermissionState('error');
+ setPermissionMessage(`Kamera konnte nicht gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
}
- };
-
- setupCamera();
+ }
+ }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task]);
+ useEffect(() => {
+ if (!task || loadingTask) return;
+ startCamera();
return () => {
- if (stream) {
- stream.getTracks().forEach(track => track.stop());
- setStream(null);
- }
+ stopStream();
};
- }, [slug, loading, task, facingMode]);
+ }, [task, loadingTask, startCamera, stopStream, preferences.facingMode]);
- // Handle capture
- const handleCapture = useCallback(async () => {
- if (!videoRef.current || !canvasRef.current || !task) return;
+ // Countdown live region updates
+ useEffect(() => {
+ if (!liveRegionRef.current) return;
+ if (mode === 'countdown') {
+ liveRegionRef.current.textContent = `Foto wird in ${countdownValue} Sekunden aufgenommen.`;
+ } else if (mode === 'review') {
+ liveRegionRef.current.textContent = 'Foto aufgenommen. �berpr�fe die Vorschau.';
+ } else if (mode === 'uploading') {
+ liveRegionRef.current.textContent = 'Foto wird hochgeladen.';
+ } else {
+ liveRegionRef.current.textContent = '';
+ }
+ }, [mode, countdownValue]);
- setCapturing(true);
- setIsPulsing(false);
+ const dismissPrimer = useCallback(() => {
+ setShowPrimer(false);
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(primerStorageKey, '1');
+ }
+ }, [primerStorageKey]);
- // Start countdown
- let count = 3;
- setCountdown(count);
-
- const countdownInterval = setInterval(() => {
- count--;
- setCountdown(count);
- if (count <= 0) {
- clearInterval(countdownInterval);
-
- // Capture photo
- const video = videoRef.current;
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const context = canvas.getContext('2d');
- if (!context || !video) return;
-
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
- context.drawImage(video, 0, 0);
-
- // Convert to blob
- canvas.toBlob(async (blob) => {
- if (blob && task && slug) {
- try {
- // Show uploading state
- setUploading(true);
- setCapturing(false);
- setCountdown(3);
- setError(null);
-
- // Use emotionSlug directly (backend expects string slug)
-
- // Convert Blob to File with proper filename
- const timestamp = Date.now();
- const fileName = `photo-${timestamp}-${task.id}.jpg`;
- const file = new File([blob], fileName, {
- type: 'image/jpeg',
- lastModified: timestamp
- });
-
- console.log('Uploading photo:', {
- taskId: task.id,
- emotionSlug,
- fileName,
- fileSize: file.size
- });
-
- // Upload the photo
- const photoId = await uploadPhoto(slug, file, task.id, emotionSlug);
-
- console.log('Upload successful, photo ID:', photoId);
-
- // Navigate to gallery with success
- navigate(`/e/${slug}/gallery?task=${task.id}&emotion=${emotionSlug}&uploaded=true`);
- } catch (error: any) {
- console.error('Upload failed:', error);
- setError(`Upload fehlgeschlagen: ${error.message}\n\nFoto wurde erstellt, aber nicht hochgeladen.\nVersuchen Sie es erneut oder wählen Sie ein anderes Foto aus.`);
- } finally {
- setUploading(false);
- }
+ const handleToggleGrid = useCallback(() => {
+ setPreferences((prev) => ({ ...prev, gridEnabled: !prev.gridEnabled }));
+ }, []);
+
+ const handleToggleMirror = useCallback(() => {
+ setPreferences((prev) => ({ ...prev, mirrorFrontPreview: !prev.mirrorFrontPreview }));
+ }, []);
+
+ const handleToggleCountdown = useCallback(() => {
+ setPreferences((prev) => ({ ...prev, countdownEnabled: !prev.countdownEnabled }));
+ }, []);
+
+ const handleSwitchCamera = useCallback(() => {
+ setPreferences((prev) => ({
+ ...prev,
+ facingMode: prev.facingMode === 'user' ? 'environment' : 'user',
+ }));
+ }, []);
+
+ const handleToggleFlashPreference = useCallback(() => {
+ setPreferences((prev) => ({ ...prev, flashPreferred: !prev.flashPreferred }));
+ }, []);
+
+ const resetCountdownTimer = useCallback(() => {
+ if (countdownTimerRef.current) {
+ window.clearInterval(countdownTimerRef.current);
+ countdownTimerRef.current = null;
+ }
+ }, []);
+
+ const performCapture = useCallback(() => {
+ if (!videoRef.current || !canvasRef.current) {
+ setUploadError('Kamera nicht bereit. Bitte versuche es erneut.');
+ setMode('preview');
+ return;
+ }
+
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ const width = video.videoWidth;
+ const height = video.videoHeight;
+
+ if (!width || !height) {
+ setUploadError('Kamera liefert kein Bild. Bitte starte die Kamera neu.');
+ setMode('preview');
+ startCamera();
+ return;
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d');
+ if (!context) {
+ setUploadError('Canvas konnte nicht initialisiert werden.');
+ setMode('preview');
+ return;
+ }
+
+ context.save();
+ const shouldMirror = preferences.facingMode === 'user' && preferences.mirrorFrontPreview;
+ if (shouldMirror) {
+ context.scale(-1, 1);
+ context.drawImage(video, -width, 0, width, height);
+ } else {
+ context.drawImage(video, 0, 0, width, height);
+ }
+ context.restore();
+
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) {
+ setUploadError('Foto konnte nicht erstellt werden.');
+ setMode('preview');
+ return;
+ }
+ const timestamp = Date.now();
+ const fileName = `photo-${timestamp}.jpg`;
+ const file = new File([blob], fileName, { type: 'image/jpeg', lastModified: timestamp });
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
+ setReviewPhoto({ dataUrl, file });
+ setMode('review');
+ },
+ 'image/jpeg',
+ 0.92
+ );
+ }, [preferences.facingMode, preferences.mirrorFrontPreview, startCamera]);
+
+ const beginCapture = useCallback(() => {
+ setUploadError(null);
+ if (preferences.countdownEnabled && preferences.countdownSeconds > 0) {
+ setMode('countdown');
+ setCountdownValue(preferences.countdownSeconds);
+ resetCountdownTimer();
+ countdownTimerRef.current = window.setInterval(() => {
+ setCountdownValue((prev) => {
+ if (prev <= 1) {
+ resetCountdownTimer();
+ performCapture();
+ return preferences.countdownSeconds;
}
- }, 'image/jpeg', 0.8);
-
- setCapturing(false);
- setCountdown(3);
+ return prev - 1;
+ });
+ }, 1000);
+ } else {
+ performCapture();
+ }
+ }, [performCapture, preferences.countdownEnabled, preferences.countdownSeconds, resetCountdownTimer]);
+
+ const handleRetake = useCallback(() => {
+ setReviewPhoto(null);
+ setUploadProgress(0);
+ setUploadError(null);
+ setMode('preview');
+ }, []);
+
+ const navigateAfterUpload = useCallback(
+ (photoId: number | undefined) => {
+ if (!slug || !task) return;
+ const params = new URLSearchParams();
+ params.set('uploaded', 'true');
+ if (task.id) params.set('task', String(task.id));
+ if (photoId) params.set('photo', String(photoId));
+ if (emotionSlug) params.set('emotion', emotionSlug);
+ navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`);
+ },
+ [emotionSlug, navigate, slug, task]
+ );
+
+ const handleUsePhoto = useCallback(async () => {
+ if (!slug || !reviewPhoto || !task) return;
+ setMode('uploading');
+ setUploadProgress(5);
+ setUploadError(null);
+ setStatusMessage('Foto wird vorbereitet...');
+
+ if (uploadProgressTimerRef.current) {
+ window.clearInterval(uploadProgressTimerRef.current);
+ }
+ uploadProgressTimerRef.current = window.setInterval(() => {
+ setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev));
+ }, 400);
+
+ try {
+ const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined);
+ setUploadProgress(100);
+ setStatusMessage('Upload abgeschlossen.');
+ markCompleted(task.id);
+ stopStream();
+ navigateAfterUpload(photoId);
+ } catch (error: any) {
+ console.error('Upload failed', error);
+ setUploadError(error?.message || 'Upload fehlgeschlagen. Bitte versuche es erneut.');
+ setMode('review');
+ } finally {
+ if (uploadProgressTimerRef.current) {
+ window.clearInterval(uploadProgressTimerRef.current);
+ uploadProgressTimerRef.current = null;
}
- }, 1000);
+ setStatusMessage('');
+ }
+ }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task]);
- // Start pulsing animation
- setIsPulsing(true);
- }, [task, emotionSlug, slug, navigate]);
+ const handleGalleryPick = useCallback((event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ setUploadError(null);
+ const reader = new FileReader();
+ reader.onload = () => {
+ setReviewPhoto({ dataUrl: reader.result as string, file });
+ setMode('review');
+ };
+ reader.onerror = () => {
+ setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
+ };
+ reader.readAsDataURL(file);
+ }, []);
- // Switch camera
- const switchCamera = () => {
- setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
- };
+ const difficultyBadgeClass = useMemo(() => {
+ if (!task) return 'text-white';
+ switch (task.difficulty) {
+ case 'easy':
+ return 'text-emerald-400';
+ case 'hard':
+ return 'text-rose-400';
+ default:
+ return 'text-amber-300';
+ }
+ }, [task]);
- // Toggle flash (for back camera only)
- const toggleFlash = () => {
- if (facingMode !== 'environment') return;
- setFlashOn(prev => !prev);
- // TODO: Implement actual flash control if possible
- };
+ const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
+ const showTaskOverlay = task && mode !== 'uploading';
- if (loading) {
+ useEffect(() => () => {
+ resetCountdownTimer();
+ if (uploadProgressTimerRef.current) {
+ window.clearInterval(uploadProgressTimerRef.current);
+ }
+ }, [resetCountdownTimer]);
+
+ if (!supportsCamera && !task) {
return (
-
-
-
-
Kamera wird gestartet...
-
-
-
- );
- }
-
- if (error || !task) {
- return (
-
-
-
-
-
Kamera nicht verfügbar
-
{error}
-
-
-
-
-
- );
- }
-
- if (uploading) {
- return (
-
-
-
-
Foto wird hochgeladen
-
Bitte warten... Dies kann einen Moment dauern.
-
-
-
-
- );
- }
-
- const difficultyColor = task.difficulty === 'easy' ? 'text-green-400' :
- task.difficulty === 'medium' ? 'text-yellow-400' : 'text-red-400';
-
- return (
-
-
- {/* Camera Preview Container */}
-
- {/* Video Background */}
-
-
- {/* Task Info Overlay */}
-
-
-
{task.title}
-
{task.description}
- {task.instructions && (
-
- 💡 {task.instructions}
-
- )}
-
-
- Schwierigkeit: {task.difficulty}
-
- {emotionSlug && (
-
- Stimmung: {task.emotion?.name || emotionSlug}
-
- )}
-
-
-
-
- {/* Camera Controls */}
-
-
- {/* Flash Button */}
-
-
- {/* Capture Button */}
-
-
- {/* Pulsing Ring Animation */}
- {isPulsing && (
-
- )}
-
-
- {/* Switch Camera Button */}
-
-
-
-
- {/* Hidden Canvas for Capture */}
-
-
-
+
+
+
+
+
+ Dieses Gerät unterstützt keine Kamera-Zugriffe. Du kannst stattdessen Fotos aus deiner Galerie hochladen.
+
+
+
+ );
+ }
-
-
+ if (loadingTask) {
+ return (
+
+
+
+
+ Aufgabe und Kamera werden vorbereitet ...
+
+
+
+ );
+ }
+
+ const renderPrimer = () => (
+ showPrimer && (
+
+
+
+
+
Bereit für dein Shooting?
+
+ Suche dir gutes Licht, halte die Stimmung der Aufgabe fest und nutze die Kontrollleiste für Countdown, Grid und Kamerawechsel.
+
+
+
+
+
+ )
+ );
+
+ const renderPermissionNotice = () => {
+ if (permissionState === 'granted') return null;
+ if (permissionState === 'unsupported') {
+ return (
+
+
+ Dieses Gerät unterstützt keine Kamera. Nutze den Button `Foto aus Galerie wählen`, um dennoch teilzunehmen.
+
+
+ );
+ }
+ if (permissionState === 'denied' || permissionState === 'error') {
+ return (
+
+
+ {permissionMessage}
+
+
+
+ );
+ }
+ return (
+
+
+ Wir benötigen Zugriff auf deine Kamera. Bestätige die Browser-Abfrage oder nutze alternativ ein Foto aus deiner Galerie.
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ {renderPrimer()}
+
+
+ {permissionState !== 'granted' && renderPermissionNotice()}
+
+
+
+
+
+ {preferences.gridEnabled && (
+
+ )}
+
+ {!isCameraActive && (
+
+
+
+ Kamera ist nicht aktiv. {permissionMessage || 'Tippe auf `Kamera starten`, um loszulegen.'}
+
+
+
+
+
+
+ )}
+
+ {showTaskOverlay && task && (
+
+
+
+
+ Aufgabe #{task.id}
+
+
+ {task.difficulty === 'easy'
+ ? 'Leicht'
+ : task.difficulty === 'hard'
+ ? 'Herausfordernd'
+ : 'Medium'}
+
+
+
+
{task.title}
+
{task.description}
+
+
+ {task.instructions && Hinweis: {task.instructions}}
+ {emotionSlug && (
+ Stimmung: {task.emotion?.name || emotionSlug}
+ )}
+ {preferences.countdownEnabled && (
+
+ Countdown: {preferences.countdownSeconds}s
+
+ )}
+
+
+ )}
+
+ {mode === 'countdown' && (
+
+
{countdownValue}
+
Bereit machen ...
+
+ )}
+
+ {mode === 'review' && reviewPhoto && (
+
+

+
+ )}
+
+ {mode === 'uploading' && (
+
+
+
+
+ {statusMessage &&
{statusMessage}
}
+
+
+ )}
+
+
+
+ {uploadError && (
+
+
+
+ {uploadError}
+
+
+ )}
+
+
+
+
+
+ {preferences.facingMode === 'user' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {mode === 'review' && reviewPhoto ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts
index a53a9a7..f03ac63 100644
--- a/resources/js/guest/polling/usePollGalleryDelta.ts
+++ b/resources/js/guest/polling/usePollGalleryDelta.ts
@@ -8,6 +8,9 @@ export function usePollGalleryDelta(slug: string) {
const [newCount, setNewCount] = useState(0);
const latestAt = useRef
(null);
const timer = useRef(null);
+ const [visible, setVisible] = useState(
+ typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
+ );
async function fetchDelta() {
try {
@@ -75,16 +78,25 @@ export function usePollGalleryDelta(slug: string) {
}
}
+ useEffect(() => {
+ const onVis = () => setVisible(document.visibilityState === 'visible');
+ document.addEventListener('visibilitychange', onVis);
+ return () => document.removeEventListener('visibilitychange', onVis);
+ }, []);
+
useEffect(() => {
setLoading(true);
latestAt.current = null;
setPhotos([]);
fetchDelta();
- timer.current = window.setInterval(fetchDelta, 30_000);
+ if (timer.current) window.clearInterval(timer.current);
+ // Poll less aggressively when hidden
+ const interval = visible ? 30_000 : 90_000;
+ timer.current = window.setInterval(fetchDelta, interval);
return () => {
if (timer.current) window.clearInterval(timer.current);
};
- }, [slug]);
+ }, [slug, visible]);
function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew };
diff --git a/resources/js/guest/polling/usePollStats.ts b/resources/js/guest/polling/usePollStats.ts
index bed0dd0..bf3ee74 100644
--- a/resources/js/guest/polling/usePollStats.ts
+++ b/resources/js/guest/polling/usePollStats.ts
@@ -1,39 +1,67 @@
import { useEffect, useRef, useState } from 'react';
-type Stats = { onlineGuests: number; tasksSolved: number; latestPhotoAt?: string };
+export type EventStats = {
+ onlineGuests: number;
+ tasksSolved: number;
+ latestPhotoAt: string | null;
+};
-export function usePollStats(slug: string) {
- const [data, setData] = useState(null);
+type StatsResponse = {
+ online_guests?: number;
+ tasks_solved?: number;
+ latest_photo_at?: string;
+};
+
+export function usePollStats(slug: string | null | undefined) {
+ const [data, setData] = useState({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
const [loading, setLoading] = useState(true);
const timer = useRef(null);
- const visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true;
+ const [visible, setVisible] = useState(
+ typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
+ );
- async function fetchOnce() {
+ const canPoll = Boolean(slug);
+
+ async function fetchOnce(activeSlug: string) {
try {
- const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/stats`, {
+ const res = await fetch(`/api/v1/events/${encodeURIComponent(activeSlug)}/stats`, {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
- const json = await res.json();
- setData({ onlineGuests: json.online_guests ?? 0, tasksSolved: json.tasks_solved ?? 0, latestPhotoAt: json.latest_photo_at });
+ const json: StatsResponse = await res.json();
+ setData({
+ onlineGuests: json.online_guests ?? 0,
+ tasksSolved: json.tasks_solved ?? 0,
+ latestPhotoAt: json.latest_photo_at ?? null,
+ });
} finally {
setLoading(false);
}
}
useEffect(() => {
- setLoading(true);
- fetchOnce();
- function schedule() {
- if (!visible) return;
- timer.current = window.setInterval(fetchOnce, 10_000);
+ const onVis = () => setVisible(document.visibilityState === 'visible');
+ document.addEventListener('visibilitychange', onVis);
+ return () => document.removeEventListener('visibilitychange', onVis);
+ }, []);
+
+ useEffect(() => {
+ if (!canPoll) {
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ const activeSlug = String(slug);
+ fetchOnce(activeSlug);
+ if (timer.current) window.clearInterval(timer.current);
+ if (visible) {
+ timer.current = window.setInterval(() => fetchOnce(activeSlug), 10_000);
}
- schedule();
return () => {
if (timer.current) window.clearInterval(timer.current);
};
- }, [slug, visible]);
+ }, [slug, visible, canPoll]);
- return { loading, onlineGuests: data?.onlineGuests ?? 0, tasksSolved: data?.tasksSolved ?? 0 };
+ return { ...data, loading };
}
-
diff --git a/resources/js/guest/queue/queue.ts b/resources/js/guest/queue/queue.ts
index 2ebcb0a..0fd6410 100644
--- a/resources/js/guest/queue/queue.ts
+++ b/resources/js/guest/queue/queue.ts
@@ -2,6 +2,7 @@ import { withStore } from './idb';
import { getDeviceId } from '../lib/device';
import { createUpload } from './xhr';
import { notify } from './notify';
+type SyncManager = { register(tag: string): Promise; };
export type QueueItem = {
id?: number;
@@ -26,7 +27,10 @@ export async function enqueue(item: Omit
- {slug ? : }
-
-
+
+ if (!slug) {
+ return (
+
-
-
+ );
+ }
+
+ return (
+
+
+
+
+
);
}
export const router = createBrowserRouter([
{ path: '/', element: },
- { path: '/setup', element: },
+ {
+ path: '/setup/:slug',
+ element: ,
+ children: [
+ { index: true, element: },
+ ],
+ },
{
path: '/e/:slug',
element: ,
@@ -53,6 +78,21 @@ export const router = createBrowserRouter([
{ path: '*', element: },
]);
+function SetupLayout() {
+ const { slug } = useParams<{ slug: string }>();
+ if (!slug) return null;
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -64,3 +104,4 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
);
}
+
diff --git a/resources/js/guest/services/achievementApi.ts b/resources/js/guest/services/achievementApi.ts
new file mode 100644
index 0000000..fe8c0a1
--- /dev/null
+++ b/resources/js/guest/services/achievementApi.ts
@@ -0,0 +1,212 @@
+import { getDeviceId } from '../lib/device';
+
+export interface AchievementBadge {
+ id: string;
+ title: string;
+ description: string;
+ earned: boolean;
+ progress: number;
+ target: number;
+}
+
+export interface LeaderboardEntry {
+ guest: string;
+ photos: number;
+ likes: number;
+}
+
+export interface TopPhotoHighlight {
+ photoId: number;
+ guest: string;
+ likes: number;
+ task?: string | null;
+ createdAt: string;
+ thumbnail: string | null;
+}
+
+export interface TrendingEmotionHighlight {
+ emotionId: number;
+ name: string;
+ count: number;
+}
+
+export interface TimelinePoint {
+ date: string;
+ photos: number;
+ guests: number;
+}
+
+export interface FeedEntry {
+ photoId: number;
+ guest: string;
+ task?: string | null;
+ likes: number;
+ createdAt: string;
+ thumbnail: string | null;
+}
+
+export interface AchievementsPayload {
+ summary: {
+ totalPhotos: number;
+ uniqueGuests: number;
+ tasksSolved: number;
+ likesTotal: number;
+ };
+ personal: {
+ guestName: string;
+ photos: number;
+ tasks: number;
+ likes: number;
+ badges: AchievementBadge[];
+ } | null;
+ leaderboards: {
+ uploads: LeaderboardEntry[];
+ likes: LeaderboardEntry[];
+ };
+ highlights: {
+ topPhoto: TopPhotoHighlight | null;
+ trendingEmotion: TrendingEmotionHighlight | null;
+ timeline: TimelinePoint[];
+ };
+ feed: FeedEntry[];
+}
+
+function toNumber(value: unknown, fallback = 0): number {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string' && value !== '') {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+ }
+ return fallback;
+}
+
+function safeString(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
+export async function fetchAchievements(
+ slug: string,
+ guestName?: string,
+ signal?: AbortSignal
+): Promise {
+ const params = new URLSearchParams();
+ if (guestName && guestName.trim().length > 0) {
+ params.set('guest_name', guestName.trim());
+ }
+
+ const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
+ method: 'GET',
+ headers: {
+ 'X-Device-Id': getDeviceId(),
+ 'Cache-Control': 'no-store',
+ },
+ signal,
+ });
+
+ if (!response.ok) {
+ const message = await response.text();
+ throw new Error(message || 'Achievements request failed');
+ }
+
+ const json = await response.json();
+ const summary = json.summary ?? {};
+ const personalRaw = json.personal ?? null;
+ const leaderboards = json.leaderboards ?? {};
+ const highlights = json.highlights ?? {};
+ const feedRaw = Array.isArray(json.feed) ? json.feed : [];
+
+ const personal = personalRaw
+ ? {
+ guestName: safeString(personalRaw.guest_name),
+ photos: toNumber(personalRaw.photos),
+ tasks: toNumber(personalRaw.tasks),
+ likes: toNumber(personalRaw.likes),
+ badges: Array.isArray(personalRaw.badges)
+ ? personalRaw.badges.map((badge: any): AchievementBadge => ({
+ id: safeString(badge.id),
+ title: safeString(badge.title),
+ description: safeString(badge.description),
+ earned: Boolean(badge.earned),
+ progress: toNumber(badge.progress),
+ target: toNumber(badge.target, 1),
+ }))
+ : [],
+ }
+ : null;
+
+ const uploadsBoard = Array.isArray(leaderboards.uploads)
+ ? leaderboards.uploads.map((row: any): LeaderboardEntry => ({
+ guest: safeString(row.guest),
+ photos: toNumber(row.photos),
+ likes: toNumber(row.likes),
+ }))
+ : [];
+
+ const likesBoard = Array.isArray(leaderboards.likes)
+ ? leaderboards.likes.map((row: any): LeaderboardEntry => ({
+ guest: safeString(row.guest),
+ photos: toNumber(row.photos),
+ likes: toNumber(row.likes),
+ }))
+ : [];
+
+ const topPhotoRaw = highlights.top_photo ?? null;
+ const topPhoto = topPhotoRaw
+ ? {
+ photoId: toNumber(topPhotoRaw.photo_id),
+ guest: safeString(topPhotoRaw.guest),
+ likes: toNumber(topPhotoRaw.likes),
+ task: topPhotoRaw.task ?? null,
+ createdAt: safeString(topPhotoRaw.created_at),
+ thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
+ }
+ : null;
+
+ const trendingRaw = highlights.trending_emotion ?? null;
+ const trendingEmotion = trendingRaw
+ ? {
+ emotionId: toNumber(trendingRaw.emotion_id),
+ name: safeString(trendingRaw.name),
+ count: toNumber(trendingRaw.count),
+ }
+ : null;
+
+ const timeline = Array.isArray(highlights.timeline)
+ ? highlights.timeline.map((row: any): TimelinePoint => ({
+ date: safeString(row.date),
+ photos: toNumber(row.photos),
+ guests: toNumber(row.guests),
+ }))
+ : [];
+
+ const feed = feedRaw.map((row: any): FeedEntry => ({
+ photoId: toNumber(row.photo_id),
+ guest: safeString(row.guest),
+ task: row.task ?? null,
+ likes: toNumber(row.likes),
+ createdAt: safeString(row.created_at),
+ thumbnail: row.thumbnail ? safeString(row.thumbnail) : null,
+ }));
+
+ return {
+ summary: {
+ totalPhotos: toNumber(summary.total_photos),
+ uniqueGuests: toNumber(summary.unique_guests),
+ tasksSolved: toNumber(summary.tasks_solved),
+ likesTotal: toNumber(summary.likes_total),
+ },
+ personal,
+ leaderboards: {
+ uploads: uploadsBoard,
+ likes: likesBoard,
+ },
+ highlights: {
+ topPhoto,
+ trendingEmotion,
+ timeline,
+ },
+ feed,
+ };
+}
diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php
index cae8e63..af2aad7 100644
--- a/resources/views/admin.blade.php
+++ b/resources/views/admin.blade.php
@@ -5,6 +5,7 @@
{{ __('admin.shell.tenant_admin_title') }}
+ @viteReactRefresh
@vite('resources/js/admin/main.tsx')
diff --git a/resources/views/guest.blade.php b/resources/views/guest.blade.php
index 0ec327c..5cac0b1 100644
--- a/resources/views/guest.blade.php
+++ b/resources/views/guest.blade.php
@@ -5,6 +5,7 @@
{{ config('app.name', 'Fotospiel') }}
+ @viteReactRefresh
@vite('resources/js/guest/main.tsx')
diff --git a/routes/api.php b/routes/api.php
index c878d09..d48b38c 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,4 +1,4 @@
-name('api.v1.')->group(function () {
Route::middleware('throttle:100,1')->group(function () {
Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
+ Route::get('/events/{slug}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
Route::get('/events/{slug}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
Route::get('/events/{slug}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
Route::get('/events/{slug}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
@@ -24,8 +25,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
});
- // Protected tenant API routes (require auth:sanctum + tenant middleware)
- Route::middleware(['auth:sanctum', \App\Http\Middleware\TenantTokenGuard::class, \App\Http\Middleware\TenantIsolation::class])->prefix('tenant')->group(function () {
+ // Protected tenant API routes (JWT tenants via OAuth guard)
+ Route::middleware(['tenant.token', 'tenant.isolation'])->prefix('tenant')->group(function () {
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
// Events CRUD
@@ -64,6 +65,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history');
+ Route::post('purchase', [CreditController::class, 'purchase'])->name('tenant.credits.purchase');
+ Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync');
});
});
-});
\ No newline at end of file
+});
+
+
diff --git a/routes/web.php b/routes/web.php
index fd4265d..37b763a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -44,25 +44,6 @@ Route::get('/super-admin/templates/emotions.csv', function () {
return response()->stream($callback, 200, $headers);
});
-// Tenant Admin API (temporary token-based, no hardening)
-Route::prefix('api/v1/tenant')->group(function () {
- Route::post('/login', [\App\Http\Controllers\Api\TenantController::class, 'login']);
- Route::middleware([\App\Http\Middleware\ApiTokenAuth::class])->group(function () {
- Route::get('/me', [\App\Http\Controllers\Api\TenantController::class, 'me']);
- Route::get('/events', [\App\Http\Controllers\Api\TenantController::class, 'events']);
- Route::get('/events/{id}', [\App\Http\Controllers\Api\TenantController::class, 'showEvent']);
- Route::post('/events', [\App\Http\Controllers\Api\TenantController::class, 'storeEvent']);
- Route::put('/events/{id}', [\App\Http\Controllers\Api\TenantController::class, 'updateEvent']);
- Route::post('/events/{id}/toggle', [\App\Http\Controllers\Api\TenantController::class, 'toggleEvent']);
- Route::get('/events/{id}/photos', [\App\Http\Controllers\Api\TenantController::class, 'eventPhotos']);
- Route::get('/events/{id}/stats', [\App\Http\Controllers\Api\TenantController::class, 'eventStats']);
- Route::post('/events/{id}/invites', [\App\Http\Controllers\Api\TenantController::class, 'createInvite']);
- Route::post('/photos/{id}/feature', [\App\Http\Controllers\Api\TenantController::class, 'featurePhoto']);
- Route::post('/photos/{id}/unfeature', [\App\Http\Controllers\Api\TenantController::class, 'unfeaturePhoto']);
- Route::delete('/photos/{id}', [\App\Http\Controllers\Api\TenantController::class, 'deletePhoto']);
- });
-});
-
// Tenant Admin PWA shell
Route::view('/admin/{any?}', 'admin')->where('any', '.*');
Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png']);
diff --git a/tests/Feature/OAuthFlowTest.php b/tests/Feature/OAuthFlowTest.php
new file mode 100644
index 0000000..24202c2
--- /dev/null
+++ b/tests/Feature/OAuthFlowTest.php
@@ -0,0 +1,143 @@
+create([
+ 'slug' => 'test-tenant',
+ ]);
+
+ OAuthClient::create([
+ 'id' => (string) Str::uuid(),
+ 'client_id' => 'tenant-admin-app',
+ 'tenant_id' => $tenant->id,
+ 'redirect_uris' => ['http://localhost/callback'],
+ 'scopes' => ['tenant:read', 'tenant:write'],
+ 'is_active' => true,
+ ]);
+
+ $codeVerifier = 'unit-test-code-verifier-1234567890';
+ $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
+ $state = Str::random(10);
+
+ $response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
+ 'client_id' => 'tenant-admin-app',
+ 'redirect_uri' => 'http://localhost/callback',
+ 'response_type' => 'code',
+ 'scope' => 'tenant:read tenant:write',
+ 'state' => $state,
+ 'code_challenge' => $codeChallenge,
+ 'code_challenge_method' => 'S256',
+ ]));
+
+ $response->assertRedirect();
+ $location = $response->headers->get('Location');
+ $this->assertNotNull($location);
+
+ $query = [];
+ parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
+ $authorizationCode = $query['code'] ?? null;
+ $this->assertNotNull($authorizationCode, 'Authorization code should be present');
+ $this->assertEquals($state, $query['state'] ?? null);
+
+ $tokenResponse = $this->post('/api/v1/oauth/token', [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authorizationCode,
+ 'client_id' => 'tenant-admin-app',
+ 'redirect_uri' => 'http://localhost/callback',
+ 'code_verifier' => $codeVerifier,
+ ]);
+
+ $tokenResponse->assertOk();
+ $tokenData = $tokenResponse->json();
+
+ $this->assertArrayHasKey('access_token', $tokenData);
+ $this->assertArrayHasKey('refresh_token', $tokenData);
+ $this->assertSame('Bearer', $tokenData['token_type']);
+
+ $meResponse = $this->get('/api/v1/tenant/me', [
+ 'Authorization' => 'Bearer ' . $tokenData['access_token'],
+ ]);
+
+ $meResponse->assertOk();
+ $meResponse->assertJsonFragment([
+ 'tenant_id' => $tenant->id,
+ 'name' => $tenant->name,
+ ]);
+
+ $refreshResponse = $this->post('/api/v1/oauth/token', [
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $tokenData['refresh_token'],
+ 'client_id' => 'tenant-admin-app',
+ ]);
+
+ $refreshResponse->assertOk();
+ $refreshData = $refreshResponse->json();
+ $this->assertArrayHasKey('access_token', $refreshData);
+ $this->assertArrayHasKey('refresh_token', $refreshData);
+ $this->assertNotEquals($refreshData['access_token'], $tokenData['access_token']);
+ }
+}
diff --git a/tests/Feature/TenantCreditsTest.php b/tests/Feature/TenantCreditsTest.php
new file mode 100644
index 0000000..ae5a12b
--- /dev/null
+++ b/tests/Feature/TenantCreditsTest.php
@@ -0,0 +1,174 @@
+create([
+ 'slug' => 'credits-tenant',
+ 'event_credits_balance' => 0,
+ ]);
+
+ $client = OAuthClient::create([
+ 'id' => (string) Str::uuid(),
+ 'client_id' => 'tenant-admin-app',
+ 'tenant_id' => $tenant->id,
+ 'redirect_uris' => ['http://localhost/callback'],
+ 'scopes' => ['tenant:read', 'tenant:write'],
+ 'is_active' => true,
+ ]);
+
+ [$accessToken] = $this->obtainTokens($client);
+
+ $headers = [
+ 'Authorization' => 'Bearer '.$accessToken,
+ ];
+
+ $balanceResponse = $this->withHeaders($headers)
+ ->getJson('/api/v1/tenant/credits/balance');
+
+ $balanceResponse->assertOk()
+ ->assertJsonStructure(['balance', 'free_event_granted_at']);
+
+ $purchaseResponse = $this->withHeaders($headers)
+ ->postJson('/api/v1/tenant/credits/purchase', [
+ 'package_id' => 'event_starter',
+ 'credits_added' => 5,
+ 'platform' => 'capacitor',
+ 'transaction_id' => 'txn_test_123',
+ 'subscription_active' => false,
+ ]);
+
+ $purchaseResponse->assertCreated()
+ ->assertJsonStructure(['message', 'balance', 'subscription_active']);
+
+ $tenant->refresh();
+ $this->assertSame(5, $tenant->event_credits_balance);
+
+ $this->assertDatabaseHas('event_purchases', [
+ 'tenant_id' => $tenant->id,
+ 'events_purchased' => 5,
+ 'external_receipt_id' => 'txn_test_123',
+ ]);
+
+ $this->assertDatabaseHas('event_credits_ledger', [
+ 'tenant_id' => $tenant->id,
+ 'delta' => 5,
+ 'reason' => 'purchase',
+ ]);
+
+ $syncResponse = $this->withHeaders($headers)
+ ->postJson('/api/v1/tenant/credits/sync', [
+ 'balance' => $tenant->event_credits_balance,
+ 'subscription_active' => false,
+ 'last_sync' => now()->toIso8601String(),
+ ]);
+
+ $syncResponse->assertOk()
+ ->assertJsonStructure(['balance', 'subscription_active', 'server_time']);
+ }
+
+ private function obtainTokens(OAuthClient $client): array
+ {
+ $codeVerifier = 'tenant-credits-code-verifier-1234567890';
+ $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
+ $state = Str::random(10);
+
+ $response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
+ 'client_id' => $client->client_id,
+ 'redirect_uri' => 'http://localhost/callback',
+ 'response_type' => 'code',
+ 'scope' => 'tenant:read tenant:write',
+ 'state' => $state,
+ 'code_challenge' => $codeChallenge,
+ 'code_challenge_method' => 'S256',
+ ]));
+
+ $response->assertRedirect();
+ $location = $response->headers->get('Location');
+ $this->assertNotNull($location);
+
+ $query = [];
+ parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
+ $authorizationCode = $query['code'] ?? null;
+ $this->assertNotNull($authorizationCode, 'Authorization code should be present');
+
+ $tokenResponse = $this->post('/api/v1/oauth/token', [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authorizationCode,
+ 'client_id' => $client->client_id,
+ 'redirect_uri' => 'http://localhost/callback',
+ 'code_verifier' => $codeVerifier,
+ ]);
+
+ $tokenResponse->assertOk();
+
+ return [
+ $tokenResponse->json('access_token'),
+ $tokenResponse->json('refresh_token'),
+ ];
+ }
+
+}
diff --git a/tests/e2e/guest-profile-flow.test.ts b/tests/e2e/guest-profile-flow.test.ts
new file mode 100644
index 0000000..35efd42
--- /dev/null
+++ b/tests/e2e/guest-profile-flow.test.ts
@@ -0,0 +1,52 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Guest Profile Flow', () => {
+ test('should require name setup on first event join and persist it', async ({ page }) => {
+ // Assume Vite dev server is running on localhost:5173
+ await page.goto('http://localhost:5173/');
+
+ // Enter event slug manually
+ await page.fill('input[placeholder*="Event-Code"]', 'test-event');
+ await page.click('button:has-text("Event beitreten")');
+
+ // Should redirect to setup if no name
+ await expect(page).toHaveURL(/.*\/e\/test-event\/setup/);
+
+ // Fill name and submit
+ await page.fill('input[placeholder*="Dein Name"]', 'Test User');
+ await page.click('button:has-text("LET\'S GO! ✨")');
+
+ // Should navigate to home
+ await expect(page).toHaveURL(/.*\/e\/test-event$/);
+
+ // Check localStorage
+ const storedName = await page.evaluate(() => localStorage.getItem('guestName_test-event'));
+ expect(storedName).toBe('Test User');
+
+ // Reload to test persistence - should stay on home, not redirect to setup
+ await page.reload();
+ await expect(page).toHaveURL(/.*\/e\/test-event$/);
+
+ // Re-nav to landing and join again - should go directly to home
+ await page.goto('http://localhost:5173/');
+ await page.fill('input[placeholder*="Event-Code"]', 'test-event');
+ await page.click('button:has-text("Event beitreten")');
+ await expect(page).toHaveURL(/.*\/e\/test-event$/);
+ });
+
+ test('should go directly to home if name already stored', async ({ page }) => {
+ // Pre-set name in localStorage
+ await page.addInitScript(() => {
+ localStorage.setItem('guestName_test-event', 'Existing User');
+ });
+
+ await page.goto('http://localhost:5173/');
+
+ // Join
+ await page.fill('input[placeholder*="Event-Code"]', 'test-event');
+ await page.click('button:has-text("Event beitreten")');
+
+ // Should go directly to home
+ await expect(page).toHaveURL(/.*\/e\/test-event$/);
+ });
+});
\ No newline at end of file
diff --git a/tests/e2e/oauth-flow.test.ts b/tests/e2e/oauth-flow.test.ts
new file mode 100644
index 0000000..3050c05
--- /dev/null
+++ b/tests/e2e/oauth-flow.test.ts
@@ -0,0 +1,76 @@
+import { test, expect } from '@playwright/test';
+
+test('OAuth Flow for tenant-admin-app', async ({ page }) => {
+ const code_challenge = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
+ const code_verifier = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
+ const redirect_uri = 'http://localhost:8000/auth/callback';
+ const state = 'teststate';
+ const scope = 'tenant:read tenant:write tenant:admin';
+
+ const authorizeUrl = `/api/v1/oauth/authorize?response_type=code&client_id=tenant-admin-app&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}&code_challenge=${code_challenge}&code_challenge_method=S256&state=${state}`;
+
+ // Navigate to authorize - should immediately redirect to callback
+ await page.goto(authorizeUrl);
+ await page.waitForLoadState('networkidle');
+
+ // Log response if no redirect
+ const currentUrl = page.url();
+ if (currentUrl.includes('/authorize')) {
+ const response = await page.content();
+ console.log('No redirect, response:', response.substring(0, 500)); // First 500 chars
+ }
+
+ // Wait for redirect to callback and parse params
+ await expect(page).toHaveURL(new RegExp(`${redirect_uri}\\?.*`));
+ const urlObj = new URL(currentUrl);
+ const code = urlObj.searchParams.get('code') || '';
+ const receivedState = urlObj.searchParams.get('state') || '';
+
+ expect(receivedState).toBe(state);
+ expect(code).not.toBeNull();
+
+ console.log('Authorization code:', code);
+
+ // Token exchange via fetch
+ const tokenParams = {
+ code: code!,
+ redirect_uri,
+ code_verifier
+ };
+ const tokenResponse = await page.evaluate(async (params) => {
+ const response = await fetch('/api/v1/oauth/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: 'tenant-admin-app',
+ code: params.code,
+ redirect_uri: params.redirect_uri,
+ code_verifier: params.code_verifier,
+ }).toString(),
+ });
+ return await response.json();
+ }, tokenParams);
+
+ console.log('Token response:', tokenResponse);
+ expect(tokenResponse.access_token).toBeTruthy();
+
+ const accessToken = tokenResponse.access_token;
+
+ // Call /tenant/me with token
+ const meResponse = await page.evaluate(async (token) => {
+ const response = await fetch('/api/v1/tenant/me', {
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Accept': 'application/json',
+ },
+ });
+ return await response.json();
+ }, accessToken);
+
+ console.log('/tenant/me response:', meResponse);
+ expect(meResponse).toHaveProperty('id');
+ expect(meResponse.email).toBe('demo@example.com');
+});
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 56153f6..2bb436e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -19,19 +19,53 @@ export default defineConfig({
origin: devServerOrigin,
hmr: {
host: parsedOrigin.hostname,
- protocol: parsedOrigin.protocol.replace(':', ''),
- port: hmrPort,
+ protocol: parsedOrigin.protocol.replace(':','') as 'http' | 'https',
+ clientPort: hmrPort,
+ },
+ fs: {
+ strict: true,
+ // Erlaube nur das App-Package (ggf. Pfade anpassen)
+ allow: [__dirname],
},
cors: {
origin: appUrl,
credentials: true,
},
+ watch: {
+ // WENIGER ist mehr: Alles ausklammern, was nicht für HMR nötig ist
+ ignored: [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/dist/**',
+ '**/build/**',
+ '**/.next/**',
+ '**/coverage/**',
+ '**/.cache/**',
+ // Laravel-spezifisch
+ '**/public/build/**',
+ '**/storage/**',
+ '**/vendor/**',
+ '**/bootstrap/cache/**',
+ // Monorepo-Nachbarn
+ '../**/node_modules/**',
+ '../**/dist/**',
+ '../**/build/**',
+ '../**/coverage/**',
+ ],
+ // Falls ihr auf gemounteten FS seid und Events fehlen:
+ // usePolling: true, interval: 500,
+ },
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx', 'resources/js/guest/main.tsx', 'resources/js/admin/main.tsx'],
ssr: 'resources/js/ssr.tsx',
- refresh: true,
+ refresh: [
+ 'resources/views/**/*.blade.php',
+ 'resources/lang/**/*.php',
+ 'app/Http/Livewire/**', // falls genutzt
+ // NICHT beobachten: storage/logs, vendor, public/build, etc.
+ ],
}),
react(),
tailwindcss(),
@@ -42,4 +76,20 @@ export default defineConfig({
esbuild: {
jsx: 'automatic',
},
+ optimizeDeps: {
+ // Bei großen Monorepos hilfreich:
+ entries: ['resources/js/**/*'],
+ exclude: [
+ // füge notfalls große/selten genutzte Pakete hinzu
+ ],
+ },
+
+ // Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht:
+ build: {
+ sourcemap: false,
+ target: 'es2020',
+ rollupOptions: {
+ // keine externen Monster-Globs
+ },
+ },
});