Public Access
On /odoo-black-friday/ the offer row is now two columns at >=901px: the deal (left, unchanged and still the single dominant primary) with the free trial and a booking link beside it (right, deliberately smaller and quieter). - Trial: the existing #trialCard/#trialForm moves verbatim from the features grid into the row's aside. Same ids, same static/black-friday.js handler, same consent gate and label (AW-18065522009/AtAPCIjjvrAdENn6p6ZD) — no second trial mechanism. - Booking: a plain <a target=_blank rel=noopener> to Oliver's verified Google appointment page (no iframe). Its click fires AW-18065522009/P0j5CPiCv7AdENn6p6ZD through the same gate via window.o4pAds.bookConversion; the click is never preventDefault-ed, so a blocked tag can never stop the link opening. - ads-consent.js gains the booking label (from the page, not hard-coded) and the bookConversion hook; the buy path is untouched. Deal buy stays the dominant action; ONE plausible Buy mark; one price; countdown pair unchanged.
235 lines
8.4 KiB
JavaScript
235 lines
8.4 KiB
JavaScript
/* ads-consent.js — Google Ads tag, gated behind a consent banner. Page-side only.
|
|
*
|
|
* Page: /odoo-black-friday/. Companion to /static/black-friday.js (which is untouched).
|
|
* The tag ID and the conversion label are read from the banner element's data-* attributes
|
|
* (#adsConsent[data-ads-id][data-ads-send-to]), so this file stays generic and reusable and
|
|
* the page holds the one copy of the config. Without those attributes the script stays inert.
|
|
*
|
|
* WHY A GATE: the Google Ads tag is NOT cookieless — it sets the advertising cookies
|
|
* _gcl_au / _gcl_aw. So nothing loads and no request fires until the visitor accepts.
|
|
* The default is DENY. Declining genuinely prevents the tag from loading.
|
|
*
|
|
* WHAT THE CONVERSIONS ARE: both are CLICK PROXIES, not sales.
|
|
* - a buy form on this page (POST /shop/post_basket) -> data-ads-send-to
|
|
* - the free-trial form (#trialForm, the n8n webhook) -> data-ads-trial-send-to
|
|
* The real purchase is measured on the checkout host (20.odoo4projects.com) by odoo-dev.
|
|
* See wiki/concepts/conversion-tracking-contract. Never label either a sale.
|
|
*
|
|
* SAFETY: a conversion tag must never swallow an action. On submit we fire the event and run the
|
|
* action from the event callback, with a hard timeout that runs it anyway. For a buy form the
|
|
* action is form.submit(); for the trial form the page hands us its send function via
|
|
* window.o4pAds.trialConversion. With JavaScript off the native POST forms submit untouched
|
|
* (no banner, no conversion, no error) and the trial form does its native thing.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var STORE_KEY = "o4p_ads_consent"; // "granted" | "denied"
|
|
var SUBMIT_FALLBACK_MS = 700; // hard cap: submit even if the tag never answers
|
|
var cfg = null; // { id, sendTo, trialSendTo } — from the banner, never hard-coded
|
|
|
|
/* ---- the visitor's stored choice ---- */
|
|
function readChoice() {
|
|
try {
|
|
return window.localStorage.getItem(STORE_KEY);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
function saveChoice(value) {
|
|
try {
|
|
window.localStorage.setItem(STORE_KEY, value);
|
|
} catch (e) {
|
|
/* private mode / storage disabled: treat as no choice, banner stays up */
|
|
}
|
|
}
|
|
function isGranted() {
|
|
return readChoice() === "granted";
|
|
}
|
|
|
|
/* ---- cookie removal (only used to revoke a prior grant) ---- */
|
|
function clearCookie(name) {
|
|
var host = window.location.hostname;
|
|
var bare = host.replace(/^www\./, "");
|
|
var paths = ["/", "/odoo-black-friday/"];
|
|
var domains = ["", "; domain=" + host, "; domain=." + bare];
|
|
for (var i = 0; i < paths.length; i++) {
|
|
for (var j = 0; j < domains.length; j++) {
|
|
document.cookie =
|
|
name + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=" + paths[i] + domains[j];
|
|
}
|
|
}
|
|
}
|
|
function clearAdsCookies() {
|
|
clearCookie("_gcl_au");
|
|
clearCookie("_gcl_aw");
|
|
}
|
|
|
|
/* ---- the tag: loaded ONLY on consent ---- */
|
|
function loadGtag() {
|
|
if (!cfg || window.__o4pAdsLoaded) return;
|
|
window.__o4pAdsLoaded = true;
|
|
window.dataLayer = window.dataLayer || [];
|
|
window.gtag =
|
|
window.gtag ||
|
|
function () {
|
|
window.dataLayer.push(arguments);
|
|
};
|
|
window.gtag("js", new Date());
|
|
window.gtag("config", cfg.id);
|
|
var s = document.createElement("script");
|
|
s.async = true;
|
|
s.src = "https://www.googletagmanager.com/gtag/js?id=" + cfg.id;
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
/* ---- fire one conversion, then run the action from the callback or the hard timeout ---- */
|
|
function fireConversion(sendTo, action) {
|
|
var done = false;
|
|
function run() {
|
|
if (done) return;
|
|
done = true;
|
|
action(); // the action always runs: at most SUBMIT_FALLBACK_MS after the event
|
|
}
|
|
try {
|
|
window.gtag("event", "conversion", {
|
|
send_to: sendTo,
|
|
event_callback: run,
|
|
});
|
|
} catch (err) {
|
|
run();
|
|
}
|
|
window.setTimeout(run, SUBMIT_FALLBACK_MS);
|
|
}
|
|
|
|
/* ---- the conversion proxy, fired on a buy-form submit ---- */
|
|
/* NOTE: the buy path below is deliberately left byte-identical to what shipped and was
|
|
* verified on 2026-11-02 — its label, its one mark and its 700 ms fallback are unchanged. */
|
|
function onSubmit(e) {
|
|
var form = e.target;
|
|
if (!form || form.tagName !== "FORM") return;
|
|
var action = form.getAttribute("action") || "";
|
|
if (action.indexOf("/shop/post_basket") === -1) return;
|
|
|
|
// Not configured, no consent, or no tag: do nothing, let the native POST go through.
|
|
if (!cfg || !isGranted() || typeof window.gtag !== "function") return;
|
|
|
|
e.preventDefault();
|
|
var submitted = false;
|
|
function submitNow() {
|
|
if (submitted) return;
|
|
submitted = true;
|
|
form.submit(); // bypasses the submit event: no loop, no re-fire
|
|
}
|
|
try {
|
|
window.gtag("event", "conversion", {
|
|
send_to: cfg.sendTo,
|
|
event_callback: submitNow,
|
|
});
|
|
} catch (err) {
|
|
submitNow();
|
|
}
|
|
// Hard fallback: a blocked / slow / failed tag must never stop the purchase.
|
|
window.setTimeout(submitNow, SUBMIT_FALLBACK_MS);
|
|
}
|
|
|
|
/* ---- the banner ---- */
|
|
function bannerEl() {
|
|
return document.getElementById("adsConsent");
|
|
}
|
|
function showBanner() {
|
|
var el = bannerEl();
|
|
if (el) el.hidden = false;
|
|
}
|
|
function hideBanner() {
|
|
var el = bannerEl();
|
|
if (el) el.hidden = true;
|
|
}
|
|
function accept() {
|
|
saveChoice("granted");
|
|
loadGtag();
|
|
hideBanner();
|
|
}
|
|
function decline() {
|
|
saveChoice("denied");
|
|
clearAdsCookies();
|
|
hideBanner();
|
|
}
|
|
|
|
function init() {
|
|
document.addEventListener("submit", onSubmit, false);
|
|
|
|
var banner = bannerEl();
|
|
if (!banner) return;
|
|
|
|
// Config lives in the page. Missing config -> stay inert (no tag, no conversion).
|
|
var id = banner.getAttribute("data-ads-id");
|
|
var sendTo = banner.getAttribute("data-ads-send-to");
|
|
if (!id || !sendTo) return;
|
|
cfg = {
|
|
id: id,
|
|
sendTo: sendTo,
|
|
trialSendTo: banner.getAttribute("data-ads-trial-send-to"),
|
|
bookSendTo: banner.getAttribute("data-ads-book-send-to"),
|
|
};
|
|
|
|
var choice = readChoice();
|
|
if (choice === "granted") {
|
|
loadGtag(); // returning visitor who already accepted: load straight away
|
|
} else if (choice !== "denied") {
|
|
showBanner(); // first visit (or no stored choice): default deny, ask
|
|
}
|
|
|
|
var acceptBtn = document.getElementById("adsConsentAccept");
|
|
if (acceptBtn) acceptBtn.addEventListener("click", accept);
|
|
var declineBtn = document.getElementById("adsConsentDecline");
|
|
if (declineBtn) declineBtn.addEventListener("click", decline);
|
|
|
|
// A real way to change the choice later.
|
|
var opener = document.getElementById("adsConsentOpen");
|
|
if (opener) {
|
|
opener.addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
showBanner();
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ---- the trial conversion: the page hands us its real submit function ---- *
|
|
* Called by /static/black-friday.js when #trialForm is submitted. With consent we fire the Ads
|
|
* conversion and run the page's submit from the callback; a blocked/slow tag is covered by the
|
|
* hard timeout, so the trial always completes. Without consent (or without the tag) we run the
|
|
* submit immediately and fire nothing — the trial is never gated on the tag.
|
|
*/
|
|
window.o4pAds = window.o4pAds || {};
|
|
window.o4pAds.trialConversion = function (action) {
|
|
if (typeof action !== "function") return;
|
|
if (!cfg || !cfg.trialSendTo || !isGranted() || typeof window.gtag !== "function") {
|
|
action();
|
|
return;
|
|
}
|
|
fireConversion(cfg.trialSendTo, action);
|
|
};
|
|
|
|
/* ---- the booking conversion: fired on a click of the booking link ---- *
|
|
* The booking happens on Google's domain, off-site, so a click is all we can observe. The
|
|
* caller does NOT preventDefault: the link opens in a new tab on its own, so even a blocked,
|
|
* slow or absent tag can never stop the booking page from opening — this only ADDS the event.
|
|
* Without consent (or the label) it fires nothing and simply runs the (optional) action.
|
|
*/
|
|
window.o4pAds.bookConversion = function (action) {
|
|
if (typeof action !== "function") action = function () {};
|
|
if (!cfg || !cfg.bookSendTo || !isGranted() || typeof window.gtag !== "function") {
|
|
action();
|
|
return;
|
|
}
|
|
fireConversion(cfg.bookSendTo, action);
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|