Files
cup26/src/features/match/MatchPreviewPage.tsx
T
NilsBriggen 5951b2de65 Slim match summary + per-deploy "What's new" modal
- Removed the Videos feed and the "Market movement vs the model" card from
  the match Summary tab (and the now-orphaned OddsMovement component, the
  odds-history fetch, and the dead i18n keys).
- New WhatsNew modal (src/components/WhatsNew.tsx, mounted in RootLayout):
  on first visit after a deploy it shows the changelog entries the visitor
  hasn't seen yet, then records the version in localStorage. Bilingual,
  driven by public/data/changelog.json (en+de per entry); silent once the
  visitor is current. Bump changelog.json's top entry each deploy.

Gates: 170 tests, build, typecheck:server — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:28:18 +02:00

628 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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<string, string>): Record<string, string> => d;
type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups';
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
const total = home + away || 1;
return (
<div>
<div className="flex items-center justify-between text-sm">
<span className="tnum font-bold text-accent">{home}</span>
<span className="text-xs uppercase tracking-wide text-faint">{label}</span>
<span className="tnum font-bold text-info">{away}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-accent" style={{ width: `${(home / total) * 100}%` }} />
<div className="ml-auto bg-info" style={{ width: `${(away / total) * 100}%` }} />
</div>
</div>
);
}
function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
const t = useT();
return (
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink"><span aria-hidden>{teamFlag(team)}</span> {team}</div>
{players.length ? (
<ul className="space-y-1">
{players.slice(0, 6).map((p) => (
<li key={p.name} className="flex items-center justify-between text-sm">
<PlayerLink name={p.name} className="truncate text-ink-soft transition-colors hover:text-accent" />
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}</span>
</li>
))}
</ul>
) : <p className="text-xs text-faint">{t.common.noData}</p>}
</div>
);
}
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 (
<div>
<div className="mb-2 text-sm font-semibold text-ink">{team}</div>
<ul className="space-y-0.5 text-sm">
{list.map((p) => (
<li key={p.name} className="flex items-center gap-2 text-ink-soft">
<span className="tnum w-5 text-right text-xs text-faint">{p.number ?? ''}</span>
<PlayerLink name={p.name} className="truncate transition-colors hover:text-accent" />
<span className="ml-auto text-[11px] text-faint">{p.position}</span>
</li>
))}
</ul>
</div>
);
}
/** Hero stat chip: faint label · mono value. */
function HeroChip({ label, value, gold }: { label: string; value: string; gold?: boolean }) {
return (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-elevated px-3 py-1 text-xs">
<span className="shrink-0 text-faint">{label}</span>
<span className={`tnum truncate ${gold ? 'text-gold' : 'text-ink-soft'}`}>{value}</span>
</span>
);
}
export function MatchPreviewPage() {
const { num: numParam } = useParams({ strict: false }) as { num: string };
const { t, locale, kickoffDay, kickoffTime } = useFormat();
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
const [rich, setRich] = useState<MatchRich | null>(null);
const [failed, setFailed] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('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 <div className="py-16 text-center text-muted">{t.match.loadError} <Link to="/" className="text-accent">{t.match.backToLive}</Link></div>;
if (!preview || !f) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
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 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? t.match.preMatchModel : t.match.modelExpectation}</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
<p className="text-xs text-faint">
{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}
</p>
</CardBody>
</Card>
);
return (
<div className="space-y-5">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> {t.common.back}</Link>
{/* hero scoreboard */}
<div className={`relative overflow-hidden rounded-2xl border bg-gradient-to-b from-panel to-surface p-6 md:p-8 ${isLive ? 'border-live/40 ring-1 ring-live/30' : 'border-line'}`}>
<div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_90%_at_50%_-20%,var(--app-accent-glow),transparent_60%)]" />
<div className="relative">
<div className="smallcaps text-center">
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}
<span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)}
<span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)}
<span className="text-line-strong"> · </span>
{f.venue ? (
<Link to="/venue/$name" params={{ name: f.venue }} className="whitespace-nowrap transition-colors hover:text-accent">{preview.venue}</Link>
) : (
<span className="whitespace-nowrap">{preview.venue}</span>
)}
</div>
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
<span aria-hidden className="text-4xl md:text-6xl">{f.home.team ? teamFlag(f.home.team) : <Shield size={40} className="text-faint" />}</span>
<span className="text-center text-[15px] font-bold text-ink md:text-lg">{homeName}</span>
</Link>
<div className="text-center">
{hasScore ? (
<div className="tnum text-[40px] font-semibold leading-none tracking-[-0.04em] text-ink md:text-6xl">
{f.homeScore ?? 0}<span className="text-[0.5em] text-line-strong"></span>{f.awayScore ?? 0}
</div>
) : (
<div className="tnum text-2xl font-bold text-muted md:text-3xl">{kickoffTime(f.kickoff)}</div>
)}
{isLive && <div className="mt-2 inline-flex items-center gap-1.5 text-[11px] font-bold uppercase text-live"><span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}</div>}
{finished && <div className="mt-2 text-[11px] font-bold uppercase tracking-wider text-faint">{t.common.fullTime}</div>}
</div>
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
<span aria-hidden className="text-4xl md:text-6xl">{f.away.team ? teamFlag(f.away.team) : <Shield size={40} className="text-faint" />}</span>
<span className="text-center text-[15px] font-bold text-ink md:text-lg">{awayName}</span>
</Link>
</div>
{heroChips.length > 0 && (
<div className="mt-5 flex flex-wrap items-center justify-center gap-2 border-t border-line pt-4">
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
</div>
)}
{(weatherText || infoText || tvText) && (
<div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted">
{weatherText && (
<div className="flex flex-wrap items-center justify-center gap-2">
<span>{weatherText}</span>
{altitudeM != null && <Badge tone="muted">{fmt(t.match.altitude, { m: altitudeM })}</Badge>}
</div>
)}
{infoText && <div>{infoText}</div>}
{tvText && <div>{tvText}</div>}
</div>
)}
</div>
</div>
{/* sticky sub-nav */}
<div className="sticky top-14 z-20 bg-surface/90 py-1 backdrop-blur">
<div className="flex gap-1 overflow-x-auto rounded-xl border border-line bg-panel p-1" role="tablist">
{tabs.map((k) => (
<button
key={k}
role="tab"
aria-selected={tab === k}
onClick={() => setActiveTab(k)}
className={`flex-1 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-semibold transition-colors ${tab === k ? 'bg-panel-2 text-ink shadow-sm' : 'text-muted hover:text-ink-soft'}`}
>
{t.match.tabs[k]}
</button>
))}
</div>
</div>
{/* ── Summary ── */}
{tab === 'summary' && (
<div className="space-y-4">
<div className="grid items-start gap-4 md:grid-cols-2">
{inPlay && preview.prediction ? (
<Card className="border-live/30">
<CardHeader className="flex items-center gap-2">
<span className="live-dot h-2 w-2 rounded-full bg-live" />
<span className="font-display font-bold text-ink">{t.match.liveWinProbability}</span>
</CardHeader>
<CardBody>
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
{timeline.length >= 2 && (
<div className="mt-4">
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
</div>
)}
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
</CardBody>
</Card>
) : modelCard}
{f.status === 'scheduled' && preview.scorerProps.length > 0 && (
<Card>
<CardHeader className="flex items-center gap-2">
<Goal size={16} className="text-accent" />
<span className="font-display font-bold text-ink">{t.match.whoScores.title}</span>
</CardHeader>
<CardBody>
<ul className="space-y-1.5">
{preview.scorerProps.map((p) => (
<li key={p.player} className="flex items-center gap-2 text-sm">
<PlayerLink name={p.player} className="truncate font-medium text-ink transition-colors hover:text-accent" />
<span className="tnum ml-auto shrink-0 rounded-full bg-elevated px-2 py-0.5 text-xs font-semibold text-ink-soft">
{p.odds > 0 ? `+${p.odds}` : p.odds}
</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-faint">{t.match.whoScores.note}</p>
</CardBody>
</Card>
)}
{preview.events.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.keyMoments}</span></CardHeader>
<CardBody>
{goals.length ? (
<div className="space-y-2.5">
{goals.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="tnum w-8 shrink-0 text-xs text-faint">{g.minute != null ? fmt(t.live.minute, { n: g.minute }) : ''}</span>
<span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${g.side === 'away' ? 'bg-info' : 'bg-accent'}`} />
<EventIcon kind="goal" />
<span className="truncate font-medium text-ink-soft">{g.title}</span>
{g.score && <span className="tnum ml-auto shrink-0 text-xs text-faint">{g.score[0]}{g.score[1]}</span>}
</div>
))}
</div>
) : (
<p className="text-sm text-faint">{t.match.noGoalsYet}</p>
)}
<button onClick={() => setActiveTab('timeline')} className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-accent hover:underline">
{t.match.viewTimeline} <ArrowRight size={14} />
</button>
</CardBody>
</Card>
)}
</div>
{kicks.length > 0 && (
<Card className="border-gold/40">
<CardHeader className="flex items-center gap-2">
<Goal size={16} className="text-gold" />
<span className="font-display font-bold text-ink">{t.match.shootoutTitle}</span>
</CardHeader>
<CardBody className="space-y-2">
{(['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 (
<div key={side} className="flex items-center gap-2.5 text-sm">
<span className="flex w-32 shrink-0 items-center gap-1.5 font-medium text-ink">
{team && <span aria-hidden>{teamFlag(team)}</span>}
<span className="truncate">{side === 'home' ? homeName : awayName}</span>
</span>
<span className="flex flex-wrap items-center gap-1">
{sideKicks.map((k, i) => (
<span
key={i}
title={k.player ?? ''}
className={`h-3 w-3 rounded-full ${k.scored ? 'bg-accent' : 'border-2 border-loss'}`}
/>
))}
</span>
<span className="tnum ml-auto shrink-0 font-bold text-ink">{sideKicks.filter((k) => k.scored).length}</span>
</div>
);
})}
<p className="text-xs text-faint">{t.match.shootoutNote}</p>
</CardBody>
</Card>
)}
{/* live matches keep the pre-match view alongside the in-play one */}
{inPlay && preview.prediction && modelCard}
{finished && timeline.length >= 2 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
<CardBody>
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
</CardBody>
</Card>
)}
</div>
)}
{/* ── Timeline ── */}
{tab === 'timeline' && preview.events.length > 0 && (
<Card>
<CardBody className="px-2 sm:px-4">
<MatchTimeline events={preview.events} commentary={preview.commentary} shots={rich?.shots ?? []} momentum={rich?.momentum ?? []} report={preview.report?.story ?? ''} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
</CardBody>
</Card>
)}
{/* ── Stats ── */}
{tab === 'stats' && (
<div className="grid items-start gap-4 md:grid-cols-2">
{preview.liveStats && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.statsTitle}</span></CardHeader>
<CardBody className="space-y-3">
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
))}
</CardBody>
</Card>
)}
{rich?.fullStats && rich.fullStats.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.fullStatsTitle}</span></CardHeader>
<CardBody>
<div className="space-y-2">
{rich.fullStats.map((r) => (
<div key={r.key} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-sm">
<span className="tnum font-semibold text-ink">{r.home}</span>
<span className="text-center text-xs text-muted">{fullStatLabels(t.match.fullStats)[r.key] ?? r.key}</span>
<span className="tnum text-right font-semibold text-ink">{r.away}</span>
</div>
))}
</div>
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
</CardBody>
</Card>
)}
{rich?.zones && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.zonesTitle}</span></CardHeader>
<CardBody className="space-y-3">
{([
['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 (
<div key={side}>
<div className="mb-1 text-sm font-medium text-ink">{name}</div>
<div className="flex h-3 overflow-hidden rounded-full bg-elevated">
<div className={tones[0]} style={{ width: `${(z.left / total) * 100}%` }} />
<div className={tones[1]} style={{ width: `${(z.center / total) * 100}%` }} />
<div className={tones[2]} style={{ width: `${(z.right / total) * 100}%` }} />
</div>
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{t.match.zones.left} {z.left}%</span>
<span>{t.match.zones.center} {z.center}%</span>
<span>{t.match.zones.right} {z.right}%</span>
</div>
</div>
);
})}
<p className="text-xs text-faint">{t.match.zonesNote}</p>
</CardBody>
</Card>
)}
{rich && rich.momentum.length >= 10 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
<CardBody>
<MomentumStrip points={rich.momentum} home={homeName} away={awayName} />
</CardBody>
</Card>
)}
{rich && rich.shots.length >= 3 && (
<>
<Card>
<CardHeader className="flex items-center justify-between">
<span className="font-display font-bold text-ink">{t.match.xgRace}</span>
{rich.xg && (
<span className="tnum text-sm font-bold">
<span className="text-accent">{rich.xg.home.toFixed(2)}</span>
<span className="font-normal text-faint"> </span>
<span className="text-info">{rich.xg.away.toFixed(2)}</span>
</span>
)}
</CardHeader>
<CardBody>
<XgRace shots={rich.shots} home={homeName} away={awayName} />
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.shotMap}</span></CardHeader>
<CardBody>
<ShotMap shots={rich.shots} home={homeName} away={awayName} />
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
</CardBody>
</Card>
</>
)}
</div>
)}
{/* ── Lineups ── */}
{tab === 'lineups' && (
<div className="space-y-4">
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0 ||
preview.injuries.home.length > 0 || preview.injuries.away.length > 0) && (
<Card className="border-loss/30">
<CardBody className="space-y-1.5 text-sm">
{([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const)
.filter(([, players]) => players.length > 0)
.map(([side, players]) => (
<div key={side} className="flex items-center gap-2 text-ink-soft">
<Ban size={14} className="shrink-0 text-loss" />
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
<span>{fmt(t.match.suspended, { players: players.join(', ') })}</span>
</div>
))}
{(['home', 'away'] as const)
.filter((side) => preview.injuries[side].length > 0)
.map((side) => (
<div key={`inj-${side}`} className="flex items-center gap-2 text-ink-soft">
<Stethoscope size={14} className="shrink-0 text-gold" />
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
<span>
{fmt(t.match.unavailable, {
players: preview.injuries[side].map((i) => (i.reason ? `${i.player} (${i.reason})` : i.player)).join(', '),
})}
</span>
</div>
))}
</CardBody>
</Card>
)}
{/* Before kickoff the pitch shows FotMob's projected XIs, clearly
labeled as predicted; from kickoff it's the confirmed lineup. */}
{rich?.lineup ? (
<Card>
<CardHeader className="flex items-center gap-2">
<Shirt size={16} className="text-accent" />
<span className="font-display font-bold text-ink">{hasScore || rich.lineupConfirmed ? t.match.lineups : t.match.predictedLineups}</span>
{rich.lineupConfirmed && !finished && (
<span className="ml-auto flex items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 text-[11px] font-semibold text-accent">
<BadgeCheck size={12} /> {t.match.confirmedLineups}
</span>
)}
</CardHeader>
<CardBody>
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} mode={isLive ? 'current' : 'starters'} />
<p className="mt-2 text-xs text-faint">{hasScore || rich.lineupConfirmed ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
</CardBody>
</Card>
) : (
<Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
<CardBody className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6">
{preview.lineups ? (
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
) : (
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
)}
</CardBody>
</Card>
)}
<div className="grid items-start gap-4 md:grid-cols-2">
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.home.team && teamFlag(f.home.team)}</span> <span className="truncate">{homeName}</span></span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.away.team && teamFlag(f.away.team)}</span> <span className="truncate">{awayName}</span></span><FormChips form={preview.form.away} /></div>
</CardBody>
</Card>
)}
{preview.h2h && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.headToHead}</span></CardHeader>
<CardBody>
<div className="mb-3 grid grid-cols-3 items-center text-center">
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: homeName })}</div></div>
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}</div></div>
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: awayName })}</div></div>
</div>
<div className="border-t border-line pt-2">
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
<ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between gap-2 text-ink-soft"><span className="shrink-0 text-faint">{m.date.slice(0, 10)}</span><span className="min-w-0 truncate text-right">{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
))}
</ul>
</div>
</CardBody>
</Card>
)}
</div>
</div>
)}
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
</div>
);
}