Files
worldcup/app/tournaments/[year]/page.tsx
T

290 lines
13 KiB
TypeScript
Raw Normal View History

'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use, useEffect } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
import { LiveBadge } from '@/components/live-badge'
const TOURNAMENT_QUERY = gql`
query Tournament($year: Int!) {
tournament(year: $year) {
year host winner runnerUp thirdPlace fourthPlace
totalGoals matchesCount teamsCount avgGoalsPerGame
topScorers(limit: 10) {
playerName goals penalties ownGoals
team { name iso2 slug }
}
matches {
id year round group date time isLive isQualiPlayoff
scoreFt scoreHt scoreEt scoreP
team1 { id name iso2 slug } team2 { id name iso2 slug }
goals { playerName minute minuteOffset isPenalty isOwnGoal team { id } }
}
}
groupStandings(year: $year) {
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
team { id name iso2 slug }
}
}
`
interface MatchData {
id: number; year: number; round: string; group?: string | null
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
scoreFt?: number[] | null; scoreHt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
team1: { id: number; name: string; iso2?: string | null; slug: string }
team2: { id: number; name: string; iso2?: string | null; slug: string }
goals: Array<{ playerName: string; minute?: number | null; minuteOffset?: number | null; isPenalty: boolean; isOwnGoal: boolean; team: { id: number } }>
}
interface Standing {
groupName: string; pos?: number | null
played: number; won: number; drawn: number; lost: number
goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number
team: { id: number; name: string; iso2?: string | null; slug: string }
}
function GoalList({ match }: { match: MatchData }) {
if (!match.goals?.length) return null
const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id)
const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id)
const renderGoal = (g: MatchData['goals'][0], i: number) => (
<span key={i}>
{i > 0 && <span className="mx-0.5">,</span>}
<Link href={`/players/${encodeURIComponent(g.playerName)}`}
className="underline decoration-dotted underline-offset-2 hover:text-green hover:decoration-solid transition-colors">
{g.playerName}
</Link>
{' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
</span>
)
return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-green-muted">
<div className="text-left">{t1Goals.map(renderGoal)}</div>
<div className="text-right">{t2Goals.map(renderGoal)}</div>
</div>
)
}
export 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 })
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])
useEffect(() => {
document.title = data?.tournament
? `${year} World Cup · World Cup`
: `${year} · World Cup`
}, [data, year])
const t = data?.tournament
const standings: Standing[] = data?.groupStandings ?? []
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const allMatches: MatchData[] = t?.matches ?? []
const byRound = allMatches.reduce<Record<string, MatchData[]>>((acc, m) => {
const key = m.group ?? m.round
acc[key] = [...(acc[key] ?? []), m]
return acc
}, {})
// Union of groups from standings + groups from match data (handles groups with no played matches yet)
const groupNames = new Set([
...Object.keys(byGroup),
...allMatches.filter(m => m.group).map(m => m.group!),
])
const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]])
const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
const koByRound = koRounds.reduce<Record<string, MatchData[]>>((acc, m) => {
acc[m.round] = [...(acc[m.round] ?? []), m]
return acc
}, {})
const liveMatches = allMatches.filter(m => m.isLive)
const maxScorer = Math.max(...(t?.topScorers?.map((s: { goals: number }) => s.goals) ?? [1]), 1)
if (loading && !data) {
return (
<div className="max-w-[1200px] mx-auto px-7 py-10">
<div className="h-24 w-48 rounded-xl animate-pulse mb-6 bg-card" />
<div className="text-green-muted text-sm">Loading {year} World Cup…</div>
</div>
)
}
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Tournament {year} not found.</div>
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Header */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
{liveMatches.length > 0 && <div className="mb-3"><LiveBadge label="Live Now" /></div>}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="font-['Bebas_Neue'] text-[64px] text-green leading-none">{year}</h1>
<p className="text-green-sec text-lg mt-1">{t.host}</p>
</div>
{t.winner && (
<div className="text-center">
<TeamFlag name={t.winner} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-2xl text-text">{t.winner}</div>
{t.runnerUp && <div className="text-xs text-green-muted mt-1">def. {t.runnerUp}</div>}
</div>
)}
</div>
<div className="flex gap-6 mt-4 flex-wrap">
{[
{ label: 'Teams', value: t.teamsCount },
{ label: 'Matches', value: t.matchesCount },
{ label: 'Goals', value: t.totalGoals },
{ label: 'Goals/Game', value: t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(2) : null },
].filter(s => s.value != null).map(s => (
<div key={s.label}>
<div className="text-[9px] text-green-muted tracking-[0.12em] uppercase">{s.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
<div>
{/* Live matches first */}
{liveMatches.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green-light mb-4">LIVE</h2>
<div className="flex flex-col gap-4">
{liveMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} />
<GoalList match={m} />
</div>
))}
</div>
</div>
)}
{/* Group stage */}
{groupRounds.length > 0 && (
<div className="mb-8">
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Group Stage</h2>
{groupRounds.map(([groupName, rows]) => {
const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff)
const groupMatches = (byRound[groupName] ?? []).sort((a, b) => {
if (!a.date) return 1; if (!b.date) return -1
const cmp = a.date.localeCompare(b.date)
if (cmp !== 0) return cmp
return (a.time ?? '').localeCompare(b.time ?? '')
})
return (
<div key={groupName} className="mb-8">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
{/* Standings mini */}
<div className="glass-card rounded-xl mb-3">
{sorted.map((s, i) => (
<Link key={s.team.id} href={`/teams/${s.team.slug}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i < 2 ? 'bg-green/[2%]' : ''}`}>
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
<span className="flex-1 text-[13px] text-green-sec truncate">{s.team.name}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.played}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.won}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.drawn}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.lost}</span>
<span className="text-[11px] font-bold text-green w-6 text-center">{s.pts}</span>
</div>
</Link>
))}
</div>
{/* Group matches */}
<div className="flex flex-col gap-2">
{groupMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Knockout rounds */}
{Object.keys(koByRound).length > 0 && (
<div>
<h2 className="font-['Bebas_Neue'] text-2xl text-green mb-5">Knockout Stage</h2>
{Object.entries(koByRound).map(([round, roundMatches]) => (
<div key={round} className="mb-6">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{round}</h3>
<div className="flex flex-col gap-3">
{roundMatches.map(m => (
<div key={m.id} id={`match-${m.id}`}>
<MatchCard match={m} compact />
<GoalList match={m} />
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
<div className="sticky top-[76px]">
<h2 className="font-['Bebas_Neue'] text-xl text-green mb-4">TOP SCORERS</h2>
<div className="glass-card">
{t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{s.playerName}</div>
{s.penalties > 0 && <div className="text-[9px] text-green-muted">{s.penalties} pen</div>}
</div>
<div className="w-12 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
{t.thirdPlace && (
<div className="glass-card mt-4 rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">3rd Place</div>
<div className="flex items-center gap-2">
<TeamFlag name={t.thirdPlace} size="sm" />
<span className="text-sm text-green-sec">{t.thirdPlace}</span>
</div>
{t.fourthPlace && (
<div className="flex items-center gap-2 mt-1.5">
<TeamFlag name={t.fourthPlace} size="sm" />
<span className="text-sm text-green-mid">{t.fourthPlace}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}