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>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Database
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
DATABASE_URL=postgres://wc:changeme@db:5432/worldcup
|
||||||
|
|
||||||
|
# Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik)
|
||||||
|
TRAEFIK_ENABLED=false
|
||||||
|
TRAEFIK_HOST=worldcup.example.com
|
||||||
|
NETWORK_NAME=traefik-network
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/lib ./lib
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000 HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# World Cup
|
||||||
|
|
||||||
|
A full-stack World Cup statistics web app covering every tournament from 1930 to 2026. Built with Next.js 16, TailwindCSS 4, GraphQL, and PostgreSQL. Data is sourced from [openfootball/worldcup.json](https://github.com/openfootball/worldcup.json) and synced on a schedule so live 2026 results appear within minutes.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Live 2026 matches** — detected automatically when today's date matches a scheduled fixture; Apollo polls every 60 seconds for score updates
|
||||||
|
- **All-time statistics** — goals, hat-tricks, biggest wins, highest-scoring games, penalty stats, goals-by-minute heatmap, confederation performance, title counts
|
||||||
|
- **Group standings** — computed from match results for every tournament, pre-seeded from openfootball's standings files where available
|
||||||
|
- **Deep-linked pages** — every tournament, team, and player has a permanent URL (`/tournaments/1966`, `/teams/brazil`, `/players/Pelé`)
|
||||||
|
- **Full-text search** — across teams, tournaments, and players
|
||||||
|
- **Squad data** — 26-man rosters for 2026 with position, shirt number, and date of birth
|
||||||
|
- **Qualification playoffs** — 2026 inter-confederation playoff results stored separately
|
||||||
|
- **Country flags** — via `flag-icons` CSS classes, ~200 nations covered
|
||||||
|
- **Dark pitch aesthetic** — Bebas Neue headings, Space Grotesk body, green-on-black design
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Content |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Home: live matches, stat pills, latest result, upcoming fixtures, Golden Boot race |
|
||||||
|
| `/groups` | All 12 group tables for 2026 (P/W/D/L/GD/Pts) |
|
||||||
|
| `/stats` | Historical stats: goals chart, top scorers, hat-tricks, biggest wins, goals by minute, ET/shootout stats, confederation stats |
|
||||||
|
| `/history` | All 23 tournament cards newest-first, each with host, winner, top scorer |
|
||||||
|
| `/search?q=…` | Full-text search across teams, players, tournaments |
|
||||||
|
| `/tournaments/[year]` | Tournament detail: group stage with standings + matches, knockout rounds, scorer sidebar |
|
||||||
|
| `/teams/[slug]` | Team profile: all-time record, top scorers, WC appearances |
|
||||||
|
| `/players/[name]` | Player profile: goals by tournament, penalties vs open play breakdown |
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Next.js 16.2 (App Router, standalone output) |
|
||||||
|
| Styling | TailwindCSS 4 (CSS-first `@theme` config) |
|
||||||
|
| GraphQL server | GraphQL Yoga in `/api/graphql` Next.js route |
|
||||||
|
| GraphQL client | Apollo Client 4 with 60 s poll for live matches |
|
||||||
|
| ORM | Drizzle ORM with `postgres` driver |
|
||||||
|
| Database | PostgreSQL 16 |
|
||||||
|
| Flags | `flag-icons` npm package |
|
||||||
|
| Fonts | Bebas Neue + Space Grotesk (Google Fonts) |
|
||||||
|
| Container | Docker multi-stage build, Traefik-compatible |
|
||||||
|
|
||||||
|
## Data sources
|
||||||
|
|
||||||
|
All data is fetched from the [openfootball/worldcup.json](https://github.com/openfootball/worldcup.json) GitHub repository via raw URLs. The sync script fetches up to seven files per tournament year depending on availability:
|
||||||
|
|
||||||
|
| File | Content | Years available |
|
||||||
|
|---|---|---|
|
||||||
|
| `worldcup.json` | Matches, scores (FT/HT/ET/P), goal-scorer events | All (1930–2026) |
|
||||||
|
| `worldcup.teams.json` | Team details, FIFA codes, confederation | 2014–2026 |
|
||||||
|
| `worldcup.stadiums.json` | Stadium name, city, capacity, coordinates | 2014–2026 |
|
||||||
|
| `worldcup.groups.json` | Group compositions | 2014–2026 |
|
||||||
|
| `worldcup.standings.json` | Pre-computed group standings | 2014, 2018 |
|
||||||
|
| `worldcup.squads.json` | 26-man player rosters | 2026 |
|
||||||
|
| `worldcup.quali_playoffs.json` | Inter-confederation playoff results | 2026 |
|
||||||
|
|
||||||
|
**Note:** Individual goal-scorer records are only available from openfootball for 1930–1950, 1990, 2006, and 2014–2026. Match scores (used for standings, biggest wins, etc.) are complete for all years.
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
```
|
||||||
|
tournaments year PK, host, winner, runner_up, third, fourth,
|
||||||
|
teams_count, matches_count, total_goals, avg_goals_per_game
|
||||||
|
|
||||||
|
teams id, name UNIQUE, iso2, fifa_code, continent, confederation
|
||||||
|
|
||||||
|
stadiums id, tournament_year FK, name, city, country_code,
|
||||||
|
capacity, timezone, coordinates
|
||||||
|
|
||||||
|
matches id, tournament_year FK, round, group_name, date, time_local,
|
||||||
|
stadium_id FK, team1_id FK, team2_id FK,
|
||||||
|
score_ft_home, score_ft_away,
|
||||||
|
score_ht_home, score_ht_away,
|
||||||
|
score_et_home, score_et_away,
|
||||||
|
score_p_home, score_p_away,
|
||||||
|
is_quali_playoff
|
||||||
|
|
||||||
|
goals id, match_id FK, team_id FK, player_name,
|
||||||
|
minute, minute_offset, is_penalty, is_own_goal
|
||||||
|
|
||||||
|
group_standings tournament_year FK, group_name, team_id FK,
|
||||||
|
pos, played, won, drawn, lost,
|
||||||
|
goals_for, goals_against, goal_diff, pts
|
||||||
|
|
||||||
|
squads id, tournament_year FK, team_id FK, player_name,
|
||||||
|
shirt_number, position, date_of_birth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js 22+, pnpm 10+, Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and install
|
||||||
|
git clone <repo-url> worldcup
|
||||||
|
cd worldcup
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 2. Start the database
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 3. Seed all 23 tournaments
|
||||||
|
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync
|
||||||
|
|
||||||
|
# 4. Start the dev server
|
||||||
|
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
To stop the database: `docker compose -f docker-compose.dev.yml down`
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | Yes | PostgreSQL connection string |
|
||||||
|
| `DB_PASSWORD` | Production | Password for the `wc` DB user (used by docker-compose.yml) |
|
||||||
|
| `TRAEFIK_ENABLED` | Production | Set to `true` to activate Traefik router labels |
|
||||||
|
| `TRAEFIK_HOST` | Production | Public hostname, e.g. `worldcup.example.com` |
|
||||||
|
| `NETWORK_NAME` | Production | Name of the external Docker network Traefik is attached to |
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in the values before deploying.
|
||||||
|
|
||||||
|
## Deployment (Coolify + Traefik)
|
||||||
|
|
||||||
|
The app is designed for self-hosted deployment via [Coolify](https://coolify.io) behind a [Traefik](https://traefik.io) reverse proxy.
|
||||||
|
|
||||||
|
### 1. Configure environment
|
||||||
|
|
||||||
|
In Coolify's environment variable editor set:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_PASSWORD=<strong-random-password>
|
||||||
|
DATABASE_URL=postgres://wc:<DB_PASSWORD>@db:5432/worldcup
|
||||||
|
TRAEFIK_ENABLED=true
|
||||||
|
TRAEFIK_HOST=worldcup.yourdomain.com
|
||||||
|
NETWORK_NAME=<your-traefik-network-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy
|
||||||
|
|
||||||
|
Coolify builds the Docker image via `docker compose up` and attaches the container to the Traefik network automatically. TLS certificates are issued by the `resolver` cert resolver configured in Traefik.
|
||||||
|
|
||||||
|
### 3. Initial data sync
|
||||||
|
|
||||||
|
After the first deployment run the sync once manually in Coolify's terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app pnpm sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Scheduled sync (live updates)
|
||||||
|
|
||||||
|
In Coolify → your service → **Scheduled Tasks**, add:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Command | `pnpm sync` |
|
||||||
|
| Schedule | `*/10 * * * *` |
|
||||||
|
| Container | `app` |
|
||||||
|
|
||||||
|
This re-syncs from openfootball every 10 minutes. During the 2026 group stage new match results appear within 10 minutes of the final whistle.
|
||||||
|
|
||||||
|
## Running the sync manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From host (dev)
|
||||||
|
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync
|
||||||
|
|
||||||
|
# Inside the app container (production)
|
||||||
|
docker compose exec app pnpm sync
|
||||||
|
```
|
||||||
|
|
||||||
|
The sync is fully idempotent — safe to run repeatedly. It upserts every record and recomputes tournament aggregates at the end of each year.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
worldcup/
|
||||||
|
├── app/
|
||||||
|
│ ├── layout.tsx # Root layout: nav, fonts, Apollo provider
|
||||||
|
│ ├── page.tsx # Home page
|
||||||
|
│ ├── groups/page.tsx # 2026 group standings
|
||||||
|
│ ├── stats/page.tsx # All-time statistics
|
||||||
|
│ ├── history/page.tsx # Tournament history cards
|
||||||
|
│ ├── search/page.tsx # Full-text search
|
||||||
|
│ ├── tournaments/[year]/page.tsx # Tournament detail
|
||||||
|
│ ├── teams/[slug]/page.tsx # Team profile
|
||||||
|
│ ├── players/[name]/page.tsx # Player profile
|
||||||
|
│ └── api/graphql/route.ts # GraphQL Yoga endpoint
|
||||||
|
├── components/
|
||||||
|
│ ├── apollo-provider.tsx # Apollo Client provider wrapper
|
||||||
|
│ ├── nav.tsx # Top navigation bar
|
||||||
|
│ ├── team-flag.tsx # flag-icons wrapper component
|
||||||
|
│ ├── match-card.tsx # Match result / fixture card
|
||||||
|
│ └── live-badge.tsx # Pulsing LIVE indicator
|
||||||
|
├── lib/
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── schema.ts # Drizzle table definitions
|
||||||
|
│ │ └── index.ts # DB connection singleton
|
||||||
|
│ ├── graphql/
|
||||||
|
│ │ ├── schema.ts # GraphQL SDL
|
||||||
|
│ │ ├── resolvers/index.ts # All resolvers
|
||||||
|
│ │ ├── hooks.ts # Apollo v4 useQuery wrapper
|
||||||
|
│ │ └── client.ts # Apollo Client factory
|
||||||
|
│ └── iso-codes.ts # Team name → ISO2 country code map
|
||||||
|
├── scripts/
|
||||||
|
│ └── sync.ts # Data sync script (all years, idempotent)
|
||||||
|
├── docker-compose.yml # Production (Traefik + external network)
|
||||||
|
├── docker-compose.dev.yml # Local dev (DB only, port 5432 exposed)
|
||||||
|
├── Dockerfile # Multi-stage pnpm build
|
||||||
|
├── .env.example # Environment variable template
|
||||||
|
├── next.config.ts # standalone output, serverExternalPackages
|
||||||
|
├── drizzle.config.ts # Drizzle Kit config
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
**Live match detection** — A match is considered live when its date equals today and the current time is within 5 minutes before kick-off to 125 minutes after. Time zones are stripped; all times are treated as local tournament time. Apollo's `pollInterval: 60_000` re-queries `liveMatches` every minute.
|
||||||
|
|
||||||
|
**Apollo Client v4** — This project uses Apollo Client 4 which moved hooks to `@apollo/client/react` and core utilities to `@apollo/client/core`. A thin wrapper in `lib/graphql/hooks.ts` re-exports `useQuery` typed as `Record<string, any>` to avoid the v4 `TData = {}` default breaking all field accesses.
|
||||||
|
|
||||||
|
**Standalone Docker output** — `next.config.ts` sets `output: 'standalone'` which produces a self-contained `server.js`. The `scripts/` and `lib/` directories are copied separately into the runner stage so `pnpm sync` works inside the container without needing a full Node/TypeScript toolchain reinstall.
|
||||||
|
|
||||||
|
**Group standings** — Pre-computed standings from openfootball are stored directly. For all other years (and 2026 during the tournament) standings are computed live from match results via a SQL `GROUP BY` query in the `groupStandings` resolver.
|
||||||
|
|
||||||
|
**Total goals** — Tournament goal counts are derived from match score totals (`score_ft_home + score_ft_away`), not from the goals table. This ensures correct numbers for all years, including those where individual scorer records are not available in the openfootball dataset.
|
||||||
|
|
||||||
|
## GraphQL API
|
||||||
|
|
||||||
|
The GraphQL playground is available at `/api/graphql` in development.
|
||||||
|
|
||||||
|
Key queries:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# Live matches right now
|
||||||
|
{ liveMatches { id date time team1 { name } team2 { name } scoreFt isLive } }
|
||||||
|
|
||||||
|
# All-time top scorers
|
||||||
|
{ topScorers(limit: 10) { playerName goals penalties team { name iso2 } } }
|
||||||
|
|
||||||
|
# 2026 group standings
|
||||||
|
{ groupStandings(year: 2026) { groupName pos team { name iso2 } played won drawn lost goalsFor goalsAgainst pts } }
|
||||||
|
|
||||||
|
# Tournament detail
|
||||||
|
{ tournament(year: 2022) { year host winner totalGoals avgGoalsPerGame } }
|
||||||
|
|
||||||
|
# Team stats
|
||||||
|
{ team(slug: "brazil") { name stats { appearances wins losses titles goalsFor } } }
|
||||||
|
|
||||||
|
# Full-text search
|
||||||
|
{ search(query: "Ronaldo") { teams { name } players { playerName goals } } }
|
||||||
|
|
||||||
|
# Hat-tricks in World Cup history
|
||||||
|
{ hatTricks { playerName goals year round team { name } opponent { name } } }
|
||||||
|
|
||||||
|
# Global stats
|
||||||
|
{ tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } }
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createYoga } from 'graphql-yoga'
|
||||||
|
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||||
|
import { typeDefs } from '@/lib/graphql/schema'
|
||||||
|
import { resolvers } from '@/lib/graphql/resolvers'
|
||||||
|
|
||||||
|
const schema = makeExecutableSchema({ typeDefs, resolvers })
|
||||||
|
|
||||||
|
const yoga = createYoga({
|
||||||
|
schema,
|
||||||
|
graphqlEndpoint: '/api/graphql',
|
||||||
|
fetchAPI: { Response, Request, ReadableStream },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GET = yoga
|
||||||
|
export const POST = yoga
|
||||||
|
export const OPTIONS = yoga
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "flag-icons/css/flag-icons.min.css";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: #040d08;
|
||||||
|
--color-card: #0a1810;
|
||||||
|
--color-hero: #0d2416;
|
||||||
|
--color-green: #22c55e;
|
||||||
|
--color-green-light: #4ade80;
|
||||||
|
--color-green-sec: #6abf7a;
|
||||||
|
--color-green-muted: #2a5c35;
|
||||||
|
--color-green-dark: #1a3a22;
|
||||||
|
--color-text: #dff5e8;
|
||||||
|
--color-border: rgba(34,197,94,0.15);
|
||||||
|
|
||||||
|
--font-display: "Bebas Neue", cursive;
|
||||||
|
--font-body: "Space Grotesk", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #040d08;
|
||||||
|
color: #dff5e8;
|
||||||
|
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: #020a04; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.25); border-radius: 4px; }
|
||||||
|
|
||||||
|
@keyframes livePulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.2; transform: scale(0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-live { animation: livePulse 2s ease-in-out infinite; }
|
||||||
|
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
|
||||||
|
.pitch-grid {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 44px,
|
||||||
|
rgba(34,197,94,0.018) 44px,
|
||||||
|
rgba(34,197,94,0.018) 88px
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { TeamFlag } from '@/components/team-flag'
|
||||||
|
|
||||||
|
const GROUPS_QUERY = gql`
|
||||||
|
query Groups {
|
||||||
|
groupStandings(year: 2026) {
|
||||||
|
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
|
||||||
|
team { id name iso2 slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface Standing {
|
||||||
|
groupName: string; pos?: number | null
|
||||||
|
played: number; won: number; drawn: number; lost: number
|
||||||
|
goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number
|
||||||
|
team: { id: number; name: string; iso2?: string | null; slug: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
|
||||||
|
|
||||||
|
const standings: Standing[] = data?.groupStandings ?? []
|
||||||
|
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
||||||
|
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const groups = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||||||
|
<div className="mb-9">
|
||||||
|
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-[#22c55e] leading-none">2026 Groups</h1>
|
||||||
|
<p className="text-[#2a5c35] text-sm mt-1.5">48 teams · 12 groups · Top 2 + 8 best 3rd-place advance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-56 rounded-2xl animate-pulse" style={{ background: '#0a1810' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||||
|
{groups.map(([groupName, rows]) => {
|
||||||
|
const sorted = [...rows].sort((a, b) => {
|
||||||
|
if (b.pts !== a.pts) return b.pts - a.pts
|
||||||
|
if (b.goalDiff !== a.goalDiff) return b.goalDiff - a.goalDiff
|
||||||
|
return b.goalsFor - a.goalsFor
|
||||||
|
})
|
||||||
|
const letter = groupName.replace('Group ', '')
|
||||||
|
return (
|
||||||
|
<div key={groupName} className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||||
|
<div className="px-4 py-3 border-b" style={{
|
||||||
|
background: 'linear-gradient(90deg,rgba(34,197,94,0.12) 0%,rgba(34,197,94,0.04) 100%)',
|
||||||
|
borderColor: 'rgba(34,197,94,0.1)',
|
||||||
|
}}>
|
||||||
|
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] tracking-[0.05em]">GROUP {letter}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
|
||||||
|
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
|
||||||
|
<span>Team</span>
|
||||||
|
<span className="text-center">P</span><span className="text-center">W</span>
|
||||||
|
<span className="text-center">D</span><span className="text-center">L</span>
|
||||||
|
<span className="text-center">GD</span><span className="text-center">Pts</span>
|
||||||
|
</div>
|
||||||
|
{sorted.map((t, idx) => (
|
||||||
|
<Link key={t.team.id} href={`/teams/${t.team.slug}`}>
|
||||||
|
<div className="grid px-4 py-2.5 items-center border-t hover:bg-[rgba(34,197,94,0.03)] transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px',
|
||||||
|
gap: '3px',
|
||||||
|
borderColor: 'rgba(34,197,94,0.06)',
|
||||||
|
background: idx < 2 ? 'rgba(34,197,94,0.025)' : undefined,
|
||||||
|
}}>
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<TeamFlag name={t.team.name} iso2={t.team.iso2} size="sm" />
|
||||||
|
<span className={`text-sm truncate font-medium ${idx < 2 ? 'text-[#dff5e8]' : 'text-[#6abf7a]'}`}>{t.team.name}</span>
|
||||||
|
</div>
|
||||||
|
{[t.played, t.won, t.drawn, t.lost].map((v, i) => (
|
||||||
|
<span key={i} className="text-center text-[13px] text-[#4a7a55]">{v}</span>
|
||||||
|
))}
|
||||||
|
<span className="text-center text-[13px] text-[#4a7a55]">
|
||||||
|
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
|
||||||
|
</span>
|
||||||
|
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.pts}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { TeamFlag } from '@/components/team-flag'
|
||||||
|
|
||||||
|
const HISTORY_QUERY = gql`
|
||||||
|
query History {
|
||||||
|
tournaments {
|
||||||
|
year host winner runnerUp thirdPlace fourthPlace
|
||||||
|
totalGoals matchesCount teamsCount avgGoalsPerGame
|
||||||
|
topScorers(limit: 1) { playerName goals team { name iso2 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface Tournament {
|
||||||
|
year: number; host: string; winner?: string | null; runnerUp?: string | null
|
||||||
|
thirdPlace?: string | null; fourthPlace?: string | null
|
||||||
|
totalGoals?: number | null; matchesCount?: number | null; teamsCount?: number | null
|
||||||
|
avgGoalsPerGame?: string | number | null
|
||||||
|
topScorers: Array<{ playerName: string; goals: number; team?: { name: string; iso2?: string | null } | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostIso(host: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'Uruguay': 'uy', 'Italy': 'it', 'France': 'fr', 'Brazil': 'br',
|
||||||
|
'Switzerland': 'ch', 'Sweden': 'se', 'Chile': 'cl', 'England': 'gb-eng',
|
||||||
|
'Mexico': 'mx', 'Germany': 'de', 'Argentina': 'ar', 'Spain': 'es',
|
||||||
|
'South Korea / Japan': 'kr', 'South Africa': 'za', 'Russia': 'ru',
|
||||||
|
'Qatar': 'qa', 'USA': 'us', 'USA / Canada / Mexico': 'us',
|
||||||
|
}
|
||||||
|
return map[host] ?? 'un'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const { data, loading } = useQuery(HISTORY_QUERY)
|
||||||
|
const tournaments: Tournament[] = data?.tournaments ?? []
|
||||||
|
const is2026InProgress = !tournaments.find(t => t.year === 2026)?.winner
|
||||||
|
|
||||||
|
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-2">
|
||||||
|
World Cup History
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#2a5c35] text-sm mb-9">
|
||||||
|
Every edition — Uruguay 1930 through 2026 · {tournaments.length} tournaments
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||||
|
{Array.from({ length: 24 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-52 rounded-2xl animate-pulse" style={{ background: '#0a1810' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(238px,1fr))] gap-3.5">
|
||||||
|
{tournaments.map(t => {
|
||||||
|
const inProgress = t.year === 2026 && is2026InProgress
|
||||||
|
const topScorer = t.topScorers?.[0]
|
||||||
|
return (
|
||||||
|
<Link key={t.year} href={`/tournaments/${t.year}`}>
|
||||||
|
<div className="rounded-2xl p-5 relative overflow-hidden cursor-pointer hover:border-[rgba(34,197,94,0.3)] transition-colors"
|
||||||
|
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.13)' }}>
|
||||||
|
{/* Year watermark */}
|
||||||
|
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none"
|
||||||
|
style={{ color: 'rgba(34,197,94,0.04)' }}>
|
||||||
|
{t.year}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex justify-between items-start mb-3.5">
|
||||||
|
<div>
|
||||||
|
<div className="font-['Bebas_Neue'] text-[34px] text-[#22c55e] leading-none">{t.year}</div>
|
||||||
|
<div className="text-xs text-[#2a5c35] mt-0.5 flex items-center gap-1.5">
|
||||||
|
<span className={`fi fi-${HostIso(t.host)} rounded-sm text-sm inline-block`} />
|
||||||
|
{t.host}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inProgress
|
||||||
|
? <div className="text-[10px] text-[#22c55e] font-bold tracking-[0.12em] bg-[rgba(34,197,94,0.1)] px-2.5 py-1 rounded-full mt-1">
|
||||||
|
IN PROGRESS
|
||||||
|
</div>
|
||||||
|
: t.winner && (
|
||||||
|
<div className="text-right">
|
||||||
|
<TeamFlag name={t.winner} size="md" />
|
||||||
|
<div className="text-[11px] text-[#6abf7a] mt-0.5">{t.winner}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!inProgress && t.winner && t.runnerUp && (
|
||||||
|
<div className="rounded-lg px-3 py-2 text-xs text-[#6abf7a] mb-3"
|
||||||
|
style={{ background: 'rgba(34,197,94,0.07)' }}>
|
||||||
|
<span className="font-semibold text-[#dff5e8]">{t.winner}</span>
|
||||||
|
<span className="mx-2 text-[#2a5c35]">def.</span>
|
||||||
|
{t.runnerUp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3.5 text-[11px] text-[#2a5c35] flex-wrap">
|
||||||
|
{t.totalGoals != null && <span>⚽ {t.totalGoals}</span>}
|
||||||
|
{t.matchesCount != null && <span>🗓 {t.matchesCount} games</span>}
|
||||||
|
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topScorer && (
|
||||||
|
<div className="mt-2 text-[10px] text-[#1a3a22]">
|
||||||
|
Golden Boot: <span className="text-[#2a5c35]">{topScorer.playerName} ({topScorer.goals}⚽)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Bebas_Neue, Space_Grotesk } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
import { Nav } from '@/components/nav'
|
||||||
|
import { AppApolloProvider } from '@/components/apollo-provider'
|
||||||
|
|
||||||
|
const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' })
|
||||||
|
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { default: 'World Cup', template: '%s · World Cup' },
|
||||||
|
description: 'Comprehensive World Cup statistics from 1930 to 2026',
|
||||||
|
icons: {
|
||||||
|
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='46' fill='%230a1810'/><polygon points='54,38 63,50 54,62 39,57 40,42' fill='none' stroke='%2322c55e' stroke-width='3'/><line x1='54' y1='38' x2='65' y2='9' stroke='%2322c55e' stroke-width='2.5'/><line x1='63' y1='50' x2='94' y2='51' stroke='%2322c55e' stroke-width='2.5'/><line x1='54' y1='62' x2='62' y2='92' stroke='%2322c55e' stroke-width='2.5'/><line x1='39' y1='57' x2='14' y2='75' stroke='%2322c55e' stroke-width='2.5'/><line x1='40' y1='42' x2='15' y2='23' stroke='%2322c55e' stroke-width='2.5'/><circle cx='50' cy='50' r='46' fill='none' stroke='%2322c55e' stroke-width='3'/></svg>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={`${bebasNeue.variable} ${spaceGrotesk.variable}`}>
|
||||||
|
<body>
|
||||||
|
<AppApolloProvider>
|
||||||
|
<Nav />
|
||||||
|
<main className="pt-[60px] min-h-screen">{children}</main>
|
||||||
|
</AppApolloProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
'use client'
|
||||||
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { TeamFlag } from '@/components/team-flag'
|
||||||
|
import { LiveBadge } from '@/components/live-badge'
|
||||||
|
import { MatchCard } from '@/components/match-card'
|
||||||
|
|
||||||
|
const HOME_QUERY = gql`
|
||||||
|
query Home {
|
||||||
|
tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame }
|
||||||
|
liveMatches {
|
||||||
|
id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff
|
||||||
|
team1 { name iso2 } team2 { name iso2 }
|
||||||
|
}
|
||||||
|
recentMatches(limit: 9) {
|
||||||
|
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
|
||||||
|
team1 { name iso2 } team2 { name iso2 }
|
||||||
|
}
|
||||||
|
upcomingMatches(limit: 9) {
|
||||||
|
id year round group date time isLive isQualiPlayoff scoreFt
|
||||||
|
team1 { name iso2 } team2 { name iso2 }
|
||||||
|
}
|
||||||
|
topScorers(year: 2026, limit: 8) {
|
||||||
|
playerName goals penalties ownGoals
|
||||||
|
team { name iso2 }
|
||||||
|
}
|
||||||
|
tournament(year: 2026) { year totalGoals matchesCount avgGoalsPerGame }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function SectionHeader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 mb-4">
|
||||||
|
<div className="w-[3px] h-[18px] bg-[#22c55e] rounded-sm" />
|
||||||
|
<span className="text-[11px] text-[#2a5c35] font-bold tracking-[0.12em] uppercase">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[90px] rounded-xl p-3.5 px-5"
|
||||||
|
style={{ background: 'rgba(34,197,94,0.05)', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||||
|
<div className="text-[9px] text-[#2a5c35] tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
|
||||||
|
<div className="font-['Bebas_Neue'] text-[30px] text-[#22c55e] leading-none">{value ?? '–'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpcomingMatch {
|
||||||
|
id: number; year: number; time?: string | null; date?: string | null
|
||||||
|
team1: { name: string; iso2?: string | null }
|
||||||
|
team2: { name: string; iso2?: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpcomingFixture({ match }: { match: UpcomingMatch }) {
|
||||||
|
const time = match.time?.split(' ')[0] ?? ''
|
||||||
|
return (
|
||||||
|
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
|
||||||
|
<div className="rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-[rgba(34,197,94,0.2)] transition-colors cursor-pointer"
|
||||||
|
style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.07)' }}>
|
||||||
|
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
|
||||||
|
<div className="flex-1 text-[13px] text-[#6abf7a] font-medium truncate">
|
||||||
|
{match.team1.name} <span className="text-[#2a5c35]">vs</span> {match.team2.name}
|
||||||
|
</div>
|
||||||
|
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
|
||||||
|
{time && <div className="text-[11px] text-[#2a5c35] whitespace-nowrap ml-1">{time}</div>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScorerEntry {
|
||||||
|
playerName: string; goals: number; penalties: number
|
||||||
|
team?: { name: string; iso2?: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchData {
|
||||||
|
id: number; year: number; round: string; group?: string | null
|
||||||
|
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
|
||||||
|
scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
|
||||||
|
team1: { name: string; iso2?: string | null }
|
||||||
|
team2: { name: string; iso2?: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 })
|
||||||
|
|
||||||
|
const stats = data?.tournamentStats
|
||||||
|
const live: MatchData[] = data?.liveMatches ?? []
|
||||||
|
const recent: MatchData[] = data?.recentMatches ?? []
|
||||||
|
const upcoming: UpcomingMatch[] = data?.upcomingMatches ?? []
|
||||||
|
const scorers: ScorerEntry[] = data?.topScorers ?? []
|
||||||
|
const wc2026 = data?.tournament
|
||||||
|
|
||||||
|
const maxGoals = Math.max(...scorers.map(s => s.goals), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── Hero ── */}
|
||||||
|
<div className="pitch-grid border-b" style={{
|
||||||
|
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 55%,#0a1a0e 100%)',
|
||||||
|
borderColor: 'rgba(34,197,94,0.15)',
|
||||||
|
padding: '52px 0 44px',
|
||||||
|
}}>
|
||||||
|
<div className="max-w-[1200px] mx-auto px-7">
|
||||||
|
<div className="mb-4">
|
||||||
|
{live.length > 0
|
||||||
|
? <LiveBadge label="Live · Group Stage in Progress" />
|
||||||
|
: <div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#22c55e] inline-block" />
|
||||||
|
<span className="text-[11px] font-bold text-[#22c55e] tracking-[0.14em] uppercase">World Cup 2026 · In Progress</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-['Bebas_Neue'] text-[clamp(50px,9vw,100px)] tracking-[0.04em] text-white leading-[0.92] mb-2.5">
|
||||||
|
World Cup 2026
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#2a5c35] text-sm mb-9">
|
||||||
|
<span className="fi fi-us rounded-sm text-lg mx-0.5 inline-block" /> USA ·{' '}
|
||||||
|
<span className="fi fi-ca rounded-sm text-lg mx-0.5 inline-block" /> Canada ·{' '}
|
||||||
|
<span className="fi fi-mx rounded-sm text-lg mx-0.5 inline-block" /> Mexico
|
||||||
|
· 11 June – 19 July 2026 · 48 Teams
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2.5 flex-wrap max-w-[760px]">
|
||||||
|
{stats ? <>
|
||||||
|
<StatPill label="Tournaments" value={stats.totalTournaments} />
|
||||||
|
<StatPill label="Matches" value={stats.totalMatches} />
|
||||||
|
<StatPill label="Goals" value={stats.totalGoals} />
|
||||||
|
<StatPill label="Goals/Game" value={stats.avgGoalsPerGame?.toFixed(2) ?? '–'} />
|
||||||
|
{wc2026 && <>
|
||||||
|
<StatPill label="2026 Goals" value={wc2026.totalGoals ?? 0} />
|
||||||
|
<StatPill label="2026 Avg" value={wc2026.avgGoalsPerGame ? Number(wc2026.avgGoalsPerGame).toFixed(2) : '–'} />
|
||||||
|
</>}
|
||||||
|
</> : [1,2,3,4].map(i => (
|
||||||
|
<div key={i} className="flex-1 min-w-[90px] h-20 rounded-xl animate-pulse" style={{ background: 'rgba(34,197,94,0.04)' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-[1200px] mx-auto px-7">
|
||||||
|
{/* Live matches */}
|
||||||
|
{live.length > 0 && (
|
||||||
|
<div className="pt-9">
|
||||||
|
<SectionHeader label="Live Now" />
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{live.map(m => <MatchCard key={m.id} match={m} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Latest result */}
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div className="pt-9">
|
||||||
|
<SectionHeader label="Latest Result" />
|
||||||
|
<MatchCard match={recent[0]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent grid */}
|
||||||
|
{recent.length > 1 && (
|
||||||
|
<div className="pt-8">
|
||||||
|
<SectionHeader label="Recent Results" />
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(290px,1fr))] gap-2.5">
|
||||||
|
{recent.slice(1).map(m => <MatchCard key={m.id} match={m} compact />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming */}
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<div className="pt-8">
|
||||||
|
<SectionHeader label="Upcoming Fixtures" />
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2">
|
||||||
|
{upcoming.map(m => <UpcomingFixture key={m.id} match={m} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Golden Boot 2026 */}
|
||||||
|
{scorers.length > 0 && (
|
||||||
|
<div className="pt-8 pb-16">
|
||||||
|
<SectionHeader label="2026 Golden Boot Race" />
|
||||||
|
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.16)' }}>
|
||||||
|
{scorers.map((s, i) => (
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
|
||||||
|
<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-[10px] text-[#2a5c35]">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</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="h-full rounded-full bg-[#22c55e] transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[#1a3a22] mt-3 text-center">
|
||||||
|
<Link href="/stats" className="hover:text-[#2a5c35]">View all-time top scorers →</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="py-16 text-center text-[#2a5c35] text-sm">Loading live World Cup data…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
'use client'
|
||||||
|
import { useQuery, gql } from '@/lib/graphql/hooks'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { TeamFlag } from '@/components/team-flag'
|
||||||
|
|
||||||
|
const STATS_QUERY = gql`
|
||||||
|
query Stats {
|
||||||
|
tournaments { year host totalGoals matchesCount avgGoalsPerGame winner }
|
||||||
|
topScorers(limit: 20) {
|
||||||
|
playerName goals penalties ownGoals tournaments
|
||||||
|
team { name iso2 slug }
|
||||||
|
}
|
||||||
|
teams {
|
||||||
|
id name iso2 slug
|
||||||
|
stats { appearances titles wins draws losses goalsFor goalsAgainst winPct }
|
||||||
|
}
|
||||||
|
goalsByMinute { bucket count }
|
||||||
|
confederationStats { confederation appearances titles totalGoals }
|
||||||
|
hatTricks {
|
||||||
|
playerName year round goals
|
||||||
|
team { name iso2 }
|
||||||
|
opponent { name iso2 }
|
||||||
|
}
|
||||||
|
biggestWins(limit: 10) {
|
||||||
|
id year round date margin totalGoals scoreFt
|
||||||
|
team1 { name iso2 } team2 { name iso2 }
|
||||||
|
}
|
||||||
|
highestScoringMatches(limit: 10) {
|
||||||
|
id year round date totalGoals scoreFt
|
||||||
|
team1 { name iso2 } team2 { name iso2 }
|
||||||
|
}
|
||||||
|
extraTimeStats {
|
||||||
|
totalKnockoutMatches wentToExtraTime wentToPenalties extraTimePct penaltiesPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return <h2 className="text-[11px] font-bold tracking-[0.14em] uppercase text-[#2a5c35] mb-4">{children}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl overflow-hidden ${className}`} style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tournament { year: number; host: string; totalGoals?: number | null; matchesCount?: number | null; avgGoalsPerGame?: string | number | null; winner?: string | null }
|
||||||
|
interface Scorer { playerName: string; goals: number; penalties: number; ownGoals: number; tournaments: number; team?: { name: string; iso2?: string | null; slug: string } | null }
|
||||||
|
interface TeamRow { id: number; name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number; wins: number; draws: number; losses: number; goalsFor: number; goalsAgainst: number; winPct: number } | null }
|
||||||
|
interface MinuteBucket { bucket: string; count: number }
|
||||||
|
interface ConfStat { confederation: string; appearances: number; titles: number; totalGoals: number }
|
||||||
|
interface HatTrick { playerName: string; year: number; round: string; goals: number; team?: { name: string; iso2?: string | null } | null; opponent?: { name: string; iso2?: string | null } | null }
|
||||||
|
interface MatchRow { id: number; year: number; round: string; date?: string | null; margin?: number | null; totalGoals?: number | null; scoreFt?: number[] | null; team1: { name: string; iso2?: string | null }; team2: { name: string; iso2?: string | null } }
|
||||||
|
interface ETStats { totalKnockoutMatches: number; wentToExtraTime: number; wentToPenalties: number; extraTimePct: number; penaltiesPct: number }
|
||||||
|
|
||||||
|
export default function StatsPage() {
|
||||||
|
const { data, loading } = useQuery(STATS_QUERY)
|
||||||
|
|
||||||
|
const tournaments: Tournament[] = (data?.tournaments ?? []).filter((t: Tournament) => t.totalGoals != null).sort((a: Tournament, b: Tournament) => a.year - b.year)
|
||||||
|
const scorers: Scorer[] = data?.topScorers ?? []
|
||||||
|
const teams: TeamRow[] = (data?.teams ?? []).filter((t: TeamRow) => t.stats && t.stats.appearances > 0).sort((a: TeamRow, b: TeamRow) => (b.stats?.appearances ?? 0) - (a.stats?.appearances ?? 0))
|
||||||
|
const minuteBuckets: MinuteBucket[] = data?.goalsByMinute ?? []
|
||||||
|
const confStats: ConfStat[] = data?.confederationStats ?? []
|
||||||
|
const hatTricks: HatTrick[] = data?.hatTricks ?? []
|
||||||
|
const biggestWins: MatchRow[] = data?.biggestWins ?? []
|
||||||
|
const highScoring: MatchRow[] = data?.highestScoringMatches ?? []
|
||||||
|
const etStats: ETStats | null = data?.extraTimeStats ?? null
|
||||||
|
|
||||||
|
const titlesByNation = teams
|
||||||
|
.filter(t => (t.stats?.titles ?? 0) > 0)
|
||||||
|
.sort((a, b) => (b.stats?.titles ?? 0) - (a.stats?.titles ?? 0))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
const maxGoals = Math.max(...tournaments.map(t => t.totalGoals ?? 0), 1)
|
||||||
|
const maxScorer = Math.max(...scorers.map(s => s.goals), 1)
|
||||||
|
const maxMinute = Math.max(...minuteBuckets.map(b => b.count), 1)
|
||||||
|
|
||||||
|
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-10">Historical Statistics</h1>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="text-[#2a5c35] text-sm py-16 text-center">Loading statistics…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Goals per tournament bar chart ── */}
|
||||||
|
{tournaments.length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<SectionTitle>⚽ Goals Scored per Tournament</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<div className="p-7 pb-0">
|
||||||
|
<div className="flex items-end gap-[3px] h-[170px]">
|
||||||
|
{tournaments.map(t => {
|
||||||
|
const h = Math.max(4, Math.round(((t.totalGoals ?? 0) / maxGoals) * 140))
|
||||||
|
const avg = t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(1) : null
|
||||||
|
return (
|
||||||
|
<Link key={t.year} href={`/tournaments/${t.year}`} className="flex flex-col items-center flex-1 min-w-[16px] group">
|
||||||
|
<div className="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)]"
|
||||||
|
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` : ''}`}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[3px] pt-1.5 pb-3.5 border-t mt-0" style={{ borderColor: 'rgba(34,197,94,0.06)' }}>
|
||||||
|
{tournaments.map(t => (
|
||||||
|
<div key={t.year} className="flex-1 text-center text-[6px] text-[#1a3a22]" style={{ transform: 'rotate(-45deg)', transformOrigin: 'center top' }}>
|
||||||
|
{t.year}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||||
|
{/* ── All-time top scorers ── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>🏅 All-Time Top Scorers</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
{scorers.map((s, i) => (
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
|
||||||
|
<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-[10px] text-[#2a5c35]">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="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>
|
||||||
|
<span className="font-['Bebas_Neue'] text-[22px] text-[#22c55e] min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── World Cup titles ── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>🏆 World Cup Titles by Nation</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
{titlesByNation.map((t, i) => (
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
<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 gap-0.5 flex-shrink-0">
|
||||||
|
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
|
||||||
|
<span key={j} className="text-sm">🏆</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="font-['Bebas_Neue'] text-[28px] text-[#22c55e] flex-shrink-0">{t.stats?.titles}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Goals by minute heatmap ── */}
|
||||||
|
{minuteBuckets.length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<SectionTitle>⏱ Goals by Minute (All-Time)</SectionTitle>
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-end gap-3 h-24">
|
||||||
|
{minuteBuckets.map(b => {
|
||||||
|
const h = Math.max(8, Math.round((b.count / maxMinute) * 80))
|
||||||
|
return (
|
||||||
|
<div key={b.bucket} className="flex-1 flex flex-col items-center gap-1.5">
|
||||||
|
<span className="text-[9px] text-[#2a5c35] font-bold">{b.count}</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)' }} />
|
||||||
|
<span className="text-[9px] text-[#1a3a22]">{b.bucket}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||||
|
{/* ── Biggest wins ── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>💥 Biggest Victories</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
{biggestWins.map(m => (
|
||||||
|
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||||||
|
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
|
||||||
|
{m.scoreFt?.[0]}–{m.scoreFt?.[1]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[#2a5c35] flex-shrink-0">+{m.margin}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Highest scoring matches ── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>🔥 Highest Scoring Matches</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
{highScoring.map(m => (
|
||||||
|
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ borderColor: 'rgba(34,197,94,0.05)' }}>
|
||||||
|
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-[#dff5e8] truncate">{m.team1.name} vs {m.team2.name}</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35]">{m.year} · {m.round}</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">
|
||||||
|
{m.scoreFt?.[0]}–{m.scoreFt?.[1]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[#4ade80] flex-shrink-0">{m.totalGoals} goals</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Hat-tricks ── */}
|
||||||
|
{hatTricks.length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<SectionTitle>🎩 Hat-Tricks</SectionTitle>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
|
||||||
|
{hatTricks.map((h, i) => (
|
||||||
|
<div key={i} className="rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{h.team && <TeamFlag name={h.team.name} iso2={h.team.iso2} size="sm" />}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[#dff5e8]">{h.playerName}</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35]">{h.team?.name}</div>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-[#22c55e]">{h.goals}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35]">
|
||||||
|
{h.year} · {h.round}
|
||||||
|
{h.opponent && <span> vs {h.opponent.name}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── ET & Penalty stats ── */}
|
||||||
|
{etStats && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<SectionTitle>⚡ Extra Time & Penalty Shootouts</SectionTitle>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Knockout Matches', value: etStats.totalKnockoutMatches },
|
||||||
|
{ label: 'Went to AET', value: `${etStats.wentToExtraTime} (${etStats.extraTimePct}%)` },
|
||||||
|
{ label: 'Decided by PSO', value: `${etStats.wentToPenalties} (${etStats.penaltiesPct}%)` },
|
||||||
|
{ label: 'Decided in 90min', value: etStats.totalKnockoutMatches - etStats.wentToExtraTime },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.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-2">{s.label}</div>
|
||||||
|
<div className="font-['Bebas_Neue'] text-2xl text-[#22c55e]">{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Confederation stats ── */}
|
||||||
|
{confStats.length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<SectionTitle>🌍 Performance by Confederation</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
|
||||||
|
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px' }}>
|
||||||
|
<span>Confederation</span><span className="text-center">Appearances</span><span className="text-center">Titles</span><span className="text-center">Goals</span>
|
||||||
|
</div>
|
||||||
|
{confStats.map(c => (
|
||||||
|
<div key={c.confederation} className="grid px-4 py-3 border-t items-center"
|
||||||
|
style={{ gridTemplateColumns: '1fr 80px 60px 80px', gap: '8px', borderColor: 'rgba(34,197,94,0.06)' }}>
|
||||||
|
<span className="text-sm font-medium text-[#dff5e8]">{c.confederation}</span>
|
||||||
|
<span className="text-center text-sm text-[#6abf7a]">{c.appearances}</span>
|
||||||
|
<span className="text-center font-['Bebas_Neue'] text-xl text-[#22c55e]">{c.titles}</span>
|
||||||
|
<span className="text-center text-sm text-[#6abf7a]">{c.totalGoals}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── All-time team table ── */}
|
||||||
|
{teams.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>📊 All-Time Team Table</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<div className="grid px-4 py-2 text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase"
|
||||||
|
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px' }}>
|
||||||
|
<span>#</span><span>Team</span><span className="text-center">WC</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>
|
||||||
|
<span className="text-center">Win%</span>
|
||||||
|
</div>
|
||||||
|
{teams.slice(0, 40).map((t, i) => (
|
||||||
|
<Link key={t.id} href={`/teams/${t.slug}`}>
|
||||||
|
<div className="grid px-4 py-2.5 border-t items-center hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ gridTemplateColumns: '28px 1fr 50px 44px 44px 44px 60px 60px 60px 52px', gap: '4px', borderColor: 'rgba(34,197,94,0.05)' }}>
|
||||||
|
<span className="text-[11px] text-[#2a5c35] font-bold">{i + 1}</span>
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
|
||||||
|
<span className="text-sm text-[#dff5e8] truncate">{t.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.appearances}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.wins}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.draws}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.losses}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsFor}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{t.stats?.goalsAgainst}</span>
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">
|
||||||
|
{(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0) >= 0
|
||||||
|
? `+${(t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}`
|
||||||
|
: (t.stats?.goalsFor ?? 0) - (t.stats?.goalsAgainst ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-center text-[13px] font-bold text-[#22c55e]">{t.stats?.winPct}%</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'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 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($teamName: String!) {
|
||||||
|
topScorers(limit: 100) {
|
||||||
|
playerName goals penalties ownGoals tournaments
|
||||||
|
team { name iso2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamPage({ 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
|
||||||
|
|
||||||
|
// Load all scorers to filter by team
|
||||||
|
const { data: scorerData } = useQuery(gql`
|
||||||
|
query TeamScorers {
|
||||||
|
topScorers(limit: 200) {
|
||||||
|
playerName goals penalties ownGoals tournaments
|
||||||
|
team { id name iso2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const allScorers = scorerData?.topScorers ?? []
|
||||||
|
const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : []
|
||||||
|
|
||||||
|
if (loading && !teamData) {
|
||||||
|
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading team…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">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 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">
|
||||||
|
<TeamFlag name={team.name} iso2={team.iso2} size="xl" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-['Bebas_Neue'] text-[56px] text-[#22c55e] leading-none">{team.name}</h1>
|
||||||
|
<div className="flex gap-3 mt-2 flex-wrap">
|
||||||
|
{team.fifaCode && <span className="text-[11px] text-[#2a5c35] font-bold tracking-wider">{team.fifaCode}</span>}
|
||||||
|
{team.confederation && <span className="text-[11px] text-[#2a5c35]">{team.confederation}</span>}
|
||||||
|
{team.continent && <span className="text-[11px] text-[#2a5c35]">{team.continent}</span>}
|
||||||
|
{(s?.titles ?? 0) > 0 && (
|
||||||
|
<span className="text-[11px] text-[#22c55e] font-bold">
|
||||||
|
{Array.from({ length: s?.titles ?? 0 }).map(() => '🏆').join('')} {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-[#2a5c35] 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="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>
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.12)' }}>
|
||||||
|
<div className="grid px-4 py-2.5 text-[9px] text-[#2a5c35] 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 items-center"
|
||||||
|
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px', borderColor: 'rgba(34,197,94,0.06)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
|
||||||
|
<span className="text-sm text-[#dff5e8]">{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-[#4a7a55]">{v}</span>
|
||||||
|
))}
|
||||||
|
<span className="text-center text-sm text-[#4a7a55]">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: top scorers */}
|
||||||
|
<div>
|
||||||
|
{teamScorers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[11px] text-[#2a5c35] font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
|
||||||
|
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||||
|
{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-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
|
||||||
|
<span className="text-[10px] text-[#2a5c35] 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-[#dff5e8] truncate">{sc.playerName}</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35]">
|
||||||
|
{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" style={{ background: 'rgba(34,197,94,0.1)' }}>
|
||||||
|
<div className="h-full rounded-full bg-[#22c55e]" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{sc.goals}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
'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'
|
||||||
|
import { LiveBadge } from '@/components/live-badge'
|
||||||
|
|
||||||
|
const TOURNAMENT_QUERY = gql`
|
||||||
|
query Tournament($year: Int!) {
|
||||||
|
tournament(year: $year) {
|
||||||
|
year host winner runnerUp thirdPlace fourthPlace
|
||||||
|
totalGoals matchesCount teamsCount avgGoalsPerGame
|
||||||
|
topScorers(limit: 10) {
|
||||||
|
playerName goals penalties ownGoals
|
||||||
|
team { name iso2 slug }
|
||||||
|
}
|
||||||
|
matches {
|
||||||
|
id year round group date time isLive isQualiPlayoff
|
||||||
|
scoreFt scoreHt scoreEt scoreP
|
||||||
|
team1 { id name iso2 slug } team2 { id name iso2 slug }
|
||||||
|
goals { playerName minute minuteOffset isPenalty isOwnGoal team { id } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupStandings(year: $year) {
|
||||||
|
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts
|
||||||
|
team { id name iso2 slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface MatchData {
|
||||||
|
id: number; year: number; round: string; group?: string | null
|
||||||
|
date?: string | null; time?: string | null; isLive: boolean; isQualiPlayoff: boolean
|
||||||
|
scoreFt?: number[] | null; scoreHt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
|
||||||
|
team1: { id: number; name: string; iso2?: string | null; slug: string }
|
||||||
|
team2: { id: number; name: string; iso2?: string | null; slug: string }
|
||||||
|
goals: Array<{ playerName: string; minute?: number | null; minuteOffset?: number | null; isPenalty: boolean; isOwnGoal: boolean; team: { id: number } }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Standing {
|
||||||
|
groupName: string; pos?: number | null
|
||||||
|
played: number; won: number; drawn: number; lost: number
|
||||||
|
goalsFor: number; goalsAgainst: number; goalDiff: number; pts: number
|
||||||
|
team: { id: number; name: string; iso2?: string | null; slug: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoalList({ match }: { match: MatchData }) {
|
||||||
|
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 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]) =>
|
||||||
|
`${g.playerName} ${g.minute ?? ''}${g.minuteOffset ? `+${g.minuteOffset}` : ''}'${g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}`
|
||||||
|
return (
|
||||||
|
<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-right">{t2Goals.map(renderGoal).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TournamentPage({ params }: { params: Promise<{ year: string }> }) {
|
||||||
|
const { year: yearStr } = use(params)
|
||||||
|
const year = parseInt(yearStr)
|
||||||
|
const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 })
|
||||||
|
|
||||||
|
const t = data?.tournament
|
||||||
|
const standings: Standing[] = data?.groupStandings ?? []
|
||||||
|
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
|
||||||
|
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const allMatches: MatchData[] = t?.matches ?? []
|
||||||
|
const byRound = allMatches.reduce<Record<string, MatchData[]>>((acc, m) => {
|
||||||
|
const key = m.group ?? m.round
|
||||||
|
acc[key] = [...(acc[key] ?? []), m]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const groupRounds = Object.entries(byGroup).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
const koRounds = allMatches.filter(m => !m.group && !m.isQualiPlayoff)
|
||||||
|
const koByRound = koRounds.reduce<Record<string, MatchData[]>>((acc, m) => {
|
||||||
|
acc[m.round] = [...(acc[m.round] ?? []), m]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const liveMatches = allMatches.filter(m => m.isLive)
|
||||||
|
const maxScorer = Math.max(...(t?.topScorers?.map((s: { goals: number }) => s.goals) ?? [1]), 1)
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1200px] mx-auto px-7 py-10">
|
||||||
|
<div className="h-24 w-48 rounded-xl animate-pulse mb-6" style={{ background: '#0a1810' }} />
|
||||||
|
<div className="text-[#2a5c35] text-sm">Loading {year} World Cup…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Tournament {year} not found.</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="pitch-grid rounded-2xl p-8 mb-8" style={{
|
||||||
|
background: 'linear-gradient(145deg,#0a1a0e 0%,#0d2416 100%)',
|
||||||
|
border: '1px solid rgba(34,197,94,0.2)',
|
||||||
|
}}>
|
||||||
|
{liveMatches.length > 0 && <div className="mb-3"><LiveBadge label="Live Now" /></div>}
|
||||||
|
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-['Bebas_Neue'] text-[64px] text-[#22c55e] leading-none">{year}</h1>
|
||||||
|
<p className="text-[#6abf7a] text-lg mt-1">{t.host}</p>
|
||||||
|
</div>
|
||||||
|
{t.winner && (
|
||||||
|
<div className="text-center">
|
||||||
|
<TeamFlag name={t.winner} size="xl" className="mb-2" />
|
||||||
|
<div className="font-['Bebas_Neue'] text-2xl text-[#dff5e8]">{t.winner}</div>
|
||||||
|
{t.runnerUp && <div className="text-xs text-[#2a5c35] mt-1">def. {t.runnerUp}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 mt-4 flex-wrap">
|
||||||
|
{[
|
||||||
|
{ label: 'Teams', value: t.teamsCount },
|
||||||
|
{ label: 'Matches', value: t.matchesCount },
|
||||||
|
{ label: 'Goals', value: t.totalGoals },
|
||||||
|
{ label: 'Goals/Game', value: t.avgGoalsPerGame ? Number(t.avgGoalsPerGame).toFixed(2) : null },
|
||||||
|
].filter(s => s.value != null).map(s => (
|
||||||
|
<div key={s.label}>
|
||||||
|
<div className="text-[9px] text-[#2a5c35] tracking-[0.12em] uppercase">{s.label}</div>
|
||||||
|
<div className="font-['Bebas_Neue'] text-3xl text-[#22c55e]">{s.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8">
|
||||||
|
<div>
|
||||||
|
{/* Live matches first */}
|
||||||
|
{liveMatches.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="font-['Bebas_Neue'] text-2xl text-[#4ade80] mb-4">LIVE</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{liveMatches.map(m => (
|
||||||
|
<div key={m.id} id={`match-${m.id}`}>
|
||||||
|
<MatchCard match={m} />
|
||||||
|
<GoalList match={m} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group stage */}
|
||||||
|
{groupRounds.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="font-['Bebas_Neue'] text-2xl text-[#22c55e] mb-5">Group Stage</h2>
|
||||||
|
{groupRounds.map(([groupName, rows]) => {
|
||||||
|
const sorted = [...rows].sort((a, b) => b.pts - a.pts || b.goalDiff - a.goalDiff)
|
||||||
|
const groupMatches = (byRound[groupName] ?? []).sort((a, b) => (a.date ?? '') < (b.date ?? '') ? -1 : 1)
|
||||||
|
return (
|
||||||
|
<div key={groupName} className="mb-8">
|
||||||
|
<h3 className="text-[13px] font-bold text-[#22c55e] tracking-wide uppercase mb-3">{groupName}</h3>
|
||||||
|
{/* Standings mini */}
|
||||||
|
<div className="rounded-xl overflow-hidden mb-3" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
|
||||||
|
{sorted.map((s, i) => (
|
||||||
|
<Link key={s.team.id} href={`/teams/${s.team.slug}`}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ borderColor: 'rgba(34,197,94,0.05)', background: i < 2 ? 'rgba(34,197,94,0.02)' : undefined }}>
|
||||||
|
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
|
||||||
|
<span className="flex-1 text-[13px] text-[#6abf7a] truncate">{s.team.name}</span>
|
||||||
|
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.played}</span>
|
||||||
|
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.won}</span>
|
||||||
|
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.drawn}</span>
|
||||||
|
<span className="text-[11px] text-[#4a7a55] w-6 text-center">{s.lost}</span>
|
||||||
|
<span className="text-[11px] font-bold text-[#22c55e] w-6 text-center">{s.pts}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Group matches */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{groupMatches.map(m => (
|
||||||
|
<div key={m.id} id={`match-${m.id}`}>
|
||||||
|
<MatchCard match={m} compact />
|
||||||
|
<GoalList match={m} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Knockout rounds */}
|
||||||
|
{Object.keys(koByRound).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="font-['Bebas_Neue'] text-2xl text-[#22c55e] mb-5">Knockout Stage</h2>
|
||||||
|
{Object.entries(koByRound).map(([round, roundMatches]) => (
|
||||||
|
<div key={round} className="mb-6">
|
||||||
|
<h3 className="text-[13px] font-bold text-[#22c55e] tracking-wide uppercase mb-3">{round}</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{roundMatches.map(m => (
|
||||||
|
<div key={m.id} id={`match-${m.id}`}>
|
||||||
|
<MatchCard match={m} compact />
|
||||||
|
<GoalList match={m} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: top scorers */}
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-[76px]">
|
||||||
|
<h2 className="font-['Bebas_Neue'] text-xl text-[#22c55e] mb-4">TOP SCORERS</h2>
|
||||||
|
<div className="rounded-2xl overflow-hidden" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.15)' }}>
|
||||||
|
{t.topScorers?.map((s: { playerName: string; goals: number; penalties: number; team?: { name: string; iso2?: string | null; slug: string } | null }, i: number) => (
|
||||||
|
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
|
||||||
|
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b hover:bg-[rgba(34,197,94,0.03)] cursor-pointer"
|
||||||
|
style={{ borderColor: 'rgba(34,197,94,0.06)', background: i === 0 ? 'rgba(34,197,94,0.04)' : undefined }}>
|
||||||
|
<span className="text-[10px] text-[#2a5c35] w-4 text-right font-bold flex-shrink-0">{i + 1}</span>
|
||||||
|
{s.team && <TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] font-semibold text-[#dff5e8] truncate">{s.playerName}</div>
|
||||||
|
{s.penalties > 0 && <div className="text-[9px] text-[#2a5c35]">{s.penalties} pen</div>}
|
||||||
|
</div>
|
||||||
|
<div className="w-12 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>
|
||||||
|
<span className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0">{s.goals}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.thirdPlace && (
|
||||||
|
<div className="mt-4 rounded-xl p-4" style={{ background: '#0a1810', border: '1px solid rgba(34,197,94,0.1)' }}>
|
||||||
|
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2">3rd Place</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TeamFlag name={t.thirdPlace} size="sm" />
|
||||||
|
<span className="text-sm text-[#6abf7a]">{t.thirdPlace}</span>
|
||||||
|
</div>
|
||||||
|
{t.fourthPlace && (
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<TeamFlag name={t.fourthPlace} size="sm" />
|
||||||
|
<span className="text-sm text-[#4a7a55]">{t.fourthPlace}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
import { ApolloProvider } from '@apollo/client/react'
|
||||||
|
import { getApolloClient } from '@/lib/graphql/client'
|
||||||
|
|
||||||
|
export function AppApolloProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return <ApolloProvider client={getApolloClient()}>{children}</ApolloProvider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function LiveBadge({ label = 'Live' }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#4ade80] flex-shrink-0 animate-live" />
|
||||||
|
<span className="text-[11px] font-bold text-[#4ade80] tracking-[0.14em] uppercase">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { TeamFlag } from './team-flag'
|
||||||
|
import { LiveBadge } from './live-badge'
|
||||||
|
|
||||||
|
interface Team { name: string; iso2?: string | null }
|
||||||
|
interface Match {
|
||||||
|
id: number
|
||||||
|
year: number
|
||||||
|
round: string
|
||||||
|
group?: string | null
|
||||||
|
date?: string | null
|
||||||
|
time?: string | null
|
||||||
|
team1: Team
|
||||||
|
team2: Team
|
||||||
|
scoreFt?: number[] | null
|
||||||
|
scoreEt?: number[] | null
|
||||||
|
scoreP?: number[] | null
|
||||||
|
isLive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) {
|
||||||
|
const ft = match.scoreFt
|
||||||
|
const hasScore = ft != null
|
||||||
|
const winner = ft ? (ft[0] > ft[1] ? 'home' : ft[0] < ft[1] ? 'away' : 'draw') : null
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block">
|
||||||
|
<div className="bg-[#0a1810] border border-[rgba(34,197,94,0.08)] rounded-xl p-3.5 hover:border-[rgba(34,197,94,0.22)] transition-colors">
|
||||||
|
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2.5">
|
||||||
|
{match.round}{match.group ? ` · ${match.group}` : ''} · {match.date ? formatDate(match.date) : ''}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 flex items-center gap-2 overflow-hidden">
|
||||||
|
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
|
||||||
|
<span className={`text-sm font-medium truncate ${winner === 'home' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}>
|
||||||
|
{match.team1.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0 min-w-[44px] text-center">
|
||||||
|
{hasScore ? `${ft![0]} – ${ft![1]}` : match.isLive ? <LiveBadge label="•" /> : '–'}
|
||||||
|
</div>
|
||||||
|
<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]'}`}>
|
||||||
|
{match.team2.name}
|
||||||
|
</span>
|
||||||
|
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{match.scoreEt && <div className="text-[9px] text-[#2a5c35] mt-1 text-center">AET · {match.scoreP ? `PSO ${match.scoreP[0]}-${match.scoreP[1]}` : ''}</div>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 p-9 hover:border-[rgba(34,197,94,0.45)] transition-colors">
|
||||||
|
{match.isLive && <div className="mb-4"><LiveBadge label="Live Now" /></div>}
|
||||||
|
<div className="flex items-center justify-center gap-8 flex-wrap">
|
||||||
|
<div className="text-center flex-1 min-w-[100px]">
|
||||||
|
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2.5" />
|
||||||
|
<div className="font-['Bebas_Neue'] text-xl tracking-[0.07em] text-[#dff5e8]">{match.team1.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center flex-shrink-0">
|
||||||
|
<div className="font-['Bebas_Neue'] text-[76px] text-[#22c55e] leading-none">
|
||||||
|
{hasScore ? `${ft![0]} – ${ft![1]}` : '? – ?'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-[#2a5c35] tracking-[0.12em] uppercase mt-1.5">{match.round}</div>
|
||||||
|
<div className="text-xs text-[#1a3a22] mt-1">{match.date ? formatDate(match.date) : ''}</div>
|
||||||
|
{match.scoreEt && <div className="text-[10px] text-[#2a5c35] mt-1">AET {match.scoreEt[0]}–{match.scoreEt[1]}</div>}
|
||||||
|
{match.scoreP && <div className="text-[10px] text-[#4ade80] mt-0.5">PSO {match.scoreP[0]}–{match.scoreP[1]}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-center flex-1 min-w-[100px]">
|
||||||
|
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="xl" className="mb-2.5" />
|
||||||
|
<div className="font-['Bebas_Neue'] text-xl tracking-[0.07em] text-[#dff5e8]">{match.team2.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const WC_BALL = (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="50" cy="50" r="46" fill="#0a1810" />
|
||||||
|
<polygon points="54,38 63,50 54,62 39,57 40,42" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinejoin="round" />
|
||||||
|
<line x1="54" y1="38" x2="65" y2="9" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
|
||||||
|
<line x1="63" y1="50" x2="94" y2="51" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
|
||||||
|
<line x1="54" y1="62" x2="62" y2="92" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
|
||||||
|
<line x1="39" y1="57" x2="14" y2="75" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
|
||||||
|
<line x1="40" y1="42" x2="15" y2="23" stroke="#22c55e" strokeWidth="2.5" opacity=".9" />
|
||||||
|
<path d="M65,9 Q86,26 94,51" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
|
||||||
|
<path d="M94,51 Q84,76 62,92" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
|
||||||
|
<path d="M62,92 Q35,91 14,75" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
|
||||||
|
<path d="M14,75 Q7,49 15,23" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
|
||||||
|
<path d="M15,23 Q38,8 65,9" stroke="#22c55e" strokeWidth="2" fill="none" opacity=".5" />
|
||||||
|
<circle cx="50" cy="50" r="46" fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/groups', label: 'Groups' },
|
||||||
|
{ href: '/stats', label: 'Statistics' },
|
||||||
|
{ href: '/history', label: 'History' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 h-[60px] flex items-center px-5 gap-0"
|
||||||
|
style={{ background: 'rgba(4,13,8,0.97)', backdropFilter: 'blur(18px)', borderBottom: '1px solid rgba(34,197,94,0.18)' }}>
|
||||||
|
<Link href="/" className="flex items-center gap-2.5 mr-5 flex-shrink-0 cursor-pointer select-none">
|
||||||
|
{WC_BALL}
|
||||||
|
<span className="font-['Bebas_Neue'] text-lg tracking-[3px] text-[#22c55e] whitespace-nowrap">WORLD CUP</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex gap-0.5 flex-1 min-w-0">
|
||||||
|
{NAV_LINKS.map(({ href, label }) => {
|
||||||
|
const active = href === '/' ? pathname === '/' : pathname.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link key={href} href={href}
|
||||||
|
className={`px-3.5 py-1.5 rounded-lg text-[13px] font-medium whitespace-nowrap flex-shrink-0 transition-colors
|
||||||
|
${active ? 'bg-[rgba(34,197,94,0.12)] text-[#22c55e]' : 'text-[#4a7a55] hover:text-[#6abf7a]'}`}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} className="relative flex-shrink-0 ml-2">
|
||||||
|
<input
|
||||||
|
type="text" value={q} onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder="Search…"
|
||||||
|
className="w-44 pl-8 pr-3.5 py-1.5 rounded-[20px] text-[13px] text-[#dff5e8] outline-none"
|
||||||
|
style={{ background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.18)' }}
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-30 pointer-events-none" width="14" height="14" 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>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getIso } from '@/lib/iso-codes'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
iso2?: string | null
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl', xl: 'text-[60px]' }
|
||||||
|
|
||||||
|
export function TeamFlag({ name, iso2, size = 'md', className = '' }: Props) {
|
||||||
|
const code = iso2 ?? getIso(name)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`fi fi-${code} rounded-sm inline-block flex-shrink-0 ${sizes[size]} ${className}`}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: worldcup
|
||||||
|
POSTGRES_USER: wc
|
||||||
|
POSTGRES_PASSWORD: wc
|
||||||
|
volumes:
|
||||||
|
- pgdata_dev:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata_dev:
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://wc:${DB_PASSWORD}@db:5432/worldcup
|
||||||
|
NODE_ENV: production
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=${TRAEFIK_ENABLED:-false}"
|
||||||
|
- "traefik.http.middlewares.worldcup-redirect-web-secure.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.routers.worldcup-web.middlewares=worldcup-redirect-web-secure"
|
||||||
|
- "traefik.http.routers.worldcup-web.rule=Host(`${TRAEFIK_HOST}`)"
|
||||||
|
- "traefik.http.routers.worldcup-web.entrypoints=web"
|
||||||
|
- "traefik.http.routers.worldcup-web-secure.rule=Host(`${TRAEFIK_HOST}`)"
|
||||||
|
- "traefik.http.routers.worldcup-web-secure.tls.certresolver=resolver"
|
||||||
|
- "traefik.http.routers.worldcup-web-secure.entrypoints=web-secure"
|
||||||
|
- "traefik.http.routers.worldcup-web-secure.middlewares=security-headers@file,no-index@file"
|
||||||
|
- "traefik.http.services.worldcup-web-secure.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=${NETWORK_NAME}"
|
||||||
|
networks:
|
||||||
|
- compose_network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: worldcup
|
||||||
|
POSTGRES_USER: wc
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U wc -d worldcup"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- compose_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
compose_network:
|
||||||
|
name: ${NETWORK_NAME}
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './lib/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup',
|
||||||
|
},
|
||||||
|
} satisfies Config
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
|
import postgres from 'postgres'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
|
||||||
|
|
||||||
|
const client = postgres(connectionString, { max: 10 })
|
||||||
|
export const db = drizzle(client, { schema })
|
||||||
|
|
||||||
|
export * from './schema'
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { pgTable, serial, integer, text, boolean, numeric, date, primaryKey } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
export const tournaments = pgTable('tournaments', {
|
||||||
|
year: integer('year').primaryKey(),
|
||||||
|
host: text('host').notNull(),
|
||||||
|
winner: text('winner'),
|
||||||
|
runnerUp: text('runner_up'),
|
||||||
|
thirdPlace: text('third_place'),
|
||||||
|
fourthPlace: text('fourth_place'),
|
||||||
|
teamsCount: integer('teams_count'),
|
||||||
|
matchesCount: integer('matches_count'),
|
||||||
|
totalGoals: integer('total_goals'),
|
||||||
|
avgGoalsPerGame: numeric('avg_goals_per_game', { precision: 4, scale: 2 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teams = pgTable('teams', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
name: text('name').unique().notNull(),
|
||||||
|
iso2: text('iso2'),
|
||||||
|
fifaCode: text('fifa_code'),
|
||||||
|
continent: text('continent'),
|
||||||
|
confederation: text('confederation'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const stadiums = pgTable('stadiums', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
tournamentYear: integer('tournament_year'),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
city: text('city'),
|
||||||
|
countryCode: text('country_code'),
|
||||||
|
capacity: integer('capacity'),
|
||||||
|
timezone: text('timezone'),
|
||||||
|
coordinates: text('coordinates'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const matches = pgTable('matches', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
tournamentYear: integer('tournament_year').notNull(),
|
||||||
|
round: text('round').notNull(),
|
||||||
|
groupName: text('group_name'),
|
||||||
|
date: date('date'),
|
||||||
|
timeLocal: text('time_local'),
|
||||||
|
stadiumId: integer('stadium_id'),
|
||||||
|
team1Id: integer('team1_id').notNull(),
|
||||||
|
team2Id: integer('team2_id').notNull(),
|
||||||
|
scoreFtHome: integer('score_ft_home'),
|
||||||
|
scoreFtAway: integer('score_ft_away'),
|
||||||
|
scoreHtHome: integer('score_ht_home'),
|
||||||
|
scoreHtAway: integer('score_ht_away'),
|
||||||
|
scoreEtHome: integer('score_et_home'),
|
||||||
|
scoreEtAway: integer('score_et_away'),
|
||||||
|
scorePHome: integer('score_p_home'),
|
||||||
|
scorePAway: integer('score_p_away'),
|
||||||
|
isQualiPlayoff: boolean('is_quali_playoff').default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const goals = pgTable('goals', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
matchId: integer('match_id').notNull(),
|
||||||
|
teamId: integer('team_id').notNull(),
|
||||||
|
playerName: text('player_name').notNull(),
|
||||||
|
minute: integer('minute'),
|
||||||
|
minuteOffset: integer('minute_offset').default(0),
|
||||||
|
isPenalty: boolean('is_penalty').default(false),
|
||||||
|
isOwnGoal: boolean('is_own_goal').default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const groupStandings = pgTable('group_standings', {
|
||||||
|
tournamentYear: integer('tournament_year').notNull(),
|
||||||
|
groupName: text('group_name').notNull(),
|
||||||
|
teamId: integer('team_id').notNull(),
|
||||||
|
pos: integer('pos'),
|
||||||
|
played: integer('played').default(0),
|
||||||
|
won: integer('won').default(0),
|
||||||
|
drawn: integer('drawn').default(0),
|
||||||
|
lost: integer('lost').default(0),
|
||||||
|
goalsFor: integer('goals_for').default(0),
|
||||||
|
goalsAgainst: integer('goals_against').default(0),
|
||||||
|
goalDiff: integer('goal_diff').default(0),
|
||||||
|
pts: integer('pts').default(0),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.tournamentYear, t.groupName, t.teamId] })])
|
||||||
|
|
||||||
|
export const squads = pgTable('squads', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
tournamentYear: integer('tournament_year').notNull(),
|
||||||
|
teamId: integer('team_id').notNull(),
|
||||||
|
playerName: text('player_name').notNull(),
|
||||||
|
shirtNumber: integer('shirt_number'),
|
||||||
|
position: text('position'),
|
||||||
|
dateOfBirth: date('date_of_birth'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let client: InstanceType<typeof ApolloClient> | null = null
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
return new ApolloClient({
|
||||||
|
link: new HttpLink({ uri: '/api/graphql' }),
|
||||||
|
cache: new InMemoryCache(),
|
||||||
|
defaultOptions: { watchQuery: { fetchPolicy: 'cache-and-network' } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApolloClient() {
|
||||||
|
if (typeof window === 'undefined') return createClient()
|
||||||
|
if (!client) client = createClient()
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
/* Apollo Client v4 defaults TData to unknown — wrap to restore convenient any typing */
|
||||||
|
import { useQuery as _useQuery } from '@apollo/client/react'
|
||||||
|
import type { DocumentNode } from '@apollo/client/core'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function useQuery(query: DocumentNode, options?: Record<string, unknown>) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return _useQuery<Record<string, any>>(query, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { gql } from '@apollo/client/core'
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { tournaments, teams, matches, goals, groupStandings, stadiums, squads } from '@/lib/db/schema'
|
||||||
|
import { slugify, getIso } from '@/lib/iso-codes'
|
||||||
|
import { eq, and, desc, asc, sql, ilike, or, isNotNull, lt, gt, gte } from 'drizzle-orm'
|
||||||
|
|
||||||
|
function teamWithSlug(t: typeof teams.$inferSelect) {
|
||||||
|
return { ...t, slug: slugify(t.name), iso2: t.iso2 ?? getIso(t.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamById(id: number) {
|
||||||
|
const rows = await db.select().from(teams).where(eq(teams.id, id)).limit(1)
|
||||||
|
return rows[0] ? teamWithSlug(rows[0]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLive(dateStr: string | null, timeStr: string | null): boolean {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const now = new Date()
|
||||||
|
const today = now.toISOString().slice(0, 10)
|
||||||
|
if (dateStr !== today) return false
|
||||||
|
if (!timeStr) return true
|
||||||
|
// parse time like "20:00 CET" or "13:00 UTC-6"
|
||||||
|
const timePart = timeStr.split(' ')[0]
|
||||||
|
const [h, m] = timePart.split(':').map(Number)
|
||||||
|
if (isNaN(h)) return true
|
||||||
|
const kickoff = new Date(now)
|
||||||
|
kickoff.setHours(h, m ?? 0, 0, 0)
|
||||||
|
const diffMin = (now.getTime() - kickoff.getTime()) / 60000
|
||||||
|
return diffMin >= -5 && diffMin <= 125
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateMatch(row: typeof matches.$inferSelect) {
|
||||||
|
const [t1, t2] = await Promise.all([getTeamById(row.team1Id), getTeamById(row.team2Id)])
|
||||||
|
const matchGoals = await db.select().from(goals).where(eq(goals.matchId, row.id)).orderBy(asc(goals.minute))
|
||||||
|
const goalsHydrated = await Promise.all(matchGoals.map(async g => ({
|
||||||
|
...g,
|
||||||
|
team: (await getTeamById(g.teamId)) ?? { id: g.teamId, name: '', slug: '', iso2: null, fifaCode: null, continent: null, confederation: null },
|
||||||
|
isPenalty: g.isPenalty ?? false,
|
||||||
|
isOwnGoal: g.isOwnGoal ?? false,
|
||||||
|
minuteOffset: g.minuteOffset ?? 0,
|
||||||
|
})))
|
||||||
|
const ft = row.scoreFtHome !== null ? [row.scoreFtHome, row.scoreFtAway!] : null
|
||||||
|
const ht = row.scoreHtHome !== null ? [row.scoreHtHome, row.scoreHtAway!] : null
|
||||||
|
const et = row.scoreEtHome !== null ? [row.scoreEtHome, row.scoreEtAway!] : null
|
||||||
|
const p = row.scorePHome !== null ? [row.scorePHome, row.scorePAway!] : null
|
||||||
|
const margin = ft ? Math.abs(ft[0] - ft[1]) : null
|
||||||
|
const totalGoalsCount = ft ? ft[0] + ft[1] : null
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
year: row.tournamentYear,
|
||||||
|
group: row.groupName,
|
||||||
|
time: row.timeLocal,
|
||||||
|
stadium: null,
|
||||||
|
team1: t1!, team2: t2!,
|
||||||
|
scoreFt: ft, scoreHt: ht, scoreEt: et, scoreP: p,
|
||||||
|
goals: goalsHydrated,
|
||||||
|
isLive: isLive(row.date, row.timeLocal),
|
||||||
|
isQualiPlayoff: row.isQualiPlayoff ?? false,
|
||||||
|
margin, totalGoals: totalGoalsCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolvers = {
|
||||||
|
Query: {
|
||||||
|
async tournaments() {
|
||||||
|
const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year))
|
||||||
|
return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null }))
|
||||||
|
},
|
||||||
|
async tournament(_: unknown, { year }: { year: number }) {
|
||||||
|
const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
|
||||||
|
if (!rows[0]) return null
|
||||||
|
return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null }
|
||||||
|
},
|
||||||
|
async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean }) {
|
||||||
|
const conditions = []
|
||||||
|
if (args.year) conditions.push(eq(matches.tournamentYear, args.year))
|
||||||
|
if (args.group) conditions.push(eq(matches.groupName, args.group))
|
||||||
|
if (args.round) conditions.push(eq(matches.round, args.round))
|
||||||
|
if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali))
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(asc(matches.date), asc(matches.id))
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
async match(_: unknown, { id }: { id: number }) {
|
||||||
|
const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1)
|
||||||
|
return rows[0] ? hydrateMatch(rows[0]) : null
|
||||||
|
},
|
||||||
|
async liveMatches() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false)))
|
||||||
|
.orderBy(asc(matches.id))
|
||||||
|
const hydrated = await Promise.all(rows.map(hydrateMatch))
|
||||||
|
return hydrated.filter(m => m.isLive)
|
||||||
|
},
|
||||||
|
async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(
|
||||||
|
lt(matches.date, today),
|
||||||
|
isNotNull(matches.scoreFtHome),
|
||||||
|
eq(matches.isQualiPlayoff, false),
|
||||||
|
))
|
||||||
|
.orderBy(desc(matches.date), desc(matches.id))
|
||||||
|
.limit(limit)
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(
|
||||||
|
gte(matches.date, today),
|
||||||
|
sql`${matches.scoreFtHome} IS NULL`,
|
||||||
|
eq(matches.isQualiPlayoff, false),
|
||||||
|
))
|
||||||
|
.orderBy(asc(matches.date), asc(matches.id))
|
||||||
|
.limit(limit)
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
async teams() {
|
||||||
|
const rows = await db.select().from(teams).orderBy(asc(teams.name))
|
||||||
|
return rows.map(teamWithSlug)
|
||||||
|
},
|
||||||
|
async team(_: unknown, { slug }: { slug: string }) {
|
||||||
|
const rows = await db.select().from(teams)
|
||||||
|
const found = rows.find(r => slugify(r.name) === slug)
|
||||||
|
return found ? teamWithSlug(found) : null
|
||||||
|
},
|
||||||
|
async topScorers(_: unknown, { year, limit = 20 }: { year?: number; limit?: number }) {
|
||||||
|
const conditions = year
|
||||||
|
? sql`AND m.tournament_year = ${year} AND m.is_quali_playoff = false`
|
||||||
|
: sql`AND m.is_quali_playoff = false`
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
g.player_name,
|
||||||
|
g.team_id,
|
||||||
|
COUNT(*)::int AS goals,
|
||||||
|
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
|
||||||
|
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
|
||||||
|
COUNT(DISTINCT m.tournament_year)::int AS tournaments
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE true ${conditions}
|
||||||
|
GROUP BY g.player_name, g.team_id
|
||||||
|
ORDER BY goals DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`)
|
||||||
|
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
|
||||||
|
playerName: r.player_name,
|
||||||
|
goals: r.goals,
|
||||||
|
penalties: r.penalties,
|
||||||
|
ownGoals: r.own_goals,
|
||||||
|
tournaments: r.tournaments,
|
||||||
|
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||||
|
})))
|
||||||
|
},
|
||||||
|
async player(_: unknown, { name }: { name: string }) {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
g.player_name,
|
||||||
|
g.team_id,
|
||||||
|
COUNT(*)::int AS goals,
|
||||||
|
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
|
||||||
|
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
|
||||||
|
COUNT(DISTINCT m.tournament_year)::int AS tournaments
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE LOWER(g.player_name) LIKE LOWER(${`%${name}%`})
|
||||||
|
AND m.is_quali_playoff = false
|
||||||
|
GROUP BY g.player_name, g.team_id
|
||||||
|
ORDER BY goals DESC
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
if (!rows[0]) return null
|
||||||
|
const r = rows[0] as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
playerName: r.player_name,
|
||||||
|
goals: r.goals,
|
||||||
|
penalties: r.penalties,
|
||||||
|
ownGoals: r.own_goals,
|
||||||
|
tournaments: r.tournaments,
|
||||||
|
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async hatTricks(_: unknown, { year }: { year?: number }) {
|
||||||
|
const conditions = year ? sql`AND m.tournament_year = ${year}` : sql``
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
g.player_name,
|
||||||
|
g.team_id,
|
||||||
|
g.match_id,
|
||||||
|
COUNT(*)::int AS goals,
|
||||||
|
m.tournament_year AS year,
|
||||||
|
m.round,
|
||||||
|
CASE WHEN m.team1_id = g.team_id THEN m.team2_id ELSE m.team1_id END AS opponent_id
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE m.is_quali_playoff = false ${conditions}
|
||||||
|
GROUP BY g.player_name, g.team_id, g.match_id, m.tournament_year, m.round, m.team1_id, m.team2_id
|
||||||
|
HAVING COUNT(*) >= 3
|
||||||
|
ORDER BY goals DESC, m.tournament_year DESC
|
||||||
|
`)
|
||||||
|
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
|
||||||
|
playerName: r.player_name,
|
||||||
|
year: r.year,
|
||||||
|
round: r.round,
|
||||||
|
goals: r.goals,
|
||||||
|
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||||
|
opponent: r.opponent_id ? await getTeamById(r.opponent_id as number) : null,
|
||||||
|
})))
|
||||||
|
},
|
||||||
|
async groupStandings(_: unknown, { year }: { year: number }) {
|
||||||
|
const rows = await db.select({
|
||||||
|
groupName: groupStandings.groupName,
|
||||||
|
pos: groupStandings.pos,
|
||||||
|
teamId: groupStandings.teamId,
|
||||||
|
played: groupStandings.played,
|
||||||
|
won: groupStandings.won,
|
||||||
|
drawn: groupStandings.drawn,
|
||||||
|
lost: groupStandings.lost,
|
||||||
|
goalsFor: groupStandings.goalsFor,
|
||||||
|
goalsAgainst: groupStandings.goalsAgainst,
|
||||||
|
goalDiff: groupStandings.goalDiff,
|
||||||
|
pts: groupStandings.pts,
|
||||||
|
}).from(groupStandings)
|
||||||
|
.where(eq(groupStandings.tournamentYear, year))
|
||||||
|
.orderBy(asc(groupStandings.groupName), asc(groupStandings.pos))
|
||||||
|
return Promise.all(rows.map(async r => ({
|
||||||
|
...r,
|
||||||
|
played: r.played ?? 0,
|
||||||
|
won: r.won ?? 0,
|
||||||
|
drawn: r.drawn ?? 0,
|
||||||
|
lost: r.lost ?? 0,
|
||||||
|
goalsFor: r.goalsFor ?? 0,
|
||||||
|
goalsAgainst: r.goalsAgainst ?? 0,
|
||||||
|
goalDiff: r.goalDiff ?? 0,
|
||||||
|
pts: r.pts ?? 0,
|
||||||
|
team: (await getTeamById(r.teamId))!,
|
||||||
|
})))
|
||||||
|
},
|
||||||
|
async stadiums(_: unknown, { year }: { year?: number }) {
|
||||||
|
const rows = year
|
||||||
|
? await db.select().from(stadiums).where(eq(stadiums.tournamentYear, year))
|
||||||
|
: await db.select().from(stadiums).orderBy(asc(stadiums.name))
|
||||||
|
return rows.map(r => ({ ...r, matchCount: 0 }))
|
||||||
|
},
|
||||||
|
async squads(_: unknown, { year, team }: { year: number; team?: string }) {
|
||||||
|
const rows = await db.select().from(squads).where(eq(squads.tournamentYear, year)).orderBy(asc(squads.shirtNumber))
|
||||||
|
const filtered = team ? rows.filter(r => r.teamId !== null) : rows
|
||||||
|
return Promise.all(filtered.map(async r => {
|
||||||
|
const t = await getTeamById(r.teamId)
|
||||||
|
const dob = r.dateOfBirth
|
||||||
|
const age = dob ? Math.floor((Date.now() - new Date(dob).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : null
|
||||||
|
return { ...r, team: t!, age }
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async tournamentStats() {
|
||||||
|
const [totals] = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT t.year)::int AS total_tournaments,
|
||||||
|
COUNT(DISTINCT m.id)::int AS total_matches,
|
||||||
|
COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals,
|
||||||
|
ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals
|
||||||
|
FROM tournaments t
|
||||||
|
LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL
|
||||||
|
`)
|
||||||
|
const r = totals as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
totalTournaments: r.total_tournaments,
|
||||||
|
totalMatches: r.total_matches,
|
||||||
|
totalGoals: r.total_goals,
|
||||||
|
avgGoalsPerGame: r.avg_goals,
|
||||||
|
mostGoalsInTournament: null,
|
||||||
|
highestScoringMatch: null,
|
||||||
|
biggestWin: null,
|
||||||
|
mostTitles: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async goalsByMinute() {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN g.minute BETWEEN 0 AND 15 THEN '0-15'
|
||||||
|
WHEN g.minute BETWEEN 16 AND 30 THEN '16-30'
|
||||||
|
WHEN g.minute BETWEEN 31 AND 45 THEN '31-45'
|
||||||
|
WHEN g.minute BETWEEN 46 AND 60 THEN '46-60'
|
||||||
|
WHEN g.minute BETWEEN 61 AND 75 THEN '61-75'
|
||||||
|
WHEN g.minute BETWEEN 76 AND 90 THEN '76-90'
|
||||||
|
WHEN g.minute > 90 THEN '90+'
|
||||||
|
ELSE 'Unknown'
|
||||||
|
END AS bucket,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE g.minute IS NOT NULL AND m.is_quali_playoff = false
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY MIN(g.minute)
|
||||||
|
`)
|
||||||
|
return rows as unknown as { bucket: string; count: number }[]
|
||||||
|
},
|
||||||
|
async confederationStats() {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
t.confederation,
|
||||||
|
COUNT(DISTINCT m.tournament_year || '-' || t.id)::int AS appearances,
|
||||||
|
COUNT(DISTINCT CASE WHEN tr.winner = t.name THEN tr.year END)::int AS titles,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN m.team1_id = t.id THEN COALESCE(m.score_ft_home, 0)
|
||||||
|
WHEN m.team2_id = t.id THEN COALESCE(m.score_ft_away, 0)
|
||||||
|
ELSE 0 END
|
||||||
|
), 0)::int AS total_goals
|
||||||
|
FROM teams t
|
||||||
|
JOIN matches m ON (m.team1_id = t.id OR m.team2_id = t.id) AND m.is_quali_playoff = false
|
||||||
|
JOIN tournaments tr ON tr.year = m.tournament_year
|
||||||
|
WHERE t.confederation IS NOT NULL
|
||||||
|
GROUP BY t.confederation
|
||||||
|
ORDER BY appearances DESC
|
||||||
|
`)
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
async biggestWins(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
|
||||||
|
.orderBy(desc(sql`ABS(${matches.scoreFtHome} - ${matches.scoreFtAway})`), desc(matches.date))
|
||||||
|
.limit(limit)
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
async highestScoringMatches(_: unknown, { limit = 10 }: { limit?: number }) {
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false)))
|
||||||
|
.orderBy(desc(sql`${matches.scoreFtHome} + ${matches.scoreFtAway}`), desc(matches.date))
|
||||||
|
.limit(limit)
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
async extraTimeStats() {
|
||||||
|
const [r] = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total_knockout,
|
||||||
|
SUM(CASE WHEN score_et_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_et,
|
||||||
|
SUM(CASE WHEN score_p_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_pens
|
||||||
|
FROM matches
|
||||||
|
WHERE group_name IS NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
`)
|
||||||
|
const row = r as Record<string, number>
|
||||||
|
const total = row.total_knockout || 1
|
||||||
|
return {
|
||||||
|
totalKnockoutMatches: row.total_knockout,
|
||||||
|
wentToExtraTime: row.went_et,
|
||||||
|
wentToPenalties: row.went_pens,
|
||||||
|
extraTimePct: Math.round((row.went_et / total) * 100),
|
||||||
|
penaltiesPct: Math.round((row.went_pens / total) * 100),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async search(_: unknown, { query }: { query: string }) {
|
||||||
|
if (!query || query.trim().length < 2) return { tournaments: [], teams: [], players: [], matches: [] }
|
||||||
|
const q = `%${query.trim()}%`
|
||||||
|
const [tourRows, teamRows, playerRows, matchRows] = await Promise.all([
|
||||||
|
db.select().from(tournaments).where(ilike(tournaments.host, q)).limit(5),
|
||||||
|
db.select().from(teams).where(or(ilike(teams.name, q), ilike(teams.fifaCode!, q))).limit(8),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT g.player_name, g.team_id,
|
||||||
|
COUNT(*)::int AS goals,
|
||||||
|
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
|
||||||
|
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
|
||||||
|
COUNT(DISTINCT m.tournament_year)::int AS tournaments
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE LOWER(g.player_name) LIKE LOWER(${q}) AND m.is_quali_playoff = false
|
||||||
|
GROUP BY g.player_name, g.team_id
|
||||||
|
ORDER BY goals DESC LIMIT 8
|
||||||
|
`),
|
||||||
|
db.select().from(matches)
|
||||||
|
.where(and(
|
||||||
|
or(ilike(matches.round, q), ilike(matches.groupName!, q)),
|
||||||
|
eq(matches.isQualiPlayoff, false),
|
||||||
|
))
|
||||||
|
.limit(5),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
tournaments: tourRows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })),
|
||||||
|
teams: teamRows.map(teamWithSlug),
|
||||||
|
players: await Promise.all(playerRows.map(async (r: Record<string, unknown>) => ({
|
||||||
|
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
|
||||||
|
ownGoals: r.own_goals, tournaments: r.tournaments,
|
||||||
|
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||||
|
}))),
|
||||||
|
matches: await Promise.all(matchRows.map(hydrateMatch)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tournament: {
|
||||||
|
async topScorers(parent: { year: number }, { limit = 10 }: { limit?: number }) {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT g.player_name, g.team_id,
|
||||||
|
COUNT(*)::int AS goals,
|
||||||
|
SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties,
|
||||||
|
SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals,
|
||||||
|
1 AS tournaments
|
||||||
|
FROM goals g
|
||||||
|
JOIN matches m ON g.match_id = m.id
|
||||||
|
WHERE m.tournament_year = ${parent.year} AND m.is_quali_playoff = false
|
||||||
|
GROUP BY g.player_name, g.team_id
|
||||||
|
ORDER BY goals DESC LIMIT ${limit}
|
||||||
|
`)
|
||||||
|
return Promise.all(rows.map(async (r: Record<string, unknown>) => ({
|
||||||
|
playerName: r.player_name, goals: r.goals, penalties: r.penalties,
|
||||||
|
ownGoals: r.own_goals, tournaments: r.tournaments,
|
||||||
|
team: r.team_id ? await getTeamById(r.team_id as number) : null,
|
||||||
|
})))
|
||||||
|
},
|
||||||
|
async matches(parent: { year: number }) {
|
||||||
|
const rows = await db.select().from(matches)
|
||||||
|
.where(and(eq(matches.tournamentYear, parent.year), eq(matches.isQualiPlayoff, false)))
|
||||||
|
.orderBy(asc(matches.date), asc(matches.id))
|
||||||
|
return Promise.all(rows.map(hydrateMatch))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Team: {
|
||||||
|
async stats(parent: { id: number; name: string }) {
|
||||||
|
const [r] = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT m.tournament_year)::int AS appearances,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN m.team1_id = ${parent.id} AND m.score_ft_home > m.score_ft_away THEN 1
|
||||||
|
WHEN m.team2_id = ${parent.id} AND m.score_ft_away > m.score_ft_home THEN 1
|
||||||
|
ELSE 0 END)::int AS wins,
|
||||||
|
SUM(CASE WHEN m.score_ft_home = m.score_ft_away AND m.score_ft_home IS NOT NULL THEN 1 ELSE 0 END)::int AS draws,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN m.team1_id = ${parent.id} AND m.score_ft_home < m.score_ft_away THEN 1
|
||||||
|
WHEN m.team2_id = ${parent.id} AND m.score_ft_away < m.score_ft_home THEN 1
|
||||||
|
ELSE 0 END)::int AS losses,
|
||||||
|
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0)
|
||||||
|
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0) ELSE 0 END)::int AS goals_for,
|
||||||
|
SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0)
|
||||||
|
WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0) ELSE 0 END)::int AS goals_against,
|
||||||
|
COUNT(DISTINCT CASE WHEN t.winner = ${parent.name} THEN t.year END)::int AS titles
|
||||||
|
FROM matches m
|
||||||
|
JOIN tournaments t ON t.year = m.tournament_year
|
||||||
|
WHERE (m.team1_id = ${parent.id} OR m.team2_id = ${parent.id})
|
||||||
|
AND m.is_quali_playoff = false
|
||||||
|
AND m.score_ft_home IS NOT NULL
|
||||||
|
`)
|
||||||
|
const row = r as Record<string, number>
|
||||||
|
const played = (row.wins ?? 0) + (row.draws ?? 0) + (row.losses ?? 0)
|
||||||
|
return {
|
||||||
|
appearances: row.appearances ?? 0,
|
||||||
|
wins: row.wins ?? 0,
|
||||||
|
draws: row.draws ?? 0,
|
||||||
|
losses: row.losses ?? 0,
|
||||||
|
goalsFor: row.goals_for ?? 0,
|
||||||
|
goalsAgainst: row.goals_against ?? 0,
|
||||||
|
goalDiff: (row.goals_for ?? 0) - (row.goals_against ?? 0),
|
||||||
|
titles: row.titles ?? 0,
|
||||||
|
winPct: played > 0 ? Math.round(((row.wins ?? 0) / played) * 100) : 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
export const typeDefs = /* GraphQL */ `
|
||||||
|
type Tournament {
|
||||||
|
year: Int!
|
||||||
|
host: String!
|
||||||
|
winner: String
|
||||||
|
runnerUp: String
|
||||||
|
thirdPlace: String
|
||||||
|
fourthPlace: String
|
||||||
|
teamsCount: Int
|
||||||
|
matchesCount: Int
|
||||||
|
totalGoals: Int
|
||||||
|
avgGoalsPerGame: Float
|
||||||
|
topScorers: [ScorerEntry!]!
|
||||||
|
matches: [Match!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team {
|
||||||
|
id: Int!
|
||||||
|
name: String!
|
||||||
|
slug: String!
|
||||||
|
iso2: String
|
||||||
|
fifaCode: String
|
||||||
|
continent: String
|
||||||
|
confederation: String
|
||||||
|
stats: TeamStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamStats {
|
||||||
|
appearances: Int!
|
||||||
|
wins: Int!
|
||||||
|
draws: Int!
|
||||||
|
losses: Int!
|
||||||
|
goalsFor: Int!
|
||||||
|
goalsAgainst: Int!
|
||||||
|
goalDiff: Int!
|
||||||
|
titles: Int!
|
||||||
|
winPct: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stadium {
|
||||||
|
id: Int!
|
||||||
|
tournamentYear: Int
|
||||||
|
name: String!
|
||||||
|
city: String
|
||||||
|
countryCode: String
|
||||||
|
capacity: Int
|
||||||
|
timezone: String
|
||||||
|
coordinates: String
|
||||||
|
matchCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Match {
|
||||||
|
id: Int!
|
||||||
|
year: Int!
|
||||||
|
round: String!
|
||||||
|
group: String
|
||||||
|
date: String
|
||||||
|
time: String
|
||||||
|
stadium: String
|
||||||
|
team1: Team!
|
||||||
|
team2: Team!
|
||||||
|
scoreFt: [Int!]
|
||||||
|
scoreHt: [Int!]
|
||||||
|
scoreEt: [Int!]
|
||||||
|
scoreP: [Int!]
|
||||||
|
goals: [Goal!]!
|
||||||
|
isLive: Boolean!
|
||||||
|
isQualiPlayoff: Boolean!
|
||||||
|
margin: Int
|
||||||
|
totalGoals: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Goal {
|
||||||
|
id: Int!
|
||||||
|
team: Team!
|
||||||
|
playerName: String!
|
||||||
|
minute: Int
|
||||||
|
minuteOffset: Int
|
||||||
|
isPenalty: Boolean!
|
||||||
|
isOwnGoal: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScorerEntry {
|
||||||
|
playerName: String!
|
||||||
|
team: Team
|
||||||
|
goals: Int!
|
||||||
|
penalties: Int!
|
||||||
|
ownGoals: Int!
|
||||||
|
tournaments: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupStanding {
|
||||||
|
groupName: String!
|
||||||
|
pos: Int
|
||||||
|
team: Team!
|
||||||
|
played: Int!
|
||||||
|
won: Int!
|
||||||
|
drawn: Int!
|
||||||
|
lost: Int!
|
||||||
|
goalsFor: Int!
|
||||||
|
goalsAgainst: Int!
|
||||||
|
goalDiff: Int!
|
||||||
|
pts: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SquadPlayer {
|
||||||
|
playerName: String!
|
||||||
|
shirtNumber: Int
|
||||||
|
position: String
|
||||||
|
dateOfBirth: String
|
||||||
|
team: Team!
|
||||||
|
age: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalStats {
|
||||||
|
totalTournaments: Int!
|
||||||
|
totalMatches: Int!
|
||||||
|
totalGoals: Int!
|
||||||
|
avgGoalsPerGame: Float!
|
||||||
|
mostGoalsInTournament: TournamentGoalRecord
|
||||||
|
highestScoringMatch: Match
|
||||||
|
biggestWin: Match
|
||||||
|
mostTitles: ScorerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type TournamentGoalRecord {
|
||||||
|
year: Int!
|
||||||
|
host: String!
|
||||||
|
totalGoals: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type HatTrick {
|
||||||
|
playerName: String!
|
||||||
|
team: Team
|
||||||
|
year: Int!
|
||||||
|
round: String!
|
||||||
|
opponent: Team
|
||||||
|
goals: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type MinuteBucket {
|
||||||
|
bucket: String!
|
||||||
|
count: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfederationStat {
|
||||||
|
confederation: String!
|
||||||
|
appearances: Int!
|
||||||
|
titles: Int!
|
||||||
|
totalGoals: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResults {
|
||||||
|
tournaments: [Tournament!]!
|
||||||
|
teams: [Team!]!
|
||||||
|
players: [ScorerEntry!]!
|
||||||
|
matches: [Match!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
tournaments: [Tournament!]!
|
||||||
|
tournament(year: Int!): Tournament
|
||||||
|
|
||||||
|
matches(year: Int, group: String, round: String, isQuali: Boolean): [Match!]!
|
||||||
|
match(id: Int!): Match
|
||||||
|
liveMatches: [Match!]!
|
||||||
|
recentMatches(limit: Int): [Match!]!
|
||||||
|
upcomingMatches(limit: Int): [Match!]!
|
||||||
|
|
||||||
|
teams: [Team!]!
|
||||||
|
team(slug: String!): Team
|
||||||
|
|
||||||
|
topScorers(year: Int, limit: Int): [ScorerEntry!]!
|
||||||
|
player(name: String!): ScorerEntry
|
||||||
|
hatTricks(year: Int): [HatTrick!]!
|
||||||
|
|
||||||
|
groupStandings(year: Int!): [GroupStanding!]!
|
||||||
|
|
||||||
|
stadiums(year: Int): [Stadium!]!
|
||||||
|
squads(year: Int!, team: String): [SquadPlayer!]!
|
||||||
|
|
||||||
|
tournamentStats: GlobalStats!
|
||||||
|
goalsByMinute: [MinuteBucket!]!
|
||||||
|
confederationStats: [ConfederationStat!]!
|
||||||
|
biggestWins(limit: Int): [Match!]!
|
||||||
|
highestScoringMatches(limit: Int): [Match!]!
|
||||||
|
extraTimeStats: ExtraTimeStats!
|
||||||
|
|
||||||
|
search(query: String!): SearchResults!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtraTimeStats {
|
||||||
|
totalKnockoutMatches: Int!
|
||||||
|
wentToExtraTime: Int!
|
||||||
|
wentToPenalties: Int!
|
||||||
|
extraTimePct: Float!
|
||||||
|
penaltiesPct: Float!
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export const TEAM_ISO: Record<string, string> = {
|
||||||
|
// A
|
||||||
|
'Afghanistan': 'af', 'Albania': 'al', 'Algeria': 'dz', 'Angola': 'ao',
|
||||||
|
'Argentina': 'ar', 'Armenia': 'am', 'Australia': 'au', 'Austria': 'at',
|
||||||
|
// B
|
||||||
|
'Bahrain': 'bh', 'Belgium': 'be', 'Bolivia': 'bo',
|
||||||
|
'Bosnia & Herzegovina': 'ba', 'Bosnia and Herzegovina': 'ba', 'Brazil': 'br',
|
||||||
|
'Bulgaria': 'bg', 'Burkina Faso': 'bf',
|
||||||
|
// C
|
||||||
|
'Cameroon': 'cm', 'Canada': 'ca', 'Cape Verde': 'cv', 'Chile': 'cl',
|
||||||
|
'China': 'cn', "China PR": 'cn', 'Colombia': 'co', 'Costa Rica': 'cr',
|
||||||
|
'Croatia': 'hr', 'Cuba': 'cu', 'Curaçao': 'cw', 'Curacao': 'cw',
|
||||||
|
'Cyprus': 'cy', 'Czech Republic': 'cz', 'Czechia': 'cz',
|
||||||
|
// D
|
||||||
|
'Denmark': 'dk', 'DR Congo': 'cd', 'Dutch East Indies': 'id',
|
||||||
|
// E
|
||||||
|
'Ecuador': 'ec', 'Egypt': 'eg', 'El Salvador': 'sv',
|
||||||
|
'England': 'gb-eng', 'Estonia': 'ee', 'Ethiopia': 'et',
|
||||||
|
// F
|
||||||
|
'Finland': 'fi', 'France': 'fr',
|
||||||
|
// G
|
||||||
|
'Gabon': 'ga', 'Germany': 'de', 'West Germany': 'de', 'East Germany': 'de',
|
||||||
|
'Ghana': 'gh', 'Greece': 'gr', 'Guatemala': 'gt', 'Guinea': 'gn',
|
||||||
|
// H
|
||||||
|
'Haiti': 'ht', 'Honduras': 'hn', 'Hungary': 'hu',
|
||||||
|
// I
|
||||||
|
'Iceland': 'is', 'India': 'in', 'Indonesia': 'id', 'Iran': 'ir',
|
||||||
|
'Iraq': 'iq', 'Ireland': 'ie', 'Republic of Ireland': 'ie',
|
||||||
|
'Israel': 'il', 'Italy': 'it', 'Ivory Coast': 'ci', "Côte d'Ivoire": 'ci',
|
||||||
|
// J
|
||||||
|
'Jamaica': 'jm', 'Japan': 'jp', 'Jordan': 'jo',
|
||||||
|
// K
|
||||||
|
'Kazakhstan': 'kz', 'Kenya': 'ke', 'Kuwait': 'kw',
|
||||||
|
// L
|
||||||
|
'Latvia': 'lv', 'Lebanon': 'lb', 'Liberia': 'lr', 'Lithuania': 'lt',
|
||||||
|
// M
|
||||||
|
'Mali': 'ml', 'Malta': 'mt', 'Mexico': 'mx', 'Moldova': 'md',
|
||||||
|
'Montenegro': 'me', 'Morocco': 'ma', 'Mozambique': 'mz',
|
||||||
|
// N
|
||||||
|
'Netherlands': 'nl', 'New Zealand': 'nz', 'Nigeria': 'ng',
|
||||||
|
'North Korea': 'kp', "Korea DPR": 'kp', 'Northern Ireland': 'gb-nir',
|
||||||
|
'North Macedonia': 'mk', 'Norway': 'no',
|
||||||
|
// O
|
||||||
|
'Oman': 'om',
|
||||||
|
// P
|
||||||
|
'Panama': 'pa', 'Paraguay': 'py', 'Peru': 'pe', 'Philippines': 'ph',
|
||||||
|
'Poland': 'pl', 'Portugal': 'pt',
|
||||||
|
// Q
|
||||||
|
'Qatar': 'qa',
|
||||||
|
// R
|
||||||
|
'Romania': 'ro', 'Russia': 'ru', 'Soviet Union': 'su',
|
||||||
|
// S
|
||||||
|
'Saudi Arabia': 'sa', 'Scotland': 'gb-sct', 'Senegal': 'sn',
|
||||||
|
'Serbia': 'rs', 'Yugoslavia': 'yu', 'Slovakia': 'sk', 'Slovenia': 'si',
|
||||||
|
'Somalia': 'so', 'South Africa': 'za', 'South Korea': 'kr',
|
||||||
|
'Korea Republic': 'kr', 'Spain': 'es', 'Sweden': 'se', 'Switzerland': 'ch',
|
||||||
|
// T
|
||||||
|
'Taiwan': 'tw', 'Tanzania': 'tz', 'Thailand': 'th', 'Togo': 'tg',
|
||||||
|
'Trinidad and Tobago': 'tt', 'Tunisia': 'tn', 'Turkey': 'tr',
|
||||||
|
// U
|
||||||
|
'UAE': 'ae', 'United Arab Emirates': 'ae', 'Uganda': 'ug', 'Ukraine': 'ua',
|
||||||
|
'Uruguay': 'uy', 'USA': 'us', 'United States': 'us', 'Uzbekistan': 'uz',
|
||||||
|
// V
|
||||||
|
'Venezuela': 've', 'Vietnam': 'vn',
|
||||||
|
// W
|
||||||
|
'Wales': 'gb-wls',
|
||||||
|
// Z
|
||||||
|
'Zambia': 'zm', 'Zimbabwe': 'zw',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIso(teamName: string): string {
|
||||||
|
return TEAM_ISO[teamName] ?? teamName.toLowerCase().replace(/\s+/g, '-').substring(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
serverExternalPackages: ['postgres'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "worldcup",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.28.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"sync": "tsx scripts/sync.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:push": "drizzle-kit push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/client": "^4.2.3",
|
||||||
|
"@graphql-tools/schema": "^10.0.33",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"flag-icons": "^7.5.0",
|
||||||
|
"graphql": "^16.14.2",
|
||||||
|
"graphql-yoga": "^5.21.2",
|
||||||
|
"next": "16.2.9",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.9",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5400
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
+426
@@ -0,0 +1,426 @@
|
|||||||
|
import postgres from 'postgres'
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { TEAM_ISO, getIso } from '../lib/iso-codes'
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
|
||||||
|
const BASE = 'https://raw.githubusercontent.com/openfootball/worldcup.json/master'
|
||||||
|
|
||||||
|
const YEARS = [
|
||||||
|
1930, 1934, 1938, 1950, 1954, 1958, 1962, 1966, 1970,
|
||||||
|
1974, 1978, 1982, 1986, 1990, 1994, 1998, 2002, 2006,
|
||||||
|
2010, 2014, 2018, 2022, 2026,
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOSTS: Record<number, string> = {
|
||||||
|
1930: 'Uruguay', 1934: 'Italy', 1938: 'France', 1950: 'Brazil',
|
||||||
|
1954: 'Switzerland', 1958: 'Sweden', 1962: 'Chile', 1966: 'England',
|
||||||
|
1970: 'Mexico', 1974: 'Germany', 1978: 'Argentina', 1982: 'Spain',
|
||||||
|
1986: 'Mexico', 1990: 'Italy', 1994: 'USA', 1998: 'France',
|
||||||
|
2002: 'South Korea / Japan', 2006: 'Germany', 2010: 'South Africa',
|
||||||
|
2014: 'Brazil', 2018: 'Russia', 2022: 'Qatar', 2026: 'USA / Canada / Mexico',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINNERS: Record<number, { winner: string; runnerUp: string; third?: string; fourth?: string }> = {
|
||||||
|
1930: { winner: 'Uruguay', runnerUp: 'Argentina', third: 'USA', fourth: 'Yugoslavia' },
|
||||||
|
1934: { winner: 'Italy', runnerUp: 'Czechoslovakia', third: 'Germany', fourth: 'Austria' },
|
||||||
|
1938: { winner: 'Italy', runnerUp: 'Hungary', third: 'Brazil', fourth: 'Sweden' },
|
||||||
|
1950: { winner: 'Uruguay', runnerUp: 'Brazil', third: 'Sweden', fourth: 'Spain' },
|
||||||
|
1954: { winner: 'Germany', runnerUp: 'Hungary', third: 'Austria', fourth: 'Uruguay' },
|
||||||
|
1958: { winner: 'Brazil', runnerUp: 'Sweden', third: 'France', fourth: 'Germany' },
|
||||||
|
1962: { winner: 'Brazil', runnerUp: 'Czechoslovakia', third: 'Chile', fourth: 'Yugoslavia' },
|
||||||
|
1966: { winner: 'England', runnerUp: 'Germany', third: 'Portugal', fourth: 'Soviet Union' },
|
||||||
|
1970: { winner: 'Brazil', runnerUp: 'Italy', third: 'Germany', fourth: 'Uruguay' },
|
||||||
|
1974: { winner: 'Germany', runnerUp: 'Netherlands', third: 'Poland', fourth: 'Brazil' },
|
||||||
|
1978: { winner: 'Argentina', runnerUp: 'Netherlands', third: 'Brazil', fourth: 'Italy' },
|
||||||
|
1982: { winner: 'Italy', runnerUp: 'Germany', third: 'Poland', fourth: 'France' },
|
||||||
|
1986: { winner: 'Argentina', runnerUp: 'Germany', third: 'France', fourth: 'Belgium' },
|
||||||
|
1990: { winner: 'Germany', runnerUp: 'Argentina', third: 'Italy', fourth: 'England' },
|
||||||
|
1994: { winner: 'Brazil', runnerUp: 'Italy', third: 'Sweden', fourth: 'Bulgaria' },
|
||||||
|
1998: { winner: 'France', runnerUp: 'Brazil', third: 'Croatia', fourth: 'Netherlands' },
|
||||||
|
2002: { winner: 'Brazil', runnerUp: 'Germany', third: 'Turkey', fourth: 'South Korea' },
|
||||||
|
2006: { winner: 'Italy', runnerUp: 'France', third: 'Germany', fourth: 'Portugal' },
|
||||||
|
2010: { winner: 'Spain', runnerUp: 'Netherlands', third: 'Germany', fourth: 'Uruguay' },
|
||||||
|
2014: { winner: 'Germany', runnerUp: 'Argentina', third: 'Netherlands', fourth: 'Brazil' },
|
||||||
|
2018: { winner: 'France', runnerUp: 'Croatia', third: 'Belgium', fourth: 'England' },
|
||||||
|
2022: { winner: 'Argentina', runnerUp: 'France', third: 'Croatia', fourth: 'Morocco' },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url: string): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawGoal = { name: string; minute?: string | number; offset?: number; penalty?: boolean; owngoal?: boolean }
|
||||||
|
type RawScore = { ft?: number[]; ht?: number[]; et?: number[]; p?: number[] } | number[]
|
||||||
|
type RawMatch = {
|
||||||
|
round?: string; date?: string; time?: string;
|
||||||
|
team1: string; team2: string; score?: RawScore;
|
||||||
|
goals1?: RawGoal[]; goals2?: RawGoal[];
|
||||||
|
group?: string; ground?: string;
|
||||||
|
}
|
||||||
|
type RawData = { matches: RawMatch[] }
|
||||||
|
|
||||||
|
function parseScore(score: RawScore | undefined) {
|
||||||
|
if (!score) return {}
|
||||||
|
if (Array.isArray(score)) return { ft: score }
|
||||||
|
return { ft: score.ft, ht: score.ht, et: score.et, p: score.p }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const client = postgres(DATABASE_URL, { max: 5 })
|
||||||
|
const db = drizzle(client)
|
||||||
|
|
||||||
|
console.log('Creating tables...')
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS tournaments (
|
||||||
|
year INTEGER PRIMARY KEY,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
winner TEXT,
|
||||||
|
runner_up TEXT,
|
||||||
|
third_place TEXT,
|
||||||
|
fourth_place TEXT,
|
||||||
|
teams_count INTEGER,
|
||||||
|
matches_count INTEGER,
|
||||||
|
total_goals INTEGER,
|
||||||
|
avg_goals_per_game NUMERIC(4,2)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
iso2 TEXT,
|
||||||
|
fifa_code TEXT,
|
||||||
|
continent TEXT,
|
||||||
|
confederation TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS stadiums (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tournament_year INTEGER,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
city TEXT,
|
||||||
|
country_code TEXT,
|
||||||
|
capacity INTEGER,
|
||||||
|
timezone TEXT,
|
||||||
|
coordinates TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS matches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tournament_year INTEGER NOT NULL,
|
||||||
|
round TEXT NOT NULL,
|
||||||
|
group_name TEXT,
|
||||||
|
date DATE,
|
||||||
|
time_local TEXT,
|
||||||
|
stadium_id INTEGER,
|
||||||
|
team1_id INTEGER NOT NULL,
|
||||||
|
team2_id INTEGER NOT NULL,
|
||||||
|
score_ft_home INTEGER,
|
||||||
|
score_ft_away INTEGER,
|
||||||
|
score_ht_home INTEGER,
|
||||||
|
score_ht_away INTEGER,
|
||||||
|
score_et_home INTEGER,
|
||||||
|
score_et_away INTEGER,
|
||||||
|
score_p_home INTEGER,
|
||||||
|
score_p_away INTEGER,
|
||||||
|
is_quali_playoff BOOLEAN DEFAULT false
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS matches_unique ON matches (tournament_year, team1_id, team2_id, date, is_quali_playoff);
|
||||||
|
CREATE TABLE IF NOT EXISTS goals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
match_id INTEGER NOT NULL,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
minute INTEGER,
|
||||||
|
minute_offset INTEGER DEFAULT 0,
|
||||||
|
is_penalty BOOLEAN DEFAULT false,
|
||||||
|
is_own_goal BOOLEAN DEFAULT false
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS group_standings (
|
||||||
|
tournament_year INTEGER NOT NULL,
|
||||||
|
group_name TEXT NOT NULL,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
pos INTEGER,
|
||||||
|
played INTEGER DEFAULT 0,
|
||||||
|
won INTEGER DEFAULT 0,
|
||||||
|
drawn INTEGER DEFAULT 0,
|
||||||
|
lost INTEGER DEFAULT 0,
|
||||||
|
goals_for INTEGER DEFAULT 0,
|
||||||
|
goals_against INTEGER DEFAULT 0,
|
||||||
|
goal_diff INTEGER DEFAULT 0,
|
||||||
|
pts INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (tournament_year, group_name, team_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS squads (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tournament_year INTEGER NOT NULL,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
shirt_number INTEGER,
|
||||||
|
position TEXT,
|
||||||
|
date_of_birth DATE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS squads_unique ON squads (tournament_year, team_id, shirt_number);
|
||||||
|
`)
|
||||||
|
|
||||||
|
const teamCache = new Map<string, number>()
|
||||||
|
|
||||||
|
async function upsertTeam(name: string, extra?: { iso2?: string; fifaCode?: string; continent?: string; confederation?: string }) {
|
||||||
|
if (teamCache.has(name)) return teamCache.get(name)!
|
||||||
|
const iso2 = extra?.iso2 ?? getIso(name)
|
||||||
|
const [row] = await db.execute(sql`
|
||||||
|
INSERT INTO teams (name, iso2, fifa_code, continent, confederation)
|
||||||
|
VALUES (${name}, ${iso2 ?? null}, ${extra?.fifaCode ?? null}, ${extra?.continent ?? null}, ${extra?.confederation ?? null})
|
||||||
|
ON CONFLICT (name) DO UPDATE SET
|
||||||
|
iso2 = COALESCE(EXCLUDED.iso2, teams.iso2),
|
||||||
|
fifa_code = COALESCE(EXCLUDED.fifa_code, teams.fifa_code),
|
||||||
|
continent = COALESCE(EXCLUDED.continent, teams.continent),
|
||||||
|
confederation = COALESCE(EXCLUDED.confederation, teams.confederation)
|
||||||
|
RETURNING id
|
||||||
|
`)
|
||||||
|
const id = (row as { id: number }).id
|
||||||
|
teamCache.set(name, id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertMatch(
|
||||||
|
year: number, round: string, group: string | null, dateStr: string | null,
|
||||||
|
timeStr: string | null, team1Id: number, team2Id: number, score: ReturnType<typeof parseScore>,
|
||||||
|
isQuali: boolean
|
||||||
|
) {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
INSERT INTO matches (tournament_year, round, group_name, date, time_local, team1_id, team2_id,
|
||||||
|
score_ft_home, score_ft_away, score_ht_home, score_ht_away,
|
||||||
|
score_et_home, score_et_away, score_p_home, score_p_away, is_quali_playoff)
|
||||||
|
VALUES (
|
||||||
|
${year}, ${round}, ${group}, ${dateStr ?? null}, ${timeStr ?? null},
|
||||||
|
${team1Id}, ${team2Id},
|
||||||
|
${score.ft?.[0] ?? null}, ${score.ft?.[1] ?? null},
|
||||||
|
${score.ht?.[0] ?? null}, ${score.ht?.[1] ?? null},
|
||||||
|
${score.et?.[0] ?? null}, ${score.et?.[1] ?? null},
|
||||||
|
${score.p?.[0] ?? null}, ${score.p?.[1] ?? null},
|
||||||
|
${isQuali}
|
||||||
|
)
|
||||||
|
ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET
|
||||||
|
round = EXCLUDED.round,
|
||||||
|
score_ft_home = COALESCE(EXCLUDED.score_ft_home, matches.score_ft_home),
|
||||||
|
score_ft_away = COALESCE(EXCLUDED.score_ft_away, matches.score_ft_away),
|
||||||
|
score_ht_home = COALESCE(EXCLUDED.score_ht_home, matches.score_ht_home),
|
||||||
|
score_ht_away = COALESCE(EXCLUDED.score_ht_away, matches.score_ht_away),
|
||||||
|
score_et_home = COALESCE(EXCLUDED.score_et_home, matches.score_et_home),
|
||||||
|
score_et_away = COALESCE(EXCLUDED.score_et_away, matches.score_et_away),
|
||||||
|
score_p_home = COALESCE(EXCLUDED.score_p_home, matches.score_p_home),
|
||||||
|
score_p_away = COALESCE(EXCLUDED.score_p_away, matches.score_p_away),
|
||||||
|
time_local = COALESCE(EXCLUDED.time_local, matches.time_local)
|
||||||
|
RETURNING id
|
||||||
|
`)
|
||||||
|
return (rows[0] as { id: number }).id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncGoals(matchId: number, teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number) {
|
||||||
|
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
|
||||||
|
for (const g of rawGoals) {
|
||||||
|
if (!g.name) continue
|
||||||
|
const minute = g.minute != null ? parseInt(String(g.minute)) : null
|
||||||
|
const scoringTeamId = g.owngoal ? isOwnGoalTeamId : teamId
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
|
||||||
|
VALUES (${matchId}, ${scoringTeamId}, ${g.name}, ${isNaN(minute!) ? null : minute},
|
||||||
|
${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false})
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const year of YEARS) {
|
||||||
|
console.log(`\nSyncing ${year}...`)
|
||||||
|
|
||||||
|
// 1. Upsert tournament
|
||||||
|
const winData = WINNERS[year]
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO tournaments (year, host, winner, runner_up, third_place, fourth_place)
|
||||||
|
VALUES (${year}, ${HOSTS[year]}, ${winData?.winner ?? null}, ${winData?.runnerUp ?? null},
|
||||||
|
${winData?.third ?? null}, ${winData?.fourth ?? null})
|
||||||
|
ON CONFLICT (year) DO UPDATE SET
|
||||||
|
winner = COALESCE(EXCLUDED.winner, tournaments.winner),
|
||||||
|
runner_up = COALESCE(EXCLUDED.runner_up, tournaments.runner_up)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 2. Teams enrichment
|
||||||
|
const teamsData = await fetchJson(`${BASE}/${year}/worldcup.teams.json`) as Record<string, unknown>[] | null
|
||||||
|
if (teamsData && Array.isArray(teamsData)) {
|
||||||
|
for (const t of teamsData) {
|
||||||
|
const name = (t.name ?? t.name_normalised) as string
|
||||||
|
const iso2 = (t.flag_icon as string)?.match(/[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]/)?.[0]
|
||||||
|
? TEAM_ISO[name as string] ?? getIso(name)
|
||||||
|
: TEAM_ISO[name as string] ?? getIso(name)
|
||||||
|
await upsertTeam(name, {
|
||||||
|
iso2: iso2,
|
||||||
|
fifaCode: t.fifa_code as string,
|
||||||
|
continent: t.continent as string,
|
||||||
|
confederation: t.confed as string,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Stadiums
|
||||||
|
const stadiumsData = await fetchJson(`${BASE}/${year}/worldcup.stadiums.json`) as { stadiums?: Record<string, unknown>[] } | null
|
||||||
|
if (stadiumsData?.stadiums) {
|
||||||
|
for (const s of stadiumsData.stadiums) {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO stadiums (tournament_year, name, city, country_code, capacity, timezone, coordinates)
|
||||||
|
VALUES (${year}, ${s.name as string}, ${s.city as string}, ${(s.cc as string | undefined) ?? null},
|
||||||
|
${(s.capacity as number | undefined) ?? null}, ${(s.timezone as string | undefined) ?? null}, ${(s.coords as string | undefined) ?? null})
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Main matches
|
||||||
|
const mainData = await fetchJson(`${BASE}/${year}/worldcup.json`) as RawData | null
|
||||||
|
if (!mainData?.matches) { console.log(` No match data`); continue }
|
||||||
|
|
||||||
|
let matchCount = 0, goalCount = 0
|
||||||
|
for (const m of mainData.matches) {
|
||||||
|
const t1Id = await upsertTeam(m.team1)
|
||||||
|
const t2Id = await upsertTeam(m.team2)
|
||||||
|
const score = parseScore(m.score)
|
||||||
|
const group = m.group ?? null
|
||||||
|
const matchId = await upsertMatch(year, m.round ?? 'Unknown', group, m.date ?? null, m.time ?? null, t1Id, t2Id, score, false)
|
||||||
|
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id)
|
||||||
|
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id)
|
||||||
|
matchCount++
|
||||||
|
goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Standings (2014, 2018)
|
||||||
|
const standingsData = await fetchJson(`${BASE}/${year}/worldcup.standings.json`) as { groups?: Record<string, unknown>[] } | null
|
||||||
|
if (standingsData?.groups) {
|
||||||
|
for (const grp of standingsData.groups) {
|
||||||
|
const standings = grp.standings as Record<string, unknown>[]
|
||||||
|
for (const s of standings) {
|
||||||
|
const t = s.team as { name: string; code: string }
|
||||||
|
const teamId = await upsertTeam(t.name, { fifaCode: t.code })
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO group_standings (tournament_year, group_name, team_id, pos, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
|
||||||
|
VALUES (${year}, ${grp.name as string}, ${teamId}, ${s.pos as number ?? null},
|
||||||
|
${s.played as number ?? 0}, ${s.won as number ?? 0}, ${s.drawn as number ?? 0}, ${s.lost as number ?? 0},
|
||||||
|
${s.goals_for as number ?? 0}, ${s.goals_against as number ?? 0},
|
||||||
|
${((s.goals_for as number ?? 0) - (s.goals_against as number ?? 0))},
|
||||||
|
${s.pts as number ?? 0})
|
||||||
|
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
|
||||||
|
pos = EXCLUDED.pos, played = EXCLUDED.played, won = EXCLUDED.won,
|
||||||
|
drawn = EXCLUDED.drawn, lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for,
|
||||||
|
goals_against = EXCLUDED.goals_against, goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (year !== 2026) {
|
||||||
|
// Compute standings from match results for years without standings.json
|
||||||
|
await db.execute(sql`
|
||||||
|
WITH match_results AS (
|
||||||
|
SELECT tournament_year, group_name,
|
||||||
|
team1_id AS team_id,
|
||||||
|
score_ft_home AS gf, score_ft_away AS ga
|
||||||
|
FROM matches WHERE tournament_year = ${year} AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT tournament_year, group_name,
|
||||||
|
team2_id, score_ft_away, score_ft_home
|
||||||
|
FROM matches WHERE tournament_year = ${year} AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
)
|
||||||
|
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
|
||||||
|
SELECT
|
||||||
|
tournament_year, group_name, team_id,
|
||||||
|
COUNT(*)::int, SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
|
||||||
|
(SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END))::int
|
||||||
|
FROM match_results
|
||||||
|
GROUP BY tournament_year, group_name, team_id
|
||||||
|
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
|
||||||
|
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
|
||||||
|
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for, goals_against = EXCLUDED.goals_against,
|
||||||
|
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Squads (2026)
|
||||||
|
const squadsData = await fetchJson(`${BASE}/${year}/worldcup.squads.json`) as Record<string, unknown>[] | null
|
||||||
|
if (squadsData && Array.isArray(squadsData)) {
|
||||||
|
for (const sq of squadsData) {
|
||||||
|
const teamId = await upsertTeam(sq.name as string)
|
||||||
|
for (const p of (sq.players as Record<string, unknown>[])) {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO squads (tournament_year, team_id, player_name, shirt_number, position, date_of_birth)
|
||||||
|
VALUES (${year}, ${teamId}, ${p.name as string}, ${p.number as number ?? null},
|
||||||
|
${p.pos as string ?? null}, ${p.date_of_birth as string ?? null})
|
||||||
|
ON CONFLICT (tournament_year, team_id, shirt_number) DO UPDATE SET
|
||||||
|
player_name = EXCLUDED.player_name, position = EXCLUDED.position, date_of_birth = EXCLUDED.date_of_birth
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Squads loaded for ${year}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Quali playoffs (2026)
|
||||||
|
const qualiData = await fetchJson(`${BASE}/${year}/worldcup.quali_playoffs.json`) as RawData | null
|
||||||
|
if (qualiData?.matches) {
|
||||||
|
for (const m of qualiData.matches) {
|
||||||
|
const t1Id = await upsertTeam(m.team1)
|
||||||
|
const t2Id = await upsertTeam(m.team2)
|
||||||
|
const score = parseScore(m.score)
|
||||||
|
const matchId = await upsertMatch(year, m.round ?? 'Qualifier', null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, true)
|
||||||
|
if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id)
|
||||||
|
if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id)
|
||||||
|
}
|
||||||
|
console.log(` Quali playoffs: ${qualiData.matches.length} matches`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Recompute tournament aggregates
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE tournaments SET
|
||||||
|
matches_count = (SELECT COUNT(*)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false),
|
||||||
|
total_goals = (SELECT COALESCE(SUM(score_ft_home + score_ft_away), 0)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL),
|
||||||
|
avg_goals_per_game = (
|
||||||
|
SELECT ROUND(COALESCE(SUM(score_ft_home + score_ft_away), 0)::numeric / NULLIF(COUNT(*), 0), 2)
|
||||||
|
FROM matches
|
||||||
|
WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE year = ${year}
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log(` ✓ ${matchCount} matches, ${goalCount} goals`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute 2026 group standings from match results
|
||||||
|
await db.execute(sql`
|
||||||
|
WITH match_results AS (
|
||||||
|
SELECT tournament_year, group_name, team1_id AS team_id, score_ft_home AS gf, score_ft_away AS ga
|
||||||
|
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT tournament_year, group_name, team2_id, score_ft_away, score_ft_home
|
||||||
|
FROM matches WHERE tournament_year = 2026 AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL
|
||||||
|
)
|
||||||
|
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts)
|
||||||
|
SELECT tournament_year, group_name, team_id,
|
||||||
|
COUNT(*)::int, SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
|
||||||
|
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
|
||||||
|
SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END)::int
|
||||||
|
FROM match_results
|
||||||
|
GROUP BY tournament_year, group_name, team_id
|
||||||
|
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
|
||||||
|
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
|
||||||
|
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for, goals_against = EXCLUDED.goals_against,
|
||||||
|
goal_diff = EXCLUDED.goal_diff, pts = EXCLUDED.pts
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log('\n✅ Sync complete!')
|
||||||
|
await client.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => { console.error(e); process.exit(1) })
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user