676ed9c1b3
- Dashboard "Jetzt tippen" opens TipModal directly instead of navigating to /spiele (no more dead-end spielplan) - After tipping, dashboard updates to show "Dein Tipp: X:Y ✓" - Spielplan auto-opens all sections when only 1-2 exist (no more collapsed "Demnächst" as only section) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { api, Match } from '../api/client';
|
||
import MatchCard from '../components/MatchCard';
|
||
import TipModal from '../components/TipModal';
|
||
import { useRevealQueue } from '../hooks/useRevealQueue';
|
||
import ConfettiReveal from '../components/ConfettiReveal';
|
||
import styles from './MatchesPage.module.css';
|
||
|
||
type Section = {
|
||
key: string;
|
||
label: string;
|
||
matches: Match[];
|
||
defaultOpen: boolean;
|
||
highlight: boolean;
|
||
};
|
||
|
||
function groupIntoSections(matches: Match[]): Section[] {
|
||
const now = new Date();
|
||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const tomorrowStart = new Date(todayStart);
|
||
tomorrowStart.setDate(todayStart.getDate() + 1);
|
||
const dayAfterTomorrow = new Date(todayStart);
|
||
dayAfterTomorrow.setDate(todayStart.getDate() + 2);
|
||
const weekEnd = new Date(todayStart);
|
||
weekEnd.setDate(todayStart.getDate() + 7);
|
||
|
||
const sections: Section[] = [
|
||
{ key: 'today', label: 'Heute', matches: [], defaultOpen: true, highlight: true },
|
||
{ key: 'tomorrow', label: 'Morgen', matches: [], defaultOpen: true, highlight: false },
|
||
{ key: 'week', label: 'Diese Woche', matches: [], defaultOpen: false, highlight: false },
|
||
{ key: 'later', label: 'Demnächst', matches: [], defaultOpen: false, highlight: false },
|
||
{ key: 'past', label: 'Vergangene Spiele', matches: [], defaultOpen: false, highlight: false },
|
||
];
|
||
|
||
for (const match of matches) {
|
||
const d = new Date(match.utcDate);
|
||
if (d < todayStart) {
|
||
sections[4].matches.push(match); // past
|
||
} else if (d < tomorrowStart) {
|
||
sections[0].matches.push(match); // today
|
||
} else if (d < dayAfterTomorrow) {
|
||
sections[1].matches.push(match); // tomorrow
|
||
} else if (d < weekEnd) {
|
||
sections[2].matches.push(match); // this week
|
||
} else {
|
||
sections[3].matches.push(match); // later
|
||
}
|
||
}
|
||
|
||
// Past matches: most recent first
|
||
sections[4].matches.reverse();
|
||
|
||
return sections.filter(s => s.matches.length > 0);
|
||
}
|
||
|
||
export default function MatchesPage() {
|
||
const [allMatches, setAllMatches] = useState<Match[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [stageFilter, setStageFilter] = useState('');
|
||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
|
||
|
||
const loadMatches = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api.getMatches();
|
||
setAllMatches(res.matches);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||
|
||
// Initialize open sections after initial load
|
||
useEffect(() => {
|
||
if (allMatches.length > 0) {
|
||
const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
|
||
const sections = groupIntoSections(filteredMatches);
|
||
// If only 1-2 sections exist, open all of them
|
||
if (sections.length <= 2) {
|
||
setOpenSections(new Set(sections.map(s => s.key)));
|
||
} else {
|
||
setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key)));
|
||
}
|
||
}
|
||
}, [allMatches]); // only on initial load
|
||
|
||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||
setAllMatches(prev => prev.map(m =>
|
||
m.id === matchId
|
||
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
||
: m
|
||
));
|
||
setSelectedMatch(null);
|
||
};
|
||
|
||
function toggleSection(key: string) {
|
||
setOpenSections(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches);
|
||
|
||
const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
|
||
const sections = groupIntoSections(filteredMatches);
|
||
|
||
// Stats always over all matches (unfiltered)
|
||
const tipped = allMatches.filter(m => m.userTip).length;
|
||
const tippable = allMatches.filter(m => m.tippable && !m.userTip).length;
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
{revealMatch && (
|
||
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
|
||
)}
|
||
|
||
{/* Header Stats */}
|
||
<div className={styles.statsRow}>
|
||
<div className={`card ${styles.statCard}`}>
|
||
<span className={styles.statValue}>{allMatches.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 Dropdown */}
|
||
<select
|
||
className={styles.stageFilter}
|
||
value={stageFilter}
|
||
onChange={e => setStageFilter(e.target.value)}
|
||
>
|
||
<option value="">Alle Phasen</option>
|
||
<option value="GROUP_STAGE">Gruppenphase</option>
|
||
<option value="ROUND_OF_32">Runde der 32</option>
|
||
<option value="LAST_16">Achtelfinale</option>
|
||
<option value="QUARTER_FINALS">Viertelfinale</option>
|
||
<option value="SEMI_FINALS">Halbfinale</option>
|
||
<option value="THIRD_PLACE">Platz 3</option>
|
||
<option value="FINAL">Finale</option>
|
||
</select>
|
||
|
||
{/* 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 && filteredMatches.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 && sections.map(section => (
|
||
<div key={section.key} className={`${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`}>
|
||
<button className={styles.sectionHeader} onClick={() => toggleSection(section.key)}>
|
||
<span className={styles.sectionLabel}>{section.label}</span>
|
||
<span className={styles.sectionCount}>{section.matches.length} Spiele</span>
|
||
<span className={styles.sectionChevron}>{openSections.has(section.key) ? '▾' : '▸'}</span>
|
||
</button>
|
||
{openSections.has(section.key) && (
|
||
<div className={styles.sectionContent}>
|
||
{section.matches.map(match => (
|
||
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{selectedMatch && (
|
||
<TipModal
|
||
match={selectedMatch}
|
||
onClose={() => setSelectedMatch(null)}
|
||
onSaved={handleTipSaved}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|