diff --git a/de/odoo-erp-automation-bootcamp/index.html b/de/odoo-erp-automation-bootcamp/index.html index 30e967f..4c787d1 100644 --- a/de/odoo-erp-automation-bootcamp/index.html +++ b/de/odoo-erp-automation-bootcamp/index.html @@ -771,6 +771,7 @@

Preis und Buchung

Das Programm kostet USD 595.00 für alle zehn Sitzungen.

Begrenzt auf 6 Teilnehmer — die Gruppe ist klein, damit der Trainer sich Ihre eigene Konfiguration ansehen kann.

+

Ein Platz ist jederzeit übertragbar und kann bis zwei Wochen vor der ersten Sitzung am 21. Januar 2027 storniert werden.

Umfasst das komplette Programm: zehn Live-Sessions, ein Jahr gehostetes Odoo Community Edition, die Alumni-Community und die Expertenprüfung. @@ -879,6 +880,7 @@ + diff --git a/es/odoo-erp-automation-bootcamp/index.html b/es/odoo-erp-automation-bootcamp/index.html index 2048bd4..8da038b 100644 --- a/es/odoo-erp-automation-bootcamp/index.html +++ b/es/odoo-erp-automation-bootcamp/index.html @@ -771,6 +771,7 @@

Precio y reserva

El programa cuesta USD 595.00 por las diez sesiones.

Limitado a 6 participantes — el grupo es reducido, para que el instructor pueda trabajar sobre su propia configuración.

+

Una plaza se puede transferir en cualquier momento y cancelar hasta dos semanas antes de la primera sesión, el 21 de enero de 2027.

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 @@ + diff --git a/odoo-erp-automation-bootcamp/index.html b/odoo-erp-automation-bootcamp/index.html index fcb6352..dbfb12f 100644 --- a/odoo-erp-automation-bootcamp/index.html +++ b/odoo-erp-automation-bootcamp/index.html @@ -788,6 +788,7 @@

Price and booking

The programme costs USD 595.00 for all ten sessions.

Limited to 6 participants — the cohort is capped, so the instructor can work through your own setup.

+

A seat can be transferred at any time, and cancelled up to two weeks before the first session on 21 January 2027.

Covers the full programme: ten live sessions, one year of hosted Odoo Community Edition, the @@ -899,6 +900,7 @@ + diff --git a/static/bootcamp-availability.js b/static/bootcamp-availability.js new file mode 100644 index 0000000..e64c1ca --- /dev/null +++ b/static/bootcamp-availability.js @@ -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 " 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(); + } +})();