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