diff --git a/app/[locale]/components/Bracket.tsx b/app/[locale]/components/Bracket.tsx index c4c7dd6..24bd583 100644 --- a/app/[locale]/components/Bracket.tsx +++ b/app/[locale]/components/Bracket.tsx @@ -1,18 +1,19 @@ "use client"; import { useMemo, useState, useCallback } from "react"; -import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types"; +import { GroupTable, Match, Team, ThirdPlaceRow, teamName } from "@/lib/types"; import { ThirdAssignment, orderedRound } from "@/lib/bracket"; import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket"; +import { Dictionary } from "@/lib/i18n"; import { STADIUMS, MATCH_STADIUMS, MATCH_DATES } from "@/lib/stadiums"; import Flag from "./Flag"; interface TipState { x: number; y: number; text: string } function Side({ - s, prob, teams, onShowTip, onHideTip, + s, prob, teams, locale, dict, onShowTip, onHideTip, }: { - s: ResolvedSide; prob?: number | null; teams: Team[]; + s: ResolvedSide; prob?: number | null; teams: Team[]; locale: string; dict: Dictionary; onShowTip: (text: string, x: number, y: number) => void; onHideTip: () => void; }) { @@ -33,8 +34,8 @@ function Side({ {s.teamId ? ( <> - {s.label} - {s.provisional && } + {teamName(team, locale)} + {s.provisional && } ) : ( {s.label} @@ -48,11 +49,12 @@ function Side({ ); } -function fmtMatchInfo(utcDate?: string, stadiumId?: string): string { +function fmtMatchInfo(utcDate?: string, stadiumId?: string, locale: string = "de"): string { if (!utcDate) return ""; + const intlLocale = locale === "en" ? "en-US" : "de-DE"; const d = new Date(utcDate); - const dateStr = d.toLocaleDateString("de-DE", { day: "numeric", month: "numeric" }); - const timeStr = d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + const dateStr = d.toLocaleDateString(intlLocale, { day: "numeric", month: "numeric" }); + const timeStr = d.toLocaleTimeString(intlLocale, { hour: "2-digit", minute: "2-digit" }); const city = stadiumId ? STADIUMS[stadiumId]?.city : ""; const parts = [`${dateStr}`, `${timeStr}`]; if (city) parts.push(city); @@ -60,10 +62,11 @@ function fmtMatchInfo(utcDate?: string, stadiumId?: string): string { } function Tie({ - tie, teams, isFinal, matchInfo, onShowTip, onHideTip, + tie, teams, isFinal, matchInfo, locale, dict, onShowTip, onHideTip, }: { tie: ResolvedTie; teams: Team[]; isFinal?: boolean; matchInfo?: string; + locale: string; dict: Dictionary; onShowTip: (text: string, x: number, y: number) => void; onHideTip: () => void; }) { @@ -71,13 +74,13 @@ function Tie({
{tie.matchNumber}
- {matchInfo || `Spiel ${tie.matchNumber}`} + {matchInfo || dict.bracket.match(tie.matchNumber)}
- - + + {tie.homePenalty != null && tie.awayPenalty != null && (
- ({tie.homePenalty}:{tie.awayPenalty} i.E.) + ({tie.homePenalty}:{tie.awayPenalty} {dict.bracket.penaltyShootout})
)}
@@ -86,14 +89,15 @@ function Tie({ export default function Bracket({ matches, teams, tables, thirds, assignment, annexResolved, - preResolved, + preResolved, dict, locale, }: { matches: Match[]; teams: Team[]; tables: GroupTable[]; thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean; preResolved?: { r32: ResolvedTie[]; later: Record }; + dict: Dictionary; locale: string; }) { const { r32: r32Array, later } = preResolved ?? resolveBracket( - matches, teams, tables, thirds, assignment, annexResolved, + matches, teams, tables, thirds, assignment, annexResolved, undefined, dict, ); const r32ByNum = new Map(r32Array.map((t) => [t.matchNumber, t])); @@ -104,11 +108,11 @@ export default function Bracket({ for (let n = 73; n <= 104; n++) { const utcDate = MATCH_DATES[n]; const stadiumId = MATCH_STADIUMS[n]; - const info = fmtMatchInfo(utcDate, stadiumId); + const info = fmtMatchInfo(utcDate, stadiumId, locale); if (info) meta.set(n, info); } return meta; - }, []); + }, [locale]); const sfOrder = orderedRound([104]); const qfOrder = orderedRound(sfOrder); @@ -129,48 +133,48 @@ export default function Bracket({ return (
- Annex-C-Zuordnung der Drittplatzierten: + {dict.bracket.annexHeader} {annexResolved - ? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt" - : "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"} + ? dict.bracket.annexResolved + : dict.bracket.annexPending}
-
Letzte 32
+
{dict.round.r32}
- {r32.map((t) => )} + {r32.map((t) => )}
-
Achtelfinale
+
{dict.round.r16}
- {r16.map((t) => )} + {r16.map((t) => )}
-
Viertelfinale
+
{dict.round.qf}
- {qf.map((t) => )} + {qf.map((t) => )}
-
Halbfinale
+
{dict.round.sf}
- {sf.map((t) => )} + {sf.map((t) => )}
-
Finale
+
{dict.round.final}
- {fin && } + {fin && } {third && (
-
Spiel um Platz 3
- +
{dict.round.thirdPlace}
+
)}
@@ -179,12 +183,12 @@ export default function Bracket({
- Sieger / weiter - Finale - fix qualifiziert - vorläufig (≈, nach aktueller Tabelle) - Platzhalter offen - %-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar) + {dict.legend.winner} + {dict.legend.final} + {dict.legend.fixed} + {dict.legend.provisional} + {dict.legend.placeholder} + {dict.legend.probExplanation}
{tip && ( diff --git a/app/[locale]/components/Fixtures.tsx b/app/[locale]/components/Fixtures.tsx index 96f52b9..92cccc7 100644 --- a/app/[locale]/components/Fixtures.tsx +++ b/app/[locale]/components/Fixtures.tsx @@ -1,53 +1,54 @@ "use client"; -import { GROUP_IDS, GroupId, Match, Team } from "@/lib/types"; +import { GROUP_IDS, GroupId, Match, Team, teamName } from "@/lib/types"; import { computeGroupTables } from "@/lib/standings"; +import { Dictionary } from "@/lib/i18n"; import Flag from "./Flag"; function teamById(teams: Team[], id: string | null) { return id ? teams.find((t) => t.id === id) : undefined; } -// Kürzel der lokalen Browser-Zeitzone, z.B. "MEZ"/"GMT+1" – einmal ermittelt. -const TZ_LABEL = (() => { +function tzLabel(locale: string): string { try { - const parts = new Intl.DateTimeFormat("de-DE", { timeZoneName: "short" }) + const parts = new Intl.DateTimeFormat(locale === "en" ? "en-US" : "de-DE", { timeZoneName: "short" }) .formatToParts(new Date()); return parts.find((p) => p.type === "timeZoneName")?.value ?? ""; } catch { return ""; } -})(); +} -function fmtDate(iso: string): string { +function fmtDate(iso: string, locale: string): string { const d = new Date(iso); - const s = d.toLocaleString("de-DE", { + const s = d.toLocaleString(locale === "en" ? "en-US" : "de-DE", { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); - return TZ_LABEL ? `${s} ${TZ_LABEL}` : s; + const tz = tzLabel(locale); + return tz ? `${s} ${tz}` : s; } -function statusText(m: Match): { text: string; live: boolean } { +function statusText(m: Match, dict: Dictionary): { text: string; live: boolean } { switch (m.status) { case "LIVE": case "IN_PLAY": - return { text: m.minute != null ? `${m.minute}'` : "läuft", live: true }; + return { text: m.minute != null ? `${m.minute}'` : dict.status.running, live: true }; case "PAUSED": - return { text: "Halbzeit", live: true }; + return { text: dict.status.halftime, live: true }; case "FINISHED": - return { text: "Beendet", live: false }; + return { text: dict.status.finished, live: false }; default: - return { text: fmtDate(m.utcDate), live: false }; + return { text: "", live: false }; } } // Mitte: Ergebnis (wenn vorhanden) oder ein schlichtes "–" bei ungespielten // Spielen. Datum/Uhrzeit steht bereits links im Status und wird hier NICHT // wiederholt. -function ScoreCell({ m }: { m: Match }) { +function ScoreCell({ m, dict }: { m: Match; dict: Dictionary }) { const hasScore = m.homeScore != null && m.awayScore != null; - const st = statusText(m); + const st = statusText(m, dict); if (hasScore) { return ( @@ -59,8 +60,8 @@ function ScoreCell({ m }: { m: Match }) { } function GroupStandings({ - group, teams, matches, -}: { group: GroupId; teams: Team[]; matches: Match[] }) { + group, teams, matches, dict, +}: { group: GroupId; teams: Team[]; matches: Match[]; dict: Dictionary }) { // Live-Tabelle: laufende Spiele werden mit Zwischenstand eingerechnet. const table = computeGroupTables(teams, matches, true).find((t) => t.group === group); if (!table) return null; @@ -80,15 +81,15 @@ function GroupStandings({ return (
- Tabelle – Gruppe {group} - {hasLive && ● LIVE} + {dict.fixtures.standingsTitle(group)} + {hasLive && {dict.label.liveIndicator}}
- - - + + + @@ -101,8 +102,8 @@ function GroupStandings({ @@ -121,11 +122,13 @@ function GroupStandings({ } export default function Fixtures({ - group, teams, matches, onSelectGroup, + group, teams, matches, onSelectGroup, dict, locale, }: { group: GroupId; teams: Team[]; matches: Match[]; onSelectGroup: (g: GroupId) => void; + dict: Dictionary; locale: string; }) { + const intlLocale = locale === "en" ? "en-US" : "de-DE"; const list = matches .filter((m) => m.group === group) .sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate)); @@ -145,36 +148,37 @@ export default function Fixtures({ {list.length === 0 ? ( -
Für Gruppe {group} liegen noch keine Spiele im Feed vor.
+
{dict.fixtures.noMatches(group)}
) : (
{list.map((m) => { const home = teamById(teams, m.homeTeamId); const away = teamById(teams, m.awayTeamId); - const st = statusText(m); + const st = statusText(m, dict); + const displayText = st.text || fmtDate(m.utcDate, locale); const homeWin = m.homeScore != null && m.awayScore != null && m.homeScore > m.awayScore; const awayWin = m.homeScore != null && m.awayScore != null && m.awayScore > m.homeScore; return (
{st.live && } - {st.text} + {displayText}
- {home?.localisedName ?? home?.name ?? "—"} + {teamName(home, locale)}
- +
- {away?.localisedName ?? away?.name ?? "—"} + {teamName(away, locale)}
{m.venue && 📍 {m.venue}} {m.attendance != null && ( - 👥 {m.attendance.toLocaleString("de-DE")} + 👥 {m.attendance.toLocaleString(intlLocale)} )}
@@ -184,7 +188,7 @@ export default function Fixtures({ )} {list.length > 0 && ( - + )}
); diff --git a/app/[locale]/components/Groups.tsx b/app/[locale]/components/Groups.tsx index 5a5ccfb..35080d1 100644 --- a/app/[locale]/components/Groups.tsx +++ b/app/[locale]/components/Groups.tsx @@ -1,6 +1,7 @@ "use client"; -import { GroupId, GroupTable, Match, Team } from "@/lib/types"; +import { GroupId, GroupTable, Match, Team, teamName } from "@/lib/types"; +import { Dictionary } from "@/lib/i18n"; import Flag from "./Flag"; function teamById(teams: Team[], id: string) { @@ -26,10 +27,11 @@ function liveTeamIdsFor(group: GroupId, matches: Match[]): Set { } export default function Groups({ - tables, teams, matches, onOpenGroup, + tables, teams, matches, onOpenGroup, dict, locale, }: { tables: GroupTable[]; teams: Team[]; matches: Match[]; onOpenGroup: (g: GroupId) => void; + dict: Dictionary; locale: string; }) { return (
@@ -43,19 +45,19 @@ export default function Groups({
MannschaftSpSUNTore±Pkt{dict.table.team}{dict.table.matches}{dict.table.won}{dict.table.drawn}{dict.table.lost}{dict.table.goals}{dict.table.goalDiff}{dict.table.points}
{r.rank} - {team?.localisedName ?? team?.name ?? r.teamId} - {isLive && } + {teamName(team, "de")} + {isLive && } {r.played} {r.won}
- - - + + + @@ -68,8 +70,8 @@ export default function Groups({ diff --git a/app/[locale]/components/KoFixtures.tsx b/app/[locale]/components/KoFixtures.tsx index 6ce3d1d..94cb4ab 100644 --- a/app/[locale]/components/KoFixtures.tsx +++ b/app/[locale]/components/KoFixtures.tsx @@ -1,47 +1,48 @@ "use client"; import { useMemo, useState, useEffect, useRef } from "react"; -import { Match, Team } from "@/lib/types"; +import { Match, Team, teamName } from "@/lib/types"; import { STADIUMS, MATCH_STADIUMS } from "@/lib/stadiums"; +import { Dictionary } from "@/lib/i18n"; import Flag from "./Flag"; -function fmtShortDate(iso: string): string { +function fmtShortDate(iso: string, locale: string): string { const d = new Date(iso); - return d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); + return d.toLocaleDateString(locale === "en" ? "en-US" : "de-DE", { weekday: "short", day: "2-digit", month: "2-digit" }); } -function fmtTime(iso: string): string { +function fmtTime(iso: string, locale: string): string { const d = new Date(iso); - return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + return d.toLocaleTimeString(locale === "en" ? "en-US" : "de-DE", { hour: "2-digit", minute: "2-digit" }); } -function scoreDisplay(m: Match): string { +function scoreDisplay(m: Match, dict: Dictionary): string { if (m.status === "SCHEDULED" || m.status === "POSTPONED" || (m.homeScore == null && m.awayScore == null)) return "– : –"; const base = `${m.homeScore ?? 0} : ${m.awayScore ?? 0}`; if (m.homePenalty != null && m.awayPenalty != null) { - return `${base} (${m.homePenalty}:${m.awayPenalty} i.E.)`; + return `${base} (${m.homePenalty}:${m.awayPenalty} ${dict.bracket.penaltyShootout})`; } return base; } -function statusLabel(m: Match): string { - switch (m.status) { - case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : "Live"; - case "PAUSED": return "Halbzeit"; - case "POSTPONED": return "Verzögert"; - case "FINISHED": return "Beendet"; - default: return fmtTime(m.utcDate); - } -} - function isLive(m: Match): boolean { return m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED"; } -export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: Match[] }) { +export default function KoFixtures({ teams, matches, dict, locale }: { teams: Team[]; matches: Match[]; dict: Dictionary; locale: string }) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); + function statusLabel(m: Match): string { + switch (m.status) { + case "LIVE": case "IN_PLAY": return m.minute != null ? `${m.minute}'` : dict.status.live; + case "PAUSED": return dict.status.halftime; + case "POSTPONED": return dict.status.postponed; + case "FINISHED": return dict.status.finished; + default: return fmtTime(m.utcDate, locale); + } + } + const koMatches = useMemo(() => { return matches .filter((m) => @@ -107,7 +108,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: }, []); if (koMatches.length === 0) { - return
Keine K.o.-Spiele mit bekannten Teams verfügbar.
; + return
{dict.koFixtures.noMatches}
; } return ( @@ -120,7 +121,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--ink-dim)", margin: "0 0 10px", }}> - {fmtShortDate(groupMatches[0].utcDate)} + {fmtShortDate(groupMatches[0].utcDate, locale)}
{groupMatches.map((m) => { @@ -128,8 +129,8 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: const away = m.awayTeamId ? teams.find(t => t.id === m.awayTeamId) : undefined; const live = isLive(m); const stageMap: Record = { - R32: "R32", R16: "Achtelfinale", QF: "Viertelfinale", - SF: "Halbfinale", "3RD": "Platz 3", FINAL: "Finale", + R32: "R32", R16: dict.stage.r16, QF: dict.stage.qf, + SF: dict.stage.sf, "3RD": dict.stage.thirdPlace, FINAL: dict.stage.final, }; const city = STADIUMS[MATCH_STADIUMS[m.matchNumber]]?.city; @@ -162,7 +163,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: padding: "1px 6px", borderRadius: 999, letterSpacing: "0.04em", }}> - {live ? "LIVE" : "NÄCHSTES SPIEL"} + {live ? dict.koFixtures.liveBadge : dict.koFixtures.nextMatch} )} {m.status === "POSTPONED" && ( @@ -172,10 +173,10 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: padding: "1px 6px", borderRadius: 999, letterSpacing: "0.04em", }}> - VERZÖGERT + {dict.koFixtures.postponedBadge} )} - {stageMap[m.stage] ?? m.stage} · Spiel {m.matchNumber || "—"} + {stageMap[m.stage] ?? m.stage} · {m.matchNumber ? dict.koFixtures.match(m.matchNumber) : dict.koFixtures.match(0).replace("0", "—")} {city ? ` · ${city}` : ""} @@ -185,15 +186,15 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: {/* Teams + Score */}
- + - {scoreDisplay(m)} + {scoreDisplay(m, dict)} - +
{/* Torabfolge */} @@ -204,7 +205,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: fontSize: 12, fontFamily: "var(--font-mono)", }}>
- Tore + {dict.label.goals}
{m.goals.map((g, i) => ( @@ -251,7 +252,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, lineHeight: 1, }} - title="Nach oben scrollen" + title={dict.label.scrollToTop} > ↑ @@ -260,7 +261,7 @@ export default function KoFixtures({ teams, matches }: { teams: Team[]; matches: ); } -function TeamLabel({ team, reverse }: { team?: Team; reverse?: boolean }) { +function TeamLabel({ team, reverse, locale }: { team?: Team; reverse?: boolean; locale: string }) { return (
- {team?.localisedName ?? team?.name ?? "—"} + {teamName(team, locale)}
); diff --git a/app/[locale]/components/Simulation.tsx b/app/[locale]/components/Simulation.tsx index 16f98ec..591cfca 100644 --- a/app/[locale]/components/Simulation.tsx +++ b/app/[locale]/components/Simulation.tsx @@ -2,13 +2,14 @@ import { useMemo } from "react"; import { Match, Team } from "@/lib/types"; +import { Dictionary } from "@/lib/i18n"; import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; import { qualifiedThirdGroups, resolveAnnexC, LATER_ROUNDS } from "@/lib/bracket"; import { resolveBracket, ResolvedTie } from "@/lib/resolve-bracket"; import { buildSimMatches } from "@/lib/simulation"; import Bracket from "./Bracket"; -export default function Simulation({ teams, matches }: { teams: Team[]; matches: Match[] }) { +export default function Simulation({ teams, matches, dict, locale }: { teams: Team[]; matches: Match[]; dict: Dictionary; locale: string }) { // ---- Simulations-Pipeline (Polymarket-Defaults, keine manuellen Overrides) ---- const simMatches = useMemo(() => buildSimMatches(matches, {}), [matches]); const simTables = useMemo(() => computeGroupTables(teams, simMatches), [teams, simMatches]); @@ -21,8 +22,8 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches: const simAnnexResolved = simAnnex != null; const simBracket = useMemo( - () => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, true), - [simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved], + () => resolveBracket(simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, true, dict), + [simMatches, teams, simTables, simThirds, simAnnex, simAnnexResolved, dict], ); // Echte Tabellen/Bracket für fix/provisional-Flags (nur tatsächlich FINISHED). @@ -34,8 +35,8 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches: [realQGroups], ); const realBracket = useMemo( - () => resolveBracket(matches, teams, realTables, realThirds, realAnnex, realAnnex != null), - [matches, teams, realTables, realThirds, realAnnex], + () => resolveBracket(matches, teams, realTables, realThirds, realAnnex, realAnnex != null, undefined, dict), + [matches, teams, realTables, realThirds, realAnnex, dict], ); // R32: provisional-Flags aus dem echten Bracket übernehmen. @@ -95,12 +96,11 @@ export default function Simulation({ teams, matches }: { teams: Team[]; matches: return (
- Simulation — alle ungespielten Spiele sind mit dem Polymarket-Favoriten - vorbelegt. Die angezeigten Wahrscheinlichkeiten stammen von Polymarket. + {dict.sim.notice}

- Simulierter K.o.-Baum + {dict.sim.heading}

); diff --git a/app/[locale]/components/ThirdPlace.tsx b/app/[locale]/components/ThirdPlace.tsx index fcfce3a..ea3ccfa 100644 --- a/app/[locale]/components/ThirdPlace.tsx +++ b/app/[locale]/components/ThirdPlace.tsx @@ -1,29 +1,27 @@ "use client"; -import { Team, ThirdPlaceRow } from "@/lib/types"; +import { Team, ThirdPlaceRow, teamName } from "@/lib/types"; +import { Dictionary } from "@/lib/i18n"; export default function ThirdPlace({ - rows, teams, secureTeams, -}: { rows: ThirdPlaceRow[]; teams: Team[]; secureTeams?: string[] }) { + rows, teams, secureTeams, dict, +}: { rows: ThirdPlaceRow[]; teams: Team[]; secureTeams?: string[]; dict: Dictionary }) { const name = (id: string) => { const t = teams.find((t) => t.id === id); - return t?.localisedName ?? t?.name ?? id; + return teamName(t, "de"); }; const secureSet = secureTeams ? new Set(secureTeams) : null; return (

- Acht der zwölf Gruppendritten erreichen die Runde der letzten 32. Gewertet wird - gruppenübergreifend nach Punkten, Tordifferenz und Toren — der Direktvergleich - entfällt, weil diese Teams nie gegeneinander gespielt haben. Die Trennlinie markiert - den Schnitt zwischen Platz 8 und 9. + {dict.thirds.explanation}

MannschaftSpSUNTore±Pkt{dict.table.team}{dict.table.matches}{dict.table.won}{dict.table.drawn}{dict.table.lost}{dict.table.goals}{dict.table.goalDiff}{dict.table.points}
{r.rank} - {team?.localisedName ?? team?.name ?? r.teamId} - {isLive && } + {teamName(team, locale)} + {isLive && } {r.played} {r.won}
- - + + @@ -39,7 +37,7 @@ export default function ThirdPlace({ @@ -48,7 +46,7 @@ export default function ThirdPlace({ diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c300b3d..69f01a5 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo, use } from "react"; import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types"; import { ThirdAssignment } from "@/lib/bracket"; +import { getDictionary, Locale, Dictionary } from "@/lib/i18n"; import Groups from "./components/Groups"; import ThirdPlace from "./components/ThirdPlace"; import Bracket from "./components/Bracket"; @@ -24,7 +25,9 @@ interface ApiData { type Tab = "groups" | "groupfixtures" | "kofixtures" | "thirds" | "bracket" | "sim"; -export default function Home() { +export default function Home({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = use(params) as { locale: Locale }; + const dict = useMemo(() => getDictionary(locale), [locale]); const [data, setData] = useState(null); const [error, setError] = useState(null); const [tab, setTab] = useState("kofixtures"); @@ -33,7 +36,7 @@ export default function Home() { const load = useCallback(async () => { try { - const res = await fetch("/api/matches", { cache: "no-store" }); + const res = await fetch(`/api/matches?locale=${locale}`, { cache: "no-store" }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.detail || `Fehler ${res.status}`); @@ -43,7 +46,7 @@ export default function Home() { } catch (e) { setError(e instanceof Error ? e.message : "Verbindung fehlgeschlagen"); } - }, []); + }, [locale]); useEffect(() => { load(); @@ -67,37 +70,37 @@ export default function Home() {
WM 26 - USA · Kanada · Mexiko + {dict.header.hostCountries}
{error - ? "Feed offline" + ? dict.status.feedOffline : data - ? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}` - : "Lade Daten…"} + ? anyLive ? dict.status.live : dict.status.updated(new Date(data.updatedAt).toLocaleTimeString(locale === "en" ? "en-US" : "de-DE")) + : dict.status.loading}
@@ -108,9 +111,7 @@ export default function Home() {
{error && (
- Die Live-Feeds sind gerade nicht erreichbar: {error}. - Prüfe den FOOTBALL_DATA_TOKEN und die Netzwerkfreigabe des Servers. - Die Seite versucht es automatisch erneut. + {dict.error.feedUnreachable}
)} @@ -121,32 +122,32 @@ export default function Home() { )} {data && tab === "kofixtures" && ( - + )} {data && tab === "groups" && ( )} {data && tab === "groupfixtures" && fixturesGroup && ( )} {data && tab === "thirds" && ( - + )} {data && tab === "bracket" && ( )} {data && tab === "sim" && ( - + )}
@@ -182,13 +183,13 @@ export default function Home() { fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--ink-faint)", }}> - WM 2026 Dashboard + {dict.footer.dashboard} - Daten: football-data.org · Polymarket Gamma API · FIFA Annex C + {dict.footer.dataSources} diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts index fd2c530..0472c51 100644 --- a/app/api/matches/route.ts +++ b/app/api/matches/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { fetchMatchesAndTeams, fetchOdds, attachOdds, attachKOOdds, fetchFifaScores, applyFifaScores, assignKONumbersBySlots, attachFifaGoals } from "@/lib/feeds"; import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket"; @@ -8,7 +9,8 @@ import { securelyQualifiedThirdTeams } from "@/lib/third-place-security"; // Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden. export const dynamic = "force-dynamic"; -export async function GET() { +export async function GET(request: NextRequest) { + const locale = request.nextUrl.searchParams.get("locale") || "de"; try { const { matches: rawMatches, teams } = await fetchMatchesAndTeams(); @@ -30,10 +32,10 @@ export async function GET() { } try { - const fifaData = await fetchFifaScores(); + const fifaData = await fetchFifaScores(locale); matches = applyFifaScores(matches, teams, fifaData); try { - matches = await attachFifaGoals(matches, fifaData); + matches = await attachFifaGoals(matches, fifaData, locale); } catch (err) { console.warn("[fifa] Goals fehlgeschlagen:", err instanceof Error ? err.message : err); } diff --git a/lib/bracket.ts b/lib/bracket.ts index dd9edfd..b5c42c4 100644 --- a/lib/bracket.ts +++ b/lib/bracket.ts @@ -1,5 +1,6 @@ import { ANNEX_C } from "./annexc-data"; import { GroupId, GroupTable, ThirdPlaceRow } from "./types"; +import { Dictionary } from "./i18n"; // Die festen Paarungen der Runde der letzten 32 (FIFA-Spielplan, Annex zur Auslosung). // Quelle: FIFA WM 2026 Wettbewerbsregeln, Spiele 73-88. @@ -100,14 +101,16 @@ export function slotLabel( slot: BracketSlot, assignment: ThirdAssignment | null, winnerGroupForThisMatch?: GroupId, + dict?: Dictionary, ): string { - if (slot.type === "W") return `Sieger ${slot.group}`; - if (slot.type === "R") return `Zweiter ${slot.group}`; + const s = dict?.slot; + if (slot.type === "W") return s?.winner(slot.group!) ?? `Sieger ${slot.group}`; + if (slot.type === "R") return s?.runnerUp(slot.group!) ?? `Zweiter ${slot.group}`; // 3. Platz if (assignment && winnerGroupForThisMatch && assignment[winnerGroupForThisMatch]) { - return `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`; + return s?.thirdOfGroup(assignment[winnerGroupForThisMatch]!) ?? `3. der Gruppe ${assignment[winnerGroupForThisMatch]}`; } - return `3. ${slot.thirdPool?.join("/") ?? "?"}`; + return s?.thirdPlacePool(slot.thirdPool?.join("/") ?? "?") ?? `3. ${slot.thirdPool?.join("/") ?? "?"}`; } // Lookup: matchNumber → Quellspiele (fromHome, fromAway, losers). diff --git a/lib/feeds.ts b/lib/feeds.ts index 8ecb6b0..76dc665 100644 --- a/lib/feeds.ts +++ b/lib/feeds.ts @@ -181,6 +181,10 @@ export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: id, name: side.name, code: side.tla ?? "", group, crest: `/crests/${id}.svg`, localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""), + localisedNames: { + de: localisedTeamName(side.tla ?? "", side.name ?? "", "de"), + en: localisedTeamName(side.tla ?? "", side.name ?? "", "en"), + }, }); } } @@ -606,12 +610,13 @@ interface FifaScores { } // Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück. -export async function fetchFifaScores(): Promise<{ +export async function fetchFifaScores(locale: string = "de"): Promise<{ scores: Map; fifaIdToAppCode: Map; }> { - return cached("fifa:scores", 45_000, async () => { - const url = `${FIFA_BASE}/calendar/matches?language=de&count=500&idSeason=${FIFA_SEASON}`; + return cached(`fifa:scores:${locale}`, 45_000, async () => { + const lang = locale === "en" ? "en" : "de"; + const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, @@ -715,9 +720,10 @@ function normMinuteStr(min: string | null | undefined): string { return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim(); } -async function fetchFifaGoals(idStage: string, idMatch: string): Promise { - return cached(`fifa:detail:${idMatch}`, 60_000, async () => { - const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=de`; +async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise { + return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => { + const lang = locale === "en" ? "en" : "de"; + const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, @@ -762,6 +768,7 @@ async function fetchFifaGoals(idStage: string, idMatch: string): Promise; fifaIdToAppCode: Map }, + locale: string = "de", ): Promise { const targets = matches.filter(m => (m.status === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") && @@ -771,7 +778,7 @@ export async function attachFifaGoals( await Promise.all(targets.map(async (m) => { const fs = fifaData.scores.get(m.matchNumber); if (!fs?.idMatch || !fs?.idStage) return; - const goals = await fetchFifaGoals(fs.idStage, fs.idMatch); + const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale); if (goals.length) goalsByMatchId.set(m.id, goals); })); if (goalsByMatchId.size === 0) return matches; diff --git a/lib/resolve-bracket.ts b/lib/resolve-bracket.ts index 51eaf29..baacb77 100644 --- a/lib/resolve-bracket.ts +++ b/lib/resolve-bracket.ts @@ -5,6 +5,7 @@ import { } from "@/lib/bracket"; import { placeIsSecure } from "@/lib/secure-places"; import { thirdSlotIsSecure, securelyQualifiedThirdTeams } from "@/lib/third-place-security"; +import { Dictionary } from "@/lib/i18n"; // Ein aufgelöster Teilnehmer einer K.o.-Begegnung. export interface ResolvedSide { @@ -84,23 +85,25 @@ function resolveR32Slot( teams: Team[], annexResolved: boolean, secureTeamIds: Set, + dict?: Dictionary, ): { teamId: string | null; provisional: boolean; tooltip: string | null } { + const t = dict?.tooltips; const table = (g: GroupId) => tables.find((t) => t.group === g); if (slot.type === "W") { - const t = table(slot.group!); - const teamId = t?.rows.find((r) => r.rank === 1)?.teamId ?? null; + const tab = table(slot.group!); + const teamId = tab?.rows.find((r) => r.rank === 1)?.teamId ?? null; // Sieger fix, sobald Platz 1 rechnerisch gesichert ist (auch vor Gruppenende). const provisional = !placeIsSecure(slot.group!, 1, teams, matches); - const prefix = provisional ? "aktuell " : ""; - return { teamId, provisional, tooltip: `${prefix}1. Gruppe ${slot.group}` }; + const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; + return { teamId, provisional, tooltip: `${prefix}${t?.firstOfGroup(slot.group!) ?? `1. Gruppe ${slot.group}`}` }; } if (slot.type === "R") { - const t = table(slot.group!); - const teamId = t?.rows.find((r) => r.rank === 2)?.teamId ?? null; + const tab = table(slot.group!); + const teamId = tab?.rows.find((r) => r.rank === 2)?.teamId ?? null; // Zweiter fix, sobald Platz 2 rechnerisch gesichert ist. const provisional = !placeIsSecure(slot.group!, 2, teams, matches); - const prefix = provisional ? "aktuell " : ""; - return { teamId, provisional, tooltip: `${prefix}2. Gruppe ${slot.group}` }; + const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; + return { teamId, provisional, tooltip: `${prefix}${t?.secondOfGroup(slot.group!) ?? `2. Gruppe ${slot.group}`}` }; } // 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels if (slot.type === "3" && assignment && winnerGroup) { @@ -116,17 +119,17 @@ function resolveR32Slot( const teamSecure = row?.teamId ? secureTeamIds.has(row.teamId) : false; const fix = annexResolved && slotStable && teamSecure && row?.qualifies === true; const provisional = !fix; - const prefix = provisional ? "aktuell " : ""; + const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; return { teamId: row?.teamId ?? null, provisional, - tooltip: `${prefix}3. der Gruppe ${thirdGroup}`, + tooltip: `${prefix}${t?.thirdOfGroup(thirdGroup) ?? `3. der Gruppe ${thirdGroup}`}`, }; } } if (slot.type === "3") { const pool = slot.thirdPool?.join("") ?? "?"; - return { teamId: null, provisional: true, tooltip: `aktuell 3. Gruppe ${pool}` }; + return { teamId: null, provisional: true, tooltip: t?.provisionalThird(pool) ?? `aktuell 3. Gruppe ${pool}` }; } return { teamId: null, provisional: true, tooltip: null }; } @@ -163,6 +166,7 @@ export function resolveBracket( thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null, annexResolved: boolean, resolveWinners = false, + dict?: Dictionary, ): { r32: ResolvedTie[]; later: Record } { // Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden) const winners = new Map(); @@ -171,6 +175,8 @@ export function resolveBracket( const decided = new Map(); const secureTeamIds = securelyQualifiedThirdTeams(matches, teams); + const b = dict?.bracket; + const r32: ResolvedTie[] = R32.map((rm: R32Match) => { const feed = feedMatch(matches, rm.matchNumber); const homeGroup = rm.home.type === "W" ? rm.home.group : undefined; @@ -178,10 +184,10 @@ export function resolveBracket( // Für 3.-Platz-Slots: Gruppensieger ist die andere Seite (immer W) const winnerGroup = (homeGroup ?? awayGroup) as GroupId | undefined; - const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds); - const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds); - const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup), teams, feed, "home", h.provisional, h.tooltip); - const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup), teams, feed, "away", a.provisional, a.tooltip); + const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict); + const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict); + const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup, dict), teams, feed, "home", h.provisional, h.tooltip); + const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup, dict), teams, feed, "away", a.provisional, a.tooltip); const feedWinner = winnerOf(feed); if (resolveWinners && feed && h.teamId && a.teamId @@ -223,10 +229,19 @@ export function resolveBracket( // Ein Folgeteam ist fix, wenn das speisende Spiel beendet ist. const homeProv = !(decided.get(km.fromHome) ?? false); const awayProv = !(decided.get(km.fromAway) ?? false); - const homeLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromHome}`; - const awayLabel = `${km.losers ? "Verlierer" : "Sieger"} ${km.fromAway}`; - const homeTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromHome}`; - const awayTtip = `${km.losers ? "Verlierer" : "Sieger"} aus Spiel ${km.fromAway}`; + const loserLabel = km.losers ? true : false; + const homeLabel = loserLabel + ? (b?.loser ?? "Verlierer") + " " + km.fromHome + : (b?.winner ?? "Sieger") + " " + km.fromHome; + const awayLabel = loserLabel + ? (b?.loser ?? "Verlierer") + " " + km.fromAway + : (b?.winner ?? "Sieger") + " " + km.fromAway; + const homeTtip = loserLabel + ? (b?.loserFromMatch(km.fromHome) ?? `Verlierer aus Spiel ${km.fromHome}`) + : (b?.winnerFromMatch(km.fromHome) ?? `Sieger aus Spiel ${km.fromHome}`); + const awayTtip = loserLabel + ? (b?.loserFromMatch(km.fromAway) ?? `Verlierer aus Spiel ${km.fromAway}`) + : (b?.winnerFromMatch(km.fromAway) ?? `Sieger aus Spiel ${km.fromAway}`); const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv, homeTtip); const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv, awayTtip); diff --git a/lib/types.ts b/lib/types.ts index 0b44a0f..684bfe9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -16,6 +16,13 @@ export interface Team { group: GroupId; crest?: string | null; // URL zur Flagge / zum Wappen (football-data: crest) localisedName: string; // Lokalisierter Anzeigename (z.B. "Deutschland") + localisedNames?: { de: string; en: string }; +} + +export function teamName(team: Team | undefined, locale: string): string { + if (!team) return "—"; + if (locale === "en") return team.localisedNames?.en ?? team.name ?? "—"; + return team.localisedNames?.de ?? team.name ?? "—"; } export type MatchStatus =
#GruppeTeamSpPkt±ToreStatus{dict.table.rank}{dict.table.group}Team{dict.table.matches}{dict.table.points}{dict.table.goalDiff}{dict.table.goals}{dict.table.status}
{name(r.teamId)} {secureSet?.has(r.teamId) && ( - + )} {r.played}{r.goalsFor}:{r.goalsAgainst} - {r.qualifies ? "weiter" : "raus"} + {r.qualifies ? dict.thirds.advances : dict.thirds.eliminated}