Files
fotospiel-app/resources/js/guest/pages/AchievementsPage.tsx

506 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, 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 { 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 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 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,
};
}
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
if (badges.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Erfülle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Noch keine Badges verfügbar.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</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);
return (
<div
key={badge.id}
className={cn(
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
badge.earned
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900'
: 'border-border/60 bg-white/80',
)}
>
<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>
</div>
<span className="rounded-full bg-white/30 p-2 text-pink-500">
<Award className="h-4 w-4" />
</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>
</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} Gäste</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> {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 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);
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 }) {
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
</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
</Link>
</Button>
</div>
);
}
export default function AchievementsPage() {
const { token } = 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 (!token) return;
const controller = new AbortController();
setLoading(true);
setError(null);
fetchAchievements(token, 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();
}, [token, personalName]);
const hasPersonal = Boolean(data?.personal);
if (!token) {
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 Gäste 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 && (
<>
<FlowSummary data={data} token={token} />
<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 token={token} />
</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 Gäste"
icon={Trophy}
entries={data.leaderboards.likes}
emptyCopy="Likes fehlen noch motiviere die Gäste, Fotos zu liken."
/>
</div>
</div>
)}
{activeTab === 'feed' && <Feed feed={data.feed} />}
</>
)}
</div>
);
}