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

@@ -771,6 +771,7 @@
<h2 class="section-title">Preis und Buchung</h2>
<p class="bc-price">Das Programm kostet USD 595.00 für alle zehn Sitzungen.</p>
<p class="bc-note"><strong>Begrenzt auf 6 Teilnehmer</strong> — die Gruppe ist klein, damit der Trainer sich Ihre eigene Konfiguration ansehen kann.</p>
<p class="bc-note bc-seats" data-bc-seats hidden></p>
<p class="bc-note">Ein Platz ist <strong>jederzeit übertragbar</strong> und kann <strong>bis zwei Wochen vor</strong> der ersten Sitzung am 21. Januar 2027 storniert werden.</p>
<p class="bc-lead">
Umfasst das komplette Programm: zehn Live-Sessions, ein Jahr gehostetes Odoo Community Edition, die Alumni-Community und die Expertenprüfung.
@@ -879,6 +880,7 @@
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/lang.js"></script>
</body>
</html>
@@ -771,6 +771,7 @@
<h2 class="section-title">Precio y reserva</h2>
<p class="bc-price">El programa cuesta USD 595.00 por las diez sesiones.</p>
<p class="bc-note"><strong>Limitado a 6 participantes</strong> — el grupo es reducido, para que el instructor pueda trabajar sobre su propia configuración.</p>
<p class="bc-note bc-seats" data-bc-seats hidden></p>
<p class="bc-note">Una plaza se puede <strong>transferir en cualquier momento</strong> y <strong>cancelar hasta dos semanas antes</strong> de la primera sesión, el 21 de enero de 2027.</p>
<p class="bc-lead">
Cubre el programa completo: diez sesiones en directo, un año de Odoo Community Edition alojado, la comunidad de antiguos alumnos y la revisión experta.
@@ -879,6 +880,7 @@
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/lang.js"></script>
</body>
</html>
+2
View File
@@ -788,6 +788,7 @@
<h2 class="section-title">Price and booking</h2>
<p class="bc-price">The programme costs USD 595.00 for all ten sessions.</p>
<p class="bc-note"><strong>Limited to 6 participants</strong> &mdash; the cohort is capped, so the instructor can work through your own setup.</p>
<p class="bc-note bc-seats" data-bc-seats hidden></p>
<p class="bc-note">A seat can be <strong>transferred at any time</strong>, and <strong>cancelled up to two weeks before</strong> the first session on 21 January 2027.</p>
<p class="bc-lead">
Covers the full programme: ten live sessions, one year of hosted Odoo Community Edition, the
@@ -899,6 +900,7 @@
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/lang.js"></script>
</body>
</html>
+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();
}
})();