Files
cup26/src/components/MatchCard.tsx
T
NilsBriggen 51dfe00216 v2 Phase 5: incredible UI pass — deep interlinking + Teams hub
- Match cards surface the model's pre-match expectation (favoured team + win%).
- Everything is now clickable: group-table + odds-table team rows → team
  profiles; bracket matches → match pages; match ↔ team cross-links.
- New /teams hub: all 48 nations ranked by title odds, linking to profiles
  (reachable from Groups + every team link; back-links point here).
- Predict → Methodology link ('How & how accurate'); cohesive hover/transition
  polish across cards.
- 26 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:24:46 +02:00

89 lines
3.3 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 { Link } from '@tanstack/react-router';
import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams';
import type { Fixture } from '@/lib/types';
import { kickoffTime, relativeKickoff } from '@/lib/format';
import { useTournamentStore } from '@/stores/tournamentStore';
import { TeamLabel } from './TeamLabel';
/** The model's pre-match lean for scheduled matches — the "expectation" hint. */
function ModelHint({ num }: { num: number }) {
const pred = useTournamentStore((s) => s.model?.matches.find((m) => m.num === num));
if (!pred) return null;
const { home, draw, away } = pred.probs;
const drawLikely = draw >= home && draw >= away;
const homeFav = home >= away;
const side = drawLikely ? null : homeFav ? pred.home : pred.away;
const p = drawLikely ? draw : Math.max(home, away);
return (
<div className="mt-1.5 text-center text-[11px] text-faint">
model: {side ? <>{teamFlag(side)} {side} </> : 'draw '}
<span className="font-semibold text-muted">{Math.round(p * 100)}%</span>
</div>
);
}
const STAGE_LABEL: Record<Fixture['stage'], string> = {
group: 'Group',
r32: 'Round of 32',
r16: 'Round of 16',
qf: 'Quarter-final',
sf: 'Semi-final',
third: 'Third place',
final: 'Final',
};
function tag(f: Fixture): string {
if (f.stage === 'group') return `Group ${f.group}`;
return STAGE_LABEL[f.stage];
}
function StatusPill({ f }: { f: Fixture }) {
if (f.status === 'live') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-live/40 bg-live/15 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-live">
<span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />
{f.minute ? `${f.minute}'` : 'Live'}
</span>
);
}
if (f.status === 'finished') {
return <span className="text-[11px] font-bold uppercase tracking-wide text-faint">FT</span>;
}
return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>;
}
export function MatchCard({ f }: { f: Fixture }) {
const hasScore = f.status === 'live' || f.status === 'finished';
return (
<Link
to="/match/$num"
params={{ num: String(f.num) }}
className={cn(
'block rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2',
f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
)}
>
<div className="mb-2 flex items-center justify-between">
<span className="smallcaps">{tag(f)}</span>
<StatusPill f={f} />
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamLabel slot={f.home} align="right" />
<div className="min-w-[64px] text-center">
{hasScore ? (
<div className="tnum text-xl font-bold text-ink">
{f.homeScore ?? 0}<span className="px-1.5 text-faint"></span>{f.awayScore ?? 0}
</div>
) : (
<div className="tnum text-sm font-semibold text-muted">{kickoffTime(f.kickoff)}</div>
)}
</div>
<TeamLabel slot={f.away} align="left" />
</div>
{f.status === 'scheduled' && <ModelHint num={f.num} />}
<div className="mt-1.5 text-center text-[11px] text-faint">{f.venue}</div>
</Link>
);
}