Match center redesign: center-spine timeline + hero scoreboard + tabbed layout
Implements steps 1-3 of the Claude Design handoff: - MatchTimeline: vertical center spine (home left, away right, minute pills on the rail), goal cards with parsed scorer/assist/running score, sub and booking chips, synthetic kick-off/half-time/full-time dividers; single left rail under the sm breakpoint - matchEvents: pure ESPN commentary parser (scorer, assist, penalty, own goal, sub in/out, embedded score) with tests on real feed shapes - Hero scoreboard: panel gradient + accent glow, smallcaps meta, mono score, stat chips (xG / POTM / attendance / referee; possession and shots while live); weather line stays for upcoming matches - Sticky Summary/Timeline/Stats/Lineups sub-nav; all existing cards re-bucketed with no feature loss (in-play win prob, swing chart, xG race, shot map, momentum, odds movement, form, H2H, lineups) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Shield, Shirt } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
@@ -10,24 +10,17 @@ 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 { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { goalEvents } from '@/lib/matchEvents';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
|
||||
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
|
||||
|
||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
||||
* color chips for bookings, arrows for substitutions. */
|
||||
function EventIcon({ type }: { type: string }) {
|
||||
const t = type.toLowerCase();
|
||||
if (t.includes('goal') || t.includes('penalty - scored')) return <Volleyball size={14} className="shrink-0 text-accent" />;
|
||||
if (t.includes('yellow')) return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-amber-400" />;
|
||||
if (t.includes('red')) return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-red-500" />;
|
||||
if (t.includes('substitution')) return <ArrowRightLeft size={13} className="shrink-0 text-muted" />;
|
||||
return <CircleDot size={10} className="shrink-0 text-faint" />;
|
||||
}
|
||||
type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups';
|
||||
|
||||
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
|
||||
const total = home + away || 1;
|
||||
@@ -84,6 +77,16 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
|
||||
);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -92,6 +95,7 @@ export function MatchPreviewPage() {
|
||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||
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.
|
||||
@@ -104,6 +108,7 @@ export function MatchPreviewPage() {
|
||||
setTimeline([]);
|
||||
setRich(null);
|
||||
setFailed(false);
|
||||
setActiveTab('summary');
|
||||
const load = () =>
|
||||
fetch(`/api/preview/${numParam}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
@@ -153,8 +158,10 @@ export function MatchPreviewPage() {
|
||||
['saves', t.match.stats.saves],
|
||||
], [t]);
|
||||
|
||||
const goals = useMemo(() => (preview ? goalEvents(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-xl border border-line bg-panel" />;
|
||||
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;
|
||||
@@ -164,43 +171,94 @@ export function MatchPreviewPage() {
|
||||
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
|
||||
|
||||
// Hero extras from the rich capture: kickoff-hour weather (pre-match only)
|
||||
// and the official stadium/attendance/referee line.
|
||||
// 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
|
||||
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 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.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">{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}</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 */}
|
||||
<Card className={isLive ? 'border-live/40 ring-1 ring-live/30' : ''}>
|
||||
<CardBody>
|
||||
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
|
||||
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
|
||||
{/* 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>{preview.venue}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
|
||||
<span aria-hidden className="grid h-10 place-items-center text-4xl">{f.home.team ? teamFlag(f.home.team) : <Shield size={32} className="text-faint" />}</span>
|
||||
<span className="text-center text-sm font-semibold text-ink">{homeName}</span>
|
||||
<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-3xl font-extrabold text-ink">{f.homeScore ?? 0}–{f.awayScore ?? 0}</div> : <div className="tnum text-lg font-bold text-muted">{kickoffTime(f.kickoff)}</div>}
|
||||
{isLive && <div className="inline-flex items-center gap-1 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="text-[11px] font-bold uppercase text-faint">{t.common.fullTime}</div>}
|
||||
{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 hover:opacity-80">
|
||||
<span aria-hidden className="grid h-10 place-items-center text-4xl">{f.away.team ? teamFlag(f.away.team) : <Shield size={32} className="text-faint" />}</span>
|
||||
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
|
||||
<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) && (
|
||||
<div className="mt-3 space-y-1 border-t border-line pt-2 text-center text-xs text-muted">
|
||||
<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>
|
||||
@@ -210,180 +268,199 @@ export function MatchPreviewPage() {
|
||||
{infoText && <div>{infoText}</div>}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LIVE: in-play win probability */}
|
||||
{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">
|
||||
{/* 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}
|
||||
{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>
|
||||
{/* 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} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* FT: how the win probability swung over the match */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: cumulative xG race + shot map from the captured shot list */}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: attack momentum */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: stats */}
|
||||
{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])} />
|
||||
))}
|
||||
{rich?.potm && (
|
||||
<div className="flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-xs uppercase tracking-wide text-faint">{t.match.potm}</span>
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
{rich.potm.name}
|
||||
{rich.potm.rating != null && <Badge tone="gold">{rich.potm.rating}</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: timeline */}
|
||||
{preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.timeline}</span></CardHeader>
|
||||
<CardBody>
|
||||
<ul className="space-y-1.5">
|
||||
{preview.events.map((e, i) => (
|
||||
<li key={i} className={`flex items-center gap-2 text-sm ${e.team === 'away' ? 'flex-row-reverse text-right' : ''}`}>
|
||||
<span className="tnum w-8 shrink-0 text-xs text-faint">{e.minute != null ? fmt(t.live.minute, { n: e.minute }) : ''}</span>
|
||||
<span aria-hidden className="grid w-4 shrink-0 place-items-center"><EventIcon type={e.type} /></span>
|
||||
<span className="truncate text-ink-soft">{e.text || e.type}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* model expectation */}
|
||||
{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">{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* bookmaker line movement vs the model (benchmark only) */}
|
||||
{preview.prediction && hasOddsChart && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* form */}
|
||||
{(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"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* head to head */}
|
||||
{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 text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}–{m.awayScore}</span> {m.away}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* lineups or key players */}
|
||||
<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-2 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>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{preview.prediction && hasOddsChart && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Timeline ── */}
|
||||
{tab === 'timeline' && preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-2 sm:px-4">
|
||||
<MatchTimeline events={preview.events} 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 && 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">
|
||||
<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-2 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"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</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 text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{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>
|
||||
|
||||
Reference in New Issue
Block a user