a494c80a76
- 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>
193 lines
8.8 KiB
TypeScript
193 lines
8.8 KiB
TypeScript
'use client'
|
||
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||
import { useSearchParams, useRouter } from 'next/navigation'
|
||
import { useState, useEffect, Suspense } from 'react'
|
||
import Link from 'next/link'
|
||
import { TeamFlag } from '@/components/team-flag'
|
||
import { TrophyIcon, FireIcon } from '@heroicons/react/24/outline'
|
||
|
||
const SEARCH_QUERY = gql`
|
||
query Search($q: String!) {
|
||
search(query: $q) {
|
||
tournaments { year host winner totalGoals matchesCount }
|
||
teams { name iso2 slug stats { appearances titles } }
|
||
players { playerName goals tournaments team { name iso2 } }
|
||
matches {
|
||
id year round group date scoreFt isQualiPlayoff
|
||
team1 { name iso2 } team2 { name iso2 }
|
||
}
|
||
}
|
||
}
|
||
`
|
||
|
||
interface SearchMatch {
|
||
id: number; year: number; round: string; group?: string | null
|
||
date?: string | null; scoreFt?: number[] | null; isQualiPlayoff: boolean
|
||
team1: { name: string; iso2?: string | null }
|
||
team2: { name: string; iso2?: string | null }
|
||
}
|
||
|
||
function SearchContent() {
|
||
const searchParams = useSearchParams()
|
||
const router = useRouter()
|
||
const initialQ = searchParams.get('q') ?? ''
|
||
const [q, setQ] = useState(initialQ)
|
||
const [debouncedQ, setDebouncedQ] = useState(initialQ)
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => {
|
||
setDebouncedQ(q)
|
||
if (q.trim()) router.replace(`/search?q=${encodeURIComponent(q.trim())}`, { scroll: false })
|
||
}, 300)
|
||
return () => clearTimeout(t)
|
||
}, [q, router])
|
||
|
||
useEffect(() => {
|
||
}, [q])
|
||
|
||
const skip = debouncedQ.trim().length < 2
|
||
const { data, loading } = useQuery(SEARCH_QUERY, {
|
||
variables: { q: debouncedQ },
|
||
skip,
|
||
})
|
||
|
||
const results = data?.search
|
||
const total = skip ? 0 : (
|
||
(results?.tournaments?.length ?? 0) +
|
||
(results?.teams?.length ?? 0) +
|
||
(results?.players?.length ?? 0) +
|
||
(results?.matches?.length ?? 0)
|
||
)
|
||
|
||
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-6">Search</h1>
|
||
|
||
{/* Search input */}
|
||
<div className="relative max-w-lg mb-8">
|
||
<input
|
||
type="text" value={q} onChange={e => setQ(e.target.value)}
|
||
placeholder="Search teams, players, tournaments…"
|
||
autoFocus
|
||
className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20"
|
||
/>
|
||
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
{loading && <div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-green border-t-transparent rounded-full animate-spin" />}
|
||
</div>
|
||
|
||
{/* Prompt */}
|
||
{skip && (
|
||
<div className="flex flex-col items-center py-20 text-center">
|
||
<div className="text-[56px] mb-5">🔍</div>
|
||
<div className="text-green-muted text-base">Search for nations, players, or tournaments…</div>
|
||
<div className="text-green-dark text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* No results */}
|
||
{!skip && !loading && total === 0 && (
|
||
<div className="text-center text-green-dark py-16 text-sm">No results for "{debouncedQ}"</div>
|
||
)}
|
||
|
||
{/* Results count */}
|
||
{!skip && total > 0 && (
|
||
<div className="text-[13px] text-green-muted mb-6">{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"</div>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-6">
|
||
{/* Teams */}
|
||
{results?.teams?.length > 0 && (
|
||
<section>
|
||
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Teams</h3>
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5">
|
||
{results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
|
||
<Link key={t.name} href={`/teams/${t.slug}`}>
|
||
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
|
||
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
|
||
<div>
|
||
<div className="text-sm font-semibold text-text">{t.name}</div>
|
||
<div className="text-[10px] text-green-muted">
|
||
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? <span className="inline-flex items-center gap-0.5 ml-1">· {t.stats.titles}<TrophyIcon className="w-3 h-3 inline" /></span> : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Players */}
|
||
{results?.players?.length > 0 && (
|
||
<section>
|
||
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Players</h3>
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2.5">
|
||
{results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
|
||
<Link key={p.playerName} href={`/players/${encodeURIComponent(p.playerName)}`}>
|
||
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
|
||
{p.team && <TeamFlag name={p.team.name} iso2={p.team.iso2} size="sm" />}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-semibold text-text truncate">{p.playerName}</div>
|
||
<div className="text-[10px] text-green-muted">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
|
||
</div>
|
||
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0 inline-flex items-center gap-0.5">{p.goals}<FireIcon className="w-3.5 h-3.5" /></span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Tournaments */}
|
||
{results?.tournaments?.length > 0 && (
|
||
<section>
|
||
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Tournaments</h3>
|
||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
|
||
{results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
|
||
<Link key={t.year} href={`/tournaments/${t.year}`}>
|
||
<div className="glass-card p-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
|
||
<div className="font-['Bebas_Neue'] text-3xl text-green">{t.year}</div>
|
||
<div className="text-sm text-text">{t.host}</div>
|
||
{t.winner && <div className="text-[10px] text-green-muted mt-1 flex items-center gap-1"><TrophyIcon className="w-3 h-3 flex-shrink-0" />{t.winner}</div>}
|
||
{t.totalGoals && <div className="text-[10px] text-green-dark flex items-center gap-1"><FireIcon className="w-3 h-3 flex-shrink-0" />{t.totalGoals} goals</div>}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Matches */}
|
||
{results?.matches?.length > 0 && (
|
||
<section>
|
||
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Matches</h3>
|
||
<div className="flex flex-col gap-2">
|
||
{results.matches.map((m: SearchMatch) => (
|
||
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
|
||
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
|
||
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||
<div className="flex-1 text-sm text-text">{m.team1.name} vs {m.team2.name}</div>
|
||
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-green">{m.scoreFt[0]}–{m.scoreFt[1]}</span>}
|
||
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
|
||
<div className="text-[10px] text-green-muted whitespace-nowrap">{m.year} · {m.round}</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function SearchClient() {
|
||
return (
|
||
<Suspense fallback={<div className="p-10 text-green-muted">Loading…</div>}>
|
||
<SearchContent />
|
||
</Suspense>
|
||
)
|
||
}
|