sponsored-prology/prology-product-banner.js

371 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =============================================================================
* Prology Related Products Carousel (vanilla JS, embeddable) [SERVER-ONLY]
* -----------------------------------------------------------------------------
* Public-facing widget. It NEVER touches Magento directly: no admin
* credentials, no admin token, no Magento REST URLs live in this file.
* All of that stays on YOUR server. This script only:
* - detects keywords from the post title
* - detects the visitor's store by geo (au/us) — geo only, no Magento info
* - calls YOUR endpoint and renders the returned products in a carousel
*
* EMBED:
* <script>
* window.PROLOGY_BANNER = { endpoint: 'https://your-server/api/related-products' };
* </script>
* <script src="prology-product-banner.js" defer></script>
*
* Your server returns a JSON array of:
* { name, image, url, price, qty } (price already formatted, url absolute)
* See server/server.js for the reference implementation.
* ========================================================================== */
window.PROLOGY_BANNER = {
endpoint: "http://localhost:8787/api/related-products",
};
(function () {
"use strict";
// ----- Defaults (override via window.PROLOGY_BANNER) -----------------------
var CFG = Object.assign(
{
// REQUIRED: your server endpoint (does the Magento login + stock filtering).
endpoint: null,
count: 16, // how many products to show (10-20)
// Keywords for related products. Empty -> auto-detect from the post title.
keywords: null, // e.g. ['Catalyst 9300', 'Catalyst 9200', 'Cisco']
// Store hint passed to the server. null -> detect by visitor geo (au/us).
// This is GEO only (Cloudflare trace); it carries no Magento information.
store: null, // 'au' | 'us' | null = auto-detect
traceUrl: "/cdn-cgi/trace",
// Where to inject: CSS selector of the post content area.
mountSelector: ".entry-content",
// Placement: 'after-first-table' | 'end' | 'start'
placement: "after-first-table",
},
window.PROLOGY_BANNER || {},
);
// ---------------------------------------------------------------------------
// 1. Detect related keywords from the page
// ---------------------------------------------------------------------------
function detectKeywords() {
if (CFG.keywords && CFG.keywords.length) return CFG.keywords.slice();
var h1 = document.querySelector("h1");
var text = ((h1 && h1.textContent) || document.title || "").trim();
var BRANDS = [
"Cisco",
"Catalyst",
"Meraki",
"Aruba",
"HPE",
"Juniper",
"Ubiquiti",
"UniFi",
"Fortinet",
"Netgear",
"MikroTik",
];
var found = [];
var re = /([A-Z][a-zA-Z]+)\s*(\d{3,4}[A-Za-z]?)/g,
m;
while ((m = re.exec(text)) !== null) found.push(m[1] + " " + m[2]);
BRANDS.forEach(function (b) {
if (new RegExp("\\b" + b + "\\b", "i").test(text)) found.push(b);
});
var seen = {},
out = [];
found.forEach(function (k) {
var key = k.toLowerCase();
if (!seen[key]) {
seen[key] = 1;
out.push(k);
}
});
return out.length ? out : ["Cisco"];
}
// ---------------------------------------------------------------------------
// 2. Detect store by visitor geo (AU -> 'au', otherwise -> 'us'). Geo only.
// The server validates this and can override it (e.g. via CF-IPCountry).
// ---------------------------------------------------------------------------
function fallbackStore() {
try {
if (
/Australia/i.test(
Intl.DateTimeFormat().resolvedOptions().timeZone || "",
)
)
return "au";
} catch (e) {}
return /-au\b/i.test(navigator.language || "") ? "au" : "us";
}
function resolveStore() {
if (CFG.store) return Promise.resolve(CFG.store);
return fetch(CFG.traceUrl)
.then(function (r) {
return r.ok ? r.text() : "";
})
.then(function (t) {
var m = /(?:^|\n)loc=([A-Z]{2})/.exec(t || "");
return m ? (m[1] === "AU" ? "au" : "us") : fallbackStore();
})
.catch(fallbackStore);
}
// ---------------------------------------------------------------------------
// 3. Load products from YOUR server (the only network call to your backend)
// ---------------------------------------------------------------------------
function loadProducts(store) {
var q = encodeURIComponent(detectKeywords().join(","));
var sep = CFG.endpoint.indexOf("?") >= 0 ? "&" : "?";
var url =
CFG.endpoint +
sep +
"q=" +
q +
"&count=" +
CFG.count +
(store ? "&store=" + encodeURIComponent(store) : "");
return fetch(url)
.then(function (r) {
return r.json();
})
.then(function (list) {
return Array.isArray(list) ? list : (list && list.items) || [];
});
}
// ---------------------------------------------------------------------------
// 4. Render (Shadow DOM) carousel with prev/next arrows, no header
// ---------------------------------------------------------------------------
var STYLE =
'\
:host{all:initial;display:block!important;width:100%;max-width:100%;min-width:0}\
*{box-sizing:border-box}\
.pb{position:relative;width:100%;max-width:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;margin:32px 0;color:#0f2747}\
.pb-spon{display:block;text-align:right;font-size:10px;letter-spacing:.6px;text-transform:uppercase;color:#9aa7b8;margin:0 2px 4px}\
.pb-track{display:flex;gap:14px;width:100%;min-width:0;max-width:100%;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;\
padding:6px 2px 12px;-webkit-overflow-scrolling:touch;scrollbar-width:none}\
.pb-track::-webkit-scrollbar{display:none}\
.pb-card{scroll-snap-align:start;flex:0 0 200px;display:flex;flex-direction:column;\
border:1px solid #eef2f7;border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;background:#fff;\
transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease}\
.pb-card:hover{transform:translateY(-3px);box-shadow:0 10px 22px rgba(16,42,77,.12);border-color:#cfe0f5}\
.pb-thumb{position:relative;aspect-ratio:1/1;background:#f5f8fc;display:flex;align-items:center;justify-content:center}\
.pb-thumb img{width:100%;height:100%;object-fit:contain;padding:10px}\
.pb-stock{position:absolute;top:8px;left:8px;font-size:10px;font-weight:600;color:#0a7d3c;\
background:#e6f7ec;border:1px solid #b6e6c6;border-radius:20px;padding:2px 8px}\
.pb-body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:7px;flex:1}\
.pb-name{font-size:12.5px;line-height:1.35;font-weight:600;color:#13294a;\
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:34px}\
.pb-foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px}\
.pb-price{font-size:14px;font-weight:800;color:#0a4a8f}\
.pb-cta{font-size:11px;font-weight:700;color:#0a4a8f;background:#eaf2fc;border-radius:7px;padding:5px 9px;white-space:nowrap}\
.pb-card:hover .pb-cta{background:#0a4a8f;color:#fff}\
.pb-nav{position:absolute;top:38%;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;\
border:1px solid #e3e9f1;background:#fff;color:#0a4a8f;cursor:pointer;z-index:3;\
display:flex;align-items:center;justify-content:center;box-shadow:0 4px 16px rgba(16,42,77,.16);\
transition:opacity .15s ease,background .15s ease,color .15s ease}\
.pb-nav:hover{background:#0a4a8f;color:#fff}\
.pb-nav svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}\
.pb-prev{left:6px}.pb-next{right:6px}\
.pb-nav[disabled]{opacity:0;pointer-events:none}\
.pb-empty,.pb-load{padding:26px 20px;text-align:center;color:#6b7a90;font-size:13px}\
.pb-sk{flex:0 0 200px;background:linear-gradient(90deg,#f0f3f8 25%,#e7ecf3 37%,#f0f3f8 63%);\
background-size:400% 100%;animation:pbsh 1.3s ease infinite;border-radius:12px;aspect-ratio:.74/1}\
@keyframes pbsh{0%{background-position:100% 0}100%{background-position:-100% 0}}\
@media(max-width:680px){.pb-card,.pb-sk{flex:0 0 158px}.pb-nav{width:34px;height:34px}.pb-prev{left:4px}.pb-next{right:4px}}\
@media(max-width:380px){.pb-card,.pb-sk{flex:0 0 144px}}';
var ARROW_L = '<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg>';
var ARROW_R = '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
function el(host) {
var root = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
var st = document.createElement("style");
st.textContent = STYLE;
root.appendChild(st);
var wrap = document.createElement("div");
wrap.className = "pb";
root.appendChild(wrap);
return wrap;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c];
});
}
function skeleton(wrap) {
var cards = "";
for (var i = 0; i < 6; i++) cards += '<div class="pb-sk"></div>';
wrap.innerHTML = '<div class="pb-track">' + cards + "</div>";
}
function wireCarousel(wrap) {
var track = wrap.querySelector(".pb-track");
var prev = wrap.querySelector(".pb-prev");
var next = wrap.querySelector(".pb-next");
if (!track || !prev || !next) return;
function stepSize() {
return Math.max(track.clientWidth * 0.85, 200);
}
prev.addEventListener("click", function () {
track.scrollBy({ left: -stepSize(), behavior: "smooth" });
});
next.addEventListener("click", function () {
track.scrollBy({ left: stepSize(), behavior: "smooth" });
});
function update() {
prev.disabled = track.scrollLeft <= 2;
next.disabled =
track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
}
var host = track.getRootNode().host;
track.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", function () {
fit(host);
update();
});
update();
}
function render(wrap, items) {
if (!items.length) {
wrap.innerHTML =
'<div class="pb-empty">No related products in stock right now.</div>';
return;
}
var cards = items
.map(function (p) {
return (
'<a class="pb-card" href="' +
esc(p.url) +
'" target="_blank" rel="nofollow noopener sponsored">' +
'<div class="pb-thumb">' +
(p.qty ? '<span class="pb-stock">In stock</span>' : "") +
'<img loading="lazy" alt="' +
esc(p.name) +
'" src="' +
esc(p.image) +
'"></div>' +
'<div class="pb-body"><div class="pb-name">' +
esc(p.name) +
"</div>" +
'<div class="pb-foot">' +
(p.price
? '<span class="pb-price">' + esc(p.price) + "</span>"
: "<span></span>") +
'<span class="pb-cta">View</span>' +
"</div></div></a>"
);
})
.join("");
wrap.innerHTML =
'<span class="pb-spon">Sponsored</span>' +
'<button class="pb-nav pb-prev" aria-label="Previous">' +
ARROW_L +
"</button>" +
'<div class="pb-track">' +
cards +
"</div>" +
'<button class="pb-nav pb-next" aria-label="Next">' +
ARROW_R +
"</button>";
wireCarousel(wrap);
}
// ---------------------------------------------------------------------------
// 5. Inject into the page + lock width to the real column (no column blowout)
// ---------------------------------------------------------------------------
function fit(host) {
if (!host || !host.parentNode) return;
host.style.width = "0px"; // collapse -> column reflows to real width
var p = host.parentNode,
cs = getComputedStyle(p);
var w =
p.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
host.style.width = (w > 0 ? Math.floor(w) : 0) + "px";
}
function mountPoint() {
var host = document.createElement("div");
host.setAttribute("data-prology-banner", "");
host.style.cssText =
"display:block;max-width:100%;min-width:0;box-sizing:border-box;overflow:hidden;";
var container = document.querySelector(CFG.mountSelector);
if (!container) {
document.body.appendChild(host);
return host;
}
if (CFG.placement === "after-first-table") {
var t = container.querySelector("table, figure.wp-block-table");
if (t) {
t.parentNode.insertBefore(host, t.nextSibling);
return host;
}
}
if (CFG.placement === "start") {
container.insertBefore(host, container.firstChild);
return host;
}
container.appendChild(host);
return host;
}
function init() {
if (!CFG.endpoint) {
console.error(
"[Prology Banner] Missing config. Set " +
'window.PROLOGY_BANNER = { endpoint: "https://your-server/api/related-products" } before this script.',
);
return;
}
var host = mountPoint();
fit(host);
var wrap = el(host);
skeleton(wrap);
resolveStore()
.then(function (s) {
return loadProducts(s);
})
.then(function (items) {
render(wrap, items);
})
.catch(function (err) {
console.error("[Prology Banner]", err);
render(wrap, []);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();