(() => {
if (!document.body.classList.contains("ex-u-2098094")) return;
("use strict");
if (window.__hmPdpRedesign) return;
window.__hmPdpRedesign = true;
/* ------------------------------------------------------------------ *
* Config
* ------------------------------------------------------------------ */
const CONFIG = {
locale: "en-CA",
uomLabels: {
BX: ["box", "boxes"],
CS: ["case", "cases"],
EA: ["unit", "units"],
PK: ["pack", "packs"],
CT: ["carton", "cartons"],
RL: ["roll", "rolls"],
BG: ["bag", "bags"],
DZ: ["dozen", "dozen"],
PR: ["pair", "pairs"],
BT: ["bottle", "bottles"],
TB: ["tube", "tubes"],
KT: ["kit", "kits"],
ST: ["set", "sets"],
PL: ["pail", "pails"],
DR: ["drum", "drums"],
SL: ["sleeve", "sleeves"],
},
fallbackUom: ["unit", "units"],
sizeAbbr: {
xxsmall: "XXS",
xsmall: "XS",
extrasmall: "XS",
small: "S",
medium: "M",
large: "L",
xlarge: "XL",
extralarge: "XL",
xxlarge: "XXL",
"2xlarge": "2XL",
xxxlarge: "XXXL",
"3xlarge": "3XL",
},
shippingNote: "", // e.g. 'Free shipping over $99' - shown under Add to cart when set
hideSocial: true, // hides the FB like / X post buttons
fixStickyAncestors: true, // converts overflow:hidden ancestors to overflow:clip so the buy box can stick
stickyGap: 40, // sticky top offset + breathing room (px); buy column taller than viewport minus this stops sticking
stockTimeout: 10000, // stop showing the stock skeleton after this (ms)
priceTimeout: 6000, // stop showing the price skeleton after this (ms)
switchTimeout: 8000, // failsafe for the option-switch loading state (ms)
pollQtyMs: 400, // EvoX sets qty via .val() without events - cheap poll as backup
};
const ROOT_SELECTOR = ".mainproductpagedesktop, .mainproductpagemobile";
const SIDEBAR_SELECTOR = ".filter-container";
const GEN = "data-hm-gen";
const WIDGET_RE = /\b([a-z0-9]+)_container\b/i;
const TABS_ANCHOR_RE = /\bproduct(description|specs)_container\b/i;
const IGNORE_SEL = [
".productimage_container",
"[data-hm-gen]",
".btn-add-to-cart",
".social-links",
".product-compare-action",
".ql-loginmsg",
".magnify",
"script",
"iframe",
].join(",");
const ATTR_RELEVANT_SEL = [
".selection-not-available",
".selection-not-available-mobile",
".dds-specs-loader-wrapper",
".product-price-rrp",
".multiple-uom",
"[data-tierpricing]",
".has-price",
".has-savings-price",
".productsrelated_container",
".productreviews_container",
].join(",");
const state = new WeakMap();
const roots = [];
/* ------------------------------------------------------------------ *
* Helpers
* ------------------------------------------------------------------ */
const qs = (sel, ctx) => (ctx || document).querySelector(sel);
const qsa = (sel, ctx) => Array.from((ctx || document).querySelectorAll(sel));
const txt = (node) => (node ? node.textContent.replace(/\s+/g, " ").trim() : "");
const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/["\\]/g, "\\$&"));
const classOf = (el) => (el && el.getAttribute && el.getAttribute("class")) || "";
const parseNum = (s) => {
if (s == null) return NaN;
const m = String(s)
.replace(/,/g, "")
.match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : NaN;
};
const h = (tag, attrs, children) => {
const el = document.createElement(tag);
Object.keys(attrs || {}).forEach((k) => {
const v = attrs[k];
if (v == null || v === false) return;
if (k === "class") el.className = v;
else if (k === "text") el.textContent = v;
else if (k === "html") el.innerHTML = v;
else el.setAttribute(k, v === true ? "" : String(v));
});
[].concat(children || []).forEach((c) => {
if (c == null || c === false) return;
el.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c);
});
return el;
};
const ICONS = {
check: '',
circleCheck: '',
trendDown: '',
zap: '',
shield:
'',
file: '',
truck:
'',
};
const icon = (name, cls) =>
h("span", {
class: `hm-ico${cls ? ` ${cls}` : ""}`,
"aria-hidden": "true",
html: ``,
});
// True when an element shows something to the user (ignores scripts / whitespace)
const hasContent = (el) => {
if (!el) return false;
if (qs("img, iframe, video, svg, button, input, select, textarea, table, .item", el)) return true;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n = walker.nextNode();
while (n) {
const p = n.parentElement;
if (p && !p.closest("script, style, noscript, template") && n.nodeValue.trim()) return true;
n = walker.nextNode();
}
return false;
};
const isHiddenByClass = (el) => !!(el && el.closest(".hide, .hidden"));
const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true }));
const fmt = (ctx, n) => {
const num = Number(n || 0).toLocaleString(CONFIG.locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `${ctx.cur.pre}${num}${ctx.cur.post}`;
};
const unitWord = (ctx, n) => (n === 1 ? ctx.uom[0] : ctx.uom[1]);
const abbrSize = (label) => {
const key = String(label)
.toLowerCase()
.replace(/[\s\-_.]/g, "");
return CONFIG.sizeAbbr[key] || label;
};
const widgetType = (el) => (classOf(el).match(WIDGET_RE) || [])[1] || "widget";
/* ------------------------------------------------------------------ *
* Data readers
* ------------------------------------------------------------------ */
function readContext(root) {
const productEl = qs(".product-shopping-actions[data-pack-uom], .product-summary[data-pack-uom]", root);
const uomCode = ((productEl && productEl.getAttribute("data-pack-uom")) || "").toUpperCase();
const zeroPriced = !!(productEl && productEl.getAttribute("data-zeropriced") === "1");
const priceEl = qs(".product-shopping-actions-prices .has-price", root);
const rawPrice = txt(priceEl);
let price = parseNum((priceEl && priceEl.getAttribute("data-value")) || rawPrice);
if (!(price > 0) || zeroPriced) price = null;
const preM = rawPrice.match(/^[^\d\s.,-]+/);
const postM = rawPrice.match(/[^\d\s.,-]+$/);
let cur = { pre: "$", post: "" };
if (preM) cur = { pre: preM[0], post: "" };
else if (postM) cur = { pre: "", post: ` ${postM[0]}` };
let list = null;
const rrpWrap = qs(".product-price-rrp", root);
if (rrpWrap && !isHiddenByClass(rrpWrap)) {
const saveEl = qs(".has-savings-price", rrpWrap);
list = parseNum(saveEl && saveEl.getAttribute("data-value"));
if (!(list > 0)) list = parseNum(txt(qs(".rrp", rrpWrap)));
if (!(list > 0) || (price && list <= price)) list = null;
}
const packEl = qs(".product-shopping-actions-prices .uomblock", root);
const packQty = packEl && !isHiddenByClass(packEl) ? parseInt(txt(packEl), 10) : NaN;
return {
uom: CONFIG.uomLabels[uomCode] || CONFIG.fallbackUom,
zeroPriced,
price,
list,
packQty: packQty > 0 ? packQty : null,
cur,
};
}
function readOptionGroups(summary) {
return qsa(".product-options .option-group", summary)
.map((group) => {
const labelEl = qs(":scope > label", group) || qs("label", group);
const name =
group.getAttribute("data-name") ||
txt(labelEl)
.replace(txt(qs(".selectedname", labelEl)), "")
.trim() ||
"Options";
let values = [];
const radios = qsa('input[type="radio"]', group);
if (radios.length) {
values = radios.map((input) => {
const label = input.id ? qs(`label[for="${cssEsc(input.id)}"]`, group) : null;
return { label: txt(label) || input.value, input, labelEl: label, disabled: input.disabled };
});
} else {
const select = qs("select", group);
if (select) {
values = Array.from(select.options)
.filter((o) => o.value !== "")
.map((o) => ({ label: txt(o), select, option: o, disabled: o.disabled }));
}
}
return { name, values };
})
.filter((g) => g.values.length > 0);
}
const isSelected = (v) => (v.input ? v.input.checked : !!(v.option && v.option.selected && v.select.value !== ""));
function readStock(root) {
const wrap = qs(".live-inventory-v2-wrapper", root);
if (!wrap) return null;
const span = qs(".live-inventory-v2-shipping", wrap) || wrap;
const t = txt(span);
if (!t) return { pending: true };
const n = parseNum(t);
const inStock = span.classList.contains("in-stock") || (/in stock/i.test(t) && !(n === 0));
const text = /^[\d,.]+\s+in stock$/i.test(t) ? t.replace(/in stock/i, "in stock") : t;
return { text, inStock };
}
/* ------------------------------------------------------------------ *
* Layout: build shell once, move native widgets into it
* ------------------------------------------------------------------ */
function findWidgets(root) {
return qsa('[class*="_container"]', root).filter((el) => {
if (!WIDGET_RE.test(classOf(el))) return false;
let p = el.parentElement;
while (p && p !== root) {
if (WIDGET_RE.test(classOf(p))) return false;
p = p.parentElement;
}
return true;
});
}
function ensureShell(root, st) {
if (st.shell && root.contains(st.shell.pdp)) return st.shell;
const crumbs = h("div", { class: "hm-pdp__crumbs" });
const gallery = h("div", { class: "hm-pdp__gallery" });
const info = h("div", { class: "hm-pdp__info" });
const optSlot = h("div", { class: "hm-buy__options" });
const buyMain = h("div", { class: "hm-buy__main" }); // only used when the page has no sidebar
const buy = h("div", { class: "hm-pdp__buy" }, [optSlot, buyMain]);
// Main-column widgets that sit above the description (e.g. banner rows) keep that position
const pre = h("div", { class: "hm-pdp__pre", hidden: true });
const tabsCell = h("div", { class: "hm-pdp__tabs", hidden: true }, [pre]);
const top = h("div", { class: "hm-pdp__top" }, [gallery, info, buy, tabsCell]);
const below = h("div", { class: "hm-pdp__below" });
const pdp = h("div", { class: "hm-pdp", "data-hm-layout": "" }, [crumbs, top, below]);
root.insertBefore(pdp, root.firstChild);
st.shell = { pdp, crumbs, top, gallery, info, buy, optSlot, buyMain, side: null, pre, tabsCell, below, tabs: null };
return st.shell;
}
// The EvoX sidebar goes into the buy column as ONE block: its widgets keep their order
// (gift banner, cart, notes, recommended products, icons). We only add style hooks.
function placeSidebar(root, s) {
if (!s.side || !s.buy.contains(s.side)) {
const side = qsa(SIDEBAR_SELECTOR, root).find((el) => !s.pdp.contains(el));
if (side) {
side.classList.add("hm-side");
s.buy.appendChild(side);
s.side = side;
}
}
if (!s.side) return;
findWidgets(s.side).forEach((w) => {
if (!w.classList.contains("hm-widget")) w.classList.add("hm-widget", `hm-w-${widgetType(w)}`);
});
// Option chips sit directly above the cart (below the gift block)
const cart = qs(".hm-w-productaddtocart", s.side);
if (cart && cart.previousElementSibling !== s.optSlot) cart.insertAdjacentElement("beforebegin", s.optSlot);
}
function placeWidgets(root, st) {
const s = ensureShell(root, st);
const crumb = qs(".row-breadcrumb", root);
if (crumb && !s.crumbs.contains(crumb)) {
crumb.classList.add("hm-crumb");
s.crumbs.appendChild(crumb);
}
placeSidebar(root, s);
const widgets = findWidgets(root).filter((w) => !s.pdp.contains(w));
if (!widgets.length) return;
// Remember which widgets were above the description/specs in the original layout
const anchor = widgets.find((w) => TABS_ANCHOR_RE.test(classOf(w)));
const beforeAnchor = new Set(anchor ? widgets.filter((w) => w.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_FOLLOWING) : []);
let desc = null;
let specs = null;
const rest = [];
widgets.forEach((w) => {
const type = widgetType(w);
w.classList.add("hm-widget", `hm-w-${type}`);
switch (type) {
case "productimage":
if (!qs(".hm-w-productimage", s.gallery)) s.gallery.appendChild(w);
else rest.push(w);
break;
case "productsummary":
if (!qs(".hm-w-productsummary", s.info)) s.info.appendChild(w);
else rest.push(w);
break;
case "productaddtocart": // only reached when the cart isn't inside a sidebar
if (!qs(".hm-w-productaddtocart", s.buy)) s.buyMain.appendChild(w);
else rest.push(w);
break;
case "productdescription":
desc = w;
break;
case "productspecs":
specs = w;
break;
default:
if (beforeAnchor.has(w)) s.pre.appendChild(w);
else rest.push(w);
}
});
if (desc || specs) ensureTabs(root, st, desc, specs);
rest.forEach((w) => s.below.appendChild(w));
// Hide the now-empty original bootstrap rows (keep anything unknown that still has content)
Array.from(root.children).forEach((child) => {
if (child === s.pdp || child.tagName === "SCRIPT") return;
child.classList.toggle("hm-src-hidden", !hasContent(child));
});
st.needsNudge = true;
}
/* ------------------------------------------------------------------ *
* Tabs (Description / Specifications)
* ------------------------------------------------------------------ */
function ensureTabs(root, st, desc, specs) {
const s = st.shell;
if (!s.tabs) {
const bar = h("div", { class: "hm-tabs__bar", role: "tablist", "aria-label": "Product information" });
const section = h("section", { class: "hm-tabs" }, [bar]);
const items = {};
[
["desc", "Description"],
["specs", "Specifications"],
].forEach(([key, label]) => {
const tabId = `hm-tab-${st.uid}-${key}`;
const panelId = `hm-panel-${st.uid}-${key}`;
const tab = h("button", {
type: "button",
role: "tab",
id: tabId,
class: "hm-tabs__tab",
"aria-controls": panelId,
"aria-selected": "false",
tabindex: "-1",
text: label,
});
const panel = h("div", { role: "tabpanel", id: panelId, class: "hm-tabs__panel", "aria-labelledby": tabId, hidden: true });
items[key] = { tab, panel, host: panel };
tab.addEventListener("click", () => selectTab(st, key, false));
bar.appendChild(tab);
section.appendChild(panel);
});
bar.addEventListener("keydown", (e) => {
if (!/^(ArrowLeft|ArrowRight|Home|End)$/.test(e.key)) return;
const visible = Object.keys(items).filter((k) => !items[k].tab.hidden);
const cur = visible.indexOf(st.activeTab);
let next = cur;
if (e.key === "ArrowRight") next = (cur + 1) % visible.length;
if (e.key === "ArrowLeft") next = (cur - 1 + visible.length) % visible.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = visible.length - 1;
e.preventDefault();
selectTab(st, visible[next], true);
});
s.tabsCell.appendChild(section); // after s.pre
s.tabs = { section, bar, items };
}
const put = (key, widget) => {
if (!widget) return;
const it = s.tabs.items[key];
qsa(".hm-widget", it.host).forEach((old) => {
if (old !== widget) old.remove();
});
it.host.appendChild(widget);
const heading = txt(qs(".panel-heading h3, .panel-heading", widget));
if (heading) it.tab.textContent = heading;
};
put("desc", desc);
put("specs", specs);
}
function selectTab(st, key, focus) {
const t = st.shell && st.shell.tabs;
if (!t || !t.items[key]) return;
st.activeTab = key;
Object.keys(t.items).forEach((k) => {
const on = k === key;
t.items[k].tab.setAttribute("aria-selected", String(on));
t.items[k].tab.setAttribute("tabindex", on ? "0" : "-1");
t.items[k].panel.hidden = !on;
});
if (focus) t.items[key].tab.focus();
}
function evaluateTabs(root, st) {
const t = st.shell && st.shell.tabs;
if (!t) return;
const descW = qs(".hm-widget", t.items.desc.host);
const specsW = qs(".hm-widget", t.items.specs.host);
const descLoading = !!(descW && qs(".dds-specs-loader-wrapper:not(.hide)", descW));
const hasDesc = !!descW && (descLoading || hasContent(qs(".product-description", descW) || descW));
const hasSpecs = !!specsW && hasContent(qs(".product-specs", specsW) || specsW);
t.items.desc.tab.hidden = !hasDesc;
t.items.specs.tab.hidden = !hasSpecs;
t.section.hidden = !hasDesc && !hasSpecs;
// tabsCell visibility is decided in layoutFlags (it also holds the pre-tabs banners)
const current = st.activeTab && !t.items[st.activeTab].tab.hidden ? st.activeTab : hasDesc ? "desc" : "specs";
selectTab(st, current, false);
}
/* ------------------------------------------------------------------ *
* Info column: meta line (code / stock / mfr ref)
* ------------------------------------------------------------------ */
function renderMeta(root, summary) {
const anchor = qs(".product-title-wrapper", summary) || qs(".product-name", summary);
if (!anchor) return;
const codeEl = qs(".prod-code .label", summary) || qs(".prod-code", summary);
const code = txt(codeEl).replace(/^item\s*code\s*:?\s*/i, "");
const mfrEl = qs(".manufacturer-ref", summary);
const mfrStrong = mfrEl && qs("strong", mfrEl);
const mfrLabel = txt(mfrStrong) || "Manufacturer Ref";
const mfr = mfrEl ? txt(mfrEl).replace(txt(mfrStrong), "").trim() : "";
const hasStock = !!qs(".live-inventory-v2-wrapper", summary);
if (!code && !mfr && !hasStock) return;
const meta = h("p", { class: "hm-meta", [GEN]: "" });
if (code) meta.appendChild(h("span", { class: "hm-meta__item" }, ["Item code ", h("strong", { text: code })]));
if (hasStock) meta.appendChild(h("span", { class: "hm-meta__item hm-stock", "aria-live": "polite" }));
if (mfr) meta.appendChild(h("span", { class: "hm-meta__item" }, [`${mfrLabel} `, h("strong", { text: mfr })]));
anchor.insertAdjacentElement("afterend", meta);
root.classList.add("hm-meta-ok");
}
/* ------------------------------------------------------------------ *
* Buy column: option chips (proxy to native radios / selects)
* ------------------------------------------------------------------ */
function renderOptions(root, st, summary) {
st.chips = [];
const groups = summary ? readOptionGroups(summary) : [];
if (!groups.length) return;
groups.forEach((g, gi) => {
const isSize = /size/i.test(g.name);
const labels = g.values.map((v) => (isSize ? abbrSize(v.label) : v.label));
const wide = labels.some((l) => l.length > 4);
const selected = g.values.find(isSelected);
const selEl = h("span", { class: "hm-opt__sel", text: selected ? selected.label : "", hidden: !selected });
const metaBits = h("span", { class: "hm-opt__meta" }, [selEl]);
if (gi === 0 && qs(".live-inventory-v2-wrapper", summary)) metaBits.appendChild(h("span", { class: "hm-stock" }));
const grid = h("div", { class: "hm-opt__grid", role: "group", "aria-label": g.name });
g.values.forEach((v, vi) => {
const short = labels[vi];
const btn = h(
"button",
{
type: "button",
class: "hm-chip",
"aria-pressed": String(isSelected(v)),
"aria-label": short !== v.label ? v.label : null,
title: v.label,
disabled: v.disabled || null,
},
[h("span", { class: "hm-chip__label", text: short }), h("span", { class: "hm-chip__tick" }, [icon("check")])],
);
btn.addEventListener("click", () => selectValue(root, st, v));
grid.appendChild(btn);
st.chips.push({ btn, v, selEl, group: g });
});
const card = h("section", { class: `hm-card hm-opt${wide ? " hm-opt--wide" : ""}`, [GEN]: "", "aria-label": g.name }, [
h("div", { class: "hm-opt__head" }, [h("h2", { class: "hm-opt__title", text: g.name }), metaBits]),
grid,
]);
st.shell.optSlot.appendChild(card);
});
root.classList.add("hm-opts-ok");
}
function selectValue(root, st, v) {
if (v.disabled || isSelected(v)) return;
root.classList.add("hm-is-switching");
st.switchAt = Date.now();
clearTimeout(st.switchTimer);
st.switchTimer = setTimeout(() => root.classList.remove("hm-is-switching"), CONFIG.switchTimeout);
if (v.input) {
// Clicking the label runs any label-bound handlers AND checks/changes the radio
if (v.labelEl && v.labelEl.isConnected) v.labelEl.click();
else v.input.click();
} else if (v.select) {
v.select.value = v.option.value;
fire(v.select, "change");
}
syncChips(st);
}
function syncChips(st) {
(st.chips || []).forEach(({ btn, v, selEl, group }) => {
btn.setAttribute("aria-pressed", String(isSelected(v)));
const sel = group.values.find(isSelected);
selEl.textContent = sel ? sel.label : "";
selEl.hidden = !sel;
});
}
/* ------------------------------------------------------------------ *
* Buy column: price header, total, volume tiers
* ------------------------------------------------------------------ */
function buildPriceHead(ctx) {
const sub = [];
if (ctx.packQty) sub.push(`${ctx.packQty.toLocaleString(CONFIG.locale)} per ${ctx.uom[0]}`);
const subEl = h("p", { class: "hm-price__sub" });
sub.forEach((s) => subEl.appendChild(h("span", { text: s })));
if (ctx.list) subEl.appendChild(h("span", {}, [h("s", { text: fmt(ctx, ctx.list) }), " list"]));
const pct = ctx.list ? Math.round((1 - ctx.price / ctx.list) * 100) : 0;
return h("div", { class: "hm-price", [GEN]: "" }, [
h("div", { class: "hm-price__main" }, [
h("div", { class: "hm-price__amount" }, [
h("span", { class: "hm-price__value", text: fmt(ctx, ctx.price) }),
h("span", { class: "hm-price__uom", text: `/ ${ctx.uom[0]}` }),
]),
subEl.childNodes.length ? subEl : null,
]),
pct > 0 ? h("span", { class: "hm-pill hm-pill--save", text: `Save ${pct}%` }) : null,
]);
}
const buildPriceSkeleton = () =>
h("div", { class: "hm-price hm-price--loading", [GEN]: "", "aria-busy": "true" }, [
h("div", { class: "hm-price__main" }, [
h("span", { class: "hm-skel hm-skel--price" }),
h("span", { class: "hm-skel hm-skel--text" }),
]),
]);
function buildTierModel(ctx, tierEl, qtyEl) {
if (!tierEl || !ctx.price) return [];
let raw = [];
try {
raw = JSON.parse(tierEl.getAttribute("data-tierpricing") || "[]");
} catch (e) {
raw = [];
}
if (!Array.isArray(raw)) return [];
const minOrder = Math.max(1, parseInt(qtyEl && qtyEl.getAttribute("data-min-order"), 10) || 1);
const list = raw
.map((t) => ({ qty: parseInt(t && (t.quantity != null ? t.quantity : t.qty), 10), price: parseNum(t && t.price) }))
.filter((t) => t.qty > 0 && t.price > 0)
.sort((a, b) => a.qty - b.qty)
.filter((t, i, arr) => i === 0 || t.qty !== arr[i - 1].qty);
if (!list.length) return [];
if (list[0].qty > minOrder) list.unshift({ qty: minOrder, price: ctx.price });
return list.map((t, i) => Object.assign({}, t, { max: i < list.length - 1 ? list[i + 1].qty - 1 : Infinity }));
}
const tierIndex = (tiers, q) => {
let idx = 0;
tiers.forEach((t, i) => {
if (q >= t.qty) idx = i;
});
return idx;
};
function buildTierCard(root, st, ctx, tiers) {
const ref = ctx.list || tiers[0].price;
const pctOf = (p) => Math.max(0, Math.round((1 - p / ref) * 100));
const maxPct = Math.max.apply(
null,
tiers.map((t) => pctOf(t.price)),
);
const buttons = tiers.map((t, i) => {
const range =
t.max === Infinity
? `${t.qty.toLocaleString(CONFIG.locale)}+ ${ctx.uom[1]}`
: t.max === t.qty
? `${t.qty} ${unitWord(ctx, t.qty)}`
: `${t.qty.toLocaleString(CONFIG.locale)}\u2013${t.max.toLocaleString(CONFIG.locale)} ${ctx.uom[1]}`;
const pct = pctOf(t.price);
let saveText = "Standard price";
if (pct > 0) saveText = ctx.list ? `Save ${pct}% vs list price` : `Save ${pct}% per ${ctx.uom[0]}`;
const isBest = i === tiers.length - 1 && tiers.length > 1;
const btn = h("button", { type: "button", class: "hm-tier", "aria-pressed": "false" }, [
h("span", { class: "hm-tier__info" }, [
h("span", { class: "hm-tier__qty" }, [
h("span", { text: range }),
isBest ? h("span", { class: "hm-badge-best" }, [icon("zap"), "Best value"]) : null,
]),
h("span", { class: "hm-tier__save", text: saveText }),
]),
h("span", { class: "hm-tier__right" }, [
h("span", { class: "hm-tier__pricebox" }, [
h("span", { class: "hm-tier__price", text: fmt(ctx, t.price) }),
h("span", { class: "hm-tier__per", text: `/ ${ctx.uom[0]}` }),
]),
h("span", { class: "hm-tier__radio" }, [icon("check")]),
]),
]);
btn.addEventListener("click", () => {
const q = currentQty(st);
if (q >= t.qty && q <= t.max) return;
setQty(root, t.qty);
});
return btn;
});
const bar = h("div", { class: "hm-progress__bar" });
const progressText = h("p", { class: "hm-progress__text", "aria-live": "polite" });
const card = h("section", { class: "hm-card hm-tiers", [GEN]: "", "aria-label": "Volume pricing" }, [
h("header", { class: "hm-tiers__head" }, [
h("div", { class: "hm-tiers__title" }, [
h("span", { class: "hm-tiers__icon" }, [icon("trendDown")]),
h("div", {}, [
h("h3", { class: "hm-tiers__h", text: "Buy more, pay less" }),
h("p", { class: "hm-tiers__p", text: `Volume pricing per ${ctx.uom[0]}` }),
]),
]),
maxPct > 0 ? h("span", { class: "hm-pill hm-pill--soft", text: `Save up to ${maxPct}%` }) : null,
]),
h("div", { class: "hm-tiers__list" }, buttons),
h("div", { class: "hm-progress" }, [h("div", { class: "hm-progress__track" }, [bar]), progressText]),
]);
return { card, buttons, bar, progressText };
}
function renderPurchase(root, st, cart, ctx) {
st.purchase = null;
const actions = qs(".product-shopping-actions", cart);
if (!actions) return;
// Price header (skip when multiple UoM selector is live - native stays)
const pricesCol = qs(".product-shopping-actions-prices", actions);
const multiUom = qs(".multiple-uom:not(.hide)", actions);
if (pricesCol && !multiUom) {
if (ctx.price) {
actions.insertBefore(buildPriceHead(ctx), actions.firstChild);
root.classList.add("hm-price-ok");
} else if (!st.priceSettled && !ctx.zeroPriced) {
actions.insertBefore(buildPriceSkeleton(), actions.firstChild);
root.classList.add("hm-price-ok");
}
}
const qtyEl = qs(".qnt-count", actions);
const input = qtyEl && qs(".inputquantity", qtyEl);
// Total next to the quantity stepper
let totalEl = null;
if (qtyEl && input && ctx.price) {
totalEl = h("strong", { class: "hm-total__value" });
qtyEl.insertAdjacentElement("afterend", h("div", { class: "hm-total", [GEN]: "" }, ["Total ", totalEl]));
}
// Optional shipping line
const btn = qs(".btn-add-to-cart", actions);
const btnWrap = qs(".live-inventory-v2-add-to-cart-wrapper", actions) || (btn && btn.parentElement);
if (CONFIG.shippingNote && btnWrap) {
btnWrap.insertAdjacentElement("afterend", h("p", { class: "hm-ship", [GEN]: "" }, [icon("truck"), CONFIG.shippingNote]));
}
// Quick list / compare row sits inline at the bottom of the card
const ql = qs(".product-shopping-actions-addtoquicklist", actions);
if (ql) (ql.closest(".row") || ql).classList.add("hm-qlrow");
// Volume tiers
const tierEl = qs("[data-tierpricing]", actions);
const tiers = buildTierModel(ctx, tierEl, qtyEl);
let tierUi = null;
if (tiers.length > 1) {
tierUi = buildTierCard(root, st, ctx, tiers);
actions.insertAdjacentElement("afterend", tierUi.card);
root.classList.add("hm-tiers-ok");
}
const isMultiple = qtyEl && qtyEl.getAttribute("data-is-multiple") === "1";
const multiple = isMultiple ? parseInt(qtyEl.getAttribute("data-multiple-qty"), 10) || 1 : 1;
const minOrder = Math.max(1, parseInt(qtyEl && qtyEl.getAttribute("data-min-order"), 10) || 1);
st.purchase = { ctx, input, totalEl, tiers, tierUi, multiple, minOrder };
}
const currentQty = (st) => {
const p = st.purchase;
if (!p || !p.input) return 0;
return Math.max(0, parseInt(String(p.input.value).replace(/[^\d]/g, ""), 10) || 0);
};
function setQty(root, n) {
const st = state.get(root);
const p = st && st.purchase;
if (!p || !p.input) return;
let q = Math.max(p.minOrder, n);
if (p.multiple > 1) q = Math.ceil(q / p.multiple) * p.multiple;
p.input.value = String(q);
fire(p.input, "input");
fire(p.input, "change");
syncQty(root, true);
}
function syncQty(root, force) {
const st = state.get(root);
const p = st && st.purchase;
if (!p || !p.input) return;
const q = currentQty(st);
if (!force && q === st.lastQty) return;
st.lastQty = q;
const tiers = p.tiers;
const idx = tiers.length ? tierIndex(tiers, q) : -1;
const unit = idx >= 0 ? tiers[idx].price : p.ctx.price;
if (p.totalEl && unit) p.totalEl.textContent = fmt(p.ctx, unit * q);
if (!p.tierUi) return;
p.tierUi.buttons.forEach((b, i) => b.setAttribute("aria-pressed", String(i === idx)));
const next = tiers[idx + 1];
const textEl = p.tierUi.progressText;
textEl.textContent = "";
if (next) {
const need = Math.max(0, next.qty - q);
const pct = Math.min(100, Math.max(4, (q / next.qty) * 100));
p.tierUi.bar.style.width = `${pct}%`;
const saving = (unit - next.price) * next.qty;
const link = h("button", { type: "button", class: "hm-link", text: `${need} more ${unitWord(p.ctx, need)}` });
link.addEventListener("click", () => setQty(root, next.qty));
textEl.append("Add ", link, ` to unlock ${fmt(p.ctx, next.price)}/${p.ctx.uom[0]}`);
if (saving > 0) textEl.append(" \u2014 save ", h("strong", { class: "hm-save-text", text: fmt(p.ctx, saving) }), " on your order.");
else textEl.append(".");
} else {
p.tierUi.bar.style.width = "100%";
textEl.append(`You're getting the best price per ${p.ctx.uom[0]}.`);
}
}
/* ------------------------------------------------------------------ *
* Key features card (info column) - replaces native points + docs
* ------------------------------------------------------------------ */
function renderFeatures(root, summary) {
if (!summary) return;
const pointsWrap = qs(".product-points-wrapper", summary);
const docsWrap = qs(".product-links-wrapper", summary);
const points = qsa(".product-points li", summary).map(txt).filter(Boolean);
const docs = docsWrap ? qsa("a[href]", docsWrap) : [];
if (!points.length && !docs.length) return;
const box = h("div", { class: "hm-feat", [GEN]: "" }, [
h("h3", { class: "hm-feat__title" }, [icon("shield"), points.length ? "Key features" : "Documents"]),
]);
if (points.length) {
box.appendChild(
h(
"ul",
{ class: "hm-feat__list" },
points.map((p) => h("li", {}, [icon("circleCheck"), h("span", { text: p })])),
),
);
}
if (docs.length) {
box.appendChild(
h(
"div",
{ class: "hm-feat__docs" },
docs.map((a) => {
const href = a.getAttribute("href");
const label = txt(qs(".description", a)) || a.getAttribute("title") || "document";
const isPdf = /\.pdf(\?|#|$)/i.test(href);
return h("a", { class: "hm-feat__doc", href, target: a.getAttribute("target") || "_blank", rel: "noopener" }, [
icon("file"),
`Download ${label.toLowerCase()}${isPdf ? " (PDF)" : ""}`,
]);
}),
),
);
}
const anchor = pointsWrap || docsWrap;
if (anchor) anchor.insertAdjacentElement("beforebegin", box);
else summary.appendChild(box);
root.classList.add("hm-feat-ok");
}
/* ------------------------------------------------------------------ *
* Stock
* ------------------------------------------------------------------ */
function paintStock(root) {
const st = state.get(root);
if (!st) return;
const info = readStock(root);
qsa(".hm-stock", root).forEach((el) => {
el.textContent = "";
el.classList.remove("is-loading", "is-in", "is-out");
el.removeAttribute("aria-busy");
el.hidden = false;
if (!info || (info.pending && st.stockSettled)) {
el.hidden = true;
return;
}
if (info.pending) {
el.classList.add("is-loading");
el.setAttribute("aria-busy", "true");
el.appendChild(h("span", { class: "hm-skel hm-skel--stock" }));
return;
}
el.classList.add(info.inStock ? "is-in" : "is-out");
el.appendChild(h("span", { class: "hm-stock__dot", "aria-hidden": "true" }));
el.appendChild(document.createTextNode(info.text));
});
}
/* ------------------------------------------------------------------ *
* Render (idempotent)
* ------------------------------------------------------------------ */
function markEmpty(st) {
const s = st.shell;
[s.below, s.pre, s.buy].forEach((host) => {
qsa(".hm-widget", host).forEach((w) => {
if (w.closest(".hm-tabs")) return;
w.classList.toggle("hm-is-empty", !hasContent(w));
});
});
}
// A sticky buy column taller than the viewport can never show its bottom - stop sticking then
function checkBuyFit(st) {
const s = st && st.shell;
if (!s) return;
s.pdp.classList.toggle("hm-pdp--buy-tall", s.buy.offsetHeight > window.innerHeight - CONFIG.stickyGap);
}
function layoutFlags(root, st) {
const s = st.shell;
const preVisible = qsa(".hm-widget:not(.hm-is-empty)", s.pre).length > 0;
const tabsVisible = !!(s.tabs && !s.tabs.section.hidden);
s.pre.hidden = !preVisible;
s.tabsCell.hidden = !preVisible && !tabsVisible;
const hasBuy = s.optSlot.children.length > 0 || qsa(".hm-widget:not(.hm-is-empty)", s.buy).length > 0;
s.pdp.classList.toggle("hm-pdp--no-buy", !hasBuy);
s.pdp.classList.toggle("hm-pdp--no-gallery", !qs(".hm-w-productimage", s.gallery));
s.crumbs.hidden = !s.crumbs.children.length;
if (CONFIG.hideSocial) root.classList.add("hm-hide-social");
checkBuyFit(st);
}
function render(root) {
const st = state.get(root);
if (!st) return;
if (st.observer) st.observer.disconnect();
try {
placeWidgets(root, st);
qsa(`[${GEN}]`, root).forEach((n) => n.remove());
root.classList.remove("hm-meta-ok", "hm-opts-ok", "hm-price-ok", "hm-tiers-ok", "hm-feat-ok");
const summary = qs(".hm-w-productsummary", st.shell.info);
const cart = qs(".hm-w-productaddtocart", st.shell.buy);
const ctx = readContext(root);
st.ctx = ctx;
if (summary) renderMeta(root, summary);
renderFeatures(root, summary);
renderOptions(root, st, summary);
if (cart) renderPurchase(root, st, cart, ctx);
evaluateTabs(root, st);
markEmpty(st);
layoutFlags(root, st);
paintStock(root);
syncQty(root, true);
} catch (err) {
if (window.console) console.warn("[hm-pdp] render failed", err);
} finally {
observe(root, st);
}
if (st.needsNudge) {
st.needsNudge = false;
nudgeSliders();
}
}
/* ------------------------------------------------------------------ *
* Observers & events
* ------------------------------------------------------------------ */
function observe(root, st) {
if (!st.observer) st.observer = new MutationObserver((muts) => onMutations(root, st, muts));
st.observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["data-tierpricing", "data-value", "disabled", "checked", "class", "style"],
});
}
function onMutations(root, st, muts) {
let needRender = false;
let needStock = false;
muts.forEach((m) => {
const target = m.target.nodeType === 1 ? m.target : m.target.parentElement;
if (!target) return;
if (target !== root && target.closest(IGNORE_SEL)) return;
if (target.closest(".live-inventory-v2-wrapper")) {
needStock = true;
return;
}
if (m.type === "attributes") {
if (target === root || target.hasAttribute("data-hm-layout")) return;
const a = m.attributeName;
if (a === "checked" || a === "disabled") {
if (target.matches("input, select, option")) needRender = true;
} else if (a === "class" || a === "style") {
if (target.matches(ATTR_RELEVANT_SEL)) needRender = true;
} else {
needRender = true; // data-tierpricing / data-value
}
return;
}
if (m.type === "childList") {
const nodes = Array.from(m.addedNodes).concat(Array.from(m.removedNodes));
const trivial = nodes.every((n) =>
n.nodeType === 1 ? n.hasAttribute(GEN) || n.tagName === "SCRIPT" : !(n.nodeValue || "").trim(),
);
if (trivial) return;
}
needRender = true;
});
if (needStock) requestAnimationFrame(() => paintStock(root));
if (needRender) {
if (st.switchAt && Date.now() - st.switchAt > 250) {
root.classList.remove("hm-is-switching");
st.switchAt = 0;
}
clearTimeout(st.renderTimer);
st.renderTimer = setTimeout(() => render(root), 120);
}
}
function bindRoot(root, st) {
root.addEventListener("click", (e) => {
if (e.target.closest && e.target.closest(".incr-btn")) setTimeout(() => syncQty(root, true), 0);
});
["input", "change", "keyup", "blur"].forEach((type) => {
root.addEventListener(
type,
(e) => {
if (e.target && e.target.classList && e.target.classList.contains("inputquantity")) syncQty(root);
},
true,
);
});
root.addEventListener("change", (e) => {
if (e.target.closest && e.target.closest(".product-options")) syncChips(st);
});
}
let globalsBound = false;
let jqBound = false;
function onInventoryDone() {
roots.forEach((r) => {
const st = state.get(r);
if (!st) return;
st.stockSettled = true;
requestAnimationFrame(() => paintStock(r));
});
}
function onPricingDone() {
roots.forEach((r) => {
const st = state.get(r);
if (!st) return;
st.priceSettled = true;
clearTimeout(st.renderTimer);
st.renderTimer = setTimeout(() => render(r), 60);
});
}
function bindJq() {
const $ = window.jQuery;
if (jqBound || !$) return;
jqBound = true;
// jQuery .trigger() on document doesn't reach native listeners - bind both ways
$(document).on("live-inventory-done.hmpdp", onInventoryDone);
$(document).on("live-pricing-done.hmpdp", onPricingDone);
}
function bindGlobal() {
bindJq();
if (globalsBound) return;
globalsBound = true;
document.addEventListener("live-inventory-done", onInventoryDone);
window.addEventListener("live-inventory-done", onInventoryDone);
document.addEventListener("live-pricing-done", onPricingDone);
window.addEventListener("live-pricing-done", onPricingDone);
window.addEventListener("load", () => {
bindJq();
nudgeSliders();
});
window.addEventListener("resize", () => roots.forEach((r) => checkBuyFit(state.get(r))));
window.addEventListener("pageshow", () => roots.forEach((r) => r.classList.remove("hm-is-switching")));
setInterval(() => {
if (document.hidden) return;
roots.forEach((r) => syncQty(r));
}, CONFIG.pollQtyMs);
}
// Master Slider / Owl / magnifier recalc after we move things into narrower columns
let nudgeTimer = null;
function nudgeSliders() {
clearTimeout(nudgeTimer);
const go = () => window.dispatchEvent(new Event("resize"));
requestAnimationFrame(go);
nudgeTimer = setTimeout(go, 400);
}
// overflow:hidden on theme wrappers kills position:sticky - swap for clip (keeps BFC via flow-root)
function fixStickyAncestors(el) {
if (!CONFIG.fixStickyAncestors || !(window.CSS && CSS.supports && CSS.supports("overflow", "clip"))) return;
let node = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
const cs = getComputedStyle(node);
if (cs.overflowX === "hidden" || cs.overflowY === "hidden") {
if (cs.display === "block") node.style.display = "flow-root";
node.style.overflow = "clip";
}
node = node.parentElement;
}
}
/* ------------------------------------------------------------------ *
* Boot
* ------------------------------------------------------------------ */
function setupRoot(root) {
if (state.has(root)) return;
if (!qs(".product-summary", root)) {
root.classList.add("hm-active");
return;
} // not a standard PDP - leave untouched
const st = {
uid: root.classList.contains("mainproductpagemobile") ? "m" : "d",
stockSettled: false,
priceSettled: false,
lastQty: null,
activeTab: "desc",
chips: [],
};
state.set(root, st);
roots.push(root);
root.classList.add("hm-active");
render(root);
bindRoot(root, st);
// Sidebar images / carousels load late and change the buy column height
if (window.ResizeObserver && st.shell) new ResizeObserver(() => checkBuyFit(st)).observe(st.shell.buy);
setTimeout(() => {
st.stockSettled = true;
paintStock(root);
}, CONFIG.stockTimeout);
setTimeout(() => {
if (st.priceSettled) return;
st.priceSettled = true;
if (st.ctx && !st.ctx.price) render(root);
}, CONFIG.priceTimeout);
if (st.uid === "d" && st.shell) fixStickyAncestors(st.shell.buy);
}
function init() {
qsa(ROOT_SELECTOR).forEach(setupRoot);
if (roots.length) bindGlobal();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();