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:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 ?? '–'} : {match.score.away ?? '–'}
|
||||
</span>
|
||||
<div className={styles.scoreStack}>
|
||||
<span className={styles.score}>
|
||||
{match.score.home ?? '–'} : {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} />
|
||||
|
||||
Reference in New Issue
Block a user