German language option + a full wording-clarity pass
A tiny typed i18n layer (no library): en.ts is the single source of UI copy and the clarity rewrite lives there — shorter sentences, plain words, the dense Methodology/Scoreboard copy untangled. de.ts mirrors its shape exactly, enforced by the compiler, with placeholder parity covered by tests. Every page now reads from the dictionary via useT(); dates flip locale through useFormat() (en-GB/de-DE); raw server values (status, outcome, stage) map through total records. The header gains an EN/DE toggle (persisted, defaults from the browser language); <html lang> stays in sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,22 +5,11 @@ import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
import { FormChips } from '@/components/FormChips';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { kickoffDay, kickoffTime } from '@/lib/format';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import type { Fixture, KeyPlayer, MatchPreview } from '@/lib/types';
|
||||
|
||||
const STAGE: Record<string, string> = {
|
||||
group: 'Group', r32: 'Round of 32', r16: 'Round of 16', qf: 'Quarter-final', sf: 'Semi-final', third: 'Third place', final: 'Final',
|
||||
};
|
||||
const STAT_ROWS: [string, string][] = [
|
||||
['possessionPct', 'Possession'],
|
||||
['totalShots', 'Shots'],
|
||||
['shotsOnTarget', 'On target'],
|
||||
['wonCorners', 'Corners'],
|
||||
['foulsCommitted', 'Fouls'],
|
||||
['saves', 'Saves'],
|
||||
];
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
|
||||
|
||||
function eventIcon(type: string): string {
|
||||
@@ -50,6 +39,7 @@ function LiveStatRow({ label, home, away }: { label: string; home: number; away:
|
||||
}
|
||||
|
||||
function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink"><span aria-hidden>{teamFlag(team)}</span> {team}</div>
|
||||
@@ -58,11 +48,11 @@ function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
|
||||
{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>
|
||||
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${p.pens}p)` : ''}</span>
|
||||
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${fmt(t.common.pensShort, { n: p.pens })})` : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-xs text-faint">no data</p>}
|
||||
) : <p className="text-xs text-faint">{t.common.noData}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +78,7 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
|
||||
|
||||
export function MatchPreviewPage() {
|
||||
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
@@ -117,7 +108,16 @@ export function MatchPreviewPage() {
|
||||
return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore);
|
||||
}, [preview, f]);
|
||||
|
||||
if (failed) return <div className="py-16 text-center text-muted">Couldn't load that match. <Link to="/" className="text-accent">Back to Live</Link></div>;
|
||||
const statRows = useMemo<[string, string][]>(() => [
|
||||
['possessionPct', t.match.stats.possessionPct],
|
||||
['totalShots', t.match.stats.totalShots],
|
||||
['shotsOnTarget', t.match.stats.shotsOnTarget],
|
||||
['wonCorners', t.match.stats.wonCorners],
|
||||
['foulsCommitted', t.match.stats.foulsCommitted],
|
||||
['saves', t.match.stats.saves],
|
||||
], [t]);
|
||||
|
||||
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" />;
|
||||
|
||||
const hasScore = f.status === 'live' || f.status === 'finished';
|
||||
@@ -127,13 +127,13 @@ export function MatchPreviewPage() {
|
||||
|
||||
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} /> Back</Link>
|
||||
<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 ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
|
||||
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {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">
|
||||
@@ -142,8 +142,8 @@ export function MatchPreviewPage() {
|
||||
</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 ? `${f.minute}'` : 'Live'}</div>}
|
||||
{finished && <div className="text-[11px] font-bold uppercase text-faint">Full time</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>}
|
||||
</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="text-4xl">{f.away.team ? teamFlag(f.away.team) : '⚽'}</span>
|
||||
@@ -158,11 +158,11 @@ export function MatchPreviewPage() {
|
||||
<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">Live win probability</span>
|
||||
<span className="font-display font-bold text-ink">{t.match.liveWinProbability}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
||||
<p className="mt-2 text-xs text-faint">Updates with the score and clock — remaining goals modelled from each side's pre-match expectation.</p>
|
||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
@@ -170,9 +170,9 @@ export function MatchPreviewPage() {
|
||||
{/* LIVE/FT: stats */}
|
||||
{preview.liveStats && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Match stats</span></CardHeader>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.statsTitle}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{STAT_ROWS.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
|
||||
{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>
|
||||
@@ -182,12 +182,12 @@ export function MatchPreviewPage() {
|
||||
{/* LIVE/FT: timeline */}
|
||||
{preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Timeline</span></CardHeader>
|
||||
<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 ? `${e.minute}'` : ''}</span>
|
||||
<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>{eventIcon(e.type)}</span>
|
||||
<span className="truncate text-ink-soft">{e.text || e.type}</span>
|
||||
</li>
|
||||
@@ -200,10 +200,10 @@ export function MatchPreviewPage() {
|
||||
{/* model expectation */}
|
||||
{preview.prediction && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? 'Pre-match model' : 'Model expectation'}</span></CardHeader>
|
||||
<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">Expected goals: {preview.prediction.lambdaHome.toFixed(2)} – {preview.prediction.lambdaAway.toFixed(2)} · model odds, not betting advice</p>
|
||||
<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>
|
||||
)}
|
||||
@@ -211,7 +211,7 @@ export function MatchPreviewPage() {
|
||||
{/* form */}
|
||||
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Recent form</span></CardHeader>
|
||||
<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>
|
||||
@@ -222,15 +222,15 @@ export function MatchPreviewPage() {
|
||||
{/* head to head */}
|
||||
{preview.h2h && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Head to head</span></CardHeader>
|
||||
<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">{homeName} wins</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">draws · {preview.h2h.games} total</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{awayName} wins</div></div>
|
||||
<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">Recent meetings</div>
|
||||
<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>
|
||||
@@ -243,7 +243,7 @@ export function MatchPreviewPage() {
|
||||
|
||||
{/* 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 ? 'Lineups' : 'Key players (all-time scorers)'}</span></CardHeader>
|
||||
<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} /></>
|
||||
|
||||
Reference in New Issue
Block a user