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>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||
import Link from 'next/link'
|
||||
import { TeamFlag } from '@/components/team-flag'
|
||||
import { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
const HISTORY_QUERY = gql`
|
||||
query History {
|
||||
tournaments {
|
||||
year host winner runnerUp thirdPlace fourthPlace
|
||||
totalGoals matchesCount teamsCount avgGoalsPerGame
|
||||
topScorers(limit: 1) { playerName goals team { name iso2 } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Tournament {
|
||||
year: number; host: string; winner?: string | null; runnerUp?: string | null
|
||||
thirdPlace?: string | null; fourthPlace?: string | null
|
||||
totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null
|
||||
avgGoalsPerGame?: string | number | null
|
||||
topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }>
|
||||
}
|
||||
|
||||
|
||||
export function HistoryClient() {
|
||||
|
||||
const { data, loading } = useQuery(HISTORY_QUERY)
|
||||
const tournaments: Tournament[] = data?.tournaments ?? []
|
||||
const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||||
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-2">
|
||||
World Cup History
|
||||
</h1>
|
||||
<p className="text-green-muted text-sm mb-9">
|
||||
Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments
|
||||
</p>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className="h-52 rounded-2xl animate-pulse bg-card" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||
{tournaments.map(t => {
|
||||
const inProgress = t.year === 2026 && is2026InProgress
|
||||
const topScorer = t.topScorers?.[0]
|
||||
return (
|
||||
<Link key={t.year} href={`/tournaments/${t.year}`}>
|
||||
<div className="glass-card p-5 relative cursor-pointer hover:border-green/30 transition-colors">
|
||||
{/* Year watermark */}
|
||||
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none text-green/[4%]">
|
||||
{t.year}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-start mb-3.5">
|
||||
<div>
|
||||
<div className="font-['Bebas_Neue'] text-[34px] text-green leading-none">{t.year}</div>
|
||||
<div className="text-xs text-green-muted mt-0.5">
|
||||
{t.host}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inProgress
|
||||
? <div className="text-[10px] text-green font-bold tracking-[0.12em] bg-green/10 px-2.5 py-1 rounded-full mt-1">
|
||||
IN PROGRESS
|
||||
</div>
|
||||
: t.winner && (
|
||||
<div className="text-right">
|
||||
<TeamFlag name={t.winner} size="md" />
|
||||
<div className="text-[11px] text-green-sec mt-0.5">{t.winner}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inProgress && t.winner && t.runnerUp && (
|
||||
<div className="rounded-lg px-3 py-2 text-xs text-green-sec mb-3 bg-green/[7%]">
|
||||
<span className="font-semibold text-text">{t.winner}</span>
|
||||
<span className="mx-2 text-green-muted">def.</span>
|
||||
{t.runnerUp}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3.5 text-[11px] text-green-muted flex-wrap">
|
||||
{t.totalGoals != null && <span className="inline-flex items-center gap-1"><FireIcon className="w-3 h-3" />{t.totalGoals}</span>}
|
||||
{t.matchesCount != null && <span className="inline-flex items-center gap-1"><CalendarDaysIcon className="w-3 h-3" />{t.matchesCount} games</span>}
|
||||
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
|
||||
</div>
|
||||
|
||||
{topScorer && (
|
||||
<div className="mt-2 text-[10px] text-green-dark">
|
||||
Golden Boot: <span className="text-green-muted">{topScorer.playerName} (<span className="inline-flex items-center gap-0.5"><FireIcon className="w-2.5 h-2.5 inline" />{topScorer.goals}</span>)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-106
@@ -1,111 +1,16 @@
|
||||
'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 { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline'
|
||||
import type { Metadata } from 'next'
|
||||
import { HistoryClient } from './client'
|
||||
|
||||
const HISTORY_QUERY = gql`
|
||||
query History {
|
||||
tournaments {
|
||||
year host winner runnerUp thirdPlace fourthPlace
|
||||
totalGoals matchesCount teamsCount avgGoalsPerGame
|
||||
topScorers(limit: 1) { playerName goals team { name iso2 } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Tournament {
|
||||
year: number; host: string; winner?: string | null; runnerUp?: string | null
|
||||
thirdPlace?: string | null; fourthPlace?: string | null
|
||||
totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null
|
||||
avgGoalsPerGame?: string | number | null
|
||||
topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }>
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tournament History',
|
||||
description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026 — hosts, winners, and key statistics.',
|
||||
openGraph: {
|
||||
title: 'FIFA World Cup Tournament History (1930–2026)',
|
||||
description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026.',
|
||||
url: '/history',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
export default function HistoryPage() {
|
||||
useEffect(() => { document.title = 'History · World Cup' }, [])
|
||||
|
||||
const { data, loading } = useQuery(HISTORY_QUERY)
|
||||
const tournaments: Tournament[] = data?.tournaments ?? []
|
||||
const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||||
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-2">
|
||||
World Cup History
|
||||
</h1>
|
||||
<p className="text-green-muted text-sm mb-9">
|
||||
Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments
|
||||
</p>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className="h-52 rounded-2xl animate-pulse bg-card" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||
{tournaments.map(t => {
|
||||
const inProgress = t.year === 2026 && is2026InProgress
|
||||
const topScorer = t.topScorers?.[0]
|
||||
return (
|
||||
<Link key={t.year} href={`/tournaments/${t.year}`}>
|
||||
<div className="glass-card p-5 relative cursor-pointer hover:border-green/30 transition-colors">
|
||||
{/* Year watermark */}
|
||||
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none text-green/[4%]">
|
||||
{t.year}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-start mb-3.5">
|
||||
<div>
|
||||
<div className="font-['Bebas_Neue'] text-[34px] text-green leading-none">{t.year}</div>
|
||||
<div className="text-xs text-green-muted mt-0.5">
|
||||
{t.host}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inProgress
|
||||
? <div className="text-[10px] text-green font-bold tracking-[0.12em] bg-green/10 px-2.5 py-1 rounded-full mt-1">
|
||||
IN PROGRESS
|
||||
</div>
|
||||
: t.winner && (
|
||||
<div className="text-right">
|
||||
<TeamFlag name={t.winner} size="md" />
|
||||
<div className="text-[11px] text-green-sec mt-0.5">{t.winner}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!inProgress && t.winner && t.runnerUp && (
|
||||
<div className="rounded-lg px-3 py-2 text-xs text-green-sec mb-3 bg-green/[7%]">
|
||||
<span className="font-semibold text-text">{t.winner}</span>
|
||||
<span className="mx-2 text-green-muted">def.</span>
|
||||
{t.runnerUp}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3.5 text-[11px] text-green-muted flex-wrap">
|
||||
{t.totalGoals != null && <span className="inline-flex items-center gap-1"><FireIcon className="w-3 h-3" />{t.totalGoals}</span>}
|
||||
{t.matchesCount != null && <span className="inline-flex items-center gap-1"><CalendarDaysIcon className="w-3 h-3" />{t.matchesCount} games</span>}
|
||||
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
|
||||
</div>
|
||||
|
||||
{topScorer && (
|
||||
<div className="mt-2 text-[10px] text-green-dark">
|
||||
Golden Boot: <span className="text-green-muted">{topScorer.playerName} (<span className="inline-flex items-center gap-0.5"><FireIcon className="w-2.5 h-2.5 inline" />{topScorer.goals}</span>)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <HistoryClient />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user