From 58d930f28937039255b9029af3a84684346a0574 Mon Sep 17 00:00:00 2001 From: mandalore Date: Fri, 4 Sep 2026 15:55:25 -0500 Subject: [PATCH] Add events.gateway-gamers.net: upcoming events across BCP circuits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 5th site in the monorepo, following the same shared header/footer/ images pattern as the others. Shows upcoming events for a configurable list of BCP circuits (CONFIG.circuits in sites/events/src/main.js) — adding a circuit is a one-line edit. Data comes from the BCP API (`GET /v1/events?leagueId=`, same auth-free headers the other sites already use) — "circuit" in BCP's frontend URLs maps to `leagueId` in the API, confirmed via /v1/leagues/ returning circuitLeague: true. Paginates newest-date- first and stops once a full page has entirely concluded, so it doesn't walk a circuit's whole history just to find the handful of upcoming events. Verified against the live API for all 4 configured circuits before writing this commit. Nav cross-links with scouting only (per request), not the other 3 sites. nginx conf follows the same shared-asset-alias pattern as the rest; TLS/DNS for the new subdomain still need to be provisioned before it can go live (see follow-up). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK --- .gitlab-ci.yml | 33 ++++ sites/events/.gitignore | 4 + sites/events/events.gateway-gamers.net.conf | 46 +++++ sites/events/index.html | 45 +++++ sites/events/nav.html | 2 + sites/events/package.json | 19 ++ sites/events/postcss.config.js | 5 + sites/events/public/footer.html | 1 + sites/events/public/header.html | 59 ++++++ sites/events/public/images | 1 + sites/events/src/main.js | 196 ++++++++++++++++++++ sites/events/src/style.css | 7 + sites/events/vite.config.js | 15 ++ sites/scouting/nav.html | 1 + sites/scouting/public/header.html | 2 + 15 files changed, 436 insertions(+) create mode 100644 sites/events/.gitignore create mode 100644 sites/events/events.gateway-gamers.net.conf create mode 100644 sites/events/index.html create mode 100644 sites/events/nav.html create mode 100644 sites/events/package.json create mode 100644 sites/events/postcss.config.js create mode 120000 sites/events/public/footer.html create mode 100644 sites/events/public/header.html create mode 120000 sites/events/public/images create mode 100644 sites/events/src/main.js create mode 100644 sites/events/src/style.css create mode 100644 sites/events/vite.config.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5efb24d..29288ce 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -176,3 +176,36 @@ deploy:scouting: environment: name: production url: https://scouting.gateway-gamers.net + +# --------------------------------------------------------------------------- +# events +# --------------------------------------------------------------------------- + +build:events: + stage: build + variables: + SITE: events + script: *build_script + artifacts: + paths: + - sites/events/dist/ + expire_in: 1 hour + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + changes: + - sites/events/**/* + - shared/**/* + +deploy:events: + stage: deploy + needs: ["build:events"] + script: + - rsync -avz --delete --exclude images --exclude footer.html sites/events/dist/ /var/www/domains/gateway-gamers.net/events/ + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + changes: + - sites/events/**/* + - shared/**/* + environment: + name: production + url: https://events.gateway-gamers.net diff --git a/sites/events/.gitignore b/sites/events/.gitignore new file mode 100644 index 0000000..dafa699 --- /dev/null +++ b/sites/events/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +.env.local diff --git a/sites/events/events.gateway-gamers.net.conf b/sites/events/events.gateway-gamers.net.conf new file mode 100644 index 0000000..cde173c --- /dev/null +++ b/sites/events/events.gateway-gamers.net.conf @@ -0,0 +1,46 @@ +server { + listen 80; + listen [::]:80; + server_name events.gateway-gamers.net; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name events.gateway-gamers.net; + + root /var/www/domains/gateway-gamers.net/events; + index index.html; + + # SSL — managed by Certbot + # ssl_certificate /etc/letsencrypt/live/events.gateway-gamers.net/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/events.gateway-gamers.net/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Hide nginx version + server_tokens off; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always; + + # Shared assets (images + footer) — single on-disk copy under shared/, + # deployed once and aliased by every gateway-gamers.net site. + location /images/ { + alias /var/www/domains/gateway-gamers.net/shared/images/; + } + + location = /footer.html { + alias /var/www/domains/gateway-gamers.net/shared/footer.html; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/sites/events/index.html b/sites/events/index.html new file mode 100644 index 0000000..d7d7c4c --- /dev/null +++ b/sites/events/index.html @@ -0,0 +1,45 @@ + + + + + + Gateway Gamers — Events + + + + + + +
+ + +
+

Upcoming Events

+

Across the circuits Gateway Gamers follows

+
+ + +
+
+
+
+
+
+
+ + + + +
+ +

+ Data from Best Coast Pairings · showing only upcoming events, soonest first +

+ +
+ + + + + + diff --git a/sites/events/nav.html b/sites/events/nav.html new file mode 100644 index 0000000..7ee81df --- /dev/null +++ b/sites/events/nav.html @@ -0,0 +1,2 @@ +
  • Events
  • +
  • Scouting
  • diff --git a/sites/events/package.json b/sites/events/package.json new file mode 100644 index 0000000..810230a --- /dev/null +++ b/sites/events/package.json @@ -0,0 +1,19 @@ +{ + "name": "events-gateway-gamers", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "predev": "node ../../scripts/render-header.mjs events", + "dev": "vite", + "prebuild": "node ../../scripts/render-header.mjs events", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "daisyui": "^5.0.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } +} diff --git a/sites/events/postcss.config.js b/sites/events/postcss.config.js new file mode 100644 index 0000000..1d5a3b2 --- /dev/null +++ b/sites/events/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {} + } +} diff --git a/sites/events/public/footer.html b/sites/events/public/footer.html new file mode 120000 index 0000000..f9e4b6b --- /dev/null +++ b/sites/events/public/footer.html @@ -0,0 +1 @@ +../../../shared/footer.html \ No newline at end of file diff --git a/sites/events/public/header.html b/sites/events/public/header.html new file mode 100644 index 0000000..1e4e482 --- /dev/null +++ b/sites/events/public/header.html @@ -0,0 +1,59 @@ +
    + + +
    + + + + + +
    + + +
    + + Gateway Gamers + +
    + +
    + + + + +
    diff --git a/sites/events/public/images b/sites/events/public/images new file mode 120000 index 0000000..c7406c1 --- /dev/null +++ b/sites/events/public/images @@ -0,0 +1 @@ +../../../shared/images \ No newline at end of file diff --git a/sites/events/src/main.js b/sites/events/src/main.js new file mode 100644 index 0000000..d0e7a1b --- /dev/null +++ b/sites/events/src/main.js @@ -0,0 +1,196 @@ +/* ---- Shared: header / footer ---- */ +function loadHTML(file, elementId, callback) { + fetch(file) + .then(r => r.text()) + .then(html => { + document.getElementById(elementId).innerHTML = html; + if (callback) callback(); + }) + .catch(err => console.error('Error loading:', file, err)); +} + +function highlightActiveNav() { + const current = location.pathname.split('/').pop() || 'index.html'; + document.querySelectorAll('#header a[href]').forEach(link => { + if (link.getAttribute('href') === `/${current}`) { + link.classList.add('active'); + } + }); +} + +function startCarousel() { + const slides = document.querySelectorAll('#hero-carousel .carousel-slide'); + if (!slides.length) return; + let current = 0; + slides[current].style.opacity = '1'; + setInterval(() => { + slides[current].style.opacity = '0'; + current = (current + 1) % slides.length; + slides[current].style.opacity = '1'; + }, 5000); +} + +loadHTML('/header.html', 'header', () => { + highlightActiveNav(); + startCarousel(); +}); +loadHTML('/footer.html', 'footer'); + +/* ============================================================ + CONFIG — the circuits shown on the page. Add/remove/edit + entries here; the page builds one section per circuit. + `id` is the leagueId from a circuit's BCP calendar URL, + e.g. bestcoastpairings.com/circuit//calendar + ============================================================ */ +const CONFIG = { + circuits: [ + { id: "Teqdr6p0aGvO", name: "Away Games 2025/26 ITC Season" }, + { id: "t5kk3NvGDMXc", name: "National Tabletop League (NTL) 8-Player Circuit" }, + { id: "m4OIU24N0IZO", name: "TEO 2026 Circuit" }, + { id: "cY0LeCAPfyiG", name: "2026 Lord Marshal Conference" }, + ], +}; + +/* ---- BCP API ---- */ +const API = "https://newprod-api.bestcoastpairings.com/v1"; +const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" }; +const BCP_ORIGIN = "https://www.bestcoastpairings.com"; + +// Cursor-paginated events for a league/circuit, newest-date-first (BCP +// returns the same cursor when there are no more results — stop when it +// loops, same guard as the other sites' streamPlacings). +async function* streamEvents(leagueId) { + const base = `${API}/events?leagueId=${leagueId}&sortAscending=false&limit=50`; + + let nextKey = null; + let prevNextKey = undefined; + + do { + const url = nextKey ? `${base}&nextKey=${encodeURIComponent(nextKey)}` : base; + const r = await fetch(url, { headers: HEADERS, credentials: "omit" }); + if (!r.ok) throw new Error("events/" + r.status); + const p = await r.json(); + const page = Array.isArray(p) ? p : (p.data || []); + + yield page; + + const rawKey = p.nextKey; + nextKey = !rawKey ? null + : typeof rawKey === "string" ? rawKey + : btoa(JSON.stringify(rawKey)); + + if (nextKey === prevNextKey) break; + prevNextKey = nextKey; + } while (nextKey); +} + +// Sorted newest-first, so we can stop as soon as we cross into the past +// instead of paging through a circuit's entire history every time. +async function fetchUpcomingEvents(leagueId) { + const now = Date.now(); + const upcoming = []; + for await (const page of streamEvents(leagueId)) { + if (!page.length) break; + for (const ev of page) { + const end = Date.parse(ev.eventEndDate || ev.eventDate); + if (Number.isFinite(end) && end >= now) upcoming.push(ev); + } + // Page is sorted by start date descending, so once the oldest (last) + // event here has already ended, every event on later pages — all with + // even older start dates — will have ended too. Stop paging. + const last = page[page.length - 1]; + const lastEnd = Date.parse(last.eventEndDate || last.eventDate); + if (!Number.isFinite(lastEnd) || lastEnd < now) break; + } + return upcoming.reverse(); // soonest first +} + +/* ---- Render ---- */ +const $ = id => document.getElementById(id); + +function esc(s) { + return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +function fmtDateRange(startIso, endIso) { + const start = new Date(startIso); + const opts = { weekday: "short", month: "short", day: "numeric", year: "numeric" }; + const startStr = start.toLocaleDateString(undefined, opts); + if (!endIso) return startStr; + const end = new Date(endIso); + if (start.toDateString() === end.toDateString()) return startStr; + const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear(); + const endStr = sameMonth + ? end.toLocaleDateString(undefined, { day: "numeric" }) + : end.toLocaleDateString(undefined, opts); + return `${startStr} – ${endStr}`; +} + +function locationOf(ev) { + const parts = [ev.locationName, ev.city, ev.country].filter(Boolean); + return parts.length ? parts.join(", ") : (ev.formatted_address || "Location TBD"); +} + +function eventCard(ev) { + const capacity = ev.numTickets + ? `${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in` + : ""; + const system = ev.gameSystemName + ? `${esc(ev.gameSystemName)}` + : ""; + return ` + +
    +
    +
    ${esc(ev.name)}
    +
    ${esc(locationOf(ev))}
    +
    ${system}${capacity}
    +
    +
    +
    ${fmtDateRange(ev.eventDate, ev.eventEndDate)}
    +
    +
    +
    `; +} + +function circuitSection(circuit, events) { + const body = events.length + ? events.map(eventCard).join("") + : `

    No upcoming events for this circuit.

    `; + return ` +
    +

    + + ${esc(circuit.name)} + +

    +
    ${body}
    +
    `; +} + +/* ---- Orchestration ---- */ +async function load() { + const results = await Promise.all( + CONFIG.circuits.map(async circuit => { + try { + return { circuit, events: await fetchUpcomingEvents(circuit.id) }; + } catch (err) { + console.error("Failed to load circuit", circuit.id, err); + return { circuit, events: [], error: true }; + } + }) + ); + + $("loading").classList.add("hidden"); + const container = $("circuits"); + container.classList.remove("hidden"); + container.innerHTML = results.map(({ circuit, events, error }) => + error + ? `

    ${esc(circuit.name)}

    +

    Couldn't load events for this circuit — try refreshing.

    ` + : circuitSection(circuit, events) + ).join(""); +} + +load(); diff --git a/sites/events/src/style.css b/sites/events/src/style.css new file mode 100644 index 0000000..13dd9f4 --- /dev/null +++ b/sites/events/src/style.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; +@plugin "daisyui"; + +/* header.html is generated at build time (see scripts/render-header.mjs) — + name it explicitly so Tailwind always scans it for classes, regardless + of gitignore state (see the other sites' style.css for why this matters). */ +@source "../public/header.html"; diff --git a/sites/events/vite.config.js b/sites/events/vite.config.js new file mode 100644 index 0000000..87b601b --- /dev/null +++ b/sites/events/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'index.html'), + } + } + } +}); diff --git a/sites/scouting/nav.html b/sites/scouting/nav.html index b6a5a68..a08d0ad 100644 --- a/sites/scouting/nav.html +++ b/sites/scouting/nav.html @@ -1 +1,2 @@
  • Scouting
  • +
  • Events
  • diff --git a/sites/scouting/public/header.html b/sites/scouting/public/header.html index c4923b7..c73add2 100644 --- a/sites/scouting/public/header.html +++ b/sites/scouting/public/header.html @@ -29,6 +29,7 @@