feat: SEO enhancements — server metadata, sitemap, robots, dynamic base URL

- Split all page.tsx files into server wrapper (metadata export) + client.tsx (Apollo/interactive)
- Add robots.ts and sitemap.ts (tournaments, teams, players)
- Add metadataBase, OpenGraph and Twitter card metadata to root layout
- Replace hardcoded worldcup.pivoine.art with NEXT_PUBLIC_SITE_URL env var (sitemap/robots) and relative paths (page metadata, resolved by Next.js against metadataBase)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:18:36 +02:00
parent 2bd32daae1
commit a494c80a76
19 changed files with 1968 additions and 1770 deletions
+240
View File
@@ -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 (
<div className="flex items-center gap-2.5 mb-4">
<div className="w-[3px] h-[18px] bg-green rounded-sm" />
<span className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase">{label}</span>
</div>
)
}
function StatPill({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex-1 min-w-[90px] rounded-xl p-3.5 px-5 bg-green/5 border border-green/[12%]">
<div className="text-[9px] text-green-muted tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
<div className="font-['Bebas_Neue'] text-[30px] text-green leading-none">{value ?? ''}</div>
</div>
)
}
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 (
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
<div className="glass-card rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-green/20 transition-colors cursor-pointer">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<div className="flex-1 text-[13px] text-green-sec font-medium truncate">
{match.team1.name} <span className="text-green-muted">vs</span> {match.team2.name}
</div>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
{label && <div className="text-[11px] text-green-muted whitespace-nowrap ml-1">{label}</div>}
</div>
</Link>
)
}
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 (
<div>
{/* ── Hero ── */}
<div className="pitch-grid border-b border-border" style={{
background: 'linear-gradient(145deg,rgba(10,26,14,0.9) 0%,rgba(13,36,22,0.9) 55%,rgba(10,26,14,0.9) 100%)',
padding: '52px 0 44px',
}}>
<div className="max-w-[1200px] mx-auto px-7">
<div className="mb-4">
{live.length > 0
? <LiveBadge label="Live · Group Stage in Progress" />
: <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green inline-block" />
<span className="text-[11px] font-bold text-green tracking-[0.14em] uppercase">World Cup 2026 · In Progress</span>
</div>
}
</div>
<h1 className="font-['Bebas_Neue'] text-[clamp(50px,9vw,100px)] tracking-[0.04em] text-white leading-[0.92] mb-2.5">
World Cup 2026
</h1>
<p className="text-green-muted text-sm mb-9">
USA · Canada · Mexico &nbsp;·&nbsp; 11 June 19 July 2026 · 48 Teams
</p>
<div className="flex gap-2.5 flex-wrap max-w-[760px]">
{stats ? <>
<StatPill label="Tournaments" value={stats.totalTournaments} />
<StatPill label="Matches" value={stats.totalMatches} />
<StatPill label="Goals" value={stats.totalGoals} />
<StatPill label="Goals/Game" value={stats.avgGoalsPerGame?.toFixed(2) ?? ''} />
{wc2026 && <>
<StatPill label="2026 Goals" value={wc2026.totalGoals ?? 0} />
<StatPill label="2026 Avg" value={wc2026.avgGoalsPerGame ? Number(wc2026.avgGoalsPerGame).toFixed(2) : ''} />
</>}
</> : [1,2,3,4].map(i => (
<div key={i} className="flex-1 min-w-[90px] h-20 rounded-xl animate-pulse bg-green/[4%]" />
))}
</div>
</div>
</div>
<div className="max-w-[1200px] mx-auto px-7">
{/* Live matches */}
{live.length > 0 && (
<div className="pt-9">
<SectionHeader label="Live Now" />
<div className="grid gap-4">
{live.map(m => <MatchCard key={m.id} match={m} />)}
</div>
</div>
)}
{/* Latest result */}
{recent.length > 0 && (
<div className="pt-9">
<SectionHeader label="Latest Result" />
<MatchCard match={recent[0]} />
</div>
)}
{/* Recent grid */}
{recent.length > 1 && (
<div className="pt-8">
<SectionHeader label="Recent Results" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-2.5">
{recent.slice(1).map(m => <MatchCard key={m.id} match={m} compact />)}
</div>
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="pt-8">
<SectionHeader label="Upcoming Fixtures" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2">
{upcoming.map(m => <UpcomingFixture key={m.id} match={m} />)}
</div>
</div>
)}
{/* Golden Boot 2026 */}
{scorers.length > 0 && (
<div className="pt-8 pb-16">
<SectionHeader label="2026 Golden Boot Race" />
<div className="glass-card">
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i === 0 ? 'text-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-24 h-1 rounded-full overflow-hidden flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
<p className="text-[10px] text-green-dark mt-3 text-center">
<Link href="/stats" className="hover:text-green-muted">View all-time top scorers </Link>
</p>
</div>
)}
{loading && !data && (
<div className="py-16 text-center text-green-muted text-sm">Loading live World Cup data</div>
)}
</div>
</div>
)
}
+207
View File
@@ -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<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const matchesByGroup = allMatches
.filter(m => m.group)
.reduce<Record<string, MatchRow[]>>((acc, m) => {
acc[m.group!] = [...(acc[m.group!] ?? []), m]
return acc
}, {})
const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<div className="mb-9">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none">2026 Groups</h1>
<p className="text-green-muted text-sm mt-1.5">48 teams · 12 groups · Top 2 + 8 best 3rd-place advance</p>
</div>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
{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 (
<div key={groupName} className="glass-card">
{/* Header */}
<div className="px-4 py-3 border-b border-green/10"
style={{ background: 'linear-gradient(90deg,color-mix(in srgb,var(--color-green) 12%,transparent) 0%,color-mix(in srgb,var(--color-green) 4%,transparent) 100%)' }}>
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
</div>
{/* Standings */}
<div className="grid px-4 py-2 text-[9px] text-green-muted tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<span>Team</span>
<span className="text-center">P</span><span className="text-center">W</span>
<span className="text-center">D</span><span className="text-center">L</span>
<span className="text-center">GD</span><span className="text-center">Pts</span>
</div>
{sorted.map((t, idx) => (
<Link key={t.team.id} href={`/teams/${t.team.slug}`}>
<div className={`grid px-4 py-2.5 items-center border-t border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${idx < 2 ? 'bg-green/[2.5%]' : ''}`}
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<div className="flex items-center gap-2 overflow-hidden">
<TeamFlag name={t.team.name} iso2={t.team.iso2} size="sm" />
<span className={`text-sm truncate font-medium ${idx < 2 ? 'text-text' : 'text-green-sec'}`}>{t.team.name}</span>
</div>
{[t.played, t.won, t.drawn, t.lost].map((v, i) => (
<span key={i} className="text-center text-[13px] text-green-mid">{v}</span>
))}
<span className="text-center text-[13px] text-green-mid">
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
</span>
<span className="text-center text-[13px] font-bold text-green">{t.pts}</span>
</div>
</Link>
))}
{/* Live matches */}
{live.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1.5">
{live.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 hover:opacity-80">
<span className="text-[9px] font-bold text-green-light tracking-wider animate-pulse">LIVE</span>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-[12px] text-text font-medium">{m.team1.name}</span>
<span className="text-[11px] text-green-muted mx-0.5">vs</span>
<span className="text-[12px] text-text font-medium">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Results */}
{played.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1">
{played.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="font-['Bebas_Neue'] text-[15px] text-green tabular-nums">
{m.scoreFt![0]}{m.scoreFt![1]}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="border-t border-green/[6%] px-4 py-2.5 space-y-1">
{upcoming.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="text-[10px] text-green-muted whitespace-nowrap tabular-nums">
{m.date ? formatKickoff(m.date, m.time) : ''}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
+11 -204
View File
@@ -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<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const matchesByGroup = allMatches
.filter(m => m.group)
.reduce<Record<string, MatchRow[]>>((acc, m) => {
acc[m.group!] = [...(acc[m.group!] ?? []), m]
return acc
}, {})
const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<div className="mb-9">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none">2026 Groups</h1>
<p className="text-green-muted text-sm mt-1.5">48 teams · 12 groups · Top 2 + 8 best 3rd-place advance</p>
</div>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3.5">
{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 (
<div key={groupName} className="glass-card">
{/* Header */}
<div className="px-4 py-3 border-b border-green/10"
style={{ background: 'linear-gradient(90deg,color-mix(in srgb,var(--color-green) 12%,transparent) 0%,color-mix(in srgb,var(--color-green) 4%,transparent) 100%)' }}>
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
</div>
{/* Standings */}
<div className="grid px-4 py-2 text-[9px] text-green-muted tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<span>Team</span>
<span className="text-center">P</span><span className="text-center">W</span>
<span className="text-center">D</span><span className="text-center">L</span>
<span className="text-center">GD</span><span className="text-center">Pts</span>
</div>
{sorted.map((t, idx) => (
<Link key={t.team.id} href={`/teams/${t.team.slug}`}>
<div className={`grid px-4 py-2.5 items-center border-t border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${idx < 2 ? 'bg-green/[2.5%]' : ''}`}
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<div className="flex items-center gap-2 overflow-hidden">
<TeamFlag name={t.team.name} iso2={t.team.iso2} size="sm" />
<span className={`text-sm truncate font-medium ${idx < 2 ? 'text-text' : 'text-green-sec'}`}>{t.team.name}</span>
</div>
{[t.played, t.won, t.drawn, t.lost].map((v, i) => (
<span key={i} className="text-center text-[13px] text-green-mid">{v}</span>
))}
<span className="text-center text-[13px] text-green-mid">
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
</span>
<span className="text-center text-[13px] font-bold text-green">{t.pts}</span>
</div>
</Link>
))}
{/* Live matches */}
{live.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1.5">
{live.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 hover:opacity-80">
<span className="text-[9px] font-bold text-green-light tracking-wider animate-pulse">LIVE</span>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-[12px] text-text font-medium">{m.team1.name}</span>
<span className="text-[11px] text-green-muted mx-0.5">vs</span>
<span className="text-[12px] text-text font-medium">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Results */}
{played.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1">
{played.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="font-['Bebas_Neue'] text-[15px] text-green tabular-nums">
{m.scoreFt![0]}{m.scoreFt![1]}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="border-t border-green/[6%] px-4 py-2.5 space-y-1">
{upcoming.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="text-[10px] text-green-muted whitespace-nowrap tabular-nums">
{m.date ? formatKickoff(m.date, m.time) : ''}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
return <GroupsClient />
}
+109
View File
@@ -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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-2">
World Cup History
</h1>
<p className="text-green-muted text-sm mb-9">
Every edition Uruguay 1930 through 2026 · {tournaments.length} tournaments
</p>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="h-52 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{tournaments.map(t => {
const inProgress = t.year === 2026 && is2026InProgress
const topScorer = t.topScorers?.[0]
return (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="glass-card p-5 relative cursor-pointer hover:border-green/30 transition-colors">
{/* Year watermark */}
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none text-green/[4%]">
{t.year}
</div>
<div className="relative">
<div className="flex justify-between items-start mb-3.5">
<div>
<div className="font-['Bebas_Neue'] text-[34px] text-green leading-none">{t.year}</div>
<div className="text-xs text-green-muted mt-0.5">
{t.host}
</div>
</div>
{inProgress
? <div className="text-[10px] text-green font-bold tracking-[0.12em] bg-green/10 px-2.5 py-1 rounded-full mt-1">
IN PROGRESS
</div>
: t.winner && (
<div className="text-right">
<TeamFlag name={t.winner} size="md" />
<div className="text-[11px] text-green-sec mt-0.5">{t.winner}</div>
</div>
)}
</div>
{!inProgress && t.winner && t.runnerUp && (
<div className="rounded-lg px-3 py-2 text-xs text-green-sec mb-3 bg-green/[7%]">
<span className="font-semibold text-text">{t.winner}</span>
<span className="mx-2 text-green-muted">def.</span>
{t.runnerUp}
</div>
)}
<div className="flex gap-3.5 text-[11px] text-green-muted flex-wrap">
{t.totalGoals != null && <span className="inline-flex items-center gap-1"><FireIcon className="w-3 h-3" />{t.totalGoals}</span>}
{t.matchesCount != null && <span className="inline-flex items-center gap-1"><CalendarDaysIcon className="w-3 h-3" />{t.matchesCount} games</span>}
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
</div>
{topScorer && (
<div className="mt-2 text-[10px] text-green-dark">
Golden Boot: <span className="text-green-muted">{topScorer.playerName} (<span className="inline-flex items-center gap-0.5"><FireIcon className="w-2.5 h-2.5 inline" />{topScorer.goals}</span>)</span>
</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
}
+11 -106
View File
@@ -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 (19302026)',
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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-2">
World Cup History
</h1>
<p className="text-green-muted text-sm mb-9">
Every edition Uruguay 1930 through 2026 · {tournaments.length} tournaments
</p>
{loading && !data && (
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="h-52 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
{tournaments.map(t => {
const inProgress = t.year === 2026 && is2026InProgress
const topScorer = t.topScorers?.[0]
return (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="glass-card p-5 relative cursor-pointer hover:border-green/30 transition-colors">
{/* Year watermark */}
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none text-green/[4%]">
{t.year}
</div>
<div className="relative">
<div className="flex justify-between items-start mb-3.5">
<div>
<div className="font-['Bebas_Neue'] text-[34px] text-green leading-none">{t.year}</div>
<div className="text-xs text-green-muted mt-0.5">
{t.host}
</div>
</div>
{inProgress
? <div className="text-[10px] text-green font-bold tracking-[0.12em] bg-green/10 px-2.5 py-1 rounded-full mt-1">
IN PROGRESS
</div>
: t.winner && (
<div className="text-right">
<TeamFlag name={t.winner} size="md" />
<div className="text-[11px] text-green-sec mt-0.5">{t.winner}</div>
</div>
)}
</div>
{!inProgress && t.winner && t.runnerUp && (
<div className="rounded-lg px-3 py-2 text-xs text-green-sec mb-3 bg-green/[7%]">
<span className="font-semibold text-text">{t.winner}</span>
<span className="mx-2 text-green-muted">def.</span>
{t.runnerUp}
</div>
)}
<div className="flex gap-3.5 text-[11px] text-green-muted flex-wrap">
{t.totalGoals != null && <span className="inline-flex items-center gap-1"><FireIcon className="w-3 h-3" />{t.totalGoals}</span>}
{t.matchesCount != null && <span className="inline-flex items-center gap-1"><CalendarDaysIcon className="w-3 h-3" />{t.matchesCount} games</span>}
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
</div>
{topScorer && (
<div className="mt-2 text-[10px] text-green-dark">
Golden Boot: <span className="text-green-muted">{topScorer.playerName} (<span className="inline-flex items-center gap-0.5"><FireIcon className="w-2.5 h-2.5 inline" />{topScorer.goals}</span>)</span>
</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
return <HistoryClient />
}
+19 -5
View File
@@ -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' }],
},
}
+11 -237
View File
@@ -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 (
<div className="flex items-center gap-2.5 mb-4">
<div className="w-[3px] h-[18px] bg-green rounded-sm" />
<span className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase">{label}</span>
</div>
)
}
function StatPill({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex-1 min-w-[90px] rounded-xl p-3.5 px-5 bg-green/5 border border-green/[12%]">
<div className="text-[9px] text-green-muted tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
<div className="font-['Bebas_Neue'] text-[30px] text-green leading-none">{value ?? ''}</div>
</div>
)
}
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 (
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
<div className="glass-card rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-green/20 transition-colors cursor-pointer">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<div className="flex-1 text-[13px] text-green-sec font-medium truncate">
{match.team1.name} <span className="text-green-muted">vs</span> {match.team2.name}
</div>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
{label && <div className="text-[11px] text-green-muted whitespace-nowrap ml-1">{label}</div>}
</div>
</Link>
)
}
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 (
<div>
{/* ── Hero ── */}
<div className="pitch-grid border-b border-border" style={{
background: 'linear-gradient(145deg,rgba(10,26,14,0.9) 0%,rgba(13,36,22,0.9) 55%,rgba(10,26,14,0.9) 100%)',
padding: '52px 0 44px',
}}>
<div className="max-w-[1200px] mx-auto px-7">
<div className="mb-4">
{live.length > 0
? <LiveBadge label="Live · Group Stage in Progress" />
: <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green inline-block" />
<span className="text-[11px] font-bold text-green tracking-[0.14em] uppercase">World Cup 2026 · In Progress</span>
</div>
}
</div>
<h1 className="font-['Bebas_Neue'] text-[clamp(50px,9vw,100px)] tracking-[0.04em] text-white leading-[0.92] mb-2.5">
World Cup 2026
</h1>
<p className="text-green-muted text-sm mb-9">
USA · Canada · Mexico &nbsp;·&nbsp; 11 June 19 July 2026 · 48 Teams
</p>
<div className="flex gap-2.5 flex-wrap max-w-[760px]">
{stats ? <>
<StatPill label="Tournaments" value={stats.totalTournaments} />
<StatPill label="Matches" value={stats.totalMatches} />
<StatPill label="Goals" value={stats.totalGoals} />
<StatPill label="Goals/Game" value={stats.avgGoalsPerGame?.toFixed(2) ?? ''} />
{wc2026 && <>
<StatPill label="2026 Goals" value={wc2026.totalGoals ?? 0} />
<StatPill label="2026 Avg" value={wc2026.avgGoalsPerGame ? Number(wc2026.avgGoalsPerGame).toFixed(2) : ''} />
</>}
</> : [1,2,3,4].map(i => (
<div key={i} className="flex-1 min-w-[90px] h-20 rounded-xl animate-pulse bg-green/[4%]" />
))}
</div>
</div>
</div>
<div className="max-w-[1200px] mx-auto px-7">
{/* Live matches */}
{live.length > 0 && (
<div className="pt-9">
<SectionHeader label="Live Now" />
<div className="grid gap-4">
{live.map(m => <MatchCard key={m.id} match={m} />)}
</div>
</div>
)}
{/* Latest result */}
{recent.length > 0 && (
<div className="pt-9">
<SectionHeader label="Latest Result" />
<MatchCard match={recent[0]} />
</div>
)}
{/* Recent grid */}
{recent.length > 1 && (
<div className="pt-8">
<SectionHeader label="Recent Results" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-2.5">
{recent.slice(1).map(m => <MatchCard key={m.id} match={m} compact />)}
</div>
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="pt-8">
<SectionHeader label="Upcoming Fixtures" />
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2">
{upcoming.map(m => <UpcomingFixture key={m.id} match={m} />)}
</div>
</div>
)}
{/* Golden Boot 2026 */}
{scorers.length > 0 && (
<div className="pt-8 pb-16">
<SectionHeader label="2026 Golden Boot Race" />
<div className="glass-card">
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i === 0 ? 'text-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-24 h-1 rounded-full overflow-hidden flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
<p className="text-[10px] text-green-dark mt-3 text-center">
<Link href="/stats" className="hover:text-green-muted">View all-time top scorers </Link>
</p>
</div>
)}
{loading && !data && (
<div className="py-16 text-center text-green-muted text-sm">Loading live World Cup data</div>
)}
</div>
</div>
)
return <HomeClient />
}
+117
View File
@@ -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 <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading player</div>
}
if (!player) {
return (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<h1 className="font-['Bebas_Neue'] text-[52px] text-green">{name}</h1>
<p className="text-green-muted mt-4">No goal data found for this player in World Cup history.</p>
<Link href="/stats" className="text-green text-sm mt-4 inline-block hover:underline"> All-time scorers</Link>
</div>
)
}
const normalGoals = player.goals - player.penalties - player.ownGoals
return (
<div className="max-w-[900px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
<div className="flex items-center gap-6 flex-wrap">
{player.team && <TeamFlag name={player.team.name} iso2={player.team.iso2} size="xl" />}
<div>
<h1 className="font-['Bebas_Neue'] text-[clamp(36px,6vw,64px)] text-green leading-none">{player.playerName}</h1>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green-sec text-sm mt-1 hover:text-text transition-colors inline-block">
{player.team.name}
</Link>
)}
</div>
<div className="ml-auto text-right">
<div className="font-['Bebas_Neue'] text-[80px] text-green leading-none">{player.goals}</div>
<div className="text-[10px] text-green-muted tracking-[0.12em] uppercase">World Cup Goals</div>
</div>
</div>
</div>
{/* Stats breakdown */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{[
{ label: 'Total Goals', value: player.goals },
{ label: 'Open Play', value: normalGoals },
{ label: 'Penalties', value: player.penalties },
{ label: 'Tournaments', value: player.tournaments },
].map(item => (
<div key={item.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
{player.ownGoals > 0 && (
<div className="mb-6 glass-card rounded-xl p-3 px-4 text-sm text-green-muted">
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
</div>
)}
{/* Back links */}
<div className="flex gap-4 mt-8">
<Link href="/stats" className="text-green text-sm hover:underline"> All-time scorers</Link>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green text-sm hover:underline">
{player.team.name} team page
</Link>
)}
</div>
</div>
)
}
+15 -113
View File
@@ -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<Metadata> {
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 }
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 (loading && !data) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading player</div>
}
if (!player) {
return (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<h1 className="font-['Bebas_Neue'] text-[52px] text-green">{name}</h1>
<p className="text-green-muted mt-4">No goal data found for this player in World Cup history.</p>
<Link href="/stats" className="text-green text-sm mt-4 inline-block hover:underline"> All-time scorers</Link>
</div>
)
}
const normalGoals = player.goals - player.penalties - player.ownGoals
return (
<div className="max-w-[900px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
<div className="flex items-center gap-6 flex-wrap">
{player.team && <TeamFlag name={player.team.name} iso2={player.team.iso2} size="xl" />}
<div>
<h1 className="font-['Bebas_Neue'] text-[clamp(36px,6vw,64px)] text-green leading-none">{player.playerName}</h1>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green-sec text-sm mt-1 hover:text-text transition-colors inline-block">
{player.team.name}
</Link>
)}
</div>
<div className="ml-auto text-right">
<div className="font-['Bebas_Neue'] text-[80px] text-green leading-none">{player.goals}</div>
<div className="text-[10px] text-green-muted tracking-[0.12em] uppercase">World Cup Goals</div>
</div>
</div>
</div>
{/* Stats breakdown */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{[
{ label: 'Total Goals', value: player.goals },
{ label: 'Open Play', value: normalGoals },
{ label: 'Penalties', value: player.penalties },
{ label: 'Tournaments', value: player.tournaments },
].map(item => (
<div key={item.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
{player.ownGoals > 0 && (
<div className="mb-6 glass-card rounded-xl p-3 px-4 text-sm text-green-muted">
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
</div>
)}
{/* Back links */}
<div className="flex gap-4 mt-8">
<Link href="/stats" className="text-green text-sm hover:underline"> All-time scorers</Link>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green text-sm hover:underline">
{player.team.name} team page
</Link>
)}
</div>
</div>
)
}
export default function PlayerPage({ params }: Props) {
return <PlayerClient params={params} />
}
+8
View File
@@ -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`,
}
}
+192
View File
@@ -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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-6">Search</h1>
{/* Search input */}
<div className="relative max-w-lg mb-8">
<input
type="text" value={q} onChange={e => 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"
/>
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
{loading && <div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-green border-t-transparent rounded-full animate-spin" />}
</div>
{/* Prompt */}
{skip && (
<div className="flex flex-col items-center py-20 text-center">
<div className="text-[56px] mb-5">🔍</div>
<div className="text-green-muted text-base">Search for nations, players, or tournaments</div>
<div className="text-green-dark text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
</div>
)}
{/* No results */}
{!skip && !loading && total === 0 && (
<div className="text-center text-green-dark py-16 text-sm">No results for "{debouncedQ}"</div>
)}
{/* Results count */}
{!skip && total > 0 && (
<div className="text-[13px] text-green-muted mb-6">{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"</div>
)}
<div className="flex flex-col gap-6">
{/* Teams */}
{results?.teams?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Teams</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5">
{results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
<div>
<div className="text-sm font-semibold text-text">{t.name}</div>
<div className="text-[10px] text-green-muted">
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? <span className="inline-flex items-center gap-0.5 ml-1">· {t.stats.titles}<TrophyIcon className="w-3 h-3 inline" /></span> : ''}
</div>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Players */}
{results?.players?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Players</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2.5">
{results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
<Link key={p.playerName} href={`/players/${encodeURIComponent(p.playerName)}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
{p.team && <TeamFlag name={p.team.name} iso2={p.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-text truncate">{p.playerName}</div>
<div className="text-[10px] text-green-muted">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0 inline-flex items-center gap-0.5">{p.goals}<FireIcon className="w-3.5 h-3.5" /></span>
</div>
</Link>
))}
</div>
</section>
)}
{/* Tournaments */}
{results?.tournaments?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Tournaments</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
{results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="glass-card p-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<div className="font-['Bebas_Neue'] text-3xl text-green">{t.year}</div>
<div className="text-sm text-text">{t.host}</div>
{t.winner && <div className="text-[10px] text-green-muted mt-1 flex items-center gap-1"><TrophyIcon className="w-3 h-3 flex-shrink-0" />{t.winner}</div>}
{t.totalGoals && <div className="text-[10px] text-green-dark flex items-center gap-1"><FireIcon className="w-3 h-3 flex-shrink-0" />{t.totalGoals} goals</div>}
</div>
</Link>
))}
</div>
</section>
)}
{/* Matches */}
{results?.matches?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Matches</h3>
<div className="flex flex-col gap-2">
{results.matches.map((m: SearchMatch) => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 text-sm text-text">{m.team1.name} vs {m.team2.name}</div>
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-green">{m.scoreFt[0]}{m.scoreFt[1]}</span>}
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
<div className="text-[10px] text-green-muted whitespace-nowrap">{m.year} · {m.round}</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
)
}
export function SearchClient() {
return (
<Suspense fallback={<div className="p-10 text-green-muted">Loading</div>}>
<SearchContent />
</Suspense>
)
}
+7 -188
View File
@@ -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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-6">Search</h1>
{/* Search input */}
<div className="relative max-w-lg mb-8">
<input
type="text" value={q} onChange={e => 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"
/>
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
{loading && <div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-green border-t-transparent rounded-full animate-spin" />}
</div>
{/* Prompt */}
{skip && (
<div className="flex flex-col items-center py-20 text-center">
<div className="text-[56px] mb-5">🔍</div>
<div className="text-green-muted text-base">Search for nations, players, or tournaments</div>
<div className="text-green-dark text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
</div>
)}
{/* No results */}
{!skip && !loading && total === 0 && (
<div className="text-center text-green-dark py-16 text-sm">No results for "{debouncedQ}"</div>
)}
{/* Results count */}
{!skip && total > 0 && (
<div className="text-[13px] text-green-muted mb-6">{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"</div>
)}
<div className="flex flex-col gap-6">
{/* Teams */}
{results?.teams?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Teams</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5">
{results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
<div>
<div className="text-sm font-semibold text-text">{t.name}</div>
<div className="text-[10px] text-green-muted">
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? <span className="inline-flex items-center gap-0.5 ml-1">· {t.stats.titles}<TrophyIcon className="w-3 h-3 inline" /></span> : ''}
</div>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Players */}
{results?.players?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Players</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2.5">
{results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
<Link key={p.playerName} href={`/players/${encodeURIComponent(p.playerName)}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
{p.team && <TeamFlag name={p.team.name} iso2={p.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-text truncate">{p.playerName}</div>
<div className="text-[10px] text-green-muted">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0 inline-flex items-center gap-0.5">{p.goals}<FireIcon className="w-3.5 h-3.5" /></span>
</div>
</Link>
))}
</div>
</section>
)}
{/* Tournaments */}
{results?.tournaments?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Tournaments</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
{results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="glass-card p-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<div className="font-['Bebas_Neue'] text-3xl text-green">{t.year}</div>
<div className="text-sm text-text">{t.host}</div>
{t.winner && <div className="text-[10px] text-green-muted mt-1 flex items-center gap-1"><TrophyIcon className="w-3 h-3 flex-shrink-0" />{t.winner}</div>}
{t.totalGoals && <div className="text-[10px] text-green-dark flex items-center gap-1"><FireIcon className="w-3 h-3 flex-shrink-0" />{t.totalGoals} goals</div>}
</div>
</Link>
))}
</div>
</section>
)}
{/* Matches */}
{results?.matches?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Matches</h3>
<div className="flex flex-col gap-2">
{results.matches.map((m: SearchMatch) => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 text-sm text-text">{m.team1.name} vs {m.team2.name}</div>
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-green">{m.scoreFt[0]}{m.scoreFt[1]}</span>}
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
<div className="text-[10px] text-green-muted whitespace-nowrap">{m.year} · {m.round}</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
)
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 (
<Suspense fallback={<div className="p-10 text-green-muted">Loading</div>}>
<SearchContent />
</Suspense>
)
return <SearchClient />
}
+42
View File
@@ -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<MetadataRoute.Sitemap> {
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,
})),
]
}
+371
View File
@@ -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 (
<h2 className="flex items-center gap-1.5 text-[11px] font-bold tracking-[0.14em] uppercase text-green-muted mb-4">
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{children}
</h2>
)
}
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`glass-card ${className}`}>
{children}
</div>
)
}
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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-10">Historical Statistics</h1>
{loading && !data && (
<div className="text-green-muted text-sm py-16 text-center">Loading statistics</div>
)}
{/* ── Goals per tournament bar chart ── */}
{tournaments.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ChartBarIcon}>Goals Scored per Tournament</SectionTitle>
<Card>
<div className="px-3 pt-4 pb-0 sm:px-7 sm:pt-7">
<div className="flex items-end gap-[2px] sm:gap-[3px] h-[170px]">
{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 (
<Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[8px] group">
<div className="text-[6px] sm:text-[7px] text-green-muted font-semibold mb-1 leading-none group-hover:text-green">{t.totalGoals}</div>
<div className="w-full rounded-t-sm border-t-2 border-green/45 transition-colors group-hover:bg-green/35 bg-green/[18%]"
style={{ height: `${h}px` }}
title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`}
/>
</Link>
)
})}
</div>
<div className="flex gap-[2px] sm:gap-[3px] pt-1.5 pb-3 border-t border-green/[6%]">
{tournaments.map(t => (
<div key={t.year} className="flex-1 text-center text-[6px] text-green-dark" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}>
{t.year}
</div>
))}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── All-time top scorers ── */}
<div>
<SectionTitle icon={StarIcon}>All-Time Top Scorers</SectionTitle>
<Card>
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i < 3 ? 'text-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-16 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── World Cup titles ── */}
<div>
<SectionTitle icon={TrophyIcon}>World Cup Titles by Nation</SectionTitle>
<Card>
{titlesByNation.map((t, i) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<div className="flex-1 min-w-0 text-sm font-semibold text-text truncate">{t.name}</div>
<div className="hidden sm:flex gap-0.5 flex-shrink-0">
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
<TrophyIcon key={j} className="w-4 h-4 text-green" />
))}
</div>
<span className="font-['Bebas_Neue'] text-[28px] text-green flex-shrink-0">{t.stats?.titles}</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Goals by minute heatmap ── */}
{minuteBuckets.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ClockIcon}>Goals by Minute (All-Time)</SectionTitle>
<Card>
<div className="px-3 py-4 sm:p-6">
<div className="flex items-end gap-1 sm:gap-3 h-24">
{minuteBuckets.map(b => {
const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
return (
<div key={b.bucket} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[7px] sm:text-[9px] text-green-muted font-bold leading-none">{b.count}</span>
<div className="w-full rounded-t bg-green/30 border border-green/50" style={{ height: `${h}px` }} />
<span className="text-[7px] sm:text-[9px] text-green-dark leading-none">{b.bucket}</span>
</div>
)
})}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── Biggest wins ── */}
<div>
<SectionTitle icon={BoltIcon}>Biggest Victories</SectionTitle>
<Card>
{biggestWins.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-muted flex-shrink-0">+{m.margin}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── Highest scoring matches ── */}
<div>
<SectionTitle icon={FireIcon}>Highest Scoring Matches</SectionTitle>
<Card>
{highScoring.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-light flex-shrink-0">{m.totalGoals} goals</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Hat-tricks ── */}
{hatTricks.length > 0 && (
<div className="mb-12">
<SectionTitle icon={SparklesIcon}>Hat-Tricks</SectionTitle>
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
{hatTricks.map((h, i) => (
<div key={i} className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
{h.team && <TeamFlag name={h.team.name} iso2={h.team.iso2} size="sm" />}
<div>
<div className="text-sm font-semibold text-text">{h.playerName}</div>
<div className="text-[10px] text-green-muted">{h.team?.name}</div>
</div>
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-green">{h.goals}</span>
</div>
<div className="text-[10px] text-green-muted">
{h.year} · {h.round}
{h.opponent && <span> vs {h.opponent.name}</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* ── ET & Penalty stats ── */}
{etStats && (
<div className="mb-12">
<SectionTitle icon={ArrowPathIcon}>Extra Time & Penalty Shootouts</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 => (
<div key={s.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">{s.label}</div>
<div className="font-['Bebas_Neue'] text-2xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
)}
{/* ── Confederation stats ── */}
{confStats.length > 0 && (
<div className="mb-12">
<SectionTitle icon={GlobeEuropeAfricaIcon}>Performance by Confederation</SectionTitle>
<Card>
<table className="w-full">
<thead>
<tr className="border-b border-green/8">
<th className="text-left px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Confederation</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Appearances</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Titles</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Goals</th>
</tr>
</thead>
<tbody>
{confStats.map(c => (
<tr key={c.confederation} className="border-t border-green/[6%]">
<td className="px-4 py-3 text-sm font-medium text-text">{c.confederation}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.appearances}</td>
<td className="px-4 py-3 text-right font-['Bebas_Neue'] text-xl text-green">{c.titles}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.totalGoals}</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
)}
{/* ── All-time team table ── */}
{teams.length > 0 && (
<div>
<SectionTitle icon={TableCellsIcon}>All-Time Team Table</SectionTitle>
<Card>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: '560px' }}>
<thead>
<tr className="border-b border-green/8">
{['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => (
<th key={h} className={`py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted ${i === 0 ? 'pl-4 pr-2 text-left w-8' : i === 1 ? 'px-2 text-left' : 'px-2 text-right'}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{teams.slice(0, 40).map((t, i) => (
<tr key={t.id} className="border-t border-green/5 hover:bg-green/[3%]">
<td className="pl-4 pr-2 py-2.5 text-[11px] text-green-muted font-bold">{i + 1}</td>
<td className="px-2 py-2.5">
<Link href={`/teams/${t.slug}`} className="flex items-center gap-2">
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<span className="text-sm text-text whitespace-nowrap">{t.name}</span>
</Link>
</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.appearances}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.wins}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.draws}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.losses}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsFor}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsAgainst}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">
{(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)}
</td>
<td className="px-2 pr-4 py-2.5 text-right text-[13px] font-bold text-green">{t.stats?.winPct}%</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
}
+11 -368
View File
@@ -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 (
<h2 className="flex items-center gap-1.5 text-[11px] font-bold tracking-[0.14em] uppercase text-green-muted mb-4">
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{children}
</h2>
)
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 (
<div className={`glass-card ${className}`}>
{children}
</div>
)
}
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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-10">Historical Statistics</h1>
{loading && !data && (
<div className="text-green-muted text-sm py-16 text-center">Loading statistics</div>
)}
{/* ── Goals per tournament bar chart ── */}
{tournaments.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ChartBarIcon}>Goals Scored per Tournament</SectionTitle>
<Card>
<div className="px-3 pt-4 pb-0 sm:px-7 sm:pt-7">
<div className="flex items-end gap-[2px] sm:gap-[3px] h-[170px]">
{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 (
<Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[8px] group">
<div className="text-[6px] sm:text-[7px] text-green-muted font-semibold mb-1 leading-none group-hover:text-green">{t.totalGoals}</div>
<div className="w-full rounded-t-sm border-t-2 border-green/45 transition-colors group-hover:bg-green/35 bg-green/[18%]"
style={{ height: `${h}px` }}
title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`}
/>
</Link>
)
})}
</div>
<div className="flex gap-[2px] sm:gap-[3px] pt-1.5 pb-3 border-t border-green/[6%]">
{tournaments.map(t => (
<div key={t.year} className="flex-1 text-center text-[6px] text-green-dark" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}>
{t.year}
</div>
))}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── All-time top scorers ── */}
<div>
<SectionTitle icon={StarIcon}>All-Time Top Scorers</SectionTitle>
<Card>
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i < 3 ? 'text-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-16 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── World Cup titles ── */}
<div>
<SectionTitle icon={TrophyIcon}>World Cup Titles by Nation</SectionTitle>
<Card>
{titlesByNation.map((t, i) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<span className="text-[11px] text-green-muted w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<div className="flex-1 min-w-0 text-sm font-semibold text-text truncate">{t.name}</div>
<div className="hidden sm:flex gap-0.5 flex-shrink-0">
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
<TrophyIcon key={j} className="w-4 h-4 text-green" />
))}
</div>
<span className="font-['Bebas_Neue'] text-[28px] text-green flex-shrink-0">{t.stats?.titles}</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Goals by minute heatmap ── */}
{minuteBuckets.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ClockIcon}>Goals by Minute (All-Time)</SectionTitle>
<Card>
<div className="px-3 py-4 sm:p-6">
<div className="flex items-end gap-1 sm:gap-3 h-24">
{minuteBuckets.map(b => {
const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
return (
<div key={b.bucket} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[7px] sm:text-[9px] text-green-muted font-bold leading-none">{b.count}</span>
<div className="w-full rounded-t bg-green/30 border border-green/50" style={{ height: `${h}px` }} />
<span className="text-[7px] sm:text-[9px] text-green-dark leading-none">{b.bucket}</span>
</div>
)
})}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── Biggest wins ── */}
<div>
<SectionTitle icon={BoltIcon}>Biggest Victories</SectionTitle>
<Card>
{biggestWins.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-muted flex-shrink-0">+{m.margin}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── Highest scoring matches ── */}
<div>
<SectionTitle icon={FireIcon}>Highest Scoring Matches</SectionTitle>
<Card>
{highScoring.map(m => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-light flex-shrink-0">{m.totalGoals} goals</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Hat-tricks ── */}
{hatTricks.length > 0 && (
<div className="mb-12">
<SectionTitle icon={SparklesIcon}>Hat-Tricks</SectionTitle>
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
{hatTricks.map((h, i) => (
<div key={i} className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
{h.team && <TeamFlag name={h.team.name} iso2={h.team.iso2} size="sm" />}
<div>
<div className="text-sm font-semibold text-text">{h.playerName}</div>
<div className="text-[10px] text-green-muted">{h.team?.name}</div>
</div>
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-green">{h.goals}</span>
</div>
<div className="text-[10px] text-green-muted">
{h.year} · {h.round}
{h.opponent && <span> vs {h.opponent.name}</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* ── ET & Penalty stats ── */}
{etStats && (
<div className="mb-12">
<SectionTitle icon={ArrowPathIcon}>Extra Time & Penalty Shootouts</SectionTitle>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 => (
<div key={s.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">{s.label}</div>
<div className="font-['Bebas_Neue'] text-2xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
)}
{/* ── Confederation stats ── */}
{confStats.length > 0 && (
<div className="mb-12">
<SectionTitle icon={GlobeEuropeAfricaIcon}>Performance by Confederation</SectionTitle>
<Card>
<table className="w-full">
<thead>
<tr className="border-b border-green/8">
<th className="text-left px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Confederation</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Appearances</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Titles</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Goals</th>
</tr>
</thead>
<tbody>
{confStats.map(c => (
<tr key={c.confederation} className="border-t border-green/[6%]">
<td className="px-4 py-3 text-sm font-medium text-text">{c.confederation}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.appearances}</td>
<td className="px-4 py-3 text-right font-['Bebas_Neue'] text-xl text-green">{c.titles}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.totalGoals}</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
)}
{/* ── All-time team table ── */}
{teams.length > 0 && (
<div>
<SectionTitle icon={TableCellsIcon}>All-Time Team Table</SectionTitle>
<Card>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: '560px' }}>
<thead>
<tr className="border-b border-green/8">
{['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => (
<th key={h} className={`py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted ${i === 0 ? 'pl-4 pr-2 text-left w-8' : i === 1 ? 'px-2 text-left' : 'px-2 text-right'}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{teams.slice(0, 40).map((t, i) => (
<tr key={t.id} className="border-t border-green/5 hover:bg-green/[3%]">
<td className="pl-4 pr-2 py-2.5 text-[11px] text-green-muted font-bold">{i + 1}</td>
<td className="px-2 py-2.5">
<Link href={`/teams/${t.slug}`} className="flex items-center gap-2">
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<span className="text-sm text-text whitespace-nowrap">{t.name}</span>
</Link>
</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.appearances}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.wins}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.draws}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.losses}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsFor}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsAgainst}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">
{(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)}
</td>
<td className="px-2 pr-4 py-2.5 text-right text-[13px] font-bold text-green">{t.stats?.winPct}%</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
return <StatsClient />
}
+270
View File
@@ -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<number, MatchRow[]>, m) => {
;(acc[m.year] ??= []).push(m)
return acc
}, {})
const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading team</div>
}
if (!team) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Team not found.</div>
}
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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
<div className="flex items-center gap-6 flex-wrap">
<TeamFlag name={team.name} iso2={team.iso2} size="xl" />
<div>
<h1 className="font-['Bebas_Neue'] text-[56px] text-green leading-none">{team.name}</h1>
<div className="flex gap-3 mt-2 flex-wrap">
{team.fifaCode && <span className="text-[11px] text-green-muted font-bold tracking-wider">{team.fifaCode}</span>}
{team.confederation && <span className="text-[11px] text-green-muted">{team.confederation}</span>}
{team.continent && <span className="text-[11px] text-green-muted">{team.continent}</span>}
{(s?.titles ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-green font-bold">
{Array.from({ length: s?.titles ?? 0 }).map((_, i) => <TrophyIcon key={i} className="w-3.5 h-3.5" />)}
{s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-8">
<div>
{/* Stats grid */}
{s && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">World Cup Record</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
{[
{ label: 'Appearances', value: s.appearances },
{ label: 'Matches', value: played },
{ label: 'Win %', value: `${s.winPct}%` },
{ label: 'Goals For', value: s.goalsFor },
].map(item => (
<div key={item.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
<div className="glass-card rounded-xl">
<div className="grid px-4 py-2.5 text-[9px] text-green-muted tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<span>Team</span><span className="text-center">W</span><span className="text-center">D</span>
<span className="text-center">L</span><span className="text-center">GF</span>
<span className="text-center">GA</span><span className="text-center">GD</span>
</div>
<div className="grid px-4 py-3 border-t border-green/[6%] items-center"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<div className="flex items-center gap-2">
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
<span className="text-sm text-text">{team.name}</span>
</div>
{[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
<span key={i} className="text-center text-sm text-green-mid">{v}</span>
))}
<span className="text-center text-sm text-green-mid">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
</div>
</div>
</div>
)}
{/* Tournament participations */}
{years.length > 0 && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Tournament Participations</h2>
<div className="flex flex-wrap gap-2">
{years.map(year => (
<Link key={year} href={`/tournaments/${year}`}
className="font-['Bebas_Neue'] text-lg px-3 py-1 rounded-lg transition-colors text-green-sec bg-bg/[78%] border border-border hover:text-green hover:border-green/40 backdrop-blur-sm">
{year}
</Link>
))}
</div>
</div>
)}
{/* Match history by year */}
{years.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Match History</h2>
<div className="space-y-6">
{years.map(year => {
const yMatches = matchesByYear[year]
return (
<div key={year}>
<Link href={`/tournaments/${year}`}
className="inline-block font-['Bebas_Neue'] text-[22px] text-green mb-2 hover:opacity-70 transition-opacity">
{year}
</Link>
<div className="glass-card rounded-xl">
{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 (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className={`flex items-center gap-3 px-3 sm:px-4 py-2.5 border-b hover:bg-green/[3%] transition-colors border-green/[6%] ${i % 2 !== 0 ? 'bg-green/[1%]' : ''}`}>
<span className={`text-[11px] font-bold w-4 flex-shrink-0 ${resultColor}`}>{result ?? ''}</span>
<TeamFlag name={opponent.name} iso2={opponent.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text truncate">{opponent.name}</div>
<div className="text-[10px] text-green-muted">
{m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-['Bebas_Neue'] text-lg text-green leading-none">
{scoreP
? `${isHome ? scoreP[0] : scoreP[1]}${isHome ? scoreP[1] : scoreP[0]}`
: displayScore
? `${isHome ? displayScore[0] : displayScore[1]}${isHome ? displayScore[1] : displayScore[0]}`
: ''}
</div>
{scoreP && ft && (
<div className="text-[9px] text-green-muted leading-none">
{`${isHome ? ft[0] : ft[1]}${isHome ? ft[1] : ft[0]}`} a.e.t.
</div>
)}
{scoreEt && !scoreP && (
<div className="text-[9px] text-green-muted leading-none">a.e.t.</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
{teamScorers.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
<div className="glass-card">
{teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
<Link key={sc.playerName} href={`/players/${encodeURIComponent(sc.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{sc.playerName}</div>
<div className="text-[10px] text-green-muted">
{sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
</div>
</div>
<div className="w-10 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{sc.goals}</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+21 -264
View File
@@ -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 }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
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}` },
}
}
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<number, MatchRow[]>, m) => {
;(acc[m.year] ??= []).push(m)
return acc
}, {})
const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading team</div>
}
if (!team) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Team not found.</div>
}
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 (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
<div className="flex items-center gap-6 flex-wrap">
<TeamFlag name={team.name} iso2={team.iso2} size="xl" />
<div>
<h1 className="font-['Bebas_Neue'] text-[56px] text-green leading-none">{team.name}</h1>
<div className="flex gap-3 mt-2 flex-wrap">
{team.fifaCode && <span className="text-[11px] text-green-muted font-bold tracking-wider">{team.fifaCode}</span>}
{team.confederation && <span className="text-[11px] text-green-muted">{team.confederation}</span>}
{team.continent && <span className="text-[11px] text-green-muted">{team.continent}</span>}
{(s?.titles ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-green font-bold">
{Array.from({ length: s?.titles ?? 0 }).map((_, i) => <TrophyIcon key={i} className="w-3.5 h-3.5" />)}
{s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-8">
<div>
{/* Stats grid */}
{s && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">World Cup Record</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
{[
{ label: 'Appearances', value: s.appearances },
{ label: 'Matches', value: played },
{ label: 'Win %', value: `${s.winPct}%` },
{ label: 'Goals For', value: s.goalsFor },
].map(item => (
<div key={item.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
<div className="glass-card rounded-xl">
<div className="grid px-4 py-2.5 text-[9px] text-green-muted tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<span>Team</span><span className="text-center">W</span><span className="text-center">D</span>
<span className="text-center">L</span><span className="text-center">GF</span>
<span className="text-center">GA</span><span className="text-center">GD</span>
</div>
<div className="grid px-4 py-3 border-t border-green/[6%] items-center"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<div className="flex items-center gap-2">
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
<span className="text-sm text-text">{team.name}</span>
</div>
{[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
<span key={i} className="text-center text-sm text-green-mid">{v}</span>
))}
<span className="text-center text-sm text-green-mid">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
</div>
</div>
</div>
)}
{/* Tournament participations */}
{years.length > 0 && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Tournament Participations</h2>
<div className="flex flex-wrap gap-2">
{years.map(year => (
<Link key={year} href={`/tournaments/${year}`}
className="font-['Bebas_Neue'] text-lg px-3 py-1 rounded-lg transition-colors text-green-sec bg-bg/[78%] border border-border hover:text-green hover:border-green/40 backdrop-blur-sm">
{year}
</Link>
))}
</div>
</div>
)}
{/* Match history by year */}
{years.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Match History</h2>
<div className="space-y-6">
{years.map(year => {
const yMatches = matchesByYear[year]
return (
<div key={year}>
<Link href={`/tournaments/${year}`}
className="inline-block font-['Bebas_Neue'] text-[22px] text-green mb-2 hover:opacity-70 transition-opacity">
{year}
</Link>
<div className="glass-card rounded-xl">
{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 (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className={`flex items-center gap-3 px-3 sm:px-4 py-2.5 border-b hover:bg-green/[3%] transition-colors border-green/[6%] ${i % 2 !== 0 ? 'bg-green/[1%]' : ''}`}>
<span className={`text-[11px] font-bold w-4 flex-shrink-0 ${resultColor}`}>{result ?? ''}</span>
<TeamFlag name={opponent.name} iso2={opponent.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text truncate">{opponent.name}</div>
<div className="text-[10px] text-green-muted">
{m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-['Bebas_Neue'] text-lg text-green leading-none">
{scoreP
? `${isHome ? scoreP[0] : scoreP[1]}${isHome ? scoreP[1] : scoreP[0]}`
: displayScore
? `${isHome ? displayScore[0] : displayScore[1]}${isHome ? displayScore[1] : displayScore[0]}`
: ''}
</div>
{scoreP && ft && (
<div className="text-[9px] text-green-muted leading-none">
{`${isHome ? ft[0] : ft[1]}${isHome ? ft[1] : ft[0]}`} a.e.t.
</div>
)}
{scoreEt && !scoreP && (
<div className="text-[9px] text-green-muted leading-none">a.e.t.</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
{teamScorers.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
<div className="glass-card">
{teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
<Link key={sc.playerName} href={`/players/${encodeURIComponent(sc.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{sc.playerName}</div>
<div className="text-[10px] text-green-muted">
{sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
</div>
</div>
<div className="w-10 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{sc.goals}</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
export default function TeamPage({ params }: Props) {
return <TeamClient params={params} />
}
+284
View File
@@ -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) => (
<span key={i}>
{i > 0 && <span className="mx-0.5">,</span>}
<Link href={`/players/${encodeURIComponent(g.playerName)}`}
className="underline decoration-dotted underline-offset-2 hover:text-green hover:decoration-solid transition-colors">
{g.playerName}
</Link>
{' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
</span>
)
return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-green-muted">
<div className="text-left">{t1Goals.map(renderGoal)}</div>
<div className="text-right">{t2Goals.map(renderGoal)}</div>
</div>
)
}
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<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const allMatches: MatchData[] = t?.matches ?? []
const byRound = allMatches.reduce<Record<string, MatchData[]>>((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<Record<string, MatchData[]>>((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 (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<div className="h-24 w-48 rounded-xl animate-pulse mb-6 bg-card" />
<div className="text-green-muted text-sm">Loading {year} World Cup…</div>
</div>
)
}
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Tournament {year} not found.</div>
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Header */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
{liveMatches.length > 0 && <div className="mb-3"><LiveBadge label="Live Now" /></div>}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="font-['Bebas_Neue'] text-[64px] text-green leading-none">{year}</h1>
<p className="text-green-sec text-lg mt-1">{t.host}</p>
</div>
{t.winner && (
<div className="text-center">
<TeamFlag name={t.winner} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-2xl text-text">{t.winner}</div>
{t.runnerUp && <div className="text-xs text-green-muted mt-1">def. {t.runnerUp}</div>}
</div>
)}
</div>
<div className="flex gap-6 mt-4 flex-wrap">
{[
{ 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 => (
<div key={s.label}>
<div className="text-[9px] text-green-muted tracking-[0.12em] uppercase">{s.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
<div>
{/* Live matches first */}
{liveMatches.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green-light mb-4">LIVE</h2>
<div className="flex flex-col gap-4">
{liveMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} />
<GoalList match={m} />
</div>
))}
</div>
</div>
)}
{/* Group stage */}
{groupRounds.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Group Stage</h2>
{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 (
<div key={groupName} className="mb-8">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
{/* Standings mini */}
<div className="glass-card rounded-xl mb-3">
{sorted.map((s, i) => (
<Link key={s.team.id} href={`/teams/${s.team.slug}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i < 2 ? 'bg-green/[2%]' : ''}`}>
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
<span className="flex-1 text-[13px] text-green-sec truncate">{s.team.name}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.played}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.won}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.drawn}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.lost}</span>
<span className="text-[11px] font-bold text-green w-6 text-center">{s.pts}</span>
</div>
</Link>
))}
</div>
{/* Group matches */}
<div className="flex flex-col gap-2">
{groupMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Knockout rounds */}
{Object.keys(koByRound).length > 0 && (
<div>
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Knockout Stage</h2>
{Object.entries(koByRound).map(([round, roundMatches]) => (
<div key={round} className="mb-6">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{round}</h3>
<div className="flex flex-col gap-3">
{roundMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
<div className="sticky top-[76px]">
<h2 className="font-['Bebas_Neue'] text-xl text-green mb-4">TOP SCORERS</h2>
<div className="glass-card">
{t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{s.playerName}</div>
{s.penalties > 0 && <div className="text-[9px] text-green-muted">{s.penalties} pen</div>}
</div>
<div className="w-12 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
{t.thirdPlace && (
<div className="glass-card mt-4 rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">3rd Place</div>
<div className="flex items-center gap-2">
<TeamFlag name={t.thirdPlace} size="sm" />
<span className="text-sm text-green-sec">{t.thirdPlace}</span>
</div>
{t.fourthPlace && (
<div className="flex items-center gap-2 mt-1.5">
<TeamFlag name={t.fourthPlace} size="sm" />
<span className="text-sm text-green-mid">{t.fourthPlace}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+21 -284
View File
@@ -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) => (
<span key={i}>
{i > 0 && <span className="mx-0.5">,</span>}
<Link href={`/players/${encodeURIComponent(g.playerName)}`}
className="underline decoration-dotted underline-offset-2 hover:text-green hover:decoration-solid transition-colors">
{g.playerName}
</Link>
{' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
</span>
)
return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-green-muted">
<div className="text-left">{t1Goals.map(renderGoal)}</div>
<div className="text-right">{t2Goals.map(renderGoal)}</div>
</div>
)
}
export default function TournamentPage({ params }: { params: Promise<{ year: string }> }) {
const { year: yearStr } = use(params)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
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<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const allMatches: MatchData[] = t?.matches ?? []
const byRound = allMatches.reduce<Record<string, MatchData[]>>((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<Record<string, MatchData[]>>((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 (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<div className="h-24 w-48 rounded-xl animate-pulse mb-6 bg-card" />
<div className="text-green-muted text-sm">Loading {year} World Cup…</div>
</div>
)
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 <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Tournament {year} not found.</div>
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Header */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
{liveMatches.length > 0 && <div className="mb-3"><LiveBadge label="Live Now" /></div>}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="font-['Bebas_Neue'] text-[64px] text-green leading-none">{year}</h1>
<p className="text-green-sec text-lg mt-1">{t.host}</p>
</div>
{t.winner && (
<div className="text-center">
<TeamFlag name={t.winner} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-2xl text-text">{t.winner}</div>
{t.runnerUp && <div className="text-xs text-green-muted mt-1">def. {t.runnerUp}</div>}
</div>
)}
</div>
<div className="flex gap-6 mt-4 flex-wrap">
{[
{ 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 => (
<div key={s.label}>
<div className="text-[9px] text-green-muted tracking-[0.12em] uppercase">{s.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
<div>
{/* Live matches first */}
{liveMatches.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green-light mb-4">LIVE</h2>
<div className="flex flex-col gap-4">
{liveMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} />
<GoalList match={m} />
</div>
))}
</div>
</div>
)}
{/* Group stage */}
{groupRounds.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Group Stage</h2>
{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 (
<div key={groupName} className="mb-8">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
{/* Standings mini */}
<div className="glass-card rounded-xl mb-3">
{sorted.map((s, i) => (
<Link key={s.team.id} href={`/teams/${s.team.slug}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i < 2 ? 'bg-green/[2%]' : ''}`}>
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
<span className="flex-1 text-[13px] text-green-sec truncate">{s.team.name}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.played}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.won}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.drawn}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.lost}</span>
<span className="text-[11px] font-bold text-green w-6 text-center">{s.pts}</span>
</div>
</Link>
))}
</div>
{/* Group matches */}
<div className="flex flex-col gap-2">
{groupMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Knockout rounds */}
{Object.keys(koByRound).length > 0 && (
<div>
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Knockout Stage</h2>
{Object.entries(koByRound).map(([round, roundMatches]) => (
<div key={round} className="mb-6">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{round}</h3>
<div className="flex flex-col gap-3">
{roundMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
<div className="sticky top-[76px]">
<h2 className="font-['Bebas_Neue'] text-xl text-green mb-4">TOP SCORERS</h2>
<div className="glass-card">
{t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{s.playerName}</div>
{s.penalties > 0 && <div className="text-[9px] text-green-muted">{s.penalties} pen</div>}
</div>
<div className="w-12 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
{t.thirdPlace && (
<div className="glass-card mt-4 rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">3rd Place</div>
<div className="flex items-center gap-2">
<TeamFlag name={t.thirdPlace} size="sm" />
<span className="text-sm text-green-sec">{t.thirdPlace}</span>
</div>
{t.fourthPlace && (
<div className="flex items-center gap-2 mt-1.5">
<TeamFlag name={t.fourthPlace} size="sm" />
<span className="text-sm text-green-mid">{t.fourthPlace}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default function TournamentPage({ params }: Props) {
return <TournamentClient params={params} />
}