Add events.gateway-gamers.net: upcoming events across BCP circuits

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=<circuitId>`,
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/<id> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
This commit is contained in:
2026-09-04 15:55:25 -05:00
co-authored by Claude Sonnet 5
parent d0fee77bae
commit 58d930f289
15 changed files with 436 additions and 0 deletions
+33
View File
@@ -176,3 +176,36 @@ deploy:scouting:
environment: environment:
name: production name: production
url: https://scouting.gateway-gamers.net 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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.env.local
@@ -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;
}
}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gateway Gamers — Events</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body class="min-h-screen flex flex-col">
<div id="header"></div>
<main class="flex-1 max-w-5xl w-full mx-auto px-6 py-10">
<!-- Page heading -->
<div class="mb-8">
<h1 class="text-4xl font-bold uppercase tracking-wide">Upcoming Events</h1>
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Across the circuits Gateway Gamers follows</p>
</div>
<!-- Loading skeleton -->
<div class="space-y-8" id="loading">
<div class="space-y-3">
<div class="skeleton h-6 w-1/3"></div>
<div class="skeleton h-24 w-full"></div>
<div class="skeleton h-24 w-full"></div>
</div>
</div>
<!-- Circuit sections get built here, one per entry in CONFIG.circuits -->
<div class="hidden space-y-10" id="circuits"></div>
<div class="divider mt-8"></div>
<p class="text-xs text-base-content/40 text-center">
Data from Best Coast Pairings · showing only upcoming events, soonest first
</p>
</main>
<div id="footer"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
<li><a href="/index.html">Events</a></li>
<li><a href="https://scouting.gateway-gamers.net">Scouting</a></li>
+19
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
}
+1
View File
@@ -0,0 +1 @@
../../../shared/footer.html
+59
View File
@@ -0,0 +1,59 @@
<header class="bg-base-300 shadow-lg">
<!-- Hero: carousel background + centered logo -->
<div class="relative overflow-hidden min-h-64 py-10 flex items-center justify-center">
<!-- Carousel images -->
<div id="hero-carousel" class="absolute inset-0">
<img src="/images/photo1.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo2.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo3.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo4.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
<img src="/images/photo5.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
</div>
<!-- Overlay so logo stays readable over busy images -->
<div class="absolute inset-0 bg-black/40"></div>
<!-- Logo -->
<div class="relative z-10">
<a href="/index.html">
<img src="/images/logo.png" alt="Gateway Gamers" class="h-48 w-auto drop-shadow-2xl" />
</a>
</div>
</div>
<!-- Nav row -->
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
<div class="navbar-center hidden lg:flex flex-1 justify-center">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="/index.html">Events</a></li>
<li><a href="https://scouting.gateway-gamers.net">Scouting</a></li>
</ul>
</div>
<div class="navbar-end gap-2 ml-auto">
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
<input type="checkbox" class="theme-controller" value="light" />
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64 13a1 1 0 0 0-1.27-.66 8.5 8.5 0 0 1-10.71-10.71 1 1 0 0 0-1.32-1.27A10 10 0 1 0 22 14.29a1 1 0 0 0-.36-1.29z"/>
</svg>
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64 17l-.71.71a1 1 0 0 0 1.41 1.41l.71-.71A1 1 0 0 0 5.64 17zM5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1zm7-7a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1zM5.64 7.05a1 1 0 0 0 .7.29 1 1 0 0 0 .71-.29 1 1 0 0 0 0-1.41l-.71-.71a1 1 0 0 0-1.41 1.41zm12 .29a1 1 0 0 0 .7-.29l.71-.71a1 1 0 0 0-1.41-1.41l-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29zM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2zm-9 8a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1zm6.36-2a1 1 0 0 0-1.41 1.41l.71.71a1 1 0 0 0 1.41-1.41zM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5z"/>
</svg>
</label>
<div class="dropdown dropdown-end lg:hidden">
<label tabindex="0" class="btn btn-ghost btn-circle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52">
<li><a href="/index.html">Events</a></li>
<li><a href="https://scouting.gateway-gamers.net">Scouting</a></li>
</ul>
</div>
</div>
</div>
</header>
+1
View File
@@ -0,0 +1 @@
../../../shared/images
+196
View File
@@ -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/<id>/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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
? `<span class="badge badge-ghost badge-sm">${ev.checkedInPlayers ?? 0}/${ev.numTickets} checked in</span>`
: "";
const system = ev.gameSystemName
? `<span class="badge badge-outline badge-sm">${esc(ev.gameSystemName)}</span>`
: "";
return `
<a href="${BCP_ORIGIN}/event/${esc(ev.id)}" target="_blank" rel="noopener"
class="card card-border bg-base-200 hover:border-warning transition-colors">
<div class="card-body py-4 flex-row items-center justify-between gap-4 flex-wrap">
<div class="min-w-0">
<div class="font-semibold truncate">${esc(ev.name)}</div>
<div class="text-sm opacity-60 mt-1">${esc(locationOf(ev))}</div>
<div class="flex gap-2 mt-2">${system}${capacity}</div>
</div>
<div class="text-right shrink-0">
<div class="font-mono text-sm text-warning">${fmtDateRange(ev.eventDate, ev.eventEndDate)}</div>
</div>
</div>
</a>`;
}
function circuitSection(circuit, events) {
const body = events.length
? events.map(eventCard).join("")
: `<p class="text-sm text-base-content/50 py-4">No upcoming events for this circuit.</p>`;
return `
<section>
<h2 class="text-2xl font-bold uppercase tracking-wide mb-3">
<a href="${BCP_ORIGIN}/circuit/${esc(circuit.id)}/calendar" target="_blank" rel="noopener" class="hover:text-warning">
${esc(circuit.name)}
</a>
</h2>
<div class="space-y-3">${body}</div>
</section>`;
}
/* ---- 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
? `<section><h2 class="text-2xl font-bold uppercase tracking-wide mb-3">${esc(circuit.name)}</h2>
<p class="text-sm text-error py-4">Couldn't load events for this circuit — try refreshing.</p></section>`
: circuitSection(circuit, events)
).join("");
}
load();
+7
View File
@@ -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";
+15
View File
@@ -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'),
}
}
}
});
+1
View File
@@ -1 +1,2 @@
<li><a href="/index.html">Scouting</a></li> <li><a href="/index.html">Scouting</a></li>
<li><a href="https://events.gateway-gamers.net">Events</a></li>
+2
View File
@@ -29,6 +29,7 @@
<div class="navbar-center hidden lg:flex flex-1 justify-center"> <div class="navbar-center hidden lg:flex flex-1 justify-center">
<ul class="menu menu-horizontal px-1 gap-1"> <ul class="menu menu-horizontal px-1 gap-1">
<li><a href="/index.html">Scouting</a></li> <li><a href="/index.html">Scouting</a></li>
<li><a href="https://events.gateway-gamers.net">Events</a></li>
</ul> </ul>
</div> </div>
<div class="navbar-end gap-2 ml-auto"> <div class="navbar-end gap-2 ml-auto">
@@ -49,6 +50,7 @@
</label> </label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52"> <ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52">
<li><a href="/index.html">Scouting</a></li> <li><a href="/index.html">Scouting</a></li>
<li><a href="https://events.gateway-gamers.net">Events</a></li>
</ul> </ul>
</div> </div>
</div> </div>