Public Access
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:
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>
|
||||
@@ -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> — 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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user