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
+113
View File
@@ -0,0 +1,113 @@
import { Match } from '../api/client';
import styles from './MatchCard.module.css';
interface Props {
match: Match;
onTip: () => void;
}
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Geplant',
TIMED: 'Terminiert',
IN_PLAY: '🔴 Live',
PAUSED: 'Pause',
FINISHED: 'Beendet',
POSTPONED: 'Verschoben',
CANCELLED: 'Abgesagt',
};
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
}) + ' Uhr';
}
function CountdownBadge({ minutes }: { minutes: number }) {
if (minutes <= 0) return null;
if (minutes < 60) return <span className={styles.badgeUrgent}>in {minutes} Min.</span>;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 24) return <span className={styles.badge}>in {h}h {m > 0 ? `${m}m` : ''}</span>;
const d = Math.floor(h / 24);
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
}
export default function MatchCard({ match, onTip }: Props) {
const isFinished = match.status === 'FINISHED';
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
const hasTip = !!match.userTip;
return (
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
{/* Status + Kickoff */}
<div className={styles.topRow}>
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
{STATUS_LABELS[match.status] ?? match.status}
</span>
<span className={styles.kickoff}>{formatKickoff(match.utcDate)}</span>
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
{match.group && <span className={styles.group}>{match.group.replace('GROUP_', 'Gruppe ')}</span>}
</div>
{/* Teams + Score */}
<div className={styles.matchRow}>
{/* Home Team */}
<div className={styles.teamHome}>
{match.homeTeam.crest && (
<img className={styles.crest} src={match.homeTeam.crest} alt="" />
)}
<span className={styles.teamName}>{match.homeTeam.name}</span>
</div>
{/* Score / VS */}
<div className={styles.scoreBox}>
{isFinished || isLive ? (
<span className={styles.score}>
{match.score.home ?? ''} : {match.score.away ?? ''}
</span>
) : (
<span className={styles.vs}>vs</span>
)}
</div>
{/* Away Team */}
<div className={styles.teamAway}>
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && (
<img className={styles.crest} src={match.awayTeam.crest} alt="" />
)}
</div>
</div>
{/* Tipp-Bereich */}
<div className={styles.tipRow}>
{hasTip ? (
<div className={styles.tipDisplay}>
<span className={styles.tipLabel}>Dein Tipp:</span>
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
{match.userTip!.points !== null && (
<span className={`${styles.points} ${
match.userTip!.points === 3 ? styles.exact :
match.userTip!.points === 1 ? styles.tendency : styles.wrong
}`}>
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
</span>
)}
{match.tippable && (
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
)}
</div>
) : match.tippable ? (
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
Tipp abgeben
</button>
) : (
<span className={styles.noTip}>Kein Tipp abgegeben</span>
)}
</div>
</div>
);
}