2026-06-14 15:36:44 +02:00
|
|
|
'use client'
|
|
|
|
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
2026-06-14 19:36:08 +02:00
|
|
|
import { use, useEffect } from 'react'
|
2026-06-14 15:36:44 +02:00
|
|
|
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 })
|
|
|
|
|
|
2026-06-14 19:36:08 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!data) return
|
|
|
|
|
const hash = window.location.hash
|
|
|
|
|
if (!hash) return
|
|
|
|
|
const el = document.getElementById(hash.slice(1))
|
|
|
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
|
|
}, [data])
|
|
|
|
|
|
2026-06-14 15:36:44 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|