feat: localize guest endpoints and caching

This commit is contained in:
Codex Agent
2025-11-12 15:48:06 +01:00
parent d91108c883
commit 062932ce38
19 changed files with 1538 additions and 595 deletions

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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