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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
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 { Check, TrendingUp, X } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Match } from '../api/client';
|
import { Match } from '../api/client';
|
||||||
import styles from './MatchCard.module.css';
|
import styles from './MatchCard.module.css';
|
||||||
|
|
||||||
@@ -7,6 +8,32 @@ interface Props {
|
|||||||
onTip: () => void;
|
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> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
SCHEDULED: 'Geplant',
|
SCHEDULED: 'Geplant',
|
||||||
TIMED: 'Terminiert',
|
TIMED: 'Terminiert',
|
||||||
@@ -45,10 +72,15 @@ function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MatchCard({ match, onTip }: Props) {
|
export default function MatchCard({ match, onTip }: Props) {
|
||||||
const isFinished = match.status === 'FINISHED';
|
const state = getCardState(match);
|
||||||
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
|
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 hasTip = !!match.userTip;
|
||||||
const points = match.userTip?.points ?? null;
|
const points = match.userTip?.points ?? null;
|
||||||
|
|
||||||
const resultClass =
|
const resultClass =
|
||||||
points === 3 ? styles.exact :
|
points === 3 ? styles.exact :
|
||||||
points === 1 ? styles.tendency :
|
points === 1 ? styles.tendency :
|
||||||
@@ -60,19 +92,29 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
isFinished && points === 0 ? styles.glowWrong : '';
|
isFinished && points === 0 ? styles.glowWrong : '';
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Top row: Status / Kickoff / Badges */}
|
||||||
<div className={styles.topRow}>
|
<div className={styles.topRow}>
|
||||||
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
|
<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>
|
</span>
|
||||||
{match.group && (
|
{match.group && (
|
||||||
<span className={styles.group}>
|
<span className={styles.group}>
|
||||||
{match.group.replace('GROUP_', 'Gruppe ')}
|
{match.group.replace('GROUP_', 'Gruppe ')}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Teams + Score */}
|
{/* Teams + Score */}
|
||||||
@@ -86,9 +128,17 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
{/* Score / Kickoff time */}
|
{/* Score / Kickoff time */}
|
||||||
<div className={styles.scoreBox}>
|
<div className={styles.scoreBox}>
|
||||||
{isFinished || isLive ? (
|
{isFinished || isLive ? (
|
||||||
<span className={styles.score}>
|
<div className={styles.scoreStack}>
|
||||||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
<span className={styles.score}>
|
||||||
</span>
|
{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}>
|
<div className={styles.kickoffCenter}>
|
||||||
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
|
<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 */}
|
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
|
||||||
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
|
<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 ? (
|
points !== null ? (
|
||||||
/* ── Auswertungs-Banner ── */
|
/* ── Auswertungs-Banner ── */
|
||||||
<div className={styles.tipDisplay}>
|
<div className={styles.tipDisplay}>
|
||||||
@@ -130,24 +199,30 @@ export default function MatchCard({ match, onTip }: Props) {
|
|||||||
|
|
||||||
{/* Rechts: Punkte */}
|
{/* Rechts: Punkte */}
|
||||||
<div className={styles.tipRight}>
|
<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.`}
|
{points === 0 ? '0 Pkt.' : `+${points} Pkt.`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Tipp vorhanden, noch nicht ausgewertet ── */
|
/* ── Tipp vorhanden, noch nicht ausgewertet (tipped state) ── */
|
||||||
<div className={styles.tipDisplay}>
|
<div className={styles.tipDisplay}>
|
||||||
<div className={styles.tipLeft}>
|
<div className={styles.tipLeft}>
|
||||||
{match.tippable && (
|
{match.tippable && (
|
||||||
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
|
<button className={styles.changeBtn} onClick={onTip}>Ändern</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tipCenter}>
|
<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>}
|
{!match.tippable && <span className={styles.tipLabel}>DEIN TIPP</span>}
|
||||||
<span className={styles.tipScore}>
|
<span className={styles.tipDisplay_score}>
|
||||||
{match.userTip!.home} : {match.userTip!.away}
|
<Check size={13} strokeWidth={3} style={{ color: 'var(--success)', flexShrink: 0 }} />
|
||||||
|
<span className={styles.tipScore}>
|
||||||
|
{match.userTip!.home} : {match.userTip!.away}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tipRight} />
|
<div className={styles.tipRight} />
|
||||||
|
|||||||
Reference in New Issue
Block a user