Files
worldcup/app/client.tsx
T
valknar a494c80a76 feat: SEO enhancements — server metadata, sitemap, robots, dynamic base URL
- 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>
2026-06-15 20:18:36 +02:00

241 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 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 formatKickoff(date: string | null | undefined, time: string | null | undefined): string {
if (!date) return ''
const today = new Date()
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1)
if (time) {
const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/)
if (m) {
const [y, mo, d] = date.split('-').map(Number)
const h = parseInt(m[1]), min = parseInt(m[2])
const offsetH = m[3] ? parseFloat(m[3]) : 0
// Compute UTC kickoff, then let the browser render in its local timezone
const local = new Date(Date.UTC(y, mo - 1, d, h - offsetH, min))
const isToday = local.toDateString() === today.toDateString()
const isTomorrow = local.toDateString() === tomorrow.toDateString()
const dayLabel = isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
const localTime = local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return `${dayLabel} · ${localTime}`
}
}
// No time — fall back to venue date label only
const matchDate = new Date(date + 'T00:00:00')
const isToday = matchDate.toDateString() === today.toDateString()
const isTomorrow = matchDate.toDateString() === tomorrow.toDateString()
return isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: matchDate.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
}
function UpcomingFixture({ match }: { match: UpcomingMatch }) {
const label = formatKickoff(match.date, match.time)
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" />
{label && <div className="text-[11px] text-green-muted whitespace-nowrap ml-1">{label}</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 function HomeClient() {
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 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 &nbsp;·&nbsp; 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>
)
}