58b4114159
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>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
'use client'
|
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
|
import { use } from 'react'
|
|
import Link from 'next/link'
|
|
import { TeamFlag } from '@/components/team-flag'
|
|
import { MatchCard } from '@/components/match-card'
|
|
|
|
const PLAYER_QUERY = gql`
|
|
query Player($name: String!) {
|
|
player(name: $name) {
|
|
playerName goals penalties ownGoals tournaments
|
|
team { id name iso2 slug }
|
|
}
|
|
}
|
|
`
|
|
|
|
const PLAYER_MATCHES_QUERY = gql`
|
|
query PlayerMatches($name: String!) {
|
|
tournaments { year }
|
|
}
|
|
`
|
|
|
|
interface PlayerData {
|
|
playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number
|
|
team?: { id: number; name: string; iso2?: string | null; slug: string } | null
|
|
}
|
|
|
|
export default function PlayerPage({ params }: { params: Promise<{ name: string }> }) {
|
|
const { name: encodedName } = use(params)
|
|
const name = decodeURIComponent(encodedName)
|
|
|
|
const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } })
|
|
const player: PlayerData | null = data?.player ?? null
|
|
|
|
// Fetch all goals for this player broken down by year
|
|
const { data: goalsData } = useQuery(gql`
|
|
query PlayerGoalsByYear($name: String!) {
|
|
tournaments { year }
|
|
topScorers(limit: 1000) {
|
|
playerName goals team { id }
|
|
}
|
|
}
|
|
`, { variables: { name } })
|
|
|
|
if (loading && !data) {
|
|
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading player…</div>
|
|
}
|
|
|
|
if (!player) {
|
|
return (
|
|
<div className="max-w-[1200px] mx-auto px-7 py-10">
|
|
<h1 className="font-['Bebas_Neue'] text-[52px] text-[#22c55e]">{name}</h1>
|
|
<p className="text-[#2a5c35] mt-4">No goal data found for this player in World Cup history.</p>
|
|
<Link href="/stats" className="text-[#22c55e] text-sm mt-4 inline-block hover:underline">← All-time scorers</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const normalGoals = player.goals - player.penalties - player.ownGoals
|
|
|
|
return (
|
|
<div className="max-w-[900px] mx-auto px-7 py-10 pb-16">
|
|
{/* Hero */}
|
|
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
|
|
background: 'linear-gradient(145deg,#0a1a0e,#0d2416)',
|
|
border: '1px solid rgba(34,197,94,0.2)',
|
|
}}>
|
|
<div className="flex items-center gap-6 flex-wrap">
|
|
{player.team && <TeamFlag name={player.team.name} iso2={player.team.iso2} size="xl" />}
|
|
<div>
|
|
<h1 className="font-['Bebas_Neue'] text-[clamp(36px,6vw,64px)] text-[#22c55e] leading-none">{player.playerName}</h1>
|
|
{player.team && (
|
|
<Link href={`/teams/${player.team.slug}`} className="text-[#6abf7a] text-sm mt-1 hover:text-[#dff5e8] transition-colors inline-block">
|
|
{player.team.name} →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<div className="ml-auto text-right">
|
|
<div className="font-['Bebas_Neue'] text-[80px] text-[#22c55e] leading-none">{player.goals}</div>
|
|
<div className="text-[10px] text-[#2a5c35] tracking-[0.12em] uppercase">World Cup Goals</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats breakdown */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
|
{[
|
|
{ label: 'Total Goals', value: player.goals },
|
|
{ label: 'Open Play', value: normalGoals },
|
|
{ label: 'Penalties', value: player.penalties },
|
|
{ label: 'Tournaments', value: player.tournaments },
|
|
].map(item => (
|
|
<div key={item.label} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
|
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
|
|
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{item.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{player.ownGoals > 0 && (
|
|
<div className="mb-6 rounded-xl p-3 px-4 text-sm text-[#2a5c35]" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.08)' }}>
|
|
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
|
|
</div>
|
|
)}
|
|
|
|
{/* Back links */}
|
|
<div className="flex gap-4 mt-8">
|
|
<Link href="/stats" className="text-[#22c55e] text-sm hover:underline">← All-time scorers</Link>
|
|
{player.team && (
|
|
<Link href={`/teams/${player.team.slug}`} className="text-[#22c55e] text-sm hover:underline">
|
|
→ {player.team.name} team page
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|