feat: initial commit — World Cup stats app with pnpm, Traefik, Docker
Full-stack World Cup web app (1930–2026): - Next.js 16 + TailwindCSS 4 + GraphQL Yoga + Apollo Client 4 + Drizzle + PostgreSQL 16 - 23 tournaments synced from openfootball/worldcup.json (matches, goals, teams, stadiums, squads, standings) - Pages: home (live), groups, stats, history, search, /tournaments/[year], /teams/[slug], /players/[name] - Live match detection via isLive() + Apollo 60 s poll - pnpm with node-linker=hoisted for Docker compatibility - docker-compose.yml with Traefik labels (HTTPS redirect, TLS, security middleware) - docker-compose.dev.yml for local dev (DB only, port 5432 exposed) - Dockerfile: multi-stage pnpm build, standalone Next.js output, sync script bundled - .env.example with all required variables documented - Comprehensive README with local dev, deployment, schema, and GraphQL API reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { createYoga } from 'graphql-yoga'
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import { typeDefs } from '@/lib/graphql/schema'
|
||||
import { resolvers } from '@/lib/graphql/resolvers'
|
||||
|
||||
const schema = makeExecutableSchema({ typeDefs, resolvers })
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: '/api/graphql',
|
||||
fetchAPI: { Response, Request, ReadableStream },
|
||||
})
|
||||
|
||||
export const GET = yoga
|
||||
export const POST = yoga
|
||||
export const OPTIONS = yoga
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
@import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
@theme inline {
|
||||
--color-bg: #040d08;
|
||||
--color-card: #0a1810;
|
||||
--color-hero: #0d2416;
|
||||
--color-green: #22c55e;
|
||||
--color-green-light: #4ade80;
|
||||
--color-green-sec: #6abf7a;
|
||||
--color-green-muted: #2a5c35;
|
||||
--color-green-dark: #1a3a22;
|
||||
--color-text: #dff5e8;
|
||||
--color-border: rgba(34,197,94,0.15);
|
||||
|
||||
--font-display: "Bebas Neue", cursive;
|
||||
--font-body: "Space Grotesk", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: #040d08;
|
||||
color: #dff5e8;
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: #020a04; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.25); border-radius: 4px; }
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.2; transform: scale(0.6); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-live { animation: livePulse 2s ease-in-out infinite; }
|
||||
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
|
||||
.pitch-grid {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 44px,
|
||||
rgba(34,197,94,0.018) 44px,
|
||||
rgba(34,197,94,0.018) 88px
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'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 }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
||||
|
||||
const standings: Standing[] = data?.groupStandings ?? []
|
||||
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
||||
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
||||
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-[#22c55e] leading-none">2026 Groups</h1>
|
||||
<p className="text-[#2a5c35] 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(268px,1fr))] gap-3.5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="h-56 rounded-2xl animate-pulse" style={{ background: '#0a1810' }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,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 ', '')
|
||||
return (
|
||||
<div key={groupName} className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{
|
||||
background: 'linear-gradient(90deg,rgba(34,197,94,0.12) 0%,rgba(34,197,94,0.04) 100%)',
|
||||
borderColor: 'rgba(34,197,94,0.1)',
|
||||
}}>
|
||||
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] tracking-[0.05em]">GROUP {letter}</span>
|
||||
</div>
|
||||
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] 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 hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px',
|
||||
gap: '3px',
|
||||
borderColor: 'rgba(34,197,94,0.06)',
|
||||
background: idx < 2 ? 'rgba(34,197,94,0.025)' : undefined,
|
||||
}}>
|
||||
<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-[#dff5e8]' : 'text-[#6abf7a]'}`}>{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-[#4a7a55]">{v}</span>
|
||||
))}
|
||||
<span className="text-center text-[13px] text-[#4a7a55]">
|
||||
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
|
||||
</span>
|
||||
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.pts}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||
import Link from 'next/link'
|
||||
import { TeamFlag } from '@/components/team-flag'
|
||||
|
||||
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 }>
|
||||
}
|
||||
|
||||
function HostIso(host: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Uruguay': 'uy', 'Italy': 'it', 'France': 'fr', 'Brazil': 'br',
|
||||
'Switzerland': 'ch', 'Sweden': 'se', 'Chile': 'cl', 'England': 'gb-eng',
|
||||
'Mexico': 'mx', 'Germany': 'de', 'Argentina': 'ar', 'Spain': 'es',
|
||||
'South Korea / Japan': 'kr', 'South Africa': 'za', 'Russia': 'ru',
|
||||
'Qatar': 'qa', 'USA': 'us', 'USA / Canada / Mexico': 'us',
|
||||
}
|
||||
return map[host] ?? 'un'
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
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-[#22c55e] leading-none mb-2">
|
||||
World Cup History
|
||||
</h1>
|
||||
<p className="text-[#2a5c35] 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" style={{ background: '#0a1810' }} />
|
||||
))}
|
||||
</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="rounded-2xl p-5 relative overflow-hidden cursor-pointer hover:border-[rgba(34,197,94,0.3)] transition-colors"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.13)' }}>
|
||||
{/* Year watermark */}
|
||||
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none"
|
||||
style={{ color: 'rgba(34,197,94,0.04)' }}>
|
||||
{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-[#22c55e] leading-none">{t.year}</div>
|
||||
<div className="text-xs text-[#2a5c35] mt-0.5 flex items-center gap-1.5">
|
||||
<span className={`fi fi-${HostIso(t.host)} rounded-sm text-sm inline-block`} />
|
||||
{t.host}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inProgress
|
||||
? <div className="text-[10px] text-[#22c55e] font-bold tracking-[0.12em] bg-[rgba(34,197,94,0.1)] 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-[#6abf7a] mt-0.5">{t.winner}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inProgress && t.winner && t.runnerUp && (
|
||||
<div className="rounded-lg px-3 py-2 text-xs text-[#6abf7a] mb-3"
|
||||
style={{ background: 'rgba(34,197,94,0.07)' }}>
|
||||
<span className="font-semibold text-[#dff5e8]">{t.winner}</span>
|
||||
<span className="mx-2 text-[#2a5c35]">def.</span>
|
||||
{t.runnerUp}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3.5 text-[11px] text-[#2a5c35] flex-wrap">
|
||||
{t.totalGoals != null && <span>⚽ {t.totalGoals}</span>}
|
||||
{t.matchesCount != null && <span>🗓 {t.matchesCount} games</span>}
|
||||
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
|
||||
</div>
|
||||
|
||||
{topScorer && (
|
||||
<div className="mt-2 text-[10px] text-[#1a3a22]">
|
||||
Golden Boot: <span className="text-[#2a5c35]">{topScorer.playerName} ({topScorer.goals}⚽)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Bebas_Neue, Space_Grotesk } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Nav } from '@/components/nav'
|
||||
import { AppApolloProvider } from '@/components/apollo-provider'
|
||||
|
||||
const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' })
|
||||
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'World Cup', template: '%s · World Cup' },
|
||||
description: 'Comprehensive World Cup statistics from 1930 to 2026',
|
||||
icons: {
|
||||
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='46' fill='%230a1810'/><polygon points='54,38 63,50 54,62 39,57 40,42' fill='none' stroke='%2322c55e' stroke-width='3'/><line x1='54' y1='38' x2='65' y2='9' stroke='%2322c55e' stroke-width='2.5'/><line x1='63' y1='50' x2='94' y2='51' stroke='%2322c55e' stroke-width='2.5'/><line x1='54' y1='62' x2='62' y2='92' stroke='%2322c55e' stroke-width='2.5'/><line x1='39' y1='57' x2='14' y2='75' stroke='%2322c55e' stroke-width='2.5'/><line x1='40' y1='42' x2='15' y2='23' stroke='%2322c55e' stroke-width='2.5'/><circle cx='50' cy='50' r='46' fill='none' stroke='%2322c55e' stroke-width='3'/></svg>",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${bebasNeue.variable} ${spaceGrotesk.variable}`}>
|
||||
<body>
|
||||
<AppApolloProvider>
|
||||
<Nav />
|
||||
<main className="pt-[60px] min-h-screen">{children}</main>
|
||||
</AppApolloProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
'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 } team2 { name iso2 }
|
||||
}
|
||||
recentMatches(limit: 9) {
|
||||
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
|
||||
team1 { name iso2 } team2 { name iso2 }
|
||||
}
|
||||
upcomingMatches(limit: 9) {
|
||||
id year round group date time isLive isQualiPlayoff scoreFt
|
||||
team1 { name iso2 } team2 { name iso2 }
|
||||
}
|
||||
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-[#22c55e] rounded-sm" />
|
||||
<span className="text-[11px] text-[#2a5c35] 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"
|
||||
style={{ background: 'rgba(34,197,94,0.05)', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<div className="text-[9px] text-[#2a5c35] tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
|
||||
<div className="font-['Bebas_Neue'] text-[30px] text-[#22c55e] 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 UpcomingFixture({ match }: { match: UpcomingMatch }) {
|
||||
const time = match.time?.split(' ')[0] ?? ''
|
||||
return (
|
||||
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
|
||||
<div className="rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-[rgba(34,197,94,0.2)] transition-colors cursor-pointer"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.07)' }}>
|
||||
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
|
||||
<div className="flex-1 text-[13px] text-[#6abf7a] font-medium truncate">
|
||||
{match.team1.name} <span className="text-[#2a5c35]">vs</span> {match.team2.name}
|
||||
</div>
|
||||
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
|
||||
{time && <div className="text-[11px] text-[#2a5c35] whitespace-nowrap ml-1">{time}</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 }
|
||||
team2: { name: string; iso2?: string | null }
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
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" style={{
|
||||
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 55%,#0a1a0e 100%)',
|
||||
borderColor: 'rgba(34,197,94,0.15)',
|
||||
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-[#22c55e] inline-block" />
|
||||
<span className="text-[11px] font-bold text-[#22c55e] 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-[#2a5c35] text-sm mb-9">
|
||||
<span className="fi fi-us rounded-sm text-lg mx-0.5 inline-block" /> USA ·{' '}
|
||||
<span className="fi fi-ca rounded-sm text-lg mx-0.5 inline-block" /> Canada ·{' '}
|
||||
<span className="fi fi-mx rounded-sm text-lg mx-0.5 inline-block" /> 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" style={{ background: 'rgba(34,197,94,0.04)' }} />
|
||||
))}
|
||||
</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="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.16)' }}>
|
||||
{scorers.map((s, i) => (
|
||||
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
|
||||
style={{ borderColor: 'rgba(34,197,94,0.06)', 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 === 0 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
|
||||
<div className="text-[10px] text-[#2a5c35]">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
|
||||
</div>
|
||||
<div className="w-24 h-1 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
|
||||
<div className="h-full rounded-full bg-[#22c55e] transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-[#1a3a22] mt-3 text-center">
|
||||
<Link href="/stats" className="hover:text-[#2a5c35]">View all-time top scorers →</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !data && (
|
||||
<div className="py-16 text-center text-[#2a5c35] 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 } 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 default function PlayerPage({ 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
|
||||
|
||||
// Fetch all goals for this player broken down by year
|
||||
const { data: goalsData } = useQuery(gql`
|
||||
query PlayerGoalsByYear($name: String!) {
|
||||
tournaments { year }
|
||||
topScorers(limit: 1000) {
|
||||
playerName goals team { id }
|
||||
}
|
||||
}
|
||||
`, { variables: { name } })
|
||||
|
||||
if (loading && !data) {
|
||||
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">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-[#22c55e]">{name}</h1>
|
||||
<p className="text-[#2a5c35] mt-4">No goal data found for this player in World Cup history.</p>
|
||||
<Link href="/stats" className="text-[#22c55e] 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 rounded-2xl p-8 mb-8" style={{
|
||||
background: 'linear-gradient(145deg,#0a1a0e,#0d2416)',
|
||||
border: '1px solid rgba(34,197,94,0.2)',
|
||||
}}>
|
||||
<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-[#22c55e] leading-none">{player.playerName}</h1>
|
||||
{player.team && (
|
||||
<Link href={`/teams/${player.team.slug}`} className="text-[#6abf7a] text-sm mt-1 hover:text-[#dff5e8] transition-colors inline-block">
|
||||
{player.team.name} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<div className="font-['Bebas_Neue'] text-[80px] text-[#22c55e] leading-none">{player.goals}</div>
|
||||
<div className="text-[10px] text-[#2a5c35] 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="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
|
||||
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{player.ownGoals > 0 && (
|
||||
<div className="mb-6 rounded-xl p-3 px-4 text-sm text-[#2a5c35]" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.08)' }}>
|
||||
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back links */}
|
||||
<div className="flex gap-4 mt-8">
|
||||
<Link href="/stats" className="text-[#22c55e] text-sm hover:underline">← All-time scorers</Link>
|
||||
{player.team && (
|
||||
<Link href={`/teams/${player.team.slug}`} className="text-[#22c55e] text-sm hover:underline">
|
||||
→ {player.team.name} team page
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'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'
|
||||
|
||||
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])
|
||||
|
||||
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-[#22c55e] 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-[#dff5e8] text-sm outline-none"
|
||||
style={{ background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)' }}
|
||||
/>
|
||||
<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="#dff5e8" 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-[#22c55e] 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-[#2a5c35] text-base">Search for nations, players, or tournaments…</div>
|
||||
<div className="text-[#1a3a22] text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!skip && !loading && total === 0 && (
|
||||
<div className="text-center text-[#1a3a22] py-16 text-sm">No results for "{debouncedQ}"</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
{!skip && total > 0 && (
|
||||
<div className="text-[13px] text-[#2a5c35] 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-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[#dff5e8]">{t.name}</div>
|
||||
<div className="text-[10px] text-[#2a5c35]">
|
||||
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? ` · ${t.stats.titles} 🏆` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Players */}
|
||||
{results?.players?.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-[11px] text-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
{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-[#dff5e8] truncate">{p.playerName}</div>
|
||||
<div className="text-[10px] text-[#2a5c35]">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{p.goals}⚽</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tournaments */}
|
||||
{results?.tournaments?.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-[11px] text-[#2a5c35] 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="p-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{t.year}</div>
|
||||
<div className="text-sm text-[#dff5e8]">{t.host}</div>
|
||||
{t.winner && <div className="text-[10px] text-[#2a5c35] mt-1">🏆 {t.winner}</div>}
|
||||
{t.totalGoals && <div className="text-[10px] text-[#1a3a22]">⚽ {t.totalGoals} goals</div>}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Matches */}
|
||||
{results?.matches?.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-[11px] text-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
|
||||
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||||
<div className="flex-1 text-sm text-[#dff5e8]">{m.team1.name} vs {m.team2.name}</div>
|
||||
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-[#22c55e]">{m.scoreFt[0]}–{m.scoreFt[1]}</span>}
|
||||
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
|
||||
<div className="text-[10px] text-[#2a5c35] whitespace-nowrap">{m.year} · {m.round}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-[#2a5c35]">Loading…</div>}>
|
||||
<SearchContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||
import Link from 'next/link'
|
||||
import { TeamFlag } from '@/components/team-flag'
|
||||
|
||||
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 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 }: { children: React.ReactNode }) {
|
||||
return <h2 className="text-[11px] font-bold tracking-[0.14em] uppercase text-[#2a5c35] mb-4">{children}</h2>
|
||||
}
|
||||
|
||||
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-2xl overflow-hidden ${className}`} style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||
{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() {
|
||||
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>⚽ Goals Scored per Tournament</SectionTitle>
|
||||
<Card>
|
||||
<div className="p-7 pb-0">
|
||||
<div className="flex items-end 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-[16px] group">
|
||||
<div className="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-[3px] pt-1.5 pb-3.5 border-t mt-0" 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>🏅 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-3 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]">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
|
||||
</div>
|
||||
<div className="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>🏆 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-3 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 text-sm font-semibold text-[#dff5e8]">{t.name}</div>
|
||||
<div className="flex gap-0.5 flex-shrink-0">
|
||||
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
|
||||
<span key={j} className="text-sm">🏆</span>
|
||||
))}
|
||||
</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>⏱ Goals by Minute (All-Time)</SectionTitle>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-end 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.5">
|
||||
<span className="text-[9px] text-[#2a5c35] font-bold">{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-[9px] text-[#1a3a22]">{b.bucket}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||
{/* ── Biggest wins ── */}
|
||||
<div>
|
||||
<SectionTitle>💥 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>🔥 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>🎩 Hat-Tricks</SectionTitle>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
|
||||
{hatTricks.map((h, i) => (
|
||||
<div key={i} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<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>⚡ 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="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<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>🌍 Performance by Confederation</SectionTitle>
|
||||
<Card>
|
||||
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
|
||||
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px' }}>
|
||||
<span>Confederation</span><span className="text-center">Appearances</span><span className="text-center">Titles</span><span className="text-center">Goals</span>
|
||||
</div>
|
||||
{confStats.map(c => (
|
||||
<div key={c.confederation} className="grid px-4 py-3 border-t items-center"
|
||||
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px', borderColor: 'rgba(34,197,94,0.06)' }}>
|
||||
<span className="text-sm font-medium text-[#dff5e8]">{c.confederation}</span>
|
||||
<span className="text-center text-sm text-[#6abf7a]">{c.appearances}</span>
|
||||
<span className="text-center font-['Bebas_Neue'] text-xl text-[#22c55e]">{c.titles}</span>
|
||||
<span className="text-center text-sm text-[#6abf7a]">{c.totalGoals}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── All-time team table ── */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>📊 All-Time Team Table</SectionTitle>
|
||||
<Card>
|
||||
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
|
||||
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px' }}>
|
||||
<span>#</span><span>Team</span><span className="text-center">WC</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>
|
||||
<span className="text-center">Win%</span>
|
||||
</div>
|
||||
{teams.slice(0, 40).map((t, i) => (
|
||||
<Link key={t.id} href={`/teams/${t.slug}`}>
|
||||
<div className="grid px-4 py-2.5 border-t items-center hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px', borderColor: 'rgba(34,197,94,0.05)' }}>
|
||||
<span className="text-[11px] text-[#2a5c35] font-bold">{i + 1}</span>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
|
||||
<span className="text-sm text-[#dff5e8] truncate">{t.name}</span>
|
||||
</div>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.appearances}</span>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.wins}</span>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.draws}</span>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.losses}</span>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsFor}</span>
|
||||
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsAgainst}</span>
|
||||
<span className="text-center 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)}
|
||||
</span>
|
||||
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.stats?.winPct}%</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||
import { use } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { TeamFlag } from '@/components/team-flag'
|
||||
import { MatchCard } from '@/components/match-card'
|
||||
|
||||
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($teamName: String!) {
|
||||
topScorers(limit: 100) {
|
||||
playerName goals penalties ownGoals tournaments
|
||||
team { name iso2 }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Load all scorers to filter by team
|
||||
const { data: scorerData } = useQuery(gql`
|
||||
query TeamScorers {
|
||||
topScorers(limit: 200) {
|
||||
playerName goals penalties ownGoals tournaments
|
||||
team { id name iso2 }
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const allScorers = scorerData?.topScorers ?? []
|
||||
const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : []
|
||||
|
||||
if (loading && !teamData) {
|
||||
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading team…</div>
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">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 rounded-2xl p-8 mb-8" style={{
|
||||
background: 'linear-gradient(145deg,#0a1a0e,#0d2416)',
|
||||
border: '1px solid rgba(34,197,94,0.2)',
|
||||
}}>
|
||||
<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-[#22c55e] leading-none">{team.name}</h1>
|
||||
<div className="flex gap-3 mt-2 flex-wrap">
|
||||
{team.fifaCode && <span className="text-[11px] text-[#2a5c35] font-bold tracking-wider">{team.fifaCode}</span>}
|
||||
{team.confederation && <span className="text-[11px] text-[#2a5c35]">{team.confederation}</span>}
|
||||
{team.continent && <span className="text-[11px] text-[#2a5c35]">{team.continent}</span>}
|
||||
{(s?.titles ?? 0) > 0 && (
|
||||
<span className="text-[11px] text-[#22c55e] font-bold">
|
||||
{Array.from({ length: s?.titles ?? 0 }).map(() => '🏆').join('')} {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-[#2a5c35] 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="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
|
||||
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||
<div className="grid px-4 py-2.5 text-[9px] text-[#2a5c35] 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 items-center"
|
||||
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px', borderColor: 'rgba(34,197,94,0.06)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
|
||||
<span className="text-sm text-[#dff5e8]">{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-[#4a7a55]">{v}</span>
|
||||
))}
|
||||
<span className="text-center text-sm text-[#4a7a55]">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar: top scorers */}
|
||||
<div>
|
||||
{teamScorers.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||
{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-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
|
||||
<span className="text-[10px] text-[#2a5c35] 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-[#dff5e8] truncate">{sc.playerName}</div>
|
||||
<div className="text-[10px] text-[#2a5c35]">
|
||||
{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" style={{ background: 'rgba(34,197,94,0.1)' }}>
|
||||
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{sc.goals}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||
import { use } 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]) =>
|
||||
`${g.playerName} ${g.minute ?? ''}${g.minuteOffset ? `+${g.minuteOffset}` : ''}'${g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}`
|
||||
return (
|
||||
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-[#2a5c35]">
|
||||
<div className="text-left">{t1Goals.map(renderGoal).join(', ')}</div>
|
||||
<div className="text-right">{t2Goals.map(renderGoal).join(', ')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TournamentPage({ 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 })
|
||||
|
||||
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
|
||||
}, {})
|
||||
|
||||
const groupRounds = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
|
||||
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" style={{ background: '#0a1810' }} />
|
||||
<div className="text-[#2a5c35] text-sm">Loading {year} World Cup…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Tournament {year} not found.</div>
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||||
{/* Header */}
|
||||
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
|
||||
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 100%)',
|
||||
border: '1px solid rgba(34,197,94,0.2)',
|
||||
}}>
|
||||
{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-[#22c55e] leading-none">{year}</h1>
|
||||
<p className="text-[#6abf7a] 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-[#dff5e8]">{t.winner}</div>
|
||||
{t.runnerUp && <div className="text-xs text-[#2a5c35] 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-[#2a5c35] tracking-[0.12em] uppercase">{s.label}</div>
|
||||
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{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-[#4ade80] 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-[#22c55e] 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) => (a.date ?? '') < (b.date ?? '') ? -1 : 1)
|
||||
return (
|
||||
<div key={groupName} className="mb-8">
|
||||
<h3 className="text-[13px] font-bold text-[#22c55e] tracking-wide uppercase mb-3">{groupName}</h3>
|
||||
{/* Standings mini */}
|
||||
<div className="rounded-xl overflow-hidden mb-3" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
|
||||
{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-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i < 2 ? 'rgba(34,197,94,0.02)' : undefined }}>
|
||||
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
|
||||
<span className="flex-1 text-[13px] text-[#6abf7a] truncate">{s.team.name}</span>
|
||||
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.played}</span>
|
||||
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.won}</span>
|
||||
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.drawn}</span>
|
||||
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.lost}</span>
|
||||
<span className="text-[11px] font-bold text-[#22c55e] 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-[#22c55e] mb-5">Knockout Stage</h2>
|
||||
{Object.entries(koByRound).map(([round, roundMatches]) => (
|
||||
<div key={round} className="mb-6">
|
||||
<h3 className="text-[13px] font-bold text-[#22c55e] 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-[#22c55e] mb-4">TOP SCORERS</h2>
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||
{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-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
|
||||
<span className="text-[10px] text-[#2a5c35] 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-[#dff5e8] truncate">{s.playerName}</div>
|
||||
{s.penalties > 0 && <div className="text-[9px] text-[#2a5c35]">{s.penalties} pen</div>}
|
||||
</div>
|
||||
<div className="w-12 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-xl text-[#22c55e] flex-shrink-0">{s.goals}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{t.thirdPlace && (
|
||||
<div className="mt-4 rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
|
||||
<div className="text-[9px] text-[#2a5c35] 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-[#6abf7a]">{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-[#4a7a55]">{t.fourthPlace}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user