Files
worldcup/app/search/page.tsx
T
valknar 58b4114159 feat: initial commit — World Cup stats app with pnpm, Traefik, Docker
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>
2026-06-14 15:36:44 +02:00

194 lines
8.9 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 { useSearchParams, useRouter } from 'next/navigation'
import { useState, useEffect, Suspense } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
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])
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-[#22c55e] 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-[#dff5e8] text-sm outline-none"
style={{ background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)' }}
/>
<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="#dff5e8" 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-[#22c55e] 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-[#2a5c35] text-base">Search for nations, players, or tournaments</div>
<div className="text-[#1a3a22] text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
</div>
)}
{/* No results */}
{!skip && !loading && total === 0 && (
<div className="text-center text-[#1a3a22] py-16 text-sm">No results for "{debouncedQ}"</div>
)}
{/* Results count */}
{!skip && total > 0 && (
<div className="text-[13px] text-[#2a5c35] 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-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
<div>
<div className="text-sm font-semibold text-[#dff5e8]">{t.name}</div>
<div className="text-[10px] text-[#2a5c35]">
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? ` · ${t.stats.titles} 🏆` : ''}
</div>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Players */}
{results?.players?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
{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-[#dff5e8] truncate">{p.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{p.goals}</span>
</div>
</Link>
))}
</div>
</section>
)}
{/* Tournaments */}
{results?.tournaments?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] 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="p-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{t.year}</div>
<div className="text-sm text-[#dff5e8]">{t.host}</div>
{t.winner && <div className="text-[10px] text-[#2a5c35] mt-1">🏆 {t.winner}</div>}
{t.totalGoals && <div className="text-[10px] text-[#1a3a22]"> {t.totalGoals} goals</div>}
</div>
</Link>
))}
</div>
</section>
)}
{/* Matches */}
{results?.matches?.length > 0 && (
<section>
<h3 className="text-[11px] text-[#2a5c35] 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="flex items-center gap-3 p-3 px-4 rounded-xl hover:border-[rgba(34,197,94,0.25)] transition-colors cursor-pointer"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 text-sm text-[#dff5e8]">{m.team1.name} vs {m.team2.name}</div>
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-[#22c55e]">{m.scoreFt[0]}{m.scoreFt[1]}</span>}
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
<div className="text-[10px] text-[#2a5c35] whitespace-nowrap">{m.year} · {m.round}</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense fallback={<div className="p-10 text-[#2a5c35]">Loading</div>}>
<SearchContent />
</Suspense>
)
}