feat(bootcamp): live remaining-seat count from availability webhook

Show the honest remaining seats for product 17 (Bootcamp Q1-2027) on all
three bootcamp pages, in the page's own language, from the availability
webhook. Progressive enhancement (static/bootcamp-availability.js): empty
and hidden by default, filled on a successful fetch; on qty 0 the cohort is
reported full, the next cohort is pointed at, and the Buy buttons are
disabled; on any timeout/error/unexpected payload the page is left exactly
as-is and the Buy buttons stay enabled. The count is never written into the
HTML as a fixed string.
This commit is contained in:
webmaster committed 2026-11-02 15:17:36 +00:00
1 parent d8a6126b9d
commit f2e375a8f1
4 files changed
+143

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
/* bootcamp-availability.js — live remaining-seat count for the bootcamp cohort.
*
* Source of truth: the availability webhook (product 17 = Bootcamp Q1-2027).
* Progressive enhancement, same spirit as buy.js — it only ever ADDS, and it can
* never block the buy:
*
* fetch OK, seats > 0 -> fill the [data-bc-seats] line with "<n> of 6 seats available"
* in the page's own language; Buy stays enabled.
* fetch OK, seats == 0 -> say the cohort is full, point at the next one, and disable
* the Buy buttons (we must not sell a full cohort).
* anything else -> DO NOTHING. Timeout, network error, CORS, non-200 or an
* unexpected payload leave the page exactly as it is: the static
* cap text shows and the Buy buttons stay enabled. A
* conversion-critical element must not fail closed.
*
* The count is live and authoritative, so it is never written into the HTML as a fixed
* string — the [data-bc-seats] element ships empty and hidden.
*/
(function () {
'use strict';
var ENDPOINT = 'https://n8n.odoo4projects.com/webhook/ODOO4projects/available';
var PRODUCT_ID = 17;
var CAP = 6; /* the page's own cohort cap ("Limited to 6 participants") */
var TIMEOUT_MS = 3000;
/* The page's own words and language. "seat" = seat / Platz / plaza, as used elsewhere
on the same page ("A seat can be transferred" / "Ein Platz ist übertragbar" /
"Una plaza se puede transferir"). */
var TEXT = {
en: {
available: function (n) { return n + ' of ' + CAP + ' seats available'; },
full: 'This cohort is full.',
next: 'Talk to us about the next cohort',
href: '/support.html#contact'
},
de: {
available: function (n) { return n + ' von ' + CAP + ' Plätzen frei'; },
full: 'Dieser Kurs ist ausgebucht.',
next: 'Sprechen Sie mit uns über den nächsten Kurs',
href: '/de/support.html#contact'
},
es: {
available: function (n) { return n + ' de ' + CAP + ' plazas disponibles'; },
full: 'Este grupo está completo.',
next: 'Hable con nosotros sobre el próximo grupo',
href: '/es/support.html#contact'
}
};
function lang() {
var l = (document.documentElement.getAttribute('lang') || 'en').toLowerCase();
if (l.indexOf('de') === 0) return 'de';
if (l.indexOf('es') === 0) return 'es';
return 'en';
}
function renderAvailable(el, n, t) {
el.textContent = '';
var strong = document.createElement('strong');
strong.textContent = t.available(n);
el.appendChild(strong);
el.hidden = false;
}
function renderFull(el, t) {
el.textContent = '';
var strong = document.createElement('strong');
strong.textContent = t.full;
el.appendChild(strong);
el.appendChild(document.createTextNode(' '));
var a = document.createElement('a');
a.href = t.href;
a.textContent = t.next;
el.appendChild(a);
el.hidden = false;
disableBuy();
}
function disableBuy() {
var buttons = document.querySelectorAll('form[action$="/shop/post_basket"] button[type="submit"]');
for (var i = 0; i < buttons.length; i++) {
buttons[i].disabled = true;
buttons[i].setAttribute('aria-disabled', 'true');
}
}
/* Unwrap [{data:[...]}] or {data:[...]} and read product 17's qty_available.
Returns a finite number >= 0, or null if the shape is not what we expect. */
function seatsFrom(json) {
var obj = json;
if (Array.isArray(obj)) obj = obj[0];
if (!obj || !Array.isArray(obj.data)) return null;
for (var i = 0; i < obj.data.length; i++) {
var row = obj.data[i];
if (row && Number(row.id) === PRODUCT_ID) {
var q = Number(row.qty_available);
return (isFinite(q) && q >= 0) ? q : null;
}
}
return null;
}
function run() {
var el = document.querySelector('[data-bc-seats]');
if (!el) return;
if (!window.fetch || typeof AbortController === 'undefined') return; /* keep fallback */
var t = TEXT[lang()];
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, TIMEOUT_MS);
fetch(ENDPOINT, {
signal: ctrl.signal,
headers: { 'Accept': 'application/json' },
cache: 'no-store'
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (json) {
var n = seatsFrom(json);
if (n === null) return; /* unexpected payload -> keep fallback */
if (n === 0) renderFull(el, t);
else renderAvailable(el, n, t);
})
.catch(function () { /* keep fallback; Buy stays enabled */ })
.then(function () { clearTimeout(timer); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();