Public Access
Fix 1: the On the Rise pricing card quoted $395 while the deal block said $295 first year — a price contradiction within one scroll. On this page only the card now reads $295.00 / 'for your first year, then $395 a year'. Other pages keep $395. JSON-LD Offer price was already 295.00 and still matches the visible price. Fix 2: the three list-price Buy Now buttons (data-id 6/4/5) competed with the deal. Side Hustle and Powerhouse buy buttons removed on this page. The On the Rise card's buy is now the deal purchase — the same one-click checkout as the offer block (product 4, /coupon/HOST27?r=/shop/checkout), native POST so it works with JS off. Tracking: the offer-block form is the single element carrying the Plausible Buy class; the Bootcamp CTA and the two list-price buttons no longer carry it. Fix 10: a time-remaining element near the offer and near the buy, computed client-side from one documented constant (2026-11-30T23:59:59-05:00, US/Eastern). The static sentence 'Ends Monday 30 November 2026.' still reads with JS off; past the instant the text switches to the past tense. No visibility animation.
466 lines
19 KiB
JavaScript
466 lines
19 KiB
JavaScript
/* Page-local script for /odoo-black-friday/.
|
|
The homepage behaviours this landing page needs, copied from the site's /script.js:
|
|
the hero slide-in image driven by the server-location selector, the Buy Now buttons,
|
|
the free-trial card, the Custom Quote modal and the footer contact form.
|
|
The blog, OCA-modules and project-plan blocks of /script.js are removed here because this
|
|
page does not carry those sections (and those blocks throw on a page without their markup).
|
|
Image paths are root-relative: this page lives in a subdirectory (/odoo-black-friday/).
|
|
ES5, no build step, no new dependency beyond the gsap the homepage already loads. */
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ---- UTM tracking helpers (verbatim from /script.js) ---- */
|
|
(function () {
|
|
function getCookie(name) {
|
|
var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
|
return match ? decodeURIComponent(match[2]) : null;
|
|
}
|
|
function setCookie(name, value, days) {
|
|
var expires = '';
|
|
if (days) {
|
|
var date = new Date();
|
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
expires = '; expires=' + date.toUTCString();
|
|
}
|
|
document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
|
|
}
|
|
function parseUTM() {
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var utm = {};
|
|
var keys = ['source', 'medium', 'campaign'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var val = urlParams.get('utm_' + keys[i]);
|
|
if (val) {
|
|
utm[keys[i]] = val;
|
|
setCookie('utm_' + keys[i], val, 90);
|
|
}
|
|
}
|
|
return utm;
|
|
}
|
|
function getStoredUTM() {
|
|
var utm = {};
|
|
var keys = ['source', 'medium', 'campaign'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var val = getCookie('utm_' + keys[i]);
|
|
if (val) utm[keys[i]] = val;
|
|
}
|
|
return utm;
|
|
}
|
|
function injectUTM(data) {
|
|
var stored = getStoredUTM();
|
|
if (Object.keys(stored).length > 0) {
|
|
data.utm_source = stored.source || null;
|
|
data.utm_medium = stored.medium || null;
|
|
data.utm_campaign = stored.campaign || null;
|
|
}
|
|
return data;
|
|
}
|
|
function appendToForm(form) {
|
|
var stored = getStoredUTM();
|
|
var keys = ['source', 'medium', 'campaign'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
if (stored[keys[i]]) {
|
|
var input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'utm_' + keys[i];
|
|
input.value = stored[keys[i]];
|
|
form.appendChild(input);
|
|
}
|
|
}
|
|
}
|
|
function toQuery() {
|
|
var stored = getStoredUTM();
|
|
var parts = [];
|
|
var keys = ['source', 'medium', 'campaign'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
if (stored[keys[i]]) {
|
|
parts.push('utm_' + keys[i] + '=' + encodeURIComponent(stored[keys[i]]));
|
|
}
|
|
}
|
|
return parts.join('&');
|
|
}
|
|
window.UTM = { parse: parseUTM, getStored: getStoredUTM, inject: injectUTM, appendToForm: appendToForm, toQuery: toQuery };
|
|
window.UTM.parse();
|
|
})();
|
|
|
|
/* ---- Hero slide-in image + server-location selector (homepage behaviour) ---- */
|
|
(function () {
|
|
var SERVERS = [
|
|
{ host: 'api.meppel.odoo4projects.com', city: 'Amsterdam' },
|
|
{ host: 'api.boston.odoo4projects.com', city: 'Boston' },
|
|
{ host: 'api.manchester.odoo4projects.com', city: 'Manchester' },
|
|
{ host: 'api.mumbai.odoo4projects.com', city: 'Mumbai' },
|
|
{ host: 'api.saopaulo.odoo4projects.com', city: 'S\u00e3o Paulo' },
|
|
{ host: 'api.sydney.odoo4projects.com', city: 'Sydney' }
|
|
];
|
|
/* Root-relative on purpose: this page is served from /odoo-black-friday/. */
|
|
var heroImages = {
|
|
'api.meppel.odoo4projects.com': '/images/hero/amsterdam.webp',
|
|
'api.boston.odoo4projects.com': '/images/hero/boston.webp',
|
|
'api.manchester.odoo4projects.com': '/images/hero/london.webp',
|
|
'api.mumbai.odoo4projects.com': '/images/hero/mumbai.webp',
|
|
'api.saopaulo.odoo4projects.com': '/images/hero/brazil.webp',
|
|
'api.sydney.odoo4projects.com': '/images/hero/sydney.webp'
|
|
};
|
|
|
|
function pingServer(host) {
|
|
return new Promise(function (resolve) {
|
|
var start = performance.now();
|
|
var img = new Image();
|
|
var timedOut = false;
|
|
var timer = setTimeout(function () {
|
|
timedOut = true; img.src = '';
|
|
resolve({ host: host, time: -1 });
|
|
}, 2000);
|
|
img.onload = function () {
|
|
var elapsed = performance.now() - start;
|
|
if (!timedOut) { clearTimeout(timer); resolve({ host: host, time: elapsed }); }
|
|
};
|
|
img.onerror = function () {
|
|
var elapsed = performance.now() - start;
|
|
if (!timedOut) { clearTimeout(timer); resolve({ host: host, time: elapsed }); }
|
|
};
|
|
img.src = 'https://' + host + '/favicon.ico?_=' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
|
});
|
|
}
|
|
|
|
function animateHero(city, image, showLabel) {
|
|
var hero = document.querySelector('.hero');
|
|
if (!hero || !window.gsap) return;
|
|
var bg = hero.querySelector('.hero-bg');
|
|
if (!bg) {
|
|
bg = document.createElement('div');
|
|
bg.className = 'hero-bg';
|
|
hero.insertBefore(bg, hero.firstChild);
|
|
}
|
|
var hasExisting = bg.children.length > 0;
|
|
var newEl = document.createElement('div');
|
|
newEl.className = 'hero-bg-slide';
|
|
newEl.style.backgroundImage = "url('" + image + "')";
|
|
bg.appendChild(newEl);
|
|
|
|
var tl = gsap.timeline();
|
|
gsap.set(newEl, { x: '100%' });
|
|
|
|
var label = null;
|
|
if (showLabel !== false && city) {
|
|
var oldLabel = hero.querySelector('.hero-city-label');
|
|
if (oldLabel) oldLabel.remove();
|
|
label = document.createElement('div');
|
|
label.className = 'hero-city-label';
|
|
label.innerHTML = 'Our <strong>' + city + '</strong> server fits your location';
|
|
hero.appendChild(label);
|
|
gsap.set(label, { x: '150%' });
|
|
}
|
|
|
|
if (hasExisting) {
|
|
var oldEl = bg.children[0];
|
|
gsap.set(oldEl, { x: '0%' });
|
|
tl.to(oldEl, { x: '100%', duration: 3, ease: 'power2.out' }, 0)
|
|
.to(newEl, { x: '0%', duration: 3, ease: 'power2.out' }, 0);
|
|
if (label) {
|
|
tl.to(label, { x: '0%', duration: 2.5, ease: 'power2.out' }, 0.3);
|
|
}
|
|
tl.call(function () { oldEl.remove(); });
|
|
} else {
|
|
tl.to(newEl, { x: '0%', duration: 3, ease: 'power2.out' }, 0);
|
|
if (label) {
|
|
tl.to(label, { x: '0%', duration: 2.5, ease: 'power2.out' }, 0.3);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Location selector: click a server card to slide its city into the hero. */
|
|
var serverCards = document.querySelectorAll('.server-card');
|
|
serverCards.forEach(function (card, idx) {
|
|
card.addEventListener('click', function () {
|
|
var s = SERVERS[idx];
|
|
if (!s || !heroImages[s.host]) return;
|
|
serverCards.forEach(function (c) { c.classList.remove('server-best'); });
|
|
card.classList.add('server-best');
|
|
animateHero(null, heroImages[s.host], false);
|
|
});
|
|
});
|
|
|
|
/* Populate the trial + pricing location selects. */
|
|
var trialLoc = document.getElementById('trialLocation');
|
|
var pricingLoc = document.getElementById('pricingServerLocation');
|
|
[trialLoc, pricingLoc].forEach(function (el) {
|
|
if (el) {
|
|
SERVERS.forEach(function (s) {
|
|
var opt = document.createElement('option');
|
|
var cityVal = s.host.replace('api.', '').replace('.odoo4projects.com', '');
|
|
opt.value = cityVal;
|
|
opt.textContent = s.city;
|
|
el.appendChild(opt);
|
|
});
|
|
}
|
|
});
|
|
|
|
/* Ping all servers, highlight the fastest, slide its image in. */
|
|
var results = [];
|
|
var pending = SERVERS.length;
|
|
SERVERS.forEach(function (s) {
|
|
pingServer(s.host).then(function (r) {
|
|
results.push(r);
|
|
pending--;
|
|
if (pending !== 0) return;
|
|
var valid = results.filter(function (x) { return x.time >= 0; });
|
|
if (valid.length === 0) return;
|
|
valid.sort(function (a, b) { return a.time - b.time; });
|
|
var best = valid[0];
|
|
var bestServer = SERVERS.filter(function (x) { return x.host === best.host; })[0];
|
|
if (bestServer) {
|
|
[trialLoc, pricingLoc].forEach(function (el) {
|
|
if (el) {
|
|
var cityVal = bestServer.host.replace('api.', '').replace('.odoo4projects.com', '');
|
|
for (var i = 0; i < el.options.length; i++) {
|
|
if (el.options[i].value === cityVal) { el.selectedIndex = i; break; }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
if (bestServer && heroImages[best.host]) {
|
|
var cards = document.querySelectorAll('.server-card');
|
|
for (var i = 0; i < SERVERS.length && i < cards.length; i++) {
|
|
if (SERVERS[i].host === best.host) { cards[i].classList.add('server-best'); break; }
|
|
}
|
|
var img = new Image();
|
|
img.onload = function () { animateHero(bestServer.city, heroImages[best.host]); };
|
|
img.src = heroImages[best.host];
|
|
}
|
|
});
|
|
});
|
|
|
|
/* Free-trial card. */
|
|
var trialForm = document.getElementById('trialForm');
|
|
if (trialForm) {
|
|
trialForm.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var email = document.getElementById('trialEmail').value.trim();
|
|
var location = trialLoc ? trialLoc.value : '';
|
|
if (!email) return;
|
|
var btn = trialForm.querySelector('.btn-brass');
|
|
btn.textContent = 'Sending...';
|
|
btn.disabled = true;
|
|
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/trial', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(UTM.inject({ email: email, location: location }))
|
|
}).then(function () {
|
|
if (window.plausible) window.plausible('Trial');
|
|
trialForm.style.display = 'none';
|
|
document.getElementById('trialSuccess').classList.add('visible');
|
|
}).catch(function () {
|
|
btn.textContent = 'Send';
|
|
btn.disabled = false;
|
|
var errorMsg = document.createElement('div');
|
|
errorMsg.className = 'trial-error visible';
|
|
errorMsg.textContent = 'Something went wrong. Please try again.';
|
|
trialForm.appendChild(errorMsg);
|
|
setTimeout(function () { errorMsg.classList.remove('visible'); }, 3000);
|
|
});
|
|
});
|
|
}
|
|
})();
|
|
|
|
/* ---- Buy Now — POST the basket straight to the shop, open checkout in a new tab ---- */
|
|
window.buyNow = function (productId) {
|
|
var locSelect = document.getElementById('pricingServerLocation');
|
|
var location = locSelect ? locSelect.value : '';
|
|
var verSelect = document.getElementById('pricingOdooVersion');
|
|
var version = verSelect ? verSelect.value : '';
|
|
var form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = 'https://20.odoo4projects.com/shop/post_basket';
|
|
form.target = '_blank';
|
|
form.style.display = 'none';
|
|
var basket = document.createElement('input');
|
|
basket.type = 'hidden';
|
|
basket.name = 'basket';
|
|
var item = { product_id: productId, qty: 1 };
|
|
var info = location ? location.replace('.odoo4projects.com', '') : '';
|
|
if (version) { info += (info ? '|' : '') + version; }
|
|
if (info) { item.info = info; }
|
|
basket.value = JSON.stringify([item]);
|
|
form.appendChild(basket);
|
|
var clear = document.createElement('input');
|
|
clear.type = 'hidden';
|
|
clear.name = 'clear';
|
|
clear.value = '1';
|
|
form.appendChild(clear);
|
|
UTM.appendToForm(form);
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
document.body.removeChild(form);
|
|
};
|
|
|
|
/* ---- Buy buttons + Custom Quote modal ---- */
|
|
(function () {
|
|
document.querySelectorAll('.btn-buy').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var id = this.getAttribute('data-id');
|
|
if (id) buyNow(parseInt(id, 10));
|
|
});
|
|
});
|
|
|
|
function openCustomQuoteModal() {
|
|
document.getElementById('customQuotePanel').style.display = 'block';
|
|
document.getElementById('customQuoteModal').style.display = 'flex';
|
|
}
|
|
function closeCustomQuoteModal() {
|
|
document.getElementById('customQuoteModal').style.display = 'none';
|
|
document.getElementById('customQuoteForm').reset();
|
|
document.getElementById('customQuoteSuccess').style.display = 'none';
|
|
}
|
|
window.openCustomQuoteModal = openCustomQuoteModal;
|
|
window.closeCustomQuoteModal = closeCustomQuoteModal;
|
|
|
|
document.querySelectorAll('.btn-empire').forEach(function (btn) {
|
|
btn.addEventListener('click', function () { openCustomQuoteModal(); });
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') {
|
|
var modal = document.getElementById('customQuoteModal');
|
|
if (modal && modal.style.display === 'flex') { closeCustomQuoteModal(); }
|
|
}
|
|
});
|
|
|
|
var customQuoteForm = document.getElementById('customQuoteForm');
|
|
if (customQuoteForm) {
|
|
customQuoteForm.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var name = document.getElementById('cqName').value.trim();
|
|
var email = document.getElementById('cqEmail').value.trim();
|
|
var requirements = document.getElementById('cqRequirements').value.trim();
|
|
if (!name || !email || !requirements) return;
|
|
var submitBtn = customQuoteForm.querySelector('.btn-lg');
|
|
submitBtn.textContent = 'Sending...';
|
|
submitBtn.disabled = true;
|
|
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/crm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(UTM.inject({ name: name, email: email, requirements: requirements, area: 'quote' }))
|
|
}).then(function (r) {
|
|
if (!r.ok) throw new Error('bad status ' + r.status);
|
|
if (window.plausible) window.plausible('Custom Quote');
|
|
customQuoteForm.style.display = 'none';
|
|
document.getElementById('customQuoteSuccess').style.display = 'block';
|
|
submitBtn.textContent = 'Submit Request';
|
|
submitBtn.disabled = false;
|
|
}).catch(function () {
|
|
submitBtn.textContent = 'Submit Request';
|
|
submitBtn.disabled = false;
|
|
var errorMsg = document.createElement('div');
|
|
errorMsg.style.marginTop = '1rem';
|
|
errorMsg.style.color = '#dc3545';
|
|
errorMsg.textContent = 'Something went wrong. Please try again.';
|
|
customQuoteForm.appendChild(errorMsg);
|
|
setTimeout(function () { if (errorMsg.parentNode) errorMsg.parentNode.removeChild(errorMsg); }, 3000);
|
|
});
|
|
});
|
|
}
|
|
})();
|
|
|
|
/* ---- Footer contact form ---- */
|
|
(function () {
|
|
var form = document.getElementById('footerContactForm');
|
|
if (!form) return;
|
|
var emailInput = document.getElementById('footerContactEmail');
|
|
var textInput = document.getElementById('footerContactText');
|
|
var submitBtn = form.querySelector('.footer-send-btn');
|
|
var status = document.getElementById('footerFormStatus');
|
|
function showStatus(message, isError) {
|
|
status.textContent = message;
|
|
status.classList.toggle('error', !!isError);
|
|
status.classList.add('visible');
|
|
}
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var email = emailInput.value.trim();
|
|
var text = textInput.value.trim();
|
|
if (!email || !text) return;
|
|
submitBtn.disabled = true;
|
|
status.classList.remove('visible');
|
|
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/crm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(UTM.inject({ email: email, requirements: text, area: 'message' }))
|
|
}).then(function (r) {
|
|
if (!r.ok) throw new Error('bad status ' + r.status);
|
|
form.reset();
|
|
submitBtn.disabled = false;
|
|
showStatus("Thanks! We'll get back to you shortly.", false);
|
|
}).catch(function () {
|
|
submitBtn.disabled = false;
|
|
showStatus('Something went wrong. Please try again.', true);
|
|
});
|
|
});
|
|
})();
|
|
|
|
/* ---- Scroll-triggered reveal (same grids as the homepage, minus blog / project-plan) ---- */
|
|
(function () {
|
|
if (!window.gsap || !('IntersectionObserver' in window)) return;
|
|
var animated = {};
|
|
var observer = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (!entry.isIntersecting) return;
|
|
var grid = entry.target;
|
|
if (!grid || !grid.children || grid.children.length === 0) return;
|
|
var key = grid.className || 'anon';
|
|
if (animated[key]) return;
|
|
animated[key] = true;
|
|
gsap.fromTo(grid.children, { y: 30, opacity: 0 }, {
|
|
y: 0, opacity: 1, duration: 0.7, ease: 'power2.out', stagger: 0.08, overwrite: 'auto'
|
|
});
|
|
});
|
|
}, { threshold: 0.1 });
|
|
|
|
var grids = document.querySelectorAll('.features-grid, .faq-grid, .pricing-grid');
|
|
grids.forEach(function (g) {
|
|
if (g.children.length > 0) { gsap.set(g.children, { y: 30, opacity: 0 }); }
|
|
observer.observe(g);
|
|
});
|
|
})();
|
|
|
|
/* ---- Black Friday time remaining (Fix 10) ----
|
|
ONE documented constant: the instant the offer ends — 30 November 2026, end of day, in the
|
|
account's US/Eastern context (EST, UTC-5, after the 1 Nov DST change). The day count is
|
|
computed client-side from this instant on every page load; it is NEVER hard-coded, so it
|
|
stays correct as the date changes.
|
|
|
|
Fail-safe: the HTML carries the static sentence "Ends Monday 30 November 2026." with an empty
|
|
span beside it, so with JavaScript off the deadline still reads correctly on its own. Once the
|
|
end instant has passed the text switches to the past tense — it never keeps claiming time
|
|
remains. Nothing here animates visibility. */
|
|
(function () {
|
|
var BF_END = new Date('2026-11-30T23:59:59-05:00');
|
|
var DAY = 86400000;
|
|
var nodes = document.querySelectorAll('[data-bf-days]');
|
|
if (!nodes.length) return;
|
|
|
|
/* Whole calendar days in US/Eastern, so "days left" is a date difference and not a 24-hour
|
|
window (the offer ends at the end of a day, not 28 x 24h from now). */
|
|
function easternDayNumber(d) {
|
|
var parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit'
|
|
}).format(d).split('-');
|
|
return Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)) / DAY;
|
|
}
|
|
|
|
var daysLeft = easternDayNumber(BF_END) - easternDayNumber(new Date());
|
|
var text;
|
|
if (daysLeft > 1) {
|
|
text = daysLeft + ' days left';
|
|
} else if (daysLeft === 1) {
|
|
text = '1 day left';
|
|
} else if (daysLeft === 0) {
|
|
text = 'last day today';
|
|
} else {
|
|
text = 'this offer has ended';
|
|
}
|
|
nodes.forEach(function (n) { n.textContent = text; });
|
|
})();
|
|
})();
|