445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Link, useParams } from 'react-router-dom';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { cn } from '@/lib/utils';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { Separator } from '@/components/ui/separator';
|
||
import {
|
||
AchievementBadge,
|
||
AchievementsPayload,
|
||
FeedEntry,
|
||
LeaderboardEntry,
|
||
TimelinePoint,
|
||
TopPhotoHighlight,
|
||
TrendingEmotionHighlight,
|
||
fetchAchievements,
|
||
} from '../services/achievementApi';
|
||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||
import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
|
||
|
||
function formatNumber(value: number): string {
|
||
return new Intl.NumberFormat('de-DE').format(value);
|
||
}
|
||
|
||
function formatRelativeTime(input: string): string {
|
||
const date = new Date(input);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
const diff = Date.now() - date.getTime();
|
||
const minute = 60_000;
|
||
const hour = 60 * minute;
|
||
const day = 24 * hour;
|
||
if (diff < minute) return 'gerade eben';
|
||
if (diff < hour) {
|
||
const minutes = Math.round(diff / minute);
|
||
return `vor ${minutes} Min`;
|
||
}
|
||
if (diff < day) {
|
||
const hours = Math.round(diff / hour);
|
||
return `vor ${hours} Std`;
|
||
}
|
||
const days = Math.round(diff / day);
|
||
return `vor ${days} Tagen`;
|
||
}
|
||
|
||
function badgeVariant(earned: boolean): string {
|
||
return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
|
||
}
|
||
|
||
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center gap-2 pb-4">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||
<CardDescription className="text-xs">Top 5 Teilnehmer dieses Events</CardDescription>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{entries.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{emptyCopy}</p>
|
||
) : (
|
||
<ol className="space-y-2 text-sm">
|
||
{entries.map((entry, index) => (
|
||
<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>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||
<span>{entry.photos} Fotos</span>
|
||
<span>{entry.likes} Likes</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||
if (badges.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Badges</CardTitle>
|
||
<CardDescription>Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">Noch keine Badges verfuegbar.</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Badges</CardTitle>
|
||
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{badges.map((badge) => (
|
||
<div key={badge.id} className={cn('rounded-xl border px-4 py-3', badgeVariant(badge.earned))}>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<p className="text-sm font-semibold">{badge.title}</p>
|
||
<p className="text-xs text-muted-foreground">{badge.description}</p>
|
||
</div>
|
||
<Award className="h-5 w-5" />
|
||
</div>
|
||
<div className="mt-2 text-xs text-muted-foreground">
|
||
{badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Timeline({ points }: { points: TimelinePoint[] }) {
|
||
if (points.length === 0) {
|
||
return null;
|
||
}
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Timeline</CardTitle>
|
||
<CardDescription>Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.</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} Gaeste</span>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Feed({ feed }: { feed: FeedEntry[] }) {
|
||
if (feed.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Live Feed</CardTitle>
|
||
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">Noch keine Uploads - starte die Kamera und lege los!</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Live Feed</CardTitle>
|
||
<CardDescription>Die neuesten Momente aus deinem Event.</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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) {
|
||
if (!topPhoto && !trendingEmotion) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{topPhoto && (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>Publikumsliebling</CardTitle>
|
||
<CardDescription>Das Foto mit den meisten Likes.</CardDescription>
|
||
</div>
|
||
<Trophy className="h-6 w-6 text-amber-400" />
|
||
</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" />
|
||
) : (
|
||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
|
||
)}
|
||
</div>
|
||
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> <EFBFBD> {topPhoto.likes} Likes</p>
|
||
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
|
||
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{trendingEmotion && (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<div>
|
||
<CardTitle>Trend-Emotion</CardTitle>
|
||
<CardDescription>Diese Stimmung taucht gerade besonders oft auf.</CardDescription>
|
||
</div>
|
||
<Flame className="h-6 w-6 text-pink-500" />
|
||
</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>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SummaryCards({ data }: { data: AchievementsPayload }) {
|
||
return (
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-1 py-4">
|
||
<span className="text-xs uppercase text-muted-foreground">Fotos gesamt</span>
|
||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.totalPhotos)}</span>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-1 py-4">
|
||
<span className="text-xs uppercase text-muted-foreground">Aktive Gaeste</span>
|
||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-1 py-4">
|
||
<span className="text-xs uppercase text-muted-foreground">Erfuellte Aufgaben</span>
|
||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex flex-col gap-1 py-4">
|
||
<span className="text-xs uppercase text-muted-foreground">Likes insgesamt</span>
|
||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.likesTotal)}</span>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PersonalActions({ slug }: { slug: string }) {
|
||
return (
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button asChild>
|
||
<Link to={`/e/${encodeURIComponent(slug)}/upload`} className="flex items-center gap-2">
|
||
<Camera className="h-4 w-4" />
|
||
Neues Foto hochladen
|
||
</Link>
|
||
</Button>
|
||
<Button variant="outline" asChild>
|
||
<Link to={`/e/${encodeURIComponent(slug)}/tasks`} className="flex items-center gap-2">
|
||
<Sparkles className="h-4 w-4" />
|
||
Aufgabe ziehen
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function AchievementsPage() {
|
||
const { token: slug } = useParams<{ token: string }>();
|
||
const identity = useGuestIdentity();
|
||
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 personalName = identity.hydrated && identity.name ? identity.name : undefined;
|
||
|
||
useEffect(() => {
|
||
if (!slug) return;
|
||
const controller = new AbortController();
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
fetchAchievements(slug, personalName, controller.signal)
|
||
.then((payload) => {
|
||
setData(payload);
|
||
if (!payload.personal) {
|
||
setActiveTab('event');
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (err.name === 'AbortError') return;
|
||
console.error('Failed to load achievements', err);
|
||
setError(err.message || 'Erfolge konnten nicht geladen werden.');
|
||
})
|
||
.finally(() => setLoading(false));
|
||
|
||
return () => controller.abort();
|
||
}, [slug, personalName]);
|
||
|
||
const hasPersonal = Boolean(data?.personal);
|
||
|
||
if (!slug) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 pb-24">
|
||
<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" />
|
||
</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 Gaeste im Blick.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{loading && (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-32 w-full" />
|
||
<Skeleton className="h-48 w-full" />
|
||
</div>
|
||
)}
|
||
|
||
{!loading && error && (
|
||
<Alert variant="destructive">
|
||
<AlertDescription className="flex items-center justify-between gap-3">
|
||
<span>{error}</span>
|
||
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
|
||
Erneut versuchen
|
||
</Button>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{!loading && !error && data && (
|
||
<>
|
||
<SummaryCards data={data} />
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
variant={activeTab === 'personal' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('personal')}
|
||
disabled={!hasPersonal}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Sparkles className="h-4 w-4" />
|
||
Meine Erfolge
|
||
</Button>
|
||
<Button
|
||
variant={activeTab === 'event' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('event')}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Users className="h-4 w-4" />
|
||
Event Highlights
|
||
</Button>
|
||
<Button
|
||
variant={activeTab === 'feed' ? 'default' : 'outline'}
|
||
onClick={() => setActiveTab('feed')}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<BarChart2 className="h-4 w-4" />
|
||
Live Feed
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{activeTab === 'personal' && hasPersonal && data.personal && (
|
||
<div className="space-y-5">
|
||
<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>
|
||
<CardDescription>
|
||
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
|
||
</CardDescription>
|
||
</div>
|
||
<PersonalActions slug={slug} />
|
||
</CardHeader>
|
||
</Card>
|
||
|
||
<BadgesGrid badges={data.personal.badges} />
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'event' && (
|
||
<div className="space-y-5">
|
||
<Highlights topPhoto={data.highlights.topPhoto} trendingEmotion={data.highlights.trendingEmotion} />
|
||
<Timeline points={data.highlights.timeline} />
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<Leaderboard
|
||
title="Top Uploads"
|
||
icon={Users}
|
||
entries={data.leaderboards.uploads}
|
||
emptyCopy="Noch keine Uploads - sobald Fotos vorhanden sind, erscheinen sie hier."
|
||
/>
|
||
<Leaderboard
|
||
title="Beliebteste Gaeste"
|
||
icon={Trophy}
|
||
entries={data.leaderboards.likes}
|
||
emptyCopy="Likes fehlen noch - motiviere die Gaeste, Fotos zu liken."
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'feed' && <Feed feed={data.feed} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|