diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index d002c8b..0a73e06 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -9,6 +9,7 @@ use App\Models\Photo; use App\Models\PhotoShareLink; use App\Services\Analytics\JoinTokenAnalyticsRecorder; use App\Services\EventJoinTokenService; +use App\Services\EventTasksCacheService; use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageUsageTracker; use App\Services\Storage\EventStorageManager; @@ -20,6 +21,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; @@ -38,6 +40,7 @@ class EventPublicController extends BaseController private readonly JoinTokenAnalyticsRecorder $analyticsRecorder, private readonly PackageLimitEvaluator $packageLimitEvaluator, private readonly PackageUsageTracker $packageUsageTracker, + private readonly EventTasksCacheService $eventTasksCache, ) {} /** @@ -439,6 +442,380 @@ class EventPublicController extends BaseController return $value ?: $default; } + /** + * @param array{tasks: array>, hash: string} $payload + */ + private function buildLocalizedTasksPayload(int $eventId, string $locale, ?string $eventDefaultLocale): array + { + $fallbacks = $this->localeFallbackChain($locale, $eventDefaultLocale); + + $rows = DB::table('tasks') + ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') + ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') + ->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id') + ->where('event_task_collection.event_id', $eventId) + ->select([ + 'tasks.id', + 'tasks.title', + 'tasks.description', + 'tasks.example_text', + 'tasks.emotion_id', + 'tasks.sort_order', + 'emotions.name as emotion_name', + 'emotions.id as emotion_lookup_id', + ]) + ->orderBy('event_task_collection.sort_order') + ->orderBy('tasks.sort_order') + ->limit(20) + ->get(); + + $tasks = $rows->map(function ($row) use ($fallbacks) { + $emotion = null; + + if ($row->emotion_id) { + $emotionName = $this->firstLocalizedValue($row->emotion_name, $fallbacks, 'Unbekannte Emotion'); + + if ($emotionName !== '') { + $emotionId = (int) ($row->emotion_lookup_id ?? $row->emotion_id); + $emotion = [ + 'slug' => 'emotion-'.$emotionId, + 'name' => $emotionName, + ]; + } + } + + return [ + 'id' => (int) $row->id, + 'title' => $this->firstLocalizedValue($row->title, $fallbacks, 'Unbenannte Aufgabe'), + 'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''), + 'instructions' => $this->firstLocalizedValue($row->example_text, $fallbacks, ''), + 'duration' => 3, + 'is_completed' => false, + 'emotion' => $emotion, + ]; + })->values()->all(); + + return [ + 'tasks' => $tasks, + 'hash' => sha1(json_encode($tasks)), + ]; + } + + private function resolveGuestLocale(Request $request, object $event): array + { + $supported = $this->eventTasksCache->supportedLocales(); + + $queryLocale = $this->normalizeLocale($request->query('locale', $request->query('lang')), $supported); + if ($queryLocale) { + return [$queryLocale, 'query']; + } + + $headerLocale = $this->normalizeLocale($request->header('X-Locale'), $supported); + if ($headerLocale) { + return [$headerLocale, 'header']; + } + + $acceptedLocale = $this->normalizeLocale($request->getPreferredLanguage($supported), $supported); + if ($acceptedLocale) { + return [$acceptedLocale, 'accept-language']; + } + + $eventLocale = $this->normalizeLocale($event->default_locale ?? null, $supported); + if ($eventLocale) { + return [$eventLocale, 'event']; + } + + $fallbackLocale = $this->normalizeLocale(config('app.fallback_locale'), $supported); + if ($fallbackLocale) { + return [$fallbackLocale, 'fallback']; + } + + return [$supported[0] ?? 'de', 'fallback']; + } + + private function localeFallbackChain(string $preferred, ?string $eventDefault): array + { + $chain = array_filter([ + $preferred, + $this->normalizeLocale($eventDefault), + $this->normalizeLocale(config('app.fallback_locale')), + 'de', + 'en', + ]); + + return array_values(array_unique($chain)); + } + + private function normalizeLocale(?string $value, ?array $allowed = null): ?string + { + if ($value === null) { + return null; + } + + $normalized = Str::of($value) + ->lower() + ->replace('_', '-') + ->before('-') + ->trim() + ->value(); + + if ($normalized === '') { + return null; + } + + if ($allowed !== null && ! in_array($normalized, $allowed, true)) { + return null; + } + + return $normalized; + } + + private function decodeTranslations(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + } + + return []; + } + + private function firstLocalizedValue(mixed $value, array $locales, string $default = ''): string + { + $translations = $this->decodeTranslations($value); + + foreach ($locales as $locale) { + $candidate = $translations[$locale] ?? null; + + if (is_string($candidate) && trim($candidate) !== '') { + return $candidate; + } + } + + if (is_string($value) && trim($value) !== '') { + return $value; + } + + if (! empty($translations)) { + $first = reset($translations); + if (is_string($first) && trim($first) !== '') { + return $first; + } + } + + return $default; + } + + private function determineGuestIdentifier(Request $request): ?string + { + $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; + + if ($candidate === '') { + return null; + } + + return substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120); + } + + private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array + { + $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, + ]; + + $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 = match ($badge['metric']) { + 'photos' => $personalPhotos, + 'tasks' => $personalTasks, + default => $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' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null, + '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->firstLocalizedValue($trendingEmotionRow->name, $fallbacks, 'Emotion'), + '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' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null, + 'likes' => (int) $row->likes_count, + 'created_at' => $row->created_at, + 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path), + ]) + ->values(); + + return [ + 'summary' => $summary, + 'personal' => $personal, + 'leaderboards' => [ + 'uploads' => $topUploads, + 'likes' => $topLikes, + ], + 'highlights' => [ + 'top_photo' => $topPhoto, + 'trending_emotion' => $trendingEmotion, + 'timeline' => $timeline, + ], + 'feed' => $feed, + ]; + } + private function toPublicUrl(?string $path): ?string { if (! $path) { @@ -1263,156 +1640,117 @@ class EventPublicController extends BaseController public function emotions(Request $request, string $token) { - $result = $this->resolvePublishedEvent($request, $token, ['id']); + $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } - [$event, $joinToken] = $result; - $eventId = $event->id; - $locale = $request->query('locale', 'de'); + [$event] = $result; + $eventId = (int) $event->id; - $rows = DB::table('emotions') - ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') - ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id') - ->join('events', 'events.event_type_id', '=', 'event_types.id') - ->where('events.id', $eventId) - ->select([ - 'emotions.id', - 'emotions.name', - 'emotions.icon as emoji', - 'emotions.description', - ]) - ->orderBy('emotions.sort_order') - ->get(); + [$locale] = $this->resolveGuestLocale($request, $event); + $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); - $payload = $rows->map(function ($r) use ($locale) { - $nameData = json_decode($r->name, true); - $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; + $cacheKey = sprintf('event:%d:emotions:%s', $eventId, $locale); - $descriptionData = json_decode($r->description, true); - $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; + $cached = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($eventId, $fallbacks) { + $rows = DB::table('emotions') + ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') + ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id') + ->join('events', 'events.event_type_id', '=', 'event_types.id') + ->where('events.id', $eventId) + ->select([ + 'emotions.id', + 'emotions.name', + 'emotions.icon as emoji', + 'emotions.description', + ]) + ->orderBy('emotions.sort_order') + ->get(); + + $data = $rows->map(function ($row) use ($fallbacks) { + return [ + 'id' => (int) $row->id, + 'slug' => 'emotion-'.$row->id, + 'name' => $this->firstLocalizedValue($row->name, $fallbacks, 'Emotion'), + 'emoji' => $row->emoji, + 'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''), + ]; + })->values()->all(); return [ - 'id' => (int) $r->id, - 'slug' => 'emotion-'.$r->id, // Generate slug from ID - 'name' => $name, - 'emoji' => $r->emoji, - 'description' => $description, + 'data' => $data, + 'hash' => sha1(json_encode($data)), ]; }); - $etag = sha1($payload->toJson()); + $payload = $cached['data']; + $etag = $cached['hash']; $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { - return response('', 304); + return response('', 304) + ->header('Cache-Control', 'public, max-age=300') + ->header('ETag', $etag) + ->header('Vary', 'Accept-Language, X-Locale') + ->header('X-Content-Locale', $locale); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') - ->header('ETag', $etag); + ->header('ETag', $etag) + ->header('Vary', 'Accept-Language, X-Locale') + ->header('X-Content-Locale', $locale); } public function tasks(Request $request, string $token) { - $result = $this->resolvePublishedEvent($request, $token, ['id']); + $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; - $eventId = $event->id; - $query = DB::table('tasks') - ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') - ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') - ->where('event_task_collection.event_id', $eventId) - ->select([ - 'tasks.id', - 'tasks.title', - 'tasks.description', - 'tasks.example_text as instructions', - 'tasks.emotion_id', - 'tasks.sort_order', - ]) - ->orderBy('event_task_collection.sort_order') - ->orderBy('tasks.sort_order') - ->limit(20); + [$resolvedLocale] = $this->resolveGuestLocale($request, $event); - $rows = $query->get(); - $locale = $request->query('locale', 'de'); - - $payload = $rows->map(function ($r) use ($locale) { - // Handle JSON fields for multilingual content - $getLocalized = function ($field, $default = '') use ($r, $locale) { - $value = $r->$field; - if (is_string($value) && json_decode($value) !== null) { - $data = json_decode($value, true); - - return $data[$locale] ?? $data['de'] ?? $default; - } - - return $value ?: $default; - }; - - // Get emotion info if emotion_id exists - $emotion = null; - if ($r->emotion_id) { - $emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']); - if ($emotionRow && isset($emotionRow->name)) { - $value = $emotionRow->name; - if (is_string($value) && json_decode($value) !== null) { - $data = json_decode($value, true); - $emotionName = $data[$locale] ?? $data['de'] ?? 'Unbekannte Emotion'; - } else { - $emotionName = $value ?: 'Unbekannte Emotion'; - } - $emotion = [ - 'slug' => 'emotion-'.$r->emotion_id, // Generate slug from ID - 'name' => $emotionName, - ]; - } - } - - return [ - 'id' => (int) $r->id, - 'title' => $getLocalized('title', 'Unbenannte Aufgabe'), - 'description' => $getLocalized('description', ''), - 'instructions' => $getLocalized('instructions', ''), - 'duration' => 3, // Default 3 minutes (no duration field in DB) - 'is_completed' => false, // Default false (no is_completed field in DB) - 'emotion' => $emotion, - ]; + $cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) { + return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null); }); - // If no tasks found, return empty array instead of 404 - if ($payload->isEmpty()) { - $payload = collect([]); - } - - $etag = sha1($payload->toJson()); + $tasks = $cached['tasks']; + $etag = $cached['hash'] ?? sha1(json_encode($tasks)); $reqEtag = $request->headers->get('If-None-Match'); + if ($reqEtag && $reqEtag === $etag) { - return response('', 304); + return response('', 304) + ->header('Cache-Control', 'public, max-age=120') + ->header('Vary', 'Accept-Language, X-Locale') + ->header('X-Content-Locale', $resolvedLocale) + ->header('ETag', $etag); } - return response()->json($payload) - ->header('Cache-Control', 'public, max-age=300') - ->header('ETag', $etag); + return response()->json($tasks) + ->header('Cache-Control', 'public, max-age=120') + ->header('ETag', $etag) + ->header('Vary', 'Accept-Language, X-Locale') + ->header('X-Content-Locale', $resolvedLocale); } public function photos(Request $request, string $token) { - $result = $this->resolvePublishedEvent($request, $token, ['id']); + $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } - [$event, $joinToken] = $result; - $eventId = $event->id; + [$event] = $result; + $eventId = (int) $event->id; + + [$locale] = $this->resolveGuestLocale($request, $event); + $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); $deviceId = (string) $request->header('X-Device-Id', 'anon'); $filter = $request->query('filter'); @@ -1446,15 +1784,13 @@ class EventPublicController extends BaseController if ($since) { $query->where('photos.created_at', '>', $since); } - $locale = $request->query('locale', 'de'); - - $rows = $query->get()->map(function ($r) use ($locale) { + $rows = $query->get()->map(function ($r) use ($fallbacks) { $r->file_path = $this->toPublicUrl((string) ($r->file_path ?? '')); $r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? '')); // Localize task title if present if ($r->task_title) { - $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); + $r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe'); } $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN; @@ -1466,19 +1802,26 @@ class EventPublicController extends BaseController 'data' => $rows, 'latest_photo_at' => $latestPhotoAt, ]; - $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt])); + $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt, $locale])); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { - return response('', 304); + return response('', 304) + ->header('Cache-Control', 'no-store') + ->header('ETag', $etag) + ->header('Last-Modified', (string) $latestPhotoAt) + ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') + ->header('X-Content-Locale', $locale); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) - ->header('Last-Modified', (string) $latestPhotoAt); + ->header('Last-Modified', (string) $latestPhotoAt) + ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') + ->header('X-Content-Locale', $locale); } - public function photo(int $id) + public function photo(Request $request, int $id) { $row = DB::table('photos') ->join('events', 'photos.event_id', '=', 'events.id') @@ -1493,6 +1836,7 @@ class EventPublicController extends BaseController 'photos.task_id', 'photos.created_at', 'tasks.title as task_title', + 'events.default_locale', ]) ->where('photos.id', $id) ->where('events.status', 'published') @@ -1509,13 +1853,24 @@ class EventPublicController extends BaseController $row->file_path = $this->toPublicUrl((string) ($row->file_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? '')); - // Localize task title if present - $locale = request()->query('locale', 'de'); + $event = (object) [ + 'id' => $row->event_id, + 'default_locale' => $row->default_locale ?? null, + ]; + + [$locale] = $this->resolveGuestLocale($request, $event); + $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); + if ($row->task_title) { - $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); + $row->task_title = $this->firstLocalizedValue($row->task_title, $fallbacks, 'Unbenannte Aufgabe'); } - return response()->json($row)->header('Cache-Control', 'no-store'); + unset($row->default_locale); + + return response()->json($row) + ->header('Cache-Control', 'no-store') + ->header('X-Content-Locale', $locale) + ->header('Vary', 'Accept-Language, X-Locale'); } public function like(Request $request, int $id) @@ -1791,220 +2146,53 @@ class EventPublicController extends BaseController public function achievements(Request $request, string $identifier) { - $result = $this->resolvePublishedEvent($request, $identifier, ['id']); + $result = $this->resolvePublishedEvent($request, $identifier, ['id', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; - $eventId = $event->id; - $locale = $request->query('locale', 'de'); + $eventId = (int) $event->id; - $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'); + [$locale] = $this->resolveGuestLocale($request, $event); + $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); - $summary = [ - 'total_photos' => $totalPhotos, - 'unique_guests' => $uniqueGuests, - 'tasks_solved' => $tasksSolved, - 'likes_total' => $likesTotal, - ]; + $guestIdentifier = $this->determineGuestIdentifier($request); - $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; + $cacheKey = sprintf( + 'event:%d:achievements:%s:%s', + $eventId, + $locale, + $guestIdentifier ? sha1($guestIdentifier) : 'public' + ); - $personal = null; - if ($guestIdentifier) { - $personalPhotos = (int) DB::table('photos') - ->where('event_id', $eventId) - ->where('guest_name', $guestIdentifier) - ->count(); + $cached = Cache::remember($cacheKey, now()->addSeconds(60), function () use ($eventId, $guestIdentifier, $fallbacks) { + $payload = $this->buildAchievementsPayload($eventId, $guestIdentifier, $fallbacks); - $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'], + return [ + 'payload' => $payload, + 'hash' => sha1(json_encode($payload)), ]; + }); - $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)); + $payload = $cached['payload']; + $etag = $cached['hash']; $ifNoneMatch = $request->headers->get('If-None-Match'); + if ($ifNoneMatch && $ifNoneMatch === $etag) { - return response('', 304); + return response('', 304) + ->header('Cache-Control', 'public, max-age=60') + ->header('ETag', $etag) + ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') + ->header('X-Content-Locale', $locale); } return response()->json($payload) - ->header('Cache-Control', 'no-store') - ->header('ETag', $etag); + ->header('Cache-Control', 'public, max-age=60') + ->header('ETag', $etag) + ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') + ->header('X-Content-Locale', $locale); } private function resolveDiskUrl(string $disk, string $path): string diff --git a/app/Models/Task.php b/app/Models/Task.php index 590fa87..b2958cd 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\EventTasksCacheService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,7 +18,9 @@ class Task extends Model use SoftDeletes; protected $table = 'tasks'; + protected $guarded = []; + protected $casts = [ 'due_date' => 'datetime', 'is_completed' => 'bool', @@ -81,6 +84,14 @@ class Task extends Model ); } }); + + static::saved(function (Task $task) { + app(EventTasksCacheService::class)->forgetForTask($task); + }); + + static::deleted(function (Task $task) { + app(EventTasksCacheService::class)->forgetForTask($task); + }); } protected static function generateSlug(string $base): string @@ -88,7 +99,7 @@ class Task extends Model $slugBase = Str::slug($base) ?: 'task'; do { - $slug = $slugBase . '-' . Str::random(6); + $slug = $slugBase.'-'.Str::random(6); } while (static::where('slug', $slug)->exists()); return $slug; @@ -97,6 +108,6 @@ class Task extends Model public function assignedEvents(): BelongsToMany { return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id') - ->withTimestamps(); + ->withTimestamps(); } } diff --git a/app/Services/EventTasksCacheService.php b/app/Services/EventTasksCacheService.php new file mode 100644 index 0000000..6e28c11 --- /dev/null +++ b/app/Services/EventTasksCacheService.php @@ -0,0 +1,101 @@ +>, hash: string} + */ + public function remember(int $eventId, string $locale, Closure $resolver): array + { + $key = $this->cacheKey($eventId, $locale); + + return Cache::remember($key, now()->addSeconds(self::CACHE_TTL_SECONDS), $resolver); + } + + public function forgetForEvents(iterable $eventIds): void + { + $locales = $this->supportedLocales(); + $ids = is_array($eventIds) ? $eventIds : iterator_to_array($eventIds, false); + + foreach (array_unique(array_map('intval', $ids)) as $eventId) { + foreach ($locales as $locale) { + Cache::forget($this->cacheKey($eventId, $locale)); + } + } + } + + public function forgetForTask(Task $task): void + { + $collectionEventIds = DB::table('event_task_collection') + ->join('task_collection_task', 'event_task_collection.task_collection_id', '=', 'task_collection_task.task_collection_id') + ->where('task_collection_task.task_id', $task->id) + ->pluck('event_task_collection.event_id') + ->all(); + + $directEventIds = $task->assignedEvents()->pluck('events.id')->all(); + + $this->forgetForEvents(array_merge($collectionEventIds, $directEventIds)); + } + + /** + * @return array + */ + public function supportedLocales(): array + { + $configured = config('app.supported_locales'); + + if (is_string($configured)) { + $configured = explode(',', $configured); + } + + if (is_array($configured) && ! empty($configured)) { + return $this->sanitizeLocales($configured); + } + + $envConfigured = env('APP_SUPPORTED_LOCALES'); + + if (is_string($envConfigured) && trim($envConfigured) !== '') { + return $this->sanitizeLocales(explode(',', $envConfigured)); + } + + $fallback = array_filter([config('app.locale'), config('app.fallback_locale')]); + + if (! empty($fallback)) { + return $this->sanitizeLocales($fallback); + } + + return ['de', 'en']; + } + + private function cacheKey(int $eventId, string $locale): string + { + return sprintf('%s:%d:%s', self::CACHE_PREFIX, $eventId, $locale); + } + + /** + * @param array $locales + * @return array + */ + private function sanitizeLocales(array $locales): array + { + $sanitized = array_map(function ($value) { + $normalized = strtolower(trim((string) $value)); + $normalized = str_replace('_', '-', $normalized); + + return explode('-', $normalized)[0] ?? $normalized; + }, $locales); + + return array_values(array_filter(array_unique($sanitized))); + } +} diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index fa4ddaa..35de491 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from '../i18n/useTranslation'; interface Emotion { id: number; @@ -33,6 +34,7 @@ export default function EmotionPicker({ const [emotions, setEmotions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { locale } = useTranslation(); // Fallback emotions (when API not available yet) const fallbackEmotions: Emotion[] = [ @@ -53,7 +55,12 @@ export default function EmotionPicker({ setError(null); // Try API first - const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions`); + const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions?locale=${encodeURIComponent(locale)}`, { + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, + }); if (response.ok) { const data = await response.json(); setEmotions(Array.isArray(data) ? data : fallbackEmotions); @@ -72,7 +79,7 @@ export default function EmotionPicker({ } fetchEmotions(); - }, [eventKey]); + }, [eventKey, locale]); const handleEmotionSelect = (emotion: Emotion) => { if (onSelect) { diff --git a/resources/js/guest/components/FiltersBar.tsx b/resources/js/guest/components/FiltersBar.tsx index 22c5606..017d34a 100644 --- a/resources/js/guest/components/FiltersBar.tsx +++ b/resources/js/guest/components/FiltersBar.tsx @@ -1,17 +1,20 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { Sparkles, Flame, UserRound, Camera } from 'lucide-react'; +import { useTranslation } from '../i18n/useTranslation'; export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; -const filterConfig: Array<{ value: GalleryFilter; label: string; icon: React.ReactNode }> = [ - { value: 'latest', label: 'Neueste', icon: }, - { value: 'popular', label: 'Beliebt', icon: }, - { value: 'mine', label: 'Meine', icon: }, - { value: 'photobooth', label: 'Fotobox', icon: }, +const filterConfig: Array<{ value: GalleryFilter; labelKey: string; icon: React.ReactNode }> = [ + { value: 'latest', labelKey: 'galleryPage.filters.latest', icon: }, + { value: 'popular', labelKey: 'galleryPage.filters.popular', icon: }, + { value: 'mine', labelKey: 'galleryPage.filters.mine', icon: }, + { value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: }, ]; export default function FiltersBar({ value, onChange, className }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string }) { + const { t } = useTranslation(); + return (
{filter.icon} - {filter.label} + {t(filter.labelKey)} ))}
diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 536257a..00fcf36 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -4,13 +4,15 @@ import { Card, CardContent } from '@/components/ui/card'; import { getDeviceId } from '../lib/device'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; import { Heart } from 'lucide-react'; +import { useTranslation } from '../i18n/useTranslation'; type Props = { token: string }; type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; export default function GalleryPreview({ token }: Props) { - const { photos, loading } = usePollGalleryDelta(token); + const { locale } = useTranslation(); + const { photos, loading } = usePollGalleryDelta(token, locale); const [mode, setMode] = React.useState('latest'); const items = React.useMemo(() => { diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index d2ca50c..657c644 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -184,6 +184,67 @@ export const messages: Record = { days: 'vor {count} Tagen', }, }, + achievements: { + page: { + title: 'Erfolge', + subtitle: 'Behalte deine Highlights, Badges und die aktivsten Gäste im Blick.', + loadError: 'Erfolge konnten nicht geladen werden.', + retry: 'Erneut versuchen', + buttons: { + personal: 'Meine Erfolge', + event: 'Event Highlights', + feed: 'Live Feed', + }, + }, + personal: { + greeting: 'Hi {name}!', + stats: '{photos} Fotos | {tasks} Aufgaben | {likes} Likes', + actions: { + upload: 'Neues Foto hochladen', + tasks: 'Aufgabe ziehen', + }, + }, + badges: { + title: 'Badges', + description: 'Dein Fortschritt bei den verfügbaren Erfolgen.', + empty: 'Noch keine Badges verfügbar.', + }, + leaderboard: { + description: 'Top 5 Teilnehmer dieses Events', + uploadsTitle: 'Top Uploads', + uploadsEmpty: 'Noch keine Uploads – sobald Fotos vorhanden sind, erscheinen sie hier.', + likesTitle: 'Beliebteste Gäste', + likesEmpty: 'Likes fehlen noch – motiviere die Gäste, Fotos zu liken.', + guestFallback: 'Gast', + item: { + photos: '{count} Fotos', + likes: '{count} Likes', + }, + }, + timeline: { + title: 'Timeline', + description: 'Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.', + row: '{photos} Fotos | {guests} Gäste', + }, + feed: { + title: 'Live Feed', + description: 'Die neuesten Momente aus deinem Event.', + empty: 'Noch keine Uploads – starte die Kamera und lege los!', + taskLabel: 'Aufgabe: {task}', + likesLabel: '{count} Likes', + thumbnailAlt: 'Vorschau', + }, + highlights: { + topTitle: 'Publikumsliebling', + topDescription: 'Das Foto mit den meisten Likes.', + noPreview: 'Kein Vorschau-Bild', + likesAmount: '{count} Likes', + taskLabel: 'Aufgabe: {task}', + trendingTitle: 'Trend-Emotion', + trendingDescription: 'Diese Stimmung taucht gerade besonders oft auf.', + trendingCount: '{count} Fotos mit dieser Stimmung', + }, + }, tasks: { page: { eyebrow: 'Aufgaben-Zentrale', @@ -243,6 +304,37 @@ export const messages: Record = { emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.', lightboxGuestFallback: 'Gast', }, + galleryPage: { + title: 'Galerie', + subtitle: 'Live-Impressionen deines Events', + loadingEvent: 'Event-Info wird geladen...', + eventNotFound: 'Event nicht gefunden.', + hero: { + label: 'Live-Galerie', + stats: '{photoCount} Fotos · {likeCount} ❤️ · {guestCount} Gäste online', + newPhotos: '{count} neue Fotos ansehen', + upload: 'Neues Foto hochladen', + eventFallback: 'Event', + }, + loading: 'Lade…', + photo: { + justNow: 'Gerade eben', + anonymous: 'Gast', + alt: 'Foto {id}{suffix}', + altTaskSuffix: ' - {task}', + likeAria: 'Foto liken', + shareAria: 'Foto teilen', + }, + filters: { + latest: 'Neueste', + popular: 'Beliebt', + mine: 'Meine', + photobooth: 'Fotobox', + }, + badge: { + newPhotos: '{count} neue Fotos', + }, + }, share: { title: 'Geteiltes Foto', defaultEvent: 'Ein besonderer Moment', @@ -729,6 +821,67 @@ export const messages: Record = { days: '{count} days ago', }, }, + achievements: { + page: { + title: 'Achievements', + subtitle: 'Keep an eye on highlights, badges, and the most active guests.', + loadError: 'Achievements could not be loaded.', + retry: 'Try again', + buttons: { + personal: 'My achievements', + event: 'Event highlights', + feed: 'Live feed', + }, + }, + personal: { + greeting: 'Hi {name}!', + stats: '{photos} photos | {tasks} tasks | {likes} likes', + actions: { + upload: 'Upload photo', + tasks: 'Draw a task', + }, + }, + badges: { + title: 'Badges', + description: 'Your progress across available achievements.', + empty: 'No badges unlocked yet.', + }, + leaderboard: { + description: 'Top 5 participants of this event', + uploadsTitle: 'Top uploads', + uploadsEmpty: 'No uploads yet – once photos arrive you will see them here.', + likesTitle: 'Most liked guests', + likesEmpty: 'No likes yet – motivate guests to like photos.', + guestFallback: 'Guest', + item: { + photos: '{count} photos', + likes: '{count} likes', + }, + }, + timeline: { + title: 'Timeline', + description: 'How the event gained momentum throughout the day.', + row: '{photos} photos | {guests} guests', + }, + feed: { + title: 'Live feed', + description: 'The freshest moments from your event.', + empty: 'No uploads yet – grab your camera and start!', + taskLabel: 'Task: {task}', + likesLabel: '{count} likes', + thumbnailAlt: 'Preview', + }, + highlights: { + topTitle: 'Audience favorite', + topDescription: 'The photo with the most likes.', + noPreview: 'No preview image', + likesAmount: '{count} likes', + taskLabel: 'Task: {task}', + trendingTitle: 'Trending emotion', + trendingDescription: 'This mood appears most often right now.', + trendingCount: '{count} photos in this mood', + }, + }, tasks: { page: { eyebrow: 'Mission hub', @@ -788,6 +941,37 @@ export const messages: Record = { emptyDescription: 'Once photos are approved they will appear here.', lightboxGuestFallback: 'Guest', }, + galleryPage: { + title: 'Gallery', + subtitle: 'Live impressions from your event', + loadingEvent: 'Loading event info…', + eventNotFound: 'Event not found.', + hero: { + label: 'Live gallery', + stats: '{photoCount} photos · {likeCount} ❤️ · {guestCount} guests online', + newPhotos: 'View {count} new photos', + upload: 'Upload new photo', + eventFallback: 'Event', + }, + loading: 'Loading…', + photo: { + justNow: 'Just now', + anonymous: 'Guest', + alt: 'Photo {id}{suffix}', + altTaskSuffix: ' - {task}', + likeAria: 'Like photo', + shareAria: 'Share photo', + }, + filters: { + latest: 'Newest', + popular: 'Popular', + mine: 'My photos', + photobooth: 'Photo booth', + }, + badge: { + newPhotos: '{count} new photos', + }, + }, share: { title: 'Shared photo', defaultEvent: 'A special moment', diff --git a/resources/js/guest/lib/localizeTaskLabel.ts b/resources/js/guest/lib/localizeTaskLabel.ts new file mode 100644 index 0000000..bec4ecb --- /dev/null +++ b/resources/js/guest/lib/localizeTaskLabel.ts @@ -0,0 +1,51 @@ +import type { LocaleCode } from '../i18n/messages'; + +type LocalizedRecord = Record; + +function pickLocalizedValue(record: LocalizedRecord, locale: LocaleCode): string | null { + if (typeof record[locale] === 'string' && record[locale]) { + return record[locale] as string; + } + + if (typeof record.de === 'string' && record.de) { + return record.de as string; + } + + if (typeof record.en === 'string' && record.en) { + return record.en as string; + } + + const firstValue = Object.values(record).find((value) => typeof value === 'string' && value); + return (firstValue as string | undefined) ?? null; +} + +export function localizeTaskLabel( + raw: string | LocalizedRecord | null | undefined, + locale: LocaleCode, +): string | null { + if (!raw) { + return null; + } + + if (typeof raw === 'object') { + return pickLocalizedValue(raw, locale); + } + + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') { + return pickLocalizedValue(parsed as LocalizedRecord, locale) ?? trimmed; + } + } catch { + return trimmed; + } + } + + return trimmed; +} diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 3362d17..85ea075 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +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'; @@ -18,32 +18,37 @@ import { } from '../services/achievementApi'; import { useGuestIdentity } from '../context/GuestIdentityContext'; import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react'; +import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; +import type { LocaleCode } from '../i18n/messages'; +import { localizeTaskLabel } from '../lib/localizeTaskLabel'; -function formatNumber(value: number): string { - return new Intl.NumberFormat('de-DE').format(value); -} +const GENERIC_ERROR = 'GENERIC_ERROR'; -function formatRelativeTime(input: string): string { +function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string { const date = new Date(input); if (Number.isNaN(date.getTime())) return ''; - const diff = Date.now() - date.getTime(); + const diff = date.getTime() - Date.now(); 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`; + const abs = Math.abs(diff); + if (abs < minute) return formatter.format(0, 'second'); + if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute'); + if (abs < day) return formatter.format(Math.round(diff / hour), 'hour'); + return formatter.format(Math.round(diff / day), 'day'); } -function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) { +type LeaderboardProps = { + title: string; + description: string; + icon: React.ElementType; + entries: LeaderboardEntry[]; + emptyCopy: string; + formatNumber: (value: number) => string; + t: TranslateFn; +}; + +function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, formatNumber, t }: LeaderboardProps) { return ( @@ -52,7 +57,7 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
{title} - Top 5 Teilnehmer dieses Events + {description}
@@ -64,11 +69,11 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
  • #{index + 1} - {entry.guest || 'Gast'} + {entry.guest || t('achievements.leaderboard.guestFallback')}
    - {entry.photos} Fotos - {entry.likes} Likes + {t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })} + {t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })}
  • ))} @@ -79,27 +84,21 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; ); } -function progressMeta(badge: AchievementBadge) { - const target = badge.target ?? 0; - const progress = badge.progress ?? 0; - const ratio = target > 0 ? Math.min(1, progress / target) : 0; - return { - progress, - target, - ratio: badge.earned ? 1 : ratio, - }; -} +type BadgesGridProps = { + badges: AchievementBadge[]; + t: TranslateFn; +}; -function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { +function BadgesGrid({ badges, t }: BadgesGridProps) { if (badges.length === 0) { return ( - Badges - Erfülle Aufgaben und sammle Likes, um Badges freizuschalten. + {t('achievements.badges.title')} + {t('achievements.badges.description')} -

    Noch keine Badges verfügbar.

    +

    {t('achievements.badges.empty')}

    ); @@ -108,13 +107,15 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { return ( - Badges - Dein Fortschritt bei den verfügbaren Erfolgen. + {t('achievements.badges.title')} + {t('achievements.badges.description')} {badges.map((badge) => { - const { ratio } = progressMeta(badge); - const percentage = Math.round(ratio * 100); + const target = badge.target ?? 0; + const progress = badge.progress ?? 0; + const ratio = target > 0 ? Math.min(1, progress / target) : 0; + const percentage = Math.round((badge.earned ? 1 : ratio) * 100); return (

    {badge.title}

    -

    {badge.description}

    +

    {badge.description}

    - - - + {percentage}%
    -
    -
    -
    -
    -

    - {badge.earned ? 'Freigeschaltet 🎉' : `Fortschritt: ${badge.progress}/${badge.target}`} -

    +
    +
    ); @@ -153,21 +144,26 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) { ); } -function Timeline({ points }: { points: TimelinePoint[] }) { - if (points.length === 0) { - return null; - } +type TimelineProps = { + points: TimelinePoint[]; + t: TranslateFn; + formatNumber: (value: number) => string; +}; + +function Timeline({ points, t, formatNumber }: TimelineProps) { return ( - Timeline - Wie das Event im Laufe der Zeit Fahrt aufgenommen hat. + {t('achievements.timeline.title')} + {t('achievements.timeline.description')} {points.map((point) => (
    {point.date} - {point.photos} Fotos | {point.guests} Gäste + + {t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })} +
    ))}
    @@ -175,16 +171,24 @@ function Timeline({ points }: { points: TimelinePoint[] }) { ); } -function Feed({ feed }: { feed: FeedEntry[] }) { +type FeedProps = { + feed: FeedEntry[]; + t: TranslateFn; + formatRelativeTime: (value: string) => string; + locale: LocaleCode; + formatNumber: (value: number) => string; +}; + +function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps) { if (feed.length === 0) { return ( - Live Feed - Neue Uploads erscheinen hier in Echtzeit. + {t('achievements.feed.title')} + {t('achievements.feed.description')} -

    Noch keine Uploads – starte die Kamera und lege los!

    +

    {t('achievements.feed.empty')}

    ); @@ -193,159 +197,135 @@ function Feed({ feed }: { feed: FeedEntry[] }) { return ( - Live Feed - Die neuesten Momente aus deinem Event. + {t('achievements.feed.title')} + {t('achievements.feed.description')} - {feed.map((item) => ( -
    - {item.thumbnail ? ( - Vorschau - ) : ( -
    - )} -
    -

    {item.guest || 'Gast'}

    - {item.task &&

    Aufgabe: {item.task}

    } -
    - {formatRelativeTime(item.createdAt)} - {item.likes} Likes + {feed.map((item) => { + const taskLabel = localizeTaskLabel(item.task ?? null, locale); + return ( +
    + {item.thumbnail ? ( + {t('achievements.feed.thumbnailAlt')} + ) : ( +
    + +
    + )} +
    +

    {item.guest || t('achievements.leaderboard.guestFallback')}

    + {taskLabel &&

    {t('achievements.feed.taskLabel', { task: taskLabel })}

    } +
    + {formatRelativeTime(item.createdAt)} + {t('achievements.feed.likesLabel', { count: formatNumber(item.likes) })} +
    -
    - ))} + ); + })} ); } -function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) { +type HighlightsProps = { + topPhoto: TopPhotoHighlight | null; + trendingEmotion: TrendingEmotionHighlight | null; + t: TranslateFn; + formatRelativeTime: (value: string) => string; + locale: LocaleCode; + formatNumber: (value: number) => string; +}; + +function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale, formatNumber }: HighlightsProps) { if (!topPhoto && !trendingEmotion) { return null; } - return ( -
    - {topPhoto && ( - + const renderTopPhoto = () => { + if (!topPhoto) return null; + const localizedTask = localizeTaskLabel(topPhoto.task ?? null, locale); + return ( +
    - Publikumsliebling - Das Foto mit den meisten Likes. + {t('achievements.highlights.topTitle')} + {t('achievements.highlights.topDescription')}
    - +
    {topPhoto.thumbnail ? ( - Top Foto + {t('achievements.highlights.topTitle')} ) : ( -
    Kein Vorschau-Bild
    +
    + {t('achievements.highlights.noPreview')} +
    )}
    -

    {topPhoto.guest || 'Gast'} – {topPhoto.likes} Likes

    - {topPhoto.task &&

    Aufgabe: {topPhoto.task}

    } +

    + {topPhoto.guest || t('achievements.leaderboard.guestFallback')} + {` – ${t('achievements.highlights.likesAmount', { count: formatNumber(topPhoto.likes) })}`} +

    + {localizedTask && ( +

    + {t('achievements.highlights.taskLabel', { task: localizedTask })} +

    + )}

    {formatRelativeTime(topPhoto.createdAt)}

    - )} + ); + }; - {trendingEmotion && ( - + const renderTrendingEmotion = () => { + if (!trendingEmotion) return null; + return ( +
    - Trend-Emotion - Diese Stimmung taucht gerade besonders oft auf. + {t('achievements.highlights.trendingTitle')} + {t('achievements.highlights.trendingDescription')}
    - +

    {trendingEmotion.name}

    -

    {trendingEmotion.count} Fotos mit dieser Stimmung

    +

    + {t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })} +

    - )} + ); + }; + + return ( +
    + {renderTopPhoto()} + {renderTrendingEmotion()}
    ); } -function FlowSummary({ data, token }: { data: AchievementsPayload; token: string }) { - const personal = data.personal; - const tasksDone = personal?.tasks ?? data.summary.tasksSolved; - const photos = personal?.photos ?? data.summary.totalPhotos; - const likes = personal?.likes ?? data.summary.likesTotal; - const guests = data.summary.uniqueGuests; - const earnedBadges = personal?.badges.filter((badge) => badge.earned).length ?? 0; - const nextBadge = personal?.badges.find((badge) => !badge.earned); +type PersonalActionsProps = { + token: string; + t: TranslateFn; +}; - return ( -
    -
    -
    -

    Dein Flow

    -

    - {tasksDone === 0 ? 'Starte deine erste Mission.' : 'Weiter so, dein Event lebt!'} -

    -

    - {nextBadge ? `Noch ${Math.max(0, nextBadge.target - nextBadge.progress)} Schritte bis „${nextBadge.title}“.` : 'Alle aktuellen Badges freigeschaltet.'} -

    -
    -
    - - -
    -
    -
    - - - - -
    -
    - - -
    -
    - ); -} - -function FlowStat({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) { - return ( -
    -

    {label}

    -

    {value}

    -
    - ); -} - -function PersonalActions({ token }: { token: string }) { +function PersonalActions({ token, t }: PersonalActionsProps) { return (
    @@ -355,11 +335,17 @@ function PersonalActions({ token }: { token: string }) { export default function AchievementsPage() { const { token } = useParams<{ token: string }>(); const identity = useGuestIdentity(); + const { t, locale } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal'); + const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]); + const formatNumber = (value: number) => numberFormatter.format(value); + const relativeFormatter = useMemo(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }), [locale]); + const formatRelative = (value: string) => formatRelativeTimestamp(value, relativeFormatter); + const personalName = identity.hydrated && identity.name ? identity.name : undefined; useEffect(() => { @@ -368,7 +354,11 @@ export default function AchievementsPage() { setLoading(true); setError(null); - fetchAchievements(token, personalName, controller.signal) + fetchAchievements(token, { + guestName: personalName, + locale, + signal: controller.signal, + }) .then((payload) => { setData(payload); if (!payload.personal) { @@ -378,12 +368,11 @@ export default function AchievementsPage() { .catch((err) => { if (err.name === 'AbortError') return; console.error('Failed to load achievements', err); - setError(err.message || 'Erfolge konnten nicht geladen werden.'); + setError(err.message || GENERIC_ERROR); }) .finally(() => setLoading(false)); - return () => controller.abort(); - }, [token, personalName]); + }, [token, personalName, locale]); const hasPersonal = Boolean(data?.personal); @@ -396,11 +385,11 @@ export default function AchievementsPage() {
    - +
    -

    Erfolge

    -

    Behalte deine Highlights, Badges und die aktivsten Gäste im Blick.

    +

    {t('achievements.page.title')}

    +

    {t('achievements.page.subtitle')}

    @@ -416,9 +405,9 @@ export default function AchievementsPage() { {!loading && error && ( - {error} + {error === GENERIC_ERROR ? t('achievements.page.loadError') : error} @@ -426,8 +415,6 @@ export default function AchievementsPage() { {!loading && !error && data && ( <> - -
    @@ -463,41 +450,68 @@ export default function AchievementsPage() {
    - Hi {data.personal.guestName || identity.name || 'Gast'}! + + {t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })} + - {data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes + {t('achievements.personal.stats', { + photos: formatNumber(data.personal.photos), + tasks: formatNumber(data.personal.tasks), + likes: formatNumber(data.personal.likes), + })}
    - +
    - +
    )} {activeTab === 'event' && (
    - - + +
    )} - {activeTab === 'feed' && } + {activeTab === 'feed' && ( + + )} )}
    diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 5b5b8b7..ec5c033 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Page } from './_util'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; -import { Button } from '@/components/ui/button'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; import { Heart, Image as ImageIcon, Share2 } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; @@ -11,6 +10,7 @@ import { fetchEvent, fetchStats, type EventData, type EventStats } from '../serv import { useTranslation } from '../i18n/useTranslation'; import { sharePhotoLink } from '../lib/sharePhoto'; import { useToast } from '../components/ToastHost'; +import { localizeTaskLabel } from '../lib/localizeTaskLabel'; const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth']; @@ -36,8 +36,8 @@ const normalizeImageUrl = (src?: string | null) => { export default function GalleryPage() { const { token } = useParams<{ token?: string }>(); - const navigate = useNavigate(); - const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? ''); + const { t, locale } = useTranslation(); + const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '', locale); const [searchParams, setSearchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); const modeParam = searchParams.get('mode'); @@ -48,9 +48,9 @@ export default function GalleryPage() { const [event, setEvent] = useState(null); const [stats, setStats] = useState(null); const [eventLoading, setEventLoading] = useState(true); - const { t } = useTranslation(); const toast = useToast(); const [shareTargetId, setShareTargetId] = React.useState(null); + const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); useEffect(() => { setFilterState(parseGalleryFilter(modeParam)); @@ -146,10 +146,11 @@ export default function GalleryPage() { if (!token) return; setShareTargetId(photo.id); try { + const localizedTask = localizeTaskLabel(photo.task_title ?? null, locale); const result = await sharePhotoLink({ token, photoId: photo.id, - title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'), + title: localizedTask ?? event?.name ?? t('share.title', 'Geteiltes Foto'), text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }), }); @@ -167,55 +168,72 @@ export default function GalleryPage() { } if (!token) { - return

    Event nicht gefunden.

    ; + return ( + +

    {t('galleryPage.eventNotFound', 'Event nicht gefunden.')}

    +
    + ); } if (eventLoading) { - return

    Lade Event-Info...

    ; + return ( + +

    {t('galleryPage.loadingEvent', 'Lade Event-Info...')}

    +
    + ); } + const newPhotosBadgeText = t('galleryPage.badge.newPhotos', { + count: numberFormatter.format(newCount), + }, `${newCount} neue Fotos`); + const badgeEmphasisClass = newCount > 0 + ? 'border border-pink-200 bg-pink-500/15 text-pink-600' + : 'border border-transparent bg-muted text-muted-foreground'; + return ( - -
    -
    -
    -
    -

    Live-Galerie

    -
    - - {event?.name ?? 'Event'} -
    -

    - {photos.length} Fotos · {totalLikes} ❤️ · {stats?.onlineGuests ?? 0} Gäste online -

    -
    -
    - {newCount > 0 && ( - - )} - -
    + + +
    +
    +
    +
    +
    +

    {t('galleryPage.title')}

    +

    {t('galleryPage.subtitle')}

    +
    + + {newCount > 0 ? ( + + ) : ( + + {newPhotosBadgeText} + + )}
    -
    + +
    - {loading &&

    Lade…

    } + {loading &&

    {t('galleryPage.loading', 'Lade…')}

    }
    {list.map((p: any) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const createdLabel = p.created_at ? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : t('gallery.justNow', 'Gerade eben'); + : t('galleryPage.photo.justNow', 'Gerade eben'); const likeCount = counts[p.id] ?? (p.likes_count || 0); + const localizedTaskTitle = localizeTaskLabel(p.task_title ?? null, locale); + const altSuffix = localizedTaskTitle + ? t('galleryPage.photo.altTaskSuffix', { task: localizedTaskTitle }) + : ''; + const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`); const openPhoto = () => { const index = list.findIndex((photo: any) => photo.id === p.id); @@ -237,7 +255,7 @@ export default function GalleryPage() { > {`Foto { (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; @@ -246,10 +264,10 @@ export default function GalleryPage() { />
    - {p.task_title &&

    {p.task_title}

    } + {localizedTaskTitle &&

    {localizedTaskTitle}

    }
    {createdLabel} - {p.uploader_name || 'Gast'} + {p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}
    @@ -260,7 +278,7 @@ export default function GalleryPage() { onShare(p); }} className={`flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-white/10 transition ${shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/20'}`} - aria-label={t('share.button', 'Teilen')} + aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')} disabled={shareTargetId === p.id} > @@ -272,7 +290,7 @@ export default function GalleryPage() { onLike(p.id); }} className={`flex items-center gap-1 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-sm font-medium transition ${liked.has(p.id) ? 'text-pink-300' : 'text-white'}`} - aria-label="Foto liken" + aria-label={t('galleryPage.photo.likeAria', 'Foto liken')} > {likeCount} diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index 62f95c4..5ec3067 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -20,7 +20,7 @@ export default function HomePage() { const stats = useEventStats(); const { event } = useEventData(); const { completedCount } = useGuestTaskProgress(token); - const { t } = useTranslation(); + const { t, locale } = useTranslation(); const { branding } = useEventBranding(); const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed'; @@ -86,7 +86,15 @@ export default function HomePage() { async function loadMissions() { setMissionLoading(true); try { - const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/tasks`); + const response = await fetch( + `/api/v1/events/${encodeURIComponent(token)}/tasks?locale=${encodeURIComponent(locale)}`, + { + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, + } + ); if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); const payload = await response.json(); if (cancelled) return; @@ -119,7 +127,7 @@ export default function HomePage() { return () => { cancelled = true; }; - }, [shuffleMissionPreview, token]); + }, [shuffleMissionPreview, token, locale]); if (!token) { return null; diff --git a/resources/js/guest/pages/PhotoLightbox.tsx b/resources/js/guest/pages/PhotoLightbox.tsx index 101ed43..9b37dd7 100644 --- a/resources/js/guest/pages/PhotoLightbox.tsx +++ b/resources/js/guest/pages/PhotoLightbox.tsx @@ -34,7 +34,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh const navigate = useNavigate(); const photoId = params.photoId; const eventToken = params.token || token; - const { t } = useTranslation(); + const { t, locale } = useTranslation(); const toast = useToast(); const [standalonePhoto, setStandalonePhoto] = useState(null); @@ -62,7 +62,12 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh setLoading(true); setError(null); try { - const res = await fetch(`/api/v1/photos/${photoId}`); + const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, { + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, + }); if (res.ok) { const fetchedPhoto: Photo = await res.json(); setStandalonePhoto(fetchedPhoto); @@ -84,7 +89,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh } else if (!isStandalone) { setLoading(false); } - }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t]); + }, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale]); // Update likes when photo changes React.useEffect(() => { @@ -148,7 +153,15 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh (async () => { setTaskLoading(true); try { - const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/tasks`); + const res = await fetch( + `/api/v1/events/${encodeURIComponent(eventToken)}/tasks?locale=${encodeURIComponent(locale)}`, + { + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, + } + ); if (res.ok) { const tasks = await res.json(); const foundTask = tasks.find((t: any) => t.id === taskId); @@ -179,7 +192,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh setTaskLoading(false); } })(); - }, [photo?.task_id, eventToken, t]); + }, [photo?.task_id, eventToken, t, locale]); async function onLike() { if (liked || !photo) return; diff --git a/resources/js/guest/pages/TaskPickerPage.tsx b/resources/js/guest/pages/TaskPickerPage.tsx index 48bfbcb..8669a4b 100644 --- a/resources/js/guest/pages/TaskPickerPage.tsx +++ b/resources/js/guest/pages/TaskPickerPage.tsx @@ -8,6 +8,7 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { cn } from '@/lib/utils'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import EmotionPicker from '../components/EmotionPicker'; import { useEventBranding } from '../context/EventBrandingContext'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; @@ -168,7 +169,7 @@ export default function TaskPickerPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { branding } = useEventBranding(); - const { t } = useTranslation(); + const { t, locale } = useTranslation(); const { completedCount, isCompleted } = useGuestTaskProgress(eventKey); @@ -194,31 +195,61 @@ export default function TaskPickerPage() { }), [branding.primaryColor, branding.secondaryColor]); const recentTaskIdsRef = React.useRef([]); + const tasksCacheRef = React.useRef>(new Map()); const initialEmotionRef = React.useRef(false); const fetchTasks = React.useCallback(async () => { if (!eventKey) return; + const cacheKey = `${eventKey}:${locale}`; + const cached = tasksCacheRef.current.get(cacheKey); setIsFetching(true); - setLoading(true); + setLoading(!cached); setError(null); + + if (cached) { + setTasks(cached.data); + } + try { - const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`); + const headers: HeadersInit = { + Accept: 'application/json', + 'X-Locale': locale, + }; + + if (cached?.etag) { + headers['If-None-Match'] = cached.etag; + } + + const response = await fetch( + `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, + { headers } + ); + + if (response.status === 304 && cached) { + return; + } + if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); const payload = await response.json(); if (Array.isArray(payload)) { + const entry = { data: payload, etag: response.headers.get('ETag') }; + tasksCacheRef.current.set(cacheKey, entry); setTasks(payload); } else { + tasksCacheRef.current.set(cacheKey, { data: [], etag: response.headers.get('ETag') }); setTasks([]); } } catch (err) { console.error('Failed to load tasks', err); setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); - setTasks([]); + if (!cached) { + setTasks([]); + } } finally { setIsFetching(false); setLoading(false); } - }, [eventKey]); + }, [eventKey, locale]); React.useEffect(() => { fetchTasks(); @@ -348,8 +379,12 @@ export default function TaskPickerPage() { const controller = new AbortController(); setPhotoPoolLoading(true); setPhotoPoolError(null); - fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=de`, { + fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/photos?locale=${encodeURIComponent(locale)}`, { signal: controller.signal, + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, }) .then((res) => { if (!res.ok) { @@ -373,7 +408,7 @@ export default function TaskPickerPage() { }); return () => controller.abort(); - }, [eventKey, photoPool.length, t]); + }, [eventKey, photoPool.length, t, locale]); const similarPhotos = React.useMemo(() => { if (!currentTask) return []; diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 2aa202d..341a919 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -110,7 +110,7 @@ export default function UploadPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { markCompleted, completedCount } = useGuestTaskProgress(token); - const { t } = useTranslation(); + const { t, locale } = useTranslation(); const stats = useEventStats(); const taskIdParam = searchParams.get('task'); @@ -209,7 +209,15 @@ const [canUpload, setCanUpload] = useState(true); try { setLoadingTask(true); - const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`); + const res = await fetch( + `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, + { + headers: { + Accept: 'application/json', + 'X-Locale': locale, + }, + } + ); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); const payload = (await res.json()) as unknown; const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : []; @@ -264,7 +272,7 @@ const [canUpload, setCanUpload] = useState(true); return () => { active = false; }; - }, [eventKey, taskId, emotionSlug, t, token]); + }, [eventKey, taskId, emotionSlug, t, token, locale]); // Check upload limits useEffect(() => { diff --git a/resources/js/guest/polling/usePollGalleryDelta.ts b/resources/js/guest/polling/usePollGalleryDelta.ts index 1d0a87e..404f6bf 100644 --- a/resources/js/guest/polling/usePollGalleryDelta.ts +++ b/resources/js/guest/polling/usePollGalleryDelta.ts @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from 'react'; +import type { LocaleCode } from '../i18n/messages'; type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string }; -export function usePollGalleryDelta(token: string) { +export function usePollGalleryDelta(token: string, locale: LocaleCode) { const [photos, setPhotos] = useState([]); const [loading, setLoading] = useState(true); const [newCount, setNewCount] = useState(0); const latestAt = useRef(null); + const etagRef = useRef(null); const timer = useRef(null); const [visible, setVisible] = useState( typeof document !== 'undefined' ? document.visibilityState === 'visible' : true @@ -19,9 +21,24 @@ export function usePollGalleryDelta(token: string) { } try { - const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : ''; - const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos${qs}`, { - headers: { 'Cache-Control': 'no-store' }, + const params = new URLSearchParams(); + if (latestAt.current) { + params.set('since', latestAt.current); + } + params.set('locale', locale); + + const headers: HeadersInit = { + 'Cache-Control': 'no-store', + 'X-Locale': locale, + 'Accept': 'application/json', + }; + + if (etagRef.current) { + headers['If-None-Match'] = etagRef.current; + } + + const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos?${params.toString()}`, { + headers, }); if (res.status === 304) return; // No new content @@ -32,6 +49,7 @@ export function usePollGalleryDelta(token: string) { } const json = await res.json(); + etagRef.current = res.headers.get('ETag'); // Handle different response formats const rawPhotos = Array.isArray(json.data) ? json.data : @@ -103,6 +121,7 @@ export function usePollGalleryDelta(token: string) { setLoading(true); latestAt.current = null; + etagRef.current = null; setPhotos([]); fetchDelta(); if (timer.current) window.clearInterval(timer.current); @@ -112,7 +131,7 @@ export function usePollGalleryDelta(token: string) { return () => { if (timer.current) window.clearInterval(timer.current); }; - }, [token, visible]); + }, [token, visible, locale]); function acknowledgeNew() { setNewCount(0); } return { loading, photos, newCount, acknowledgeNew }; diff --git a/resources/js/guest/services/achievementApi.ts b/resources/js/guest/services/achievementApi.ts index 0fc419f..2121816 100644 --- a/resources/js/guest/services/achievementApi.ts +++ b/resources/js/guest/services/achievementApi.ts @@ -1,4 +1,5 @@ import { getDeviceId } from '../lib/device'; +import { DEFAULT_LOCALE } from '../i18n/messages'; export interface AchievementBadge { id: string; @@ -86,25 +87,60 @@ function safeString(value: unknown): string { return typeof value === 'string' ? value : ''; } +type FetchAchievementsOptions = { + guestName?: string; + locale?: string; + signal?: AbortSignal; + forceRefresh?: boolean; +}; + +type AchievementsCacheEntry = { + data: AchievementsPayload; + etag: string | null; +}; + +const achievementsCache = new Map(); + export async function fetchAchievements( eventToken: string, - guestName?: string, - signal?: AbortSignal + options: FetchAchievementsOptions = {} ): Promise { + const { guestName, signal, forceRefresh } = options; + const locale = options.locale ?? DEFAULT_LOCALE; + const params = new URLSearchParams(); if (guestName && guestName.trim().length > 0) { params.set('guest_name', guestName.trim()); } + if (locale) { + params.set('locale', locale); + } + + const deviceId = getDeviceId(); + const cacheKey = [eventToken, locale, guestName?.trim() ?? '', deviceId].join(':'); + const cached = forceRefresh ? null : achievementsCache.get(cacheKey); + + const headers: HeadersInit = { + 'X-Device-Id': deviceId, + 'Cache-Control': 'no-store', + Accept: 'application/json', + 'X-Locale': locale, + }; + + if (cached?.etag) { + headers['If-None-Match'] = cached.etag; + } const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, { method: 'GET', - headers: { - 'X-Device-Id': getDeviceId(), - 'Cache-Control': 'no-store', - }, + headers, signal, }); + if (response.status === 304 && cached) { + return cached.data; + } + if (!response.ok) { const message = await response.text(); throw new Error(message || 'Achievements request failed'); @@ -190,7 +226,7 @@ export async function fetchAchievements( thumbnail: row.thumbnail ? safeString(row.thumbnail) : null, })); - return { + const payload: AchievementsPayload = { summary: { totalPhotos: toNumber(summary.total_photos), uniqueGuests: toNumber(summary.unique_guests), @@ -209,4 +245,11 @@ export async function fetchAchievements( }, feed, }; + + achievementsCache.set(cacheKey, { + data: payload, + etag: response.headers.get('ETag'), + }); + + return payload; } diff --git a/tests/Feature/Api/Event/EventAchievementsLocaleTest.php b/tests/Feature/Api/Event/EventAchievementsLocaleTest.php new file mode 100644 index 0000000..3b10753 --- /dev/null +++ b/tests/Feature/Api/Event/EventAchievementsLocaleTest.php @@ -0,0 +1,66 @@ + ['de', 'en']]); + + $event = Event::factory()->create([ + 'status' => 'published', + 'default_locale' => 'de', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'achievements']) + ->plain_token; + + $emotion = Emotion::factory()->create([ + 'name' => ['de' => 'Freude', 'en' => 'Joy'], + ]); + + $task = Task::factory()->create([ + 'tenant_id' => $event->tenant_id, + 'title' => ['de' => 'Feuerring', 'en' => 'Fire Ring'], + 'description' => ['de' => 'DE Beschr', 'en' => 'EN Desc'], + 'example_text' => ['de' => 'DE Example', 'en' => 'EN Example'], + ]); + + Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'task_id' => $task->id, + 'emotion_id' => $emotion->id, + 'likes_count' => 42, + 'guest_name' => 'LocaleTester', + ]); + + $responseEn = $this->withHeaders(['X-Device-Id' => 'LocaleTester']) + ->getJson("/api/v1/events/{$token}/achievements?locale=en&guest_name=LocaleTester"); + + $responseEn->assertOk(); + $responseEn->assertHeader('X-Content-Locale', 'en'); + $responseEn->assertJsonPath('highlights.top_photo.task', 'Fire Ring'); + $responseEn->assertJsonPath('highlights.trending_emotion.name', 'Joy'); + + $etag = $responseEn->headers->get('ETag'); + $this->assertNotEmpty($etag); + + $this->withHeaders([ + 'X-Device-Id' => 'LocaleTester', + 'If-None-Match' => $etag, + ])->getJson("/api/v1/events/{$token}/achievements?locale=en&guest_name=LocaleTester") + ->assertStatus(304); + } +} diff --git a/tests/Feature/Api/Event/EventPhotosLocaleTest.php b/tests/Feature/Api/Event/EventPhotosLocaleTest.php new file mode 100644 index 0000000..dbb69fe --- /dev/null +++ b/tests/Feature/Api/Event/EventPhotosLocaleTest.php @@ -0,0 +1,74 @@ + ['de', 'en']]); + + $event = Event::factory()->create([ + 'status' => 'published', + 'default_locale' => 'de', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'gallery']) + ->plain_token; + + $emotion = Emotion::factory()->create([ + 'name' => ['de' => 'Freude', 'en' => 'Joy'], + ]); + + $task = Task::factory()->create([ + 'tenant_id' => $event->tenant_id, + 'title' => ['de' => 'Kussmoment', 'en' => 'Kiss Moment'], + 'description' => ['de' => 'DE', 'en' => 'EN'], + 'example_text' => ['de' => 'DE Example', 'en' => 'EN Example'], + ]); + + Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'task_id' => $task->id, + 'emotion_id' => $emotion->id, + 'created_at' => now(), + ]); + + $responseEn = $this->withHeaders(['X-Device-Id' => 'device-123']) + ->getJson("/api/v1/events/{$token}/photos?locale=en"); + + $responseEn->assertOk(); + $responseEn->assertHeader('X-Content-Locale', 'en'); + $responseEn->assertJsonFragment(['task_title' => 'Kiss Moment']); + + $etag = $responseEn->headers->get('ETag'); + $this->assertNotEmpty($etag); + + $responseDe = $this->withHeaders([ + 'X-Device-Id' => 'device-123', + 'Accept-Language' => 'de-DE', + ]) + ->getJson("/api/v1/events/{$token}/photos?locale=it"); + + $responseDe->assertOk(); + $responseDe->assertHeader('X-Content-Locale', 'de'); + $responseDe->assertJsonFragment(['task_title' => 'Kussmoment']); + + $this->withHeaders([ + 'X-Device-Id' => 'device-123', + 'If-None-Match' => $etag, + ])->getJson("/api/v1/events/{$token}/photos?locale=en") + ->assertStatus(304); + } +} diff --git a/tests/Feature/Api/Event/EventTasksLocaleTest.php b/tests/Feature/Api/Event/EventTasksLocaleTest.php new file mode 100644 index 0000000..1312621 --- /dev/null +++ b/tests/Feature/Api/Event/EventTasksLocaleTest.php @@ -0,0 +1,98 @@ + ['de', 'en']]); + + $event = Event::factory()->create([ + 'default_locale' => 'de', + 'status' => 'published', + ]); + $token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'test']); + $plainToken = $token->plain_token; + + $emotion = Emotion::factory()->create([ + 'name' => ['de' => 'Freude', 'en' => 'Joy'], + ]); + + $task = Task::factory()->create([ + 'tenant_id' => $event->tenant_id, + 'title' => ['de' => 'Kussmoment', 'en' => 'Kiss Moment'], + 'description' => ['de' => 'DE Beschreibung', 'en' => 'EN Description'], + 'example_text' => ['de' => 'DE Anleitung', 'en' => 'EN Instructions'], + 'emotion_id' => $emotion->id, + ]); + + $collection = TaskCollection::factory()->create([ + 'tenant_id' => $event->tenant_id, + ]); + + $collection->tasks()->attach($task->id, ['sort_order' => 1]); + $collection->events()->attach($event->id, ['sort_order' => 1]); + + $response = $this->getJson("/api/v1/events/{$plainToken}/tasks?locale=en"); + + $response->assertOk(); + $response->assertHeader('X-Content-Locale', 'en'); + $response->assertJsonCount(1); + $response->assertJson([[ + 'id' => $task->id, + 'title' => 'Kiss Moment', + 'description' => 'EN Description', + 'instructions' => 'EN Instructions', + 'duration' => 3, + 'is_completed' => false, + ]]); + + $this->assertSame('emotion-'.$emotion->id, $response->json('0.emotion.slug')); + } + + public function test_it_falls_back_to_event_locale_when_locale_invalid(): void + { + config(['app.supported_locales' => ['de', 'en']]); + + $event = Event::factory()->create([ + 'default_locale' => 'de', + 'status' => 'published', + ]); + $token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'fallback']); + $plainToken = $token->plain_token; + + $task = Task::factory()->create([ + 'tenant_id' => $event->tenant_id, + 'title' => ['de' => 'Aufgabe', 'en' => 'Task'], + 'description' => ['de' => 'Beschreibung', 'en' => 'Description'], + 'example_text' => ['de' => 'Beispiel', 'en' => 'Example'], + ]); + + $collection = TaskCollection::factory()->create([ + 'tenant_id' => $event->tenant_id, + ]); + + $collection->tasks()->attach($task->id, ['sort_order' => 1]); + $collection->events()->attach($event->id, ['sort_order' => 1]); + + $response = $this->withHeaders(['Accept-Language' => 'zz-ZZ']) + ->getJson("/api/v1/events/{$plainToken}/tasks?locale=it"); + + $response->assertOk(); + $response->assertHeader('X-Content-Locale', 'de'); + $response->assertJson([[ + 'title' => 'Aufgabe', + ]]); + } +}