(() => { 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: `${ICONS[name]}`, }); // 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(); })();