diff --git a/app/client.tsx b/app/client.tsx new file mode 100644 index 0000000..0d9b643 --- /dev/null +++ b/app/client.tsx @@ -0,0 +1,240 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { LiveBadge } from '@/components/live-badge' +import { MatchCard } from '@/components/match-card' + +const HOME_QUERY = gql` + query Home { + tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } + liveMatches { + id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff + team1 { name iso2 slug } team2 { name iso2 slug } + } + recentMatches(limit: 9) { + id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP + team1 { name iso2 slug } team2 { name iso2 slug } + } + upcomingMatches(limit: 9) { + id year round group date time isLive isQualiPlayoff scoreFt + team1 { name iso2 slug } team2 { name iso2 slug } + } + topScorers(year: 2026, limit: 8) { + playerName goals penalties ownGoals + team { name iso2 } + } + tournament(year: 2026) { year totalGoals matchesCount avgGoalsPerGame } + } +` + +function SectionHeader({ label }: { label: string }) { + return ( +
+
+ {label} +
+ ) +} + +function StatPill({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value ?? '–'}
+
+ ) +} + +interface UpcomingMatch { + id: number; year: number; time?: string | null; date?: string | null + team1: { name: string; iso2?: string | null } + team2: { name: string; iso2?: string | null } +} + +function formatKickoff(date: string | null | undefined, time: string | null | undefined): string { + if (!date) return '' + const today = new Date() + const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1) + + if (time) { + const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/) + if (m) { + const [y, mo, d] = date.split('-').map(Number) + const h = parseInt(m[1]), min = parseInt(m[2]) + const offsetH = m[3] ? parseFloat(m[3]) : 0 + // Compute UTC kickoff, then let the browser render in its local timezone + const local = new Date(Date.UTC(y, mo - 1, d, h - offsetH, min)) + const isToday = local.toDateString() === today.toDateString() + const isTomorrow = local.toDateString() === tomorrow.toDateString() + const dayLabel = isToday ? 'Today' : isTomorrow ? 'Tomorrow' + : local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) + const localTime = local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + return `${dayLabel} · ${localTime}` + } + } + + // No time — fall back to venue date label only + const matchDate = new Date(date + 'T00:00:00') + const isToday = matchDate.toDateString() === today.toDateString() + const isTomorrow = matchDate.toDateString() === tomorrow.toDateString() + return isToday ? 'Today' : isTomorrow ? 'Tomorrow' + : matchDate.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) +} + +function UpcomingFixture({ match }: { match: UpcomingMatch }) { + const label = formatKickoff(match.date, match.time) + return ( + +
+ +
+ {match.team1.name} vs {match.team2.name} +
+ + {label &&
{label}
} +
+ + ) +} + +interface ScorerEntry { + playerName: string; goals: number; penalties: number + team?: { name: string; iso2?: string | null } | null +} + +interface MatchData { + id: number; year: number; round: string; group?: string | null + date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean + scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null + team1: { name: string; iso2?: string | null; slug?: string | null } + team2: { name: string; iso2?: string | null; slug?: string | null } +} + +export function HomeClient() { + const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 }) + + + const stats = data?.tournamentStats + const live: MatchData[] = data?.liveMatches ?? [] + const recent: MatchData[] = data?.recentMatches ?? [] + const upcoming: UpcomingMatch[] = data?.upcomingMatches ?? [] + const scorers: ScorerEntry[] = data?.topScorers ?? [] + const wc2026 = data?.tournament + + const maxGoals = Math.max(...scorers.map(s => s.goals), 1) + + return ( +
+ {/* ── Hero ── */} +
+
+
+ {live.length > 0 + ? + :
+ + World Cup 2026 · In Progress +
+ } +
+

+ World Cup 2026 +

+

+ USA · Canada · Mexico  ·  11 June – 19 July 2026 · 48 Teams +

+
+ {stats ? <> + + + + + {wc2026 && <> + + + } + : [1,2,3,4].map(i => ( +
+ ))} +
+
+
+ +
+ {/* Live matches */} + {live.length > 0 && ( +
+ +
+ {live.map(m => )} +
+
+ )} + + {/* Latest result */} + {recent.length > 0 && ( +
+ + +
+ )} + + {/* Recent grid */} + {recent.length > 1 && ( +
+ +
+ {recent.slice(1).map(m => )} +
+
+ )} + + {/* Upcoming */} + {upcoming.length > 0 && ( +
+ +
+ {upcoming.map(m => )} +
+
+ )} + + {/* Golden Boot 2026 */} + {scorers.length > 0 && ( +
+ +
+ {scorers.map((s, i) => ( + +
+ {i + 1} + {s.team && } +
+
{s.playerName}
+
{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
+
+
+
+
+ {s.goals} +
+ + ))} +
+

+ View all-time top scorers → +

+
+ )} + + {loading && !data && ( +
Loading live World Cup data…
+ )} +
+
+ ) +} diff --git a/app/groups/client.tsx b/app/groups/client.tsx new file mode 100644 index 0000000..d6a2e7e --- /dev/null +++ b/app/groups/client.tsx @@ -0,0 +1,207 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' + +const GROUPS_QUERY = gql` + query Groups { + groupStandings(year: 2026) { + groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts + team { id name iso2 slug } + } + matches(year: 2026, isQuali: false) { + id group date time isLive scoreFt + team1 { name iso2 slug } team2 { name iso2 slug } + } + } +` + +interface Standing { + groupName: string; pos?: number | null + played: number; won: number; drawn: number; lost: number + goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number + team: { id: number; name: string; iso2?: string | null; slug: string } +} + +interface MatchRow { + id: number; group?: string | null; date?: string | null; time?: string | null + isLive: boolean; scoreFt?: number[] | null + team1: { name: string; iso2?: string | null; slug: string } + team2: { name: string; iso2?: string | null; slug: string } +} + +function utcKickoff(date: string, time: string): number { + const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/) + if (!m) return new Date(date).getTime() + const [y, mo, d] = date.split('-').map(Number) + const offsetH = m[3] ? parseFloat(m[3]) : 0 + return Date.UTC(y, mo - 1, d, parseInt(m[1]) - offsetH, parseInt(m[2])) +} + +function formatKickoff(date: string, time: string | null | undefined): string { + if (!time) return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) + const ms = utcKickoff(date, time) + const local = new Date(ms) + const today = new Date() + const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1) + const isToday = local.toDateString() === today.toDateString() + const isTomorrow = local.toDateString() === tomorrow.toDateString() + const day = isToday ? 'Today' : isTomorrow ? 'Tomorrow' + : local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) + return `${day} · ${local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}` +} + +export function GroupsClient() { + const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 }) + + + const standings: Standing[] = data?.groupStandings ?? [] + const allMatches: MatchRow[] = data?.matches ?? [] + + const byGroup = standings.reduce>((acc, s) => { + acc[s.groupName] = [...(acc[s.groupName] ?? []), s] + return acc + }, {}) + + const matchesByGroup = allMatches + .filter(m => m.group) + .reduce>((acc, m) => { + acc[m.group!] = [...(acc[m.group!] ?? []), m] + return acc + }, {}) + + const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b)) + + return ( +
+
+

2026 Groups

+

48 teams · 12 groups · Top 2 + 8 best 3rd-place advance

+
+ + {loading && !data && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ )} + +
+ {groups.map(([groupName, rows]) => { + const sorted = [...rows].sort((a, b) => { + if (b.pts !== a.pts) return b.pts - a.pts + if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff + return b.goalsFor - a.goalsFor + }) + const letter = groupName.replace('Group ', '') + + const groupMatches = (matchesByGroup[groupName] ?? []) + .sort((a, b) => { + if (!a.date) return 1 + if (!b.date) return -1 + const ta = a.time ? utcKickoff(a.date, a.time) : new Date(a.date).getTime() + const tb = b.time ? utcKickoff(b.date!, b.time) : new Date(b.date!).getTime() + return ta - tb + }) + const played = groupMatches.filter(m => m.scoreFt) + const upcoming = groupMatches.filter(m => !m.scoreFt && !m.isLive) + const live = groupMatches.filter(m => m.isLive) + + return ( +
+ {/* Header */} +
+ GROUP {letter} +
+ + {/* Standings */} +
+ Team + PW + DL + GDPts +
+ {sorted.map((t, idx) => ( + +
+
+ + {t.team.name} +
+ {[t.played, t.won, t.drawn, t.lost].map((v, i) => ( + {v} + ))} + + {t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff} + + {t.pts} +
+ + ))} + + {/* Live matches */} + {live.length > 0 && ( +
+ {live.map(m => ( + +
+ LIVE + + {m.team1.name} + vs + {m.team2.name} + +
+ + ))} +
+ )} + + {/* Results */} + {played.length > 0 && ( +
+ {played.map(m => ( + +
+ + {m.team1.name} + + {m.scoreFt![0]}–{m.scoreFt![1]} + + {m.team2.name} + +
+ + ))} +
+ )} + + {/* Upcoming */} + {upcoming.length > 0 && ( +
+ {upcoming.map(m => ( + +
+ + {m.team1.name} + + {m.date ? formatKickoff(m.date, m.time) : '–'} + + {m.team2.name} + +
+ + ))} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/app/groups/page.tsx b/app/groups/page.tsx index fcca6dd..e4e2887 100644 --- a/app/groups/page.tsx +++ b/app/groups/page.tsx @@ -1,209 +1,16 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' +import type { Metadata } from 'next' +import { GroupsClient } from './client' -const GROUPS_QUERY = gql` - query Groups { - groupStandings(year: 2026) { - groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts - team { id name iso2 slug } - } - matches(year: 2026, isQuali: false) { - id group date time isLive scoreFt - team1 { name iso2 slug } team2 { name iso2 slug } - } - } -` - -interface Standing { - groupName: string; pos?: number | null - played: number; won: number; drawn: number; lost: number - goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number - team: { id: number; name: string; iso2?: string | null; slug: string } -} - -interface MatchRow { - id: number; group?: string | null; date?: string | null; time?: string | null - isLive: boolean; scoreFt?: number[] | null - team1: { name: string; iso2?: string | null; slug: string } - team2: { name: string; iso2?: string | null; slug: string } -} - -function utcKickoff(date: string, time: string): number { - const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/) - if (!m) return new Date(date).getTime() - const [y, mo, d] = date.split('-').map(Number) - const offsetH = m[3] ? parseFloat(m[3]) : 0 - return Date.UTC(y, mo - 1, d, parseInt(m[1]) - offsetH, parseInt(m[2])) -} - -function formatKickoff(date: string, time: string | null | undefined): string { - if (!time) return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) - const ms = utcKickoff(date, time) - const local = new Date(ms) - const today = new Date() - const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1) - const isToday = local.toDateString() === today.toDateString() - const isTomorrow = local.toDateString() === tomorrow.toDateString() - const day = isToday ? 'Today' : isTomorrow ? 'Tomorrow' - : local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) - return `${day} · ${local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}` +export const metadata: Metadata = { + title: '2026 Group Stage', + description: 'Live standings for all 12 groups at the 2026 FIFA World Cup — results, upcoming fixtures and qualification picture.', + openGraph: { + title: '2026 FIFA World Cup Group Stage', + description: 'Live standings for all 12 groups at the 2026 FIFA World Cup.', + url: '/groups', + }, } export default function GroupsPage() { - const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 }) - - useEffect(() => { document.title = 'Group Stage · World Cup' }, []) - - const standings: Standing[] = data?.groupStandings ?? [] - const allMatches: MatchRow[] = data?.matches ?? [] - - const byGroup = standings.reduce>((acc, s) => { - acc[s.groupName] = [...(acc[s.groupName] ?? []), s] - return acc - }, {}) - - const matchesByGroup = allMatches - .filter(m => m.group) - .reduce>((acc, m) => { - acc[m.group!] = [...(acc[m.group!] ?? []), m] - return acc - }, {}) - - const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b)) - - return ( -
-
-

2026 Groups

-

48 teams · 12 groups · Top 2 + 8 best 3rd-place advance

-
- - {loading && !data && ( -
- {Array.from({ length: 12 }).map((_, i) => ( -
- ))} -
- )} - -
- {groups.map(([groupName, rows]) => { - const sorted = [...rows].sort((a, b) => { - if (b.pts !== a.pts) return b.pts - a.pts - if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff - return b.goalsFor - a.goalsFor - }) - const letter = groupName.replace('Group ', '') - - const groupMatches = (matchesByGroup[groupName] ?? []) - .sort((a, b) => { - if (!a.date) return 1 - if (!b.date) return -1 - const ta = a.time ? utcKickoff(a.date, a.time) : new Date(a.date).getTime() - const tb = b.time ? utcKickoff(b.date!, b.time) : new Date(b.date!).getTime() - return ta - tb - }) - const played = groupMatches.filter(m => m.scoreFt) - const upcoming = groupMatches.filter(m => !m.scoreFt && !m.isLive) - const live = groupMatches.filter(m => m.isLive) - - return ( -
- {/* Header */} -
- GROUP {letter} -
- - {/* Standings */} -
- Team - PW - DL - GDPts -
- {sorted.map((t, idx) => ( - -
-
- - {t.team.name} -
- {[t.played, t.won, t.drawn, t.lost].map((v, i) => ( - {v} - ))} - - {t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff} - - {t.pts} -
- - ))} - - {/* Live matches */} - {live.length > 0 && ( -
- {live.map(m => ( - -
- LIVE - - {m.team1.name} - vs - {m.team2.name} - -
- - ))} -
- )} - - {/* Results */} - {played.length > 0 && ( -
- {played.map(m => ( - -
- - {m.team1.name} - - {m.scoreFt![0]}–{m.scoreFt![1]} - - {m.team2.name} - -
- - ))} -
- )} - - {/* Upcoming */} - {upcoming.length > 0 && ( -
- {upcoming.map(m => ( - -
- - {m.team1.name} - - {m.date ? formatKickoff(m.date, m.time) : '–'} - - {m.team2.name} - -
- - ))} -
- )} -
- ) - })} -
-
- ) + return } diff --git a/app/history/client.tsx b/app/history/client.tsx new file mode 100644 index 0000000..14bb222 --- /dev/null +++ b/app/history/client.tsx @@ -0,0 +1,109 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline' + +const HISTORY_QUERY = gql` + query History { + tournaments { + year host winner runnerUp thirdPlace fourthPlace + totalGoals matchesCount teamsCount avgGoalsPerGame + topScorers(limit: 1) { playerName goals team { name iso2 } } + } + } +` + +interface Tournament { + year: number; host: string; winner?: string | null; runnerUp?: string | null + thirdPlace?: string | null; fourthPlace?: string | null + totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null + avgGoalsPerGame?: string | number | null + topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }> +} + + +export function HistoryClient() { + + const { data, loading } = useQuery(HISTORY_QUERY) + const tournaments: Tournament[] = data?.tournaments ?? [] + const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner + + return ( +
+

+ World Cup History +

+

+ Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments +

+ + {loading && !data && ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+ )} + +
+ {tournaments.map(t => { + const inProgress = t.year === 2026 && is2026InProgress + const topScorer = t.topScorers?.[0] + return ( + +
+ {/* Year watermark */} +
+ {t.year} +
+ +
+
+
+
{t.year}
+
+ {t.host} +
+
+ + {inProgress + ?
+ IN PROGRESS +
+ : t.winner && ( +
+ +
{t.winner}
+
+ )} +
+ + {!inProgress && t.winner && t.runnerUp && ( +
+ {t.winner} + def. + {t.runnerUp} +
+ )} + +
+ {t.totalGoals != null && {t.totalGoals}} + {t.matchesCount != null && {t.matchesCount} games} + {t.teamsCount != null && 🏳 {t.teamsCount} teams} +
+ + {topScorer && ( +
+ Golden Boot: {topScorer.playerName} ({topScorer.goals}) +
+ )} +
+
+ + ) + })} +
+
+ ) +} diff --git a/app/history/page.tsx b/app/history/page.tsx index d94cfa7..033acb9 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -1,111 +1,16 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline' +import type { Metadata } from 'next' +import { HistoryClient } from './client' -const HISTORY_QUERY = gql` - query History { - tournaments { - year host winner runnerUp thirdPlace fourthPlace - totalGoals matchesCount teamsCount avgGoalsPerGame - topScorers(limit: 1) { playerName goals team { name iso2 } } - } - } -` - -interface Tournament { - year: number; host: string; winner?: string | null; runnerUp?: string | null - thirdPlace?: string | null; fourthPlace?: string | null - totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null - avgGoalsPerGame?: string | number | null - topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }> +export const metadata: Metadata = { + title: 'Tournament History', + description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026 — hosts, winners, and key statistics.', + openGraph: { + title: 'FIFA World Cup Tournament History (1930–2026)', + description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026.', + url: '/history', + }, } - export default function HistoryPage() { - useEffect(() => { document.title = 'History · World Cup' }, []) - - const { data, loading } = useQuery(HISTORY_QUERY) - const tournaments: Tournament[] = data?.tournaments ?? [] - const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner - - return ( -
-

- World Cup History -

-

- Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments -

- - {loading && !data && ( -
- {Array.from({ length: 24 }).map((_, i) => ( -
- ))} -
- )} - -
- {tournaments.map(t => { - const inProgress = t.year === 2026 && is2026InProgress - const topScorer = t.topScorers?.[0] - return ( - -
- {/* Year watermark */} -
- {t.year} -
- -
-
-
-
{t.year}
-
- {t.host} -
-
- - {inProgress - ?
- IN PROGRESS -
- : t.winner && ( -
- -
{t.winner}
-
- )} -
- - {!inProgress && t.winner && t.runnerUp && ( -
- {t.winner} - def. - {t.runnerUp} -
- )} - -
- {t.totalGoals != null && {t.totalGoals}} - {t.matchesCount != null && {t.matchesCount} games} - {t.teamsCount != null && 🏳 {t.teamsCount} teams} -
- - {topScorer && ( -
- Golden Boot: {topScorer.playerName} ({topScorer.goals}) -
- )} -
-
- - ) - })} -
-
- ) + return } diff --git a/app/layout.tsx b/app/layout.tsx index 5c1802a..0937b2d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,17 +8,31 @@ import { AppApolloProvider } from '@/components/apollo-provider' const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' }) const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' }) +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000' + export const metadata: Metadata = { - title: { default: 'World Cup', template: '%s · World Cup' }, - description: 'Comprehensive World Cup statistics from 1930 to 2026', + metadataBase: new URL(BASE_URL), + title: { default: 'World Cup Stats', template: '%s · World Cup' }, + description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.', + keywords: ['World Cup', 'FIFA', 'football', 'soccer', 'statistics', 'live scores', 'standings', '2026'], + openGraph: { + type: 'website', + siteName: 'World Cup Stats', + url: '/', + title: 'World Cup Stats', + description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.', + }, + twitter: { + card: 'summary', + title: 'World Cup Stats', + description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.', + }, icons: { icon: [ { url: '/favicon.svg', type: 'image/svg+xml' }, { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }, ], - apple: [ - { url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }, - ], + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], }, } diff --git a/app/page.tsx b/app/page.tsx index bfb23b2..3e5373c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,242 +1,16 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { LiveBadge } from '@/components/live-badge' -import { MatchCard } from '@/components/match-card' +import type { Metadata } from 'next' +import { HomeClient } from './client' -const HOME_QUERY = gql` - query Home { - tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } - liveMatches { - id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff - team1 { name iso2 slug } team2 { name iso2 slug } - } - recentMatches(limit: 9) { - id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP - team1 { name iso2 slug } team2 { name iso2 slug } - } - upcomingMatches(limit: 9) { - id year round group date time isLive isQualiPlayoff scoreFt - team1 { name iso2 slug } team2 { name iso2 slug } - } - topScorers(year: 2026, limit: 8) { - playerName goals penalties ownGoals - team { name iso2 } - } - tournament(year: 2026) { year totalGoals matchesCount avgGoalsPerGame } - } -` - -function SectionHeader({ label }: { label: string }) { - return ( -
-
- {label} -
- ) -} - -function StatPill({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value ?? '–'}
-
- ) -} - -interface UpcomingMatch { - id: number; year: number; time?: string | null; date?: string | null - team1: { name: string; iso2?: string | null } - team2: { name: string; iso2?: string | null } -} - -function formatKickoff(date: string | null | undefined, time: string | null | undefined): string { - if (!date) return '' - const today = new Date() - const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1) - - if (time) { - const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/) - if (m) { - const [y, mo, d] = date.split('-').map(Number) - const h = parseInt(m[1]), min = parseInt(m[2]) - const offsetH = m[3] ? parseFloat(m[3]) : 0 - // Compute UTC kickoff, then let the browser render in its local timezone - const local = new Date(Date.UTC(y, mo - 1, d, h - offsetH, min)) - const isToday = local.toDateString() === today.toDateString() - const isTomorrow = local.toDateString() === tomorrow.toDateString() - const dayLabel = isToday ? 'Today' : isTomorrow ? 'Tomorrow' - : local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) - const localTime = local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) - return `${dayLabel} · ${localTime}` - } - } - - // No time — fall back to venue date label only - const matchDate = new Date(date + 'T00:00:00') - const isToday = matchDate.toDateString() === today.toDateString() - const isTomorrow = matchDate.toDateString() === tomorrow.toDateString() - return isToday ? 'Today' : isTomorrow ? 'Tomorrow' - : matchDate.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) -} - -function UpcomingFixture({ match }: { match: UpcomingMatch }) { - const label = formatKickoff(match.date, match.time) - return ( - -
- -
- {match.team1.name} vs {match.team2.name} -
- - {label &&
{label}
} -
- - ) -} - -interface ScorerEntry { - playerName: string; goals: number; penalties: number - team?: { name: string; iso2?: string | null } | null -} - -interface MatchData { - id: number; year: number; round: string; group?: string | null - date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean - scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null - team1: { name: string; iso2?: string | null; slug?: string | null } - team2: { name: string; iso2?: string | null; slug?: string | null } +export const metadata: Metadata = { + title: 'World Cup 2026 — Live Scores, Groups & Stats', + description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup in USA, Canada & Mexico.', + openGraph: { + title: 'World Cup 2026 — Live Scores, Groups & Stats', + description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup.', + url: '/', + }, } export default function HomePage() { - const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 }) - - useEffect(() => { document.title = 'World Cup' }, []) - - const stats = data?.tournamentStats - const live: MatchData[] = data?.liveMatches ?? [] - const recent: MatchData[] = data?.recentMatches ?? [] - const upcoming: UpcomingMatch[] = data?.upcomingMatches ?? [] - const scorers: ScorerEntry[] = data?.topScorers ?? [] - const wc2026 = data?.tournament - - const maxGoals = Math.max(...scorers.map(s => s.goals), 1) - - return ( -
- {/* ── Hero ── */} -
-
-
- {live.length > 0 - ? - :
- - World Cup 2026 · In Progress -
- } -
-

- World Cup 2026 -

-

- USA · Canada · Mexico  ·  11 June – 19 July 2026 · 48 Teams -

-
- {stats ? <> - - - - - {wc2026 && <> - - - } - : [1,2,3,4].map(i => ( -
- ))} -
-
-
- -
- {/* Live matches */} - {live.length > 0 && ( -
- -
- {live.map(m => )} -
-
- )} - - {/* Latest result */} - {recent.length > 0 && ( -
- - -
- )} - - {/* Recent grid */} - {recent.length > 1 && ( -
- -
- {recent.slice(1).map(m => )} -
-
- )} - - {/* Upcoming */} - {upcoming.length > 0 && ( -
- -
- {upcoming.map(m => )} -
-
- )} - - {/* Golden Boot 2026 */} - {scorers.length > 0 && ( -
- -
- {scorers.map((s, i) => ( - -
- {i + 1} - {s.team && } -
-
{s.playerName}
-
{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
-
-
-
-
- {s.goals} -
- - ))} -
-

- View all-time top scorers → -

-
- )} - - {loading && !data && ( -
Loading live World Cup data…
- )} -
-
- ) + return } diff --git a/app/players/[name]/client.tsx b/app/players/[name]/client.tsx new file mode 100644 index 0000000..ce782da --- /dev/null +++ b/app/players/[name]/client.tsx @@ -0,0 +1,117 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import { use, useEffect } from 'react' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { MatchCard } from '@/components/match-card' + +const PLAYER_QUERY = gql` + query Player($name: String!) { + player(name: $name) { + playerName goals penalties ownGoals tournaments + team { id name iso2 slug } + } + } +` + +const PLAYER_MATCHES_QUERY = gql` + query PlayerMatches($name: String!) { + tournaments { year } + } +` + +interface PlayerData { + playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number + team?: { id: number; name: string; iso2?: string | null; slug: string } | null +} + +export function PlayerClient({ params }: { params: Promise<{ name: string }> }) { + const { name: encodedName } = use(params) + const name = decodeURIComponent(encodedName) + + const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } }) + const player: PlayerData | null = data?.player ?? null + + useEffect(() => { + }, [player, name]) + + // Fetch all goals for this player broken down by year + const { data: goalsData } = useQuery(gql` + query PlayerGoalsByYear { + tournaments { year } + topScorers(limit: 1000) { + playerName goals team { id } + } + } + `) + + if (loading && !data) { + return
Loading player…
+ } + + if (!player) { + return ( +
+

{name}

+

No goal data found for this player in World Cup history.

+ ← All-time scorers +
+ ) + } + + const normalGoals = player.goals - player.penalties - player.ownGoals + + return ( +
+ {/* Hero */} +
+
+ {player.team && } +
+

{player.playerName}

+ {player.team && ( + + {player.team.name} → + + )} +
+
+
{player.goals}
+
World Cup Goals
+
+
+
+ + {/* Stats breakdown */} +
+ {[ + { label: 'Total Goals', value: player.goals }, + { label: 'Open Play', value: normalGoals }, + { label: 'Penalties', value: player.penalties }, + { label: 'Tournaments', value: player.tournaments }, + ].map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ + {player.ownGoals > 0 && ( +
+ Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''} +
+ )} + + {/* Back links */} +
+ ← All-time scorers + {player.team && ( + + → {player.team.name} team page + + )} +
+
+ ) +} diff --git a/app/players/[name]/page.tsx b/app/players/[name]/page.tsx index aa692ca..9dc3d8c 100644 --- a/app/players/[name]/page.tsx +++ b/app/players/[name]/page.tsx @@ -1,118 +1,20 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { use, useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { MatchCard } from '@/components/match-card' +import type { Metadata } from 'next' +import { PlayerClient } from './client' -const PLAYER_QUERY = gql` - query Player($name: String!) { - player(name: $name) { - playerName goals penalties ownGoals tournaments - team { id name iso2 slug } - } - } -` +type Props = { params: Promise<{ name: string }> } -const PLAYER_MATCHES_QUERY = gql` - query PlayerMatches($name: String!) { - tournaments { year } - } -` - -interface PlayerData { - playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number - team?: { id: number; name: string; iso2?: string | null; slug: string } | null -} - -export default function PlayerPage({ params }: { params: Promise<{ name: string }> }) { - const { name: encodedName } = use(params) +export async function generateMetadata({ params }: Props): Promise { + const { name: encodedName } = await params const name = decodeURIComponent(encodedName) - - const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } }) - const player: PlayerData | null = data?.player ?? null - - useEffect(() => { - document.title = `${player?.playerName ?? name} · World Cup` - }, [player, name]) - - // Fetch all goals for this player broken down by year - const { data: goalsData } = useQuery(gql` - query PlayerGoalsByYear { - tournaments { year } - topScorers(limit: 1000) { - playerName goals team { id } - } - } - `) - - if (loading && !data) { - return
Loading player…
+ const title = `${name} — World Cup Goals & Stats` + const description = `${name}'s FIFA World Cup career: goals by tournament, match history and career statistics.` + return { + title, + description, + openGraph: { title, description, url: `/players/${encodedName}` }, } - - if (!player) { - return ( -
-

{name}

-

No goal data found for this player in World Cup history.

- ← All-time scorers -
- ) - } - - const normalGoals = player.goals - player.penalties - player.ownGoals - - return ( -
- {/* Hero */} -
-
- {player.team && } -
-

{player.playerName}

- {player.team && ( - - {player.team.name} → - - )} -
-
-
{player.goals}
-
World Cup Goals
-
-
-
- - {/* Stats breakdown */} -
- {[ - { label: 'Total Goals', value: player.goals }, - { label: 'Open Play', value: normalGoals }, - { label: 'Penalties', value: player.penalties }, - { label: 'Tournaments', value: player.tournaments }, - ].map(item => ( -
-
{item.label}
-
{item.value}
-
- ))} -
- - {player.ownGoals > 0 && ( -
- Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''} -
- )} - - {/* Back links */} -
- ← All-time scorers - {player.team && ( - - → {player.team.name} team page - - )} -
-
- ) +} + +export default function PlayerPage({ params }: Props) { + return } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..642a345 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,8 @@ +import type { MetadataRoute } from 'next' + +export default function robots(): MetadataRoute.Robots { + return { + rules: { userAgent: '*', allow: '/' }, + sitemap: `${(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000').replace(/\/$/, '')}/sitemap.xml`, + } +} diff --git a/app/search/client.tsx b/app/search/client.tsx new file mode 100644 index 0000000..2864321 --- /dev/null +++ b/app/search/client.tsx @@ -0,0 +1,192 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import { useSearchParams, useRouter } from 'next/navigation' +import { useState, useEffect, Suspense } from 'react' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { TrophyIcon, FireIcon } from '@heroicons/react/24/outline' + +const SEARCH_QUERY = gql` + query Search($q: String!) { + search(query: $q) { + tournaments { year host winner totalGoals matchesCount } + teams { name iso2 slug stats { appearances titles } } + players { playerName goals tournaments team { name iso2 } } + matches { + id year round group date scoreFt isQualiPlayoff + team1 { name iso2 } team2 { name iso2 } + } + } + } +` + +interface SearchMatch { + id: number; year: number; round: string; group?: string | null + date?: string | null; scoreFt?: number[] | null; isQualiPlayoff: boolean + team1: { name: string; iso2?: string | null } + team2: { name: string; iso2?: string | null } +} + +function SearchContent() { + const searchParams = useSearchParams() + const router = useRouter() + const initialQ = searchParams.get('q') ?? '' + const [q, setQ] = useState(initialQ) + const [debouncedQ, setDebouncedQ] = useState(initialQ) + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedQ(q) + if (q.trim()) router.replace(`/search?q=${encodeURIComponent(q.trim())}`, { scroll: false }) + }, 300) + return () => clearTimeout(t) + }, [q, router]) + + useEffect(() => { + }, [q]) + + const skip = debouncedQ.trim().length < 2 + const { data, loading } = useQuery(SEARCH_QUERY, { + variables: { q: debouncedQ }, + skip, + }) + + const results = data?.search + const total = skip ? 0 : ( + (results?.tournaments?.length ?? 0) + + (results?.teams?.length ?? 0) + + (results?.players?.length ?? 0) + + (results?.matches?.length ?? 0) + ) + + return ( +
+

Search

+ + {/* Search input */} +
+ setQ(e.target.value)} + placeholder="Search teams, players, tournaments…" + autoFocus + className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20" + /> + + + + {loading &&
} +
+ + {/* Prompt */} + {skip && ( +
+
🔍
+
Search for nations, players, or tournaments…
+
Examples: "Brazil", "Ronaldo", "1966"
+
+ )} + + {/* No results */} + {!skip && !loading && total === 0 && ( +
No results for "{debouncedQ}"
+ )} + + {/* Results count */} + {!skip && total > 0 && ( +
{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"
+ )} + +
+ {/* Teams */} + {results?.teams?.length > 0 && ( +
+

Teams

+
+ {results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => ( + +
+ +
+
{t.name}
+
+ {t.stats?.appearances ?? 0} WCs{t.stats?.titles ? · {t.stats.titles} : ''} +
+
+
+ + ))} +
+
+ )} + + {/* Players */} + {results?.players?.length > 0 && ( +
+

Players

+
+ {results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => ( + +
+ {p.team && } +
+
{p.playerName}
+
{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}
+
+ {p.goals} +
+ + ))} +
+
+ )} + + {/* Tournaments */} + {results?.tournaments?.length > 0 && ( +
+

Tournaments

+
+ {results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => ( + +
+
{t.year}
+
{t.host}
+ {t.winner &&
{t.winner}
} + {t.totalGoals &&
{t.totalGoals} goals
} +
+ + ))} +
+
+ )} + + {/* Matches */} + {results?.matches?.length > 0 && ( +
+

Matches

+
+ {results.matches.map((m: SearchMatch) => ( + +
+ +
{m.team1.name} vs {m.team2.name}
+ {m.scoreFt && {m.scoreFt[0]}–{m.scoreFt[1]}} + +
{m.year} · {m.round}
+
+ + ))} +
+
+ )} +
+
+ ) +} + +export function SearchClient() { + return ( + Loading…
}> + + + ) +} diff --git a/app/search/page.tsx b/app/search/page.tsx index 0471bfe..3903c75 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,193 +1,12 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { useSearchParams, useRouter } from 'next/navigation' -import { useState, useEffect, Suspense } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { TrophyIcon, FireIcon } from '@heroicons/react/24/outline' +import type { Metadata } from 'next' +import { SearchClient } from './client' -const SEARCH_QUERY = gql` - query Search($q: String!) { - search(query: $q) { - tournaments { year host winner totalGoals matchesCount } - teams { name iso2 slug stats { appearances titles } } - players { playerName goals tournaments team { name iso2 } } - matches { - id year round group date scoreFt isQualiPlayoff - team1 { name iso2 } team2 { name iso2 } - } - } - } -` - -interface SearchMatch { - id: number; year: number; round: string; group?: string | null - date?: string | null; scoreFt?: number[] | null; isQualiPlayoff: boolean - team1: { name: string; iso2?: string | null } - team2: { name: string; iso2?: string | null } -} - -function SearchContent() { - const searchParams = useSearchParams() - const router = useRouter() - const initialQ = searchParams.get('q') ?? '' - const [q, setQ] = useState(initialQ) - const [debouncedQ, setDebouncedQ] = useState(initialQ) - - useEffect(() => { - const t = setTimeout(() => { - setDebouncedQ(q) - if (q.trim()) router.replace(`/search?q=${encodeURIComponent(q.trim())}`, { scroll: false }) - }, 300) - return () => clearTimeout(t) - }, [q, router]) - - useEffect(() => { - document.title = q.trim() ? `"${q.trim()}" · World Cup` : 'Search · World Cup' - }, [q]) - - const skip = debouncedQ.trim().length < 2 - const { data, loading } = useQuery(SEARCH_QUERY, { - variables: { q: debouncedQ }, - skip, - }) - - const results = data?.search - const total = skip ? 0 : ( - (results?.tournaments?.length ?? 0) + - (results?.teams?.length ?? 0) + - (results?.players?.length ?? 0) + - (results?.matches?.length ?? 0) - ) - - return ( -
-

Search

- - {/* Search input */} -
- setQ(e.target.value)} - placeholder="Search teams, players, tournaments…" - autoFocus - className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20" - /> - - - - {loading &&
} -
- - {/* Prompt */} - {skip && ( -
-
🔍
-
Search for nations, players, or tournaments…
-
Examples: "Brazil", "Ronaldo", "1966"
-
- )} - - {/* No results */} - {!skip && !loading && total === 0 && ( -
No results for "{debouncedQ}"
- )} - - {/* Results count */} - {!skip && total > 0 && ( -
{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"
- )} - -
- {/* Teams */} - {results?.teams?.length > 0 && ( -
-

Teams

-
- {results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => ( - -
- -
-
{t.name}
-
- {t.stats?.appearances ?? 0} WCs{t.stats?.titles ? · {t.stats.titles} : ''} -
-
-
- - ))} -
-
- )} - - {/* Players */} - {results?.players?.length > 0 && ( -
-

Players

-
- {results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => ( - -
- {p.team && } -
-
{p.playerName}
-
{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}
-
- {p.goals} -
- - ))} -
-
- )} - - {/* Tournaments */} - {results?.tournaments?.length > 0 && ( -
-

Tournaments

-
- {results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => ( - -
-
{t.year}
-
{t.host}
- {t.winner &&
{t.winner}
} - {t.totalGoals &&
{t.totalGoals} goals
} -
- - ))} -
-
- )} - - {/* Matches */} - {results?.matches?.length > 0 && ( -
-

Matches

-
- {results.matches.map((m: SearchMatch) => ( - -
- -
{m.team1.name} vs {m.team2.name}
- {m.scoreFt && {m.scoreFt[0]}–{m.scoreFt[1]}} - -
{m.year} · {m.round}
-
- - ))} -
-
- )} -
-
- ) +export const metadata: Metadata = { + title: 'Search', + description: 'Search for teams, players, tournaments and stadiums across all FIFA World Cups.', + robots: { index: false }, } export default function SearchPage() { - return ( - Loading…
}> - - - ) + return } diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..095bd3b --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,42 @@ +import type { MetadataRoute } from 'next' +import { db } from '@/lib/db' +import { tournaments, teams, goals } from '@/lib/db/schema' +import { asc } from 'drizzle-orm' + +const BASE = (process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000').replace(/\/$/, '') + +function slugify(name: string) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') +} + +export default async function sitemap(): Promise { + const now = new Date() + + const [allTournaments, allTeams, allPlayers] = await Promise.all([ + db.select({ year: tournaments.year }).from(tournaments).orderBy(asc(tournaments.year)), + db.select({ name: teams.name }).from(teams).orderBy(asc(teams.name)), + db.selectDistinct({ playerName: goals.playerName }).from(goals), + ]) + + return [ + { url: BASE, lastModified: now, changeFrequency: 'hourly', priority: 1 }, + { url: `${BASE}/groups`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 }, + { url: `${BASE}/history`, changeFrequency: 'monthly', priority: 0.7 }, + { url: `${BASE}/stats`, changeFrequency: 'daily', priority: 0.7 }, + ...allTournaments.map(t => ({ + url: `${BASE}/tournaments/${t.year}`, + changeFrequency: (t.year === 2026 ? 'hourly' : 'monthly') as 'hourly' | 'monthly', + priority: t.year === 2026 ? 0.95 : 0.6, + })), + ...allTeams.map(t => ({ + url: `${BASE}/teams/${slugify(t.name)}`, + changeFrequency: 'weekly' as const, + priority: 0.5, + })), + ...allPlayers.map(p => ({ + url: `${BASE}/players/${encodeURIComponent(p.playerName)}`, + changeFrequency: 'monthly' as const, + priority: 0.4, + })), + ] +} diff --git a/app/stats/client.tsx b/app/stats/client.tsx new file mode 100644 index 0000000..16ac42e --- /dev/null +++ b/app/stats/client.tsx @@ -0,0 +1,371 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { + ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon, + FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon, +} from '@heroicons/react/24/outline' + +const STATS_QUERY = gql` + query Stats { + tournaments { year host totalGoals matchesCount avgGoalsPerGame winner } + topScorers(limit: 20) { + playerName goals penalties ownGoals tournaments + team { name iso2 slug } + } + teams { + id name iso2 slug + stats { appearances titles wins draws losses goalsFor goalsAgainst goalDiff winPct } + } + goalsByMinute { bucket count } + confederationStats { confederation appearances titles totalGoals } + hatTricks { + playerName year round goals + team { name iso2 } + opponent { name iso2 } + } + biggestWins(limit: 10) { + id year round date margin totalGoals scoreFt + team1 { name iso2 } team2 { name iso2 } + } + highestScoringMatches(limit: 10) { + id year round date totalGoals scoreFt + team1 { name iso2 } team2 { name iso2 } + } + extraTimeStats { + totalKnockoutMatches wentToExtraTime wentToPenalties extraTimePct penaltiesPct + } + } +` + +function SectionTitle({ children, icon: Icon }: { children: React.ReactNode; icon: React.ComponentType<{ className?: string }> }) { + return ( +

+ + {children} +

+ ) +} + +function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +interface Tournament { year: number; host: string; totalGoals?: number | null; matchesCount?: number | null; avgGoalsPerGame?: string | number | null; winner?: string | null } +interface Scorer { playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number; team?: { name: string; iso2?: string | null; slug: string } | null } +interface TeamRow { id: number; name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number; wins: number; draws: number; losses: number; goalsFor: number; goalsAgainst: number; winPct: number } | null } +interface MinuteBucket { bucket: string; count: number } +interface ConfStat { confederation: string; appearances: number; titles: number; totalGoals: number } +interface HatTrick { playerName: string; year: number; round: string; goals: number; team?: { name: string; iso2?: string | null } | null; opponent?: { name: string; iso2?: string | null } | null } +interface MatchRow { id: number; year: number; round: string; date?: string | null; margin?: number | null; totalGoals?: number | null; scoreFt?: number[] | null; team1: { name: string; iso2?: string | null }; team2: { name: string; iso2?: string | null } } +interface ETStats { totalKnockoutMatches: number; wentToExtraTime: number; wentToPenalties: number; extraTimePct: number; penaltiesPct: number } + +export function StatsClient() { + + const { data, loading } = useQuery(STATS_QUERY) + + const tournaments: Tournament[] = (data?.tournaments ?? []).filter((t: Tournament) => t.totalGoals != null).sort((a: Tournament, b: Tournament) => a.year - b.year) + const scorers: Scorer[] = data?.topScorers ?? [] + const teams: TeamRow[] = (data?.teams ?? []).filter((t: TeamRow) => t.stats && t.stats.appearances > 0).sort((a: TeamRow, b: TeamRow) => (b.stats?.appearances ?? 0) - (a.stats?.appearances ?? 0)) + const minuteBuckets: MinuteBucket[] = data?.goalsByMinute ?? [] + const confStats: ConfStat[] = data?.confederationStats ?? [] + const hatTricks: HatTrick[] = data?.hatTricks ?? [] + const biggestWins: MatchRow[] = data?.biggestWins ?? [] + const highScoring: MatchRow[] = data?.highestScoringMatches ?? [] + const etStats: ETStats | null = data?.extraTimeStats ?? null + + const titlesByNation = teams + .filter(t => (t.stats?.titles ?? 0) > 0) + .sort((a, b) => (b.stats?.titles ?? 0) - (a.stats?.titles ?? 0)) + .slice(0, 10) + + const maxGoals = Math.max(...tournaments.map(t => t.totalGoals ?? 0), 1) + const maxScorer = Math.max(...scorers.map(s => s.goals), 1) + const maxMinute = Math.max(...minuteBuckets.map(b => b.count), 1) + + return ( +
+

Historical Statistics

+ + {loading && !data && ( +
Loading statistics…
+ )} + + {/* ── Goals per tournament bar chart ── */} + {tournaments.length > 0 && ( +
+ Goals Scored per Tournament + +
+
+ {tournaments.map(t => { + const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140)) + const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null + return ( + +
{t.totalGoals}
+
+ + ) + })} +
+
+ {tournaments.map(t => ( +
+ {t.year} +
+ ))} +
+
+ +
+ )} + +
+ {/* ── All-time top scorers ── */} +
+ All-Time Top Scorers + + {scorers.map((s, i) => ( + +
+ {i + 1} + {s.team && } +
+
{s.playerName}
+
{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
+
+
+
+
+ {s.goals} +
+ + ))} + +
+ + {/* ── World Cup titles ── */} +
+ World Cup Titles by Nation + + {titlesByNation.map((t, i) => ( + +
+ {i + 1} + +
{t.name}
+
+ {Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => ( + + ))} +
+ {t.stats?.titles} +
+ + ))} +
+
+
+ + {/* ── Goals by minute heatmap ── */} + {minuteBuckets.length > 0 && ( +
+ Goals by Minute (All-Time) + +
+
+ {minuteBuckets.map(b => { + const h = Math.max(8, Math.round((b.count / maxMinute) * 80)) + return ( +
+ {b.count} +
+ {b.bucket} +
+ ) + })} +
+
+ +
+ )} + +
+ {/* ── Biggest wins ── */} +
+ Biggest Victories + + {biggestWins.map(m => ( + +
+ +
+
{m.team1.name} vs {m.team2.name}
+
{m.year} · {m.round}
+
+ + {m.scoreFt?.[0]}–{m.scoreFt?.[1]} + + +{m.margin} +
+ + ))} +
+
+ + {/* ── Highest scoring matches ── */} +
+ Highest Scoring Matches + + {highScoring.map(m => ( + +
+ +
+
{m.team1.name} vs {m.team2.name}
+
{m.year} · {m.round}
+
+ + {m.scoreFt?.[0]}–{m.scoreFt?.[1]} + + {m.totalGoals} goals +
+ + ))} +
+
+
+ + {/* ── Hat-tricks ── */} + {hatTricks.length > 0 && ( +
+ Hat-Tricks +
+ {hatTricks.map((h, i) => ( +
+
+ {h.team && } +
+
{h.playerName}
+
{h.team?.name}
+
+ {h.goals} +
+
+ {h.year} · {h.round} + {h.opponent && vs {h.opponent.name}} +
+
+ ))} +
+
+ )} + + {/* ── ET & Penalty stats ── */} + {etStats && ( +
+ Extra Time & Penalty Shootouts +
+ {[ + { label: 'Knockout Matches', value: etStats.totalKnockoutMatches }, + { label: 'Went to AET', value: `${etStats.wentToExtraTime} (${etStats.extraTimePct}%)` }, + { label: 'Decided by PSO', value: `${etStats.wentToPenalties} (${etStats.penaltiesPct}%)` }, + { label: 'Decided in 90min', value: etStats.totalKnockoutMatches - etStats.wentToExtraTime }, + ].map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+
+ )} + + {/* ── Confederation stats ── */} + {confStats.length > 0 && ( +
+ Performance by Confederation + + + + + + + + + + + + {confStats.map(c => ( + + + + + + + ))} + +
ConfederationAppearancesTitlesGoals
{c.confederation}{c.appearances}{c.titles}{c.totalGoals}
+
+
+ )} + + {/* ── All-time team table ── */} + {teams.length > 0 && ( +
+ All-Time Team Table + +
+ + + + {['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => ( + + ))} + + + + {teams.slice(0, 40).map((t, i) => ( + + + + + + + + + + + + + ))} + +
{h}
{i + 1} + + + {t.name} + + {t.stats?.appearances}{t.stats?.wins}{t.stats?.draws}{t.stats?.losses}{t.stats?.goalsFor}{t.stats?.goalsAgainst} + {(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0) >= 0 + ? `+${(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}` + : (t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)} + {t.stats?.winPct}%
+
+
+
+ )} +
+ ) +} diff --git a/app/stats/page.tsx b/app/stats/page.tsx index 16bb7cd..d1d2f6f 100644 --- a/app/stats/page.tsx +++ b/app/stats/page.tsx @@ -1,373 +1,16 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { - ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon, - FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon, -} from '@heroicons/react/24/outline' +import type { Metadata } from 'next' +import { StatsClient } from './client' -const STATS_QUERY = gql` - query Stats { - tournaments { year host totalGoals matchesCount avgGoalsPerGame winner } - topScorers(limit: 20) { - playerName goals penalties ownGoals tournaments - team { name iso2 slug } - } - teams { - id name iso2 slug - stats { appearances titles wins draws losses goalsFor goalsAgainst goalDiff winPct } - } - goalsByMinute { bucket count } - confederationStats { confederation appearances titles totalGoals } - hatTricks { - playerName year round goals - team { name iso2 } - opponent { name iso2 } - } - biggestWins(limit: 10) { - id year round date margin totalGoals scoreFt - team1 { name iso2 } team2 { name iso2 } - } - highestScoringMatches(limit: 10) { - id year round date totalGoals scoreFt - team1 { name iso2 } team2 { name iso2 } - } - extraTimeStats { - totalKnockoutMatches wentToExtraTime wentToPenalties extraTimePct penaltiesPct - } - } -` - -function SectionTitle({ children, icon: Icon }: { children: React.ReactNode; icon: React.ComponentType<{ className?: string }> }) { - return ( -

- - {children} -

- ) +export const metadata: Metadata = { + title: 'All-Time Statistics', + description: 'All-time FIFA World Cup statistics: top scorers, hat-tricks, penalty records, biggest victories, and goals by tournament from 1930 to 2026.', + openGraph: { + title: 'FIFA World Cup All-Time Statistics', + description: 'All-time World Cup statistics: top scorers, hat-tricks, records and more.', + url: '/stats', + }, } -function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -interface Tournament { year: number; host: string; totalGoals?: number | null; matchesCount?: number | null; avgGoalsPerGame?: string | number | null; winner?: string | null } -interface Scorer { playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number; team?: { name: string; iso2?: string | null; slug: string } | null } -interface TeamRow { id: number; name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number; wins: number; draws: number; losses: number; goalsFor: number; goalsAgainst: number; winPct: number } | null } -interface MinuteBucket { bucket: string; count: number } -interface ConfStat { confederation: string; appearances: number; titles: number; totalGoals: number } -interface HatTrick { playerName: string; year: number; round: string; goals: number; team?: { name: string; iso2?: string | null } | null; opponent?: { name: string; iso2?: string | null } | null } -interface MatchRow { id: number; year: number; round: string; date?: string | null; margin?: number | null; totalGoals?: number | null; scoreFt?: number[] | null; team1: { name: string; iso2?: string | null }; team2: { name: string; iso2?: string | null } } -interface ETStats { totalKnockoutMatches: number; wentToExtraTime: number; wentToPenalties: number; extraTimePct: number; penaltiesPct: number } - export default function StatsPage() { - useEffect(() => { document.title = 'Statistics · World Cup' }, []) - - const { data, loading } = useQuery(STATS_QUERY) - - const tournaments: Tournament[] = (data?.tournaments ?? []).filter((t: Tournament) => t.totalGoals != null).sort((a: Tournament, b: Tournament) => a.year - b.year) - const scorers: Scorer[] = data?.topScorers ?? [] - const teams: TeamRow[] = (data?.teams ?? []).filter((t: TeamRow) => t.stats && t.stats.appearances > 0).sort((a: TeamRow, b: TeamRow) => (b.stats?.appearances ?? 0) - (a.stats?.appearances ?? 0)) - const minuteBuckets: MinuteBucket[] = data?.goalsByMinute ?? [] - const confStats: ConfStat[] = data?.confederationStats ?? [] - const hatTricks: HatTrick[] = data?.hatTricks ?? [] - const biggestWins: MatchRow[] = data?.biggestWins ?? [] - const highScoring: MatchRow[] = data?.highestScoringMatches ?? [] - const etStats: ETStats | null = data?.extraTimeStats ?? null - - const titlesByNation = teams - .filter(t => (t.stats?.titles ?? 0) > 0) - .sort((a, b) => (b.stats?.titles ?? 0) - (a.stats?.titles ?? 0)) - .slice(0, 10) - - const maxGoals = Math.max(...tournaments.map(t => t.totalGoals ?? 0), 1) - const maxScorer = Math.max(...scorers.map(s => s.goals), 1) - const maxMinute = Math.max(...minuteBuckets.map(b => b.count), 1) - - return ( -
-

Historical Statistics

- - {loading && !data && ( -
Loading statistics…
- )} - - {/* ── Goals per tournament bar chart ── */} - {tournaments.length > 0 && ( -
- Goals Scored per Tournament - -
-
- {tournaments.map(t => { - const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140)) - const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null - return ( - -
{t.totalGoals}
-
- - ) - })} -
-
- {tournaments.map(t => ( -
- {t.year} -
- ))} -
-
- -
- )} - -
- {/* ── All-time top scorers ── */} -
- All-Time Top Scorers - - {scorers.map((s, i) => ( - -
- {i + 1} - {s.team && } -
-
{s.playerName}
-
{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}
-
-
-
-
- {s.goals} -
- - ))} - -
- - {/* ── World Cup titles ── */} -
- World Cup Titles by Nation - - {titlesByNation.map((t, i) => ( - -
- {i + 1} - -
{t.name}
-
- {Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => ( - - ))} -
- {t.stats?.titles} -
- - ))} -
-
-
- - {/* ── Goals by minute heatmap ── */} - {minuteBuckets.length > 0 && ( -
- Goals by Minute (All-Time) - -
-
- {minuteBuckets.map(b => { - const h = Math.max(8, Math.round((b.count / maxMinute) * 80)) - return ( -
- {b.count} -
- {b.bucket} -
- ) - })} -
-
- -
- )} - -
- {/* ── Biggest wins ── */} -
- Biggest Victories - - {biggestWins.map(m => ( - -
- -
-
{m.team1.name} vs {m.team2.name}
-
{m.year} · {m.round}
-
- - {m.scoreFt?.[0]}–{m.scoreFt?.[1]} - - +{m.margin} -
- - ))} -
-
- - {/* ── Highest scoring matches ── */} -
- Highest Scoring Matches - - {highScoring.map(m => ( - -
- -
-
{m.team1.name} vs {m.team2.name}
-
{m.year} · {m.round}
-
- - {m.scoreFt?.[0]}–{m.scoreFt?.[1]} - - {m.totalGoals} goals -
- - ))} -
-
-
- - {/* ── Hat-tricks ── */} - {hatTricks.length > 0 && ( -
- Hat-Tricks -
- {hatTricks.map((h, i) => ( -
-
- {h.team && } -
-
{h.playerName}
-
{h.team?.name}
-
- {h.goals} -
-
- {h.year} · {h.round} - {h.opponent && vs {h.opponent.name}} -
-
- ))} -
-
- )} - - {/* ── ET & Penalty stats ── */} - {etStats && ( -
- Extra Time & Penalty Shootouts -
- {[ - { label: 'Knockout Matches', value: etStats.totalKnockoutMatches }, - { label: 'Went to AET', value: `${etStats.wentToExtraTime} (${etStats.extraTimePct}%)` }, - { label: 'Decided by PSO', value: `${etStats.wentToPenalties} (${etStats.penaltiesPct}%)` }, - { label: 'Decided in 90min', value: etStats.totalKnockoutMatches - etStats.wentToExtraTime }, - ].map(s => ( -
-
{s.label}
-
{s.value}
-
- ))} -
-
- )} - - {/* ── Confederation stats ── */} - {confStats.length > 0 && ( -
- Performance by Confederation - - - - - - - - - - - - {confStats.map(c => ( - - - - - - - ))} - -
ConfederationAppearancesTitlesGoals
{c.confederation}{c.appearances}{c.titles}{c.totalGoals}
-
-
- )} - - {/* ── All-time team table ── */} - {teams.length > 0 && ( -
- All-Time Team Table - -
- - - - {['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => ( - - ))} - - - - {teams.slice(0, 40).map((t, i) => ( - - - - - - - - - - - - - ))} - -
{h}
{i + 1} - - - {t.name} - - {t.stats?.appearances}{t.stats?.wins}{t.stats?.draws}{t.stats?.losses}{t.stats?.goalsFor}{t.stats?.goalsAgainst} - {(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0) >= 0 - ? `+${(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}` - : (t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)} - {t.stats?.winPct}%
-
-
-
- )} -
- ) + return } diff --git a/app/teams/[slug]/client.tsx b/app/teams/[slug]/client.tsx new file mode 100644 index 0000000..8e00a70 --- /dev/null +++ b/app/teams/[slug]/client.tsx @@ -0,0 +1,270 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import { use, useEffect } from 'react' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { TrophyIcon } from '@heroicons/react/24/outline' + +const TEAM_QUERY = gql` + query Team($slug: String!) { + team(slug: $slug) { + id name iso2 slug fifaCode continent confederation + stats { appearances wins draws losses goalsFor goalsAgainst goalDiff titles winPct } + } + } +` +const TEAM_MATCHES_QUERY = gql` + query TeamMatches($teamId: Int!) { + matches(teamId: $teamId, isQuali: false) { + id year round group date isLive scoreFt scoreEt scoreP + team1 { name iso2 slug } team2 { name iso2 slug } + } + } +` + +interface TeamData { + id: number; name: string; iso2?: string | null; slug: string + fifaCode?: string | null; continent?: string | null; confederation?: string | null + stats?: { + appearances: number; wins: number; draws: number; losses: number + goalsFor: number; goalsAgainst: number; goalDiff: number; titles: number; winPct: number + } | null +} + +interface MatchRow { + id: number; year: number; round: string; group?: string | null + date?: string | null; isLive: boolean + scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null + team1: { name: string; iso2?: string | null; slug?: string | null } + team2: { name: string; iso2?: string | null; slug?: string | null } +} + +function formatDate(d: string) { + return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) +} + +export function TeamClient({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = use(params) + const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } }) + const team: TeamData | null = teamData?.team ?? null + + useEffect(() => { + }, [team]) + + const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, { + variables: { teamId: team?.id }, + skip: !team?.id, + }) + + const { data: scorerData } = useQuery(gql` + query TeamScorers($teamId: Int!) { + topScorers(teamId: $teamId, limit: 30) { + playerName goals penalties ownGoals tournaments + team { id name iso2 } + } + } + `, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id }) + + const teamScorers = scorerData?.topScorers ?? [] + const teamMatches: MatchRow[] = matchesData?.matches ?? [] + + // Group matches by year for the history display + const matchesByYear = teamMatches.reduce((acc: Record, m) => { + ;(acc[m.year] ??= []).push(m) + return acc + }, {}) + const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a) + + if (loading && !teamData) { + return
Loading team…
+ } + + if (!team) { + return
Team not found.
+ } + + const s = team.stats + const played = (s?.wins ?? 0) + (s?.draws ?? 0) + (s?.losses ?? 0) + const maxScorer = Math.max(...teamScorers.map((sc: { goals: number }) => sc.goals), 1) + + return ( +
+ {/* Hero */} +
+
+ +
+

{team.name}

+
+ {team.fifaCode && {team.fifaCode}} + {team.confederation && {team.confederation}} + {team.continent && {team.continent}} + {(s?.titles ?? 0) > 0 && ( + + {Array.from({ length: s?.titles ?? 0 }).map((_, i) => )} + {s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''} + + )} +
+
+
+
+ +
+
+ {/* Stats grid */} + {s && ( +
+

World Cup Record

+
+ {[ + { label: 'Appearances', value: s.appearances }, + { label: 'Matches', value: played }, + { label: 'Win %', value: `${s.winPct}%` }, + { label: 'Goals For', value: s.goalsFor }, + ].map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
+ TeamWD + LGF + GAGD +
+
+
+ + {team.name} +
+ {[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => ( + {v} + ))} + {s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff} +
+
+
+ )} + + {/* Tournament participations */} + {years.length > 0 && ( +
+

Tournament Participations

+
+ {years.map(year => ( + + {year} + + ))} +
+
+ )} + + {/* Match history by year */} + {years.length > 0 && ( +
+

Match History

+
+ {years.map(year => { + const yMatches = matchesByYear[year] + return ( +
+ + {year} + +
+ {yMatches.map((m, i) => { + const isHome = m.team1.name === team.name + const opponent = isHome ? m.team2 : m.team1 + const ft = m.scoreFt + const scoreEt = m.scoreEt + const scoreP = m.scoreP + // Winner: PSO first, then ET, then FT + const decisive = scoreP ?? scoreEt ?? ft + const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null + const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null + const result = myScore != null && theirScore != null + ? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D' + : null + const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec' + // Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts) + const displayScore = scoreP ? null : (scoreEt ?? ft) + return ( + +
+ {result ?? '–'} + +
+
{opponent.name}
+
+ {m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''} +
+
+
+
+ {scoreP + ? `${isHome ? scoreP[0] : scoreP[1]}–${isHome ? scoreP[1] : scoreP[0]}` + : displayScore + ? `${isHome ? displayScore[0] : displayScore[1]}–${isHome ? displayScore[1] : displayScore[0]}` + : '–'} +
+ {scoreP && ft && ( +
+ {`${isHome ? ft[0] : ft[1]}–${isHome ? ft[1] : ft[0]}`} a.e.t. +
+ )} + {scoreEt && !scoreP && ( +
a.e.t.
+ )} +
+
+ + ) + })} +
+
+ ) + })} +
+
+ )} +
+ + {/* Sidebar: top scorers */} +
+ {teamScorers.length > 0 && ( +
+

Top Scorers

+
+ {teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => ( + +
+ {i + 1} +
+
{sc.playerName}
+
+ {sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''} +
+
+
+
+
+ {sc.goals} +
+ + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/app/teams/[slug]/page.tsx b/app/teams/[slug]/page.tsx index 531e9c6..a101c16 100644 --- a/app/teams/[slug]/page.tsx +++ b/app/teams/[slug]/page.tsx @@ -1,271 +1,28 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { use, useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { TrophyIcon } from '@heroicons/react/24/outline' +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { teams } from '@/lib/db/schema' +import { TeamClient } from './client' -const TEAM_QUERY = gql` - query Team($slug: String!) { - team(slug: $slug) { - id name iso2 slug fifaCode continent confederation - stats { appearances wins draws losses goalsFor goalsAgainst goalDiff titles winPct } - } - } -` -const TEAM_MATCHES_QUERY = gql` - query TeamMatches($teamId: Int!) { - matches(teamId: $teamId, isQuali: false) { - id year round group date isLive scoreFt scoreEt scoreP - team1 { name iso2 slug } team2 { name iso2 slug } - } - } -` +type Props = { params: Promise<{ slug: string }> } -interface TeamData { - id: number; name: string; iso2?: string | null; slug: string - fifaCode?: string | null; continent?: string | null; confederation?: string | null - stats?: { - appearances: number; wins: number; draws: number; losses: number - goalsFor: number; goalsAgainst: number; goalDiff: number; titles: number; winPct: number - } | null +function slugify(name: string) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') } -interface MatchRow { - id: number; year: number; round: string; group?: string | null - date?: string | null; isLive: boolean - scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null - team1: { name: string; iso2?: string | null; slug?: string | null } - team2: { name: string; iso2?: string | null; slug?: string | null } -} - -function formatDate(d: string) { - return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) -} - -export default function TeamPage({ params }: { params: Promise<{ slug: string }> }) { - const { slug } = use(params) - const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } }) - const team: TeamData | null = teamData?.team ?? null - - useEffect(() => { - document.title = team ? `${team.name} · World Cup` : 'Team · World Cup' - }, [team]) - - const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, { - variables: { teamId: team?.id }, - skip: !team?.id, - }) - - const { data: scorerData } = useQuery(gql` - query TeamScorers($teamId: Int!) { - topScorers(teamId: $teamId, limit: 30) { - playerName goals penalties ownGoals tournaments - team { id name iso2 } - } - } - `, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id }) - - const teamScorers = scorerData?.topScorers ?? [] - const teamMatches: MatchRow[] = matchesData?.matches ?? [] - - // Group matches by year for the history display - const matchesByYear = teamMatches.reduce((acc: Record, m) => { - ;(acc[m.year] ??= []).push(m) - return acc - }, {}) - const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a) - - if (loading && !teamData) { - return
Loading team…
+export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + const allTeams = await db.select({ name: teams.name }).from(teams) + const team = allTeams.find(t => slugify(t.name) === slug) + const name = team?.name ?? slug + const title = `${name} at the FIFA World Cup` + const description = `${name} World Cup history — all matches, results, goals and top scorers across every tournament appearance.` + return { + title, + description, + openGraph: { title, description, url: `/teams/${slug}` }, } - - if (!team) { - return
Team not found.
- } - - const s = team.stats - const played = (s?.wins ?? 0) + (s?.draws ?? 0) + (s?.losses ?? 0) - const maxScorer = Math.max(...teamScorers.map((sc: { goals: number }) => sc.goals), 1) - - return ( -
- {/* Hero */} -
-
- -
-

{team.name}

-
- {team.fifaCode && {team.fifaCode}} - {team.confederation && {team.confederation}} - {team.continent && {team.continent}} - {(s?.titles ?? 0) > 0 && ( - - {Array.from({ length: s?.titles ?? 0 }).map((_, i) => )} - {s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''} - - )} -
-
-
-
- -
-
- {/* Stats grid */} - {s && ( -
-

World Cup Record

-
- {[ - { label: 'Appearances', value: s.appearances }, - { label: 'Matches', value: played }, - { label: 'Win %', value: `${s.winPct}%` }, - { label: 'Goals For', value: s.goalsFor }, - ].map(item => ( -
-
{item.label}
-
{item.value}
-
- ))} -
-
-
- TeamWD - LGF - GAGD -
-
-
- - {team.name} -
- {[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => ( - {v} - ))} - {s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff} -
-
-
- )} - - {/* Tournament participations */} - {years.length > 0 && ( -
-

Tournament Participations

-
- {years.map(year => ( - - {year} - - ))} -
-
- )} - - {/* Match history by year */} - {years.length > 0 && ( -
-

Match History

-
- {years.map(year => { - const yMatches = matchesByYear[year] - return ( -
- - {year} - -
- {yMatches.map((m, i) => { - const isHome = m.team1.name === team.name - const opponent = isHome ? m.team2 : m.team1 - const ft = m.scoreFt - const scoreEt = m.scoreEt - const scoreP = m.scoreP - // Winner: PSO first, then ET, then FT - const decisive = scoreP ?? scoreEt ?? ft - const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null - const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null - const result = myScore != null && theirScore != null - ? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D' - : null - const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec' - // Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts) - const displayScore = scoreP ? null : (scoreEt ?? ft) - return ( - -
- {result ?? '–'} - -
-
{opponent.name}
-
- {m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''} -
-
-
-
- {scoreP - ? `${isHome ? scoreP[0] : scoreP[1]}–${isHome ? scoreP[1] : scoreP[0]}` - : displayScore - ? `${isHome ? displayScore[0] : displayScore[1]}–${isHome ? displayScore[1] : displayScore[0]}` - : '–'} -
- {scoreP && ft && ( -
- {`${isHome ? ft[0] : ft[1]}–${isHome ? ft[1] : ft[0]}`} a.e.t. -
- )} - {scoreEt && !scoreP && ( -
a.e.t.
- )} -
-
- - ) - })} -
-
- ) - })} -
-
- )} -
- - {/* Sidebar: top scorers */} -
- {teamScorers.length > 0 && ( -
-

Top Scorers

-
- {teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => ( - -
- {i + 1} -
-
{sc.playerName}
-
- {sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''} -
-
-
-
-
- {sc.goals} -
- - ))} -
-
- )} -
-
-
- ) +} + +export default function TeamPage({ params }: Props) { + return } diff --git a/app/tournaments/[year]/client.tsx b/app/tournaments/[year]/client.tsx new file mode 100644 index 0000000..724e06b --- /dev/null +++ b/app/tournaments/[year]/client.tsx @@ -0,0 +1,284 @@ +'use client' +import { useQuery, gql } from '@/lib/graphql/hooks' +import { use, useEffect } from 'react' +import Link from 'next/link' +import { TeamFlag } from '@/components/team-flag' +import { MatchCard } from '@/components/match-card' +import { LiveBadge } from '@/components/live-badge' + +const TOURNAMENT_QUERY = gql` + query Tournament($year: Int!) { + tournament(year: $year) { + year host winner runnerUp thirdPlace fourthPlace + totalGoals matchesCount teamsCount avgGoalsPerGame + topScorers(limit: 10) { + playerName goals penalties ownGoals + team { name iso2 slug } + } + matches { + id year round group date time isLive isQualiPlayoff + scoreFt scoreHt scoreEt scoreP + team1 { id name iso2 slug } team2 { id name iso2 slug } + goals { playerName minute minuteOffset isPenalty isOwnGoal team { id } } + } + } + groupStandings(year: $year) { + groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts + team { id name iso2 slug } + } + } +` + +interface MatchData { + id: number; year: number; round: string; group?: string | null + date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean + scoreFt?: number[] | null; scoreHt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null + team1: { id: number; name: string; iso2?: string | null; slug: string } + team2: { id: number; name: string; iso2?: string | null; slug: string } + goals: Array<{ playerName: string; minute?: number | null; minuteOffset?: number | null; isPenalty: boolean; isOwnGoal: boolean; team: { id: number } }> +} + +interface Standing { + groupName: string; pos?: number | null + played: number; won: number; drawn: number; lost: number + goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number + team: { id: number; name: string; iso2?: string | null; slug: string } +} + +function GoalList({ match }: { match: MatchData }) { + if (!match.goals?.length) return null + const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id) + const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id) + const renderGoal = (g: MatchData['goals'][0], i: number) => ( + + {i > 0 && ,} + + {g.playerName} + + {' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''} + + ) + return ( +
+
{t1Goals.map(renderGoal)}
+
{t2Goals.map(renderGoal)}
+
+ ) +} + +export function TournamentClient({ params }: { params: Promise<{ year: string }> }) { + const { year: yearStr } = use(params) + const year = parseInt(yearStr) + const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 }) + + useEffect(() => { + if (!data) return + const hash = window.location.hash + if (!hash) return + const el = document.getElementById(hash.slice(1)) + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, [data]) + + + const t = data?.tournament + const standings: Standing[] = data?.groupStandings ?? [] + const byGroup = standings.reduce>((acc, s) => { + acc[s.groupName] = [...(acc[s.groupName] ?? []), s] + return acc + }, {}) + + const allMatches: MatchData[] = t?.matches ?? [] + const byRound = allMatches.reduce>((acc, m) => { + const key = m.group ?? m.round + acc[key] = [...(acc[key] ?? []), m] + return acc + }, {}) + + // Union of groups from standings + groups from match data (handles groups with no played matches yet) + const groupNames = new Set([ + ...Object.keys(byGroup), + ...allMatches.filter(m => m.group).map(m => m.group!), + ]) + const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]]) + const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff) + const koByRound = koRounds.reduce>((acc, m) => { + acc[m.round] = [...(acc[m.round] ?? []), m] + return acc + }, {}) + + const liveMatches = allMatches.filter(m => m.isLive) + const maxScorer = Math.max(...(t?.topScorers?.map((s: { goals: number }) => s.goals) ?? [1]), 1) + + if (loading && !data) { + return ( +
+
+
Loading {year} World Cup…
+
+ ) + } + + if (!t) return
Tournament {year} not found.
+ + return ( +
+ {/* Header */} +
+ {liveMatches.length > 0 &&
} +
+
+

{year}

+

{t.host}

+
+ {t.winner && ( +
+ +
{t.winner}
+ {t.runnerUp &&
def. {t.runnerUp}
} +
+ )} +
+
+ {[ + { label: 'Teams', value: t.teamsCount }, + { label: 'Matches', value: t.matchesCount }, + { label: 'Goals', value: t.totalGoals }, + { label: 'Goals/Game', value: t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(2) : null }, + ].filter(s => s.value != null).map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+
+ +
+
+ {/* Live matches first */} + {liveMatches.length > 0 && ( +
+

LIVE

+
+ {liveMatches.map(m => ( +
+ + +
+ ))} +
+
+ )} + + {/* Group stage */} + {groupRounds.length > 0 && ( +
+

Group Stage

+ {groupRounds.map(([groupName, rows]) => { + const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff) + const groupMatches = (byRound[groupName] ?? []).sort((a, b) => { + if (!a.date) return 1; if (!b.date) return -1 + const cmp = a.date.localeCompare(b.date) + if (cmp !== 0) return cmp + return (a.time ?? '').localeCompare(b.time ?? '') + }) + return ( +
+

{groupName}

+ {/* Standings mini */} +
+ {sorted.map((s, i) => ( + +
+ + {s.team.name} + {s.played} + {s.won} + {s.drawn} + {s.lost} + {s.pts} +
+ + ))} +
+ {/* Group matches */} +
+ {groupMatches.map(m => ( +
+ + +
+ ))} +
+
+ ) + })} +
+ )} + + {/* Knockout rounds */} + {Object.keys(koByRound).length > 0 && ( +
+

Knockout Stage

+ {Object.entries(koByRound).map(([round, roundMatches]) => ( +
+

{round}

+
+ {roundMatches.map(m => ( +
+ + +
+ ))} +
+
+ ))} +
+ )} +
+ + {/* Sidebar: top scorers */} +
+
+

TOP SCORERS

+
+ {t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => ( + +
+ {i + 1} + {s.team && } +
+
{s.playerName}
+ {s.penalties > 0 &&
{s.penalties} pen
} +
+
+
+
+ {s.goals} +
+ + ))} +
+ + {t.thirdPlace && ( +
+
3rd Place
+
+ + {t.thirdPlace} +
+ {t.fourthPlace && ( +
+ + {t.fourthPlace} +
+ )} +
+ )} +
+
+
+
+ ) +} diff --git a/app/tournaments/[year]/page.tsx b/app/tournaments/[year]/page.tsx index 2676ac6..1181f15 100644 --- a/app/tournaments/[year]/page.tsx +++ b/app/tournaments/[year]/page.tsx @@ -1,289 +1,26 @@ -'use client' -import { useQuery, gql } from '@/lib/graphql/hooks' -import { use, useEffect } from 'react' -import Link from 'next/link' -import { TeamFlag } from '@/components/team-flag' -import { MatchCard } from '@/components/match-card' -import { LiveBadge } from '@/components/live-badge' +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { tournaments } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { TournamentClient } from './client' -const TOURNAMENT_QUERY = gql` - query Tournament($year: Int!) { - tournament(year: $year) { - year host winner runnerUp thirdPlace fourthPlace - totalGoals matchesCount teamsCount avgGoalsPerGame - topScorers(limit: 10) { - playerName goals penalties ownGoals - team { name iso2 slug } - } - matches { - id year round group date time isLive isQualiPlayoff - scoreFt scoreHt scoreEt scoreP - team1 { id name iso2 slug } team2 { id name iso2 slug } - goals { playerName minute minuteOffset isPenalty isOwnGoal team { id } } - } - } - groupStandings(year: $year) { - groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts - team { id name iso2 slug } - } - } -` +type Props = { params: Promise<{ year: string }> } -interface MatchData { - id: number; year: number; round: string; group?: string | null - date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean - scoreFt?: number[] | null; scoreHt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null - team1: { id: number; name: string; iso2?: string | null; slug: string } - team2: { id: number; name: string; iso2?: string | null; slug: string } - goals: Array<{ playerName: string; minute?: number | null; minuteOffset?: number | null; isPenalty: boolean; isOwnGoal: boolean; team: { id: number } }> -} - -interface Standing { - groupName: string; pos?: number | null - played: number; won: number; drawn: number; lost: number - goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number - team: { id: number; name: string; iso2?: string | null; slug: string } -} - -function GoalList({ match }: { match: MatchData }) { - if (!match.goals?.length) return null - const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id) - const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id) - const renderGoal = (g: MatchData['goals'][0], i: number) => ( - - {i > 0 && ,} - - {g.playerName} - - {' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''} - - ) - return ( -
-
{t1Goals.map(renderGoal)}
-
{t2Goals.map(renderGoal)}
-
- ) -} - -export default function TournamentPage({ params }: { params: Promise<{ year: string }> }) { - const { year: yearStr } = use(params) +export async function generateMetadata({ params }: Props): Promise { + const { year: yearStr } = await params const year = parseInt(yearStr) - const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 }) - - useEffect(() => { - if (!data) return - const hash = window.location.hash - if (!hash) return - const el = document.getElementById(hash.slice(1)) - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }, [data]) - - useEffect(() => { - document.title = data?.tournament - ? `${year} World Cup · World Cup` - : `${year} · World Cup` - }, [data, year]) - - const t = data?.tournament - const standings: Standing[] = data?.groupStandings ?? [] - const byGroup = standings.reduce>((acc, s) => { - acc[s.groupName] = [...(acc[s.groupName] ?? []), s] - return acc - }, {}) - - const allMatches: MatchData[] = t?.matches ?? [] - const byRound = allMatches.reduce>((acc, m) => { - const key = m.group ?? m.round - acc[key] = [...(acc[key] ?? []), m] - return acc - }, {}) - - // Union of groups from standings + groups from match data (handles groups with no played matches yet) - const groupNames = new Set([ - ...Object.keys(byGroup), - ...allMatches.filter(m => m.group).map(m => m.group!), - ]) - const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]]) - const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff) - const koByRound = koRounds.reduce>((acc, m) => { - acc[m.round] = [...(acc[m.round] ?? []), m] - return acc - }, {}) - - const liveMatches = allMatches.filter(m => m.isLive) - const maxScorer = Math.max(...(t?.topScorers?.map((s: { goals: number }) => s.goals) ?? [1]), 1) - - if (loading && !data) { - return ( -
-
-
Loading {year} World Cup…
-
- ) + const [t] = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1) + const title = `${year} FIFA World Cup` + const description = t + ? `${year} FIFA World Cup hosted by ${t.host}.${t.winner ? ` Winner: ${t.winner}.` : ''} Matches, scores, group standings and statistics.` + : `${year} FIFA World Cup — matches, scores and statistics.` + return { + title, + description, + openGraph: { title, description, url: `/tournaments/${year}` }, } - - if (!t) return
Tournament {year} not found.
- - return ( -
- {/* Header */} -
- {liveMatches.length > 0 &&
} -
-
-

{year}

-

{t.host}

-
- {t.winner && ( -
- -
{t.winner}
- {t.runnerUp &&
def. {t.runnerUp}
} -
- )} -
-
- {[ - { label: 'Teams', value: t.teamsCount }, - { label: 'Matches', value: t.matchesCount }, - { label: 'Goals', value: t.totalGoals }, - { label: 'Goals/Game', value: t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(2) : null }, - ].filter(s => s.value != null).map(s => ( -
-
{s.label}
-
{s.value}
-
- ))} -
-
- -
-
- {/* Live matches first */} - {liveMatches.length > 0 && ( -
-

LIVE

-
- {liveMatches.map(m => ( -
- - -
- ))} -
-
- )} - - {/* Group stage */} - {groupRounds.length > 0 && ( -
-

Group Stage

- {groupRounds.map(([groupName, rows]) => { - const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff) - const groupMatches = (byRound[groupName] ?? []).sort((a, b) => { - if (!a.date) return 1; if (!b.date) return -1 - const cmp = a.date.localeCompare(b.date) - if (cmp !== 0) return cmp - return (a.time ?? '').localeCompare(b.time ?? '') - }) - return ( -
-

{groupName}

- {/* Standings mini */} -
- {sorted.map((s, i) => ( - -
- - {s.team.name} - {s.played} - {s.won} - {s.drawn} - {s.lost} - {s.pts} -
- - ))} -
- {/* Group matches */} -
- {groupMatches.map(m => ( -
- - -
- ))} -
-
- ) - })} -
- )} - - {/* Knockout rounds */} - {Object.keys(koByRound).length > 0 && ( -
-

Knockout Stage

- {Object.entries(koByRound).map(([round, roundMatches]) => ( -
-

{round}

-
- {roundMatches.map(m => ( -
- - -
- ))} -
-
- ))} -
- )} -
- - {/* Sidebar: top scorers */} -
-
-

TOP SCORERS

-
- {t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => ( - -
- {i + 1} - {s.team && } -
-
{s.playerName}
- {s.penalties > 0 &&
{s.penalties} pen
} -
-
-
-
- {s.goals} -
- - ))} -
- - {t.thirdPlace && ( -
-
3rd Place
-
- - {t.thirdPlace} -
- {t.fourthPlace && ( -
- - {t.fourthPlace} -
- )} -
- )} -
-
-
-
- ) +} + +export default function TournamentPage({ params }: Props) { + return }