feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -1,11 +1,444 @@
import React from 'react';
import { Page } from './_util';
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';
export default function AchievementsPage() {
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 (
<Page title="Erfolge">
<p>Badges and progress placeholder.</p>
</Page>
<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 { slug } = useParams<{ slug: 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>
);
}

View File

@@ -106,17 +106,7 @@ export default function GalleryPage() {
imageUrl = imageUrl.replace(/\/+/g, '/');
}
// Extended debug logging
console.log(`Photo ${p.id} URL processing:`, {
id: p.id,
original: imgSrc,
thumbnail_path: p.thumbnail_path,
file_path: p.file_path,
cleanPath,
finalUrl: imageUrl,
isHttp: imageUrl?.startsWith('http'),
startsWithStorage: imageUrl?.startsWith('/storage/')
});
// Production: avoid heavy console logging for each image
return (
<Card key={p.id} className="relative overflow-hidden">
@@ -133,11 +123,8 @@ export default function GalleryPage() {
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="aspect-square w-full object-cover bg-gray-200"
onError={(e) => {
console.error(`❌ Failed to load image ${p.id}:`, imageUrl);
console.error('Error details:', e);
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
loading="lazy"
/>
</div>

View File

@@ -1,37 +1,186 @@
import React from 'react';
import { Page } from './_util';
import { useParams, Link } from 'react-router-dom';
import { usePollStats } from '../polling/usePollStats';
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import Header from '../components/Header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview';
import BottomNav from '../components/BottomNav';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
export default function HomePage() {
const { slug } = useParams();
const stats = usePollStats(slug!);
const { slug } = useParams<{ slug: string }>();
const { name, hydrated } = useGuestIdentity();
const stats = useEventStats();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(slug);
if (!slug) return null;
const displayName = hydrated && name ? name : 'Gast';
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
{
to: 'tasks',
label: 'Aufgabe ziehen',
description: 'Hol dir deine naechste Challenge',
icon: <Sparkles className="h-5 w-5" />,
},
{
to: 'upload',
label: 'Direkt hochladen',
description: 'Teile deine neuesten Fotos',
icon: <UploadCloud className="h-5 w-5" />,
},
{
to: 'gallery',
label: 'Galerie ansehen',
description: 'Lass dich von anderen inspirieren',
icon: <Images className="h-5 w-5" />,
},
];
const checklistItems = [
'Aufgabe auswaehlen oder starten',
'Emotion festhalten und Foto schiessen',
'Bild hochladen und Credits sammeln',
];
return (
<Page title={`Event: ${slug}`}>
<Header slug={slug!} title={`Event: ${slug}`} />
<div className="px-4 py-6 pb-20 space-y-6"> {/* Consistent spacing */}
{/* Prominent Draw Task Button */}
<Link to="tasks">
<Button className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-4 rounded-xl text-base font-semibold mb-6 shadow-lg hover:shadow-xl transition-all duration-200">
<span className="flex items-center gap-2">
🎲 Aufgabe ziehen
</span>
</Button>
</Link>
<div className="space-y-6 pb-24">
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
{/* How do you feel? Section */}
<EmotionPicker />
<Card>
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile
icon={<Users className="h-4 w-4" />}
label="Gleichzeitig online"
value={`${stats.onlineGuests}`}
/>
<StatTile
icon={<Sparkles className="h-4 w-4" />}
label="Aufgaben gelöst"
value={`${stats.tasksSolved}`}
/>
<StatTile
icon={<TimerReset className="h-4 w-4" />}
label="Letzter Upload"
value={latestUploadText}
/>
<StatTile
icon={<CheckCircle2 className="h-4 w-4" />}
label="Deine erledigten Aufgaben"
value={`${completedCount}`}
/>
</CardContent>
</Card>
<GalleryPreview slug={slug!} />
</div>
{/* Bottom Navigation */}
<BottomNav />
</Page>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => (
<Link to={action.to} key={action.to} className="block">
<Card className="transition-all hover:shadow-lg">
<CardContent className="flex items-center gap-3 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-pink-100 text-pink-600">
{action.icon}
</div>
<div className="flex flex-col">
<span className="text-base font-semibold">{action.label}</span>
<span className="text-sm text-muted-foreground">{action.description}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
<Button variant="outline" asChild className="w-full">
<Link to="queue">Uploads in Warteschlange ansehen</Link>
</Button>
</section>
<Card>
<CardHeader>
<CardTitle>Dein Fortschritt</CardTitle>
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{checklistItems.map((item) => (
<div key={item} className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
</div>
))}
</CardContent>
</Card>
<Separator />
<EmotionPicker />
<GalleryPreview slug={slug} />
</div>
);
}
function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
const progressMessage = tasksCompleted > 0
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
return (
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
<CardHeader className="space-y-1">
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
</CardHeader>
</Card>
);
}
function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-pink-600 shadow-sm">
{icon}
</div>
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-lg font-semibold text-foreground">{value}</span>
</div>
</div>
);
}
function formatLatestUpload(isoDate: string | null) {
if (!isoDate) {
return 'Noch kein Upload';
}
const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) {
return 'Noch kein Upload';
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return 'Gerade eben';
}
if (diffMinutes < 60) {
return `vor ${diffMinutes} Min`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `vor ${diffHours} Std`;
}
const diffDays = Math.round(diffHours / 24);
return `vor ${diffDays} Tagen`;
}

View File

@@ -1,18 +1,23 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Html5Qrcode } from 'html5-qrcode';
import { readGuestName } from '../context/GuestIdentityContext';
export default function LandingPage() {
const nav = useNavigate();
const [slug, setSlug] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
async function join() {
const s = slug.trim();
async function join(eventSlug?: string) {
const s = (eventSlug ?? slug).trim();
if (!s) return;
setLoading(true);
setError(null);
@@ -22,30 +27,131 @@ export default function LandingPage() {
setError('Event nicht gefunden oder geschlossen.');
return;
}
nav(`/e/${encodeURIComponent(s)}`);
const storedName = readGuestName(s);
if (!storedName) {
nav(`/setup/${encodeURIComponent(s)}`);
} else {
nav(`/e/${encodeURIComponent(s)}`);
}
} catch (e) {
setError('Netzwerkfehler. Bitte später erneut versuchen.');
console.error('Join request failed', e);
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
} finally {
setLoading(false);
}
}
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
async function startScanner() {
if (scanner) {
try {
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner start failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
}
return;
}
try {
const newScanner = new Html5Qrcode('qr-reader');
setScanner(newScanner);
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
setIsScanning(true);
} catch (err) {
console.error('Scanner initialisation failed', err);
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
}
}
function stopScanner() {
if (!scanner) {
setIsScanning(false);
return;
}
scanner
.stop()
.then(() => {
setIsScanning(false);
})
.catch((err) => console.error('Scanner stop failed', err));
}
async function onScanSuccess(decodedText: string) {
const value = decodedText.trim();
if (!value) return;
await join(value);
stopScanner();
}
useEffect(() => () => {
if (scanner) {
scanner.stop().catch(() => undefined);
}
}, [scanner]);
return (
<Page title="Willkommen bei Fotochallenge 🎉">
<Page title="Willkommen bei der Fotobox!">
{error && (
<Alert className="mb-3" variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="QR/PIN oder Event-Slug eingeben"
/>
<div className="h-3" />
<Button disabled={loading || !slug.trim()} onClick={join}>
{loading ? 'Prüfe…' : 'Event beitreten'}
</Button>
<div className="space-y-6 pb-20">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
</div>
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="flex flex-col items-center space-y-3">
<div className="flex h-24 w-24 items-center justify-center rounded-lg bg-gray-200">
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
<div id="qr-reader" className="w-full" hidden={!isScanning} />
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Button
variant="outline"
className="flex-1"
onClick={isScanning ? stopScanner : startScanner}
disabled={loading}
>
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
</Button>
</div>
</div>
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
Oder manuell eingeben
</div>
<div className="space-y-2">
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="Event-Code eingeben"
disabled={loading}
/>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
disabled={loading || !slug.trim()}
onClick={() => join()}
>
{loading ? 'Pruefe...' : 'Event beitreten'}
</Button>
</div>
</CardContent>
</Card>
</div>
</Page>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import React from "react";
import { Page } from './_util';
import { useParams } from 'react-router-dom';
import { LegalMarkdown } from '../components/legal-markdown';
export default function LegalPage() {
const { page } = useParams();
@@ -9,42 +10,44 @@ export default function LegalPage() {
const [body, setBody] = React.useState('');
React.useEffect(() => {
async function load() {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }});
if (res.ok) {
const j = await res.json();
setTitle(j.title || '');
setBody(j.body_markdown || '');
}
setLoading(false);
if (!page) {
return;
}
if (page) load();
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
if (!res.ok) {
throw new Error('failed');
}
const data = await res.json();
setTitle(data.title || '');
setBody(data.body_markdown || '');
} catch (error) {
if (!controller.signal.aborted) {
console.error('Failed to load legal page', error);
setTitle('');
setBody('');
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadLegal();
return () => controller.abort();
}, [page]);
return (
<Page title={title || `Rechtliches: ${page}` }>
{loading ? <p>Lädt</p> : <Markdown md={body} />}
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
</Page>
);
}
function Markdown({ md }: { md: string }) {
// Tiny, safe Markdown: paragraphs + basic bold/italic + links; no external dependency
const html = React.useMemo(() => {
let s = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// bold **text**
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// italic *text*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// links [text](url)
s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1<\/a>');
// paragraphs
s = s.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}<\/p>`).join('\n');
return s;
}, [md]);
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -1,13 +1,104 @@
import React from 'react';
import { Page } from './_util';
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useEventData } from '../hooks/useEventData';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import Header from '../components/Header';
export default function ProfileSetupPage() {
const { slug } = useParams<{ slug: string }>();
const nav = useNavigate();
const { event, loading, error } = useEventData();
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
const [name, setName] = useState(storedName);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!slug) {
nav('/');
return;
}
}, [slug, nav]);
useEffect(() => {
if (hydrated) {
setName(storedName);
}
}, [hydrated, storedName]);
function handleChange(value: string) {
setName(value);
}
function submitName() {
if (!slug) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSubmitting(true);
try {
persistName(trimmedName);
nav(`/e/${slug}`);
} catch (e) {
console.error('Fehler beim Speichern des Namens:', e);
setSubmitting(false);
}
}
if (loading) {
return (
<div className="flex justify-center items-center h-32">
<div className="text-lg">Lade Event...</div>
</div>
);
}
if (error || !event) {
return (
<div className="text-center p-4">
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
</div>
);
}
return (
<Page title="Profil erstellen">
<input placeholder="Dein Name" style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 8 }} />
<div style={{ height: 12 }} />
<button style={{ padding: '10px 16px', borderRadius: 8, background: '#111827', color: 'white' }}>Starten</button>
</Page>
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<Header slug={slug!} />
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
<CardDescription className="text-lg text-gray-600">
Fange den schoensten Moment ein!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
<Input
id="name"
value={name}
onChange={(e) => handleChange(e.target.value)}
placeholder="Dein Name"
className="text-lg"
disabled={submitting || !hydrated}
autoComplete="name"
/>
</div>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-3 text-base font-semibold rounded-xl"
onClick={submitName}
disabled={submitting || !name.trim() || !hydrated}
>
{submitting ? 'Speichere...' : "Let's go!"}
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,19 +1,17 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Page } from './_util';
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useAppearance } from '../../hooks/use-appearance';
import { Clock, RefreshCw, Smile } from 'lucide-react';
import BottomNav from '../components/BottomNav';
import { useEventData } from '../hooks/useEventData';
import { EventData } from '../services/eventApi';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
interface Task {
id: number;
title: string;
description: string;
instructions: string;
duration: number; // in minutes
duration: number; // minutes
emotion?: {
slug: string;
name: string;
@@ -21,244 +19,508 @@ interface Task {
is_completed: boolean;
}
type EmotionOption = {
slug: string;
name: string;
};
const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>();
const [searchParams] = useSearchParams();
// emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
const navigate = useNavigate();
const { appearance } = useAppearance();
const isDark = appearance === 'dark';
const [tasks, setTasks] = useState<Task[]>([]);
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [timeLeft, setTimeLeft] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Timer state
useEffect(() => {
if (!currentTask) return;
const durationMs = currentTask.duration * 60 * 1000;
setTimeLeft(durationMs / 1000);
const interval = setInterval(() => {
setTimeLeft(prev => {
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
const [timeLeft, setTimeLeft] = React.useState<number>(0);
const [timerRunning, setTimerRunning] = React.useState(false);
const [timeUp, setTimeUp] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const recentTaskIdsRef = React.useRef<number[]>([]);
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!slug) return;
setIsFetching(true);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
setTasks(payload);
} else {
setTasks([]);
}
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
setTasks([]);
} finally {
setIsFetching(false);
setLoading(false);
}
}, [slug]);
React.useEffect(() => {
fetchTasks();
}, [fetchTasks]);
React.useEffect(() => {
if (initialEmotionRef.current) return;
const queryEmotion = searchParams.get('emotion');
if (queryEmotion) {
setSelectedEmotion(queryEmotion);
}
initialEmotionRef.current = true;
}, [searchParams]);
const emotionOptions = React.useMemo<EmotionOption[]>(() => {
const map = new Map<string, string>();
tasks.forEach((task) => {
if (task.emotion?.slug) {
map.set(task.emotion.slug, task.emotion.name);
}
});
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
}, [tasks]);
const filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
}, [tasks, selectedEmotion]);
const selectRandomTask = React.useCallback(
(list: Task[]) => {
if (!list.length) {
setCurrentTask(null);
return;
}
const avoidIds = recentTaskIdsRef.current;
const available = list.filter((task) => !isCompleted(task.id));
const base = available.length ? available : list;
let candidates = base.filter((task) => !avoidIds.includes(task.id));
if (!candidates.length) {
candidates = base;
}
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
setCurrentTask(chosen);
recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
},
[isCompleted]
);
React.useEffect(() => {
if (!filteredTasks.length) {
setCurrentTask(null);
return;
}
if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
selectRandomTask(filteredTasks);
return;
}
const matchingTask = filteredTasks.find((task) => task.id === currentTask.id);
const durationMinutes = matchingTask?.duration ?? currentTask.duration;
setTimeLeft(durationMinutes * 60);
setTimerRunning(false);
setTimeUp(false);
}, [filteredTasks, currentTask, selectRandomTask]);
React.useEffect(() => {
if (!currentTask) {
setTimeLeft(0);
setTimerRunning(false);
setTimeUp(false);
return;
}
setTimeLeft(currentTask.duration * 60);
setTimerRunning(false);
setTimeUp(false);
}, [currentTask]);
React.useEffect(() => {
if (!timerRunning) return;
if (timeLeft <= 0) {
setTimerRunning(false);
triggerTimeUp();
return;
}
const tick = window.setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval);
window.clearInterval(tick);
triggerTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
return () => window.clearInterval(tick);
}, [timerRunning, timeLeft]);
return () => clearInterval(interval);
}, [currentTask]);
// Load tasks
useEffect(() => {
if (!slug) return;
async function fetchTasks() {
try {
setLoading(true);
setError(null);
const url = `/api/v1/events/${slug}/tasks`;
console.log('Fetching tasks from:', url); // Debug
const response = await fetch(url);
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
const data = await response.json();
setTasks(Array.isArray(data) ? data : []);
console.log('Loaded tasks:', data); // Debug
// Select random task
if (data.length > 0) {
const randomIndex = Math.floor(Math.random() * data.length);
setCurrentTask(data[randomIndex]);
console.log('Selected random task:', data[randomIndex]); // Debug
}
} catch (err) {
console.error('Fetch tasks error:', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
} finally {
setLoading(false);
}
function triggerTimeUp() {
const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
setTimerRunning(false);
setTimeUp(true);
if (supportsVibration) {
try {
navigator.vibrate(TIMER_VIBRATION);
} catch (error) {
console.warn('Vibration not permitted', error);
}
}
window.setTimeout(() => setTimeUp(false), 4000);
return;
}
fetchTasks();
}, [slug]);
const formatTime = (seconds: number) => {
const formatTime = React.useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const secs = Math.max(0, seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0;
const handleSelectEmotion = (slugValue: string) => {
setSelectedEmotion(slugValue);
const next = new URLSearchParams(searchParams.toString());
if (slugValue === 'all') {
next.delete('emotion');
} else {
next.set('emotion', slugValue);
}
setSearchParams(next, { replace: true });
};
const handleNewTask = () => {
if (tasks.length === 0) return;
const randomIndex = Math.floor(Math.random() * tasks.length);
setCurrentTask(tasks[randomIndex]);
setTimeLeft(tasks[randomIndex].duration * 60);
selectRandomTask(filteredTasks);
};
const handleStartTask = () => {
const handleStartUpload = () => {
if (!currentTask || !slug) return;
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleMarkCompleted = () => {
if (!currentTask) return;
// Navigate to upload with task context
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
markCompleted(currentTask.id);
selectRandomTask(filteredTasks);
};
const handleChangeMood = () => {
navigate(`/e/${slug}`);
const handleRetryFetch = () => {
fetchTasks();
};
if (loading) {
return (
<Page title="Aufgabe laden...">
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
isDark ? 'text-white' : 'text-gray-900'
}`}>
<RefreshCw className="h-8 w-8 animate-spin mb-4" />
<p className="text-sm">Lade Aufgabe...</p>
</div>
<BottomNav />
</Page>
);
}
const handleTimerToggle = () => {
if (!currentTask) return;
if (timerRunning) {
setTimerRunning(false);
setTimeLeft(currentTask.duration * 60);
setTimeUp(false);
} else {
if (timeLeft <= 0) {
setTimeLeft(currentTask.duration * 60);
}
setTimerRunning(true);
setTimeUp(false);
}
};
if (error || !currentTask) {
return (
<Page title="Keine Aufgaben verfügbar">
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 space-y-4 ${
isDark ? 'text-white' : 'text-gray-900'
}`}>
<Smile className="h-12 w-12 text-pink-500" />
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground max-w-md">
{error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
</p>
</div>
<Button
variant="outline"
onClick={handleChangeMood}
className="w-full max-w-sm"
>
Andere Stimmung wählen
</Button>
</div>
<BottomNav />
</Page>
);
}
const emptyState = !loading && (!filteredTasks.length || !currentTask);
return (
<Page title={currentTask.title}>
<div className={`min-h-screen flex flex-col ${
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'
}`}>
{/* Task Header with Selfie Overlay */}
<div className="p-4 space-y-4">
<div className="relative">
{/* Selfie Placeholder */}
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${
isDark
? 'from-gray-800 to-gray-700 shadow-2xl'
: 'from-pink-50 to-pink-100 shadow-lg'
} flex items-center justify-center`}>
<div className="text-center space-y-2">
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${
isDark ? 'text-white' : 'text-gray-600'
}`}>
📸
</div>
<p className={`text-sm font-medium ${
isDark ? 'text-gray-300' : 'text-gray-600'
}`}>
Selfie-Vorschau
</p>
</div>
</div>
{/* Timer */}
<div className="absolute top-2 right-2">
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
timeLeft > 60
? 'bg-green-500/20 text-green-400 border-green-500/30'
: timeLeft > 30
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
: 'bg-red-500/20 text-red-400 border-red-500/30'
} border`}>
<Clock className="h-3 w-3" />
<span>{formatTime(timeLeft)}</span>
</div>
</div>
<div className="space-y-6">
<header className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
<Badge variant="secondary" className="whitespace-nowrap">
Schon {completedCount} Aufgaben erledigt
</Badge>
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
<span>Auf dem Weg zum naechsten Erfolg</span>
<span>
{completedCount >= TASK_PROGRESS_TARGET
? 'Stark!'
: `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all"
style={{ width: `${progressRatio * 100}%` }}
/>
</div>
</div>
{emotionOptions.length > 0 && (
<div className="flex flex-wrap gap-2">
<EmotionChip
active={selectedEmotion === 'all'}
label="Alle Stimmungen"
onClick={() => handleSelectEmotion('all')}
/>
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
active={selectedEmotion === emotion.slug}
label={emotion.name}
onClick={() => handleSelectEmotion(emotion.slug)}
/>
))}
</div>
)}
</header>
{/* Task Description Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
<div className="space-y-2">
<h1 className={`text-xl font-bold ${
isDark ? 'text-gray-100' : 'text-white'
}`}>
{currentTask.title}
</h1>
<p className={`text-sm leading-relaxed ${
isDark ? 'text-gray-200' : 'text-gray-100'
}`}>
{currentTask.description}
</p>
{currentTask.instructions && (
<div className={`p-2 rounded-lg ${
isDark
? 'bg-gray-700/80 text-gray-100'
: 'bg-gray-800/80 text-white border border-gray-600/50'
}`}>
<p className="text-xs italic">💡 {currentTask.instructions}</p>
</div>
{loading && (
<div className="space-y-4">
<SkeletonBlock />
<SkeletonBlock />
</div>
)}
{error && !loading && (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
{emptyState && (
<EmptyState
hasTasks={Boolean(tasks.length)}
onRetry={handleRetryFetch}
emotionOptions={emotionOptions}
onEmotionSelect={handleSelectEmotion}
/>
)}
{!emptyState && currentTask && (
<div className="space-y-6">
<article className="overflow-hidden rounded-2xl border bg-card">
<div className="relative">
<div className="flex aspect-video items-center justify-center bg-gradient-to-br from-pink-500/80 via-purple-500/60 to-indigo-500/60 text-white">
<div className="text-center">
<Sparkles className="mx-auto mb-3 h-10 w-10" />
<p className="text-sm uppercase tracking-[.2em]">Deine Mission</p>
<h2 className="mt-2 text-2xl font-semibold">{currentTask.title}</h2>
</div>
</div>
<div className="absolute right-3 top-3 flex flex-col items-end gap-2">
<BadgeTimer
label="Countdown"
value={formatTime(timeLeft)}
tone={timerTone(timeLeft, currentTask.duration)}
/>
{timeUp && (
<Badge variant="destructive" className="flex items-center gap-1">
<AlertTriangle className="h-3.5 w-3.5" />
Zeit abgelaufen!
</Badge>
)}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="px-4 pb-4 space-y-3">
<Button
onClick={handleStartTask}
className="w-full h-14 bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white rounded-xl text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
>
<span className="flex items-center justify-center gap-2">
📸 Los geht's
</span>
</Button>
<div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="flex items-center gap-2">
<TimerIcon className="h-4 w-4" />
{currentTask.duration} Min
</Badge>
{currentTask.emotion?.name && (
<Badge variant="outline" className="flex items-center gap-2">
<Smile className="h-4 w-4" />
{currentTask.emotion.name}
</Badge>
)}
{isCompleted(currentTask.id) && (
<Badge variant="secondary" className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Bereits erledigt
</Badge>
)}
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleNewTask}
className="flex-1 h-12 border-gray-300 dark:border-gray-600 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<RefreshCw className="h-4 w-4 mr-2" />
Neue Aufgabe
<p className="text-sm leading-relaxed text-muted-foreground">{currentTask.description}</p>
{currentTask.instructions && (
<div className="rounded-xl border border-dashed border-pink-200 bg-pink-50/60 p-4 text-sm font-medium text-pink-800 dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-100">
{currentTask.instructions}
</div>
)}
<ul className="space-y-2 text-sm">
<ChecklistItem text="Stimme dich auf die Aufgabe ein." />
<ChecklistItem text="Hol dir dein Team oder Motiv ins Bild." />
<ChecklistItem text="Halte Emotion und Aufgabe im Foto fest." />
</ul>
{timerRunning && currentTask.duration > 0 && (
<div className="mt-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>Countdown</span>
<span>Restzeit: {formatTime(timeLeft)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-gradient-to-r from-amber-400 to-rose-500 transition-all"
style={{
width: `${Math.max(0, Math.min(100, (timeLeft / (currentTask.duration * 60)) * 100))}%`,
}}
/>
</div>
</div>
)}
</div>
</article>
<div className="grid gap-3 sm:grid-cols-2">
<Button onClick={handleStartUpload} className="h-14 text-base font-semibold">
<span className="flex items-center justify-center gap-2">
<Sparkles className="h-5 w-5" />
Los geht's
</span>
</Button>
<Button
variant="ghost"
onClick={handleChangeMood}
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
<Button
variant={timerRunning ? 'destructive' : 'outline'}
onClick={handleTimerToggle}
className="h-14 text-base"
>
<Smile className="h-4 w-4 mr-2" />
Andere Stimmung
<span className="flex items-center justify-center gap-2">
<TimerIcon className="h-5 w-5" />
{timerRunning ? 'Timer stoppen' : 'Timer starten'}
</span>
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Button variant="secondary" onClick={handleMarkCompleted} className="h-12">
<span className="flex items-center justify-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Aufgabe erledigt
</span>
</Button>
<Button variant="ghost" onClick={handleNewTask} className="h-12">
<span className="flex items-center justify-center gap-2">
<RefreshCw className="h-5 w-5" />
Neue Aufgabe anzeigen
</span>
</Button>
</div>
</div>
)}
{/* Bottom Navigation */}
<BottomNav />
</div>
</Page>
{!loading && !tasks.length && !error && (
<Alert>
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
</Alert>
)}
</div>
);
}
function timerTone(timeLeft: number, durationMinutes: number) {
const totalSeconds = Math.max(1, durationMinutes * 60);
const ratio = timeLeft / totalSeconds;
if (ratio > 0.5) return 'okay';
if (ratio > 0.25) return 'warm';
return 'hot';
}
function EmotionChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-4 py-1 text-sm transition ${
active
? 'border-pink-500 bg-pink-500 text-white shadow-sm'
: 'border-border bg-background text-muted-foreground hover:border-pink-400 hover:text-foreground'
}`}
>
{label}
</button>
);
}
function ChecklistItem({ text }: { text: string }) {
return (
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">{text}</span>
</li>
);
}
function BadgeTimer({ label, value, tone }: { label: string; value: string; tone: 'okay' | 'warm' | 'hot' }) {
const toneClasses = {
okay: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30',
warm: 'bg-amber-500/15 text-amber-500 border-amber-500/30',
hot: 'bg-rose-500/15 text-rose-500 border-rose-500/30',
}[tone];
return (
<div className={`flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium ${toneClasses}`}>
<TimerIcon className="h-3.5 w-3.5" />
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function SkeletonBlock() {
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
}
function EmptyState({
hasTasks,
onRetry,
emotionOptions,
onEmotionSelect,
}: {
hasTasks: boolean;
onRetry: () => void;
emotionOptions: EmotionOption[];
onEmotionSelect: (slug: string) => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
<Smile className="h-12 w-12 text-pink-500" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground">
{hasTasks
? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{emotionOptions.map((emotion) => (
<EmotionChip
key={emotion.slug}
label={emotion.name}
active={false}
onClick={() => onEmotionSelect(emotion.slug)}
/>
))}
</div>
)}
<Button onClick={onRetry} variant="outline" className="mt-2">
Aufgaben neu laden
</Button>
</div>
);
}

File diff suppressed because it is too large Load Diff