(858) 224-9100
/* Filtrous - contextual (per-customer) PDP price (2026-08-04, rev 2026-08-12).
The V3 app renders prices from a static asset (catalog-index.json) baked at
theme build time, so every visitor sees list price - B2B customers included,
even though Shopify computes their contract price in Liquid on every request
and checkout charges it correctly.
sections/filtrous-ctx-price.liquid emits that request-time Liquid price as
window.FV3_CTX_PRICE. This script swaps it into the PDP price element and,
when the two differ, keeps the static index value visible struck through as
the list price - so the contract saving is legible rather than invisible.
2026-08-12: Call-for-Quote SKUs (any SKU containing "OSK", plus
EXK-BD-0096-PP) are never priced anywhere on the storefront - the app
paints "Call for Quote" on their PDP and this script must leave it alone.
Without the guard, signed-in company contacts saw their contract price
swapped over the label. Same predicate as filtrous-v3.js / hero-nine.
The struck value is read off the DOM because the app painted it from the
static index, which IS list price. Liquid cannot supply it: for a
company-location customer `variant.price` already resolves to the contract
price, and compare_at_price means sale pricing, not B2B.
That read happens ONCE per rendered node and is cached on the node itself
(data-list-price). Re-deriving it from the DOM on re-entry would be wrong:
after a swap the visible number is the contract price, so a second pass
would strike the contract price and promote list - inverting the two.
2026-08-26: the "identical for logged-out visitors" assumption only holds
while catalog-index.json is in step with Shopify. It was baked 2026-08-14,
seven SKUs have been repriced upward since, and logged-out visitors were
shown the stale lower number struck through against the current one - a
price rise dressed as a saving (MBP-10-2070 read $136.50 / $165.00).
Two guards now make that structurally impossible rather than a function of
index freshness. The section only sets showWas for a signed-in company
contact, and the strike below additionally requires the struck value to be
strictly greater than the price being shown: a contract price is never
above list, so a lower "list" is index drift, not a discount.
The price SWAP is deliberately left ungated. The Liquid value is the one
checkout charges, so swapping it in is what keeps a drifted PDP honest -
disabling the section would put the stale index price back on the page.
Scope: PDP only, and only when the rendered SKU matches the Liquid product -
the app is a SPA-ish renderer, so we guard against it painting a different
product than the one Liquid saw. Grid/search cards still show list price
(known limitation, fix is a Liquid-rendered price endpoint).
Revert: remove the Contextual price section from templates/product.json.
*/
(function () {
if (window.__fctxInit) return;
window.__fctxInit = true;
var C = window.FV3_CTX_PRICE;
if (!C || !C.sku || !C.price) return;
/* Call for Quote - these SKUs never show a price, for any customer. */
var CFQ_SKU = String(C.sku).toUpperCase();
if (CFQ_SKU.indexOf('OSK') >= 0 || CFQ_SKU === 'EXK-BD-0096-PP') return;
var SHOW_WAS = C.showWas !== false;
var MUT = '#5c6f7e'; /* --mut */
/* money string -> integer cents, for comparing the struck value to C.raw */
function cents(v) {
var n = parseFloat(String(v).replace(/[^0-9.]/g, ''));
return isFinite(n) ? Math.round(n * 100) : null;
}
/* text of the price node with the unit suffix and any prior strike removed */
function bareText(el) {
var probe = el.cloneNode(true);
var ps = probe.querySelector('small');
if (ps && ps.parentNode) ps.parentNode.removeChild(ps);
var pw = probe.querySelector('.pdp-was');
if (pw && pw.parentNode) pw.parentNode.removeChild(pw);
return (probe.textContent || '').trim();
}
function apply() {
var app = document.getElementById('fv3-app');
if (!app) return;
var part = app.querySelector('.fv3-buy .part');
if (!part) return;
if (part.textContent.trim().toUpperCase() !== String(C.sku).toUpperCase())
return;
var el = app.querySelector('.fv3-buy .pdp-price');
if (!el) return;
if (el.getAttribute('data-ctx-price') === C.price) return;
/* capture list price once per node - see header note on re-entry */
var listPrice = el.getAttribute('data-list-price');
if (listPrice === null) {
listPrice = bareText(el);
el.setAttribute('data-list-price', listPrice);
}
/* the unit suffix ("/ pack") is reused verbatim */
var small = el.querySelector('small');
el.textContent = '';
/* strike only a genuine saving - see header note on index drift */
var listC = cents(listPrice);
var ctxC = typeof C.raw === 'number' ? C.raw : cents(C.price);
var saving = listC !== null && ctxC !== null && listC > ctxC;
if (SHOW_WAS && saving && listPrice !== C.price) {
var was = document.createElement('s');
was.className = 'pdp-was';
was.setAttribute(
'style',
'margin-right:8px;font-size:15px;font-weight:500;color:' + MUT + ';'
);
was.textContent = listPrice;
el.appendChild(was);
}
el.appendChild(document.createTextNode(C.price + (small ? ' ' : '')));
if (small) el.appendChild(small);
el.setAttribute('data-ctx-price', C.price);
}
function boot() {
apply();
var mount = document.getElementById('fv3-app') || document.body;
new MutationObserver(apply).observe(mount, {
childList: true,
subtree: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();