This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/frontend/src/components/MatchCard.tsx
T
Ronny 92f847c075 style: countdown badge left-aligned, separate from header
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>
2026-04-11 23:16:17 +02:00

234 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? ''}&nbsp;:&nbsp;{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>
);
}