Files
fotospiel-app/resources/js/guest/pages/AchievementsPage.tsx
Codex Agent 3e3a2c49d6 Implemented guest-only PWA using vite-plugin-pwa (the actual published package; @vite-pwa/plugin isn’t on npm) with
injectManifest, a new typed SW source, runtime caching, and a non‑blocking update toast with an action button. The
  guest shell now links a dedicated manifest and theme color, and background upload sync is managed in a single
  PwaManager component.

  Key changes (where/why)

  - vite.config.ts: added VitePWA injectManifest config, guest manifest, and output to /public so the SW can control /
    scope.
  - resources/js/guest/guest-sw.ts: new Workbox SW (precache + runtime caching for guest navigation, GET /api/v1/*,
    images, fonts) and preserves push/sync/notification logic.
  - resources/js/guest/components/PwaManager.tsx: registers SW, shows update/offline toasts, and processes the upload
    queue on sync/online.
  - resources/js/guest/components/ToastHost.tsx: action-capable toasts so update prompts can include a CTA.
  - resources/js/guest/i18n/messages.ts: added common.updateAvailable, common.updateAction, common.offlineReady.
  - resources/views/guest.blade.php: manifest + theme color + apple touch icon.
  - .gitignore: ignore generated public/guest-sw.js and public/guest.webmanifest; public/guest-sw.js removed since it’s
    now build output.
2025-12-27 10:59:44 +01:00

572 lines
22 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, 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 { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { AnimatePresence, motion } from 'framer-motion';
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';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
const GENERIC_ERROR = 'GENERIC_ERROR';
function formatRelativeTimestamp(input: string, formatter: Intl.RelativeTimeFormat): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) return '';
const diff = date.getTime() - Date.now();
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
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');
}
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">
<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">{description}</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 || t('achievements.leaderboard.guestFallback')}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{t('achievements.leaderboard.item.photos', { count: formatNumber(entry.photos) })}</span>
<span>{t('achievements.leaderboard.item.likes', { count: formatNumber(entry.likes) })}</span>
</div>
</li>
))}
</ol>
)}
</CardContent>
</Card>
);
}
type BadgesGridProps = {
badges: AchievementBadge[];
t: TranslateFn;
};
export function BadgesGrid({ badges, t }: BadgesGridProps) {
if (badges.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.badges.title')}</CardTitle>
<CardDescription>{t('achievements.badges.description')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('achievements.badges.empty')}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<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 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}
data-testid={`badge-card-${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 dark:border-emerald-400/30 dark:from-emerald-400/20 dark:via-emerald-400/10 dark:to-slate-950/70 dark:text-emerald-50'
: 'border-border/60 bg-white/80 dark:border-slate-800/70 dark:bg-slate-950/60',
)}
>
<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">{badge.description}</p>
</div>
<span className="text-xs font-semibold text-muted-foreground">{percentage}%</span>
</div>
<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>
);
})}
</CardContent>
</Card>
);
}
type TimelineProps = {
points: TimelinePoint[];
t: TranslateFn;
formatNumber: (value: number) => string;
};
function Timeline({ points, t, formatNumber }: TimelineProps) {
return (
<Card>
<CardHeader>
<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">
{t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })}
</span>
</div>
))}
</CardContent>
</Card>
);
}
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>{t('achievements.feed.title')}</CardTitle>
<CardDescription>{t('achievements.feed.description')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('achievements.feed.empty')}</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{t('achievements.feed.title')}</CardTitle>
<CardDescription>{t('achievements.feed.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{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>
);
})}
</CardContent>
</Card>
);
}
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;
}
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>{t('achievements.highlights.topTitle')}</CardTitle>
<CardDescription>{t('achievements.highlights.topDescription')}</CardDescription>
</div>
<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={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">
{t('achievements.highlights.noPreview')}
</div>
)}
</div>
<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>
);
};
const renderTrendingEmotion = () => {
if (!trendingEmotion) return null;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('achievements.highlights.trendingTitle')}</CardTitle>
<CardDescription>{t('achievements.highlights.trendingDescription')}</CardDescription>
</div>
<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">
{t('achievements.highlights.trendingCount', { count: formatNumber(trendingEmotion.count) })}
</p>
</CardContent>
</Card>
);
};
return (
<div className="grid gap-4 md:grid-cols-2">
{renderTopPhoto()}
{renderTrendingEmotion()}
</div>
);
}
type PersonalActionsProps = {
token: string;
t: TranslateFn;
tasksEnabled: boolean;
};
function PersonalActions({ token, t, tasksEnabled }: 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" aria-hidden />
{t('achievements.personal.actions.upload')}
</Link>
</Button>
{tasksEnabled ? (
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" aria-hidden />
{t('achievements.personal.actions.tasks')}
</Link>
</Button>
) : null}
</div>
);
}
export default function AchievementsPage() {
const { token } = useParams<{ token: string }>();
const identity = useGuestIdentity();
const { t, locale } = useTranslation();
const { event } = useEventData();
const tasksEnabled = isTaskModeEnabled(event);
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;
const loadAchievements = React.useCallback(async (signal?: AbortSignal) => {
if (!token) return;
setLoading(true);
setError(null);
try {
const payload = await fetchAchievements(token, {
guestName: personalName,
locale,
signal,
});
setData(payload);
if (!payload.personal) {
setActiveTab('event');
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('Failed to load achievements', err);
setError(err instanceof Error ? err.message : GENERIC_ERROR);
} finally {
if (!signal?.aborted) {
setLoading(false);
}
}
}, [locale, personalName, token]);
useEffect(() => {
const controller = new AbortController();
void loadAchievements(controller.signal);
return () => controller.abort();
}, [loadAchievements]);
const hasPersonal = Boolean(data?.personal);
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const tabMotion = motionEnabled
? { variants: FADE_UP, initial: 'hidden', animate: 'show', exit: 'hidden' as const }
: {};
const handleRefresh = React.useCallback(async () => {
await loadAchievements();
}, [loadAchievements]);
if (!token) {
return null;
}
const tabContent = (
<>
{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">
{t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })}
</CardTitle>
<CardDescription>
{t('achievements.personal.stats', {
photos: formatNumber(data.personal.photos),
tasks: formatNumber(data.personal.tasks),
likes: formatNumber(data.personal.likes),
})}
</CardDescription>
</div>
<PersonalActions token={token} t={t} tasksEnabled={tasksEnabled} />
</CardHeader>
</Card>
<BadgesGrid badges={data.personal.badges} t={t} />
</div>
)}
{activeTab === 'event' && data && (
<div className="space-y-5">
<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={t('achievements.leaderboard.uploadsTitle')}
description={t('achievements.leaderboard.description')}
icon={Users}
entries={data.leaderboards.uploads}
emptyCopy={t('achievements.leaderboard.uploadsEmpty')}
t={t}
formatNumber={formatNumber}
/>
<Leaderboard
title={t('achievements.leaderboard.likesTitle')}
description={t('achievements.leaderboard.description')}
icon={Trophy}
entries={data.leaderboards.likes}
emptyCopy={t('achievements.leaderboard.likesEmpty')}
t={t}
formatNumber={formatNumber}
/>
</div>
</div>
)}
{activeTab === 'feed' && data && (
<Feed
feed={data.feed}
t={t}
formatRelativeTime={formatRelative}
locale={locale}
formatNumber={formatNumber}
/>
)}
</>
);
return (
<PullToRefresh
onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<motion.div className="space-y-6 pb-24" {...containerMotion}>
<motion.div className="space-y-2" {...fadeUpMotion}>
<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" aria-hidden />
</div>
<div>
<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>
</motion.div>
{loading && (
<motion.div className="space-y-4" {...fadeUpMotion}>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</motion.div>
)}
{!loading && error && (
<motion.div {...fadeUpMotion}>
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error === GENERIC_ERROR ? t('achievements.page.loadError') : error}</span>
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
{t('achievements.page.retry')}
</Button>
</AlertDescription>
</Alert>
</motion.div>
)}
{!loading && !error && data && (
<>
<motion.div className="flex flex-wrap items-center gap-2" {...fadeUpMotion}>
<Button
variant={activeTab === 'personal' ? 'default' : 'outline'}
onClick={() => setActiveTab('personal')}
disabled={!hasPersonal}
className="flex items-center gap-2"
>
<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" 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" aria-hidden />
{t('achievements.page.buttons.feed')}
</Button>
</motion.div>
<motion.div {...fadeUpMotion}>
<Separator />
</motion.div>
{motionEnabled ? (
<AnimatePresence mode="wait" initial={false}>
<motion.div key={activeTab} {...tabMotion}>
{tabContent}
</motion.div>
</AnimatePresence>
) : (
<motion.div {...fadeScaleMotion}>{tabContent}</motion.div>
)}
</>
)}
</motion.div>
</PullToRefresh>
);
}