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:
+240
@@ -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 · 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,209 +1,16 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { GroupsClient } from './client'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { TeamFlag } from '@/components/team-flag'
|
|
||||||
|
|
||||||
const GROUPS_QUERY = gql`
|
export const metadata: Metadata = {
|
||||||
query Groups {
|
title: '2026 Group Stage',
|
||||||
groupStandings(year: 2026) {
|
description: 'Live standings for all 12 groups at the 2026 FIFA World Cup — results, upcoming fixtures and qualification picture.',
|
||||||
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
|
openGraph: {
|
||||||
team { id name iso2 slug }
|
title: '2026 FIFA World Cup Group Stage',
|
||||||
}
|
description: 'Live standings for all 12 groups at the 2026 FIFA World Cup.',
|
||||||
matches(year: 2026, isQuali: false) {
|
url: '/groups',
|
||||||
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 default function GroupsPage() {
|
export default function GroupsPage() {
|
||||||
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
return <GroupsClient />
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,111 +1,16 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { HistoryClient } from './client'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { TeamFlag } from '@/components/team-flag'
|
|
||||||
import { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
const HISTORY_QUERY = gql`
|
export const metadata: Metadata = {
|
||||||
query History {
|
title: 'Tournament History',
|
||||||
tournaments {
|
description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026 — hosts, winners, and key statistics.',
|
||||||
year host winner runnerUp thirdPlace fourthPlace
|
openGraph: {
|
||||||
totalGoals matchesCount teamsCount avgGoalsPerGame
|
title: 'FIFA World Cup Tournament History (1930–2026)',
|
||||||
topScorers(limit: 1) { playerName goals team { name iso2 } }
|
description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026.',
|
||||||
}
|
url: '/history',
|
||||||
}
|
},
|
||||||
`
|
|
||||||
|
|
||||||
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 default function HistoryPage() {
|
export default function HistoryPage() {
|
||||||
useEffect(() => { document.title = 'History · World Cup' }, [])
|
return <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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -8,17 +8,31 @@ import { AppApolloProvider } from '@/components/apollo-provider'
|
|||||||
const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' })
|
const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' })
|
||||||
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' })
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: { default: 'World Cup', template: '%s · World Cup' },
|
metadataBase: new URL(BASE_URL),
|
||||||
description: 'Comprehensive World Cup statistics from 1930 to 2026',
|
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: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||||
],
|
],
|
||||||
apple: [
|
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||||
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-237
@@ -1,242 +1,16 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { HomeClient } from './client'
|
||||||
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'
|
|
||||||
|
|
||||||
const HOME_QUERY = gql`
|
export const metadata: Metadata = {
|
||||||
query Home {
|
title: 'World Cup 2026 — Live Scores, Groups & Stats',
|
||||||
tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame }
|
description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup in USA, Canada & Mexico.',
|
||||||
liveMatches {
|
openGraph: {
|
||||||
id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff
|
title: 'World Cup 2026 — Live Scores, Groups & Stats',
|
||||||
team1 { name iso2 slug } team2 { name iso2 slug }
|
description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup.',
|
||||||
}
|
url: '/',
|
||||||
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 default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 })
|
return <HomeClient />
|
||||||
|
|
||||||
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 · 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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,118 +1,20 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { PlayerClient } from './client'
|
||||||
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`
|
type Props = { params: Promise<{ name: string }> }
|
||||||
query Player($name: String!) {
|
|
||||||
player(name: $name) {
|
|
||||||
playerName goals penalties ownGoals tournaments
|
|
||||||
team { id name iso2 slug }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const PLAYER_MATCHES_QUERY = gql`
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
query PlayerMatches($name: String!) {
|
const { name: encodedName } = await params
|
||||||
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)
|
|
||||||
const name = decodeURIComponent(encodedName)
|
const name = decodeURIComponent(encodedName)
|
||||||
|
const title = `${name} — World Cup Goals & Stats`
|
||||||
const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } })
|
const description = `${name}'s FIFA World Cup career: goals by tournament, match history and career statistics.`
|
||||||
const player: PlayerData | null = data?.player ?? null
|
return {
|
||||||
|
title,
|
||||||
useEffect(() => {
|
description,
|
||||||
document.title = `${player?.playerName ?? name} · World Cup`
|
openGraph: { title, description, url: `/players/${encodedName}` },
|
||||||
}, [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 (
|
export default function PlayerPage({ params }: Props) {
|
||||||
<div className="max-w-[1200px] mx-auto px-7 py-10">
|
return <PlayerClient params={params} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,193 +1,12 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { SearchClient } from './client'
|
||||||
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`
|
export const metadata: Metadata = {
|
||||||
query Search($q: String!) {
|
title: 'Search',
|
||||||
search(query: $q) {
|
description: 'Search for teams, players, tournaments and stadiums across all FIFA World Cups.',
|
||||||
tournaments { year host winner totalGoals matchesCount }
|
robots: { index: false },
|
||||||
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 default function SearchPage() {
|
export default function SearchPage() {
|
||||||
return (
|
return <SearchClient />
|
||||||
<Suspense fallback={<div className="p-10 text-green-muted">Loading…</div>}>
|
|
||||||
<SearchContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -1,373 +1,16 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { StatsClient } from './client'
|
||||||
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'
|
|
||||||
|
|
||||||
const STATS_QUERY = gql`
|
export const metadata: Metadata = {
|
||||||
query Stats {
|
title: 'All-Time Statistics',
|
||||||
tournaments { year host totalGoals matchesCount avgGoalsPerGame winner }
|
description: 'All-time FIFA World Cup statistics: top scorers, hat-tricks, penalty records, biggest victories, and goals by tournament from 1930 to 2026.',
|
||||||
topScorers(limit: 20) {
|
openGraph: {
|
||||||
playerName goals penalties ownGoals tournaments
|
title: 'FIFA World Cup All-Time Statistics',
|
||||||
team { name iso2 slug }
|
description: 'All-time World Cup statistics: top scorers, hat-tricks, records and more.',
|
||||||
}
|
url: '/stats',
|
||||||
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 default function StatsPage() {
|
export default function StatsPage() {
|
||||||
useEffect(() => { document.title = 'Statistics · World Cup' }, [])
|
return <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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+22
-265
@@ -1,271 +1,28 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { db } from '@/lib/db'
|
||||||
import { use, useEffect } from 'react'
|
import { teams } from '@/lib/db/schema'
|
||||||
import Link from 'next/link'
|
import { TeamClient } from './client'
|
||||||
import { TeamFlag } from '@/components/team-flag'
|
|
||||||
import { TrophyIcon } from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
const TEAM_QUERY = gql`
|
type Props = { params: Promise<{ slug: string }> }
|
||||||
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 {
|
function slugify(name: string) {
|
||||||
id: number; name: string; iso2?: string | null; slug: string
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
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 {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
id: number; year: number; round: string; group?: string | null
|
const { slug } = await params
|
||||||
date?: string | null; isLive: boolean
|
const allTeams = await db.select({ name: teams.name }).from(teams)
|
||||||
scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
|
const team = allTeams.find(t => slugify(t.name) === slug)
|
||||||
team1: { name: string; iso2?: string | null; slug?: string | null }
|
const name = team?.name ?? slug
|
||||||
team2: { name: string; iso2?: string | null; slug?: string | null }
|
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 {
|
||||||
function formatDate(d: string) {
|
title,
|
||||||
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
description,
|
||||||
}
|
openGraph: { title, description, url: `/teams/${slug}` },
|
||||||
|
|
||||||
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>
|
export default function TeamPage({ params }: Props) {
|
||||||
}
|
return <TeamClient params={params} />
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,289 +1,26 @@
|
|||||||
'use client'
|
import type { Metadata } from 'next'
|
||||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
import { db } from '@/lib/db'
|
||||||
import { use, useEffect } from 'react'
|
import { tournaments } from '@/lib/db/schema'
|
||||||
import Link from 'next/link'
|
import { eq } from 'drizzle-orm'
|
||||||
import { TeamFlag } from '@/components/team-flag'
|
import { TournamentClient } from './client'
|
||||||
import { MatchCard } from '@/components/match-card'
|
|
||||||
import { LiveBadge } from '@/components/live-badge'
|
|
||||||
|
|
||||||
const TOURNAMENT_QUERY = gql`
|
type Props = { params: Promise<{ year: string }> }
|
||||||
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 {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
id: number; year: number; round: string; group?: string | null
|
const { year: yearStr } = await params
|
||||||
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)
|
|
||||||
const year = parseInt(yearStr)
|
const year = parseInt(yearStr)
|
||||||
const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 })
|
const [t] = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
|
||||||
|
const title = `${year} FIFA World Cup`
|
||||||
useEffect(() => {
|
const description = t
|
||||||
if (!data) return
|
? `${year} FIFA World Cup hosted by ${t.host}.${t.winner ? ` Winner: ${t.winner}.` : ''} Matches, scores, group standings and statistics.`
|
||||||
const hash = window.location.hash
|
: `${year} FIFA World Cup — matches, scores and statistics.`
|
||||||
if (!hash) return
|
return {
|
||||||
const el = document.getElementById(hash.slice(1))
|
title,
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
description,
|
||||||
}, [data])
|
openGraph: { title, description, url: `/tournaments/${year}` },
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Tournament {year} not found.</div>
|
|
||||||
|
export default function TournamentPage({ params }: Props) {
|
||||||
return (
|
return <TournamentClient params={params} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user