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 0f70a1913c style: LIVE badge right-aligned, bigger live score, remove redundant tip
- LIVE: pulsing red badge with dot, right-aligned in header (replaces countdown position)
- Live score: 28px instead of 22px for better visibility
- Removed duplicate tip display under live score (tip only shown in footer)
- BEENDET status stays left in header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:00:55 +02:00

227 lines
8.1 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: Group left + Status/Countdown right */}
<div className={styles.topRow}>
{isFinished && (
<span className={styles.status}>{STATUS_LABELS[match.status] ?? match.status}</span>
)}
{match.group && (
<span className={styles.group}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
<span style={{ flex: 1 }} />
{isLive && (
<span className={styles.liveBadge}>
<span className={styles.liveDot} />
LIVE
</span>
)}
{(state === 'open' || state === 'tipped') && match.tippable && (
<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>
{/* Kickoff — stadium LED display, centered */}
{!isFinished && !isLive && (
<div className={styles.kickoffRow}>
<span className={styles.kickoffLED}>{formatKickoff(match.utcDate)}</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 ? (
<span className={`${styles.score} ${isLive ? styles.scoreLive : ''}`}>
{match.score.home ?? ''}&nbsp;:&nbsp;{match.score.away ?? ''}
</span>
) : (
<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>
);
}