2026-09-04 15:55:25 -05:00
|
|
|
|
/* ---- 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 16:04:42 -05:00
|
|
|
|
// BCP's only reliable format signal is the teamEvent flag. There's no
|
|
|
|
|
|
// dedicated "team size" field — the closest candidate (totalPlayers /
|
|
|
|
|
|
// totalTeamPlayers) reflects live registration progress, not the event's
|
|
|
|
|
|
// configured format, so it's noisy/wrong for events with thin early
|
|
|
|
|
|
// signups (which is most upcoming events). Deliberately not guessing a
|
|
|
|
|
|
// specific team size here.
|
|
|
|
|
|
function formatOf(ev) {
|
|
|
|
|
|
return ev.teamEvent ? "Team Event" : "Singles";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 15:55:25 -05:00
|
|
|
|
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>`
|
|
|
|
|
|
: "";
|
2026-09-04 16:04:42 -05:00
|
|
|
|
const format = `<span class="badge badge-warning badge-sm">${formatOf(ev)}</span>`;
|
2026-09-04 15:55:25 -05:00
|
|
|
|
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>
|
2026-09-04 16:04:42 -05:00
|
|
|
|
<div class="flex gap-2 mt-2">${format}${system}${capacity}</div>
|
2026-09-04 15:55:25 -05:00
|
|
|
|
</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();
|