Files
worldcup/app/stats/client.tsx
T
valknar a494c80a76 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>
2026-06-15 20:18:36 +02:00

372 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}