92f847c075
Countdown badge now in its own row, left-aligned to match the left edge of the flags. Removed from header top row. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
import { Check, TrendingUp, X } from 'lucide-react';
|
||
import { useEffect, useState } from 'react';
|
||
import { Match } from '../api/client';
|
||
import styles from './MatchCard.module.css';
|
||
|
||
interface Props {
|
||
match: Match;
|
||
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',
|
||
IN_PLAY: 'Live',
|
||
PAUSED: 'Pause',
|
||
FINISHED: 'Beendet',
|
||
POSTPONED: 'Verschoben',
|
||
CANCELLED: 'Abgesagt',
|
||
};
|
||
|
||
function formatKickoff(utcDate: string): string {
|
||
return new Date(utcDate).toLocaleString('de-DE', {
|
||
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
|
||
});
|
||
}
|
||
|
||
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
|
||
return (
|
||
<div className={styles.flagBox}>
|
||
{crest
|
||
? <img className={styles.crest} src={crest} alt={name} />
|
||
: <span style={{ fontSize: 18 }}>🏳️</span>
|
||
}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MatchCard({ match, onTip }: Props) {
|
||
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 :
|
||
(points === 0 && isFinished) ? styles.wrong : '';
|
||
|
||
const glowClass =
|
||
isFinished && points === 3 ? styles.glowExact :
|
||
isFinished && points === 1 ? styles.glowTendency :
|
||
isFinished && points === 0 ? styles.glowWrong : '';
|
||
|
||
return (
|
||
<div className={`card ${styles.card} ${styles[`card_${state}`]} ${isLive ? styles.live : ''} ${glowClass}`}>
|
||
|
||
{/* Top row: Status/Group */}
|
||
<div className={styles.topRow}>
|
||
{(isLive || isFinished) && (
|
||
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
|
||
{isLive && <span className={styles.liveDot} />}
|
||
{STATUS_LABELS[match.status] ?? match.status}
|
||
</span>
|
||
)}
|
||
{match.group && (
|
||
<span className={styles.group}>
|
||
{match.group.replace('GROUP_', 'Gruppe ')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Kickoff — stadium LED display, centered */}
|
||
{!isFinished && !isLive && (
|
||
<div className={styles.kickoffRow}>
|
||
<span className={styles.kickoffLED}>{formatKickoff(match.utcDate)}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Countdown badge — left-aligned */}
|
||
{(state === 'open' || state === 'tipped') && match.tippable && (
|
||
<div className={styles.countdownRow}>
|
||
<span className={`${styles.countdownBadge} ${remainingMins < 60 ? styles.countdownUrgent : ''}`}>
|
||
{match.minutesUntilKickoff < 60
|
||
? `Noch ${remainingMins} Min!`
|
||
: (() => {
|
||
const h = Math.floor(match.minutesUntilKickoff / 60);
|
||
if (h < 24) return `in ${h}h`;
|
||
const d = Math.floor(h / 24);
|
||
return `in ${d} Tag${d > 1 ? 'en' : ''}`;
|
||
})()
|
||
}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Teams + Score */}
|
||
<div className={styles.matchRow}>
|
||
{/* Home */}
|
||
<div className={styles.teamHome}>
|
||
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
|
||
<span className={styles.teamName}>{match.homeTeam.shortName}</span>
|
||
</div>
|
||
|
||
{/* Center: Score or VS separator */}
|
||
<div className={styles.scoreBox}>
|
||
{isFinished || isLive ? (
|
||
<div className={styles.scoreStack}>
|
||
<span className={styles.score}>
|
||
{match.score.home ?? '–'} : {match.score.away ?? '–'}
|
||
</span>
|
||
{isLive && hasTip && (
|
||
<span className={styles.liveTipCompare}>
|
||
Tipp: {match.userTip!.home}:{match.userTip!.away}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className={styles.vsSeparator}>–</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Away */}
|
||
<div className={styles.teamAway}>
|
||
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
|
||
<span className={styles.teamName}>{match.awayTeam.shortName}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tipp area — wird zum farbigen Banner wenn Punkte ausgewertet */}
|
||
<div className={`${styles.tipRow} ${hasTip && points !== null ? `${styles.resultBanner} ${resultClass}` : ''}`}>
|
||
{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}>
|
||
{/* Links: Icon + Ergebnis-Label nebeneinander, zentriert zur Tippbox */}
|
||
<div className={`${styles.tipLeft} ${styles.bannerLeft}`}>
|
||
<span className={styles.resultIcon}>
|
||
{points === 3 ? <Check size={14} strokeWidth={3} /> :
|
||
points === 1 ? <TrendingUp size={14} strokeWidth={2.5} /> :
|
||
<X size={14} strokeWidth={3} />}
|
||
</span>
|
||
<span className={styles.resultLabel}>
|
||
{points === 3 ? 'Exakt' : points === 1 ? 'Tendenz' : 'Falsch'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Mitte: nur Score, kein Label */}
|
||
<div className={styles.tipCenter}>
|
||
<span className={styles.tipScoreBanner}>
|
||
{match.userTip!.home} : {match.userTip!.away}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Rechts: Punkte */}
|
||
<div className={styles.tipRight}>
|
||
<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 (tipped state) ── */
|
||
<div className={styles.tippedRow} onClick={match.tippable ? onTip : undefined}>
|
||
<span className={styles.tippedIcon}>✓</span>
|
||
<span className={styles.tippedLabel}>Dein Tipp</span>
|
||
<span className={styles.tippedScore}>{match.userTip!.home} : {match.userTip!.away}</span>
|
||
{match.tippable && <span className={styles.tippedEdit}>Ändern</span>}
|
||
</div>
|
||
)
|
||
) : match.tippable ? (
|
||
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
|
||
Tipp abgeben
|
||
</button>
|
||
) : (
|
||
<span className={styles.noTip}>Kein Tipp abgegeben</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|