feat: localize guest endpoints and caching
This commit is contained in:
@@ -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<int, array<string, mixed>>, 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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
101
app/Services/EventTasksCacheService.php
Normal file
101
app/Services/EventTasksCacheService.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Task;
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EventTasksCacheService
|
||||
{
|
||||
private const CACHE_PREFIX = 'event_tasks';
|
||||
|
||||
private const CACHE_TTL_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* @return array{tasks: array<int, array<string, mixed>>, 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<int, string>
|
||||
*/
|
||||
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<int, string> $locales
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -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<Emotion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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) {
|
||||
|
||||
@@ -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: <Sparkles className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'popular', label: 'Beliebt', icon: <Flame className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'mine', label: 'Meine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'photobooth', label: 'Fotobox', icon: <Camera className="h-4 w-4" aria-hidden /> },
|
||||
const filterConfig: Array<{ value: GalleryFilter; labelKey: string; icon: React.ReactNode }> = [
|
||||
{ value: 'latest', labelKey: 'galleryPage.filters.latest', icon: <Sparkles className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'popular', labelKey: 'galleryPage.filters.popular', icon: <Flame className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'mine', labelKey: 'galleryPage.filters.mine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
|
||||
{ value: 'photobooth', labelKey: 'galleryPage.filters.photobooth', icon: <Camera className="h-4 w-4" aria-hidden /> },
|
||||
];
|
||||
|
||||
export default function FiltersBar({ value, onChange, className }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -32,7 +35,7 @@ export default function FiltersBar({ value, onChange, className }: { value: Gall
|
||||
)}
|
||||
>
|
||||
{filter.icon}
|
||||
{filter.label}
|
||||
{t(filter.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<PreviewFilter>('latest');
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
|
||||
@@ -184,6 +184,67 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
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<LocaleCode, NestedMessages> = {
|
||||
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<LocaleCode, NestedMessages> = {
|
||||
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<LocaleCode, NestedMessages> = {
|
||||
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',
|
||||
|
||||
51
resources/js/guest/lib/localizeTaskLabel.ts
Normal file
51
resources/js/guest/lib/localizeTaskLabel.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
type LocalizedRecord = Record<string, string | null | undefined>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-4">
|
||||
@@ -52,7 +57,7 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">Top 5 Teilnehmer dieses Events</CardDescription>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -64,11 +69,11 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
|
||||
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
|
||||
<span className="font-medium text-foreground">{entry.guest || 'Gast'}</span>
|
||||
<span className="font-medium text-foreground">{entry.guest || t('achievements.leaderboard.guestFallback')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{entry.photos} Fotos</span>
|
||||
<span>{entry.likes} Likes</span>
|
||||
<span>{t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })}</span>
|
||||
<span>{t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Erfülle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
|
||||
<CardTitle>{t('achievements.badges.title')}</CardTitle>
|
||||
<CardDescription>{t('achievements.badges.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Badges verfügbar.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('achievements.badges.empty')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -108,13 +107,15 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
|
||||
<CardTitle>{t('achievements.badges.title')}</CardTitle>
|
||||
<CardDescription>{t('achievements.badges.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{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 (
|
||||
<div
|
||||
key={badge.id}
|
||||
@@ -128,22 +129,12 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{badge.title}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{badge.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{badge.description}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white/30 p-2 text-pink-500">
|
||||
<Award className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">{percentage}%</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/40">
|
||||
<div
|
||||
className={cn('h-2 rounded-full transition-all', badge.earned ? 'bg-emerald-500' : 'bg-pink-500')}
|
||||
style={{ width: `${Math.max(8, percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{badge.earned ? 'Freigeschaltet 🎉' : `Fortschritt: ${badge.progress}/${badge.target}`}
|
||||
</p>
|
||||
<div className="mt-3 h-2 rounded-full bg-muted">
|
||||
<div className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500" style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardDescription>Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.</CardDescription>
|
||||
<CardTitle>{t('achievements.timeline.title')}</CardTitle>
|
||||
<CardDescription>{t('achievements.timeline.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{points.map((point) => (
|
||||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
|
||||
<span className="font-medium text-foreground">{point.date}</span>
|
||||
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gäste</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
|
||||
<CardTitle>{t('achievements.feed.title')}</CardTitle>
|
||||
<CardDescription>{t('achievements.feed.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Uploads – starte die Kamera und lege los!</p>
|
||||
<p className="text-sm text-muted-foreground">{t('achievements.feed.empty')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -193,159 +197,135 @@ function Feed({ feed }: { feed: FeedEntry[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
<CardDescription>Die neuesten Momente aus deinem Event.</CardDescription>
|
||||
<CardTitle>{t('achievements.feed.title')}</CardTitle>
|
||||
<CardDescription>{t('achievements.feed.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{feed.map((item) => (
|
||||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/40 bg-muted/20 p-3">
|
||||
{item.thumbnail ? (
|
||||
<img src={item.thumbnail} alt="Vorschau" className="h-16 w-16 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted"> <Camera className="h-6 w-6 text-muted-foreground" /> </div>
|
||||
)}
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-semibold text-foreground">{item.guest || 'Gast'}</p>
|
||||
{item.task && <p className="text-xs text-muted-foreground">Aufgabe: {item.task}</p>}
|
||||
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{formatRelativeTime(item.createdAt)}</span>
|
||||
<span>{item.likes} Likes</span>
|
||||
{feed.map((item) => {
|
||||
const taskLabel = localizeTaskLabel(item.task ?? null, locale);
|
||||
return (
|
||||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/40 bg-muted/20 p-3">
|
||||
{item.thumbnail ? (
|
||||
<img src={item.thumbnail} alt={t('achievements.feed.thumbnailAlt')} className="h-16 w-16 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Camera className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-semibold text-foreground">{item.guest || t('achievements.leaderboard.guestFallback')}</p>
|
||||
{taskLabel && <p className="text-xs text-muted-foreground">{t('achievements.feed.taskLabel', { task: taskLabel })}</p>}
|
||||
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{formatRelativeTime(item.createdAt)}</span>
|
||||
<span>{t('achievements.feed.likesLabel', { count: formatNumber(item.likes) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{topPhoto && (
|
||||
<Card>
|
||||
const renderTopPhoto = () => {
|
||||
if (!topPhoto) return null;
|
||||
const localizedTask = localizeTaskLabel(topPhoto.task ?? null, locale);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Publikumsliebling</CardTitle>
|
||||
<CardDescription>Das Foto mit den meisten Likes.</CardDescription>
|
||||
<CardTitle>{t('achievements.highlights.topTitle')}</CardTitle>
|
||||
<CardDescription>{t('achievements.highlights.topDescription')}</CardDescription>
|
||||
</div>
|
||||
<Trophy className="h-6 w-6 text-amber-400" />
|
||||
<Trophy className="h-6 w-6 text-amber-400" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-border/40">
|
||||
{topPhoto.thumbnail ? (
|
||||
<img src={topPhoto.thumbnail} alt="Top Foto" className="h-48 w-full object-cover" />
|
||||
<img src={topPhoto.thumbnail} alt={t('achievements.highlights.topTitle')} className="h-48 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
|
||||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">
|
||||
{t('achievements.highlights.noPreview')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> – {topPhoto.likes} Likes</p>
|
||||
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
|
||||
<p>
|
||||
<span className="font-semibold text-foreground">{topPhoto.guest || t('achievements.leaderboard.guestFallback')}</span>
|
||||
{` – ${t('achievements.highlights.likesAmount', { count: formatNumber(topPhoto.likes) })}`}
|
||||
</p>
|
||||
{localizedTask && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('achievements.highlights.taskLabel', { task: localizedTask })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
{trendingEmotion && (
|
||||
<Card>
|
||||
const renderTrendingEmotion = () => {
|
||||
if (!trendingEmotion) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Trend-Emotion</CardTitle>
|
||||
<CardDescription>Diese Stimmung taucht gerade besonders oft auf.</CardDescription>
|
||||
<CardTitle>{t('achievements.highlights.trendingTitle')}</CardTitle>
|
||||
<CardDescription>{t('achievements.highlights.trendingDescription')}</CardDescription>
|
||||
</div>
|
||||
<Flame className="h-6 w-6 text-pink-500" />
|
||||
<Flame className="h-6 w-6 text-pink-500" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold text-foreground">{trendingEmotion.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{trendingEmotion.count} Fotos mit dieser Stimmung</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{renderTopPhoto()}
|
||||
{renderTrendingEmotion()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-[32px] border border-slate-100 bg-white/95 p-6 shadow-sm dark:border-white/10 dark:bg-slate-900/70">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-white/60">Dein Flow</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{tasksDone === 0 ? 'Starte deine erste Mission.' : 'Weiter so, dein Event lebt!'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-white/70">
|
||||
{nextBadge ? `Noch ${Math.max(0, nextBadge.target - nextBadge.progress)} Schritte bis „${nextBadge.title}“.` : 'Alle aktuellen Badges freigeschaltet.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Aufgabe ziehen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/gallery`} className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Galerie öffnen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FlowStat label="Deine Aufgaben" value={formatNumber(tasksDone)} />
|
||||
<FlowStat label="Fotos gesamt" value={formatNumber(photos)} />
|
||||
<FlowStat label="Likes gesammelt" value={formatNumber(likes)} />
|
||||
<FlowStat label="Badges" value={`${earnedBadges}/${personal?.badges.length ?? 0}`} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<FlowStat label="Aktive Gäste" value={formatNumber(guests)} />
|
||||
<FlowStat label="Letzte Mission" value={personal ? personal.guestName || 'Gast' : 'Event'} muted />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowStat({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-slate-100 bg-white/80 px-4 py-3 text-left shadow-sm dark:border-white/10 dark:bg-white/5',
|
||||
muted && 'opacity-80'
|
||||
)}
|
||||
>
|
||||
<p className="text-[0.65rem] uppercase tracking-[0.35em] text-slate-400 dark:text-white/60">{label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-slate-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalActions({ token }: { token: string }) {
|
||||
function PersonalActions({ token, t }: PersonalActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/upload`} className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Neues Foto hochladen
|
||||
<Camera className="h-4 w-4" aria-hidden />
|
||||
{t('achievements.personal.actions.upload')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Aufgabe ziehen
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
{t('achievements.personal.actions.tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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<AchievementsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||||
<Award className="h-5 w-5" />
|
||||
<Award className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1>
|
||||
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gäste im Blick.</p>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('achievements.page.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('achievements.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,9 +405,9 @@ export default function AchievementsPage() {
|
||||
{!loading && error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>{error}</span>
|
||||
<span>{error === GENERIC_ERROR ? t('achievements.page.loadError') : error}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
|
||||
Erneut versuchen
|
||||
{t('achievements.page.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -426,8 +415,6 @@ export default function AchievementsPage() {
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<FlowSummary data={data} token={token} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'personal' ? 'default' : 'outline'}
|
||||
@@ -435,24 +422,24 @@ export default function AchievementsPage() {
|
||||
disabled={!hasPersonal}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Meine Erfolge
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
{t('achievements.page.buttons.personal')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'event' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('event')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Event Highlights
|
||||
<Users className="h-4 w-4" aria-hidden />
|
||||
{t('achievements.page.buttons.event')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'feed' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('feed')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<BarChart2 className="h-4 w-4" />
|
||||
Live Feed
|
||||
<BarChart2 className="h-4 w-4" aria-hidden />
|
||||
{t('achievements.page.buttons.feed')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -463,41 +450,68 @@ export default function AchievementsPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">Hi {data.personal.guestName || identity.name || 'Gast'}!</CardTitle>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{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),
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<PersonalActions token={token} />
|
||||
<PersonalActions token={token} t={t} />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<BadgesGrid badges={data.personal.badges} />
|
||||
<BadgesGrid badges={data.personal.badges} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="space-y-5">
|
||||
<Highlights topPhoto={data.highlights.topPhoto} trendingEmotion={data.highlights.trendingEmotion} />
|
||||
<Timeline points={data.highlights.timeline} />
|
||||
<Highlights
|
||||
topPhoto={data.highlights.topPhoto}
|
||||
trendingEmotion={data.highlights.trendingEmotion}
|
||||
t={t}
|
||||
formatRelativeTime={formatRelative}
|
||||
locale={locale}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
<Timeline points={data.highlights.timeline} t={t} formatNumber={formatNumber} />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Leaderboard
|
||||
title="Top Uploads"
|
||||
title={t('achievements.leaderboard.uploadsTitle')}
|
||||
description={t('achievements.leaderboard.description')}
|
||||
icon={Users}
|
||||
entries={data.leaderboards.uploads}
|
||||
emptyCopy="Noch keine Uploads – sobald Fotos vorhanden sind, erscheinen sie hier."
|
||||
emptyCopy={t('achievements.leaderboard.uploadsEmpty')}
|
||||
t={t}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
<Leaderboard
|
||||
title="Beliebteste Gäste"
|
||||
title={t('achievements.leaderboard.likesTitle')}
|
||||
description={t('achievements.leaderboard.description')}
|
||||
icon={Trophy}
|
||||
entries={data.leaderboards.likes}
|
||||
emptyCopy="Likes fehlen noch – motiviere die Gäste, Fotos zu liken."
|
||||
emptyCopy={t('achievements.leaderboard.likesEmpty')}
|
||||
t={t}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'feed' && <Feed feed={data.feed} />}
|
||||
{activeTab === 'feed' && (
|
||||
<Feed
|
||||
feed={data.feed}
|
||||
t={t}
|
||||
formatRelativeTime={formatRelative}
|
||||
locale={locale}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<EventData | null>(null);
|
||||
const [stats, setStats] = useState<EventStats | null>(null);
|
||||
const [eventLoading, setEventLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [shareTargetId, setShareTargetId] = React.useState<number | null>(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 <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||
return (
|
||||
<Page title={t('galleryPage.title', 'Galerie')}>
|
||||
<p>{t('galleryPage.eventNotFound', 'Event nicht gefunden.')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventLoading) {
|
||||
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
|
||||
return (
|
||||
<Page title={t('galleryPage.title', 'Galerie')}>
|
||||
<p>{t('galleryPage.loadingEvent', 'Lade Event-Info...')}</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Page title="Galerie">
|
||||
<section className="space-y-4 px-4">
|
||||
<div className="rounded-[32px] border border-white/40 bg-gradient-to-br from-pink-50 via-white to-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-foreground">
|
||||
<ImageIcon className="h-6 w-6 text-pink-500" aria-hidden />
|
||||
<span>{event?.name ?? 'Event'}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{photos.length} Fotos · {totalLikes} ❤️ · {stats?.onlineGuests ?? 0} Gäste online
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
{newCount > 0 && (
|
||||
<Button size="sm" className="rounded-full" onClick={acknowledgeNew}>
|
||||
{newCount} neue Fotos ansehen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/e/${encodeURIComponent(token)}/upload`)}
|
||||
>
|
||||
Neues Foto hochladen
|
||||
</Button>
|
||||
</div>
|
||||
<Page title="">
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPage.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{newCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
|
||||
>
|
||||
{newPhotosBadgeText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`}>
|
||||
{newPhotosBadgeText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
|
||||
{loading && <p className="px-4">Lade…</p>}
|
||||
{loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>}
|
||||
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{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() {
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
||||
alt={altText}
|
||||
className="h-64 w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||
@@ -246,10 +264,10 @@ export default function GalleryPage() {
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
|
||||
{p.task_title && <p className="text-sm font-medium leading-tight line-clamp-2">{p.task_title}</p>}
|
||||
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2">{localizedTaskTitle}</p>}
|
||||
<div className="flex items-center justify-between text-xs text-white/80">
|
||||
<span>{createdLabel}</span>
|
||||
<span>{p.uploader_name || 'Gast'}</span>
|
||||
<span>{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||
@@ -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}
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden />
|
||||
@@ -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')}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
|
||||
{likeCount}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Photo | null>(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;
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
const tasksCacheRef = React.useRef<Map<string, { data: Task[]; etag?: string | null }>>(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 [];
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<Photo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const latestAt = useRef<string | null>(null);
|
||||
const etagRef = useRef<string | null>(null);
|
||||
const timer = useRef<number | null>(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 };
|
||||
|
||||
@@ -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<string, AchievementsCacheEntry>();
|
||||
|
||||
export async function fetchAchievements(
|
||||
eventToken: string,
|
||||
guestName?: string,
|
||||
signal?: AbortSignal
|
||||
options: FetchAchievementsOptions = {}
|
||||
): Promise<AchievementsPayload> {
|
||||
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;
|
||||
}
|
||||
|
||||
66
tests/Feature/Api/Event/EventAchievementsLocaleTest.php
Normal file
66
tests/Feature/Api/Event/EventAchievementsLocaleTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Task;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventAchievementsLocaleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_achievements_endpoint_returns_locale_specific_payload(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['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);
|
||||
}
|
||||
}
|
||||
74
tests/Feature/Api/Event/EventPhotosLocaleTest.php
Normal file
74
tests/Feature/Api/Event/EventPhotosLocaleTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Task;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventPhotosLocaleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_photos_endpoint_localizes_task_titles(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['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);
|
||||
}
|
||||
}
|
||||
98
tests/Feature/Api/Event/EventTasksLocaleTest.php
Normal file
98
tests/Feature/Api/Event/EventTasksLocaleTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventTasksLocaleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_requested_locale_payload(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['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',
|
||||
]]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user