feat: localize guest endpoints and caching
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user