diff --git a/frontend/src/components/MatchCard.module.css b/frontend/src/components/MatchCard.module.css index 030ae9c..c33e4cd 100644 --- a/frontend/src/components/MatchCard.module.css +++ b/frontend/src/components/MatchCard.module.css @@ -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); } +} diff --git a/frontend/src/components/MatchCard.tsx b/frontend/src/components/MatchCard.tsx index 393eae0..135e63b 100644 --- a/frontend/src/components/MatchCard.tsx +++ b/frontend/src/components/MatchCard.tsx @@ -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 = { 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 ( -
+
{/* Top row: Status / Kickoff / Badges */}
- {isLive && '● '}{STATUS_LABELS[match.status] ?? match.status} + {isLive && } + {STATUS_LABELS[match.status] ?? match.status} {match.group && ( {match.group.replace('GROUP_', 'Gruppe ')} )} - {match.tippable && } + {/* Countdown only shown for open/tipped states */} + {(state === 'open' || state === 'tipped') && match.tippable && ( + match.minutesUntilKickoff < 60 ? ( + + Noch {remainingMins} Min! + + ) : ( + + ) + )}
{/* Teams + Score */} @@ -86,9 +128,17 @@ export default function MatchCard({ match, onTip }: Props) { {/* Score / Kickoff time */}
{isFinished || isLive ? ( - - {match.score.home ?? '–'} : {match.score.away ?? '–'} - +
+ + {match.score.home ?? '–'} : {match.score.away ?? '–'} + + {/* For live: show user's tip next to score for comparison */} + {isLive && hasTip && ( + + Tipp: {match.userTip!.home}:{match.userTip!.away} + + )} +
) : (
{formatKickoff(match.utcDate)} @@ -105,7 +155,26 @@ export default function MatchCard({ match, onTip }: Props) { {/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
- {hasTip ? ( + {state === 'missed' ? ( + /* ── Missed: no tip, match finished ── */ + Nicht getippt + ) : state === 'live' ? ( + /* ── Live: no tip input, locked ── */ + hasTip ? ( +
+
+
+ DEIN TIPP + + {match.userTip!.home} : {match.userTip!.away} + +
+
+
+ ) : ( + Kein Tipp abgegeben + ) + ) : hasTip ? ( points !== null ? ( /* ── Auswertungs-Banner ── */
@@ -130,24 +199,30 @@ export default function MatchCard({ match, onTip }: Props) { {/* Rechts: Punkte */}
- + {points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
) : ( - /* ── Tipp vorhanden, noch nicht ausgewertet ── */ + /* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
{match.tippable && ( - + )}
- {/* Label nur zeigen wenn kein Ändern-Button da ist, sonst fluchtet der Button nicht */} {!match.tippable && DEIN TIPP} - - {match.userTip!.home} : {match.userTip!.away} + + + + {match.userTip!.home} : {match.userTip!.away} +