feat: zustandsbasierte Match-Cards (open/tipped/live/finished/missed)

Each card state has distinct visual treatment:
- Open: standard with countdown timer when <1h
- Tipped: green accent with tip display
- Live: pulsing red dot
- Finished: points badge (gold/green/gray)
- Missed: grayed out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ronny
2026-04-11 19:07:21 +02:00
parent 9a9b85a269
commit 1ed64078b4
2 changed files with 208 additions and 15 deletions
+90 -15
View File
@@ -1,4 +1,5 @@
import { Check, TrendingUp, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Match } from '../api/client';
import styles from './MatchCard.module.css';
@@ -7,6 +8,32 @@ interface Props {
onTip: () => void;
}
type CardState = 'open' | 'tipped' | 'live' | 'finished' | 'missed';
function getCardState(match: Match): CardState {
if (match.status === 'IN_PLAY' || match.status === 'PAUSED') return 'live';
if (match.status === 'FINISHED') {
return match.userTip ? 'finished' : 'missed';
}
// SCHEDULED or TIMED
return match.userTip ? 'tipped' : 'open';
}
function useCountdown(minutesUntilKickoff: number) {
const [remaining, setRemaining] = useState(minutesUntilKickoff);
useEffect(() => {
if (minutesUntilKickoff > 60) return; // only active for <1h
setRemaining(minutesUntilKickoff);
const interval = setInterval(() => {
setRemaining(r => Math.max(0, r - 1 / 60));
}, 1000);
return () => clearInterval(interval);
}, [minutesUntilKickoff]);
return remaining;
}
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Geplant',
TIMED: 'Terminiert',
@@ -45,10 +72,15 @@ function FlagBox({ crest, name }: { crest: string | null; name: string }) {
}
export default function MatchCard({ match, onTip }: Props) {
const isFinished = match.status === 'FINISHED';
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
const state = getCardState(match);
const remaining = useCountdown(match.minutesUntilKickoff);
const remainingMins = Math.ceil(remaining);
const isFinished = state === 'finished' || state === 'missed';
const isLive = state === 'live';
const hasTip = !!match.userTip;
const points = match.userTip?.points ?? null;
const resultClass =
points === 3 ? styles.exact :
points === 1 ? styles.tendency :
@@ -60,19 +92,29 @@ export default function MatchCard({ match, onTip }: Props) {
isFinished && points === 0 ? styles.glowWrong : '';
return (
<div className={`card ${styles.card} ${isLive ? styles.live : ''} ${glowClass}`}>
<div className={`card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`}>
{/* Top row: Status / Kickoff / Badges */}
<div className={styles.topRow}>
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
{isLive && '● '}{STATUS_LABELS[match.status] ?? match.status}
{isLive && <span className={styles.liveDot} />}
{STATUS_LABELS[match.status] ?? match.status}
</span>
{match.group && (
<span className={styles.group}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
{/* Countdown only shown for open/tipped states */}
{(state === 'open' || state === 'tipped') && match.tippable && (
match.minutesUntilKickoff < 60 ? (
<span className={`${styles.countdown} ${remainingMins < 5 ? styles.countdownUrgent : ''}`}>
Noch {remainingMins} Min!
</span>
) : (
<CountdownBadge minutes={match.minutesUntilKickoff} />
)
)}
</div>
{/* Teams + Score */}
@@ -86,9 +128,17 @@ export default function MatchCard({ match, onTip }: Props) {
{/* Score / Kickoff time */}
<div className={styles.scoreBox}>
{isFinished || isLive ? (
<span className={styles.score}>
{match.score.home ?? ''}&nbsp;:&nbsp;{match.score.away ?? ''}
</span>
<div className={styles.scoreStack}>
<span className={styles.score}>
{match.score.home ?? ''}&nbsp;:&nbsp;{match.score.away ?? ''}
</span>
{/* For live: show user's tip next to score for comparison */}
{isLive && hasTip && (
<span className={styles.liveTipCompare}>
Tipp: {match.userTip!.home}:{match.userTip!.away}
</span>
)}
</div>
) : (
<div className={styles.kickoffCenter}>
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
@@ -105,7 +155,26 @@ export default function MatchCard({ match, onTip }: Props) {
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
{hasTip ? (
{state === 'missed' ? (
/* ── Missed: no tip, match finished ── */
<span className={styles.missedLabel}>Nicht getippt</span>
) : state === 'live' ? (
/* ── Live: no tip input, locked ── */
hasTip ? (
<div className={styles.tipDisplay}>
<div className={styles.tipLeft} />
<div className={styles.tipCenter}>
<span className={styles.tipLabel}>DEIN TIPP</span>
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
</div>
<div className={styles.tipRight} />
</div>
) : (
<span className={styles.noTip}>Kein Tipp abgegeben</span>
)
) : hasTip ? (
points !== null ? (
/* ── Auswertungs-Banner ── */
<div className={styles.tipDisplay}>
@@ -130,24 +199,30 @@ export default function MatchCard({ match, onTip }: Props) {
{/* Rechts: Punkte */}
<div className={styles.tipRight}>
<span className={styles.resultPoints}>
<span className={`${styles.pointsBadge} ${
points === 3 ? styles.pointsBadge_exact :
points === 1 ? styles.pointsBadge_tendency :
styles.pointsBadge_wrong
}`}>
{points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
</span>
</div>
</div>
) : (
/* ── Tipp vorhanden, noch nicht ausgewertet ── */
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
<div className={styles.tipDisplay}>
<div className={styles.tipLeft}>
{match.tippable && (
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
<button className={styles.changeBtn} onClick={onTip}>Ändern</button>
)}
</div>
<div className={styles.tipCenter}>
{/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */}
{!match.tippable && <span className={styles.tipLabel}>DEIN TIPP</span>}
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
<span className={styles.tipDisplay_score}>
<Check size={13} strokeWidth={3} style={{ color: 'var(--success)', flexShrink: 0 }} />
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
</span>
</div>
<div className={styles.tipRight} />