feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect, useCallback } from 'react';
import { api, Match } from '../api/client';
import MatchCard from '../components/MatchCard';
import TipModal from '../components/TipModal';
import styles from './MatchesPage.module.css';
const STAGES: { key: string; label: string }[] = [
{ key: '', label: 'Alle' },
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
{ key: 'ROUND_OF_32', label: 'Runde der 32' },
{ key: 'ROUND_OF_16', label: 'Achtelfinale' },
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
{ key: 'FINAL', label: 'Finale' },
];
export default function MatchesPage() {
const [matches, setMatches] = useState<Match[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stage, setStage] = useState('');
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
const loadMatches = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.getMatches(stage ? { stage } : undefined);
setMatches(res.matches);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [stage]);
useEffect(() => { loadMatches(); }, [loadMatches]);
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
setMatches(prev => prev.map(m =>
m.id === matchId
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
: m
));
setSelectedMatch(null);
};
// Spiele nach Datum gruppieren
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
const day = new Date(m.utcDate).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long'
});
if (!acc[day]) acc[day] = [];
acc[day].push(m);
return acc;
}, {});
const tipped = matches.filter(m => m.userTip).length;
const tippable = matches.filter(m => m.tippable).length;
return (
<div className={styles.page}>
{/* Header Stats */}
<div className={styles.statsRow}>
<div className={`card ${styles.statCard}`}>
<span className={styles.statValue}>{matches.length}</span>
<span className={styles.statLabel}>Spiele gesamt</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`${styles.statValue} text-primary`}>{tipped}</span>
<span className={styles.statLabel}>Tipps abgegeben</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`${styles.statValue} text-gold`}>{tippable}</span>
<span className={styles.statLabel}>Noch tippbar</span>
</div>
</div>
{/* Stage Filter */}
<div className={styles.filters}>
{STAGES.map(s => (
<button
key={s.key}
className={stage === s.key ? styles.filterActive : styles.filter}
onClick={() => setStage(s.key)}
>
{s.label}
</button>
))}
</div>
{/* Content */}
{loading && (
<div className={styles.loadingState}>
<div className={styles.spinner} />
<span>Spiele werden geladen</span>
</div>
)}
{error && (
<div className={styles.errorState}>
<span> {error}</span>
<button className="btn-ghost" onClick={loadMatches}>Erneut versuchen</button>
</div>
)}
{!loading && !error && matches.length === 0 && (
<div className={styles.emptyState}>
<span className={styles.emptyIcon}></span>
<p>Noch keine Spiele vorhanden.</p>
<p className={styles.emptyHint}>
Geh auf die Admin-Seite und klicke "Spiele synchronisieren".
</p>
</div>
)}
{!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => (
<div key={day} className={styles.dayGroup}>
<h2 className={styles.dayHeader}>{day}</h2>
<div className={styles.matchList}>
{dayMatches.map(match => (
<MatchCard
key={match.id}
match={match}
onTip={() => setSelectedMatch(match)}
/>
))}
</div>
</div>
))}
{selectedMatch && (
<TipModal
match={selectedMatch}
onClose={() => setSelectedMatch(null)}
onSaved={handleTipSaved}
/>
)}
</div>
);
}