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.