58b4114159
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>
101 lines
4.6 KiB
TypeScript
101 lines
4.6 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 }
|
|
}
|
|
}
|
|
`
|
|
|
|
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>
|
|
)
|
|
}
|