371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
/* =============================================================================
|
||
* 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 {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
}[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();
|
||
}
|
||
})();
|