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
+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>
);
}