Files
gateway-gamers.net/sites/events/src/main.js
T
mandaloreandClaude Sonnet 5 58d930f289 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
2026-09-04 15:55:25 -05:00

197 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ---- 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();