5951b2de65
- 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>
628 lines
33 KiB
TypeScript
628 lines
33 KiB
TypeScript
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>
|
||
);
|
||
}
|