767236739b
Diagonal ±45° goal-net texture on body background. All card surfaces converted from opaque #0a1810 to glass-card (backdrop-blur + semi-transparent rgba) or glass-card-hero (gradient rgba) so the net pattern shows through. Covers all pages: home, groups, history, search, stats, teams, tournaments, players, match cards, and 404. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
375 lines
20 KiB
TypeScript
375 lines
20 KiB
TypeScript
'use client'
|
||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||
import { useEffect } from 'react'
|
||
import Link from 'next/link'
|
||
import { TeamFlag } from '@/components/team-flag'
|
||
import {
|
||
ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon,
|
||
FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon,
|
||
} from '@heroicons/react/24/outline'
|
||
|
||
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-[#2a5c35] 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() {
|
||
useEffect(() => { document.title = 'Statistics · World Cup' }, [])
|
||
|
||
const { data, loading } = useQuery(STATS_QUERY)
|
||
|
||
const tournaments: Tournament[] = (data?.tournaments ?? []).filter((t: Tournament) => t.totalGoals != null).sort((a: Tournament, b: Tournament) => a.year - b.year)
|
||
const scorers: Scorer[] = data?.topScorers ?? []
|
||
const teams: TeamRow[] = (data?.teams ?? []).filter((t: TeamRow) => t.stats && t.stats.appearances > 0).sort((a: TeamRow, b: TeamRow) => (b.stats?.appearances ?? 0) - (a.stats?.appearances ?? 0))
|
||
const minuteBuckets: MinuteBucket[] = data?.goalsByMinute ?? []
|
||
const confStats: ConfStat[] = data?.confederationStats ?? []
|
||
const hatTricks: HatTrick[] = data?.hatTricks ?? []
|
||
const biggestWins: MatchRow[] = data?.biggestWins ?? []
|
||
const highScoring: MatchRow[] = data?.highestScoringMatches ?? []
|
||
const etStats: ETStats | null = data?.extraTimeStats ?? null
|
||
|
||
const titlesByNation = teams
|
||
.filter(t => (t.stats?.titles ?? 0) > 0)
|
||
.sort((a, b) => (b.stats?.titles ?? 0) - (a.stats?.titles ?? 0))
|
||
.slice(0, 10)
|
||
|
||
const maxGoals = Math.max(...tournaments.map(t => t.totalGoals ?? 0), 1)
|
||
const maxScorer = Math.max(...scorers.map(s => s.goals), 1)
|
||
const maxMinute = Math.max(...minuteBuckets.map(b => b.count), 1)
|
||
|
||
return (
|
||
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none mb-10">Historical Statistics</h1>
|
||
|
||
{loading && !data && (
|
||
<div className="text-[#2a5c35] 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-[#2a5c35] font-semibold mb-1 leading-none group-hover:text-[#22c55e]">{t.totalGoals}</div>
|
||
<div className="w-full rounded-t-sm border-t-2 transition-colors group-hover:bg-[rgba(34,197,94,0.35)]"
|
||
style={{ height: `${h}px`, background: 'rgba(34,197,94,0.18)', borderColor: 'rgba(34,197,94,0.45)' }}
|
||
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" style={{ borderColor: 'rgba(34,197,94,0.06)' }}>
|
||
{tournaments.map(t => (
|
||
<div key={t.year} className="flex-1 text-center text-[6px] text-[#1a3a22]" 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-[rgba(34,197,94,0.03)] cursor-pointer"
|
||
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
|
||
<span className="text-[11px] text-[#2a5c35] 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-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
|
||
<div className="text-[10px] text-[#2a5c35] 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" style={{ background: 'rgba(34,197,94,0.1)' }}>
|
||
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] 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 hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||
<span className="text-[11px] text-[#2a5c35] 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-[#dff5e8] 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-[#22c55e]" />
|
||
))}
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] 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-[#2a5c35] font-bold leading-none">{b.count}</span>
|
||
<div className="w-full rounded-t" style={{ height: `${h}px`, background: 'rgba(34,197,94,0.3)', border: '1px solid rgba(34,197,94,0.5)' }} />
|
||
<span className="text-[7px] sm:text-[9px] text-[#1a3a22] 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 hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||
<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-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
|
||
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
|
||
{m.scoreFt?.[0]}–{m.scoreFt?.[1]}
|
||
</span>
|
||
<span className="text-[10px] text-[#2a5c35] 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 hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||
<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-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
|
||
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
|
||
{m.scoreFt?.[0]}–{m.scoreFt?.[1]}
|
||
</span>
|
||
<span className="text-[10px] text-[#4ade80] 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-[#dff5e8]">{h.playerName}</div>
|
||
<div className="text-[10px] text-[#2a5c35]">{h.team?.name}</div>
|
||
</div>
|
||
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-[#22c55e]">{h.goals}</span>
|
||
</div>
|
||
<div className="text-[10px] text-[#2a5c35]">
|
||
{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-[#2a5c35] tracking-[0.1em] uppercase mb-2">{s.label}</div>
|
||
<div className="font-['Bebas_Neue'] text-2xl text-[#22c55e]">{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" style={{ borderColor: 'rgba(34,197,94,0.08)' }}>
|
||
<th className="text-left px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-[#2a5c35]">Confederation</th>
|
||
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-[#2a5c35]">Appearances</th>
|
||
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-[#2a5c35]">Titles</th>
|
||
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-[#2a5c35]">Goals</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{confStats.map(c => (
|
||
<tr key={c.confederation} className="border-t" style={{ borderColor: 'rgba(34,197,94,0.06)' }}>
|
||
<td className="px-4 py-3 text-sm font-medium text-[#dff5e8]">{c.confederation}</td>
|
||
<td className="px-4 py-3 text-right text-sm text-[#6abf7a]">{c.appearances}</td>
|
||
<td className="px-4 py-3 text-right font-['Bebas_Neue'] text-xl text-[#22c55e]">{c.titles}</td>
|
||
<td className="px-4 py-3 text-right text-sm text-[#6abf7a]">{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" style={{ borderColor: 'rgba(34,197,94,0.08)' }}>
|
||
{['#', '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-[#2a5c35] ${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 hover:bg-[rgba(34,197,94,0.03)]" style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||
<td className="pl-4 pr-2 py-2.5 text-[11px] text-[#2a5c35] 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-[#dff5e8] whitespace-nowrap">{t.name}</span>
|
||
</Link>
|
||
</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.appearances}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.wins}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.draws}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.losses}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.goalsFor}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">{t.stats?.goalsAgainst}</td>
|
||
<td className="px-2 py-2.5 text-right text-sm text-[#4a7a55]">
|
||
{(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-[#22c55e]">{t.stats?.winPct}%</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|