feat: team pages with match/tournament history, mobile padding fixes, linked scorers and nations

- Team page: add Tournament Participations (year pills → /tournaments/[year]) and
  Match History (grouped by year, W/D/L badge, opponent, score from team's perspective,
  PSO/AET annotations, each row → match anchor)
- GraphQL: extend matches() query with teamId filter (OR team1_id/team2_id)
- Match card: link team names to /teams/[slug]; fix ET score display — show scoreEt
  as headline for AET matches, scoreFt as footnote; winner determination uses
  scoreP ?? scoreEt ?? scoreFt
- Tournament page: scorer names below each match linked to /players/[name] with
  dotted underline (solid + green on hover)
- Stats page: reduce mobile padding on Goals chart, Top Scorers, Titles, Goals by
  Minute — hide progress bars and trophy emojis on small screens
- Homepage: Golden Boot Race same mobile padding/bar treatment; add slug to match
  team queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:07:56 +02:00
parent f1b5328b78
commit 9b8e266f88
9 changed files with 210 additions and 77 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+2
View File
@@ -1,6 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
@import "flag-icons/css/flag-icons.min.css"; @import "flag-icons/css/flag-icons.min.css";
@custom-variant hover (&:hover);
@theme { @theme {
--color-bg: #040d08; --color-bg: #040d08;
--color-card: #0a1810; --color-card: #0a1810;
+8 -8
View File
@@ -11,15 +11,15 @@ const HOME_QUERY = gql`
tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame }
liveMatches { liveMatches {
id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff
team1 { name iso2 } team2 { name iso2 } team1 { name iso2 slug } team2 { name iso2 slug }
} }
recentMatches(limit: 9) { recentMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
team1 { name iso2 } team2 { name iso2 } team1 { name iso2 slug } team2 { name iso2 slug }
} }
upcomingMatches(limit: 9) { upcomingMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt id year round group date time isLive isQualiPlayoff scoreFt
team1 { name iso2 } team2 { name iso2 } team1 { name iso2 slug } team2 { name iso2 slug }
} }
topScorers(year: 2026, limit: 8) { topScorers(year: 2026, limit: 8) {
playerName goals penalties ownGoals playerName goals penalties ownGoals
@@ -80,8 +80,8 @@ interface MatchData {
id: number; year: number; round: string; group?: string | null id: number; year: number; round: string; group?: string | null
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
team1: { name: string; iso2?: string | null } team1: { name: string; iso2?: string | null; slug?: string | null }
team2: { name: string; iso2?: string | null } team2: { name: string; iso2?: string | null; slug?: string | null }
} }
export default function HomePage() { export default function HomePage() {
@@ -185,15 +185,15 @@ export default function HomePage() {
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.16)' }}> <div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.16)' }}>
{scorers.map((s, i) => ( {scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}> <Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className="flex items-center gap-3 px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer" <div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}> style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span> <span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />} {s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i === 0 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div> <div className={`text-sm font-semibold truncate ${i === 0 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div> <div className="text-[10px] text-[#2a5c35] truncate">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div> </div>
<div className="w-24 h-1 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}> <div className="hidden sm:block w-24 h-1 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e] transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} /> <div className="h-full rounded-full bg-[#22c55e] transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
</div> </div>
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[24px] text-right flex-shrink-0">{s.goals}</span> <span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
+25 -23
View File
@@ -94,14 +94,14 @@ export default function StatsPage() {
<div className="mb-12"> <div className="mb-12">
<SectionTitle> Goals Scored per Tournament</SectionTitle> <SectionTitle> Goals Scored per Tournament</SectionTitle>
<Card> <Card>
<div className="p-7 pb-0"> <div className="px-3 pt-4 pb-0 sm:px-7 sm:pt-7">
<div className="flex items-end gap-[3px] h-[170px]"> <div className="flex items-end gap-[2px] sm:gap-[3px] h-[170px]">
{tournaments.map(t => { {tournaments.map(t => {
const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140)) const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140))
const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null
return ( return (
<Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[16px] group"> <Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[8px] group">
<div className="text-[7px] text-[#2a5c35] font-semibold mb-1 leading-none group-hover:text-[#22c55e]">{t.totalGoals}</div> <div className="text-[6px] sm:text-[7px] text-[#2a5c35] font-semibold mb-1 leading-none group-hover:text-[#22c55e]">{t.totalGoals}</div>
<div className="w-full rounded-t-sm border-t-2 transition-colors group-hover:bg-[rgba(34,197,94,0.35)]" <div className="w-full rounded-t-sm border-t-2 transition-colors group-hover:bg-[rgba(34,197,94,0.35)]"
style={{ height: `${h}px`, background: 'rgba(34,197,94,0.18)', borderColor: 'rgba(34,197,94,0.45)' }} style={{ height: `${h}px`, background: 'rgba(34,197,94,0.18)', borderColor: 'rgba(34,197,94,0.45)' }}
title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`} title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`}
@@ -110,7 +110,7 @@ export default function StatsPage() {
) )
})} })}
</div> </div>
<div className="flex gap-[3px] pt-1.5 pb-3.5 border-t mt-0" style={{ borderColor: 'rgba(34,197,94,0.06)' }}> <div className="flex gap-[2px] sm:gap-[3px] pt-1.5 pb-3 border-t" style={{ borderColor: 'rgba(34,197,94,0.06)' }}>
{tournaments.map(t => ( {tournaments.map(t => (
<div key={t.year} className="flex-1 text-center text-[6px] text-[#1a3a22]" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}> <div key={t.year} className="flex-1 text-center text-[6px] text-[#1a3a22]" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}>
{t.year} {t.year}
@@ -129,15 +129,15 @@ export default function StatsPage() {
<Card> <Card>
{scorers.map((s, i) => ( {scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}> <Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className="flex items-center gap-3 px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer" <div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}> style={{ borderColor: 'rgba(34,197,94,0.05)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span> <span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />} {s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`text-sm font-semibold truncate ${i < 3 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div> <div className={`text-sm font-semibold truncate ${i < 3 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{s.playerName}</div>
<div className="text-[10px] text-[#2a5c35]">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div> <div className="text-[10px] text-[#2a5c35] truncate">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div> </div>
<div className="w-16 h-1 rounded-full flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}> <div className="hidden sm:block w-16 h-1 rounded-full flex-shrink-0" style={{ background: 'rgba(34,197,94,0.1)' }}>
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(s.goals / maxScorer) * 100}%` }} /> <div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div> </div>
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[28px] text-right flex-shrink-0">{s.goals}</span> <span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
@@ -153,12 +153,12 @@ export default function StatsPage() {
<Card> <Card>
{titlesByNation.map((t, i) => ( {titlesByNation.map((t, i) => (
<Link key={t.name} href={`/teams/${t.slug}`}> <Link key={t.name} href={`/teams/${t.slug}`}>
<div className="flex items-center gap-3 px-4 py-3.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer" <div className="flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
style={{ borderColor: 'rgba(34,197,94,0.05)' }}> style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
<span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span> <span className="text-[11px] text-[#2a5c35] w-5 text-right font-bold flex-shrink-0">{i + 1}</span>
<TeamFlag name={t.name} iso2={t.iso2} size="sm" /> <TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<div className="flex-1 text-sm font-semibold text-[#dff5e8]">{t.name}</div> <div className="flex-1 min-w-0 text-sm font-semibold text-[#dff5e8] truncate">{t.name}</div>
<div className="flex gap-0.5 flex-shrink-0"> <div className="hidden sm:flex gap-0.5 flex-shrink-0">
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => ( {Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
<span key={j} className="text-sm">🏆</span> <span key={j} className="text-sm">🏆</span>
))} ))}
@@ -175,18 +175,20 @@ export default function StatsPage() {
{minuteBuckets.length > 0 && ( {minuteBuckets.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<SectionTitle> Goals by Minute (All-Time)</SectionTitle> <SectionTitle> Goals by Minute (All-Time)</SectionTitle>
<Card className="p-6"> <Card>
<div className="flex items-end gap-3 h-24"> <div className="px-3 py-4 sm:p-6">
{minuteBuckets.map(b => { <div className="flex items-end gap-1 sm:gap-3 h-24">
const h = Math.max(8, Math.round((b.count / maxMinute) * 80)) {minuteBuckets.map(b => {
return ( const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
<div key={b.bucket} className="flex-1 flex flex-col items-center gap-1.5"> return (
<span className="text-[9px] text-[#2a5c35] font-bold">{b.count}</span> <div key={b.bucket} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t" style={{ height: `${h}px`, background: 'rgba(34,197,94,0.3)', border: '1px solid rgba(34,197,94,0.5)' }} /> <span className="text-[7px] sm:text-[9px] text-[#2a5c35] font-bold leading-none">{b.count}</span>
<span className="text-[9px] text-[#1a3a22]">{b.bucket}</span> <div className="w-full rounded-t" style={{ height: `${h}px`, background: 'rgba(34,197,94,0.3)', border: '1px solid rgba(34,197,94,0.5)' }} />
</div> <span className="text-[7px] sm:text-[9px] text-[#1a3a22] leading-none">{b.bucket}</span>
) </div>
})} )
})}
</div>
</div> </div>
</Card> </Card>
</div> </div>
+116 -5
View File
@@ -3,7 +3,6 @@ import { useQuery, gql } from '@/lib/graphql/hooks'
import { use, useEffect } from 'react' import { use, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag' import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
const TEAM_QUERY = gql` const TEAM_QUERY = gql`
query Team($slug: String!) { query Team($slug: String!) {
@@ -14,10 +13,10 @@ const TEAM_QUERY = gql`
} }
` `
const TEAM_MATCHES_QUERY = gql` const TEAM_MATCHES_QUERY = gql`
query TeamMatches($teamName: String!) { query TeamMatches($teamId: Int!) {
topScorers(limit: 100) { matches(teamId: $teamId, isQuali: false) {
playerName goals penalties ownGoals tournaments id year round group date isLive scoreFt scoreEt scoreP
team { name iso2 } team1 { name iso2 slug } team2 { name iso2 slug }
} }
} }
` `
@@ -31,6 +30,18 @@ interface TeamData {
} | null } | 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 default function TeamPage({ params }: { params: Promise<{ slug: string }> }) { export default function TeamPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params) const { slug } = use(params)
const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } }) const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } })
@@ -40,6 +51,11 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }>
document.title = team ? `${team.name} · World Cup` : 'Team · World Cup' document.title = team ? `${team.name} · World Cup` : 'Team · World Cup'
}, [team]) }, [team])
const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, {
variables: { teamId: team?.id },
skip: !team?.id,
})
// Load all scorers to filter by team // Load all scorers to filter by team
const { data: scorerData } = useQuery(gql` const { data: scorerData } = useQuery(gql`
query TeamScorers { query TeamScorers {
@@ -52,6 +68,14 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }>
const allScorers = scorerData?.topScorers ?? [] const allScorers = scorerData?.topScorers ?? []
const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : [] const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : []
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) { if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading team</div> return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading team</div>
@@ -130,6 +154,93 @@ export default function TeamPage({ params }: { params: Promise<{ slug: string }>
</div> </div>
</div> </div>
)} )}
{/* Tournament participations */}
{years.length > 0 && (
<div className="mb-8">
<h2 className="text-[11px] text-[#2a5c35] 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 hover:text-[#22c55e] hover:border-[rgba(34,197,94,0.4)]"
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)', color: '#6abf7a' }}>
{year}
</Link>
))}
</div>
</div>
)}
{/* Match history by year */}
{years.length > 0 && (
<div>
<h2 className="text-[11px] text-[#2a5c35] 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-[#22c55e] mb-2 hover:opacity-70 transition-opacity">
{year}
</Link>
<div className="rounded-xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
{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-[#22c55e]' : result === 'L' ? 'text-[#ef4444]' : 'text-[#6abf7a]'
// 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-[rgba(34,197,94,0.03)] transition-colors"
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i % 2 === 0 ? undefined : 'rgba(34,197,94,0.01)' }}>
<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-[#dff5e8] truncate">{opponent.name}</div>
<div className="text-[10px] text-[#2a5c35]">
{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-[#22c55e] 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-[#2a5c35] leading-none">
{`${isHome ? ft[0] : ft[1]}${isHome ? ft[1] : ft[0]}`} a.e.t.
</div>
)}
{scoreEt && !scoreP && (
<div className="text-[9px] text-[#2a5c35] leading-none">a.e.t.</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div> </div>
{/* Sidebar: top scorers */} {/* Sidebar: top scorers */}
+12 -4
View File
@@ -49,12 +49,20 @@ function GoalList({ match }: { match: MatchData }) {
if (!match.goals?.length) return null if (!match.goals?.length) return null
const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id) const t1Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team1.id : g.team.id !== match.team1.id)
const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id) const t2Goals = match.goals.filter(g => !g.isOwnGoal ? g.team.id === match.team2.id : g.team.id !== match.team2.id)
const renderGoal = (g: MatchData['goals'][0]) => const renderGoal = (g: MatchData['goals'][0], i: number) => (
`${g.playerName} ${g.minute ?? ''}${g.minuteOffset ? `+${g.minuteOffset}` : ''}'${g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}` <span key={i}>
{i > 0 && <span className="mx-0.5">,</span>}
<Link href={`/players/${encodeURIComponent(g.playerName)}`}
className="underline decoration-dotted underline-offset-2 hover:text-[#22c55e] hover:decoration-solid transition-colors">
{g.playerName}
</Link>
{' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
</span>
)
return ( return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-[#2a5c35]"> <div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-[#2a5c35]">
<div className="text-left">{t1Goals.map(renderGoal).join(', ')}</div> <div className="text-left">{t1Goals.map(renderGoal)}</div>
<div className="text-right">{t2Goals.map(renderGoal).join(', ')}</div> <div className="text-right">{t2Goals.map(renderGoal)}</div>
</div> </div>
) )
} }
+44 -35
View File
@@ -2,7 +2,7 @@ import Link from 'next/link'
import { TeamFlag } from './team-flag' import { TeamFlag } from './team-flag'
import { LiveBadge } from './live-badge' import { LiveBadge } from './live-badge'
interface Team { name: string; iso2?: string | null } interface Team { name: string; iso2?: string | null; slug?: string | null }
interface Match { interface Match {
id: number id: number
year: number year: number
@@ -25,8 +25,8 @@ function formatDate(d: string) {
export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) { export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) {
const ft = match.scoreFt const ft = match.scoreFt
const hasScore = ft != null const hasScore = ft != null
// Penalty score determines the winner when present // Winner: penalties first, then ET, then FT
const decisive = match.scoreP ?? ft const decisive = match.scoreP ?? match.scoreEt ?? ft
const winner = decisive ? (decisive[0] > decisive[1] ? 'home' : decisive[0] < decisive[1] ? 'away' : 'draw') : null const winner = decisive ? (decisive[0] > decisive[1] ? 'home' : decisive[0] < decisive[1] ? 'away' : 'draw') : null
if (compact) { if (compact) {
@@ -48,7 +48,9 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?:
{hasScore {hasScore
? match.scoreP ? match.scoreP
? `${match.scoreP[0]} ${match.scoreP[1]}` ? `${match.scoreP[0]} ${match.scoreP[1]}`
: `${ft![0]} ${ft![1]}` : match.scoreEt
? `${match.scoreEt[0]} ${match.scoreEt[1]}`
: `${ft![0]} ${ft![1]}`
: match.isLive ? <LiveBadge label="•" /> : ''} : match.isLive ? <LiveBadge label="•" /> : ''}
</div> </div>
{match.scoreP && ( {match.scoreP && (
@@ -56,6 +58,9 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?:
{ft![0]}{ft![1]} a.e.t. {ft![0]}{ft![1]} a.e.t.
</div> </div>
)} )}
{match.scoreEt && !match.scoreP && (
<div className="text-[8px] text-[#2a5c35] leading-none">a.e.t.</div>
)}
</div> </div>
<div className="flex-1 flex items-center justify-end gap-2 overflow-hidden"> <div className="flex-1 flex items-center justify-end gap-2 overflow-hidden">
<span className={`text-sm font-medium truncate ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}> <span className={`text-sm font-medium truncate ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}>
@@ -72,42 +77,46 @@ export function MatchCard({ match, compact = false }: { match: Match; compact?:
) )
} }
const matchHref = `/tournaments/${match.year}#match-${match.id}`
return ( return (
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block"> <div className="bg-gradient-to-br from-[#0d2016] to-[#102a1c] border border-[rgba(34,197,94,0.28)] rounded-2xl px-5 py-6 sm:px-9 sm:py-9 hover:border-[rgba(34,197,94,0.45)] transition-colors">
<div className="bg-gradient-to-br from-[#0d2016] to-[#102a1c] border border-[rgba(34,197,94,0.28)] rounded-2xl px-5 py-6 sm:px-9 sm:py-9 hover:border-[rgba(34,197,94,0.45)] transition-colors"> {match.isLive && <div className="mb-4"><LiveBadge label="Live Now" /></div>}
{match.isLive && <div className="mb-4"><LiveBadge label="Live Now" /></div>} <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 sm:gap-8">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 sm:gap-8"> <Link href={match.team1.slug ? `/teams/${match.team1.slug}` : matchHref}
<div className="text-center"> className={`text-center block transition-colors hover:text-[#22c55e] ${winner === 'home' ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2" /> <TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2" />
<div className={`font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate ${winner === 'home' ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}> <div className="font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate">
{match.team1.name} {match.team1.name}
</div>
</div> </div>
<div className="text-center flex-shrink-0"> </Link>
<div className="font-['Bebas_Neue'] text-[48px] sm:text-[76px] text-[#22c55e] leading-none"> <Link href={matchHref} className="text-center flex-shrink-0 block">
{hasScore <div className="font-['Bebas_Neue'] text-[48px] sm:text-[76px] text-[#22c55e] leading-none hover:opacity-80 transition-opacity">
? match.scoreP {hasScore
? `${match.scoreP[0]}${match.scoreP[1]}` ? match.scoreP
? `${match.scoreP[0]}${match.scoreP[1]}`
: match.scoreEt
? `${match.scoreEt[0]}${match.scoreEt[1]}`
: `${ft![0]}${ft![1]}` : `${ft![0]}${ft![1]}`
: '??'} : '??'}
</div>
{match.scoreP && (
<div className="text-[10px] text-[#2a5c35] mt-0.5">{ft![0]}{ft![1]} a.e.t.</div>
)}
{match.scoreEt && !match.scoreP && (
<div className="text-[10px] text-[#2a5c35] mt-0.5">a.e.t.</div>
)}
<div className="text-[9px] text-[#2a5c35] tracking-[0.12em] uppercase mt-1.5">{match.round}</div>
<div className="text-[10px] text-[#1a3a22] mt-0.5">{match.date ? formatDate(match.date) : ''}</div>
</div> </div>
<div className="text-center"> {match.scoreP && (
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="xl" className="mb-2" /> <div className="text-[10px] text-[#2a5c35] mt-0.5">{ft![0]}{ft![1]} a.e.t.</div>
<div className={`font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}> )}
{match.team2.name} {match.scoreEt && !match.scoreP && (
</div> <div className="text-[10px] text-[#2a5c35] mt-0.5">{ft![0]}{ft![1]} (a.e.t.)</div>
)}
<div className="text-[9px] text-[#2a5c35] tracking-[0.12em] uppercase mt-1.5">{match.round}</div>
<div className="text-[10px] text-[#1a3a22] mt-0.5">{match.date ? formatDate(match.date) : ''}</div>
</Link>
<Link href={match.team2.slug ? `/teams/${match.team2.slug}` : matchHref}
className={`text-center block transition-colors hover:text-[#22c55e] ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate">
{match.team2.name}
</div> </div>
</div> </Link>
</div> </div>
</Link> </div>
) )
} }
+2 -1
View File
@@ -81,13 +81,14 @@ export const resolvers = {
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null } return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
} catch (e) { if (isMissingTable(e)) return null; throw e } } catch (e) { if (isMissingTable(e)) return null; throw e }
}, },
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) { async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean; teamId?: number }) {
try { try {
const conditions = [] const conditions = []
if (args.year) conditions.push(eq(matches.tournamentYear, args.year)) if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
if (args.group) conditions.push(eq(matches.groupName, args.group)) if (args.group) conditions.push(eq(matches.groupName, args.group))
if (args.round) conditions.push(eq(matches.round, args.round)) if (args.round) conditions.push(eq(matches.round, args.round))
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali)) if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
if (args.teamId) conditions.push(or(eq(matches.team1Id, args.teamId), eq(matches.team2Id, args.teamId))!)
const rows = await db.select().from(matches) const rows = await db.select().from(matches)
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(asc(matches.date), asc(matches.id)) .orderBy(asc(matches.date), asc(matches.id))
+1 -1
View File
@@ -161,7 +161,7 @@ export const typeDefs = /* GraphQL */ `
tournaments: [Tournament!]! tournaments: [Tournament!]!
tournament(year: Int!): Tournament tournament(year: Int!): Tournament
matches(year: Int, group: String, round: String, isQuali: Boolean): [Match!]! matches(year: Int, group: String, round: String, isQuali: Boolean, teamId: Int): [Match!]!
match(id: Int!): Match match(id: Int!): Match
liveMatches: [Match!]! liveMatches: [Match!]!
recentMatches(limit: Int): [Match!]! recentMatches(limit: Int): [Match!]!