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 82619e6db3
commit f0776e436b
2 changed files with 208 additions and 15 deletions
@@ -341,3 +341,121 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── State-based card variants ──────────────────────────────────── */
.card_open { /* default — no extra styling needed */ }
.card_tipped {
border-left: 3px solid var(--success);
}
.card_live {
border-left: 3px solid var(--error);
}
.card_finished { /* glow classes already applied via JS */ }
.card_missed {
opacity: 0.45;
pointer-events: none;
}
/* Live pulsing dot */
.liveDot {
width: 8px;
height: 8px;
background: var(--error);
border-radius: 50%;
display: inline-block;
margin-right: 6px;
vertical-align: middle;
animation: pulse 1.5s ease-in-out infinite;
}
/* Score + live tip stacked */
.scoreStack {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.liveTipCompare {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.03em;
}
/* Points badge */
.pointsBadge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 700;
}
.pointsBadge_exact {
background: linear-gradient(135deg, var(--gold), #FFD700);
color: #1a1a1a;
animation: shimmer 2s ease-in-out;
}
.pointsBadge_tendency {
background: var(--success);
color: #1a1a1a;
}
.pointsBadge_wrong {
background: var(--text-muted);
color: var(--bg-deep);
}
/* Missed label */
.missedLabel {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
/* Countdown (replaces badge when < 60 min) */
.countdown {
color: var(--error);
font-weight: 700;
font-size: 0.8rem;
}
.countdownUrgent {
animation: pulse 0.8s ease-in-out infinite;
}
/* Tipped state: checkmark + score inline */
.tipDisplay_score {
display: flex;
align-items: center;
gap: 6px;
}
/* Change button for tipped state */
.changeBtn {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 0.8rem;
text-decoration: underline;
padding: 0;
}
.changeBtn:hover {
color: var(--primary);
opacity: 0.75;
}
@keyframes shimmer {
0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
50% { box-shadow: 0 0 16px rgba(254, 174, 50, 0.5); }
100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); }
}
+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} />