Public Access
Measured live from the browser: the webhook answers in ~2.2-2.6s warm but a cold first connection can exceed the old 3s abort, so the count would fall back to the static text on a fresh visit. 6s is bounded and nothing blocks the buy either way.
138 lines
5.3 KiB
JavaScript
138 lines
5.3 KiB
JavaScript
/* 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 = 6000;
|
|
|
|
/* 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();
|
|
}
|
|
})();
|