b141356247
- Add --color-green-mid token (#4a7a55) to @theme for dimmer stat values
- Replace all text-[#hex]/bg-[#hex] arbitrary values with named tokens:
text-green, text-green-light, text-green-sec, text-green-muted,
text-green-dark, text-green-mid, text-text, bg-card, bg-bg, border-border
- Replace rgba(34,197,94,X) inline styles with bg-green/X opacity modifiers
- Convert single-prop style={{ borderColor/background }} to className
- Fix SVG stroke="#dff5e8" → stroke="currentColor"
- Use CSS variables in globals.css base styles (background-color, color)
- Move app/data/wikipedia/ → data/ (project root, not inside Next.js app dir)
- Update Dockerfile, seed.ts, scrape-wikipedia.ts paths accordingly
- Remove unused app/data/world_cup.csv
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
9.0 KiB
TypeScript
213 lines
9.0 KiB
TypeScript
'use client'
|
||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||
import { useEffect } from 'react'
|
||
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 slug } team2 { name iso2 slug }
|
||
}
|
||
recentMatches(limit: 9) {
|
||
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
|
||
team1 { name iso2 slug } team2 { name iso2 slug }
|
||
}
|
||
upcomingMatches(limit: 9) {
|
||
id year round group date time isLive isQualiPlayoff scoreFt
|
||
team1 { name iso2 slug } team2 { name iso2 slug }
|
||
}
|
||
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-green rounded-sm" />
|
||
<span className="text-[11px] text-green-muted 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 bg-green/5 border border-green/[12%]">
|
||
<div className="text-[9px] text-green-muted tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
|
||
<div className="font-['Bebas_Neue'] text-[30px] text-green 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="glass-card rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-green/20 transition-colors cursor-pointer">
|
||
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
|
||
<div className="flex-1 text-[13px] text-green-sec font-medium truncate">
|
||
{match.team1.name} <span className="text-green-muted">vs</span> {match.team2.name}
|
||
</div>
|
||
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
|
||
{time && <div className="text-[11px] text-green-muted 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; slug?: string | null }
|
||
team2: { name: string; iso2?: string | null; slug?: string | null }
|
||
}
|
||
|
||
export default function HomePage() {
|
||
const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 })
|
||
|
||
useEffect(() => { document.title = 'World Cup' }, [])
|
||
|
||
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 border-border" style={{
|
||
background: 'linear-gradient(145deg,rgba(10,26,14,0.9) 0%,rgba(13,36,22,0.9) 55%,rgba(10,26,14,0.9) 100%)',
|
||
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-green inline-block" />
|
||
<span className="text-[11px] font-bold text-green 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-green-muted text-sm mb-9">
|
||
USA · Canada · 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 bg-green/[4%]" />
|
||
))}
|
||
</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="glass-card">
|
||
{scorers.map((s, i) => (
|
||
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
|
||
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${i === 0 ? 'bg-green/[4%]' : ''}`}>
|
||
<span className="text-[11px] text-green-muted 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-text' : 'text-green-sec'}`}>{s.playerName}</div>
|
||
<div className="text-[10px] text-green-muted truncate">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
|
||
</div>
|
||
<div className="hidden sm:block w-24 h-1 rounded-full overflow-hidden flex-shrink-0 bg-green/10">
|
||
<div className="h-full rounded-full bg-green transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
<p className="text-[10px] text-green-dark mt-3 text-center">
|
||
<Link href="/stats" className="hover:text-green-muted">View all-time top scorers →</Link>
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{loading && !data && (
|
||
<div className="py-16 text-center text-green-muted text-sm">Loading live World Cup data…</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|