a494c80a76
- Split all page.tsx files into server wrapper (metadata export) + client.tsx (Apollo/interactive) - Add robots.ts and sitemap.ts (tournaments, teams, players) - Add metadataBase, OpenGraph and Twitter card metadata to root layout - Replace hardcoded worldcup.pivoine.art with NEXT_PUBLIC_SITE_URL env var (sitemap/robots) and relative paths (page metadata, resolved by Next.js against metadataBase) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
208 lines
9.8 KiB
TypeScript
208 lines
9.8 KiB
TypeScript
'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 }
|
||
}
|
||
matches(year: 2026, isQuali: false) {
|
||
id group date time isLive scoreFt
|
||
team1 { name iso2 slug } team2 { 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 }
|
||
}
|
||
|
||
interface MatchRow {
|
||
id: number; group?: string | null; date?: string | null; time?: string | null
|
||
isLive: boolean; scoreFt?: number[] | null
|
||
team1: { name: string; iso2?: string | null; slug: string }
|
||
team2: { name: string; iso2?: string | null; slug: string }
|
||
}
|
||
|
||
function utcKickoff(date: string, time: string): number {
|
||
const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/)
|
||
if (!m) return new Date(date).getTime()
|
||
const [y, mo, d] = date.split('-').map(Number)
|
||
const offsetH = m[3] ? parseFloat(m[3]) : 0
|
||
return Date.UTC(y, mo - 1, d, parseInt(m[1]) - offsetH, parseInt(m[2]))
|
||
}
|
||
|
||
function formatKickoff(date: string, time: string | null | undefined): string {
|
||
if (!time) return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||
const ms = utcKickoff(date, time)
|
||
const local = new Date(ms)
|
||
const today = new Date()
|
||
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1)
|
||
const isToday = local.toDateString() === today.toDateString()
|
||
const isTomorrow = local.toDateString() === tomorrow.toDateString()
|
||
const day = isToday ? 'Today' : isTomorrow ? 'Tomorrow'
|
||
: local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||
return `${day} · ${local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`
|
||
}
|
||
|
||
export function GroupsClient() {
|
||
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
||
|
||
|
||
const standings: Standing[] = data?.groupStandings ?? []
|
||
const allMatches: MatchRow[] = data?.matches ?? []
|
||
|
||
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
||
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
||
return acc
|
||
}, {})
|
||
|
||
const matchesByGroup = allMatches
|
||
.filter(m => m.group)
|
||
.reduce<Record<string, MatchRow[]>>((acc, m) => {
|
||
acc[m.group!] = [...(acc[m.group!] ?? []), m]
|
||
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-green leading-none">2026 Groups</h1>
|
||
<p className="text-green-muted 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(300px,1fr))] gap-3.5">
|
||
{Array.from({ length: 12 }).map((_, i) => (
|
||
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,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 ', '')
|
||
|
||
const groupMatches = (matchesByGroup[groupName] ?? [])
|
||
.sort((a, b) => {
|
||
if (!a.date) return 1
|
||
if (!b.date) return -1
|
||
const ta = a.time ? utcKickoff(a.date, a.time) : new Date(a.date).getTime()
|
||
const tb = b.time ? utcKickoff(b.date!, b.time) : new Date(b.date!).getTime()
|
||
return ta - tb
|
||
})
|
||
const played = groupMatches.filter(m => m.scoreFt)
|
||
const upcoming = groupMatches.filter(m => !m.scoreFt && !m.isLive)
|
||
const live = groupMatches.filter(m => m.isLive)
|
||
|
||
return (
|
||
<div key={groupName} className="glass-card">
|
||
{/* Header */}
|
||
<div className="px-4 py-3 border-b border-green/10"
|
||
style={{ background: 'linear-gradient(90deg,color-mix(in srgb,var(--color-green) 12%,transparent) 0%,color-mix(in srgb,var(--color-green) 4%,transparent) 100%)' }}>
|
||
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
|
||
</div>
|
||
|
||
{/* Standings */}
|
||
<div className="grid px-4 py-2 text-[9px] text-green-muted 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 border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${idx < 2 ? 'bg-green/[2.5%]' : ''}`}
|
||
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
|
||
<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-text' : 'text-green-sec'}`}>{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-green-mid">{v}</span>
|
||
))}
|
||
<span className="text-center text-[13px] text-green-mid">
|
||
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
|
||
</span>
|
||
<span className="text-center text-[13px] font-bold text-green">{t.pts}</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
|
||
{/* Live matches */}
|
||
{live.length > 0 && (
|
||
<div className="border-t border-green/10 px-4 py-2.5 space-y-1.5">
|
||
{live.map(m => (
|
||
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
|
||
<div className="flex items-center gap-2 py-1 hover:opacity-80">
|
||
<span className="text-[9px] font-bold text-green-light tracking-wider animate-pulse">LIVE</span>
|
||
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||
<span className="text-[12px] text-text font-medium">{m.team1.name}</span>
|
||
<span className="text-[11px] text-green-muted mx-0.5">vs</span>
|
||
<span className="text-[12px] text-text font-medium">{m.team2.name}</span>
|
||
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Results */}
|
||
{played.length > 0 && (
|
||
<div className="border-t border-green/10 px-4 py-2.5 space-y-1">
|
||
{played.map(m => (
|
||
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
|
||
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
|
||
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
|
||
<span className="font-['Bebas_Neue'] text-[15px] text-green tabular-nums">
|
||
{m.scoreFt![0]}–{m.scoreFt![1]}
|
||
</span>
|
||
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
|
||
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Upcoming */}
|
||
{upcoming.length > 0 && (
|
||
<div className="border-t border-green/[6%] px-4 py-2.5 space-y-1">
|
||
{upcoming.map(m => (
|
||
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
|
||
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
|
||
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
|
||
<span className="text-[10px] text-green-muted whitespace-nowrap tabular-nums">
|
||
{m.date ? formatKickoff(m.date, m.time) : '–'}
|
||
</span>
|
||
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
|
||
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|