Files
cup26/src/features/team/TeamProfilePage.tsx
T
NilsBriggen 8e6c9416fa Favorite team: star a side, get its next match pinned on Live
A star on the team page (localStorage, no accounts) pins that team's
next or running match in a My team section at the top of the Live tab.
EN/DE strings + aria-pressed on the toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:59:15 +02:00

133 lines
6.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 { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Shield, Star } from 'lucide-react';
import { useFavoriteStore } from '@/stores/favoriteStore';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat, useT } from '@/lib/i18n';
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 { t, kickoffDay, kickoffTime } = useFormat();
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 ? 'win' : us < them ? 'loss' : 'draw') : 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 ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span>
<span className="text-faint">{isHome ? t.common.vs : t.team.awayIndicator}</span>
<span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</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 === 'win' ? 'bg-win/20 text-win' : res === 'loss' ? 'bg-loss/20 text-loss' : 'bg-draw/20 text-draw'}`}>{t.team.resultShort[res]}</span>}
</Link>
);
}
function FavoriteStar({ team }: { team: string }) {
const t = useT();
const favorite = useFavoriteStore((s) => s.favorite);
const toggle = useFavoriteStore((s) => s.toggleFavorite);
const active = favorite === team;
return (
<button
type="button"
onClick={() => toggle(team)}
aria-label={active ? t.team.unfollow : t.team.follow}
aria-pressed={active}
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-gold' : 'text-faint hover:text-ink'}`}
>
<Star size={20} fill={active ? 'currentColor' : 'none'} />
</button>
);
}
export function TeamProfilePage() {
const { name } = useParams({ strict: false }) as { name: string };
const t = useT();
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">{t.team.unknown} <Link to="/groups" className="text-accent">{t.team.allTeams}</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} /> {t.team.allTeams}
</Link>
<Card>
<CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
<div>
<h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
{profile.team}
<FavoriteStar team={profile.team} />
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
<Badge tone="muted">{fmt(t.team.eloBadge, { rating: profile.elo })}</Badge>
<Badge tone="gold">{fmt(t.team.titleOddsBadge, { pct: pct(profile.championOdds) })}</Badge>
<Badge tone="accent">{fmt(t.team.advanceOddsBadge, { pct: pct(profile.qualifyOdds) })}</Badge>
</div>
</div>
{s && (
<div className="ml-auto text-right text-sm">
<div className="text-faint text-xs">{t.team.groupStanding}</div>
<div className="tnum text-ink">{fmt(t.team.standingLine, { rank: s.rank, points: s.points, won: s.won, drawn: s.drawn, lost: 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">{t.team.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">{t.team.allTimeTopScorers}</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">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
</li>
))}
</ol>
) : <p className="text-sm text-faint">{t.common.noData}</p>}
</CardBody>
</Card>
</div>
</div>
);
}