Files
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

271 lines
13 KiB
TypeScript
Raw Permalink 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 { use, useEffect } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { TrophyIcon } from '@heroicons/react/24/outline'
const TEAM_QUERY = gql`
query Team($slug: String!) {
team(slug: $slug) {
id name iso2 slug fifaCode continent confederation
stats { appearances wins draws losses goalsFor goalsAgainst goalDiff titles winPct }
}
}
`
const TEAM_MATCHES_QUERY = gql`
query TeamMatches($teamId: Int!) {
matches(teamId: $teamId, isQuali: false) {
id year round group date isLive scoreFt scoreEt scoreP
team1 { name iso2 slug } team2 { name iso2 slug }
}
}
`
interface TeamData {
id: number; name: string; iso2?: string | null; slug: string
fifaCode?: string | null; continent?: string | null; confederation?: string | null
stats?: {
appearances: number; wins: number; draws: number; losses: number
goalsFor: number; goalsAgainst: number; goalDiff: number; titles: number; winPct: number
} | null
}
interface MatchRow {
id: number; year: number; round: string; group?: string | null
date?: string | null; isLive: 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 }
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
export function TeamClient({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } })
const team: TeamData | null = teamData?.team ?? null
useEffect(() => {
}, [team])
const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, {
variables: { teamId: team?.id },
skip: !team?.id,
})
const { data: scorerData } = useQuery(gql`
query TeamScorers($teamId: Int!) {
topScorers(teamId: $teamId, limit: 30) {
playerName goals penalties ownGoals tournaments
team { id name iso2 }
}
}
`, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id })
const teamScorers = scorerData?.topScorers ?? []
const teamMatches: MatchRow[] = matchesData?.matches ?? []
// Group matches by year for the history display
const matchesByYear = teamMatches.reduce((acc: Record<number, MatchRow[]>, m) => {
;(acc[m.year] ??= []).push(m)
return acc
}, {})
const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading team</div>
}
if (!team) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Team not found.</div>
}
const s = team.stats
const played = (s?.wins ?? 0) + (s?.draws ?? 0) + (s?.losses ?? 0)
const maxScorer = Math.max(...teamScorers.map((sc: { goals: number }) => sc.goals), 1)
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Hero */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
<div className="flex items-center gap-6 flex-wrap">
<TeamFlag name={team.name} iso2={team.iso2} size="xl" />
<div>
<h1 className="font-['Bebas_Neue'] text-[56px] text-green leading-none">{team.name}</h1>
<div className="flex gap-3 mt-2 flex-wrap">
{team.fifaCode && <span className="text-[11px] text-green-muted font-bold tracking-wider">{team.fifaCode}</span>}
{team.confederation && <span className="text-[11px] text-green-muted">{team.confederation}</span>}
{team.continent && <span className="text-[11px] text-green-muted">{team.continent}</span>}
{(s?.titles ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-green font-bold">
{Array.from({ length: s?.titles ?? 0 }).map((_, i) => <TrophyIcon key={i} className="w-3.5 h-3.5" />)}
{s?.titles} title{(s?.titles ?? 0) !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-8">
<div>
{/* Stats grid */}
{s && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">World Cup Record</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
{[
{ label: 'Appearances', value: s.appearances },
{ label: 'Matches', value: played },
{ label: 'Win %', value: `${s.winPct}%` },
{ label: 'Goals For', value: s.goalsFor },
].map(item => (
<div key={item.label} className="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
<div className="glass-card rounded-xl">
<div className="grid px-4 py-2.5 text-[9px] text-green-muted tracking-[0.1em] uppercase"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<span>Team</span><span className="text-center">W</span><span className="text-center">D</span>
<span className="text-center">L</span><span className="text-center">GF</span>
<span className="text-center">GA</span><span className="text-center">GD</span>
</div>
<div className="grid px-4 py-3 border-t border-green/[6%] items-center"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<div className="flex items-center gap-2">
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
<span className="text-sm text-text">{team.name}</span>
</div>
{[s.wins, s.draws, s.losses, s.goalsFor, s.goalsAgainst].map((v, i) => (
<span key={i} className="text-center text-sm text-green-mid">{v}</span>
))}
<span className="text-center text-sm text-green-mid">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
</div>
</div>
</div>
)}
{/* Tournament participations */}
{years.length > 0 && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Tournament Participations</h2>
<div className="flex flex-wrap gap-2">
{years.map(year => (
<Link key={year} href={`/tournaments/${year}`}
className="font-['Bebas_Neue'] text-lg px-3 py-1 rounded-lg transition-colors text-green-sec bg-bg/[78%] border border-border hover:text-green hover:border-green/40 backdrop-blur-sm">
{year}
</Link>
))}
</div>
</div>
)}
{/* Match history by year */}
{years.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Match History</h2>
<div className="space-y-6">
{years.map(year => {
const yMatches = matchesByYear[year]
return (
<div key={year}>
<Link href={`/tournaments/${year}`}
className="inline-block font-['Bebas_Neue'] text-[22px] text-green mb-2 hover:opacity-70 transition-opacity">
{year}
</Link>
<div className="glass-card rounded-xl">
{yMatches.map((m, i) => {
const isHome = m.team1.name === team.name
const opponent = isHome ? m.team2 : m.team1
const ft = m.scoreFt
const scoreEt = m.scoreEt
const scoreP = m.scoreP
// Winner: PSO first, then ET, then FT
const decisive = scoreP ?? scoreEt ?? ft
const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null
const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null
const result = myScore != null && theirScore != null
? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D'
: null
const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec'
// Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts)
const displayScore = scoreP ? null : (scoreEt ?? ft)
return (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className={`flex items-center gap-3 px-3 sm:px-4 py-2.5 border-b hover:bg-green/[3%] transition-colors border-green/[6%] ${i % 2 !== 0 ? 'bg-green/[1%]' : ''}`}>
<span className={`text-[11px] font-bold w-4 flex-shrink-0 ${resultColor}`}>{result ?? ''}</span>
<TeamFlag name={opponent.name} iso2={opponent.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text truncate">{opponent.name}</div>
<div className="text-[10px] text-green-muted">
{m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-['Bebas_Neue'] text-lg text-green leading-none">
{scoreP
? `${isHome ? scoreP[0] : scoreP[1]}${isHome ? scoreP[1] : scoreP[0]}`
: displayScore
? `${isHome ? displayScore[0] : displayScore[1]}${isHome ? displayScore[1] : displayScore[0]}`
: ''}
</div>
{scoreP && ft && (
<div className="text-[9px] text-green-muted leading-none">
{`${isHome ? ft[0] : ft[1]}${isHome ? ft[1] : ft[0]}`} a.e.t.
</div>
)}
{scoreEt && !scoreP && (
<div className="text-[9px] text-green-muted leading-none">a.e.t.</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
{teamScorers.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
<div className="glass-card">
{teamScorers.map((sc: { playerName: string; goals: number; penalties: number; tournaments: number }, i: number) => (
<Link key={sc.playerName} href={`/players/${encodeURIComponent(sc.playerName)}`}>
<div className={`flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-text truncate">{sc.playerName}</div>
<div className="text-[10px] text-green-muted">
{sc.tournaments} WC{sc.tournaments !== 1 ? 's' : ''}{sc.penalties > 0 ? ` · ${sc.penalties}P` : ''}
</div>
</div>
<div className="w-10 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{sc.goals}</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}