This commit is contained in:
2026-06-22 15:32:32 -05:00
commit f5ab324cca
27 changed files with 3024 additions and 0 deletions

44
app/api/matches/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { fetchMatchesAndTeams, fetchOdds, attachOdds } from "@/lib/feeds";
import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings";
import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket";
// Diese Route wird vom Frontend gepollt. Sie ist der einzige Ort, der die
// Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden.
export const dynamic = "force-dynamic";
export async function GET() {
try {
const { matches: rawMatches, teams } = await fetchMatchesAndTeams();
// Odds sind optional: fällt der Polymarket-Call aus, liefern wir trotzdem.
let matches = rawMatches;
try {
const odds = await fetchOdds();
matches = attachOdds(rawMatches, teams, odds);
} catch {
// still ohne Wahrscheinlichkeiten
}
const groupTables = computeGroupTables(teams, matches);
const thirdTable = computeThirdPlaceTable(groupTables);
const qGroups = qualifiedThirdGroups(thirdTable);
const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null;
return NextResponse.json({
updatedAt: new Date().toISOString(),
teams,
matches,
groupTables,
thirdTable,
annexAssignment: annex,
annexResolved: annex != null,
});
} catch (err) {
const message = err instanceof Error ? err.message : "unbekannter Fehler";
return NextResponse.json(
{ error: "Feed nicht erreichbar", detail: message },
{ status: 502 },
);
}
}

113
app/components/Bracket.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import { resolveBracket, ResolvedSide, ResolvedTie } from "@/lib/resolve-bracket";
function Side({ s, prob }: { s: ResolvedSide; prob?: number | null }) {
return (
<div className={`side ${s.isWinner ? "win" : ""}`}>
<span className="nm">
{s.teamId ? (
<>
<span>{s.label}</span>
{s.code && <span className="c">{s.code}</span>}
</>
) : (
<span className="lbl">{s.label}</span>
)}
{prob != null && prob > 0 && (
<span className="prob">{Math.round(prob * 100)}%</span>
)}
</span>
<span className="sc">{s.score != null ? s.score : ""}</span>
</div>
);
}
function Tie({ tie, isFinal }: { tie: ResolvedTie; isFinal?: boolean }) {
return (
<div className={`tie ${isFinal ? "final-tie" : ""}`}>
<span className="tie-num">#{tie.matchNumber}</span>
<Side s={tie.home} prob={tie.prob?.home} />
<Side s={tie.away} prob={tie.prob?.away} />
</div>
);
}
export default function Bracket({
matches, teams, tables, thirds, assignment, annexResolved,
}: {
matches: Match[]; teams: Team[]; tables: GroupTable[];
thirds: ThirdPlaceRow[]; assignment: ThirdAssignment | null; annexResolved: boolean;
}) {
const { r32, later } = resolveBracket(matches, teams, tables, thirds, assignment);
const pick = (nums: number[]) => nums.map((n) => later[n]).filter(Boolean);
const r16 = pick([89, 90, 91, 92, 93, 94, 95, 96]);
const qf = pick([97, 98, 99, 100]);
const sf = pick([101, 102]);
const fin = later[104];
const third = later[103];
return (
<div>
<div className="bracket-banner">
<span className="k">Annex-C-Zuordnung der Drittplatzierten:</span>
<span className="v">
{annexResolved
? "aufgelöst — die acht Dritten sind den Gruppensiegern fest zugeteilt"
: "noch offen — sobald die 8 besten Dritten feststehen, verbindet sich der Baum automatisch"}
</span>
</div>
<div className="bracket-scroll">
<div className="bracket">
<div className="round">
<div className="round-label">Letzte 32</div>
<div className="round-matches">
{r32.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Achtelfinale</div>
<div className="round-matches">
{r16.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Viertelfinale</div>
<div className="round-matches">
{qf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Halbfinale</div>
<div className="round-matches">
{sf.map((t) => <Tie key={t.matchNumber} tie={t} />)}
</div>
</div>
<div className="round">
<div className="round-label">Finale</div>
<div className="round-matches">
{fin && <Tie tie={fin} isFinal />}
{third && (
<div style={{ marginTop: 20 }}>
<div className="round-label" style={{ marginBottom: 8 }}>Spiel um Platz 3</div>
<Tie tie={third} />
</div>
)}
</div>
</div>
</div>
</div>
<div className="legend">
<span><i style={{ background: "var(--turf)" }} />Sieger / weiter</span>
<span><i style={{ background: "var(--gold)" }} />Finale</span>
<span><i style={{ background: "var(--ink-faint)" }} />Platzhalter offen</span>
<span>%-Werte: Polymarket-Wahrscheinlichkeit (falls verfügbar)</span>
</div>
</div>
);
}

66
app/components/Groups.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { GroupTable, Match, Team } from "@/lib/types";
function teamById(teams: Team[], id: string) {
return teams.find((t) => t.id === id);
}
function liveMatchFor(group: string, matches: Match[]) {
return matches.find(
(m) => m.group === group && (m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED"),
);
}
export default function Groups({
tables, teams, matches,
}: { tables: GroupTable[]; teams: Team[]; matches: Match[] }) {
return (
<div className="group-grid">
{tables.map((t) => {
const live = liveMatchFor(t.group, matches);
return (
<div className="group-card" key={t.group}>
<div className="group-head">
<span className="group-name">Gruppe {t.group}</span>
<span className="group-tag">
{live ? "● LIVE" : `${t.rows.reduce((a, r) => a + r.played, 0)} Spiele`}
</span>
</div>
<table className="standings">
<thead>
<tr>
<th className="team">Team</th>
<th>Sp</th><th>S</th><th>U</th><th>N</th>
<th>Tore</th><th>±</th><th>Pkt</th>
</tr>
</thead>
<tbody>
{t.rows.map((r) => {
const team = teamById(teams, r.teamId);
const cls = r.rank <= 2 ? `q${r.rank}` : r.rank === 3 ? "q3" : "";
return (
<tr key={r.teamId}>
<td className="team">
<span className={`rankdot ${cls}`}>{r.rank}</span>
<span className="team-name">{team?.name ?? r.teamId}</span>
{team?.code && <span className="team-code">{team.code}</span>}
</td>
<td>{r.played}</td>
<td>{r.won}</td>
<td>{r.drawn}</td>
<td>{r.lost}</td>
<td>{r.goalsFor}:{r.goalsAgainst}</td>
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
<td className="pts">{r.points}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Team, ThirdPlaceRow } from "@/lib/types";
export default function ThirdPlace({
rows, teams,
}: { rows: ThirdPlaceRow[]; teams: Team[] }) {
const name = (id: string) => teams.find((t) => t.id === id)?.name ?? id;
return (
<div className="third-wrap">
<p className="notice" style={{ marginBottom: 16 }}>
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.
</p>
<table className="third-table">
<thead>
<tr>
<th>#</th><th>Gruppe</th><th>Team</th>
<th>Sp</th><th>Pkt</th><th>±</th><th>Tore</th><th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const isCut = i === 8; // erste nicht-qualifizierte Zeile
return (
<tr
key={r.teamId}
className={`third-row ${r.qualifies ? "qual" : ""} ${isCut ? "cut" : ""}`}
>
<td>{r.overallRank}</td>
<td>{r.group}</td>
<td style={{ fontWeight: 600 }}>{name(r.teamId)}</td>
<td>{r.played}</td>
<td className="pts">{r.points}</td>
<td>{r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff}</td>
<td>{r.goalsFor}:{r.goalsAgainst}</td>
<td>
<span className={`qual-badge ${r.qualifies ? "yes" : "no"}`}>
{r.qualifies ? "weiter" : "raus"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

247
app/globals.css Normal file
View File

@@ -0,0 +1,247 @@
:root {
/* Palette: Stadion bei Nacht über drei Zeitzonen.
Tiefes Mitternachtsblau, kühles Flutlicht-Weiß, warmer Rasen-Akzent,
ein Signal-Magenta für "live". Bewusst nicht die üblichen AI-Defaults. */
--bg: #0b1020;
--bg-raised: #121a32;
--bg-card: #16203c;
--line: #243152;
--line-soft: #1b2540;
--ink: #eef2fb;
--ink-dim: #9aa6c4;
--ink-faint: #5f6d92;
--turf: #4ade80; /* Rasen / qualifiziert */
--turf-deep: #1f7a45;
--floodlight: #cfe0ff;
--live: #ff3d7f; /* Live-Signal */
--gold: #ffd24a; /* Sieger / Finale */
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
--font-display: "Archivo Expanded", "Archivo", system-ui, sans-serif;
--font-body: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", "SFMono-Regular", ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
body {
background:
radial-gradient(1200px 600px at 70% -10%, #16224a 0%, transparent 55%),
radial-gradient(900px 500px at 10% 0%, #102046 0%, transparent 50%),
var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 20px; }
/* ---------- Header / Hero ---------- */
.masthead {
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(18,26,50,0.7), transparent);
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(10px);
}
.masthead-inner {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; gap: 16px; flex-wrap: wrap;
}
.brand { display: flex; align-items: baseline; gap: 12px; }
.brand-mark {
font-family: var(--font-display);
font-weight: 800; letter-spacing: -0.02em;
font-size: clamp(20px, 3vw, 28px);
text-transform: uppercase;
}
.brand-mark .accent { color: var(--turf); }
.brand-sub {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-faint); letter-spacing: 0.08em; text-transform: uppercase;
}
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 12px;
color: var(--ink-dim);
border: 1px solid var(--line); border-radius: 999px;
padding: 6px 12px; background: var(--bg-card);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); }
.dot.on { background: var(--turf); box-shadow: 0 0 0 3px rgba(74,222,128,0.18); }
.dot.live { background: var(--live); box-shadow: 0 0 0 3px rgba(255,61,127,0.2); animation: pulse 1.6s infinite; }
@keyframes pulse { 50% { box-shadow: 0 0 0 6px rgba(255,61,127,0); } }
/* ---------- Tabs ---------- */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 0 0 4px; }
.tab {
font-family: var(--font-display); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.01em;
font-size: 14px; color: var(--ink-faint);
padding: 16px 18px; cursor: pointer; border: none; background: none;
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--ink-dim); }
.tab.active { color: var(--ink); border-bottom-color: var(--turf); }
.section { padding: 28px 0 64px; }
/* ---------- Gruppen ---------- */
.group-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
}
.group-card {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden;
}
.group-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid var(--line-soft);
background: var(--bg-raised);
}
.group-name {
font-family: var(--font-display); font-weight: 800; font-size: 16px;
text-transform: uppercase; letter-spacing: 0.02em;
}
.group-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
table.standings { width: 100%; border-collapse: collapse; }
.standings th {
font-family: var(--font-mono); font-size: 10px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--ink-faint); text-align: right; padding: 8px 6px;
}
.standings th.team { text-align: left; padding-left: 14px; }
.standings td {
padding: 9px 6px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.standings td.team {
text-align: left; padding-left: 14px; display: flex; align-items: center; gap: 9px;
}
.rankdot {
width: 18px; height: 18px; border-radius: 5px; flex: none;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 10px; font-weight: 600;
color: var(--bg); background: var(--ink-faint);
}
.rankdot.q1, .rankdot.q2 { background: var(--turf); }
.rankdot.q3 { background: var(--gold); color: #2a2200; }
.rankdot.q3.out { background: var(--ink-faint); color: var(--bg); }
.team-name { font-weight: 600; }
.team-code { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.pts { font-weight: 700; color: var(--floodlight); }
/* Live-Zeile */
.score-live { color: var(--live); font-weight: 700; }
/* ---------- Drittplatzierte ---------- */
.third-wrap { margin-top: 8px; }
.third-table { width: 100%; border-collapse: collapse; background: var(--bg-card);
border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.third-table th {
font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--ink-faint); padding: 11px 12px; text-align: right;
background: var(--bg-raised); border-bottom: 1px solid var(--line);
}
.third-table th:first-child, .third-table td:first-child { text-align: left; }
.third-table td {
padding: 10px 12px; text-align: right; font-variant-numeric: tabular-nums;
border-top: 1px solid var(--line-soft); font-size: 13px;
}
.third-row.qual { background: linear-gradient(90deg, rgba(74,222,128,0.07), transparent); }
.third-row.cut td { border-top: 2px solid var(--turf-deep); }
.qual-badge {
font-family: var(--font-mono); font-size: 10px; padding: 2px 7px; border-radius: 999px;
}
.qual-badge.yes { background: rgba(74,222,128,0.16); color: var(--turf); }
.qual-badge.no { background: rgba(95,109,146,0.16); color: var(--ink-faint); }
/* ---------- Bracket ---------- */
.bracket-banner {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px;
}
.bracket-banner .k {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
}
.bracket-banner .v { font-family: var(--font-mono); font-size: 12px; color: var(--turf); }
.bracket-scroll { overflow-x: auto; padding-bottom: 16px; }
.bracket {
display: flex; gap: 26px; min-width: max-content; align-items: stretch;
}
.round { display: flex; flex-direction: column; min-width: 220px; }
.round-label {
font-family: var(--font-display); font-weight: 800; text-transform: uppercase;
font-size: 12px; letter-spacing: 0.06em; color: var(--ink-faint);
margin-bottom: 10px; padding-left: 2px;
}
.round-matches { display: flex; flex-direction: column; justify-content: space-around; flex: 1; gap: 12px; }
.tie {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius-sm); overflow: hidden; position: relative;
}
.tie.final-tie { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255,210,74,0.2); }
.tie-num {
position: absolute; top: -7px; left: 8px;
font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint);
background: var(--bg); padding: 0 5px;
}
.side {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 10px; font-size: 13px;
}
.side + .side { border-top: 1px solid var(--line-soft); }
.side .nm { display: flex; align-items: center; gap: 7px; min-width: 0; }
.side .nm .c { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); }
.side .lbl { color: var(--ink-dim); font-style: italic; }
.side .sc { font-family: var(--font-mono); font-weight: 700; color: var(--floodlight); }
.side.win .nm { color: var(--turf); font-weight: 700; }
.side.win .sc { color: var(--turf); }
.side .prob {
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
margin-left: 6px;
}
.legend { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 16px;
font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
/* ---------- Zustände ---------- */
.notice {
background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 20px; color: var(--ink-dim);
font-size: 14px;
}
.notice.err { border-color: #5a2230; color: #ffb0c0; }
.skel { background: var(--bg-card); border: 1px solid var(--line);
border-radius: var(--radius); height: 220px; animation: shimmer 1.4s infinite; }
@keyframes shimmer { 50% { opacity: .55; } }
.foot {
border-top: 1px solid var(--line); padding: 24px 0 48px;
color: var(--ink-faint); font-size: 12px; font-family: var(--font-mono);
}
.foot a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 640px) {
.tab { padding: 14px 12px; font-size: 12px; }
.group-grid { grid-template-columns: 1fr; }
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "WM 26 — Gruppen & K.o.-Baum",
description:
"Live-Gruppentabellen, Drittplatzierten-Wertung und der vollständige K.o.-Baum der FIFA WM 2026 mit Annex-C-Zuordnung und Polymarket-Wahrscheinlichkeiten.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;700;800&family=Archivo+Expanded:wght@700;800&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}

122
app/page.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types";
import { ThirdAssignment } from "@/lib/bracket";
import Groups from "./components/Groups";
import ThirdPlace from "./components/ThirdPlace";
import Bracket from "./components/Bracket";
interface ApiData {
updatedAt: string;
teams: Team[];
matches: Match[];
groupTables: GroupTable[];
thirdTable: ThirdPlaceRow[];
annexAssignment: ThirdAssignment | null;
annexResolved: boolean;
}
type Tab = "groups" | "thirds" | "bracket";
export default function Home() {
const [data, setData] = useState<ApiData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("groups");
const load = useCallback(async () => {
try {
const res = await fetch("/api/matches", { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler ${res.status}`);
}
setData(await res.json());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Verbindung fehlgeschlagen");
}
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000); // alle 30 s aktualisieren
return () => clearInterval(id);
}, [load]);
const anyLive = data?.matches.some(
(m) => m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED",
);
return (
<>
<header className="masthead">
<div className="wrap masthead-inner">
<div className="brand">
<span className="brand-mark">WM <span className="accent">26</span></span>
<span className="brand-sub">USA · Kanada · Mexiko</span>
</div>
<span className="status-pill">
<span className={`dot ${anyLive ? "live" : data ? "on" : ""}`} />
{error
? "Feed offline"
: data
? anyLive ? "Live" : `Aktualisiert ${new Date(data.updatedAt).toLocaleTimeString("de-DE")}`
: "Lade Daten…"}
</span>
</div>
</header>
<main className="wrap">
<nav className="tabs">
<button className={`tab ${tab === "groups" ? "active" : ""}`} onClick={() => setTab("groups")}>
Gruppen
</button>
<button className={`tab ${tab === "thirds" ? "active" : ""}`} onClick={() => setTab("thirds")}>
Drittplatzierte
</button>
<button className={`tab ${tab === "bracket" ? "active" : ""}`} onClick={() => setTab("bracket")}>
K.o.-Baum
</button>
</nav>
<section className="section">
{error && (
<div className="notice err">
Die Live-Feeds sind gerade nicht erreichbar: {error}.
Prüfe den <code>FOOTBALL_DATA_TOKEN</code> und die Netzwerkfreigabe des Servers.
Die Seite versucht es automatisch erneut.
</div>
)}
{!data && !error && (
<div className="group-grid">
{Array.from({ length: 6 }).map((_, i) => <div className="skel" key={i} />)}
</div>
)}
{data && tab === "groups" && (
<Groups tables={data.groupTables} teams={data.teams} matches={data.matches} />
)}
{data && tab === "thirds" && (
<ThirdPlace rows={data.thirdTable} teams={data.teams} />
)}
{data && tab === "bracket" && (
<Bracket
matches={data.matches} teams={data.teams} tables={data.groupTables}
thirds={data.thirdTable} assignment={data.annexAssignment}
annexResolved={data.annexResolved}
/>
)}
</section>
</main>
<footer className="foot">
<div className="wrap">
Daten: football-data.org (Spiele &amp; Tabellen) · Polymarket Gamma API (Wahrscheinlichkeiten) ·
Annex-C-Zuordnung nach den FIFA-Wettbewerbsregeln WM 2026. Kein offizielles FIFA-Produkt.
</div>
</footer>
</>
);
}