PageSourceSearch

https://www.brotherssupply.com/wp-content/plugins/hozio-studio/com…/free-ship/free-ship.js?ver=b42d00f78b56

js brotherssupply.com collected 2026-10-09 00:30:16 UTC 9,124 bytes, 197 lines download raw bytes

1/* ═════════════════════════════════════════════════════════════════════════
2   FREE-SHIPPING PROGRESS BAR (W-134) — behavior (Phase-2 widget system).
3
4   Keeps every .hzo-free-ship meter in sync with the LIVE cart subtotal. It is a
5   pure CONSUMER of the existing commerce pipeline — it adds no cart signal and
6   no polling:
7
8     · SUBSCRIBES to `hzo:cartupdated` — the bubbling event commerce.js fires
9       after every applyFragments() swap (the fragment-refresh signal, G10's
10       intended consumer). Reconcile/rollback also routes through applyFragments,
11       so this already covers the failure branch today.
12     · SUBSCRIBES to `hzo:cartreconcile` too — the reserved reconcile event
13       (audit roadmap step 3); harmless no-op until it ships, forward-compatible.
14     · Also listens to WC core's jQuery `wc_fragments_loaded/refreshed` (fired on
15       plain page loads / WC-native cart actions that bypass applyFragments), the
16       SAME belt-and-suspenders commerce.js uses to keep the mini-cart in sync.
17
18   The live subtotal is READ from the server-authoritative .hzo-free-ship__data
19   node (raw numeric, i18n-safe) that the additive fragment filter keeps fresh —
20   never parsed from formatted money HTML. Each bar recomputes remaining/percent/
21   unlocked against ITS OWN threshold, formats the remaining with the currency
22   primitives on its root, updates the progressbar aria + fill, pulses on the
23   locked→unlocked transition (reduced-motion-safe), and announces the change via
24   window.hzo.announce (polite) — the body-portaled region, so the announcement
25   survives an open drawer and is never defeated by a fragment swap (§4).
26
27   First paint is already correct (server-rendered), so with JS off the bar still
28   shows the right state; this only keeps it live. IIFE, no dependencies,
29   idempotent (guarded per-root + one delegated document listener).
30   ═════════════════════════════════════════════════════════════════════════ */
31(function () {
32	'use strict';
33
34	var ROOT_SEL = '.hzo-free-ship[data-hzo-free-ship]';
35	var PULSE_MS = 900; // must match the .is-pulsing animation duration in CSS
36
37	function warn(msg) {
38		if (window.console && console.warn) { console.warn('[hzo.free-ship] ' + msg); }
39	}
40
41	function prefersReducedMotion() {
42		return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
43	}
44
45	function num(v, fallback) {
46		var n = parseFloat(v);
47		return isNaN(n) ? fallback : n;
48	}
49
50	// Format an amount using the currency primitives stamped on the root by PHP —
51	// the exact JS twin of hzostudio_free_ship_format_money() so live output
52	// matches first paint. Locale-safe: separators come from WC, not assumed.
53	function formatMoney(amount, root) {
54		var symbol   = root.getAttribute('data-hzo-free-ship-symbol') || '$';
55		var decimals = Math.max(0, Math.min(6, parseInt(root.getAttribute('data-hzo-free-ship-decimals'), 10)));
56		if (isNaN(decimals)) { decimals = 2; }
57		var decPoint = root.getAttribute('data-hzo-free-ship-dec-point');
58		if (decPoint === null) { decPoint = '.'; }
59		var thousand = root.getAttribute('data-hzo-free-ship-thousand');
60		if (thousand === null) { thousand = ','; }
61		var pos = root.getAttribute('data-hzo-free-ship-symbol-pos') || 'left';
62
63		var fixed = (Math.round(amount * Math.pow(10, decimals)) / Math.pow(10, decimals)).toFixed(decimals);
64		var parts = fixed.split('.');
65		// Group the integer part with the thousand separator.
66		parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousand === '' ? '' : '�');
67		var intGrouped = thousand === '' ? parts[0] : parts[0].split('�').join(thousand);
68		var numStr = parts.length > 1 ? (intGrouped + decPoint + parts[1]) : intGrouped;
69
70		var NBSP = ' ';
71		switch (pos) {
72			case 'right':       return numStr + symbol;
73			case 'left_space':  return symbol + NBSP + numStr;
74			case 'right_space': return numStr + NBSP + symbol;
75			case 'left':
76			default:            return symbol + numStr;
77		}
78	}
79
80	function renderMessage(root, remaining, threshold, unlocked) {
81		if (unlocked) {
82			return root.getAttribute('data-hzo-free-ship-msg-unlocked') || 'Free shipping unlocked!';
83		}
84		var tpl = root.getAttribute('data-hzo-free-ship-msg-away') || "You're {remaining} away from FREE shipping";
85		return tpl
86			.split('{remaining}').join(formatMoney(remaining, root))
87			.split('{threshold}').join(formatMoney(threshold, root));
88	}
89
90	function announce(root, message) {
91		if (root.getAttribute('data-hzo-free-ship-announce') === 'false') { return; }
92		if (window.hzo && typeof window.hzo.announce === 'function') {
93			// Polite: progress is informative, never interrupting. The announcer
94			// dedupes + coalesces bursts to one settled value.
95			window.hzo.announce(message, 'polite');
96		}
97	}
98
99	function pulse(root) {
100		if (root.getAttribute('data-hzo-free-ship-pulse') === 'false') { return; }
101		if (prefersReducedMotion()) { return; } // ambient celebration stops under reduce (PRESERVE-5)
102		root.classList.remove('is-pulsing');
103		// Force reflow so re-adding the class restarts the animation.
104		void root.offsetWidth;
105		root.classList.add('is-pulsing');
106		window.setTimeout(function () { root.classList.remove('is-pulsing'); }, PULSE_MS);
107	}
108
109	// Recompute one bar from the server-truth data node it owns.
110	function update(root, isInitial) {
111		var dataNode = root.querySelector('.hzo-free-ship__data');
112		var threshold = num(root.getAttribute('data-hzo-free-ship-threshold'), 0);
113		if (threshold <= 0) {
114			if (isInitial) { warn('a bar has no positive threshold — leav
114ing server-rendered state as-is.'); }
115			return;
116		}
117
118		var subtotal = dataNode ? num(dataNode.getAttribute('data-hzo-cart-subtotal'), 0) : 0;
119		var count    = dataNode ? parseInt(dataNode.getAttribute('data-hzo-cart-count'), 10) : NaN;
120
121		var remaining = Math.max(0, threshold - subtotal);
122		var unlocked  = subtotal >= threshold;
123		var pct       = threshold > 0 ? Math.min(100, (subtotal / threshold) * 100) : 100;
124
125		var wasUnlocked = root.classList.contains('is-unlocked');
126		var message = renderMessage(root, remaining, threshold, unlocked);
127
128		// Fill + progressbar aria (the root is NOT a swapped fragment, so these
129		// live-update safely; the visible <p> mirrors the same text).
130		var fill = root.querySelector('.hzo-free-ship__fill');
131		if (fill) { fill.style.setProperty('--hzo-free-ship-pct', (Math.round(pct * 100) / 100).toString()); }
132		var msgEl = root.querySelector('.hzo-free-ship__message');
133		if (msgEl) { msgEl.textContent = message; }
134		root.setAttribute('aria-valuenow', (Math.round(pct * 100) / 100).toString());
135		root.setAttribute('aria-valuetext', message);
136		root.classList.toggle('is-unlocked', unlocked);
137
138		// hide-empty: fold the bar away on an empty cart (count known from server).
139		var hidden = false;
140		if (root.getAttribute('data-hzo-free-ship-hide-empty') !== 'false' && !isNaN(count)) {
141			hidden = count <= 0;
142			root.hidden = hidden;
143		}
144
145		if (isInitial) { return; } // first paint is already correct — don't announce/pulse on load
146		if (hidden) { return; }    // nothing to celebrate/announce while folded away
147
148		// Celebrate + announce only on the locked → unlocked transition; announce
149		// remaining-progress politely otherwise (deduped downstream).
150		if (unlocked && !wasUnlocked) {
151			pulse(root);
152			announce(root, message);
153		} else if (!unlocked) {
154			announce(root, message);
155		}
156	}
157
158	function updateAll(isInitial) {
159		var bars = document.querySelectorAll(ROOT_SEL);
160		for (var i = 0; i < bars.length; i++) {
161			update(bars[i], isInitial);
162		}
163	}
164
165	function init() {
166		var bars = document.querySelectorAll(ROOT_SEL);
167		if (!bars.length) { return; } // nothing on this page — bail (no listeners bound)
168
169		for (var i = 0; i < bars.length; i++) {
170			if (bars[i].getAttribute('data-hzo-free-ship-ready') === '1') { continue; }
171			bars[i].setAttribute('data-hzo-free-ship-ready', '1');
172		}
173		updateAll(true);
174	}
175
176	// Recompute on the existing cart-refresh signals — never a new one, never a poll.
177	document.addEventListener('hzo:cartupdated', function () { updateAll(false); });
178	document.addEventListener('hzo:cartreconcile', function () { updateAll(false); }); // reserved; harmless until it ships
179
180	// WC core's jQuery fragment path bypasses applyFragments (plain loads /
181	// WC-native cart actions) — mirror commerce.js and recompute on its events.
182	if (window.jQuery) {
183		window.jQuery(function ($) {
184			$(document.body).on('wc_fragments_loaded wc_fragments_refreshed', function () { updateAll(false); });
185		});
186	}
187
188	if (document.readyState === 'loading') {
189		document.addEventListener('DOMContentLoaded', init);
190	} else {
191		init();
192	}
193
194	// Expose for dynamically-injected instances (AJAX-loaded surfaces).
195	window.hzo = window.hzo || {};
196	window.hzo.freeShip = { init: init, update: update, updateAll: updateAll };
197})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.