Files
cup26/src/features/team/TeamProfilePage.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

109 lines
5.1 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, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import type { Fixture, TeamProfile } from '@/lib/types';
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
function FixtureRow({ f, team }: { f: Fixture; team: string }) {
const isHome = f.home.team === team;
const opp = isHome ? f.away : f.home;
const done = f.status === 'finished' || f.status === 'live';
const us = isHome ? f.homeScore : f.awayScore;
const them = isHome ? f.awayScore : f.homeScore;
const res = done && us != null && them != null ? (us > them ? 'W' : us < them ? 'L' : 'D') : null;
return (
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
<span className="w-10 text-xs text-faint">{f.group ? `Grp ${f.group}` : f.round.replace('Round of', 'R')}</span>
<span className="text-faint">{isHome ? 'vs' : '@'}</span>
<span aria-hidden>{opp.team ? teamFlag(opp.team) : '⚽'}</span>
<span className="truncate text-ink">{opp.label}</span>
<span className="ml-auto tnum text-faint">
{done && us != null ? <span className="text-ink">{us}{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</span>
{res && <span className={`grid h-5 w-5 place-items-center rounded text-[11px] font-bold ${res === 'W' ? 'bg-win/20 text-win' : res === 'L' ? 'bg-loss/20 text-loss' : 'bg-draw/20 text-draw'}`}>{res}</span>}
</Link>
);
}
export function TeamProfilePage() {
const { name } = useParams({ strict: false }) as { name: string };
const [profile, setProfile] = useState<TeamProfile | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setProfile(null);
setFailed(false);
fetch(`/api/team/${encodeURIComponent(name)}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: TeamProfile) => alive && setProfile(d))
.catch(() => alive && setFailed(true));
return () => { alive = false; };
}, [name]);
if (failed) return <div className="py-16 text-center text-muted">Unknown team. <Link to="/groups" className="text-accent">All teams</Link></div>;
if (!profile) return <div className="h-80 animate-pulse rounded-xl border border-line bg-panel" />;
const s = profile.standing;
return (
<div className="space-y-5">
<Link to="/teams" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft size={15} /> All teams
</Link>
<Card>
<CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
<div>
<h1 className="font-display text-2xl font-extrabold text-ink">{profile.team}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
{profile.group && <Badge tone="neutral">Group {profile.group}</Badge>}
<Badge tone="muted">Elo {profile.elo}</Badge>
<Badge tone="gold">Title {pct(profile.championOdds)}</Badge>
<Badge tone="accent">Advance {pct(profile.qualifyOdds)}</Badge>
</div>
</div>
{s && (
<div className="ml-auto text-right text-sm">
<div className="text-faint text-xs">Group standing</div>
<div className="tnum text-ink">#{s.rank} · {s.points} pts · {s.won}-{s.drawn}-{s.lost} · GD {s.gd > 0 ? `+${s.gd}` : s.gd}</div>
</div>
)}
</CardBody>
</Card>
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
<Card>
<CardHeader><span className="font-display font-bold text-ink">Fixtures</span></CardHeader>
<CardBody className="space-y-0.5">
{profile.fixtures.map((f) => <FixtureRow key={f.num} f={f} team={profile.team} />)}
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">All-time top scorers</span></CardHeader>
<CardBody>
{profile.keyPlayers.length ? (
<ol className="space-y-1.5">
{profile.keyPlayers.map((p, i) => (
<li key={p.name} className="flex items-center gap-2 text-sm">
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
{p.pens ? <span className="text-[11px] text-faint">{p.pens}p</span> : null}
</li>
))}
</ol>
) : <p className="text-sm text-faint">No data.</p>}
</CardBody>
</Card>
</div>
</div>
);
}