feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user