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:
+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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user