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
@@ -0,0 +1,125 @@
.card { padding: 16px 20px; transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 12px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.09); }
.live { box-shadow: 0 0 0 1px rgba(248,113,113,0.3), 0 10px 25px rgba(0,0,0,0.25) !important; }
.topRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.status { font-size: 12px; color: var(--text-muted); }
.statusLive { color: var(--error); font-weight: 600; }
.kickoff { font-size: 13px; color: var(--text-secondary); margin-left: auto; }
.badge, .badgeUrgent {
font-size: 11px;
padding: 3px 8px;
border-radius: 10px;
font-weight: 600;
}
.badge { background: var(--surface-high); color: var(--text-secondary); }
.badgeUrgent { background: rgba(254,174,50,0.15); color: var(--gold); }
.group {
font-size: 11px;
color: var(--primary);
background: var(--primary-dim);
padding: 2px 8px;
border-radius: 10px;
}
.matchRow {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.teamHome { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
.teamAway { display: flex; align-items: center; gap: 10px; }
.crest { width: 28px; height: 28px; object-fit: contain; }
.teamName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.scoreBox {
min-width: 80px;
text-align: center;
background: var(--surface-high);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.score {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 2px;
}
.vs {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
}
/* Tipp */
.tipRow {
border-top: 1px solid rgba(255,255,255,0.06);
padding-top: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.tipBtn { width: 100%; max-width: 240px; }
.tipDisplay {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.tipLabel { font-size: 13px; color: var(--text-secondary); }
.tipScore {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 16px;
color: var(--primary);
}
.points {
font-size: 13px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
}
.exact { background: rgba(52,211,153,0.15); color: var(--success); }
.tendency { background: rgba(75,183,248,0.15); color: var(--primary); }
.wrong { background: rgba(248,113,113,0.12); color: var(--error); }
.editBtn {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.editBtn:hover { border-color: var(--primary); color: var(--primary); }
.noTip { font-size: 13px; color: var(--text-muted); }
+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>
);
}
+118
View File
@@ -0,0 +1,118 @@
.overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal {
background: var(--surface-mid);
border-radius: var(--radius-xl);
padding: 28px;
width: 100%; max-width: 440px;
box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(75,183,248,0.1);
position: relative;
}
.header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px;
}
.title {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 20px; font-weight: 800;
}
.closeBtn {
background: var(--surface-high);
border: none; color: var(--text-secondary);
width: 32px; height: 32px; border-radius: 50%;
cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.closeBtn:hover { background: var(--surface-high); color: var(--text-primary); }
.teamsRow {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 28px;
}
.team { display: flex; align-items: center; gap: 8px; flex: 1; }
.teamRight { flex-direction: row-reverse; }
.crest { width: 32px; height: 32px; object-fit: contain; }
.teamName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 600; font-size: 14px;
color: var(--text-primary);
}
.vs { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.pickerRow {
display: flex; align-items: center; justify-content: center;
gap: 20px; margin-bottom: 20px;
}
.colon {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 40px; font-weight: 800;
color: var(--text-secondary);
line-height: 1;
}
.picker {
display: flex; flex-direction: column;
align-items: center; gap: 12px;
}
.pickerBtn {
width: 48px; height: 48px;
background: var(--surface-high);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 22px; font-weight: 300;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.pickerBtn:hover { background: var(--primary-dim); border-color: var(--primary); color: var(--primary); }
.pickerValue {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 52px; font-weight: 800;
color: var(--text-primary);
min-width: 60px; text-align: center;
line-height: 1;
}
.tendencyRow {
display: flex; align-items: center; justify-content: center;
gap: 8px; margin-bottom: 24px;
padding: 10px;
background: var(--surface-high);
border-radius: var(--radius-sm);
}
.tendencyLabel { font-size: 13px; color: var(--text-secondary); }
.tendencyValue { font-size: 14px; font-weight: 700; color: var(--primary); }
.error {
color: var(--error);
font-size: 13px;
text-align: center;
margin-bottom: 16px;
padding: 10px;
background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm);
}
.actions {
display: flex; gap: 12px; justify-content: flex-end;
}
.actions .btn-primary { flex: 1; }
+95
View File
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Match, api } from '../api/client';
import styles from './TipModal.module.css';
interface Props {
match: Match;
onClose: () => void;
onSaved: (matchId: number, home: number, away: number) => void;
}
export default function TipModal({ match, onClose, onSaved }: Props) {
const existing = match.userTip;
const [home, setHome] = useState(existing?.home ?? 0);
const [away, setAway] = useState(existing?.away ?? 0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjust = (setter: React.Dispatch<React.SetStateAction<number>>, val: number, delta: number) => {
setter(Math.max(0, Math.min(20, val + delta)));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.submitTip(match.id, home, away);
onSaved(match.id, home, away);
} catch (e) {
setError((e as Error).message);
setSaving(false);
}
};
// Tendenz-Anzeige
const tendency = home > away ? match.homeTeam.shortName :
away > home ? match.awayTeam.shortName : 'Unentschieden';
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header */}
<div className={styles.header}>
<h2 className={styles.title}>Tipp abgeben</h2>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{/* Teams */}
<div className={styles.teamsRow}>
<div className={styles.team}>
{match.homeTeam.crest && <img className={styles.crest} src={match.homeTeam.crest} alt="" />}
<span className={styles.teamName}>{match.homeTeam.name}</span>
</div>
<span className={styles.vs}>vs</span>
<div className={`${styles.team} ${styles.teamRight}`}>
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && <img className={styles.crest} src={match.awayTeam.crest} alt="" />}
</div>
</div>
{/* Score Picker */}
<div className={styles.pickerRow}>
<ScorePicker value={home} onChange={v => setHome(v)} />
<div className={styles.colon}>:</div>
<ScorePicker value={away} onChange={v => setAway(v)} />
</div>
{/* Tendenz */}
<div className={styles.tendencyRow}>
<span className={styles.tendencyLabel}>Tendenz:</span>
<span className={styles.tendencyValue}>{tendency}</span>
</div>
{error && <div className={styles.error}>{error}</div>}
{/* Buttons */}
<div className={styles.actions}>
<button className="btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Wird gespeichert…' : '✓ Tipp speichern'}
</button>
</div>
</div>
</div>
);
}
function ScorePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<div className={styles.picker}>
<button className={styles.pickerBtn} onClick={() => onChange(Math.min(20, value + 1))}>+</button>
<span className={styles.pickerValue}>{value}</span>
<button className={styles.pickerBtn} onClick={() => onChange(Math.max(0, value - 1))}></button>
</div>
);
}