Compare commits

..

67 Commits

Author SHA1 Message Date
valknar 1ebe4613ce docs: rewrite README with accurate data pipeline documentation
- Replace openfootball references with Wikipedia scraper workflow
- Document all three scripts: scrape (dev), seed (init), sync (scheduled)
- Explain rate-limit handling, incremental group detection, UTC kickoff ordering
- Add NEXT_PUBLIC_SITE_URL to env vars table
- Update project structure with data/, client.tsx pattern, wiki-scraper.ts
- Add architecture notes for server/client split, dynamic sitemap, standings seeding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 07:50:12 +02:00
valknar c721062560 fix: anchor scroll — double-rAF timing + scroll-mt-20 on match cards
useEffect fired before the browser had laid out the freshly-rendered
match cards, so getElementById returned null or scrollIntoView ran
before the element was in its final position. Double requestAnimationFrame
waits for React's commit AND the browser's layout pass.

scroll-mt-20 (80 px) adds clearance for the 60 px sticky nav so the
targeted card isn't hidden beneath it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:52:20 +02:00
valknar 1fc9c59367 fix: sort recentMatches by UTC kickoff time, not ID
ID order doesn't reflect actual match time — a later kickoff in a
different timezone can have a lower ID. Use the same UTC normalisation
expression as upcomingMatches so the latest finished match always
appears first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:44:38 +02:00
valknar 7fb54683e4 fix: mark sitemap as dynamic to avoid DB query at build time
The sitemap queries the database, which is only reachable at runtime
(not during next build where the 'db' hostname is unavailable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:25:46 +02:00
valknar a494c80a76 feat: SEO enhancements — server metadata, sitemap, robots, dynamic base URL
- Split all page.tsx files into server wrapper (metadata export) + client.tsx (Apollo/interactive)
- Add robots.ts and sitemap.ts (tournaments, teams, players)
- Add metadataBase, OpenGraph and Twitter card metadata to root layout
- Replace hardcoded worldcup.pivoine.art with NEXT_PUBLIC_SITE_URL env var (sitemap/robots) and relative paths (page metadata, resolved by Next.js against metadataBase)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:18:36 +02:00
valknar 2bd32daae1 fix: show 0-0 for live matches with no score data; exclude live from recent
MatchCard: display '0–0' instead of '?–?' when a match is live but
score_ft_home is still NULL (sync hasn't picked up the score yet, or
Wikipedia hasn't been updated — every match starts at 0-0).

recentMatches resolver: fetch limit*2 rows then filter out live matches
so a match with score_ft_home=0 that is still in progress doesn't appear
in both the live section and recent results simultaneously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:00:07 +02:00
valknar 71e7e47aca feat: show all groups including unplayed, add upcoming matches per group
sync.ts: after computing standings from played matches, seed 0-0-0-0 rows
for every team in any group match, so all 12 groups always appear.

/groups: fetch all 2026 matches alongside standings; each group card now
shows results (score), live badge, and upcoming fixtures with local
kickoff time, sorted by UTC kickoff.

/tournaments/[year]: derive group list from union of standings + match
group names, so groups with no played matches still render with their
fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:47:52 +02:00
valknar 76425e7f76 fix: sort upcoming fixtures by UTC kickoff time, not venue local HH:MM
SPLIT_PART sort ignored UTC offsets — a match at 18:00 UTC-7 (01:00 UTC
next day) sorted before 12:00 UTC-4 (16:00 UTC same day). Now computes
the actual UTC timestamp from date + HH:MM + offset and orders by that.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:37:41 +02:00
valknar 015f6c2ef3 fix: derive upcoming fixture day label from computed local kickoff time
Previously used the stored venue date for Today/Tomorrow logic, which
gave wrong results when the UTC kickoff crossed midnight into the next
local day (e.g. 18:00 UTC-7 = 01:00 UTC next day). Now computes UTC
kickoff first, converts to the viewer's local time, and derives the
day label from that local Date object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:34:20 +02:00
valknar 47eb5092e9 fix: show proper date and local kickoff time in Upcoming Fixtures
formatKickoff() converts "HH:MM UTC-4" + ISO date into the viewer's
local timezone using Date.UTC arithmetic. Shows "Today", "Tomorrow", or
"Mon 16 Jun" as the day label, appended with the local kickoff time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:28:20 +02:00
valknar 7e4bf2d07c fix: retry failed group subpages, add rate-limit detection in scraper
- Detect Wikipedia plain-text rate-limit response ("You are making too many
  requests") and wait 30s before retrying, rather than silently failing
- Increase inter-attempt delay from 3s to 15s per attempt
- Increase group subpage delay from 1.2s to 3s, year delay from 0.6s to 2s
- Re-scrape 1982, 1998, 2002, 2006 which had failed groups; all groups now
  complete — e.g. 2002 now has 64 matches including Group E (Germany/Klose)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:44:54 +02:00
valknar d37ebe201e refactor: consolidate data/ into single root directory, fix historical player names
Merge data/wikipedia/{year}/ into data/{year}/ so there is a single
canonical location for World Cup JSON files. Update scrape and seed
scripts to use data/ instead of data/wikipedia/.

Re-scraped all 22 years (1930-2022) with fixed player name extraction
(full name from <a title="..."> rather than abbreviated display text)
so historical goals now show e.g. "Thomas Müller" not "Müller".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:27:35 +02:00
valknar 9ce2a4e27c fix: use full player names from title attr, preserve UTC offset in match times
Wikipedia abbreviates goal scorer display text (e.g. "Müller") but the
<a title="Thomas Müller"> attribute always has the full name. Switch
parseGoals() to prefer title attr and strip disambiguation suffixes like
"(soccer, born 1993)". This ensures Gerd Müller and Thomas Müller get
separate player pages.

Also preserve the UTC offset from Wikipedia's ftime (e.g. "12:00 UTC-4")
so that isLive() can accurately compute UTC kickoff time instead of
treating local time as UTC. upcomingMatches sorts by SPLIT_PART on the
HH:MM part to ignore the timezone suffix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:14:53 +02:00
valknar b141356247 refactor: replace hardcoded hex colors with theme tokens, move data/ to root
- Add --color-green-mid token (#4a7a55) to @theme for dimmer stat values
- Replace all text-[#hex]/bg-[#hex] arbitrary values with named tokens:
  text-green, text-green-light, text-green-sec, text-green-muted,
  text-green-dark, text-green-mid, text-text, bg-card, bg-bg, border-border
- Replace rgba(34,197,94,X) inline styles with bg-green/X opacity modifiers
- Convert single-prop style={{ borderColor/background }} to className
- Fix SVG stroke="#dff5e8" → stroke="currentColor"
- Use CSS variables in globals.css base styles (background-color, color)
- Move app/data/wikipedia/ → data/ (project root, not inside Next.js app dir)
- Update Dockerfile, seed.ts, scrape-wikipedia.ts paths accordingly
- Remove unused app/data/world_cup.csv

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:08:23 +02:00
valknar 187ee2e312 fix: parse Wikipedia 12h time format and sort upcoming matches with NULLS LAST
Wikipedia stores match times as "6:00 p.m." (1-digit hour) which didn't
match the \d{2}:\d{2} regex, producing NULL for those matches. Introduced
parseTime12h() to handle 1-2 digit hours + AM/PM and convert to 24h.
Also sort upcomingMatches by NULLS LAST so unscheduled games appear after
timed ones rather than first. Dropped "openfootball" data attribution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:50:30 +02:00
valknar 42063cdfda fix: extend group heading regex from [a-h] to [a-z] for 2026 Groups I-L
2026 FIFA World Cup has 12 groups (A-L). The previous regex only matched A-H,
causing Groups I, J, K, L to fall through undetected and collapse into Group H.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:39:38 +02:00
valknar 61c3c3f6cf fix: add --force flag to sync to clear 2026 data and orphaned teams
Needed to recover from duplicate team entries (Bosnia & Herzegovina / USA)
that persisted because ON CONFLICT matching is on team IDs, so old rows
with wrong team IDs are never updated. --force clears all 2026 data and
orphaned teams before re-syncing clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:37:15 +02:00
valknar b832b62f5e fix: normalize Bosnia & Herzegovina and USA team name variants
Add TEAM_ALIASES to lib/wiki-scraper.ts applied at extraction time so both
scraper and sync consistently produce canonical names. Removes the duplicate
alias map from seed.ts in favour of the shared normalizeTeam() export.

Aliases added:
  Bosnia & Herzegovina  → Bosnia and Herzegovina
  USA                   → United States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:33:05 +02:00
valknar f885e4312c refactor: extract lib/wiki-scraper.ts, make scraper composable, sync from Wikipedia
Move all scraping logic (fetchWikiHtml, scrapeYear, scrapeSquads and all
helpers) into lib/wiki-scraper.ts as exported functions shared by both scripts.

scrape-wikipedia.ts becomes a composable CLI:
  pnpm scrape [year]             — matches + squads (default)
  pnpm scrape [year] --matches   — matches/meta/stadiums only
  pnpm scrape [year] --squads    — squads only

sync.ts drops the openfootball GitHub dependency entirely and scrapes
Wikipedia directly. Incremental: completed groups (all matches have FT
scores) are detected via DB query and their sub-pages are skipped each run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:23:17 +02:00
valknar d1171267a8 feat: scrape tournament meta from Wikipedia, drop world_cup.csv
Add worldcup.meta.json per year with host, teams_count, winner, runner_up,
third_place, fourth_place — derived from match results (Final/Third-place
match) with infobox as fallback for edge cases like 1950's round-robin final.

Fix infobox host extraction to handle <br>-separated multi-host entries
(2002: Japan / South Korea). Fix squad scraper to filter out zero-player
phantom sections that Wikipedia appends (References, Captains, etc.).

Drop app/data/world_cup.csv and the PLACEMENTS/parseCsv code in seed.ts —
all tournament metadata now comes from the scraped JSON files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:09:45 +02:00
valknar ff4989f39f refactor: rename data/openfootball → data/wikipedia, drop data/kaggle
Move world_cup.csv to app/data/ directly (the only remaining Kaggle file
used by seed.ts for tournament metadata). Delete the rest of the Kaggle CSVs.
Update path constants in scrape-wikipedia.ts and seed.ts accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:10:21 +02:00
valknar 5dcd22ad22 feat: replace Kaggle CSV with Wikipedia scraper for historical match data
Add scripts/scrape-wikipedia.ts that fetches all 22 World Cups (1930–2022)
from English Wikipedia via MediaWiki API, handles group sub-pages, AET/penalty
detection, and goal parsing, writing openfootball-format JSON to app/data/openfootball/.

Rewrite scripts/seed.ts to read these local JSON files instead of the Kaggle
CSV, producing 965 matches and 2716 goals with per-group assignments for all
historical tournaments (enabling group standings on tournament pages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:39:53 +02:00
valknar 83b1ad3e35 fix: atomic goal updates in sync — transaction + bulk INSERT
Previously each match goal sync did: DELETE (auto-commit) → N
individual INSERTs (each auto-commit). During those ~50ms readers
saw 0 goals for the match — the inconsistency window.

Now: collectGoals() builds the rows in memory, replaceGoals() wraps
the DELETE + single bulk VALUES INSERT in a transaction. Under
Postgres READ COMMITTED, readers see the old goals until commit and
the full new set after — never an empty window.

Also drop sync pool from max:5 → max:2; the job is fully sequential
and was holding idle connections unnecessarily.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:56:43 +02:00
valknar 2c981dc6c0 fix: add non-null assertion for DATABASE_URL in sync.ts closure
TypeScript doesn't narrow module-level consts across closure
boundaries, so the explicit process.exit(1) guard isn't enough —
add ! assertion at the usage site inside run().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 08:56:11 +02:00
valknar 9f8f56ac4e fix: remove redundant DDL from sync.ts and validate DATABASE_URL
The DDL block in sync.ts was a "safety net" but caused misleading
password auth errors when Coolify's scheduled task ran without
DATABASE_URL injected — the fallback `wc:wc` password was wrong.

- Drop the silent `?? 'postgres://wc:wc@...'` fallback; exit with a
  clear message if DATABASE_URL is missing so the root cause is obvious
- Remove the 90-line CREATE TABLE IF NOT EXISTS block — seed.ts runs
  before the server starts and guarantees all tables exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 08:53:20 +02:00
valknar de03dfeadb fix: suppress Apollo cache warnings for Match.team1 / Match.team2
Different queries fetch Team with different field sets (some include slug,
others don't). merge: true tells InMemoryCache to combine fields rather
than replace, avoiding the "cache data may be lost" warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:18:47 +02:00
valknar 11a89204af feat: add Umami analytics via UMAMI_ID / UMAMI_SRC env vars
Script is injected with lazyOnload strategy and omitted entirely when
the env vars are not set, so dev and staging environments stay clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:04:41 +02:00
valknar 767236739b feat: add football net background pattern and glass card styling
Diagonal ±45° goal-net texture on body background. All card surfaces
converted from opaque #0a1810 to glass-card (backdrop-blur + semi-transparent
rgba) or glass-card-hero (gradient rgba) so the net pattern shows through.
Covers all pages: home, groups, history, search, stats, teams, tournaments,
players, match cards, and 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:01:40 +02:00
valknar 479c3d93e4 fix: constrain nav and footer content to max-w-[1200px] like main content
Nav keeps full-width background; inner content wrapped in max-w-[1200px]
mx-auto px-7 container to align with page content width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:46:33 +02:00
valknar ae46cbc44e feat: add footer with copyright and dev.pivoine.art link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:44:58 +02:00
valknar 1c73baf894 fix: remove unused \$name variable from PlayerGoalsByYear query
GraphQL validation rejected the operation because \$name was declared
but never referenced in the query body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:41:42 +02:00
valknar 0b26c59ceb fix: regenerate pnpm-lock.yaml after heroicons install, remove npm lockfile
@heroicons/react was installed with npm which created package-lock.json
instead of updating pnpm-lock.yaml. Docker build uses pnpm --frozen-lockfile
so the wrong lockfile caused build failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:34:49 +02:00
valknar 3cb619d7fa fix: include today's completed matches in recentMatches
lt(date, today) excluded same-day results once the live window closed.
Changed to lte so finished matches from today appear on the homepage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:26:25 +02:00
valknar c3ddb6e874 feat: replace emoji icons with Heroicons SVG set
Install @heroicons/react and replace all emoji usage across stats, history,
search, and team pages with proper SVG icons (outline style, w-3 to w-4).
SectionTitle in stats page refactored to accept an icon component prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:23:38 +02:00
valknar a6111d7beb fix: resolve Apollo cache warning for TeamStats embedded objects
Add merge: true policy for Team.stats so InMemoryCache merges partial
TeamStats selections (e.g. search page) with fuller ones (team/stats pages)
instead of replacing and losing fields. Also align stats page query to
include goalDiff for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:12:13 +02:00
valknar 6e6e819718 fix: scope team page scorers by teamId instead of filtering global top 200
Teams with few goals (e.g. Qatar) were missing from the sidebar because
topScorers(limit:200) only returned all-time top scorers. Now the query
filters by teamId in SQL so every team shows their own scorers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:11:16 +02:00
valknar 9b8e266f88 feat: team pages with match/tournament history, mobile padding fixes, linked scorers and nations
- Team page: add Tournament Participations (year pills → /tournaments/[year]) and
  Match History (grouped by year, W/D/L badge, opponent, score from team's perspective,
  PSO/AET annotations, each row → match anchor)
- GraphQL: extend matches() query with teamId filter (OR team1_id/team2_id)
- Match card: link team names to /teams/[slug]; fix ET score display — show scoreEt
  as headline for AET matches, scoreFt as footnote; winner determination uses
  scoreP ?? scoreEt ?? scoreFt
- Tournament page: scorer names below each match linked to /players/[name] with
  dotted underline (solid + green on hover)
- Stats page: reduce mobile padding on Goals chart, Top Scorers, Titles, Goals by
  Minute — hide progress bars and trophy emojis on small screens
- Homepage: Golden Boot Race same mobile padding/bar treatment; add slug to match
  team queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:07:56 +02:00
valknar f1b5328b78 fix: switch team table and confederation stats to proper table layout
Team table: overflow-x-auto wrapper + min-w-[560px] so flags and names
never collapse; columns are right-aligned numeric data, left-aligned team.
Confederation: replace CSS grid with <table> — browser handles column
alignment automatically, no more misalignment between header and rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:26:49 +02:00
valknar e5625bf759 fix: minimize favicon margins — trophy fills 96% of height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:23:03 +02:00
valknar 9077f3ec8b fix: remove background rect from favicon, transparent icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:21:13 +02:00
valknar 2b18fa1ebb fix: rebuild favicon with inlined vector paths, no image-embed blurriness
Inline the trophy paths directly into a 100×100 viewBox wrapper SVG.
Use the original inkscape layer viewBox (0 1002.3622 20 50) so the
paths render correctly without any transform. Strip inkscape/sodipodi
namespace attrs so rsvg-convert parses cleanly. Regenerate all PNGs
from this single vector source — no rasterization artefacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:20:21 +02:00
valknar 020fbd5bdf fix: rebuild favicon as proper square SVG with dark background
Old favicon.svg was 20×50 (portrait). New version is 100×100 viewBox
with #040d08 background and trophy centred (x=34,y=10,w=32,h=80).
Regenerate all PNGs from it via rsvg-convert. Remove unused Next.js
default public files (file.svg, globe.svg, next.svg, vercel.svg, window.svg).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:17:08 +02:00
valknar 238bbabbdb feat: add proper favicon, apple-touch-icon, webmanifest and PWA icons
Generate PNGs from the trophy SVG (dark #040d08 background, centred):
- favicon.svg       — primary, all modern browsers
- favicon-32x32.png — 32×32 fallback for older browsers
- apple-touch-icon.png — 180×180 for iOS home screen
- icon-192x192.png / icon-512x512.png — webmanifest / PWA

app/manifest.ts provides /manifest.webmanifest via Next.js file convention.
layout.tsx metadata wires up all icon sizes via the icons API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:13:52 +02:00
valknar 886523173b fix: use grid layout for full match card so teams stay in one row on mobile
Replaced flex-wrap with grid-cols-[1fr_auto_1fr] so team columns fill
equally on either side of the score. Score font scales down on mobile,
padding tightens, team names truncate instead of wrapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:10:55 +02:00
valknar ee1acb6e45 feat: add mobile hamburger menu with slide-down panel
Desktop nav unchanged. On mobile: hamburger animates to X on open,
panel slides down with nav links + search, backdrop dims the page,
menu closes on route change or backdrop tap, body scroll locked while open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:08:03 +02:00
valknar 05a75fffca fix: increase trophy icon height to 36px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:02:39 +02:00
valknar ffa8ec16c8 fix: constrain trophy icon height to 22px to match nav proportions
SVG is 20×50 (tall/narrow); fixed height with auto width preserves aspect ratio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:02:16 +02:00
valknar c9e1beafc7 feat: replace hand-drawn ball SVG with FIFA trophy icon in nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:01:28 +02:00
valknar 3eb36061e0 feat: replace inline SVG icon with FIFA World Cup trophy icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:00:17 +02:00
valknar 52b8348203 feat: add 404 page matching app design system
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:54:14 +02:00
valknar 85c40cf56e fix: update document.title on every page via useEffect
All pages are 'use client' so metadata exports don't work. Each page now
sets document.title via useEffect — static pages with a fixed string,
dynamic pages keyed on data so the title reflects the loaded content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:52:59 +02:00
valknar 32d33d2f92 fix: add data-scroll-behavior="smooth" to suppress Next.js warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:45:01 +02:00
valknar 2e284ec49e fix: show penalty score as headline result, FT score as footnote
For shootout games the FT score (e.g. 2–2) was the main display, which
was misleading. Now the penalty score is the headline (4–2) with
"2–2 a.e.t." below it. Winner highlighting also uses the penalty score.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:39:01 +02:00
valknar 25e440f5a4 fix: scroll to hash anchor after Apollo data loads on tournament page
The browser fires native hash-scroll before useQuery resolves, so the
target element doesn't exist yet. A useEffect keyed on data re-scrolls
once the matches are in the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:36:08 +02:00
valknar e4d9772c47 fix: add missing team name variants and defunct nations to iso-codes
Real teams missing from TEAM_ISO: Bosnia-Herzegovina (ba), Kosovo (xk),
New Caledonia (nc), Suriname (sr). Defunct/dissolved with no flag-icons
code: Serbia and Montenegro (cs retired), Zaire (zr retired) → null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:33:01 +02:00
valknar c98d45da79 fix: add Türkiye alias to iso-codes (openfootball uses new name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:31:13 +02:00
valknar 050f661e6d fix: match placeholder badge dimensions to flag-icons flag size
Setting fontSize on the container element caused em-based width/height to
collapse relative to the shrunken font size. Move font-size to an inner
span so the outer container stays at 1.33em × 1em — matching .fi dimensions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:28:12 +02:00
valknar 39985a5c71 fix: override stale DB iso2 with registry for known defunct nations
The DB still holds old codes (su, yu) from before the null mapping was
added. TeamFlag now checks TEAM_ISO by name first so the registry wins
over any stale value the DB returns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:25:55 +02:00
valknar b942ae7c8f fix: show placeholder badge for defunct nations with no flag-icons code
Soviet Union (su), Yugoslavia (yu), East Germany, Germany DR, FR Yugoslavia,
and Czechoslovakia have no valid entry in flag-icons. Map them to null in
TEAM_ISO so getIso() returns null, and render a muted initials badge in
TeamFlag instead of a broken/empty sprite. Also drop the buggy 2-char
substring fallback that generated random valid codes for unknown teams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 19:21:38 +02:00
valknar 3955c7492b feat: replace historical sync with Kaggle seed for complete 1930-2022 goal data
- scripts/seed.ts: one-time import of Kaggle FIFA dataset (matches_1930_2022.csv,
  world_cup.csv) covering all 964 matches and 2720 goals from 1930-2022 with full
  scorer names, minutes, penalties, and own goals for every tournament
- scripts/sync.ts: stripped to 2026 only (openfootball live data); historical years
  removed since Kaggle is now authoritative for 1930-2022
- Dockerfile: copy app/data into runner image; CMD runs seed.ts before server.js so
  a fresh deployment auto-seeds on first start (skips if already seeded)
- package.json: add 'seed' script; use --force to re-import from updated CSV files
- app/data/kaggle/: bundle Kaggle CSV files in repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:43:43 +02:00
valknar 191888225f fix: delete goals once per match instead of once per team in syncGoals()
syncGoals() was calling DELETE FROM goals WHERE match_id=X at the top,
so processing goals2 (away team) wiped out goals1 (home team) that were
just inserted. Every match with goals from both sides lost all home-team
goals — Ronaldo's hat-trick vs Spain, Kane's vs Panama, and many others.

Fix: move DELETE above the goals1/goals2 loop, executed once per match.
Result: 2018 goal count corrected from 107 → 169; hat tricks from 8 → 18.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:38:35 +02:00
valknar c418a51f08 chore: remove flags from location text on homepage and history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:30:12 +02:00
valknar 78340bd2db fix: convert match local time to UTC in isLive()
setHours() used the server's local timezone, so "15:00 UTC-5" on a UTC server
was treated as 15:00 UTC instead of 20:00 UTC — causing wrong live detection.

Now parses the UTC offset from the time string and converts to actual UTC
kickoff before comparing: UTC = local_time - offset (15:00 UTC-5 = 20:00 UTC).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:24:59 +02:00
valknar 42019e5035 fix: normalize West Germany → Germany in sync script and repair existing data
openfootball uses 'West Germany' for 1954–1990 era matches. All DB references
(matches, goals, group_standings, squads) have been merged into the Germany
team on both local and VPS. TEAM_ALIASES map prevents re-creation on re-sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:12:06 +02:00
valknar 48f7e71a8e fix: map total_goals → totalGoals in confederationStats resolver
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:06:22 +02:00
valknar 9926673ffd fix: add limit arg to Tournament.topScorers in GraphQL schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:05:31 +02:00
valknar 0eb0fb5ee4 fix: use percent-encoded DATABASE_URL instead of split DB_PASSWORD trick
Drizzle ORM mutates client.options (parsers/serializers) after the postgres
client is created, which causes the separately-passed password option to be
lost on the actual connection attempt. Root cause confirmed on VPS: raw
postgres.js query succeeded while drizzle.execute() failed with auth error.

Fix: encode the password directly in DATABASE_URL (%23 = #, %5D = ], %3D = =).
postgres.js decodes percent-encoding correctly. No separate DB_PASSWORD env
var needed in the app container anymore.

DB_PASSWORD is still used by the Postgres container (POSTGRES_PASSWORD).
Coolify env var to set: DATABASE_URL=postgres://wc:<encoded-pass>@db:5432/worldcup

Also adds resolver-level isMissingTable() guards so the app returns empty
results instead of GraphQL errors on a fresh deploy before sync runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:01:06 +02:00
158 changed files with 98831 additions and 2108 deletions
+10 -7
View File
@@ -1,7 +1,13 @@
# ── Production (Coolify) ──────────────────────────────────────────────────── # ── Production (Coolify) ────────────────────────────────────────────────────
# DB_PASSWORD is passed separately so special characters never need URL-encoding. # DATABASE_URL must include the password. Special characters must be
# DATABASE_URL is constructed inside docker-compose.yml and does NOT need to be # percent-encoded so the URL parser handles them correctly:
# set in Coolify — only DB_PASSWORD is required. # # → %23 ] → %5D = → %3D @ → %40
#
# Example with password "p#ss]w=rd":
# DATABASE_URL=postgres://wc:p%23ss%5Dw%3Drd@db:5432/worldcup
#
# DB_PASSWORD is used ONLY by the Postgres container (no encoding needed).
DATABASE_URL=postgres://wc:changeme@db:5432/worldcup
DB_PASSWORD=changeme DB_PASSWORD=changeme
# Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik) # Traefik (set TRAEFIK_ENABLED=true when deploying behind Traefik)
@@ -9,8 +15,5 @@ TRAEFIK_ENABLED=false
TRAEFIK_HOST=worldcup.example.com TRAEFIK_HOST=worldcup.example.com
NETWORK_NAME=traefik-network NETWORK_NAME=traefik-network
# ── Local development ──────────────────────────────────────────────────────── # ── Local development ────────────────────────────────────────────────────────
# Set DATABASE_URL when running pnpm dev or pnpm sync on the host directly.
# The password can be plain-text here since it goes through the postgres driver,
# not URL parsing, when DB_PASSWORD is unset.
# DATABASE_URL=postgres://wc:wc@localhost:5432/worldcup # DATABASE_URL=postgres://wc:wc@localhost:5432/worldcup
+2 -1
View File
@@ -22,7 +22,8 @@ COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/lib ./lib COPY --from=builder /app/lib ./lib
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/tsconfig.json ./tsconfig.json COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/data ./data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0" ENV PORT=3000 HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["sh", "-c", "node_modules/.bin/tsx scripts/seed.ts && node server.js"]
+121 -50
View File
@@ -1,13 +1,13 @@
# World Cup # 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. 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. Historical data is scraped from English Wikipedia and committed to the repo; live 2026 results are synced from Wikipedia on a schedule so scores appear within minutes of the final whistle.
## Features ## Features
- **Live 2026 matches** — detected automatically when today's date matches a scheduled fixture; Apollo polls every 60 seconds for score updates - **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 - **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 - **Group standings** — computed from match results for every tournament, with 0-row entries seeded so all groups appear even before any matches are played
- **Deep-linked pages** — every tournament, team, and player has a permanent URL (`/tournaments/1966`, `/teams/brazil`, `/players/Pelé`) - **Deep-linked pages** — every tournament, team, and player has a permanent URL (`/tournaments/1966`, `/teams/brazil`, `/players/Pelé`) with server-side metadata for SEO
- **Full-text search** — across teams, tournaments, and players - **Full-text search** — across teams, tournaments, and players
- **Squad data** — 26-man rosters for 2026 with position, shirt number, and date of birth - **Squad data** — 26-man rosters for 2026 with position, shirt number, and date of birth
- **Qualification playoffs** — 2026 inter-confederation playoff results stored separately - **Qualification playoffs** — 2026 inter-confederation playoff results stored separately
@@ -19,9 +19,9 @@ A full-stack World Cup statistics web app covering every tournament from 1930 to
| Route | Content | | Route | Content |
|---|---| |---|---|
| `/` | Home: live matches, stat pills, latest result, upcoming fixtures, Golden Boot race | | `/` | 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) | | `/groups` | All 12 group tables for 2026 (P/W/D/L/GD/Pts) with results and upcoming fixtures |
| `/stats` | Historical stats: goals chart, top scorers, hat-tricks, biggest wins, goals by minute, ET/shootout stats, confederation stats | | `/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 | | `/history` | All 24 tournament cards newest-first, each with host, winner, top scorer |
| `/search?q=…` | Full-text search across teams, players, tournaments | | `/search?q=…` | Full-text search across teams, players, tournaments |
| `/tournaments/[year]` | Tournament detail: group stage with standings + matches, knockout rounds, scorer sidebar | | `/tournaments/[year]` | Tournament detail: group stage with standings + matches, knockout rounds, scorer sidebar |
| `/teams/[slug]` | Team profile: all-time record, top scorers, WC appearances | | `/teams/[slug]` | Team profile: all-time record, top scorers, WC appearances |
@@ -41,26 +41,69 @@ A full-stack World Cup statistics web app covering every tournament from 1930 to
| Fonts | Bebas Neue + Space Grotesk (Google Fonts) | | Fonts | Bebas Neue + Space Grotesk (Google Fonts) |
| Container | Docker multi-stage build, Traefik-compatible | | Container | Docker multi-stage build, Traefik-compatible |
## Data sources ## Data pipeline
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: Data flows through three scripts that are run at different times and for different purposes.
| File | Content | Years available | ### 1. Scrape — one-time developer task
|---|---|---|
| `worldcup.json` | Matches, scores (FT/HT/ET/P), goal-scorer events | All (19302026) |
| `worldcup.teams.json` | Team details, FIFA codes, confederation | 20142026 |
| `worldcup.stadiums.json` | Stadium name, city, capacity, coordinates | 20142026 |
| `worldcup.groups.json` | Group compositions | 20142026 |
| `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 19301950, 1990, 2006, and 20142026. Match scores (used for standings, biggest wins, etc.) are complete for all years. ```bash
pnpm scrape # all years (19302022), matches + squads
pnpm scrape 2002 # single year
pnpm scrape 2002 --matches # matches, meta, stadiums, groups only
pnpm scrape 2002 --squads # squads only
```
Fetches structured match data from English Wikipedia using the [MediaWiki parse API](https://en.wikipedia.org/w/api.php) and writes JSON files to `data/{year}/`. These files are **committed to git** so the production build never needs to hit Wikipedia for historical data.
Each year produces up to five files:
| File | Content |
|---|---|
| `worldcup.json` | Matches with scores (FT/HT/ET/P) and goal-scorer events |
| `worldcup.meta.json` | Tournament metadata: host, winner, runner-up, team count |
| `worldcup.stadiums.json` | Stadium names and cities |
| `worldcup.groups.json` | Group compositions (teams per group) |
| `worldcup.squads.json` | Player rosters (where available on Wikipedia) |
The scraper has built-in rate-limit handling: it detects Wikipedia's plain-text `"You are making too many requests"` response, waits 30 seconds, and retries with exponential back-off (up to 6 attempts, 15 s × attempt delay between retries). Group sub-pages are fetched with a 3-second delay between requests.
### 2. Seed — initial database population
```bash
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm seed
DATABASE_URL="..." pnpm seed --force # drop and re-seed from scratch
```
Reads the committed `data/{year}/` JSON files and loads them into the database. Also creates all tables (if they do not exist). Intended for first-time setup and for re-seeding after schema changes. Covers **19302022 only** — 2026 data is handled by sync.
Seed is **idempotent** and skips silently if data is already present (unless `--force` is passed).
### 3. Sync — scheduled live updates (2026 only)
```bash
DATABASE_URL="..." pnpm sync # normal run
DATABASE_URL="..." pnpm sync --force # clear and re-fetch all 2026 data
```
Fetches the current state of the 2026 Wikipedia pages and upserts everything into the database. Historical years (19302022) are not touched — they come from the committed JSON files via seed.
What sync does on each run:
1. Fetches `2026_FIFA_World_Cup` via the MediaWiki API
2. Determines which groups are fully complete (all matches have FT scores) and skips their sub-pages to save requests
3. Upserts matches, scores, and goal events
4. Fetches `2026_FIFA_World_Cup_squads` and upserts squad rosters
5. Recomputes group standings from match results
6. Seeds 0-row standing entries for groups with no played matches yet (so all groups appear in the UI)
7. Updates tournament aggregates (total goals, matches played, avg goals/game)
Sync is designed to run on a **10-minute cron** in production. Each run is safe to repeat — all writes use `ON CONFLICT DO UPDATE`.
## Database schema ## Database schema
``` ```
tournaments year PK, host, winner, runner_up, third, fourth, tournaments year PK, host, winner, runner_up, third_place, fourth_place,
teams_count, matches_count, total_goals, avg_goals_per_game teams_count, matches_count, total_goals, avg_goals_per_game
teams id, name UNIQUE, iso2, fifa_code, continent, confederation teams id, name UNIQUE, iso2, fifa_code, continent, confederation
@@ -100,10 +143,13 @@ pnpm install
# 2. Start the database # 2. Start the database
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
# 3. Seed all 23 tournaments # 3. Seed historical data (19302022) from committed JSON files
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm seed
# 4. Sync 2026 data from Wikipedia
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm sync
# 4. Start the dev server # 5. Start the dev server
DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm dev DATABASE_URL="postgres://wc:wc@localhost:5432/worldcup" pnpm dev
``` ```
@@ -111,15 +157,25 @@ Open [http://localhost:3000](http://localhost:3000).
To stop the database: `docker compose -f docker-compose.dev.yml down` To stop the database: `docker compose -f docker-compose.dev.yml down`
If you need to re-scrape historical data (e.g. after a Wikipedia article correction):
```bash
pnpm scrape 2002 # re-scrape a single year
git add data/2002/ && git commit -m "chore: refresh 2002 scraped data"
```
## Environment variables ## Environment variables
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | Yes | PostgreSQL connection string | | `DATABASE_URL` | Yes | PostgreSQL connection string |
| `NEXT_PUBLIC_SITE_URL` | Production | Public base URL, e.g. `https://worldcup.example.com` — used for sitemap and OG metadata |
| `DB_PASSWORD` | Production | Password for the `wc` DB user (used by docker-compose.yml) | | `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_ENABLED` | Production | Set to `true` to activate Traefik router labels |
| `TRAEFIK_HOST` | Production | Public hostname, e.g. `worldcup.example.com` | | `TRAEFIK_HOST` | Production | Public hostname, e.g. `worldcup.example.com` |
| `NETWORK_NAME` | Production | Name of the external Docker network Traefik is attached to | | `NETWORK_NAME` | Production | Name of the external Docker network Traefik is attached to |
| `UMAMI_ID` | Optional | Umami analytics site ID |
| `UMAMI_SRC` | Optional | Umami analytics script URL |
Copy `.env.example` to `.env` and fill in the values before deploying. Copy `.env.example` to `.env` and fill in the values before deploying.
@@ -134,6 +190,7 @@ In Coolify's environment variable editor set:
``` ```
DB_PASSWORD=<strong-random-password> DB_PASSWORD=<strong-random-password>
DATABASE_URL=postgres://wc:<DB_PASSWORD>@db:5432/worldcup DATABASE_URL=postgres://wc:<DB_PASSWORD>@db:5432/worldcup
NEXT_PUBLIC_SITE_URL=https://worldcup.yourdomain.com
TRAEFIK_ENABLED=true TRAEFIK_ENABLED=true
TRAEFIK_HOST=worldcup.yourdomain.com TRAEFIK_HOST=worldcup.yourdomain.com
NETWORK_NAME=<your-traefik-network-name> NETWORK_NAME=<your-traefik-network-name>
@@ -143,12 +200,14 @@ NETWORK_NAME=<your-traefik-network-name>
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. 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 ### 3. Initial data load
After the first deployment run the sync once manually in Coolify's terminal: After the first deployment, seed historical data and then sync 2026:
```bash ```bash
docker compose exec app pnpm sync # In Coolify's terminal for the app container:
pnpm seed # loads 19302022 from committed JSON files
pnpm sync # fetches 2026 from Wikipedia
``` ```
### 4. Scheduled sync (live updates) ### 4. Scheduled sync (live updates)
@@ -161,34 +220,29 @@ In Coolify → your service → **Scheduled Tasks**, add:
| Schedule | `*/10 * * * *` | | Schedule | `*/10 * * * *` |
| Container | `app` | | 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. This re-syncs 2026 from Wikipedia every 10 minutes. 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 ## Project structure
``` ```
worldcup/ worldcup/
├── app/ ├── app/
│ ├── layout.tsx # Root layout: nav, fonts, Apollo provider │ ├── layout.tsx # Root layout: nav, fonts, Apollo provider, global metadata
│ ├── page.tsx # Home page │ ├── robots.ts # robots.txt (Next.js convention)
│ ├── groups/page.tsx # 2026 group standings │ ├── sitemap.ts # sitemap.xml — dynamic, rendered at request time
│ ├── stats/page.tsx # All-time statistics │ ├── page.tsx # Home — server wrapper (exports metadata)
│ ├── history/page.tsx # Tournament history cards │ ├── client.tsx # Home — Apollo/interactive client component
│ ├── search/page.tsx # Full-text search │ ├── groups/
│ ├── tournaments/[year]/page.tsx # Tournament detail │ ├── page.tsx # Groups — server wrapper
├── teams/[slug]/page.tsx # Team profile │ └── client.tsx # Groups — client component
│ ├── players/[name]/page.tsx # Player profile │ ├── stats/page.tsx + client.tsx
│ ├── history/page.tsx + client.tsx
│ ├── search/page.tsx + client.tsx
│ ├── tournaments/[year]/
│ │ ├── page.tsx # generateMetadata fetches tournament from DB
│ │ └── client.tsx # Tournament detail, group standings, bracket
│ ├── teams/[slug]/page.tsx + client.tsx
│ ├── players/[name]/page.tsx + client.tsx
│ └── api/graphql/route.ts # GraphQL Yoga endpoint │ └── api/graphql/route.ts # GraphQL Yoga endpoint
├── components/ ├── components/
│ ├── apollo-provider.tsx # Apollo Client provider wrapper │ ├── apollo-provider.tsx # Apollo Client provider wrapper
@@ -205,9 +259,20 @@ worldcup/
│ │ ├── resolvers/index.ts # All resolvers │ │ ├── resolvers/index.ts # All resolvers
│ │ ├── hooks.ts # Apollo v4 useQuery wrapper │ │ ├── hooks.ts # Apollo v4 useQuery wrapper
│ │ └── client.ts # Apollo Client factory │ │ └── client.ts # Apollo Client factory
│ ├── wiki-scraper.ts # Wikipedia HTML parser (cheerio), rate-limit retry
│ └── iso-codes.ts # Team name → ISO2 country code map │ └── iso-codes.ts # Team name → ISO2 country code map
├── scripts/ ├── scripts/
── sync.ts # Data sync script (all years, idempotent) ── scrape-wikipedia.ts # Developer-only: scrape Wikipedia → data/{year}/
│ ├── seed.ts # Initial DB load from data/{year}/ JSON files
│ └── sync.ts # Scheduled: sync 2026 live data from Wikipedia
├── data/
│ ├── 1930/ … 2022/ # Committed Wikipedia scrape output (per-year JSON)
│ └── {year}/
│ ├── worldcup.json # Matches + goals
│ ├── worldcup.meta.json # Tournament metadata
│ ├── worldcup.stadiums.json # Stadiums
│ ├── worldcup.groups.json # Group compositions
│ └── worldcup.squads.json # Squad rosters (where available)
├── docker-compose.yml # Production (Traefik + external network) ├── docker-compose.yml # Production (Traefik + external network)
├── docker-compose.dev.yml # Local dev (DB only, port 5432 exposed) ├── docker-compose.dev.yml # Local dev (DB only, port 5432 exposed)
├── Dockerfile # Multi-stage pnpm build ├── Dockerfile # Multi-stage pnpm build
@@ -219,15 +284,21 @@ worldcup/
## Architecture notes ## 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. **Live match detection** — A match is considered live when its date equals today and the current time falls within 5 minutes before kick-off to 125 minutes after. Kick-off times are stored as `"HH:MM UTC±N"` strings; the resolver computes the UTC timestamp at query time using PostgreSQL interval arithmetic. Apollo's `pollInterval: 60_000` re-queries `liveMatches` and `recentMatches` every minute.
**UTC kickoff ordering** — Both `upcomingMatches` (ascending) and `recentMatches` (descending) sort by computed UTC kickoff time using a `CASE` expression that parses the `time_local` string and subtracts the UTC offset as an interval. This ensures correct ordering across time zones — a match starting later in a westward timezone is not incorrectly ranked ahead of an earlier match with a higher database ID.
**Server/client split** — All pages use a server wrapper `page.tsx` that exports `metadata` (or `generateMetadata`) and a `client.tsx` that contains the Apollo query and interactive rendering. This lets Next.js generate accurate `<title>`, OpenGraph, and Twitter card tags for each route without requiring server-side data fetching in client components.
**`NEXT_PUBLIC_SITE_URL`** — The public hostname is read from this environment variable in `sitemap.ts`, `robots.ts`, and `layout.tsx` (`metadataBase`). All per-page `openGraph.url` values use relative paths (`/groups`, `/tournaments/2026`, etc.) which Next.js resolves against `metadataBase` automatically. The sitemap is marked `export const dynamic = 'force-dynamic'` so it runs at request time when the database is reachable, not at build time.
**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. **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. **Standalone Docker output**`next.config.ts` sets `output: 'standalone'` which produces a self-contained `server.js`. The `scripts/`, `lib/`, and `data/` directories are copied separately into the runner stage so `pnpm seed` and `pnpm sync` work 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. **Group standings**Standings are computed live from match results via a SQL `GROUP BY` query in the `groupStandings` resolver. After each sync, 0-row standing entries are inserted for all teams in all 2026 groups, ensuring every group appears in the UI even before its first match is played.
**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. **Wikipedia scraper rate limits** — The MediaWiki API occasionally returns a plain-text `"You are making too many requests to the API"` response instead of JSON. The scraper detects this by reading the response as text first, then parses JSON only if the body does not start with that phrase. On rate-limit (or HTTP 429), it waits 30 seconds before retrying. Retries use exponential back-off: 15 s × attempt number, up to 6 attempts per page.
## GraphQL API ## GraphQL API
+240
View File
@@ -0,0 +1,240 @@
'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 slug } team2 { name iso2 slug }
}
recentMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt scoreEt scoreP
team1 { name iso2 slug } team2 { name iso2 slug }
}
upcomingMatches(limit: 9) {
id year round group date time isLive isQualiPlayoff scoreFt
team1 { name iso2 slug } team2 { name iso2 slug }
}
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-green rounded-sm" />
<span className="text-[11px] text-green-muted 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 bg-green/5 border border-green/[12%]">
<div className="text-[9px] text-green-muted tracking-[0.13em] uppercase mb-1.5 whitespace-nowrap">{label}</div>
<div className="font-['Bebas_Neue'] text-[30px] text-green 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 formatKickoff(date: string | null | undefined, time: string | null | undefined): string {
if (!date) return ''
const today = new Date()
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1)
if (time) {
const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/)
if (m) {
const [y, mo, d] = date.split('-').map(Number)
const h = parseInt(m[1]), min = parseInt(m[2])
const offsetH = m[3] ? parseFloat(m[3]) : 0
// Compute UTC kickoff, then let the browser render in its local timezone
const local = new Date(Date.UTC(y, mo - 1, d, h - offsetH, min))
const isToday = local.toDateString() === today.toDateString()
const isTomorrow = local.toDateString() === tomorrow.toDateString()
const dayLabel = isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
const localTime = local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
return `${dayLabel} · ${localTime}`
}
}
// No time — fall back to venue date label only
const matchDate = new Date(date + 'T00:00:00')
const isToday = matchDate.toDateString() === today.toDateString()
const isTomorrow = matchDate.toDateString() === tomorrow.toDateString()
return isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: matchDate.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
}
function UpcomingFixture({ match }: { match: UpcomingMatch }) {
const label = formatKickoff(match.date, match.time)
return (
<Link href={`/tournaments/${match.year}#match-${match.id}`}>
<div className="glass-card rounded-[10px] p-3 px-4 flex items-center gap-2.5 hover:border-green/20 transition-colors cursor-pointer">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<div className="flex-1 text-[13px] text-green-sec font-medium truncate">
{match.team1.name} <span className="text-green-muted">vs</span> {match.team2.name}
</div>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
{label && <div className="text-[11px] text-green-muted whitespace-nowrap ml-1">{label}</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; slug?: string | null }
team2: { name: string; iso2?: string | null; slug?: string | null }
}
export function HomeClient() {
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 border-border" style={{
background: 'linear-gradient(145deg,rgba(10,26,14,0.9) 0%,rgba(13,36,22,0.9) 55%,rgba(10,26,14,0.9) 100%)',
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-green inline-block" />
<span className="text-[11px] font-bold text-green 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-green-muted text-sm mb-9">
USA · Canada · Mexico &nbsp;·&nbsp; 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 bg-green/[4%]" />
))}
</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="glass-card">
{scorers.map((s, i) => (
<Link key={s.playerName} href={`/players/${encodeURIComponent(s.playerName)}`}>
<div className={`flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-3 border-b border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted 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-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-24 h-1 rounded-full overflow-hidden flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green transition-all" style={{ width: `${(s.goals / maxGoals) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[24px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
<p className="text-[10px] text-green-dark mt-3 text-center">
<Link href="/stats" className="hover:text-green-muted">View all-time top scorers </Link>
</p>
</div>
)}
{loading && !data && (
<div className="py-16 text-center text-green-muted text-sm">Loading live World Cup data</div>
)}
</div>
</div>
)
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+37 -3
View File
@@ -1,6 +1,8 @@
@import "tailwindcss"; @import "tailwindcss";
@import "flag-icons/css/flag-icons.min.css"; @import "flag-icons/css/flag-icons.min.css";
@custom-variant hover (&:hover);
@theme { @theme {
--color-bg: #040d08; --color-bg: #040d08;
--color-card: #0a1810; --color-card: #0a1810;
@@ -10,6 +12,7 @@
--color-green-sec: #6abf7a; --color-green-sec: #6abf7a;
--color-green-muted: #2a5c35; --color-green-muted: #2a5c35;
--color-green-dark: #1a3a22; --color-green-dark: #1a3a22;
--color-green-mid: #4a7a55;
--color-text: #dff5e8; --color-text: #dff5e8;
--color-border: rgba(34,197,94,0.15); --color-border: rgba(34,197,94,0.15);
@@ -23,16 +26,47 @@
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { body {
background: #040d08; background-color: var(--color-bg);
color: #dff5e8; /* Diagonal goal-net pattern */
background-image:
repeating-linear-gradient(
-45deg,
rgba(34,197,94,0.028) 0, rgba(34,197,94,0.028) 1px,
transparent 1px, transparent 28px
),
repeating-linear-gradient(
45deg,
rgba(34,197,94,0.028) 0, rgba(34,197,94,0.028) 1px,
transparent 1px, transparent 28px
);
color: var(--color-text);
font-family: "Space Grotesk", system-ui, sans-serif; font-family: "Space Grotesk", system-ui, sans-serif;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
} }
/* Glass card — semi-transparent over the body net pattern */
.glass-card {
background: rgba(4, 18, 8, 0.78);
border: 1px solid var(--color-border);
border-radius: 1rem;
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.glass-card-hero {
background: linear-gradient(145deg, rgba(13,32,22,0.82), rgba(16,42,28,0.82));
border: 1px solid color-mix(in srgb, var(--color-green) 28%, transparent);
border-radius: 1rem;
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: #020a04; } ::-webkit-scrollbar-track { background: #020a04; }
::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.25); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--color-green) 25%, transparent); border-radius: 4px; }
@keyframes livePulse { @keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); } 0%, 100% { opacity: 1; transform: scale(1); }
+207
View File
@@ -0,0 +1,207 @@
'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 }
}
matches(year: 2026, isQuali: false) {
id group date time isLive scoreFt
team1 { name iso2 slug } team2 { 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 }
}
interface MatchRow {
id: number; group?: string | null; date?: string | null; time?: string | null
isLive: boolean; scoreFt?: number[] | null
team1: { name: string; iso2?: string | null; slug: string }
team2: { name: string; iso2?: string | null; slug: string }
}
function utcKickoff(date: string, time: string): number {
const m = time.match(/^(\d{2}):(\d{2})(?:\s+UTC([+-]\d+(?:\.\d+)?))?/)
if (!m) return new Date(date).getTime()
const [y, mo, d] = date.split('-').map(Number)
const offsetH = m[3] ? parseFloat(m[3]) : 0
return Date.UTC(y, mo - 1, d, parseInt(m[1]) - offsetH, parseInt(m[2]))
}
function formatKickoff(date: string, time: string | null | undefined): string {
if (!time) return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
const ms = utcKickoff(date, time)
const local = new Date(ms)
const today = new Date()
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1)
const isToday = local.toDateString() === today.toDateString()
const isTomorrow = local.toDateString() === tomorrow.toDateString()
const day = isToday ? 'Today' : isTomorrow ? 'Tomorrow'
: local.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
return `${day} · ${local.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`
}
export function GroupsClient() {
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 })
const standings: Standing[] = data?.groupStandings ?? []
const allMatches: MatchRow[] = data?.matches ?? []
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => {
acc[s.groupName] = [...(acc[s.groupName] ?? []), s]
return acc
}, {})
const matchesByGroup = allMatches
.filter(m => m.group)
.reduce<Record<string, MatchRow[]>>((acc, m) => {
acc[m.group!] = [...(acc[m.group!] ?? []), m]
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-green leading-none">2026 Groups</h1>
<p className="text-green-muted 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(300px,1fr))] gap-3.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-72 rounded-2xl animate-pulse bg-card" />
))}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,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 ', '')
const groupMatches = (matchesByGroup[groupName] ?? [])
.sort((a, b) => {
if (!a.date) return 1
if (!b.date) return -1
const ta = a.time ? utcKickoff(a.date, a.time) : new Date(a.date).getTime()
const tb = b.time ? utcKickoff(b.date!, b.time) : new Date(b.date!).getTime()
return ta - tb
})
const played = groupMatches.filter(m => m.scoreFt)
const upcoming = groupMatches.filter(m => !m.scoreFt && !m.isLive)
const live = groupMatches.filter(m => m.isLive)
return (
<div key={groupName} className="glass-card">
{/* Header */}
<div className="px-4 py-3 border-b border-green/10"
style={{ background: 'linear-gradient(90deg,color-mix(in srgb,var(--color-green) 12%,transparent) 0%,color-mix(in srgb,var(--color-green) 4%,transparent) 100%)' }}>
<span className="font-['Bebas_Neue'] text-[28px] text-green tracking-[0.05em]">GROUP {letter}</span>
</div>
{/* Standings */}
<div className="grid px-4 py-2 text-[9px] text-green-muted 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 border-green/[6%] hover:bg-green/[3%] transition-colors cursor-pointer ${idx < 2 ? 'bg-green/[2.5%]' : ''}`}
style={{ gridTemplateColumns: '1fr 22px 22px 22px 22px 22px 30px', gap: '3px' }}>
<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-text' : 'text-green-sec'}`}>{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-green-mid">{v}</span>
))}
<span className="text-center text-[13px] text-green-mid">
{t.goalDiff > 0 ? `+${t.goalDiff}` : t.goalDiff}
</span>
<span className="text-center text-[13px] font-bold text-green">{t.pts}</span>
</div>
</Link>
))}
{/* Live matches */}
{live.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1.5">
{live.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 hover:opacity-80">
<span className="text-[9px] font-bold text-green-light tracking-wider animate-pulse">LIVE</span>
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-[12px] text-text font-medium">{m.team1.name}</span>
<span className="text-[11px] text-green-muted mx-0.5">vs</span>
<span className="text-[12px] text-text font-medium">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Results */}
{played.length > 0 && (
<div className="border-t border-green/10 px-4 py-2.5 space-y-1">
{played.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="font-['Bebas_Neue'] text-[15px] text-green tabular-nums">
{m.scoreFt![0]}{m.scoreFt![1]}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<div className="border-t border-green/[6%] px-4 py-2.5 space-y-1">
{upcoming.map(m => (
<Link key={m.id} href={`/tournaments/2026#match-${m.id}`}>
<div className="flex items-center gap-2 py-1 text-[12px] hover:opacity-80">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<span className="text-green-sec truncate flex-1">{m.team1.name}</span>
<span className="text-[10px] text-green-muted whitespace-nowrap tabular-nums">
{m.date ? formatKickoff(m.date, m.time) : ''}
</span>
<span className="text-green-sec truncate flex-1 text-right">{m.team2.name}</span>
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
+11 -95
View File
@@ -1,100 +1,16 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { GroupsClient } from './client'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
const GROUPS_QUERY = gql` export const metadata: Metadata = {
query Groups { title: '2026 Group Stage',
groupStandings(year: 2026) { description: 'Live standings for all 12 groups at the 2026 FIFA World Cup — results, upcoming fixtures and qualification picture.',
groupName pos played won drawn lost goalsFor goalsAgainst goalDiff pts openGraph: {
team { id name iso2 slug } title: '2026 FIFA World Cup Group Stage',
} description: 'Live standings for all 12 groups at the 2026 FIFA World Cup.',
} url: '/groups',
` },
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() { export default function GroupsPage() {
const { data, loading } = useQuery(GROUPS_QUERY, { pollInterval: 60_000 }) return <GroupsClient />
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>
)
} }
+109
View File
@@ -0,0 +1,109 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { FireIcon, CalendarDaysIcon, TrophyIcon } from '@heroicons/react/24/outline'
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 }>
}
export function HistoryClient() {
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-green leading-none mb-2">
World Cup History
</h1>
<p className="text-green-muted 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 bg-card" />
))}
</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="glass-card p-5 relative cursor-pointer hover:border-green/30 transition-colors">
{/* Year watermark */}
<div className="absolute right-[-6px] bottom-[-18px] font-['Bebas_Neue'] text-[88px] leading-none pointer-events-none select-none text-green/[4%]">
{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-green leading-none">{t.year}</div>
<div className="text-xs text-green-muted mt-0.5">
{t.host}
</div>
</div>
{inProgress
? <div className="text-[10px] text-green font-bold tracking-[0.12em] bg-green/10 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-green-sec mt-0.5">{t.winner}</div>
</div>
)}
</div>
{!inProgress && t.winner && t.runnerUp && (
<div className="rounded-lg px-3 py-2 text-xs text-green-sec mb-3 bg-green/[7%]">
<span className="font-semibold text-text">{t.winner}</span>
<span className="mx-2 text-green-muted">def.</span>
{t.runnerUp}
</div>
)}
<div className="flex gap-3.5 text-[11px] text-green-muted flex-wrap">
{t.totalGoals != null && <span className="inline-flex items-center gap-1"><FireIcon className="w-3 h-3" />{t.totalGoals}</span>}
{t.matchesCount != null && <span className="inline-flex items-center gap-1"><CalendarDaysIcon className="w-3 h-3" />{t.matchesCount} games</span>}
{t.teamsCount != null && <span>🏳 {t.teamsCount} teams</span>}
</div>
{topScorer && (
<div className="mt-2 text-[10px] text-green-dark">
Golden Boot: <span className="text-green-muted">{topScorer.playerName} (<span className="inline-flex items-center gap-0.5"><FireIcon className="w-2.5 h-2.5 inline" />{topScorer.goals}</span>)</span>
</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
}
+11 -116
View File
@@ -1,121 +1,16 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { HistoryClient } from './client'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
const HISTORY_QUERY = gql` export const metadata: Metadata = {
query History { title: 'Tournament History',
tournaments { description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026 — hosts, winners, and key statistics.',
year host winner runnerUp thirdPlace fourthPlace openGraph: {
totalGoals matchesCount teamsCount avgGoalsPerGame title: 'FIFA World Cup Tournament History (19302026)',
topScorers(limit: 1) { playerName goals team { name iso2 } } description: 'Every FIFA World Cup from Uruguay 1930 to USA/Canada/Mexico 2026.',
} url: '/history',
} },
`
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() { export default function HistoryPage() {
const { data, loading } = useQuery(HISTORY_QUERY) return <HistoryClient />
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>
)
} }
+40 -4
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Bebas_Neue, Space_Grotesk } from 'next/font/google' import { Bebas_Neue, Space_Grotesk } from 'next/font/google'
import Script from 'next/script'
import './globals.css' import './globals.css'
import { Nav } from '@/components/nav' import { Nav } from '@/components/nav'
import { AppApolloProvider } from '@/components/apollo-provider' import { AppApolloProvider } from '@/components/apollo-provider'
@@ -7,21 +8,56 @@ import { AppApolloProvider } from '@/components/apollo-provider'
const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' }) const bebasNeue = Bebas_Neue({ weight: '400', subsets: ['latin'], variable: '--font-bebas' })
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' }) const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' })
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { default: 'World Cup', template: '%s · World Cup' }, metadataBase: new URL(BASE_URL),
description: 'Comprehensive World Cup statistics from 1930 to 2026', title: { default: 'World Cup Stats', template: '%s · World Cup' },
description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.',
keywords: ['World Cup', 'FIFA', 'football', 'soccer', 'statistics', 'live scores', 'standings', '2026'],
openGraph: {
type: 'website',
siteName: 'World Cup Stats',
url: '/',
title: 'World Cup Stats',
description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.',
},
twitter: {
card: 'summary',
title: 'World Cup Stats',
description: 'Live scores, group standings, results and statistics for every FIFA World Cup from 1930 to 2026.',
},
icons: { 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>", icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
}, },
} }
const umamiId = process.env.UMAMI_ID
const umamiSrc = process.env.UMAMI_SRC
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${bebasNeue.variable} ${spaceGrotesk.variable}`}> <html lang="en" data-scroll-behavior="smooth" className={`${bebasNeue.variable} ${spaceGrotesk.variable}`}>
<body> <body>
{umamiId && umamiSrc && (
<Script src={umamiSrc} data-website-id={umamiId} strategy="lazyOnload" />
)}
<AppApolloProvider> <AppApolloProvider>
<Nav /> <Nav />
<main className="pt-[60px] min-h-screen">{children}</main> <main className="pt-[60px] min-h-screen">{children}</main>
<footer className="border-t border-green/8 mt-8">
<div className="max-w-[1200px] mx-auto px-7 py-6 flex flex-col sm:flex-row items-center justify-between gap-2 text-[11px] text-green-dark">
<span>© {new Date().getFullYear()} World Cup Statistics.</span>
<a href="https://dev.pivoine.art" target="_blank" rel="noopener noreferrer"
className="text-green-muted hover:text-green transition-colors">
dev.pivoine.art
</a>
</div>
</footer>
</AppApolloProvider> </AppApolloProvider>
</body> </body>
</html> </html>
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'World Cup',
short_name: 'World Cup',
description: 'Comprehensive World Cup statistics from 1930 to 2026',
start_url: '/',
display: 'standalone',
background_color: '#040d08',
theme_color: '#040d08',
icons: [
{ src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' },
],
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = { title: '404 · World Cup' }
export default function NotFound() {
return (
<div className="max-w-[1200px] mx-auto px-7 py-20 flex flex-col items-center text-center">
<div
className="pitch-grid glass-card-hero rounded-2xl px-12 py-16 w-full max-w-lg"
>
<div className="font-['Bebas_Neue'] text-[120px] text-green leading-none">
404
</div>
<p className="text-green-sec text-lg mt-2 mb-8">
This page doesn&apos;t exist.
</p>
<Link
href="/"
className="inline-block font-['Bebas_Neue'] text-xl tracking-[0.1em] text-bg bg-green px-8 py-3 rounded-xl hover:bg-green-light transition-colors"
>
Back to Home
</Link>
</div>
</div>
)
}
+11 -211
View File
@@ -1,216 +1,16 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { HomeClient } from './client'
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` export const metadata: Metadata = {
query Home { title: 'World Cup 2026 — Live Scores, Groups & Stats',
tournamentStats { totalTournaments totalMatches totalGoals avgGoalsPerGame } description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup in USA, Canada & Mexico.',
liveMatches { openGraph: {
id year round group date time isLive scoreFt scoreEt scoreP isQualiPlayoff title: 'World Cup 2026 — Live Scores, Groups & Stats',
team1 { name iso2 } team2 { name iso2 } description: 'Live scores, group standings, upcoming fixtures and all-time top scorers for the 2026 FIFA World Cup.',
} url: '/',
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() { export default function HomePage() {
const { data, loading } = useQuery(HOME_QUERY, { pollInterval: 60_000 }) return <HomeClient />
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
&nbsp;·&nbsp; 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>
)
} }
+117
View File
@@ -0,0 +1,117 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use, useEffect } 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 function PlayerClient({ 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
useEffect(() => {
}, [player, name])
// Fetch all goals for this player broken down by year
const { data: goalsData } = useQuery(gql`
query PlayerGoalsByYear {
tournaments { year }
topScorers(limit: 1000) {
playerName goals team { id }
}
}
`)
if (loading && !data) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">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-green">{name}</h1>
<p className="text-green-muted mt-4">No goal data found for this player in World Cup history.</p>
<Link href="/stats" className="text-green 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 glass-card-hero rounded-2xl p-8 mb-8">
<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-green leading-none">{player.playerName}</h1>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green-sec text-sm mt-1 hover:text-text transition-colors inline-block">
{player.team.name}
</Link>
)}
</div>
<div className="ml-auto text-right">
<div className="font-['Bebas_Neue'] text-[80px] text-green leading-none">{player.goals}</div>
<div className="text-[10px] text-green-muted 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="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
{player.ownGoals > 0 && (
<div className="mb-6 glass-card rounded-xl p-3 px-4 text-sm text-green-muted">
Includes {player.ownGoals} own goal{player.ownGoals !== 1 ? 's' : ''}
</div>
)}
{/* Back links */}
<div className="flex gap-4 mt-8">
<Link href="/stats" className="text-green text-sm hover:underline"> All-time scorers</Link>
{player.team && (
<Link href={`/teams/${player.team.slug}`} className="text-green text-sm hover:underline">
{player.team.name} team page
</Link>
)}
</div>
</div>
)
}
+13 -110
View File
@@ -1,117 +1,20 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { PlayerClient } from './client'
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` type Props = { params: Promise<{ name: string }> }
query Player($name: String!) {
player(name: $name) {
playerName goals penalties ownGoals tournaments
team { id name iso2 slug }
}
}
`
const PLAYER_MATCHES_QUERY = gql` export async function generateMetadata({ params }: Props): Promise<Metadata> {
query PlayerMatches($name: String!) { const { name: encodedName } = await params
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 name = decodeURIComponent(encodedName)
const title = `${name} — World Cup Goals & Stats`
const { data, loading } = useQuery(PLAYER_QUERY, { variables: { name } }) const description = `${name}'s FIFA World Cup career: goals by tournament, match history and career statistics.`
const player: PlayerData | null = data?.player ?? null return {
title,
// Fetch all goals for this player broken down by year description,
const { data: goalsData } = useQuery(gql` openGraph: { title, description, url: `/players/${encodedName}` },
query PlayerGoalsByYear($name: String!) {
tournaments { year }
topScorers(limit: 1000) {
playerName goals team { id }
} }
} }
`, { variables: { name } })
if (loading && !data) { export default function PlayerPage({ params }: Props) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-[#2a5c35]">Loading player</div> return <PlayerClient params={params} />
}
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>
)
} }
+8
View File
@@ -0,0 +1,8 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/' },
sitemap: `${(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000').replace(/\/$/, '')}/sitemap.xml`,
}
}
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState, useEffect, Suspense } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { TrophyIcon, FireIcon } from '@heroicons/react/24/outline'
const SEARCH_QUERY = gql`
query Search($q: String!) {
search(query: $q) {
tournaments { year host winner totalGoals matchesCount }
teams { name iso2 slug stats { appearances titles } }
players { playerName goals tournaments team { name iso2 } }
matches {
id year round group date scoreFt isQualiPlayoff
team1 { name iso2 } team2 { name iso2 }
}
}
}
`
interface SearchMatch {
id: number; year: number; round: string; group?: string | null
date?: string | null; scoreFt?: number[] | null; isQualiPlayoff: boolean
team1: { name: string; iso2?: string | null }
team2: { name: string; iso2?: string | null }
}
function SearchContent() {
const searchParams = useSearchParams()
const router = useRouter()
const initialQ = searchParams.get('q') ?? ''
const [q, setQ] = useState(initialQ)
const [debouncedQ, setDebouncedQ] = useState(initialQ)
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQ(q)
if (q.trim()) router.replace(`/search?q=${encodeURIComponent(q.trim())}`, { scroll: false })
}, 300)
return () => clearTimeout(t)
}, [q, router])
useEffect(() => {
}, [q])
const skip = debouncedQ.trim().length < 2
const { data, loading } = useQuery(SEARCH_QUERY, {
variables: { q: debouncedQ },
skip,
})
const results = data?.search
const total = skip ? 0 : (
(results?.tournaments?.length ?? 0) +
(results?.teams?.length ?? 0) +
(results?.players?.length ?? 0) +
(results?.matches?.length ?? 0)
)
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
<h1 className="font-['Bebas_Neue'] text-[52px] tracking-[0.04em] text-green leading-none mb-6">Search</h1>
{/* Search input */}
<div className="relative max-w-lg mb-8">
<input
type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search teams, players, tournaments…"
autoFocus
className="w-full pl-10 pr-4 py-3 rounded-2xl text-text text-sm outline-none bg-green/[6%] border-green/20"
/>
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
{loading && <div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-green border-t-transparent rounded-full animate-spin" />}
</div>
{/* Prompt */}
{skip && (
<div className="flex flex-col items-center py-20 text-center">
<div className="text-[56px] mb-5">🔍</div>
<div className="text-green-muted text-base">Search for nations, players, or tournaments</div>
<div className="text-green-dark text-sm mt-2">Examples: "Brazil", "Ronaldo", "1966"</div>
</div>
)}
{/* No results */}
{!skip && !loading && total === 0 && (
<div className="text-center text-green-dark py-16 text-sm">No results for "{debouncedQ}"</div>
)}
{/* Results count */}
{!skip && total > 0 && (
<div className="text-[13px] text-green-muted mb-6">{total} result{total !== 1 ? 's' : ''} for "{debouncedQ}"</div>
)}
<div className="flex flex-col gap-6">
{/* Teams */}
{results?.teams?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Teams</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5">
{results.teams.map((t: { name: string; iso2?: string | null; slug: string; stats?: { appearances: number; titles: number } | null }) => (
<Link key={t.name} href={`/teams/${t.slug}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={t.name} iso2={t.iso2} size="md" />
<div>
<div className="text-sm font-semibold text-text">{t.name}</div>
<div className="text-[10px] text-green-muted">
{t.stats?.appearances ?? 0} WCs{t.stats?.titles ? <span className="inline-flex items-center gap-0.5 ml-1">· {t.stats.titles}<TrophyIcon className="w-3 h-3 inline" /></span> : ''}
</div>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Players */}
{results?.players?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Players</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-2.5">
{results.players.map((p: { playerName: string; goals: number; tournaments: number; team?: { name: string; iso2?: string | null } | null }) => (
<Link key={p.playerName} href={`/players/${encodeURIComponent(p.playerName)}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
{p.team && <TeamFlag name={p.team.name} iso2={p.team.iso2} size="sm" />}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-text truncate">{p.playerName}</div>
<div className="text-[10px] text-green-muted">{p.team?.name} · {p.tournaments} WC{p.tournaments !== 1 ? 's' : ''}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0 inline-flex items-center gap-0.5">{p.goals}<FireIcon className="w-3.5 h-3.5" /></span>
</div>
</Link>
))}
</div>
</section>
)}
{/* Tournaments */}
{results?.tournaments?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Tournaments</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
{results.tournaments.map((t: { year: number; host: string; winner?: string | null; totalGoals?: number | null; matchesCount?: number | null }) => (
<Link key={t.year} href={`/tournaments/${t.year}`}>
<div className="glass-card p-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<div className="font-['Bebas_Neue'] text-3xl text-green">{t.year}</div>
<div className="text-sm text-text">{t.host}</div>
{t.winner && <div className="text-[10px] text-green-muted mt-1 flex items-center gap-1"><TrophyIcon className="w-3 h-3 flex-shrink-0" />{t.winner}</div>}
{t.totalGoals && <div className="text-[10px] text-green-dark flex items-center gap-1"><FireIcon className="w-3 h-3 flex-shrink-0" />{t.totalGoals} goals</div>}
</div>
</Link>
))}
</div>
</section>
)}
{/* Matches */}
{results?.matches?.length > 0 && (
<section>
<h3 className="text-[11px] text-green-muted font-bold tracking-[0.12em] uppercase mb-3">Matches</h3>
<div className="flex flex-col gap-2">
{results.matches.map((m: SearchMatch) => (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className="glass-card flex items-center gap-3 p-3 px-4 rounded-xl hover:border-green/25 transition-colors cursor-pointer">
<TeamFlag name={m.team1.name} iso2={m.team1.iso2} size="sm" />
<div className="flex-1 text-sm text-text">{m.team1.name} vs {m.team2.name}</div>
{m.scoreFt && <span className="font-['Bebas_Neue'] text-lg text-green">{m.scoreFt[0]}{m.scoreFt[1]}</span>}
<TeamFlag name={m.team2.name} iso2={m.team2.iso2} size="sm" />
<div className="text-[10px] text-green-muted whitespace-nowrap">{m.year} · {m.round}</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
)
}
export function SearchClient() {
return (
<Suspense fallback={<div className="p-10 text-green-muted">Loading</div>}>
<SearchContent />
</Suspense>
)
}
+7 -188
View File
@@ -1,193 +1,12 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { SearchClient } from './client'
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` export const metadata: Metadata = {
query Search($q: String!) { title: 'Search',
search(query: $q) { description: 'Search for teams, players, tournaments and stadiums across all FIFA World Cups.',
tournaments { year host winner totalGoals matchesCount } robots: { index: false },
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() { export default function SearchPage() {
return ( return <SearchClient />
<Suspense fallback={<div className="p-10 text-[#2a5c35]">Loading</div>}>
<SearchContent />
</Suspense>
)
} }
+45
View File
@@ -0,0 +1,45 @@
import type { MetadataRoute } from 'next'
export const dynamic = 'force-dynamic'
import { db } from '@/lib/db'
import { tournaments, teams, goals } from '@/lib/db/schema'
import { asc } from 'drizzle-orm'
const BASE = (process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000').replace(/\/$/, '')
function slugify(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const now = new Date()
const [allTournaments, allTeams, allPlayers] = await Promise.all([
db.select({ year: tournaments.year }).from(tournaments).orderBy(asc(tournaments.year)),
db.select({ name: teams.name }).from(teams).orderBy(asc(teams.name)),
db.selectDistinct({ playerName: goals.playerName }).from(goals),
])
return [
{ url: BASE, lastModified: now, changeFrequency: 'hourly', priority: 1 },
{ url: `${BASE}/groups`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 },
{ url: `${BASE}/history`, changeFrequency: 'monthly', priority: 0.7 },
{ url: `${BASE}/stats`, changeFrequency: 'daily', priority: 0.7 },
...allTournaments.map(t => ({
url: `${BASE}/tournaments/${t.year}`,
changeFrequency: (t.year === 2026 ? 'hourly' : 'monthly') as 'hourly' | 'monthly',
priority: t.year === 2026 ? 0.95 : 0.6,
})),
...allTeams.map(t => ({
url: `${BASE}/teams/${slugify(t.name)}`,
changeFrequency: 'weekly' as const,
priority: 0.5,
})),
...allPlayers.map(p => ({
url: `${BASE}/players/${encodeURIComponent(p.playerName)}`,
changeFrequency: 'monthly' as const,
priority: 0.4,
})),
]
}
+371
View File
@@ -0,0 +1,371 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import {
ChartBarIcon, StarIcon, TrophyIcon, ClockIcon, BoltIcon,
FireIcon, SparklesIcon, ArrowPathIcon, GlobeEuropeAfricaIcon, TableCellsIcon,
} from '@heroicons/react/24/outline'
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 goalDiff 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, icon: Icon }: { children: React.ReactNode; icon: React.ComponentType<{ className?: string }> }) {
return (
<h2 className="flex items-center gap-1.5 text-[11px] font-bold tracking-[0.14em] uppercase text-green-muted mb-4">
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
{children}
</h2>
)
}
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`glass-card ${className}`}>
{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 function StatsClient() {
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-green leading-none mb-10">Historical Statistics</h1>
{loading && !data && (
<div className="text-green-muted text-sm py-16 text-center">Loading statistics</div>
)}
{/* ── Goals per tournament bar chart ── */}
{tournaments.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ChartBarIcon}>Goals Scored per Tournament</SectionTitle>
<Card>
<div className="px-3 pt-4 pb-0 sm:px-7 sm:pt-7">
<div className="flex items-end gap-[2px] sm: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-[8px] group">
<div className="text-[6px] sm:text-[7px] text-green-muted font-semibold mb-1 leading-none group-hover:text-green">{t.totalGoals}</div>
<div className="w-full rounded-t-sm border-t-2 border-green/45 transition-colors group-hover:bg-green/35 bg-green/[18%]"
style={{ height: `${h}px` }}
title={`${t.year}: ${t.totalGoals} goals${avg ? ` · ${avg}/game` : ''}`}
/>
</Link>
)
})}
</div>
<div className="flex gap-[2px] sm:gap-[3px] pt-1.5 pb-3 border-t border-green/[6%]">
{tournaments.map(t => (
<div key={t.year} className="flex-1 text-center text-[6px] text-green-dark" 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 icon={StarIcon}>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-2 sm:gap-3 px-3 sm:px-4 py-3 border-b hover:bg-green/[3%] cursor-pointer border-green/5 ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[11px] text-green-muted 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-text' : 'text-green-sec'}`}>{s.playerName}</div>
<div className="text-[10px] text-green-muted truncate">{s.team?.name} · {s.tournaments} WC{s.tournaments !== 1 ? 's' : ''}{s.penalties > 0 ? ` · ${s.penalties}P` : ''}</div>
</div>
<div className="hidden sm:block w-16 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-[22px] text-green min-w-[28px] text-right flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── World Cup titles ── */}
<div>
<SectionTitle icon={TrophyIcon}>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-2 sm:gap-3 px-3 sm:px-4 py-3.5 border-b border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<span className="text-[11px] text-green-muted 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 min-w-0 text-sm font-semibold text-text truncate">{t.name}</div>
<div className="hidden sm:flex gap-0.5 flex-shrink-0">
{Array.from({ length: t.stats?.titles ?? 0 }).map((_, j) => (
<TrophyIcon key={j} className="w-4 h-4 text-green" />
))}
</div>
<span className="font-['Bebas_Neue'] text-[28px] text-green flex-shrink-0">{t.stats?.titles}</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Goals by minute heatmap ── */}
{minuteBuckets.length > 0 && (
<div className="mb-12">
<SectionTitle icon={ClockIcon}>Goals by Minute (All-Time)</SectionTitle>
<Card>
<div className="px-3 py-4 sm:p-6">
<div className="flex items-end gap-1 sm: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">
<span className="text-[7px] sm:text-[9px] text-green-muted font-bold leading-none">{b.count}</span>
<div className="w-full rounded-t bg-green/30 border border-green/50" style={{ height: `${h}px` }} />
<span className="text-[7px] sm:text-[9px] text-green-dark leading-none">{b.bucket}</span>
</div>
)
})}
</div>
</div>
</Card>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* ── Biggest wins ── */}
<div>
<SectionTitle icon={BoltIcon}>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 border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<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-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-muted flex-shrink-0">+{m.margin}</span>
</div>
</Link>
))}
</Card>
</div>
{/* ── Highest scoring matches ── */}
<div>
<SectionTitle icon={FireIcon}>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 border-green/5 hover:bg-green/[3%] cursor-pointer"
>
<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-text truncate">{m.team1.name} vs {m.team2.name}</div>
<div className="text-[10px] text-green-muted">{m.year} · {m.round}</div>
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">
{m.scoreFt?.[0]}{m.scoreFt?.[1]}
</span>
<span className="text-[10px] text-green-light flex-shrink-0">{m.totalGoals} goals</span>
</div>
</Link>
))}
</Card>
</div>
</div>
{/* ── Hat-tricks ── */}
{hatTricks.length > 0 && (
<div className="mb-12">
<SectionTitle icon={SparklesIcon}>Hat-Tricks</SectionTitle>
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
{hatTricks.map((h, i) => (
<div key={i} className="glass-card rounded-xl p-4">
<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-text">{h.playerName}</div>
<div className="text-[10px] text-green-muted">{h.team?.name}</div>
</div>
<span className="ml-auto font-['Bebas_Neue'] text-2xl text-green">{h.goals}</span>
</div>
<div className="text-[10px] text-green-muted">
{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 icon={ArrowPathIcon}>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="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2">{s.label}</div>
<div className="font-['Bebas_Neue'] text-2xl text-green">{s.value}</div>
</div>
))}
</div>
</div>
)}
{/* ── Confederation stats ── */}
{confStats.length > 0 && (
<div className="mb-12">
<SectionTitle icon={GlobeEuropeAfricaIcon}>Performance by Confederation</SectionTitle>
<Card>
<table className="w-full">
<thead>
<tr className="border-b border-green/8">
<th className="text-left px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Confederation</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Appearances</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Titles</th>
<th className="text-right px-4 py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted">Goals</th>
</tr>
</thead>
<tbody>
{confStats.map(c => (
<tr key={c.confederation} className="border-t border-green/[6%]">
<td className="px-4 py-3 text-sm font-medium text-text">{c.confederation}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.appearances}</td>
<td className="px-4 py-3 text-right font-['Bebas_Neue'] text-xl text-green">{c.titles}</td>
<td className="px-4 py-3 text-right text-sm text-green-sec">{c.totalGoals}</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
)}
{/* ── All-time team table ── */}
{teams.length > 0 && (
<div>
<SectionTitle icon={TableCellsIcon}>All-Time Team Table</SectionTitle>
<Card>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: '560px' }}>
<thead>
<tr className="border-b border-green/8">
{['#', 'Team', 'WC', 'W', 'D', 'L', 'GF', 'GA', 'GD', 'Win%'].map((h, i) => (
<th key={h} className={`py-2 text-[9px] font-bold tracking-[0.1em] uppercase text-green-muted ${i === 0 ? 'pl-4 pr-2 text-left w-8' : i === 1 ? 'px-2 text-left' : 'px-2 text-right'}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{teams.slice(0, 40).map((t, i) => (
<tr key={t.id} className="border-t border-green/5 hover:bg-green/[3%]">
<td className="pl-4 pr-2 py-2.5 text-[11px] text-green-muted font-bold">{i + 1}</td>
<td className="px-2 py-2.5">
<Link href={`/teams/${t.slug}`} className="flex items-center gap-2">
<TeamFlag name={t.name} iso2={t.iso2} size="sm" />
<span className="text-sm text-text whitespace-nowrap">{t.name}</span>
</Link>
</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.appearances}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.wins}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.draws}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.losses}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsFor}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">{t.stats?.goalsAgainst}</td>
<td className="px-2 py-2.5 text-right text-sm text-green-mid">
{(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)}
</td>
<td className="px-2 pr-4 py-2.5 text-right text-[13px] font-bold text-green">{t.stats?.winPct}%</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
}
+11 -343
View File
@@ -1,348 +1,16 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { StatsClient } from './client'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
const STATS_QUERY = gql` export const metadata: Metadata = {
query Stats { title: 'All-Time Statistics',
tournaments { year host totalGoals matchesCount avgGoalsPerGame winner } description: 'All-time FIFA World Cup statistics: top scorers, hat-tricks, penalty records, biggest victories, and goals by tournament from 1930 to 2026.',
topScorers(limit: 20) { openGraph: {
playerName goals penalties ownGoals tournaments title: 'FIFA World Cup All-Time Statistics',
team { name iso2 slug } description: 'All-time World Cup statistics: top scorers, hat-tricks, records and more.',
url: '/stats',
},
} }
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() { export default function StatsPage() {
const { data, loading } = useQuery(STATS_QUERY) return <StatsClient />
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>
)
} }
+270
View File
@@ -0,0 +1,270 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use, useEffect } from 'react'
import Link from 'next/link'
import { TeamFlag } from '@/components/team-flag'
import { TrophyIcon } from '@heroicons/react/24/outline'
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($teamId: Int!) {
matches(teamId: $teamId, isQuali: false) {
id year round group date isLive scoreFt scoreEt scoreP
team1 { name iso2 slug } team2 { name iso2 slug }
}
}
`
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
}
interface MatchRow {
id: number; year: number; round: string; group?: string | null
date?: string | null; isLive: boolean
scoreFt?: number[] | null; scoreEt?: number[] | null; scoreP?: number[] | null
team1: { name: string; iso2?: string | null; slug?: string | null }
team2: { name: string; iso2?: string | null; slug?: string | null }
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
export function TeamClient({ 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
useEffect(() => {
}, [team])
const { data: matchesData } = useQuery(TEAM_MATCHES_QUERY, {
variables: { teamId: team?.id },
skip: !team?.id,
})
const { data: scorerData } = useQuery(gql`
query TeamScorers($teamId: Int!) {
topScorers(teamId: $teamId, limit: 30) {
playerName goals penalties ownGoals tournaments
team { id name iso2 }
}
}
`, { variables: { teamId: team?.id ?? 0 }, skip: !team?.id })
const teamScorers = scorerData?.topScorers ?? []
const teamMatches: MatchRow[] = matchesData?.matches ?? []
// Group matches by year for the history display
const matchesByYear = teamMatches.reduce((acc: Record<number, MatchRow[]>, m) => {
;(acc[m.year] ??= []).push(m)
return acc
}, {})
const years = Object.keys(matchesByYear).map(Number).sort((a, b) => b - a)
if (loading && !teamData) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Loading team</div>
}
if (!team) {
return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">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 glass-card-hero rounded-2xl p-8 mb-8">
<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-green leading-none">{team.name}</h1>
<div className="flex gap-3 mt-2 flex-wrap">
{team.fifaCode && <span className="text-[11px] text-green-muted font-bold tracking-wider">{team.fifaCode}</span>}
{team.confederation && <span className="text-[11px] text-green-muted">{team.confederation}</span>}
{team.continent && <span className="text-[11px] text-green-muted">{team.continent}</span>}
{(s?.titles ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-green font-bold">
{Array.from({ length: s?.titles ?? 0 }).map((_, i) => <TrophyIcon key={i} className="w-3.5 h-3.5" />)}
{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-green-muted 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="glass-card rounded-xl p-4">
<div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-1.5">{item.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{item.value}</div>
</div>
))}
</div>
<div className="glass-card rounded-xl">
<div className="grid px-4 py-2.5 text-[9px] text-green-muted 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 border-green/[6%] items-center"
style={{ gridTemplateColumns: '1fr 44px 44px 44px 60px 60px 60px' }}>
<div className="flex items-center gap-2">
<TeamFlag name={team.name} iso2={team.iso2} size="sm" />
<span className="text-sm text-text">{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-green-mid">{v}</span>
))}
<span className="text-center text-sm text-green-mid">{s.goalDiff >= 0 ? `+${s.goalDiff}` : s.goalDiff}</span>
</div>
</div>
</div>
)}
{/* Tournament participations */}
{years.length > 0 && (
<div className="mb-8">
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Tournament Participations</h2>
<div className="flex flex-wrap gap-2">
{years.map(year => (
<Link key={year} href={`/tournaments/${year}`}
className="font-['Bebas_Neue'] text-lg px-3 py-1 rounded-lg transition-colors text-green-sec bg-bg/[78%] border border-border hover:text-green hover:border-green/40 backdrop-blur-sm">
{year}
</Link>
))}
</div>
</div>
)}
{/* Match history by year */}
{years.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Match History</h2>
<div className="space-y-6">
{years.map(year => {
const yMatches = matchesByYear[year]
return (
<div key={year}>
<Link href={`/tournaments/${year}`}
className="inline-block font-['Bebas_Neue'] text-[22px] text-green mb-2 hover:opacity-70 transition-opacity">
{year}
</Link>
<div className="glass-card rounded-xl">
{yMatches.map((m, i) => {
const isHome = m.team1.name === team.name
const opponent = isHome ? m.team2 : m.team1
const ft = m.scoreFt
const scoreEt = m.scoreEt
const scoreP = m.scoreP
// Winner: PSO first, then ET, then FT
const decisive = scoreP ?? scoreEt ?? ft
const myScore = decisive ? (isHome ? decisive[0] : decisive[1]) : null
const theirScore = decisive ? (isHome ? decisive[1] : decisive[0]) : null
const result = myScore != null && theirScore != null
? myScore > theirScore ? 'W' : myScore < theirScore ? 'L' : 'D'
: null
const resultColor = result === 'W' ? 'text-green' : result === 'L' ? 'text-red-500' : 'text-green-sec'
// Display the decisive score (ET score for AET matches, FT for normal, PSO for shootouts)
const displayScore = scoreP ? null : (scoreEt ?? ft)
return (
<Link key={m.id} href={`/tournaments/${m.year}#match-${m.id}`}>
<div className={`flex items-center gap-3 px-3 sm:px-4 py-2.5 border-b hover:bg-green/[3%] transition-colors border-green/[6%] ${i % 2 !== 0 ? 'bg-green/[1%]' : ''}`}>
<span className={`text-[11px] font-bold w-4 flex-shrink-0 ${resultColor}`}>{result ?? ''}</span>
<TeamFlag name={opponent.name} iso2={opponent.iso2} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text truncate">{opponent.name}</div>
<div className="text-[10px] text-green-muted">
{m.round}{m.group ? ` · ${m.group}` : ''}{m.date ? ` · ${formatDate(m.date)}` : ''}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-['Bebas_Neue'] text-lg text-green leading-none">
{scoreP
? `${isHome ? scoreP[0] : scoreP[1]}${isHome ? scoreP[1] : scoreP[0]}`
: displayScore
? `${isHome ? displayScore[0] : displayScore[1]}${isHome ? displayScore[1] : displayScore[0]}`
: ''}
</div>
{scoreP && ft && (
<div className="text-[9px] text-green-muted leading-none">
{`${isHome ? ft[0] : ft[1]}${isHome ? ft[1] : ft[0]}`} a.e.t.
</div>
)}
{scoreEt && !scoreP && (
<div className="text-[9px] text-green-muted leading-none">a.e.t.</div>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Sidebar: top scorers */}
<div>
{teamScorers.length > 0 && (
<div>
<h2 className="text-[11px] text-green-muted font-bold tracking-[0.14em] uppercase mb-4">Top Scorers</h2>
<div className="glass-card">
{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-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted 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-text truncate">{sc.playerName}</div>
<div className="text-[10px] text-green-muted">
{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 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(sc.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{sc.goals}</span>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+20 -154
View File
@@ -1,162 +1,28 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { db } from '@/lib/db'
import { use } from 'react' import { teams } from '@/lib/db/schema'
import Link from 'next/link' import { TeamClient } from './client'
import { TeamFlag } from '@/components/team-flag'
import { MatchCard } from '@/components/match-card'
const TEAM_QUERY = gql` type Props = { params: Promise<{ slug: string }> }
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 { function slugify(name: string) {
id: number; name: string; iso2?: string | null; slug: string return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
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 }> }) { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = use(params) const { slug } = await params
const { data: teamData, loading } = useQuery(TEAM_QUERY, { variables: { slug } }) const allTeams = await db.select({ name: teams.name }).from(teams)
const team: TeamData | null = teamData?.team ?? null const team = allTeams.find(t => slugify(t.name) === slug)
const name = team?.name ?? slug
// Load all scorers to filter by team const title = `${name} at the FIFA World Cup`
const { data: scorerData } = useQuery(gql` const description = `${name} World Cup history — all matches, results, goals and top scorers across every tournament appearance.`
query TeamScorers { return {
topScorers(limit: 200) { title,
playerName goals penalties ownGoals tournaments description,
team { id name iso2 } openGraph: { title, description, url: `/teams/${slug}` },
} }
} }
`)
const allScorers = scorerData?.topScorers ?? [] export default function TeamPage({ params }: Props) {
const teamScorers = team ? allScorers.filter((s: { team?: { id: number } | null }) => s.team?.id === team.id).slice(0, 15) : [] return <TeamClient params={params} />
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>
)
} }
+287
View File
@@ -0,0 +1,287 @@
'use client'
import { useQuery, gql } from '@/lib/graphql/hooks'
import { use, useEffect } 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], i: number) => (
<span key={i}>
{i > 0 && <span className="mx-0.5">,</span>}
<Link href={`/players/${encodeURIComponent(g.playerName)}`}
className="underline decoration-dotted underline-offset-2 hover:text-green hover:decoration-solid transition-colors">
{g.playerName}
</Link>
{' '}{g.minute ?? ''}{g.minuteOffset ? `+${g.minuteOffset}` : ''}'{g.isPenalty ? ' (P)' : g.isOwnGoal ? ' (OG)' : ''}
</span>
)
return (
<div className="flex justify-between gap-4 px-4 pb-2 text-[10px] text-green-muted">
<div className="text-left">{t1Goals.map(renderGoal)}</div>
<div className="text-right">{t2Goals.map(renderGoal)}</div>
</div>
)
}
export function TournamentClient({ 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 })
useEffect(() => {
if (!data) return
const hash = window.location.hash
if (!hash) return
// double-rAF: first frame commits React's DOM, second frame lets the browser lay out
requestAnimationFrame(() => requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1))
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}))
}, [data])
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
}, {})
// Union of groups from standings + groups from match data (handles groups with no played matches yet)
const groupNames = new Set([
...Object.keys(byGroup),
...allMatches.filter(m => m.group).map(m => m.group!),
])
const groupRounds = [...groupNames].sort().map(g => [g, byGroup[g] ?? []] as [string, Standing[]])
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 bg-card" />
<div className="text-green-muted text-sm">Loading {year} World Cup</div>
</div>
)
}
if (!t) return <div className="max-w-[1200px] mx-auto px-7 py-10 text-green-muted">Tournament {year} not found.</div>
return (
<div className="max-w-[1200px] mx-auto px-7 py-10 pb-16">
{/* Header */}
<div className="pitch-grid glass-card-hero rounded-2xl p-8 mb-8">
{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-green leading-none">{year}</h1>
<p className="text-green-sec 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-text">{t.winner}</div>
{t.runnerUp && <div className="text-xs text-green-muted 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-green-muted tracking-[0.12em] uppercase">{s.label}</div>
<div className="font-['Bebas_Neue'] text-3xl text-green">{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-green-light mb-4">LIVE</h2>
<div className="flex flex-col gap-4">
{liveMatches.map(m => (
<div key={m.id} id={`match-${m.id}`} className="scroll-mt-20">
<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-green 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) => {
if (!a.date) return 1; if (!b.date) return -1
const cmp = a.date.localeCompare(b.date)
if (cmp !== 0) return cmp
return (a.time ?? '').localeCompare(b.time ?? '')
})
return (
<div key={groupName} className="mb-8">
<h3 className="text-[13px] font-bold text-green tracking-wide uppercase mb-3">{groupName}</h3>
{/* Standings mini */}
<div className="glass-card rounded-xl mb-3">
{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-green/[3%] cursor-pointer border-green/5 ${i < 2 ? 'bg-green/[2%]' : ''}`}>
<TeamFlag name={s.team.name} iso2={s.team.iso2} size="sm" />
<span className="flex-1 text-[13px] text-green-sec truncate">{s.team.name}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.played}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.won}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.drawn}</span>
<span className="text-[11px] text-green-mid w-6 text-center">{s.lost}</span>
<span className="text-[11px] font-bold text-green 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}`} className="scroll-mt-20">
<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-green mb-5">Knockout Stage</h2>
{Object.entries(koByRound).map(([round, roundMatches]) => (
<div key={round} className="mb-6">
<h3 className="text-[13px] font-bold text-green 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}`} className="scroll-mt-20">
<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-green mb-4">TOP SCORERS</h2>
<div className="glass-card">
{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-green/[3%] cursor-pointer border-green/[6%] ${i === 0 ? 'bg-green/[4%]' : ''}`}>
<span className="text-[10px] text-green-muted 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-text truncate">{s.playerName}</div>
{s.penalties > 0 && <div className="text-[9px] text-green-muted">{s.penalties} pen</div>}
</div>
<div className="w-12 h-1 rounded-full flex-shrink-0 bg-green/10">
<div className="h-full rounded-full bg-green" style={{ width: `${(s.goals / maxScorer) * 100}%` }} />
</div>
<span className="font-['Bebas_Neue'] text-xl text-green flex-shrink-0">{s.goals}</span>
</div>
</Link>
))}
</div>
{t.thirdPlace && (
<div className="glass-card mt-4 rounded-xl p-4">
<div className="text-[9px] text-green-muted 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-green-sec">{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-green-mid">{t.fourthPlace}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+20 -256
View File
@@ -1,262 +1,26 @@
'use client' import type { Metadata } from 'next'
import { useQuery, gql } from '@/lib/graphql/hooks' import { db } from '@/lib/db'
import { use } from 'react' import { tournaments } from '@/lib/db/schema'
import Link from 'next/link' import { eq } from 'drizzle-orm'
import { TeamFlag } from '@/components/team-flag' import { TournamentClient } from './client'
import { MatchCard } from '@/components/match-card'
import { LiveBadge } from '@/components/live-badge'
const TOURNAMENT_QUERY = gql` type Props = { params: Promise<{ year: string }> }
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 { export async function generateMetadata({ params }: Props): Promise<Metadata> {
id: number; year: number; round: string; group?: string | null const { year: yearStr } = await params
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 year = parseInt(yearStr)
const { data, loading } = useQuery(TOURNAMENT_QUERY, { variables: { year }, pollInterval: year === 2026 ? 60_000 : 0 }) const [t] = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1)
const title = `${year} FIFA World Cup`
const t = data?.tournament const description = t
const standings: Standing[] = data?.groupStandings ?? [] ? `${year} FIFA World Cup hosted by ${t.host}.${t.winner ? ` Winner: ${t.winner}.` : ''} Matches, scores, group standings and statistics.`
const byGroup = standings.reduce<Record<string, Standing[]>>((acc, s) => { : `${year} FIFA World Cup — matches, scores and statistics.`
acc[s.groupName] = [...(acc[s.groupName] ?? []), s] return {
return acc title,
}, {}) description,
openGraph: { title, description, url: `/tournaments/${year}` },
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> export default function TournamentPage({ params }: Props) {
return <TournamentClient params={params} />
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>
)
} }
+2 -2
View File
@@ -1,8 +1,8 @@
export function LiveBadge({ label = 'Live' }: { label?: string }) { export function LiveBadge({ label = 'Live' }: { label?: string }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#4ade80] flex-shrink-0 animate-live" /> <span className="w-2 h-2 rounded-full bg-green-light flex-shrink-0 animate-live" />
<span className="text-[11px] font-bold text-[#4ade80] tracking-[0.14em] uppercase">{label}</span> <span className="text-[11px] font-bold text-green-light tracking-[0.14em] uppercase">{label}</span>
</div> </div>
) )
} }
+66 -30
View File
@@ -2,7 +2,7 @@ import Link from 'next/link'
import { TeamFlag } from './team-flag' import { TeamFlag } from './team-flag'
import { LiveBadge } from './live-badge' import { LiveBadge } from './live-badge'
interface Team { name: string; iso2?: string | null } interface Team { name: string; iso2?: string | null; slug?: string | null }
interface Match { interface Match {
id: number id: number
year: number year: number
@@ -25,62 +25,98 @@ function formatDate(d: string) {
export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) { export function MatchCard({ match, compact = false }: { match: Match; compact?: boolean }) {
const ft = match.scoreFt const ft = match.scoreFt
const hasScore = ft != null const hasScore = ft != null
const winner = ft ? (ft[0] > ft[1] ? 'home' : ft[0] < ft[1] ? 'away' : 'draw') : null // Winner: penalties first, then ET, then FT
const decisive = match.scoreP ?? match.scoreEt ?? ft
const winner = decisive ? (decisive[0] > decisive[1] ? 'home' : decisive[0] < decisive[1] ? 'away' : 'draw') : null
if (compact) { if (compact) {
return ( return (
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block"> <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="glass-card rounded-xl p-3.5 hover:border-green/[22%] transition-colors">
<div className="text-[9px] text-[#2a5c35] tracking-[0.1em] uppercase mb-2.5"> <div className="text-[9px] text-green-muted tracking-[0.1em] uppercase mb-2.5">
{match.round}{match.group ? ` · ${match.group}` : ''} · {match.date ? formatDate(match.date) : ''} {match.round}{match.group ? ` · ${match.group}` : ''} · {match.date ? formatDate(match.date) : ''}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 overflow-hidden"> <div className="flex-1 flex items-center gap-2 overflow-hidden">
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" /> <TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="sm" />
<span className={`text-sm font-medium truncate ${winner === 'home' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}> <span className={`text-sm font-medium truncate ${winner === 'home' ? 'text-text' : 'text-green-mid'}`}>
{match.team1.name} {match.team1.name}
</span> </span>
</div> </div>
<div className="font-['Bebas_Neue'] text-xl text-[#22c55e] flex-shrink-0 min-w-[44px] text-center"> <div className="flex-shrink-0 min-w-[52px] text-center">
{hasScore ? `${ft![0]} ${ft![1]}` : match.isLive ? <LiveBadge label="•" /> : ''} <div className="font-['Bebas_Neue'] text-xl text-green">
{hasScore
? match.scoreP
? `${match.scoreP[0]} ${match.scoreP[1]}`
: match.scoreEt
? `${match.scoreEt[0]} ${match.scoreEt[1]}`
: `${ft![0]} ${ft![1]}`
: match.isLive ? '0 0' : ''}
</div>
{match.scoreP && (
<div className="text-[8px] text-green-muted leading-none">
{ft![0]}{ft![1]} a.e.t.
</div>
)}
{match.scoreEt && !match.scoreP && (
<div className="text-[8px] text-green-muted leading-none">a.e.t.</div>
)}
</div> </div>
<div className="flex-1 flex items-center justify-end gap-2 overflow-hidden"> <div className="flex-1 flex items-center justify-end gap-2 overflow-hidden">
<span className={`text-sm font-medium truncate ${winner === 'away' ? 'text-[#dff5e8]' : 'text-[#4a7a55]'}`}> <span className={`text-sm font-medium truncate ${winner === 'away' ? 'text-text' : 'text-green-mid'}`}>
{match.team2.name} {match.team2.name}
</span> </span>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" /> <TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="sm" />
</div> </div>
</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>} {match.scoreEt && !match.scoreP && (
<div className="text-[9px] text-green-muted mt-1 text-center">a.e.t.</div>
)}
</div> </div>
</Link> </Link>
) )
} }
const matchHref = `/tournaments/${match.year}#match-${match.id}`
return ( return (
<Link href={`/tournaments/${match.year}#match-${match.id}`} className="block"> <div className="glass-card-hero rounded-2xl px-5 py-6 sm:px-9 sm:py-9 hover:border-green/45 transition-colors">
<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>} {match.isLive && <div className="mb-4"><LiveBadge label="Live Now" /></div>}
<div className="flex items-center justify-center gap-8 flex-wrap"> <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 sm:gap-8">
<div className="text-center flex-1 min-w-[100px]"> <Link href={match.team1.slug ? `/teams/${match.team1.slug}` : matchHref}
<TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2.5" /> className={`text-center block transition-colors hover:text-green ${winner === 'home' ? 'text-text' : 'text-green-sec'}`}>
<div className="font-['Bebas_Neue'] text-xl tracking-[0.07em] text-[#dff5e8]">{match.team1.name}</div> <TeamFlag name={match.team1.name} iso2={match.team1.iso2} size="xl" className="mb-2" />
</div> <div className="font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate">
<div className="text-center flex-shrink-0"> {match.team1.name}
<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> </div>
</Link> </Link>
<Link href={matchHref} className="text-center flex-shrink-0 block">
<div className="font-['Bebas_Neue'] text-[48px] sm:text-[76px] text-green leading-none hover:opacity-80 transition-opacity">
{hasScore
? match.scoreP
? `${match.scoreP[0]}${match.scoreP[1]}`
: match.scoreEt
? `${match.scoreEt[0]}${match.scoreEt[1]}`
: `${ft![0]}${ft![1]}`
: match.isLive ? '00' : '??'}
</div>
{match.scoreP && (
<div className="text-[10px] text-green-muted mt-0.5">{ft![0]}{ft![1]} a.e.t.</div>
)}
{match.scoreEt && !match.scoreP && (
<div className="text-[10px] text-green-muted mt-0.5">{ft![0]}{ft![1]} (a.e.t.)</div>
)}
<div className="text-[9px] text-green-muted tracking-[0.12em] uppercase mt-1.5">{match.round}</div>
<div className="text-[10px] text-green-dark mt-0.5">{match.date ? formatDate(match.date) : ''}</div>
</Link>
<Link href={match.team2.slug ? `/teams/${match.team2.slug}` : matchHref}
className={`text-center block transition-colors hover:text-green ${winner === 'away' ? 'text-text' : 'text-green-sec'}`}>
<TeamFlag name={match.team2.name} iso2={match.team2.iso2} size="xl" className="mb-2" />
<div className="font-['Bebas_Neue'] text-base sm:text-xl tracking-[0.07em] truncate">
{match.team2.name}
</div>
</Link>
</div>
</div>
) )
} }
+89 -37
View File
@@ -1,25 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react' import { useState, useEffect } 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 = [ const NAV_LINKS = [
{ href: '/', label: 'Home' }, { href: '/', label: 'Home' },
@@ -32,44 +14,114 @@ export function Nav() {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
// Close menu on route change
useEffect(() => { setOpen(false) }, [pathname])
// Lock body scroll when menu open
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`) if (q.trim()) {
router.push(`/search?q=${encodeURIComponent(q.trim())}`)
setOpen(false)
setQ('')
}
} }
const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname.startsWith(href)
return ( 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)' }}> <nav
<Link href="/" className="flex items-center gap-2.5 mr-5 flex-shrink-0 cursor-pointer select-none"> className="fixed top-0 left-0 right-0 z-50 h-[60px] border-b border-green/[18%]"
{WC_BALL} style={{ background: 'rgba(4,13,8,0.97)', backdropFilter: 'blur(18px)' }}
<span className="font-['Bebas_Neue'] text-lg tracking-[3px] text-[#22c55e] whitespace-nowrap">WORLD CUP</span> >
<div className="max-w-[1200px] mx-auto px-7 h-full flex items-center">
{/* Logo */}
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 cursor-pointer select-none">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/favicon.svg" style={{ height: '36px', width: 'auto' }} alt="" />
<span className="font-['Bebas_Neue'] text-lg tracking-[3px] text-green whitespace-nowrap">WORLD CUP</span>
</Link> </Link>
<div className="flex gap-0.5 flex-1 min-w-0"> {/* Desktop links */}
{NAV_LINKS.map(({ href, label }) => { <div className="hidden md:flex gap-0.5 flex-1 min-w-0 ml-5">
const active = href === '/' ? pathname === '/' : pathname.startsWith(href) {NAV_LINKS.map(({ href, label }) => (
return (
<Link key={href} href={href} <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 className={`px-3.5 py-1.5 rounded-lg text-[13px] font-medium whitespace-nowrap transition-colors
${active ? 'bg-[rgba(34,197,94,0.12)] text-[#22c55e]' : 'text-[#4a7a55] hover:text-[#6abf7a]'}`}> ${isActive(href) ? 'bg-green/[12%] text-green' : 'text-green-mid hover:text-green-sec'}`}>
{label} {label}
</Link> </Link>
) ))}
})}
</div> </div>
<form onSubmit={handleSearch} className="relative flex-shrink-0 ml-2"> {/* Desktop search */}
<form onSubmit={handleSearch} className="relative flex-shrink-0 ml-auto hidden md:block">
<input <input
type="text" value={q} onChange={e => setQ(e.target.value)} type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search…" placeholder="Search…"
className="w-44 pl-8 pr-3.5 py-1.5 rounded-[20px] text-[13px] text-[#dff5e8] outline-none" className="w-44 pl-8 pr-3.5 py-1.5 rounded-[20px] text-[13px] text-text outline-none bg-green/[6%] border border-green/[18%]"
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"> <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="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /> <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</form> </form>
{/* Hamburger */}
<button
onClick={() => setOpen(o => !o)}
className={`ml-auto md:hidden flex flex-col justify-center items-center w-9 h-9 gap-[5px] rounded-lg transition-colors ${open ? 'bg-green/10' : ''}`}
aria-label="Menu"
>
<span className={`block w-5 h-[2px] bg-green rounded-full transition-all origin-center ${open ? 'rotate-45 translate-y-[7px]' : ''}`} />
<span className={`block w-5 h-[2px] bg-green rounded-full transition-all ${open ? 'opacity-0' : ''}`} />
<span className={`block w-5 h-[2px] bg-green rounded-full transition-all origin-center ${open ? '-rotate-45 -translate-y-[7px]' : ''}`} />
</button>
</div>
</nav> </nav>
{/* Mobile menu overlay */}
{open && (
<div
className="fixed inset-0 z-40 md:hidden"
style={{ background: 'rgba(4,13,8,0.6)', backdropFilter: 'blur(4px)', top: '60px' }}
onClick={() => setOpen(false)}
/>
)}
{/* Mobile menu panel */}
<div
className={`fixed left-0 right-0 z-40 md:hidden transition-all duration-200 border-b border-green/[18%] ${open ? 'top-[60px] opacity-100' : 'top-[48px] opacity-0 pointer-events-none'}`}
style={{ background: 'rgba(4,13,8,0.98)' }}
>
<div className="px-5 py-4 flex flex-col gap-1">
{NAV_LINKS.map(({ href, label }) => (
<Link key={href} href={href}
className={`px-4 py-3 rounded-xl text-[15px] font-medium transition-colors
${isActive(href) ? 'bg-green/[12%] text-green' : 'text-green-sec hover:bg-green/[6%]'}`}>
{label}
</Link>
))}
<form onSubmit={handleSearch} className="relative mt-3">
<input
type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search players, teams, tournaments…"
className="w-full pl-9 pr-4 py-3 rounded-xl text-[14px] text-text outline-none bg-green/[6%] border border-green/[18%]"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 opacity-30 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</form>
</div>
</div>
</>
) )
} }
+34 -2
View File
@@ -1,4 +1,4 @@
import { getIso } from '@/lib/iso-codes' import { TEAM_ISO, getIso } from '@/lib/iso-codes'
interface Props { interface Props {
name: string name: string
@@ -10,7 +10,39 @@ interface Props {
const sizes = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl', xl: 'text-[60px]' } const sizes = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl', xl: 'text-[60px]' }
export function TeamFlag({ name, iso2, size = 'md', className = '' }: Props) { export function TeamFlag({ name, iso2, size = 'md', className = '' }: Props) {
const code = iso2 ?? getIso(name) // If the name is in our registry, trust it over the DB value (which may be stale).
// For unknown teams, fall back to the DB iso2.
const code = name in TEAM_ISO ? TEAM_ISO[name] : (iso2 ?? getIso(name))
if (!code) {
const abbr = name
.split(/\s+/)
.map(w => w[0])
.join('')
.slice(0, 3)
.toUpperCase()
return (
// Outer span matches flag-icons dimensions: width=1.33em, height=1em relative
// to the Tailwind font-size class. fontSize must NOT be set here or em shrinks.
<span
className={`rounded-sm inline-flex items-center justify-center flex-shrink-0 ${sizes[size]} ${className}`}
style={{ width: '1.33em', height: '1em', background: 'rgba(42,92,53,0.35)' }}
title={name}
>
<span style={{
fontSize: '0.38em',
color: 'var(--color-green-sec)',
fontFamily: 'Space Grotesk, sans-serif',
fontWeight: 700,
letterSpacing: '0.04em',
lineHeight: 1,
}}>
{abbr}
</span>
</span>
)
}
return ( return (
<span <span
className={`fi fi-${code} rounded-sm inline-block flex-shrink-0 ${sizes[size]} ${className}`} className={`fi fi-${code} rounded-sm inline-block flex-shrink-0 ${sizes[size]} ${className}`}
+37
View File
@@ -0,0 +1,37 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"France",
"Mexico",
"Argentina",
"Chile"
]
},
{
"name": "Group 2",
"teams": [
"Yugoslavia",
"Brazil",
"Bolivia"
]
},
{
"name": "Group 3",
"teams": [
"Romania",
"Peru",
"Uruguay"
]
},
{
"name": "Group 4",
"teams": [
"United States",
"Belgium",
"Paraguay"
]
}
]
}
+605
View File
@@ -0,0 +1,605 @@
{
"matches": [
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-13",
"time": "15:00",
"team1": "France",
"team2": "Mexico",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Lucien Laurent",
"minute": 19
},
{
"name": "Marcel Langiller",
"minute": 40
},
{
"name": "André Maschinot",
"minute": 43
},
{
"name": "André Maschinot",
"minute": 87
}
],
"goals2": [
{
"name": "Juan Carreño",
"minute": 70
}
],
"ground": "Estadio Pocitos, Montevideo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-15",
"time": "16:00",
"team1": "Argentina",
"team2": "France",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Luis Monti",
"minute": 81
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-16",
"time": "14:45",
"team1": "Chile",
"team2": "Mexico",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Carlos Vidal",
"minute": 3
},
{
"name": "Carlos Vidal",
"minute": 65
},
{
"name": "Manuel Rosas",
"minute": 52,
"owngoal": true
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-19",
"time": "12:50",
"team1": "Chile",
"team2": "France",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Guillermo Subiabre",
"minute": 67
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-19",
"time": "15:00",
"team1": "Argentina",
"team2": "Mexico",
"score": {
"ft": [
6,
3
]
},
"goals1": [
{
"name": "Guillermo Stábile",
"minute": 8
},
{
"name": "Guillermo Stábile",
"minute": 17
},
{
"name": "Guillermo Stábile",
"minute": 80
},
{
"name": "Adolfo Zumelzú",
"minute": 12
},
{
"name": "Adolfo Zumelzú",
"minute": 55
},
{
"name": "Francisco Varallo",
"minute": 53
}
],
"goals2": [
{
"name": "Manuel Rosas",
"minute": 42,
"penalty": true
},
{
"name": "Manuel Rosas",
"minute": 65
},
{
"name": "Roberto Gayón",
"minute": 75
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1930-07-22",
"time": "14:45",
"team1": "Argentina",
"team2": "Chile",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Guillermo Stábile",
"minute": 12
},
{
"name": "Guillermo Stábile",
"minute": 13
},
{
"name": "Mario Evaristo",
"minute": 51
}
],
"goals2": [
{
"name": "Guillermo Subiabre",
"minute": 15
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1930-07-14",
"time": "12:45",
"team1": "Yugoslavia",
"team2": "Brazil",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Aleksandar Tirnanić",
"minute": 21
},
{
"name": "Ivan Bek",
"minute": 30
}
],
"goals2": [
{
"name": "Preguinho",
"minute": 62
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1930-07-17",
"time": "12:45",
"team1": "Yugoslavia",
"team2": "Bolivia",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Ivan Bek",
"minute": 60
},
{
"name": "Ivan Bek",
"minute": 67
},
{
"name": "Blagoje Marjanović",
"minute": 65
},
{
"name": "Đorđe Vujadinović",
"minute": 85
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1930-07-20",
"time": "13:00",
"team1": "Brazil",
"team2": "Bolivia",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Moderato Wisintainer",
"minute": 37
},
{
"name": "Moderato Wisintainer",
"minute": 73
},
{
"name": "Preguinho",
"minute": 57
},
{
"name": "Preguinho",
"minute": 83
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1930-07-14",
"time": "14:50",
"team1": "Romania",
"team2": "Peru",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Adalbert Deșu",
"minute": 1
},
{
"name": "Constantin Stanciu",
"minute": 79
},
{
"name": "Miklós Kovács",
"minute": 89
}
],
"goals2": [
{
"name": "Luis de Souza",
"minute": 75
}
],
"ground": "Estadio Pocitos, Montevideo"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1930-07-18",
"time": "14:30",
"team1": "Uruguay",
"team2": "Peru",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Héctor Castro",
"minute": 65
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1930-07-21",
"time": "14:50",
"team1": "Uruguay",
"team2": "Romania",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Pablo Dorado",
"minute": 7
},
{
"name": "Héctor Scarone",
"minute": 26
},
{
"name": "Peregrino Anselmo",
"minute": 31
},
{
"name": "Pedro Cea",
"minute": 35
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1930-07-13",
"time": "15:00",
"team1": "United States",
"team2": "Belgium",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Bart McGhee",
"minute": 23
},
{
"name": "Tom Florie",
"minute": 45
},
{
"name": "Bert Patenaude",
"minute": 69
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1930-07-17",
"time": "14:45",
"team1": "United States",
"team2": "Paraguay",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Bert Patenaude",
"minute": 10
},
{
"name": "Bert Patenaude",
"minute": 15
},
{
"name": "Bert Patenaude",
"minute": 50
}
],
"ground": "Estadio Parque Central, Montevideo"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1930-07-20",
"time": "15:00",
"team1": "Paraguay",
"team2": "Belgium",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Luis Vargas Peña",
"minute": 40
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Semi-finals",
"date": "1930-07-26",
"time": "14:45",
"team1": "Argentina",
"team2": "United States",
"score": {
"ft": [
6,
1
]
},
"goals1": [
{
"name": "Luis Monti",
"minute": 20
},
{
"name": "Alejandro Scopelli",
"minute": 56
},
{
"name": "Guillermo Stábile",
"minute": 69
},
{
"name": "Guillermo Stábile",
"minute": 87
},
{
"name": "Carlos Peucelle",
"minute": 80
},
{
"name": "Carlos Peucelle",
"minute": 85
}
],
"goals2": [
{
"name": "Jim Brown",
"minute": 89
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Semi-finals",
"date": "1930-07-27",
"time": "14:45",
"team1": "Uruguay",
"team2": "Yugoslavia",
"score": {
"ft": [
6,
1
]
},
"goals1": [
{
"name": "Pedro Cea",
"minute": 18
},
{
"name": "Pedro Cea",
"minute": 67
},
{
"name": "Pedro Cea",
"minute": 72
},
{
"name": "Peregrino Anselmo",
"minute": 20
},
{
"name": "Peregrino Anselmo",
"minute": 31
},
{
"name": "Santos Iriarte",
"minute": 61
}
],
"goals2": [
{
"name": "Đorđe Vujadinović",
"minute": 4
}
],
"ground": "Estadio Centenario, Montevideo"
},
{
"round": "Final",
"date": "1930-07-30",
"time": "12:45",
"team1": "Uruguay",
"team2": "Argentina",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Pablo Dorado",
"minute": 12
},
{
"name": "Pedro Cea",
"minute": 57
},
{
"name": "Santos Iriarte",
"minute": 68
},
{
"name": "Héctor Castro",
"minute": 89
}
],
"goals2": [
{
"name": "Carlos Peucelle",
"minute": 20
},
{
"name": "Guillermo Stábile",
"minute": 37
}
],
"ground": "Estadio Centenario, Montevideo"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Uruguay",
"teams_count": 13,
"winner": "Uruguay",
"runner_up": "Argentina",
"third_place": "United States",
"fourth_place": "Yugoslavia"
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"stadiums": [
{
"name": "Estadio Pocitos",
"city": "Montevideo"
},
{
"name": "Estadio Parque Central",
"city": "Montevideo"
},
{
"name": "Estadio Centenario",
"city": "Montevideo"
}
]
}
+601
View File
@@ -0,0 +1,601 @@
{
"matches": [
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Spain",
"team2": "Brazil",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "José Iraragorri",
"minute": 18,
"penalty": true
},
{
"name": "José Iraragorri",
"minute": 25
},
{
"name": "Isidro Lángara",
"minute": 29
}
],
"goals2": [
{
"name": "Leônidas",
"minute": 55
}
],
"ground": "Stadio Luigi Ferraris, Genoa"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Hungary",
"team2": "Egypt",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Pál Teleki",
"minute": 11
},
{
"name": "Géza Toldi",
"minute": 31
},
{
"name": "Géza Toldi",
"minute": 61
},
{
"name": "Jenő Vincze",
"minute": 53
}
],
"goals2": [
{
"name": "Abdulrahman Fawzi",
"minute": 35
},
{
"name": "Abdulrahman Fawzi",
"minute": 39
}
],
"ground": "Stadio Giorgio Ascarelli, Naples"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Switzerland",
"team2": "Netherlands",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Leopold Kielholz",
"minute": 7
},
{
"name": "Leopold Kielholz",
"minute": 43
},
{
"name": "André Abegglen",
"minute": 66
}
],
"goals2": [
{
"name": "Kick Smit",
"minute": 29
},
{
"name": "Leen Vente",
"minute": 69
}
],
"ground": "Stadio San Siro, Milan"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Italy",
"team2": "United States",
"score": {
"ft": [
7,
1
]
},
"goals1": [
{
"name": "Angelo Schiavio",
"minute": 18
},
{
"name": "Angelo Schiavio",
"minute": 29
},
{
"name": "Angelo Schiavio",
"minute": 64
},
{
"name": "Raimundo Orsi",
"minute": 20
},
{
"name": "Raimundo Orsi",
"minute": 69
},
{
"name": "Giovanni Ferrari",
"minute": 63
},
{
"name": "Giuseppe Meazza",
"minute": 90
}
],
"goals2": [
{
"name": "Aldo Donelli",
"minute": 57
}
],
"ground": "Stadio Nazionale PNF, Rome"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Czechoslovakia",
"team2": "Romania",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Antonín Puč",
"minute": 50
},
{
"name": "Oldřich Nejedlý",
"minute": 67
}
],
"goals2": [
{
"name": "Ștefan Dobay",
"minute": 11
}
],
"ground": "Stadio Littorio, Trieste"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Sweden",
"team2": "Argentina",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Sven Jonasson",
"minute": 9
},
{
"name": "Sven Jonasson",
"minute": 67
},
{
"name": "Knut Kroon",
"minute": 79
}
],
"goals2": [
{
"name": "Ernesto Belis",
"minute": 4
},
{
"name": "Alberto Galateo",
"minute": 48
}
],
"ground": "Stadio Littoriale, Bologna"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Austria",
"team2": "France",
"score": {
"ft": [
1,
1
],
"et": [
3,
2
]
},
"goals1": [
{
"name": "Matthias Sindelar",
"minute": 44
},
{
"name": "Anton Schall",
"minute": 93
},
{
"name": "Josef Bican",
"minute": 109
}
],
"goals2": [
{
"name": "Jean Nicolas",
"minute": 18
},
{
"name": "Georges Verriest",
"minute": 116,
"penalty": true
}
],
"ground": "Stadio Benito Mussolini, Turin"
},
{
"round": "Round of 16",
"date": "1934-05-27",
"time": "16:00",
"team1": "Germany",
"team2": "Belgium",
"score": {
"ft": [
5,
2
]
},
"goals1": [
{
"name": "Stanislaus Kobierski",
"minute": 25
},
{
"name": "Otto Siffling",
"minute": 49
},
{
"name": "Edmund Conen",
"minute": 66
},
{
"name": "Edmund Conen",
"minute": 70
},
{
"name": "Edmund Conen",
"minute": 87
}
],
"goals2": [
{
"name": "Bernard Voorhoof",
"minute": 29
},
{
"name": "Bernard Voorhoof",
"minute": 43
}
],
"ground": "Stadio Giovanni Berta, Florence"
},
{
"round": "Quarter-finals",
"date": "1934-05-31",
"time": "16:30",
"team1": "Austria",
"team2": "Hungary",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Johann Horvath",
"minute": 8
},
{
"name": "Karl Zischek",
"minute": 51
}
],
"goals2": [
{
"name": "György Sárosi",
"minute": 60,
"penalty": true
}
],
"ground": "Stadio Littoriale, Bologna"
},
{
"round": "Quarter-finals",
"date": "1934-05-31",
"time": "16:30",
"team1": "Italy",
"team2": "Spain",
"score": {
"ft": [
1,
1
],
"et": [
1,
1
]
},
"goals1": [
{
"name": "Giovanni Ferrari",
"minute": 44
}
],
"goals2": [
{
"name": "Luis Regueiro",
"minute": 30
}
],
"ground": "Stadio Giovanni Berta, Florence"
},
{
"round": "Quarter-finals",
"date": "1934-05-31",
"time": "16:30",
"team1": "Germany",
"team2": "Sweden",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Karl Hohmann",
"minute": 60
},
{
"name": "Karl Hohmann",
"minute": 63
}
],
"goals2": [
{
"name": "Gösta Dunker",
"minute": 82
}
],
"ground": "Stadio San Siro, Milan"
},
{
"round": "Quarter-finals",
"date": "1934-05-31",
"time": "16:30",
"team1": "Czechoslovakia",
"team2": "Switzerland",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "František Svoboda",
"minute": 24
},
{
"name": "Jiří Sobotka",
"minute": 49
},
{
"name": "Oldřich Nejedlý",
"minute": 82
}
],
"goals2": [
{
"name": "Leopold Kielholz",
"minute": 18
},
{
"name": "Willy Jäggi",
"minute": 78
}
],
"ground": "Stadio Benito Mussolini, Turin"
},
{
"round": "Quarter-finals",
"date": "1934-06-01",
"time": "16:30",
"team1": "Italy",
"team2": "Spain",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Giuseppe Meazza",
"minute": 11
}
],
"ground": "Stadio Giovanni Berta, Florence"
},
{
"round": "Semi-finals",
"date": "1934-06-03",
"time": "16:30",
"team1": "Italy",
"team2": "Austria",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Enrique Guaita",
"minute": 19
}
],
"ground": "Stadio San Siro, Milan"
},
{
"round": "Semi-finals",
"date": "1934-06-03",
"time": "16:30",
"team1": "Czechoslovakia",
"team2": "Germany",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Oldřich Nejedlý",
"minute": 21
},
{
"name": "Oldřich Nejedlý",
"minute": 69
},
{
"name": "Oldřich Nejedlý",
"minute": 80
}
],
"goals2": [
{
"name": "Rudolf Noack",
"minute": 62
}
],
"ground": "Stadio Nazionale PNF, Rome"
},
{
"round": "Third-place match",
"date": "1934-06-07",
"time": "18:00",
"team1": "Germany",
"team2": "Austria",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Ernst Lehner",
"minute": 1
},
{
"name": "Ernst Lehner",
"minute": 42
},
{
"name": "Edmund Conen",
"minute": 27
}
],
"goals2": [
{
"name": "Johann Horvath",
"minute": 28
},
{
"name": "Karl Sesta",
"minute": 54
}
],
"ground": "Stadio Giorgio Ascarelli, Naples"
},
{
"round": "Final",
"date": "1934-06-10",
"time": "15:30",
"team1": "Italy",
"team2": "Czechoslovakia",
"score": {
"ft": [
1,
1
],
"et": [
2,
1
]
},
"goals1": [
{
"name": "Raimundo Orsi",
"minute": 81
},
{
"name": "Angelo Schiavio",
"minute": 95
}
],
"goals2": [
{
"name": "Antonín Puč",
"minute": 71
}
],
"ground": "Stadio Nazionale PNF, Rome"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Italy",
"teams_count": 16,
"winner": "Italy",
"runner_up": "Czechoslovakia",
"third_place": "Germany",
"fourth_place": "Austria"
}
+36
View File
@@ -0,0 +1,36 @@
{
"stadiums": [
{
"name": "Stadio Luigi Ferraris",
"city": "Genoa"
},
{
"name": "Stadio Giorgio Ascarelli",
"city": "Naples"
},
{
"name": "Stadio San Siro",
"city": "Milan"
},
{
"name": "Stadio Nazionale PNF",
"city": "Rome"
},
{
"name": "Stadio Littorio",
"city": "Trieste"
},
{
"name": "Stadio Littoriale",
"city": "Bologna"
},
{
"name": "Stadio Benito Mussolini",
"city": "Turin"
},
{
"name": "Stadio Giovanni Berta",
"city": "Florence"
}
]
}
+692
View File
@@ -0,0 +1,692 @@
{
"matches": [
{
"round": "Round of 16",
"date": "1938-06-04",
"time": "17:00",
"team1": "Switzerland",
"team2": "Germany",
"score": {
"ft": [
1,
1
],
"et": [
1,
1
]
},
"goals1": [
{
"name": "André Abegglen",
"minute": 43
}
],
"goals2": [
{
"name": "Josef Gauchel",
"minute": 29
}
],
"ground": "Parc des Princes, Paris"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "Hungary",
"team2": "Dutch East Indies",
"score": {
"ft": [
6,
0
]
},
"goals1": [
{
"name": "Vilmos Kohut",
"minute": 13
},
{
"name": "Géza Toldi",
"minute": 15
},
{
"name": "György Sárosi",
"minute": 25
},
{
"name": "György Sárosi",
"minute": 89
},
{
"name": "Gyula Zsengellér",
"minute": 30
},
{
"name": "Gyula Zsengellér",
"minute": 76
}
],
"ground": "Vélodrome Municipal, Reims"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"team1": "Sweden",
"team2": "Austria",
"ground": "Stade Gerland, Lyon"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "Cuba",
"team2": "Romania",
"score": {
"ft": [
2,
2
],
"et": [
3,
3
]
},
"goals1": [
{
"name": "Héctor Socorro",
"minute": 44
},
{
"name": "Héctor Socorro",
"minute": 103
},
{
"name": "José Magriñá",
"minute": 69
}
],
"goals2": [
{
"name": "Silviu Bindea",
"minute": 35
},
{
"name": "Iuliu Barátky",
"minute": 88
},
{
"name": "Ștefan Dobay",
"minute": 105
}
],
"ground": "Stade du T.O.E.C., Toulouse"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "France",
"team2": "Belgium",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Émile Veinante",
"minute": 1
},
{
"name": "Jean Nicolas",
"minute": 16
},
{
"name": "Jean Nicolas",
"minute": 69
}
],
"goals2": [
{
"name": "Hendrik Isemborghs",
"minute": 38
}
],
"ground": "Stade Olympique de Colombes, Colombes"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "Italy",
"team2": "Norway",
"score": {
"ft": [
1,
1
],
"et": [
2,
1
]
},
"goals1": [
{
"name": "Pietro Ferraris",
"minute": 2
},
{
"name": "Silvio Piola",
"minute": 94
}
],
"goals2": [
{
"name": "Arne Brustad",
"minute": 83
}
],
"ground": "Stade Vélodrome, Marseille"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "Brazil",
"team2": "Poland",
"score": {
"ft": [
4,
4
],
"et": [
6,
5
]
},
"goals1": [
{
"name": "Leônidas da Silva",
"minute": 18
},
{
"name": "Leônidas da Silva",
"minute": 93
},
{
"name": "Leônidas da Silva",
"minute": 104
},
{
"name": "Romeu Pellicciari",
"minute": 25
},
{
"name": "José Perácio",
"minute": 44
},
{
"name": "José Perácio",
"minute": 71
}
],
"goals2": [
{
"name": "Friedrich Scherfke",
"minute": 23,
"penalty": true
},
{
"name": "Ernst Wilimowski",
"minute": 53
},
{
"name": "Ernst Wilimowski",
"minute": 59
},
{
"name": "Ernst Wilimowski",
"minute": 89
},
{
"name": "Ernst Wilimowski",
"minute": 118
}
],
"ground": "Stade de la Meinau, Strasbourg"
},
{
"round": "Round of 16",
"date": "1938-06-05",
"time": "17:00",
"team1": "Czechoslovakia",
"team2": "Netherlands",
"score": {
"ft": [
0,
0
],
"et": [
3,
0
]
},
"goals1": [
{
"name": "Josef Košťálek",
"minute": 96
},
{
"name": "Oldřich Nejedlý",
"minute": 111
},
{
"name": "Josef Zeman",
"minute": 118
}
],
"ground": "Stade municipal, Le Havre"
},
{
"round": "Round of 16",
"date": "1938-06-09",
"time": "18:00",
"team1": "Switzerland",
"team2": "Germany",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Eugen Walaschek",
"minute": 42
},
{
"name": "Alfred Bickel",
"minute": 64
},
{
"name": "André Abegglen",
"minute": 75
},
{
"name": "André Abegglen",
"minute": 78
}
],
"goals2": [
{
"name": "Wilhelm Hahnemann",
"minute": 8
},
{
"name": "Ernst Lörtscher",
"minute": 22,
"owngoal": true
}
],
"ground": "Parc des Princes, Paris"
},
{
"round": "Round of 16",
"date": "1938-06-09",
"time": "18:00",
"team1": "Cuba",
"team2": "Romania",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Héctor Socorro",
"minute": 51
},
{
"name": "Tomás Fernández",
"minute": 57
}
],
"goals2": [
{
"name": "Ștefan Dobay",
"minute": 35
}
],
"ground": "Stade du T.O.E.C., Toulouse"
},
{
"round": "Quarter-finals",
"date": "1938-06-12",
"time": "17:00",
"team1": "Hungary",
"team2": "Switzerland",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "György Sárosi",
"minute": 40
},
{
"name": "Gyula Zsengellér",
"minute": 89
}
],
"ground": "Stade Victor Boucquey, Lille"
},
{
"round": "Quarter-finals",
"date": "1938-06-12",
"time": "17:00",
"team1": "Sweden",
"team2": "Cuba",
"score": {
"ft": [
8,
0
]
},
"goals1": [
{
"name": "Harry Andersson",
"minute": 9
},
{
"name": "Harry Andersson",
"minute": 81
},
{
"name": "Harry Andersson",
"minute": 89
},
{
"name": "Gustav Wetterström",
"minute": 22
},
{
"name": "Gustav Wetterström",
"minute": 37
},
{
"name": "Gustav Wetterström",
"minute": 44
},
{
"name": "Tore Keller",
"minute": 80
},
{
"name": "Arne Nyberg",
"minute": 84
}
],
"ground": "Stade du Fort Carré, Antibes"
},
{
"round": "Quarter-finals",
"date": "1938-06-12",
"time": "17:00",
"team1": "Italy",
"team2": "France",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Gino Colaussi",
"minute": 9
},
{
"name": "Silvio Piola",
"minute": 51
},
{
"name": "Silvio Piola",
"minute": 72
}
],
"goals2": [
{
"name": "Oscar Heisserer",
"minute": 10
}
],
"ground": "Stade Olympique de Colombes, Colombes"
},
{
"round": "Quarter-finals",
"date": "1938-06-12",
"time": "17:00",
"team1": "Brazil",
"team2": "Czechoslovakia",
"score": {
"ft": [
1,
1
],
"et": [
1,
1
]
},
"goals1": [
{
"name": "Leônidas da Silva",
"minute": 30
}
],
"goals2": [
{
"name": "Oldřich Nejedlý",
"minute": 65,
"penalty": true
}
],
"ground": "Parc Lescure, Bordeaux"
},
{
"round": "Quarter-finals",
"date": "1938-06-14",
"time": "18:00",
"team1": "Brazil",
"team2": "Czechoslovakia",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Leônidas da Silva",
"minute": 57
},
{
"name": "Roberto Emílio da Cunha",
"minute": 62
}
],
"goals2": [
{
"name": "Vlastimil Kopecký",
"minute": 25
}
],
"ground": "Parc Lescure, Bordeaux"
},
{
"round": "Semi-finals",
"date": "1938-06-16",
"time": "18:00",
"team1": "Hungary",
"team2": "Sweden",
"score": {
"ft": [
5,
1
]
},
"goals1": [
{
"name": "Sven Jacobsson",
"minute": 19,
"owngoal": true
},
{
"name": "Pál Titkos",
"minute": 37
},
{
"name": "Gyula Zsengellér",
"minute": 39
},
{
"name": "Gyula Zsengellér",
"minute": 85
},
{
"name": "György Sárosi",
"minute": 65
}
],
"goals2": [
{
"name": "Arne Nyberg",
"minute": 1
}
],
"ground": "Parc des Princes, Paris"
},
{
"round": "Semi-finals",
"date": "1938-06-16",
"time": "18:00",
"team1": "Italy",
"team2": "Brazil",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Gino Colaussi",
"minute": 51
},
{
"name": "Giuseppe Meazza",
"minute": 60,
"penalty": true
}
],
"goals2": [
{
"name": "Romeu Pellicciari",
"minute": 87
}
],
"ground": "Stade Vélodrome, Marseille"
},
{
"round": "Third-place match",
"date": "1938-06-19",
"time": "17:00",
"team1": "Brazil",
"team2": "Sweden",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Romeu Pellicciari",
"minute": 44
},
{
"name": "Leônidas da Silva",
"minute": 63
},
{
"name": "Leônidas da Silva",
"minute": 74
},
{
"name": "José Perácio",
"minute": 80
}
],
"goals2": [
{
"name": "Sven Jonasson",
"minute": 28
},
{
"name": "Arne Nyberg",
"minute": 38
}
],
"ground": "Parc Lescure, Bordeaux"
},
{
"round": "Final",
"date": "1938-06-19",
"time": "17:00",
"team1": "Italy",
"team2": "Hungary",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Gino Colaussi",
"minute": 6
},
{
"name": "Gino Colaussi",
"minute": 35
},
{
"name": "Silvio Piola",
"minute": 16
},
{
"name": "Silvio Piola",
"minute": 82
}
],
"goals2": [
{
"name": "Pál Titkos",
"minute": 8
},
{
"name": "György Sárosi",
"minute": 70
}
],
"ground": "Stade Olympique de Colombes, Paris"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "France",
"teams_count": 15,
"winner": "Italy",
"runner_up": "Hungary",
"third_place": "Brazil",
"fourth_place": "Sweden"
}
+48
View File
@@ -0,0 +1,48 @@
{
"stadiums": [
{
"name": "Parc des Princes",
"city": "Paris"
},
{
"name": "Vélodrome Municipal",
"city": "Reims"
},
{
"name": "Stade Gerland",
"city": "Lyon"
},
{
"name": "Stade du T.O.E.C.",
"city": "Toulouse"
},
{
"name": "Stade Olympique de Colombes",
"city": "Colombes"
},
{
"name": "Stade Vélodrome",
"city": "Marseille"
},
{
"name": "Stade de la Meinau",
"city": "Strasbourg"
},
{
"name": "Stade municipal",
"city": "Le Havre"
},
{
"name": "Stade Victor Boucquey",
"city": "Lille"
},
{
"name": "Stade du Fort Carré",
"city": "Antibes"
},
{
"name": "Parc Lescure",
"city": "Bordeaux"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Brazil",
"Mexico",
"Yugoslavia",
"Switzerland"
]
},
{
"name": "Group 2",
"teams": [
"England",
"Chile",
"Spain",
"United States"
]
},
{
"name": "Group 3",
"teams": [
"Sweden",
"Italy",
"Paraguay"
]
},
{
"name": "Group 4",
"teams": [
"Uruguay",
"Bolivia"
]
}
]
}
+753
View File
@@ -0,0 +1,753 @@
{
"matches": [
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-06-24",
"time": "15:00",
"team1": "Brazil",
"team2": "Mexico",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Ademir Marques de Menezes",
"minute": 30
},
{
"name": "Ademir Marques de Menezes",
"minute": 79
},
{
"name": "Jair da Rosa Pinto",
"minute": 65
},
{
"name": "Baltazar",
"minute": 71
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-06-25",
"time": "15:00",
"team1": "Yugoslavia",
"team2": "Switzerland",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Rajko Mitić",
"minute": 59
},
{
"name": "Kosta Tomašević",
"minute": 70
},
{
"name": "Tihomir Ognjanov",
"minute": 84
}
],
"ground": "Estádio Independência, Belo Horizonte"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-06-28",
"time": "15:00",
"team1": "Brazil",
"team2": "Switzerland",
"score": {
"ft": [
2,
2
]
},
"goals1": [
{
"name": "Alfredo dos Santos",
"minute": 3
},
{
"name": "Baltazar",
"minute": 32
}
],
"goals2": [
{
"name": "Jacques Fatton",
"minute": 17
},
{
"name": "Jacques Fatton",
"minute": 88
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-06-28",
"time": "15:00",
"team1": "Yugoslavia",
"team2": "Mexico",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Stjepan Bobek",
"minute": 20
},
{
"name": "Željko Čajkovski",
"minute": 23
},
{
"name": "Željko Čajkovski",
"minute": 51
},
{
"name": "Kosta Tomašević",
"minute": 81
}
],
"goals2": [
{
"name": "Héctor Ortiz",
"minute": 89,
"penalty": true
}
],
"ground": "Estádio dos Eucaliptos, Porto Alegre"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-07-01",
"time": "15:00",
"team1": "Brazil",
"team2": "Yugoslavia",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Ademir Marques de Menezes",
"minute": 4
},
{
"name": "Thomaz Soares da Silva",
"minute": 69
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1950-07-02",
"time": "15:40",
"team1": "Switzerland",
"team2": "Mexico",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "René Bader",
"minute": 10
},
{
"name": "Charles Antenen",
"minute": 44
}
],
"goals2": [
{
"name": "Horacio Casarín",
"minute": 89
}
],
"ground": "Estádio dos Eucaliptos, Porto Alegre"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-06-25",
"time": "15:00",
"team1": "England",
"team2": "Chile",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Stan Mortensen",
"minute": 39
},
{
"name": "Wilf Mannion",
"minute": 51
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-06-25",
"time": "15:00",
"team1": "Spain",
"team2": "United States",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Silvestre Igoa",
"minute": 81
},
{
"name": "Estanislau Basora",
"minute": 83
},
{
"name": "Telmo Zarra",
"minute": 89
}
],
"goals2": [
{
"name": "Gino Pariani",
"minute": 17
}
],
"ground": "Estádio Durival de Britto, Curitiba"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-06-29",
"time": "15:00",
"team1": "Spain",
"team2": "Chile",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Estanislau Basora",
"minute": 17
},
{
"name": "Telmo Zarra",
"minute": 30
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-06-29",
"time": "15:00",
"team1": "United States",
"team2": "England",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Joe Gaetjens",
"minute": 38
}
],
"ground": "Estádio Independência, Belo Horizonte"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-07-02",
"time": "15:00",
"team1": "Spain",
"team2": "England",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Telmo Zarra",
"minute": 48
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1950-07-02",
"time": "15:00",
"team1": "Chile",
"team2": "United States",
"score": {
"ft": [
5,
2
]
},
"goals1": [
{
"name": "George Robledo",
"minute": 16
},
{
"name": "Atilio Cremaschi",
"minute": 32
},
{
"name": "Atilio Cremaschi",
"minute": 60
},
{
"name": "Andrés Prieto",
"minute": 54
},
{
"name": "Fernando Riera",
"minute": 82
}
],
"goals2": [
{
"name": "Frank Wallace",
"minute": 47
},
{
"name": "Joe Maca",
"minute": 48,
"penalty": true
}
],
"ground": "Estádio Ilha do Retiro, Recife"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1950-06-25",
"time": "15:00",
"team1": "Sweden",
"team2": "Italy",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Hasse Jeppson",
"minute": 25
},
{
"name": "Hasse Jeppson",
"minute": 68
},
{
"name": "Sune Andersson",
"minute": 33
}
],
"goals2": [
{
"name": "Riccardo Carapellese",
"minute": 7
},
{
"name": "Ermes Muccinelli",
"minute": 75
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1950-06-29",
"time": "15:30",
"team1": "Sweden",
"team2": "Paraguay",
"score": {
"ft": [
2,
2
]
},
"goals1": [
{
"name": "Stig Sundqvist",
"minute": 17
},
{
"name": "Karl-Erik Palmér",
"minute": 26
}
],
"goals2": [
{
"name": "Atilio López",
"minute": 35
},
{
"name": "César López Fretes",
"minute": 74
}
],
"ground": "Estádio Durival Britto, Curitiba"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1950-07-02",
"time": "15:00",
"team1": "Italy",
"team2": "Paraguay",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Riccardo Carapellese",
"minute": 12
},
{
"name": "Egisto Pandolfini",
"minute": 62
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1950-07-02",
"time": "15:00",
"team1": "Uruguay",
"team2": "Bolivia",
"score": {
"ft": [
8,
0
]
},
"goals1": [
{
"name": "Óscar Míguez",
"minute": 14
},
{
"name": "Óscar Míguez",
"minute": 40
},
{
"name": "Óscar Míguez",
"minute": 51
},
{
"name": "Ernesto Vidal",
"minute": 18
},
{
"name": "Juan Alberto Schiaffino",
"minute": 23
},
{
"name": "Juan Alberto Schiaffino",
"minute": 54
},
{
"name": "Julio Pérez",
"minute": 83
},
{
"name": "Alcides Ghiggia",
"minute": 87
}
],
"ground": "Estádio Independência, Belo Horizonte"
},
{
"round": "Final round",
"date": "1950-07-09",
"time": "15:00",
"team1": "Uruguay",
"team2": "Spain",
"score": {
"ft": [
2,
2
]
},
"goals1": [
{
"name": "Alcides Ghiggia",
"minute": 29
},
{
"name": "Obdulio Varela",
"minute": 73
}
],
"goals2": [
{
"name": "Estanislau Basora",
"minute": 37
},
{
"name": "Estanislau Basora",
"minute": 39
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Final round",
"date": "1950-07-09",
"time": "15:00",
"team1": "Brazil",
"team2": "Sweden",
"score": {
"ft": [
7,
1
]
},
"goals1": [
{
"name": "Ademir Marques de Menezes",
"minute": 17
},
{
"name": "Ademir Marques de Menezes",
"minute": 36
},
{
"name": "Ademir Marques de Menezes",
"minute": 52
},
{
"name": "Ademir Marques de Menezes",
"minute": 58
},
{
"name": "Francisco Aramburu",
"minute": 39
},
{
"name": "Francisco Aramburu",
"minute": 88
},
{
"name": "Maneca",
"minute": 85
}
],
"goals2": [
{
"name": "Sune Andersson",
"minute": 67,
"penalty": true
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Final round",
"date": "1950-07-13",
"time": "15:00",
"team1": "Brazil",
"team2": "Spain",
"score": {
"ft": [
6,
1
]
},
"goals1": [
{
"name": "Ademir de Menezes",
"minute": 15
},
{
"name": "Ademir de Menezes",
"minute": 57
},
{
"name": "Jair da Rosa Pinto",
"minute": 21
},
{
"name": "Francisco Aramburu",
"minute": 31
},
{
"name": "Francisco Aramburu",
"minute": 55
},
{
"name": "Thomaz Soares da Silva",
"minute": 67
}
],
"goals2": [
{
"name": "Silvestre Igoa",
"minute": 71
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
},
{
"round": "Final round",
"date": "1950-07-13",
"time": "15:00",
"team1": "Uruguay",
"team2": "Sweden",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Alcides Ghiggia",
"minute": 39
},
{
"name": "Óscar Míguez",
"minute": 77
},
{
"name": "Óscar Míguez",
"minute": 85
}
],
"goals2": [
{
"name": "Karl-Erik Palmér",
"minute": 5
},
{
"name": "Stig Sundqvist",
"minute": 40
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Final round",
"date": "1950-07-16",
"time": "15:00",
"team1": "Sweden",
"team2": "Spain",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Stig Sundqvist",
"minute": 15
},
{
"name": "Bror Mellberg",
"minute": 33
},
{
"name": "Karl-Erik Palmér",
"minute": 80
}
],
"goals2": [
{
"name": "Telmo Zarra",
"minute": 82
}
],
"ground": "Estádio do Pacaembu, São Paulo"
},
{
"round": "Final round",
"date": "1950-07-16",
"time": "15:00",
"team1": "Uruguay",
"team2": "Brazil",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Juan Alberto Schiaffino",
"minute": 66
},
{
"name": "Alcides Ghiggia",
"minute": 79
}
],
"goals2": [
{
"name": "Friaça",
"minute": 47
}
],
"ground": "Estádio do Maracanã, Rio de Janeiro"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Brazil",
"teams_count": 13,
"winner": "Uruguay",
"runner_up": "Brazil",
"third_place": "Sweden",
"fourth_place": "Spain"
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"stadiums": [
{
"name": "Estádio do Maracanã",
"city": "Rio de Janeiro"
},
{
"name": "Estádio Independência",
"city": "Belo Horizonte"
},
{
"name": "Estádio do Pacaembu",
"city": "São Paulo"
},
{
"name": "Estádio dos Eucaliptos",
"city": "Porto Alegre"
},
{
"name": "Estádio Durival de Britto",
"city": "Curitiba"
},
{
"name": "Estádio Ilha do Retiro",
"city": "Recife"
},
{
"name": "Estádio Durival Britto",
"city": "Curitiba"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Brazil",
"Mexico",
"Yugoslavia",
"France"
]
},
{
"name": "Group 2",
"teams": [
"Germany",
"Turkey",
"Hungary",
"South Korea"
]
},
{
"name": "Group 3",
"teams": [
"Uruguay",
"Czechoslovakia",
"Austria",
"Scotland"
]
},
{
"name": "Group 4",
"teams": [
"Switzerland",
"Italy",
"England",
"Belgium"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Switzerland",
"teams_count": 16,
"winner": "Germany",
"runner_up": "Hungary",
"third_place": "Austria",
"fourth_place": "Uruguay"
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"stadiums": [
{
"name": "Charmilles Stadium",
"city": "Geneva"
},
{
"name": "Stade Olympique de la Pontaise",
"city": "Lausanne"
},
{
"name": "Wankdorf Stadium",
"city": "Bern"
},
{
"name": "Hardturm Stadium",
"city": "Zürich"
},
{
"name": "St. Jakob Stadium",
"city": "Basel"
},
{
"name": "Cornaredo Stadium",
"city": "Lugano"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Argentina",
"Germany",
"Northern Ireland",
"Czechoslovakia"
]
},
{
"name": "Group 2",
"teams": [
"France",
"Paraguay",
"Yugoslavia",
"Scotland"
]
},
{
"name": "Group 3",
"teams": [
"Sweden",
"Mexico",
"Hungary",
"Wales"
]
},
{
"name": "Group 4",
"teams": [
"Brazil",
"Austria",
"Soviet Union",
"England"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Sweden",
"teams_count": 16,
"winner": "Brazil",
"runner_up": "Sweden",
"third_place": "France",
"fourth_place": "Germany"
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"stadiums": [
{
"name": "Malmö Stadion",
"city": "Malmö"
},
{
"name": "Örjans Vall",
"city": "Halmstad"
},
{
"name": "Olympiastadion",
"city": "Helsingborg"
},
{
"name": "Idrottsparken",
"city": "Norrköping"
},
{
"name": "Arosvallen",
"city": "Västerås"
},
{
"name": "Eyravallen",
"city": "Örebro"
},
{
"name": "Tunavallen",
"city": "Eskilstuna"
},
{
"name": "Råsunda Stadium",
"city": "Solna"
},
{
"name": "Jernvallen",
"city": "Sandviken"
},
{
"name": "Rimnersvallen",
"city": "Uddevalla"
},
{
"name": "Ullevi",
"city": "Gothenburg"
},
{
"name": "Ryavallen",
"city": "Borås"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Uruguay",
"Colombia",
"Soviet Union",
"Yugoslavia"
]
},
{
"name": "Group 2",
"teams": [
"Chile",
"Switzerland",
"Germany",
"Italy"
]
},
{
"name": "Group 3",
"teams": [
"Brazil",
"Mexico",
"Czechoslovakia",
"Spain"
]
},
{
"name": "Group 4",
"teams": [
"Argentina",
"Bulgaria",
"Hungary",
"England"
]
}
]
}
+928
View File
@@ -0,0 +1,928 @@
{
"matches": [
{
"round": "Quarter-finals",
"date": "1962-06-10",
"time": "14:30",
"team1": "Chile",
"team2": "Soviet Union",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Leonel Sánchez",
"minute": 11
},
{
"name": "Eladio Rojas",
"minute": 29
}
],
"goals2": [
{
"name": "Igor Chislenko",
"minute": 26
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Quarter-finals",
"date": "1962-06-10",
"time": "14:30",
"team1": "Czechoslovakia",
"team2": "Hungary",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Adolf Scherer",
"minute": 13
}
],
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Quarter-finals",
"date": "1962-06-10",
"time": "14:30",
"team1": "Brazil",
"team2": "England",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Garrincha",
"minute": 31
},
{
"name": "Garrincha",
"minute": 59
},
{
"name": "Vavá",
"minute": 53
}
],
"goals2": [
{
"name": "Gerry Hitchens",
"minute": 38
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Quarter-finals",
"date": "1962-06-10",
"time": "14:30",
"team1": "Yugoslavia",
"team2": "Germany",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Petar Radaković",
"minute": 85
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Semi-finals",
"date": "1962-06-13",
"time": "14:30",
"team1": "Czechoslovakia",
"team2": "Yugoslavia",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Josef Kadraba",
"minute": 48
},
{
"name": "Adolf Scherer",
"minute": 80
},
{
"name": "Adolf Scherer",
"minute": 84,
"penalty": true
}
],
"goals2": [
{
"name": "Dražan Jerković",
"minute": 69
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Semi-finals",
"date": "1962-06-13",
"time": "14:30",
"team1": "Brazil",
"team2": "Chile",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Garrincha",
"minute": 9
},
{
"name": "Garrincha",
"minute": 32
},
{
"name": "Vavá",
"minute": 47
},
{
"name": "Vavá",
"minute": 78
}
],
"goals2": [
{
"name": "Jorge Toro",
"minute": 42
},
{
"name": "Leonel Sánchez",
"minute": 61,
"penalty": true
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Third-place match",
"date": "1962-06-16",
"time": "14:30",
"team1": "Chile",
"team2": "Yugoslavia",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Eladio Rojas",
"minute": 90
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Final",
"date": "1962-06-17",
"time": "14:30",
"team1": "Brazil",
"team2": "Czechoslovakia",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Amarildo Tavares da Silveira",
"minute": 17
},
{
"name": "Zito",
"minute": 69
},
{
"name": "Vavá",
"minute": 78
}
],
"goals2": [
{
"name": "Josef Masopust",
"minute": 15
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-05-30",
"time": "15:00",
"team1": "Uruguay",
"team2": "Colombia",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Luis Cubilla",
"minute": 56
},
{
"name": "José Sasía",
"minute": 75
}
],
"goals2": [
{
"name": "Francisco Zuluaga",
"minute": 19,
"penalty": true
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-05-31",
"time": "15:00",
"team1": "Soviet Union",
"team2": "Yugoslavia",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Valentin Kozmich Ivanov",
"minute": 51
},
{
"name": "Viktor Ponedelnik",
"minute": 83
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-06-02",
"time": "15:00",
"team1": "Yugoslavia",
"team2": "Uruguay",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Josip Skoblar",
"minute": 25,
"penalty": true
},
{
"name": "Milan Galić",
"minute": 29
},
{
"name": "Dražan Jerković",
"minute": 49
}
],
"goals2": [
{
"name": "Ángel Cabrera",
"minute": 19
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-06-03",
"time": "15:00",
"team1": "Soviet Union",
"team2": "Colombia",
"score": {
"ft": [
4,
4
]
},
"goals1": [
{
"name": "Valentin Kozmich Ivanov",
"minute": 8
},
{
"name": "Valentin Kozmich Ivanov",
"minute": 11
},
{
"name": "Igor Chislenko",
"minute": 10
},
{
"name": "Viktor Ponedelnik",
"minute": 56
}
],
"goals2": [
{
"name": "Germán Aceros",
"minute": 21
},
{
"name": "Marcos Coll",
"minute": 68
},
{
"name": "Antonio Rada",
"minute": 72
},
{
"name": "Marino Klinger",
"minute": 86
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-06-06",
"time": "15:00",
"team1": "Soviet Union",
"team2": "Uruguay",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Aleksei Mamykin",
"minute": 38
},
{
"name": "Valentin Kozmich Ivanov",
"minute": 89
}
],
"goals2": [
{
"name": "José Sasía",
"minute": 54
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1962-06-07",
"time": "15:00",
"team1": "Yugoslavia",
"team2": "Colombia",
"score": {
"ft": [
5,
0
]
},
"goals1": [
{
"name": "Milan Galić",
"minute": 20
},
{
"name": "Milan Galić",
"minute": 61
},
{
"name": "Dražan Jerković",
"minute": 25
},
{
"name": "Dražan Jerković",
"minute": 87
},
{
"name": "Vojislav Melić",
"minute": 82
}
],
"ground": "Estadio Carlos Dittborn, Arica"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-05-30",
"time": "15:00",
"team1": "Chile",
"team2": "Switzerland",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Leonel Sanchez",
"minute": 44
},
{
"name": "Leonel Sanchez",
"minute": 55
},
{
"name": "Jaime Ramírez",
"minute": 51
}
],
"goals2": [
{
"name": "Rolf Wüthrich",
"minute": 6
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-05-31",
"time": "15:00",
"team1": "Germany",
"team2": "Italy",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-06-02",
"time": "15:00",
"team1": "Chile",
"team2": "Italy",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Jaime Ramírez",
"minute": 73
},
{
"name": "Jorge Toro",
"minute": 87
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-06-03",
"time": "15:00",
"team1": "Germany",
"team2": "Switzerland",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Albert Brülls",
"minute": 45
},
{
"name": "Uwe Seeler",
"minute": 59
}
],
"goals2": [
{
"name": "Heinz Schneiter",
"minute": 73
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-06-06",
"time": "15:00",
"team1": "Germany",
"team2": "Chile",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Horst Szymaniak",
"minute": 21,
"penalty": true
},
{
"name": "Uwe Seeler",
"minute": 82
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1962-06-07",
"time": "15:00",
"team1": "Italy",
"team2": "Switzerland",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Bruno Mora",
"minute": 2
},
{
"name": "Giacomo Bulgarelli",
"minute": 65
},
{
"name": "Giacomo Bulgarelli",
"minute": 67
}
],
"ground": "Estadio Nacional, Santiago"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-05-30",
"time": "15:00",
"team1": "Brazil",
"team2": "Mexico",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Mário Zagallo",
"minute": 56
},
{
"name": "Pelé",
"minute": 73
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-05-31",
"time": "15:00",
"team1": "Czechoslovakia",
"team2": "Spain",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Jozef Štibrányi",
"minute": 80
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-06-02",
"time": "15:00",
"team1": "Brazil",
"team2": "Czechoslovakia",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-06-03",
"time": "15:00",
"team1": "Spain",
"team2": "Mexico",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Joaquín Peiró",
"minute": 90
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-06-06",
"time": "15:00",
"team1": "Brazil",
"team2": "Spain",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Amarildo Tavares da Silveira",
"minute": 72
},
{
"name": "Amarildo Tavares da Silveira",
"minute": 86
}
],
"goals2": [
{
"name": "Adelardo Rodríguez",
"minute": 35
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1962-06-07",
"time": "15:00",
"team1": "Mexico",
"team2": "Czechoslovakia",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Isidoro Díaz",
"minute": 12
},
{
"name": "Alfredo del Águila",
"minute": 29
},
{
"name": "Héctor Hernández",
"minute": 90,
"penalty": true
}
],
"goals2": [
{
"name": "Václav Mašek",
"minute": 1
}
],
"ground": "Estadio Sausalito, Viña del Mar"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-05-30",
"time": "15:00",
"team1": "Argentina",
"team2": "Bulgaria",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Héctor Facundo",
"minute": 4
}
],
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-05-31",
"time": "15:00",
"team1": "Hungary",
"team2": "England",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Lajos Tichy",
"minute": 17
},
{
"name": "Flórián Albert",
"minute": 71
}
],
"goals2": [
{
"name": "Ron Flowers",
"minute": 60,
"penalty": true
}
],
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-06-02",
"time": "15:00",
"team1": "England",
"team2": "Argentina",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Ron Flowers",
"minute": 17,
"penalty": true
},
{
"name": "Bobby Charlton",
"minute": 42
},
{
"name": "Jimmy Greaves",
"minute": 67
}
],
"goals2": [
{
"name": "José Sanfilippo",
"minute": 81
}
],
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-06-03",
"time": "15:00",
"team1": "Hungary",
"team2": "Bulgaria",
"score": {
"ft": [
6,
1
]
},
"goals1": [
{
"name": "Flórián Albert",
"minute": 1
},
{
"name": "Flórián Albert",
"minute": 6
},
{
"name": "Flórián Albert",
"minute": 53
},
{
"name": "Lajos Tichy",
"minute": 8
},
{
"name": "Lajos Tichy",
"minute": 70
},
{
"name": "Ernő Solymosi",
"minute": 12
}
],
"goals2": [
{
"name": "Georgi Sokolov",
"minute": 64
}
],
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-06-06",
"time": "15:00",
"team1": "Hungary",
"team2": "Argentina",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio El Teniente, Rancagua"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1962-06-07",
"time": "15:00",
"team1": "England",
"team2": "Bulgaria",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio El Teniente, Rancagua"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Chile",
"teams_count": 16,
"winner": "Brazil",
"runner_up": "Czechoslovakia",
"third_place": "Chile",
"fourth_place": "Yugoslavia"
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"stadiums": [
{
"name": "Estadio Carlos Dittborn",
"city": "Arica"
},
{
"name": "Estadio El Teniente",
"city": "Rancagua"
},
{
"name": "Estadio Sausalito",
"city": "Viña del Mar"
},
{
"name": "Estadio Nacional",
"city": "Santiago"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"England",
"Uruguay",
"France",
"Mexico"
]
},
{
"name": "Group 2",
"teams": [
"Germany",
"Switzerland",
"Argentina",
"Spain"
]
},
{
"name": "Group 3",
"teams": [
"Brazil",
"Bulgaria",
"Portugal",
"Hungary"
]
},
{
"name": "Group 4",
"teams": [
"Soviet Union",
"North Korea",
"Italy",
"Chile"
]
}
]
}
+939
View File
@@ -0,0 +1,939 @@
{
"matches": [
{
"round": "Quarter-finals",
"date": "1966-07-23",
"time": "15:00",
"team1": "England",
"team2": "Argentina",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Geoff Hurst",
"minute": 78
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Quarter-finals",
"date": "1966-07-23",
"time": "15:00",
"team1": "Germany",
"team2": "Uruguay",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Helmut Haller",
"minute": 11
},
{
"name": "Helmut Haller",
"minute": 83
},
{
"name": "Franz Beckenbauer",
"minute": 70
},
{
"name": "Uwe Seeler",
"minute": 75
}
],
"ground": "Hillsborough Stadium, Sheffield"
},
{
"round": "Quarter-finals",
"date": "1966-07-23",
"time": "15:00",
"team1": "Soviet Union",
"team2": "Hungary",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Igor Chislenko",
"minute": 5
},
{
"name": "Valeriy Porkujan",
"minute": 46
}
],
"goals2": [
{
"name": "Ferenc Bene",
"minute": 57
}
],
"ground": "Roker Park, Sunderland"
},
{
"round": "Quarter-finals",
"date": "1966-07-23",
"time": "15:00",
"team1": "Portugal",
"team2": "North Korea",
"score": {
"ft": [
5,
3
]
},
"goals1": [
{
"name": "Eusébio",
"minute": 27
},
{
"name": "Eusébio",
"minute": 43,
"penalty": true
},
{
"name": "Eusébio",
"minute": 56
},
{
"name": "Eusébio",
"minute": 59,
"penalty": true
},
{
"name": "José Augusto de Almeida",
"minute": 80
}
],
"goals2": [
{
"name": "Pak Seung-zin",
"minute": 1
},
{
"name": "Li Dong-woon",
"minute": 22
},
{
"name": "Yang Seung-kook",
"minute": 25
}
],
"ground": "Goodison Park, Liverpool"
},
{
"round": "Semi-finals",
"date": "1966-07-25",
"time": "19:30",
"team1": "Germany",
"team2": "Soviet Union",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Helmut Haller",
"minute": 43
},
{
"name": "Franz Beckenbauer",
"minute": 67
}
],
"goals2": [
{
"name": "Valeriy Porkujan",
"minute": 88
}
],
"ground": "Goodison Park, Liverpool"
},
{
"round": "Semi-finals",
"date": "1966-07-26",
"time": "19:30",
"team1": "England",
"team2": "Portugal",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Bobby Charlton",
"minute": 30
},
{
"name": "Bobby Charlton",
"minute": 80
}
],
"goals2": [
{
"name": "Eusébio",
"minute": 82,
"penalty": true
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Third-place match",
"date": "1966-07-28",
"time": "19:30",
"team1": "Portugal",
"team2": "Soviet Union",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Eusébio",
"minute": 12,
"penalty": true
},
{
"name": "José Augusto Torres",
"minute": 89
}
],
"goals2": [
{
"name": "Eduard Malofeyev",
"minute": 43
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Final",
"date": "1966-07-30",
"time": "15:00",
"team1": "England",
"team2": "Germany",
"score": {
"ft": [
2,
2
],
"et": [
4,
2
]
},
"goals1": [
{
"name": "Geoff Hurst",
"minute": 18
},
{
"name": "Geoff Hurst",
"minute": 101
},
{
"name": "Geoff Hurst",
"minute": 120
},
{
"name": "Martin Peters",
"minute": 78
}
],
"goals2": [
{
"name": "Helmut Haller",
"minute": 12
},
{
"name": "Wolfgang Weber",
"minute": 89
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-11",
"time": "19:30",
"team1": "England",
"team2": "Uruguay",
"score": {
"ft": [
0,
0
]
},
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-13",
"time": "19:30",
"team1": "France",
"team2": "Mexico",
"score": {
"ft": [
1,
1
]
},
"goals1": [
{
"name": "Gérard Hausser",
"minute": 62
}
],
"goals2": [
{
"name": "Enrique Borja",
"minute": 48
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-15",
"time": "19:30",
"team1": "Uruguay",
"team2": "France",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Pedro Rocha",
"minute": 26
},
{
"name": "Julio César Cortés",
"minute": 31
}
],
"goals2": [
{
"name": "Héctor De Bourgoing",
"minute": 15,
"penalty": true
}
],
"ground": "White City Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-16",
"time": "19:30",
"team1": "England",
"team2": "Mexico",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Bobby Charlton",
"minute": 37
},
{
"name": "Roger Hunt",
"minute": 75
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-19",
"time": "16:30",
"team1": "Mexico",
"team2": "Uruguay",
"score": {
"ft": [
0,
0
]
},
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1966-07-20",
"time": "19:30",
"team1": "England",
"team2": "France",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Roger Hunt",
"minute": 38
},
{
"name": "Roger Hunt",
"minute": 75
}
],
"ground": "Wembley Stadium, London"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-12",
"time": "19:30",
"team1": "Germany",
"team2": "Switzerland",
"score": {
"ft": [
5,
0
]
},
"goals1": [
{
"name": "Sigfried Held",
"minute": 16
},
{
"name": "Helmut Haller",
"minute": 21
},
{
"name": "Helmut Haller",
"minute": 77,
"penalty": true
},
{
"name": "Franz Beckenbauer",
"minute": 40
},
{
"name": "Franz Beckenbauer",
"minute": 52
}
],
"ground": "Hillsborough Stadium, Sheffield"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-13",
"time": "19:30",
"team1": "Argentina",
"team2": "Spain",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Luis Artime",
"minute": 66
},
{
"name": "Luis Artime",
"minute": 79
}
],
"goals2": [
{
"name": "Antonio Roma",
"minute": 72,
"owngoal": true
}
],
"ground": "Villa Park, Birmingham"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-15",
"time": "19:30",
"team1": "Spain",
"team2": "Switzerland",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Manuel Sanchís Martínez",
"minute": 57
},
{
"name": "Amancio Amaro",
"minute": 75
}
],
"goals2": [
{
"name": "René-Pierre Quentin",
"minute": 31
}
],
"ground": "Hillsborough Stadium, Sheffield"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-16",
"time": "15:00",
"team1": "Argentina",
"team2": "Germany",
"score": {
"ft": [
0,
0
]
},
"ground": "Villa Park, Birmingham"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-19",
"time": "19:30",
"team1": "Argentina",
"team2": "Switzerland",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Luis Artime",
"minute": 52
},
{
"name": "Ermindo Onega",
"minute": 79
}
],
"ground": "Hillsborough Stadium, Sheffield"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1966-07-20",
"time": "19:30",
"team1": "Germany",
"team2": "Spain",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Lothar Emmerich",
"minute": 39
},
{
"name": "Uwe Seeler",
"minute": 84
}
],
"goals2": [
{
"name": "Josep Maria Fusté",
"minute": 23
}
],
"ground": "Villa Park, Birmingham"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-12",
"time": "19:30",
"team1": "Brazil",
"team2": "Bulgaria",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Pelé",
"minute": 15
},
{
"name": "Garrincha",
"minute": 63
}
],
"ground": "Goodison Park, Liverpool"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-13",
"time": "19:30",
"team1": "Portugal",
"team2": "Hungary",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "José Augusto de Almeida",
"minute": 2
},
{
"name": "José Augusto de Almeida",
"minute": 67
},
{
"name": "José Augusto Torres",
"minute": 90
}
],
"goals2": [
{
"name": "Ferenc Bene",
"minute": 60
}
],
"ground": "Old Trafford, Manchester"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-15",
"time": "19:30",
"team1": "Hungary",
"team2": "Brazil",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Ferenc Bene",
"minute": 2
},
{
"name": "János Farkas",
"minute": 64
},
{
"name": "Kálmán Mészöly",
"minute": 73,
"penalty": true
}
],
"goals2": [
{
"name": "Tostão",
"minute": 14
}
],
"ground": "Goodison Park, Liverpool"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-16",
"time": "15:00",
"team1": "Portugal",
"team2": "Bulgaria",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Ivan Vutsov",
"minute": 7,
"owngoal": true
},
{
"name": "Eusébio",
"minute": 38
},
{
"name": "José Augusto Torres",
"minute": 81
}
],
"ground": "Old Trafford, Manchester"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-19",
"time": "19:30",
"team1": "Portugal",
"team2": "Brazil",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "António Simões",
"minute": 15
},
{
"name": "Eusébio",
"minute": 27
},
{
"name": "Eusébio",
"minute": 85
}
],
"goals2": [
{
"name": "Rildo da Costa Menezes",
"minute": 73
}
],
"ground": "Goodison Park, Liverpool"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1966-07-20",
"time": "19:30",
"team1": "Hungary",
"team2": "Bulgaria",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Ivan Davidov",
"minute": 43,
"owngoal": true
},
{
"name": "Kálmán Mészöly",
"minute": 45
},
{
"name": "Ferenc Bene",
"minute": 54
}
],
"goals2": [
{
"name": "Georgi Asparuhov",
"minute": 15
}
],
"ground": "Old Trafford, Manchester"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-12",
"time": "19:30",
"team1": "Soviet Union",
"team2": "North Korea",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Eduard Malofeyev",
"minute": 31
},
{
"name": "Eduard Malofeyev",
"minute": 88
},
{
"name": "Anatoliy Banishevskiy",
"minute": 33
}
],
"ground": "Ayresome Park, Middlesbrough"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-13",
"time": "19:30",
"team1": "Italy",
"team2": "Chile",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Sandro Mazzola",
"minute": 8
},
{
"name": "Paolo Barison",
"minute": 88
}
],
"ground": "Roker Park, Sunderland"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-15",
"time": "19:30",
"team1": "Chile",
"team2": "North Korea",
"score": {
"ft": [
1,
1
]
},
"goals1": [
{
"name": "Rubén Marcos",
"minute": 26,
"penalty": true
}
],
"goals2": [
{
"name": "Pak Seung-zin",
"minute": 88
}
],
"ground": "Ayresome Park, Middlesbrough"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-16",
"time": "15:00",
"team1": "Soviet Union",
"team2": "Italy",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Igor Chislenko",
"minute": 57
}
],
"ground": "Roker Park, Sunderland"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-19",
"time": "19:30",
"team1": "North Korea",
"team2": "Italy",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Pak Doo-ik",
"minute": 42
}
],
"ground": "Ayresome Park, Middlesbrough"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1966-07-20",
"time": "19:30",
"team1": "Soviet Union",
"team2": "Chile",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Valeriy Porkujan",
"minute": 28
},
{
"name": "Valeriy Porkujan",
"minute": 85
}
],
"goals2": [
{
"name": "Rubén Marcos",
"minute": 32
}
],
"ground": "Roker Park, Sunderland"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "England",
"teams_count": 16,
"winner": "England",
"runner_up": "Germany",
"third_place": "Portugal",
"fourth_place": "Soviet Union"
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"stadiums": [
{
"name": "Wembley Stadium",
"city": "London"
},
{
"name": "Hillsborough Stadium",
"city": "Sheffield"
},
{
"name": "Roker Park",
"city": "Sunderland"
},
{
"name": "Goodison Park",
"city": "Liverpool"
},
{
"name": "White City Stadium",
"city": "London"
},
{
"name": "Villa Park",
"city": "Birmingham"
},
{
"name": "Old Trafford",
"city": "Manchester"
},
{
"name": "Ayresome Park",
"city": "Middlesbrough"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Mexico",
"Soviet Union",
"Belgium",
"El Salvador"
]
},
{
"name": "Group 2",
"teams": [
"Uruguay",
"Israel",
"Italy",
"Sweden"
]
},
{
"name": "Group 3",
"teams": [
"England",
"Romania",
"Brazil",
"Czechoslovakia"
]
},
{
"name": "Group 4",
"teams": [
"Peru",
"Bulgaria",
"Germany",
"Morocco"
]
}
]
}
+965
View File
@@ -0,0 +1,965 @@
{
"matches": [
{
"round": "Quarter-finals",
"date": "1970-06-14",
"time": "12:00",
"team1": "Soviet Union",
"team2": "Uruguay",
"score": {
"ft": [
0,
0
],
"et": [
0,
1
]
},
"goals2": [
{
"name": "Víctor Espárrago",
"minute": 117
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Quarter-finals",
"date": "1970-06-14",
"time": "12:00",
"team1": "Italy",
"team2": "Mexico",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Javier Guzmán",
"minute": 25,
"owngoal": true
},
{
"name": "Gigi Riva",
"minute": 63
},
{
"name": "Gigi Riva",
"minute": 76
},
{
"name": "Gianni Rivera",
"minute": 70
}
],
"goals2": [
{
"name": "José Luis González Dávila",
"minute": 13
}
],
"ground": "Estadio Luis Dosal, Toluca"
},
{
"round": "Quarter-finals",
"date": "1970-06-14",
"time": "12:00",
"team1": "Brazil",
"team2": "Peru",
"score": {
"ft": [
4,
2
]
},
"goals1": [
{
"name": "Rivellino",
"minute": 11
},
{
"name": "Tostão",
"minute": 15
},
{
"name": "Tostão",
"minute": 52
},
{
"name": "Jairzinho",
"minute": 75
}
],
"goals2": [
{
"name": "Alberto Gallardo",
"minute": 28
},
{
"name": "Teófilo Cubillas",
"minute": 70
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Quarter-finals",
"date": "1970-06-14",
"time": "12:00",
"team1": "Germany",
"team2": "England",
"score": {
"ft": [
2,
2
],
"et": [
3,
2
]
},
"goals1": [
{
"name": "Franz Beckenbauer",
"minute": 68
},
{
"name": "Uwe Seeler",
"minute": 82
},
{
"name": "Gerd Müller",
"minute": 108
}
],
"goals2": [
{
"name": "Alan Mullery",
"minute": 31
},
{
"name": "Martin Peters",
"minute": 49
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Semi-finals",
"date": "1970-06-17",
"time": "16:00",
"team1": "Brazil",
"team2": "Uruguay",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Clodoaldo",
"minute": 44
},
{
"name": "Jairzinho",
"minute": 76
},
{
"name": "Rivellino",
"minute": 89
}
],
"goals2": [
{
"name": "Luis Cubilla",
"minute": 19
}
],
"ground": "Estadio Jalisco, Guadalajara[a]"
},
{
"round": "Semi-finals",
"date": "1970-06-17",
"time": "16:00",
"team1": "Italy",
"team2": "Germany",
"score": {
"ft": [
1,
1
],
"et": [
4,
3
]
},
"goals1": [
{
"name": "Roberto Boninsegna",
"minute": 8
},
{
"name": "Tarcisio Burgnich",
"minute": 98
},
{
"name": "Gigi Riva",
"minute": 104
},
{
"name": "Gianni Rivera",
"minute": 111
}
],
"goals2": [
{
"name": "Karl-Heinz Schnellinger",
"minute": 90,
"offset": 2
},
{
"name": "Gerd Müller",
"minute": 94
},
{
"name": "Gerd Müller",
"minute": 110
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Third-place match",
"date": "1970-06-20",
"time": "16:00",
"team1": "Germany",
"team2": "Uruguay",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Wolfgang Overath",
"minute": 26
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Final",
"date": "1970-06-21",
"time": "12:00",
"team1": "Brazil",
"team2": "Italy",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Pelé",
"minute": 18
},
{
"name": "Gérson",
"minute": 66
},
{
"name": "Jairzinho",
"minute": 71
},
{
"name": "Carlos Alberto Torres",
"minute": 86
}
],
"goals2": [
{
"name": "Roberto Boninsegna",
"minute": 37
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-05-31",
"time": "12:00",
"team1": "Mexico",
"team2": "Soviet Union",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-06-03",
"time": "16:00",
"team1": "Belgium",
"team2": "El Salvador",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Wilfried Van Moer",
"minute": 12
},
{
"name": "Wilfried Van Moer",
"minute": 54
},
{
"name": "Raoul Lambert",
"minute": 79,
"penalty": true
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-06-06",
"time": "16:00",
"team1": "Soviet Union",
"team2": "Belgium",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Anatoliy Byshovets",
"minute": 14
},
{
"name": "Anatoliy Byshovets",
"minute": 63
},
{
"name": "Kakhi Asatiani",
"minute": 57
},
{
"name": "Vitaliy Khmelnytskyi",
"minute": 76
}
],
"goals2": [
{
"name": "Raoul Lambert",
"minute": 86
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-06-07",
"time": "12:00",
"team1": "Mexico",
"team2": "El Salvador",
"score": {
"ft": [
4,
0
]
},
"goals1": [
{
"name": "Javier Valdivia",
"minute": 45
},
{
"name": "Javier Valdivia",
"minute": 46
},
{
"name": "Javier Fragoso",
"minute": 58
},
{
"name": "Juan Ignacio Basaguren",
"minute": 83
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-06-10",
"time": "16:00",
"team1": "Soviet Union",
"team2": "El Salvador",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Anatoliy Byshovets",
"minute": 51
},
{
"name": "Anatoliy Byshovets",
"minute": 74
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 1",
"date": "1970-06-11",
"time": "16:00",
"team1": "Mexico",
"team2": "Belgium",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Gustavo Peña",
"minute": 14,
"penalty": true
}
],
"ground": "Estadio Azteca, Mexico City"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-02",
"time": "16:00",
"team1": "Uruguay",
"team2": "Israel",
"score": {
"ft": [
2,
0
]
},
"goals1": [
{
"name": "Ildo Maneiro",
"minute": 23
},
{
"name": "Juan Mujica",
"minute": 50
}
],
"ground": "Estadio Cuauhtémoc, Puebla"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-03",
"time": "16:00",
"team1": "Italy",
"team2": "Sweden",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Angelo Domenghini",
"minute": 10
}
],
"ground": "Estadio Luis Dosal, Toluca"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-06",
"time": "16:00",
"team1": "Uruguay",
"team2": "Italy",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio Cuauhtémoc, Puebla"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-07",
"time": "12:00",
"team1": "Sweden",
"team2": "Israel",
"score": {
"ft": [
1,
1
]
},
"goals1": [
{
"name": "Tom Turesson",
"minute": 53
}
],
"goals2": [
{
"name": "Mordechai Spiegler",
"minute": 56
}
],
"ground": "Estadio Luis Dosal, Toluca"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-10",
"time": "16:00",
"team1": "Sweden",
"team2": "Uruguay",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Ove Grahn",
"minute": 90
}
],
"ground": "Estadio Cuauhtémoc, Puebla"
},
{
"round": "Group stage",
"group": "Group 2",
"date": "1970-06-11",
"time": "16:00",
"team1": "Italy",
"team2": "Israel",
"score": {
"ft": [
0,
0
]
},
"ground": "Estadio Luis Dosal, Toluca"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-02",
"time": "16:00",
"team1": "England",
"team2": "Romania",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Geoff Hurst",
"minute": 65
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-03",
"time": "16:00",
"team1": "Brazil",
"team2": "Czechoslovakia",
"score": {
"ft": [
4,
1
]
},
"goals1": [
{
"name": "Rivellino",
"minute": 24
},
{
"name": "Pelé",
"minute": 59
},
{
"name": "Jairzinho",
"minute": 61
},
{
"name": "Jairzinho",
"minute": 83
}
],
"goals2": [
{
"name": "Ladislav Petráš",
"minute": 11
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-06",
"time": "16:00",
"team1": "Romania",
"team2": "Czechoslovakia",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Alexandru Neagu",
"minute": 52
},
{
"name": "Florea Dumitrache",
"minute": 75,
"penalty": true
}
],
"goals2": [
{
"name": "Ladislav Petráš",
"minute": 5
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-07",
"time": "12:00",
"team1": "Brazil",
"team2": "England",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Jairzinho",
"minute": 59
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-10",
"time": "16:00",
"team1": "Brazil",
"team2": "Romania",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Pelé",
"minute": 19
},
{
"name": "Pelé",
"minute": 67
},
{
"name": "Jairzinho",
"minute": 22
}
],
"goals2": [
{
"name": "Florea Dumitrache",
"minute": 34
},
{
"name": "Emerich Dembrovschi",
"minute": 84
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 3",
"date": "1970-06-11",
"time": "16:00",
"team1": "England",
"team2": "Czechoslovakia",
"score": {
"ft": [
1,
0
]
},
"goals1": [
{
"name": "Allan Clarke",
"minute": 50,
"penalty": true
}
],
"ground": "Estadio Jalisco, Guadalajara"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-02",
"time": "16:00",
"team1": "Peru",
"team2": "Bulgaria",
"score": {
"ft": [
3,
2
]
},
"goals1": [
{
"name": "Alberto Gallardo",
"minute": 50
},
{
"name": "Héctor Chumpitaz",
"minute": 55
},
{
"name": "Teófilo Cubillas",
"minute": 73
}
],
"goals2": [
{
"name": "Dinko Dermendzhiev",
"minute": 13
},
{
"name": "Hristo Bonev",
"minute": 49
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-03",
"time": "16:00",
"team1": "Germany",
"team2": "Morocco",
"score": {
"ft": [
2,
1
]
},
"goals1": [
{
"name": "Uwe Seeler",
"minute": 56
},
{
"name": "Gerd Müller",
"minute": 80
}
],
"goals2": [
{
"name": "Houmane Jarir",
"minute": 21
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-06",
"time": "16:00",
"team1": "Peru",
"team2": "Morocco",
"score": {
"ft": [
3,
0
]
},
"goals1": [
{
"name": "Teófilo Cubillas",
"minute": 65
},
{
"name": "Teófilo Cubillas",
"minute": 75
},
{
"name": "Roberto Challe",
"minute": 67
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-07",
"time": "12:00",
"team1": "Germany",
"team2": "Bulgaria",
"score": {
"ft": [
5,
2
]
},
"goals1": [
{
"name": "Reinhard Libuda",
"minute": 20
},
{
"name": "Gerd Müller",
"minute": 27
},
{
"name": "Gerd Müller",
"minute": 52,
"penalty": true
},
{
"name": "Gerd Müller",
"minute": 88
},
{
"name": "Uwe Seeler",
"minute": 70
}
],
"goals2": [
{
"name": "Asparuh Nikodimov",
"minute": 12
},
{
"name": "Todor Kolev",
"minute": 89
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-10",
"time": "16:00",
"team1": "Germany",
"team2": "Peru",
"score": {
"ft": [
3,
1
]
},
"goals1": [
{
"name": "Gerd Müller",
"minute": 19
},
{
"name": "Gerd Müller",
"minute": 26
},
{
"name": "Gerd Müller",
"minute": 39
}
],
"goals2": [
{
"name": "Teófilo Cubillas",
"minute": 44
}
],
"ground": "Estadio Nou Camp, León"
},
{
"round": "Group stage",
"group": "Group 4",
"date": "1970-06-11",
"time": "16:00",
"team1": "Bulgaria",
"team2": "Morocco",
"score": {
"ft": [
1,
1
]
},
"goals1": [
{
"name": "Dobromir Zhechev",
"minute": 40
}
],
"goals2": [
{
"name": "Maouhoub Ghazouani",
"minute": 61
}
],
"ground": "Estadio Nou Camp, León"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Mexico",
"teams_count": 16,
"winner": "Brazil",
"runner_up": "Italy",
"third_place": "Germany",
"fourth_place": "Uruguay"
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"stadiums": [
{
"name": "Estadio Azteca",
"city": "Mexico City"
},
{
"name": "Estadio Luis Dosal",
"city": "Toluca"
},
{
"name": "Estadio Jalisco",
"city": "Guadalajara"
},
{
"name": "Estadio Nou Camp",
"city": "León"
},
{
"name": "Estadio Cuauhtémoc",
"city": "Puebla"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Germany",
"Chile",
"East Germany",
"Australia"
]
},
{
"name": "Group 2",
"teams": [
"Brazil",
"Yugoslavia",
"Zaire",
"Scotland"
]
},
{
"name": "Group 3",
"teams": [
"Uruguay",
"Netherlands",
"Sweden",
"Bulgaria"
]
},
{
"name": "Group 4",
"teams": [
"Italy",
"Haiti",
"Poland",
"Argentina"
]
},
{
"name": "Group A",
"teams": [
"Netherlands",
"Argentina",
"Brazil",
"East Germany"
]
},
{
"name": "Group B",
"teams": [
"Yugoslavia",
"Germany",
"Sweden",
"Poland"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "West Germany",
"teams_count": 16,
"winner": "Germany",
"runner_up": "Netherlands",
"third_place": "Poland",
"fourth_place": "Brazil"
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"stadiums": [
{
"name": "Olympiastadion",
"city": "Munich"
},
{
"name": "Volksparkstadion",
"city": "Hamburg"
},
{
"name": "Waldstadion",
"city": "Frankfurt"
},
{
"name": "Westfalenstadion",
"city": "Dortmund"
},
{
"name": "Parkstadion",
"city": "Gelsenkirchen"
},
{
"name": "Niedersachsenstadion",
"city": "Hanover"
},
{
"name": "Rheinstadion",
"city": "Düsseldorf"
},
{
"name": "Neckarstadion",
"city": "Stuttgart"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Italy",
"France",
"Argentina",
"Hungary"
]
},
{
"name": "Group 2",
"teams": [
"Germany",
"Poland",
"Tunisia",
"Mexico"
]
},
{
"name": "Group 3",
"teams": [
"Austria",
"Spain",
"Brazil",
"Sweden"
]
},
{
"name": "Group 4",
"teams": [
"Peru",
"Scotland",
"Netherlands",
"Iran"
]
},
{
"name": "Group A",
"teams": [
"Austria",
"Netherlands",
"Italy",
"Germany"
]
},
{
"name": "Group B",
"teams": [
"Brazil",
"Peru",
"Argentina",
"Poland"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Argentina",
"teams_count": 16,
"winner": "Argentina",
"runner_up": "Netherlands",
"third_place": "Brazil",
"fourth_place": "Italy"
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"stadiums": [
{
"name": "River Plate Stadium",
"city": "Buenos Aires"
},
{
"name": "Estadio José María Minella",
"city": "Mar del Plata"
},
{
"name": "Estadio Monumental",
"city": "Buenos Aires"
},
{
"name": "Estadio Gigante de Arroyito",
"city": "Rosario"
},
{
"name": "Estadio Chateau Carreras",
"city": "Córdoba"
},
{
"name": "Estadio Olímpico Chateau Carreras",
"city": "Córdoba"
},
{
"name": "Estadio José Amalfitani",
"city": "Buenos Aires"
},
{
"name": "Estadio José Maria Minella",
"city": "Mar del Plata"
},
{
"name": "Chateau Carreras",
"city": "Córdoba"
},
{
"name": "Estadio Ciudad de Mendoza",
"city": "Mendoza"
},
{
"name": "Estadio Malvinas Argentinas",
"city": "Mendoza"
}
]
}
+90
View File
@@ -0,0 +1,90 @@
{
"groups": [
{
"name": "Group 1",
"teams": [
"Italy",
"Poland",
"Peru",
"Cameroon"
]
},
{
"name": "Group 2",
"teams": [
"Germany",
"Algeria",
"Chile",
"Austria"
]
},
{
"name": "Group 3",
"teams": [
"Argentina",
"Belgium",
"Hungary",
"El Salvador"
]
},
{
"name": "Group 4",
"teams": [
"England",
"France",
"Czechoslovakia",
"Kuwait"
]
},
{
"name": "Group 5",
"teams": [
"Spain",
"Honduras",
"Yugoslavia",
"Northern Ireland"
]
},
{
"name": "Group 6",
"teams": [
"Brazil",
"Soviet Union",
"Scotland",
"New Zealand"
]
},
{
"name": "Group A",
"teams": [
"Poland",
"Belgium",
"Soviet Union"
]
},
{
"name": "Group B",
"teams": [
"Germany",
"England",
"Spain"
]
},
{
"name": "Group C",
"teams": [
"Italy",
"Argentina",
"Brazil"
]
},
{
"name": "Group D",
"teams": [
"Austria",
"France",
"Northern Ireland"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Spain",
"teams_count": 24,
"winner": "Italy",
"runner_up": "Germany",
"third_place": "Poland",
"fourth_place": "France"
}
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"stadiums": [
{
"name": "Camp Nou",
"city": "Barcelona"
},
{
"name": "Ramón Sánchez Pizjuán Stadium",
"city": "Seville"
},
{
"name": "Estadio José Rico Pérez",
"city": "Alicante"
},
{
"name": "Santiago Bernabéu",
"city": "Madrid"
},
{
"name": "Balaídos",
"city": "Vigo"
},
{
"name": "Estadio de Riazor",
"city": "A Coruña"
},
{
"name": "El Molinón",
"city": "Gijón"
},
{
"name": "Estadio Carlos Tartiere",
"city": "Oviedo"
},
{
"name": "Nuevo Estadio",
"city": "Elche"
},
{
"name": "San Mamés",
"city": "Bilbao"
},
{
"name": "Estadio José Zorrilla",
"city": "Valladolid"
},
{
"name": "Estadio Luis Casanova",
"city": "Valencia"
},
{
"name": "La Romareda",
"city": "Zaragoza"
},
{
"name": "Ramón Sánchez Pizjuán",
"city": "Seville"
},
{
"name": "La Rosaleda Stadium",
"city": "Málaga"
},
{
"name": "Estadio Benito Villamarín",
"city": "Seville"
},
{
"name": "Sarrià Stadium",
"city": "Barcelona"
},
{
"name": "Estadio Sarriá",
"city": "Barcelona"
},
{
"name": "Vicente Calderón",
"city": "Madrid"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"groups": [
{
"name": "Group A",
"teams": [
"Bulgaria",
"Italy",
"Argentina",
"South Korea"
]
},
{
"name": "Group B",
"teams": [
"Belgium",
"Mexico",
"Paraguay",
"Iraq"
]
},
{
"name": "Group C",
"teams": [
"Canada",
"France",
"Soviet Union",
"Hungary"
]
},
{
"name": "Group D",
"teams": [
"Spain",
"Brazil",
"Algeria",
"Northern Ireland"
]
},
{
"name": "Group E",
"teams": [
"Uruguay",
"Germany",
"Scotland",
"Denmark"
]
},
{
"name": "Group F",
"teams": [
"Morocco",
"Poland",
"Portugal",
"England"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Mexico",
"teams_count": 24,
"winner": "Argentina",
"runner_up": "Germany",
"third_place": "France",
"fourth_place": "Belgium"
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"stadiums": [
{
"name": "Estadio Azteca",
"city": "Mexico City"
},
{
"name": "Estadio Nou Camp",
"city": "León"
},
{
"name": "Estadio Jalisco",
"city": "Guadalajara"
},
{
"name": "Estadio Cuauhtémoc",
"city": "Puebla"
},
{
"name": "Estadio Olímpico Universitario",
"city": "Mexico City"
},
{
"name": "Estadio Universitario",
"city": "San Nicolás de los Garza"
},
{
"name": "Estadio La Corregidora",
"city": "Querétaro"
},
{
"name": "Estadio Toluca 7086",
"city": "Toluca"
},
{
"name": "Estadio Sergio León Chavez",
"city": "Irapuato"
},
{
"name": "Estadio Tres de Marzo",
"city": "Zapopan"
},
{
"name": "Estadio Tecnológico",
"city": "Monterrey"
},
{
"name": "Estadio Neza 86",
"city": "Nezahualcóyotl"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"groups": [
{
"name": "Group A",
"teams": [
"Italy",
"Austria",
"United States",
"Czechoslovakia"
]
},
{
"name": "Group B",
"teams": [
"Argentina",
"Cameroon",
"Soviet Union",
"Romania"
]
},
{
"name": "Group C",
"teams": [
"Brazil",
"Sweden",
"Costa Rica",
"Scotland"
]
},
{
"name": "Group D",
"teams": [
"United Arab Emirates",
"Colombia",
"Germany",
"Yugoslavia"
]
},
{
"name": "Group E",
"teams": [
"Belgium",
"South Korea",
"Uruguay",
"Spain"
]
},
{
"name": "Group F",
"teams": [
"England",
"Republic of Ireland",
"Netherlands",
"Egypt"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "Italy",
"teams_count": 24,
"winner": "Germany",
"runner_up": "Argentina",
"third_place": "Italy",
"fourth_place": "England"
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"stadiums": [
{
"name": "Stadio San Paolo",
"city": "Naples"
},
{
"name": "Stadio San Nicola",
"city": "Bari"
},
{
"name": "Stadio Delle Alpi",
"city": "Turin"
},
{
"name": "San Siro",
"city": "Milan"
},
{
"name": "Stadio Luigi Ferraris",
"city": "Genoa"
},
{
"name": "Stadio Olimpico",
"city": "Rome"
},
{
"name": "Stadio Marc'Antonio Bentegodi",
"city": "Verona"
},
{
"name": "Stadio Renato Dall'Ara",
"city": "Bologna"
},
{
"name": "Stadio Comunale",
"city": "Florence"
},
{
"name": "Stadio delle Alpi",
"city": "Turin"
},
{
"name": "Stadio Friuli",
"city": "Udine"
},
{
"name": "Stadio Sant'Elia",
"city": "Cagliari"
},
{
"name": "Stadio La Favorita",
"city": "Palermo"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"groups": [
{
"name": "Group A",
"teams": [
"United States",
"Switzerland",
"Colombia",
"Romania"
]
},
{
"name": "Group B",
"teams": [
"Cameroon",
"Sweden",
"Brazil",
"Russia"
]
},
{
"name": "Group C",
"teams": [
"Germany",
"Bolivia",
"Spain",
"South Korea"
]
},
{
"name": "Group D",
"teams": [
"Argentina",
"Greece",
"Nigeria",
"Bulgaria"
]
},
{
"name": "Group E",
"teams": [
"Italy",
"Republic of Ireland",
"Norway",
"Mexico"
]
},
{
"name": "Group F",
"teams": [
"Belgium",
"Morocco",
"Netherlands",
"Saudi Arabia"
]
}
]
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"host": "United States",
"teams_count": 24,
"winner": "Brazil",
"runner_up": "Italy",
"third_place": "Sweden",
"fourth_place": "Bulgaria"
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More