Players become first-class: profile pages, unified names, deeper match data
- /player/:name pages assembled entirely from stored data: tournament totals, per-match log (rating, minutes, G/A, cards), aggregated shot map, suspension status, next-match scorer line. Either source's spelling resolves to the same page (token-set + romanization fold). - Player names are now links everywhere: stats boards, golden boot, discipline, injuries, all-time scorers, lineups, the pitch dots, timeline events and assists, and the search palette (diacritic-folded). - Golden Boot displays the FotMob form of each name — no more "Hwang In-Beom" and "In-Beom Hwang" on the same page. - Match pages: detailed full-time stats card (passes, duels, xGOT… from the stored FotMob report), O/U + spread closing line, "Who scores?" pre-match props board, and a FIFA-confirmed-lineups badge. - Stats hub: new boards — xA, chances created, save percentage, saves per 90. 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, ArrowRight, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, BadgeCheck, Ban, Clapperboard, 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';
|
||||
@@ -18,10 +18,14 @@ 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, OddsPoint } 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 }) {
|
||||
@@ -50,7 +54,7 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
|
||||
<ul className="space-y-1">
|
||||
{players.slice(0, 6).map((p) => (
|
||||
<li key={p.name} className="flex items-center justify-between text-sm">
|
||||
<span className="truncate text-ink-soft">{p.name}</span>
|
||||
<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>
|
||||
))}
|
||||
@@ -70,7 +74,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
|
||||
{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>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<PlayerLink name={p.name} className="truncate transition-colors hover:text-accent" />
|
||||
<span className="ml-auto text-[11px] text-faint">{p.position}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -173,6 +177,10 @@ export function MatchPreviewPage() {
|
||||
const finished = f.status === 'finished';
|
||||
const koT = new Date(f.kickoff).getTime();
|
||||
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
|
||||
// Latest pre-kickoff totals/handicap line (display only, like all odds here).
|
||||
const closingLine = oddsHistory
|
||||
.filter((o) => o.captured_at <= koT && (o.over_under != null || o.spread != null))
|
||||
.sort((a, b) => b.captured_at - a.captured_at)[0] ?? null;
|
||||
|
||||
// Hero extras from the rich capture: kickoff-hour weather (pre-match only)
|
||||
// and post-match official chips (attendance, referee, player of the match).
|
||||
@@ -207,7 +215,7 @@ export function MatchPreviewPage() {
|
||||
// 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');
|
||||
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
tabs.push('lineups');
|
||||
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
|
||||
|
||||
@@ -322,6 +330,27 @@ export function MatchPreviewPage() {
|
||||
</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>
|
||||
@@ -392,11 +421,19 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.prediction && hasOddsChart && (
|
||||
{preview.prediction && (hasOddsChart || closingLine) && (
|
||||
<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} />
|
||||
{hasOddsChart && <OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />}
|
||||
{closingLine && (
|
||||
<p className={`text-xs text-faint${hasOddsChart ? ' mt-2' : ''}`}>
|
||||
{[
|
||||
closingLine.over_under != null ? fmt(t.match.oddsExtras.ou, { n: closingLine.over_under }) : null,
|
||||
closingLine.spread != null ? fmt(t.match.oddsExtras.spread, { n: `${closingLine.spread > 0 ? '+' : ''}${closingLine.spread}` }) : null,
|
||||
].filter(Boolean).join(' · ')} · {closingLine.provider}
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -457,6 +494,23 @@ export function MatchPreviewPage() {
|
||||
</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 && rich.momentum.length >= 10 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||
@@ -530,10 +584,18 @@ export function MatchPreviewPage() {
|
||||
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 ? t.match.lineups : t.match.predictedLineups}</span></CardHeader>
|
||||
<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 ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
|
||||
<p className="mt-2 text-xs text-faint">{hasScore || rich.lineupConfirmed ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user