import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Goal, Shield, Shirt, Stethoscope } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline'; import { FormChips } from '@/components/FormChips'; import { XgRace } from '@/components/XgRace'; import { ShotMap } from '@/components/ShotMap'; import { MomentumStrip } from '@/components/MomentumStrip'; import { EventIcon, MatchTimeline } from '@/components/MatchTimeline'; import { LineupPitch } from '@/components/LineupPitch'; import { teamFlag } from '@/lib/teams'; import { fmt, useFormat, useT } from '@/lib/i18n'; import { goalEvents, shootoutKicks } from '@/lib/matchEvents'; import { redCards } from '@/lib/discipline'; import { inPlayProbs } from '@/lib/model/inplay'; import { useTournamentStore } from '@/stores/tournamentStore'; import { PlayerLink } from '@/components/PlayerLink'; import type { Fixture, KeyPlayer, MatchPreview, MatchRich } from '@/lib/types'; const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0); /** Label dict for the detailed-stats card, widened for lookup by stat key. */ const fullStatLabels = (d: Record): Record => d; type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups'; function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) { const total = home + away || 1; return (
{home} {label} {away}
); } function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) { const t = useT(); return (
{teamFlag(team)} {team}
{players.length ? (
    {players.slice(0, 6).map((p) => (
  • {p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}
  • ))}
) :

{t.common.noData}

}
); } function LineupCol({ team, players }: { team: string; players: { name: string; position: string | null; number: number | null; starter: boolean }[] }) { const starters = players.filter((p) => p.starter); const list = starters.length ? starters : players; return (
{team}
    {list.map((p) => (
  • {p.number ?? ''} {p.position}
  • ))}
); } /** Hero stat chip: faint label · mono value. */ function HeroChip({ label, value, gold }: { label: string; value: string; gold?: boolean }) { return ( {label} {value} ); } export function MatchPreviewPage() { const { num: numParam } = useParams({ strict: false }) as { num: string }; const { t, locale, kickoffDay, kickoffTime } = useFormat(); const [preview, setPreview] = useState(null); const [timeline, setTimeline] = useState([]); const [rich, setRich] = useState(null); const [failed, setFailed] = useState(false); const [activeTab, setActiveTab] = useState('summary'); // Live score/minute arrive over the WebSocket snapshot — overlay them on the // (less-frequently-fetched) preview. const liveFixture = useTournamentStore((s) => s.snapshot?.fixtures.find((f) => f.num === Number(numParam))); const isLive = (liveFixture?.status ?? preview?.fixture.status) === 'live'; useEffect(() => { let alive = true; setPreview(null); setTimeline([]); setRich(null); setFailed(false); setActiveTab('summary'); const load = () => fetch(`/api/preview/${numParam}`) .then((r) => (r.ok ? r.json() : Promise.reject())) .then((d: MatchPreview) => alive && setPreview(d)) .catch(() => alive && setFailed(true)); const loadTimeline = () => fetch(`/api/inplay/${numParam}`) .then((r) => (r.ok ? r.json() : Promise.reject())) .then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points)) .catch(() => {}); const loadRich = () => fetch(`/api/match-rich/${numParam}`) .then((r) => (r.ok ? r.json() : Promise.reject())) .then((d: MatchRich) => alive && setRich(d)) .catch(() => {}); void load(); void loadTimeline(); void loadRich(); const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') { void load(); void loadTimeline(); void loadRich(); } }, 10_000); return () => { alive = false; clearInterval(id); }; }, [numParam]); const f: Fixture | undefined = liveFixture ?? preview?.fixture; const inPlay = useMemo(() => { if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null; const reds = redCards(preview.events); return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore, reds.home, reds.away); }, [preview, f]); const statRows = useMemo<[string, string][]>(() => [ ['possessionPct', t.match.stats.possessionPct], ['totalShots', t.match.stats.totalShots], ['shotsOnTarget', t.match.stats.shotsOnTarget], ['wonCorners', t.match.stats.wonCorners], ['foulsCommitted', t.match.stats.foulsCommitted], ['saves', t.match.stats.saves], ], [t]); const goals = useMemo(() => (preview ? goalEvents(preview.events) : []), [preview]); const kicks = useMemo(() => (preview ? shootoutKicks(preview.events) : []), [preview]); if (failed) return
{t.match.loadError} {t.match.backToLive}
; if (!preview || !f) return
; const hasScore = f.status === 'live' || f.status === 'finished'; const homeName = f.home.label; const awayName = f.away.label; const finished = f.status === 'finished'; // Hero extras from the rich capture: kickoff-hour weather (pre-match only) // and post-match official chips (attendance, referee, player of the match). const wx = f.status === 'scheduled' ? rich?.weather : null; const weatherText = wx && wx.tempC != null && wx.precipProbPct != null && wx.windKmh != null ? fmt(t.match.weatherLine, { temp: Math.round(wx.tempC), precip: Math.round(wx.precipProbPct), wind: Math.round(wx.windKmh) }) : null; const altitudeM = wx && wx.elevationM >= 1000 ? Math.round(wx.elevationM) : null; const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled' ? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee }) : null; const tvText = !finished && preview.broadcasts.length > 0 ? fmt(t.match.tvLine, { channels: preview.broadcasts.join(' · ') }) : null; const heroChips: { label: string; value: string; gold?: boolean }[] = []; if (hasScore) { if (rich?.xg) heroChips.push({ label: 'xG', value: `${rich.xg.home.toFixed(2)}–${rich.xg.away.toFixed(2)}` }); if (isLive && preview.liveStats) { const ls = preview.liveStats; if (ls.home.possessionPct != null || ls.away.possessionPct != null) { heroChips.push({ label: t.match.chips.poss, value: `${num(ls.home.possessionPct)}%–${num(ls.away.possessionPct)}%` }); } if (ls.home.totalShots != null || ls.away.totalShots != null) { heroChips.push({ label: t.match.chips.shots, value: `${num(ls.home.totalShots)}–${num(ls.away.totalShots)}` }); } } if (finished) { if (rich?.potm) heroChips.push({ label: t.match.potm, value: rich.potm.rating != null ? `${rich.potm.name} · ${rich.potm.rating}` : rich.potm.name, gold: true }); if (rich?.info?.attendance != null) heroChips.push({ label: t.match.chips.att, value: rich.info.attendance.toLocaleString(locale) }); if (rich?.info?.referee) heroChips.push({ label: t.match.chips.ref, value: rich.info.referee }); } } // Tabs only appear when their data exists (e.g. no Timeline before kickoff). const tabs: TabKey[] = ['summary']; if (preview.events.length > 0) tabs.push('timeline'); if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.zones != null || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats'); tabs.push('lineups'); const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary'; const modelCard = preview.prediction && ( {hasScore ? t.match.preMatchModel : t.match.modelExpectation}

{finished && f.homeScore != null && f.awayScore != null ? fmt(t.match.predictedVsActual, { ph: preview.prediction.topScore.home, pa: preview.prediction.topScore.away, ah: f.homeScore, aa: f.awayScore, }) : t.match.modelHonestyNote}

); return (
{t.common.back} {/* hero scoreboard */}
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {f.venue ? ( {preview.venue} ) : ( {preview.venue} )}
{f.home.team ? teamFlag(f.home.team) : } {homeName}
{hasScore ? (
{f.homeScore ?? 0}{f.awayScore ?? 0}
) : (
{kickoffTime(f.kickoff)}
)} {isLive &&
{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}
} {finished &&
{t.common.fullTime}
}
{f.away.team ? teamFlag(f.away.team) : } {awayName}
{heroChips.length > 0 && (
{heroChips.map((c) => )}
)} {(weatherText || infoText || tvText) && (
{weatherText && (
{weatherText} {altitudeM != null && {fmt(t.match.altitude, { m: altitudeM })}}
)} {infoText &&
{infoText}
} {tvText &&
{tvText}
}
)}
{/* sticky sub-nav */}
{tabs.map((k) => ( ))}
{/* ── Summary ── */} {tab === 'summary' && (
{inPlay && preview.prediction ? ( {t.match.liveWinProbability} {timeline.length >= 2 && (
)}

{t.match.inPlayHelp}

) : modelCard} {f.status === 'scheduled' && preview.scorerProps.length > 0 && ( {t.match.whoScores.title}
    {preview.scorerProps.map((p) => (
  • {p.odds > 0 ? `+${p.odds}` : p.odds}
  • ))}

{t.match.whoScores.note}

)} {preview.events.length > 0 && ( {t.match.keyMoments} {goals.length ? (
{goals.map((g, i) => (
{g.minute != null ? fmt(t.live.minute, { n: g.minute }) : ''} {g.title} {g.score && {g.score[0]}–{g.score[1]}}
))}
) : (

{t.match.noGoalsYet}

)}
)}
{kicks.length > 0 && ( {t.match.shootoutTitle} {(['home', 'away'] as const).map((side) => { const sideKicks = kicks.filter((k) => k.side === side); if (!sideKicks.length) return null; const team = side === 'home' ? f.home.team : f.away.team; return (
{team && {teamFlag(team)}} {side === 'home' ? homeName : awayName} {sideKicks.map((k, i) => ( ))} {sideKicks.filter((k) => k.scored).length}
); })}

{t.match.shootoutNote}

)} {/* live matches keep the pre-match view alongside the in-play one */} {inPlay && preview.prediction && modelCard} {finished && timeline.length >= 2 && ( {t.match.momentum} )}
)} {/* ── Timeline ── */} {tab === 'timeline' && preview.events.length > 0 && ( )} {/* ── Stats ── */} {tab === 'stats' && (
{preview.liveStats && ( {t.match.statsTitle} {statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( ))} )} {rich?.fullStats && rich.fullStats.length > 0 && ( {t.match.fullStatsTitle}
{rich.fullStats.map((r) => (
{r.home} {fullStatLabels(t.match.fullStats)[r.key] ?? r.key} {r.away}
))}

{t.match.xgAttribution}

)} {rich?.zones && ( {t.match.zonesTitle} {([ ['home', homeName, rich.zones.home, ['bg-accent/40', 'bg-accent/70', 'bg-accent']], ['away', awayName, rich.zones.away, ['bg-info/40', 'bg-info/70', 'bg-info']], ] as const).map(([side, name, z, tones]) => { const total = z.left + z.center + z.right || 1; return (
{name}
{t.match.zones.left} {z.left}% {t.match.zones.center} {z.center}% {t.match.zones.right} {z.right}%
); })}

{t.match.zonesNote}

)} {rich && rich.momentum.length >= 10 && ( {t.match.momentumTitle} )} {rich && rich.shots.length >= 3 && ( <> {t.match.xgRace} {rich.xg && ( {rich.xg.home.toFixed(2)} {rich.xg.away.toFixed(2)} )} {t.match.shotMap}

{t.match.xgAttribution}

)}
)} {/* ── Lineups ── */} {tab === 'lineups' && (
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0 || preview.injuries.home.length > 0 || preview.injuries.away.length > 0) && ( {([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const) .filter(([, players]) => players.length > 0) .map(([side, players]) => (
{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''} {fmt(t.match.suspended, { players: players.join(', ') })}
))} {(['home', 'away'] as const) .filter((side) => preview.injuries[side].length > 0) .map((side) => (
{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''} {fmt(t.match.unavailable, { players: preview.injuries[side].map((i) => (i.reason ? `${i.player} (${i.reason})` : i.player)).join(', '), })}
))}
)} {/* Before kickoff the pitch shows FotMob's projected XIs, clearly labeled as predicted; from kickoff it's the confirmed lineup. */} {rich?.lineup ? ( {hasScore || rich.lineupConfirmed ? t.match.lineups : t.match.predictedLineups} {rich.lineupConfirmed && !finished && ( {t.match.confirmedLineups} )}

{hasScore || rich.lineupConfirmed ? t.match.xgAttribution : t.match.predictedLineupsNote}

) : ( {preview.lineups ? t.match.lineups : t.match.keyPlayers} {preview.lineups ? ( <> ) : ( <> )} )}
{(preview.form.home.length > 0 || preview.form.away.length > 0) && ( {t.match.recentForm}
{f.home.team && teamFlag(f.home.team)} {homeName}
{f.away.team && teamFlag(f.away.team)} {awayName}
)} {preview.h2h && ( {t.match.headToHead}
{preview.h2h.homeWins}
{fmt(t.match.h2h.teamWins, { team: homeName })}
{preview.h2h.draws}
{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}
{preview.h2h.awayWins}
{fmt(t.match.h2h.teamWins, { team: awayName })}
{t.match.h2h.recentMeetings}
    {preview.h2h.last.map((m, i) => (
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • ))}
)}
)}

{preview.dataNote}

); }