PageSourceSearch

https://www.moforte.com/wp-content/plugins/moforte-store/assets/js/fastspring-pricing.js?ver=2.2.28.1782456271

js moforte.com collected 2026-10-02 15:02:20 UTC 34,736 bytes, 1,071 lines download raw bytes

1(function() {
2  'use strict';
3
4  function getOrigin() {
5    return window.location.protocol + '//' + window.location.host;
6  }
7
8  function getStorefront() {
9    if (window.mfsPricing && window.mfsPricing.storefront) {
10      return window.mfsPricing.storefront;
11    }
12    return document.body ? document.body.getAttribute('data-storefront') : null;
13  }
14
15  // Storefront discount percentages come from /wp-json/mfs/v1/pricing-config
16  // (the WP admin "Storefront Pricing" settings) — never from values baked
17  // into page HTML. Until the fetch completes, both are 0 and buttons show
18  // FastSpring list prices.
19  var discountConfig = { naadaDiscountPercent: 0, geoshredDiscountPercent: 0 };
20
21  function getNaadaDiscountPercent() {
22    var parsed = parseFloat(discountConfig.naadaDiscountPercent);
23    return isNaN(parsed) ? 0 : parsed;
24  }
25
26  function getGeoshredDiscountPercent() {
27    var parsed = parseFloat(discountConfig.geoshredDiscountPercent);
28    return isNaN(parsed) ? 0 : parsed;
29  }
30
31  function requestPricingConfig(onDone) {
32    var url = getOrigin() + '/wp-json/mfs/v1/pricing-config';
33
34    function applyConfig(config) {
35      if (config && typeof config === 'object') {
36        discountConfig = config;
37      }
38      onDone();
39    }
40
41    function failConfig(error) {
42      console.error('MFS Pricing: Failed to load pricing config:', error);
43      onDone();
44    }
45
46    if (typeof fetch === 'function') {
47      fetch(url)
48        .then(function(response) {
49          if (!response.ok) throw new Error('API error: ' + response.status);
50          return response.json();
51        })
52        .then(applyConfig)
53        .catch(failConfig);
54      return;
55    }
56
57    try {
58      var xhr = new XMLHttpRequest();
59      xhr.open('GET', url, true);
60      xhr.onreadystatechange = function() {
61        if (xhr.readyState !== 4) return;
62
63        if (xhr.status >= 200 && xhr.status < 300) {
64          try {
65            applyConfig(JSON.parse(xhr.responseText));
66          } catch (e) {
67            failConfig(e);
68          }
69        } else {
70          failConfig(new Error('API error: ' + xhr.status));
71        }
72      };
73      xhr.send();
74    } catch (e) {
75      failConfig(e);
76    }
77  }
78
79  function closestElement(start, selector) {
80    var element = start;
81    while (element && element.nodeType === 1) {
82      if (element.matches && element.matches(selector)) {
83        return element;
84      }
85      element = element.parentElement;
86    }
87    return null;
88  }
89
90  function addUniqueElement(list, element) {
91    if (!element) {
92      return;
93    }
94    for (var i = 0; i < list.length; i++) {
95      if (list[i] === element) {
96        return;
97      }
98    }
99    list.push(element);
100  }
101
102  function clearNode(node) {
103    while (node.firstChild) {
104      node.removeChild(node.firstChild);
105    }
106  }
107
108  function getPortalSession() {
109    try {
110      var session = localStorage.getItem('mfs_portal_session');
111      var email = localStorage.getItem('mfs_portal_email');
112      if (!session || !email) {
113        return null;
114      }
115      return { session: session, email: email };
116    } catch (e) {
117      return null;
118    }
119  }
120
121  function getPortalRedirect() {
122    var path = window.location.pathname || '/';
123    var search = window.location.search || '';
124    var hash = window.location.hash || '';
125    return path + search + hash;
126  }
127
128  function hideLoginGate() {
129    var overlay = document.getElementById('mfs-login-gate');
130    if (overlay) {
131      overlay.style.display = 'none';
132    }
133  }
134
135  function ensureLoginGate() {
136    var overlay = document.getElementById('mfs-login-gate');
137    if (!overlay) {
138      overlay = document.createElement('div');
139      overlay.id = 'mfs-login-gate';
140      overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999999;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);padding:20px;';
141      overlay.innerHTML = ''
142        + '<div style="position:relative;width:100%;max-width:520px;background:#f5f5f5 !important;border-radius:8px;padding:32px 24px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,0.25);">'
143        + '  <button type="button" data-mfs-close="1" aria-label="Close" style="position:absolute;top:6px;right:10px;background:transparent;border:none;font-size:28px;color:#888;cursor:pointer;line-height:1;">&times;</button>'
144        + '  <h3 style="margin:0 0 12px;color:#333 !important;">Sign in to continue</h3>'
145        + '  <p style="margin:0 0 20px;color:#666 !important;">Create an account or sign in to purchase.</p>'
146        + '  <a data-mfs-portal-link="1" class="wp-block-button__link" style="display:inline-block;padding:12px 24px;background:#ff5722 !important;color:white !important;text-decoration:none;border-radius:4px;">Create or Login to Your Account</a>'
147        + '</div>';
148      overlay.addEventListener('click', function(e) {
149        if (e.target === overlay) {
150          hideLoginGate();
151        }
152      });
153      document.body.appendChild(overlay);
154
155      var closeBtn = overlay.querySelector('[data-mfs-close]');
156      if (closeBtn) {
157        closeBtn.addEventListener('click', function(e) {
158          e.preventDefault();
159          hideLoginGate();
160        });
161      }
162    }
163
164    var link = overlay.querySelector('[data-mfs-portal-link]');
165    if (link) {
166      link.href = '/customer-portal?redirect=' + encodeURIComponent(getPortalRedirect());
167    }
168
169    overlay.style.display = 'flex';
170  }
171
172  function getCurrencyFractionDigits(currency) {
173    try {
174      return new Intl.NumberFormat(navigator.language, {
175        style: 'currency',
176        currency: currency
177      }).resolvedOptions().maximumFractionDigits;
178    } catch (e) {
179      return 2;
180    }
181  }
182
183  function roundCurrency(value, currency) {
184    var digits = getCurrencyFractionDigits(currency);
185    var factor = Math.pow(10, digits);
186    return Math.round(value * factor) / factor;
187  }
188
189  function formatCurrency(value, currency) {
190    try {
191      return new Intl.NumberFormat(navigator.language, {
192        style: 'currency',
193        currency: currency
194      }).format(value);
195    } catch (e) {
196      return currency + ' ' + value;
197    }
198  }
199
200  function parseFormattedPrice(formatted) {
201    if (!formatted) {
202      return null;
203    }
204
205    var raw = String(formatted).replace(/\u00a0/g, ' ').trim();
206    var firstDigit = raw.search(/[0-9]/);
207    if (firstDigit === -1) {
208      return null;
209    }
210
211    var lastDigit = -1;
212    for (var i = raw.length - 1; i >= 0; i--) {
213      if (raw[i] >= '0' && raw[i] <= '9') {
214        lastDigit = i;
215        break;
216      }
217    }
218    if (lastDigit === -1) {
219      return null;
220    }
221
222    var prefix = raw.slice(0, firstDigit);
223    var suffix = raw.slice(lastDigit + 1);
224    var numberPart = raw.slice(firstDigit, lastDigit + 1);
225
226    // Determine decimal separator as the last '.' or ',' that has digits after it.
227    var lastDot = numberPart.lastIndexOf('.');
228    var lastComma = numberPart.lastIndexOf(',');
229    var decimalSep = null;
230    var decimalIndex = -1;
231
232    if (lastDot !== -1 && lastComma !== -1) {
233      decimalIndex = lastDot >
233 lastComma ? lastDot : lastComma;
234      decimalSep = numberPart[decimalIndex];
235    } else if (lastDot !== -1) {
236      decimalIndex = lastDot;
237      decimalSep = '.';
238    } else if (lastComma !== -1) {
239      decimalIndex = lastComma;
240      decimalSep = ',';
241    }
242
243    var decimals = 0;
244    if (decimalIndex !== -1) {
245      decimals = numberPart.length - decimalIndex - 1;
246    }
247
248    // Determine thousands separator by looking for the "other" separator if both are present.
249    var thousandsSep = null;
250    if (decimalSep) {
251      thousandsSep = decimalSep === '.' ? ',' : '.';
252      if (numberPart.indexOf(thousandsSep) === -1) {
253        thousandsSep = null;
254      }
255    } else {
256      if (numberPart.indexOf(',') !== -1) thousandsSep = ',';
257      else if (numberPart.indexOf('.') !== -1) thousandsSep = '.';
258      else if (numberPart.indexOf(' ') !== -1) thousandsSep = ' ';
259    }
260
261    var normalized = numberPart;
262    if (thousandsSep) {
263      var reThousands = new RegExp('\\' + thousandsSep, 'g');
264      normalized = normalized.replace(reThousands, '');
265    }
266    normalized = normalized.replace(/ /g, '');
267    if (decimalSep && decimalSep !== '.') {
268      normalized = normalized.replace(decimalSep, '.');
269    }
270    normalized = normalized.replace(/[^\d.-]/g, '');
271
272    var value = parseFloat(normalized);
273    if (isNaN(value)) {
274      return null;
275    }
276
277    return {
278      prefix: prefix,
279      suffix: suffix,
280      value: value,
281      decimals: decimals,
282      decimalSep: decimalSep || '.',
283      thousandsSep: thousandsSep
284    };
285  }
286
287  function formatLikeOriginal(originalInfo, value) {
288    var decimals = originalInfo.decimals;
289    var decimalSep = originalInfo.decimalSep || '.';
290    var thousandsSep = originalInfo.thousandsSep;
291
292    var rounded = decimals > 0 ? value.toFixed(decimals) : String(Math.round(value));
293    var parts = rounded.split('.');
294    var integerPart = parts[0];
295    var fractionalPart = parts.length > 1 ? parts[1] : '';
296
297    if (thousandsSep && thousandsSep !== ' ') {
298      var sign = '';
299      if (integerPart[0] === '-') {
300        sign = '-';
301        integerPart = integerPart.slice(1);
302      }
303      integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSep);
304      integerPart = sign + integerPart;
305    }
306
307    var formattedNumber = integerPart;
308    if (decimals > 0) {
309      formattedNumber += decimalSep + fractionalPart;
310    }
311
312    return originalInfo.prefix + formattedNumber + originalInfo.suffix;
313  }
314
315  function roundToDecimals(value, decimals) {
316    var d = typeof decimals === 'number' ? decimals : 2;
317    var factor = Math.pow(10, d);
318    return Math.round(value * factor) / factor;
319  }
320
321  function requestBatchPrices(productPaths, onSuccess, onError) {
322    var url = getOrigin() + '/wp-json/mfs/v1/batch-pricing';
323
324    if (typeof fetch === 'function') {
325      fetch(url, {
326        method: 'POST',
327        headers: {
328          'Content-Type': 'application/json'
329        },
330        body: JSON.stringify(productPaths)
331      })
332        .then(function(response) {
333          if (!response.ok) throw new Error('API error: ' + response.status);
334          return response.json();
335        })
336        .then(onSuccess)
337        .catch(onError);
338      return;
339    }
340
341    try {
342      var xhr = new XMLHttpRequest();
343      xhr.open('POST', url, true);
344      xhr.setRequestHeader('Content-Type', 'application/json');
345      xhr.onreadystatechange = function() {
346        if (xhr.readyState !== 4) return;
347
348        if (xhr.status >= 200 && xhr.status < 300) {
349          try {
350            var parsed = JSON.parse(xhr.responseText);
351            onSuccess(parsed);
352          } catch (e) {
353            onError(e);
354          }
355        } else {
356          onError(new Error('API error: ' + xhr.status));
357        }
358      };
359      xhr.send(JSON.stringify(productPaths));
360    } catch (e) {
361      onError(e);
362    }
363  }
364
365  function setButtonPriceHtml(button, currentFormatted, originalFormatted, productPath) {
366    // Prefer preserving FastSpring's price span so FastSpring/theme scripts don't overwrite our button text later.
367    var originalSpan = button.querySelector('[data-fsc-item-price]');
368    if (!originalSpan) {
369      originalSpan = document.createElement('span');
370      if (productPath) {
371        originalSpan.setAttribute('data-fsc-item-path', productPath);
372      }
373      originalSpan.setAttribute('data-fsc-item-price', '');
374    }
375
376    originalSpan.classList.remove('mfs-price-original');
377    originalSpan.textContent = currentFormatted;
378
379    if (productPath) {
380      button.setAttribute('data-mfs-product-path', productPath);
381    }
382
383    isMutatingDom = true;
384    clearNode(button);
385    button.appendChild(document.createTextNode('Buy Now '));
386    button.appendChild(originalSpan);
387
388    if (originalFormatted) {
389      var strikeSpan = document.createElement('span');
390      strikeSpan.className = 'mfs-price-original';
391      strikeSpan.textContent = originalFormatted;
392
393      // Keep ONLY the bracketed original "($Y)" together; let the rest of the
394      // label wrap freely so a narrow button wraps to fit instead of clipping.
395      button.appendChild(document.createTextNode(' '));
396      var wasWrap = document.createElement('span');
397      wasWrap.style.whiteSpace = 'nowrap';
398      wasWrap.appendChild(document.createTextNode('('));
399      wasWrap.appendChild(strikeSpan);
400      wasWrap.appendChild(document.createTextNode(')'));
401      button.appendChild(wasWrap);
402    }
403    isMutatingDom = false;
404  }
405
406  function setButtonSaleHtml(button, discountedFormatted, originalFormatted, productPath) {
407    var originalSpan = button.querySelector('[data-fsc-item-price]');
408    if (!originalSpan) {
409      originalSpan = document.createElement('span');
410      if (productPath) {
411        originalSpan.setAttribute('data-fsc-item-path', productPath);
412      }
413      originalSpan.setAttribute('data-fsc-item-price', '');
414    }
415
416    originalSpan.classList.add('mfs-price-original');
417    // Apply strikethrough inline too: the static storefront pages load this JS
418    // but NOT product-pages.css, so the .mfs-price-original CSS rule never
419    // reaches them. Inline style guarantees the struck-through original price.
420    originalSpan.style.textDecoration = 'line-through';
421    if (originalFormatted) {
422      originalSpan.textContent = originalFormatted;
423    }
424
425    // Preserve the hidden unitPrice carrier span across the rebuild so FastSpring
426    // (SBL) keeps the discounted value live, e.g. on currency/country change.
427    var unitSpan = button.querySelector('[data-fsc-item-unitprice]');
428
429    if (productPath) {
430      button.setAttribute('data-mfs-product-path', productPath);
431    }
432
433    isMutatingDom = true;
434    clearNode(button);
435    if (unitSpan) {
436      unitSpan.style.display = 'none';
437      button.appendChild(unitSpan);
438    }
439    button.appendChild(document.createTextNode('Discounted to '));
440
441    var currentSpan = document.createElement('span');
442    currentSpan.className = 'mfs-price-current';
443    currentSpan.appendChild(document.createTextNode(discountedFormatted));
444    button.appendChild(currentSpan);
445
446    // Keep ONLY the bracketed original "($Y)" together; let "Discounted to $X"
447    // wrap freely so a narrow button wraps to fit instead of overflowing/clipping.
448    button.appendChild(document.createTextNode(' '));
449
450    var saleWasWrap = document.createElement('span');
451    saleWasWrap.style.whiteSpace = 'nowrap';
452    saleWasWrap.appendChild(document.createTextNode('('));
453    saleWasWrap.appendChild(originalSpan);
454    saleWasWrap.appendChild(document.createTextNode(')'));
455    button.appendChild(saleWasWrap);
456    isMutatingDom = false;
457  }
458
459  var salePriceByProductPath = {};
460  var cachedPriceByProductPath = {};
461  var isApplyingPrices = false;
462  var reapplyTimer = null;
463  var scanTimer = null;
464  var isFetchingPrices = false;
465  var isMutatingDom = false;
466
467  function getButtonProductPath(button) {
468    var productPath =
469      button.getAttribute('data-mfs-product-path') ||
470      button.getAttribute('data-product-path') ||
471      button.getAttribute('data-fsc-item-path-value');
472    if (productPath) {
473      return productPath;
474    }
475
476    var span = button.querySelector('[data-fsc-item-path]');
477    if (span) {
478      return span.getAttribute('data-fsc-item-path');
479    }
480
481    var onclick = button.getAttribute('onclick') || '';
482    if (onclick) {
483      var match = onclick.match(/checkout(?:Naada|Geoshred)\(\s*['"]([^'"]+)['"]/);
484      if (match && match[1]) {
485        return match[1];
486      }
487    }
488
489    return null;
490  }
491
492  function collectBuyButtons() {
493    var result = [];
494
495    var explicitButtons = document.querySelectorAll('[data-product-path], [data-fsc-item-path-value]');
496    for (var i = 0; i < explicitButtons.length; i++) {
497      var el = explicitButtons[i];
498      if (el && (el.tagName === 'A' || el.tagName === 'BUTTON')) {
499        addUniqueElement(result, el);
500      }
501    }
502
503    var priceSpans = document.querySelectorAll('span[data-fsc-item-price]');
504    for (var j = 0; j < priceSpans.length; j++) {
505      var maybeButton = closestElement(priceSpans[j], 'a,button');
506      if (maybeButton) {
507        addUniqueElement(result, maybeButton);
508      }
509    }
510
511    var pathSpans = document.querySelectorAll('span[data-fsc-item-path]');
512    for (var k = 0; k < pathSpans.length; k++) {
513      var maybeButton2 = closestElement(pathSpans[k], 'a,button');
514      if (maybeButton2) {
515        addUniqueElement(result, maybeButton2);
516      }
517    }
518
519    var fsBuyLinks = document.querySelectorAll('a.fs-buy');
520    for (var m = 0; m < fsBuyLinks.length; m++) {
521      addUniqueElement(result, fsBuyLinks[m]);
522    }
523
524    return result;
525  }
526
527  function applySalePricesToButtons() {
528    if (isApplyingPrices) {
529      return;
530    }
531
532    isApplyingPrices = true;
533    isMutatingDom = true;
534    try {
535      var buttons = collectBuyButtons();
536      for (var i = 0; i < buttons.length; i++) {
537        var button = buttons[i];
538        var productPath = getButtonProductPath(button);
539        if (!productPath || !salePriceByProductPath[productPath]) {
540          continue;
541        }
542
543        var info = salePriceByProductPath[productPath];
544        var currentNode = button.querySelector('.mfs-price-current');
545        var originalNode = button.querySelector('.mfs-price-original');
546
547        if (
548          currentNode &&
549          originalNode &&
550          currentNode.textContent === info.discountedFormatted &&
551          originalNode.textContent === info.originalFormatted
552        ) {
553          continue;
554        }
555
556        setButtonSaleHtml(button, info.discountedFormatted, info.originalFormatted, productPath);
557        button.setAttribute('data-mfs-sale-applied', '1');
558      }
559    } finally {
560      isApplyingPrices = false;
561      isMutatingDom = false;
562    }
563  }
564
565  function scheduleReapplySalePrices(delayMs) {
566    if (reapplyTimer) {
567      clearTimeout(reapplyTimer);
568    }
569    reapplyTimer = setTimeout(function() {
570      reapplyTimer = null;
571      applySalePricesToButtons();
572    }, delayMs || 50);
573  }
574
575  function scheduleScanForButtons(delayMs) {
576    if (scanTimer) {
577      clearTimeout(scanTimer);
578    }
579    scanTimer = setTimeout(function() {
580      scanTimer = null;
581      updateButtonPrices();
582    }, delayMs || 100);
583  }
584
585  function getActiveDiscountPercent() {
586    var storefront = getStorefront();
587    var naadaDiscountPercent = getNaadaDiscountPercent();
588    var geoshredDiscountPercent = getGeoshredDiscountPercent();
589
590    if (storefront === 'naada' && naadaDiscountPercent > 0) {
591      return naadaDiscountPercent;
592    }
593    if (storefront === 'geoshred' && geoshredDiscountPercent > 0) {
594      return geoshredDiscountPercent;
595    }
596    return 0;
597  }
598
599  function applyDiscountFromFscPriceSpan(priceSpan) {
600    if (!priceSpan) {
601      return;
602    }
603
604    var originalFormatted = String(priceSpan.textContent || '').trim();
605    if (!originalFormatted) {
606      return;
607    }
608
609    var button = closestElement(priceSpan, 'a,button');
610    if (!button) {
611      return;
612    }
613
614    var productPath = priceSpan.getAttribute('data-fsc-item-path') || getButtonProductPath(button);
615    if (!productPath) {
616      return;
617    }
618
619    // The discounted price comes straight from FastSpring: SBL populates the
620    // sibling data-fsc-item-unitPrice span (localized, already reflecting any
621    // active product discount). No client-side percentage math.
622    var unitSpan = button.querySelector('[data-fsc-item-unitprice]');
623    var discountedFormatted = unitSpan ? String(unitSpan.textContent || '').trim() : '';
624
625    // Wait until SBL has populated the discounted value.
626    if (!discountedFormatted) {
627      return;
628    }
629
630    // No active discount (unit price equals list price): leave the button's
631    // natural "Buy <price>" render untouched.
632    if (discountedFormatted === originalFormatted) {
633      return;
634    }
635
636    var alreadyCurrent = button.querySelector('.mfs-price-current');
637    var alreadyOriginal = button.querySelector('.mfs-price-original');
638    if (
639      alreadyCurrent &&
640      alreadyOriginal &&
641      alreadyCurrent.textContent === discountedFormatted &&
642      alreadyOriginal.textContent === originalFormatted
643    ) {
644      return;
645    }
646
647    salePriceByProductPath[productPath] = {
648      discountedFormatted: discountedFormatted,
649      originalFormatted: originalFormatted
650    };
651
652    setButtonSaleHtml(button, discountedFormatted, originalFormatted, productPath);
653  }
654
655  function setupDiscountFromFscPriceSpans() {
656    var discountScanTimer = null;
657
658    function scanAndApply() {
659      if (isMutatingDom) {
660        return;
661      }
662      var spans = document.querySelectorAll('span[data-fsc-item-price]');
663      for (var i = 0; i < spans.length; i++) {
664        applyDiscountFromFscPriceSpan(spans[i]);
665      }
666    }
667
668    function scheduleScan(delayMs) {
669      if (discountScanTimer) {
670        clearTimeout(discountScanTimer);
671      }
672      discountScanTimer = setTimeout(function() {
673        discountScanTimer = null;
674        scanAndApply();
675      }, delayMs || 50);
676    }
677
678    // Initial scan, then a couple early retries for slow FastSpring injection.
679    scanAndApply();
680    scheduleScan(100);
681    scheduleScan(400);
682    scheduleScan(1200);
683
684    if (typeof MutationObserver === 'undefined') {
685      return;
686    }
687
688    try {
689      var observer = new MutationObserver(function() {
690        if (isMutatingDom) {
691          return;
692        }
693        scheduleScan(100);
694      });
695
696      observer.observe(document.body, {
697        childList: true,
698        subtree: true,
699        characterData: false
700      });
701    } catch (e) {
702      console.error('MFS Pricing: Failed to start discount observer:', e);
703    }
704  }
705
706  function setupSaleReapplyObserver() {
707    if (typeof MutationObserver === 'undefined') {
708      return;
709    }
710
711    try {
712      var observer = new MutationObserver(function() {
713        if (isApplyingPrices || isMutatingDom) {
714          return;
715        }
716        scheduleReapplySalePrices(50);
717      });
718
719      observer.observe(document.body, {
720        childList: true,
721        subtree: true,
722        characterData: false
723      });
724    } catch (e) {
725      console.error('MFS Pricing: Failed to start MutationObserver:', e);
726    }
727  }
728
729  function updateButtonPrices() {
730    try {
731      if (isFetchingPrices) {
732        return;
733      }
734
735      var buttons = collectBuyButtons();
736      console.log('MFS Pricing: Found ' + buttons.length + ' buttons');
737
738      if (buttons.length === 0) return;
739
740      var storefront = getStorefront();
741      var naadaDiscountPercent = getNaadaDiscountPercent();
742      var geoshredDiscountPercent = getGeoshredDiscountPercent();
743
744      var activeDiscountPercent = 0;
745      if (storefront === 'naada' && naadaDiscountPercent > 0) {
746        activeDiscountPercent = naadaDiscountPercent;
747      } else if (storefront === 'geoshred' && geoshredDiscountPercent > 0) {
748        activeDiscountPercent = geoshredDiscountPercent;
749      }
750
751      var useStorefrontDiscount = activeDiscountPercent > 0;
752
753      // Collect all unique product paths
754      var productPaths = [];
755      var buttonsByProduct = {};
756
757      for (var i = 0; i < buttons.length; i++) {
758        var button = buttons[i];
759        var productPath = getButtonProductPath(button);
760        if (!productPath) {
761          continue;
762        }
763        if (productPath && productPaths.indexOf(productPath) === -1) {
764          productPaths.push(productPath);
765        }
766        if (!buttonsByProduct[productPath]) {
767          buttonsByProduct[productPath] = [];
768        }
769        buttonsByProduct[productPath].push(button);
770      }
771
772      var productPathsToFetch = [];
773      for (var p = 0; p < productPaths.length; p++) {
774        var pp = productPaths[p];
775        if (!cachedPriceByProductPath[pp]) {
776          productPathsToFetch.push(pp);
777        }
778      }
779
780      function renderButtons() {
781        for (var productPath in buttonsByProduct) {
782          var data = cachedPriceByProductPath[productPath];
783
784          if (!buttonsByProduct[productPath]) {
785            continue;
786          }
787
788          if (!data || !data.currency || data.price === undefined || data.price === null) {
789            buttonsByProduct[productPath].forEach(function(button) {
790              setButtonPriceHtml(button, '???', null, productPath);
791            });
792            continue;
793          }
794
795          var currency = data.currency;
796          var basePrice = parseFloat(data.price);
797          if (isNaN(basePrice)) {
798            buttonsByProduct[productPath].forEach(function(button) {
799              setButtonPriceHtml(button, '???', null, productPath);
800            });
801            continue;
802          }
803
804          var originalFormatted = data.formatted || formatCurrency(basePrice, currency);
805
806          if (useStorefrontDiscount) {
807            var discountMultiplier = 1 - (activeDiscountPercent / 100);
808            var discountedPrice = roundCurrency(basePrice * discountMultiplier, currency);
809            var discountedFormatted = formatCurrency(discountedPrice, currency);
810
811            salePriceByProductPath[productPath] = {
812              discountedFormatted: discountedFormatted,
813              originalFormatted: originalFormatted
814            };
815
816            buttonsByProduct[productPath].forEach(function(button) {
817              setButtonSaleHtml(button, discountedFormatted, originalFormatted, productPath);
818            });
819          } else {
820            var currentFormatted = data.formatted || formatCurrency(basePrice, currency);
821            buttonsByProduct[productPath].forEach(function(button) {
822              setButtonPriceHtml(button, currentFormatted, null, productPath);
823            });
824          }
825        }
826
827        if (useStorefrontDiscount) {
828          applySalePricesToButtons();
829          scheduleReapplySalePrices(250);
830          scheduleReapplySalePrices(1000);
831          scheduleReapplySalePrices(3000);
832        }
833      }
834
835      // Batch fetch all prices in one API call
836      if (productPathsToFetch.length === 0) {
837        renderButtons();
838        return;
839      }
840
841      isFetchingPrices = true;
842      requestBatchPrices(productPathsToFetch, function(allPrices) {
843        console.log('MFS Pricing: Received batch prices:', allPrices);
844        for (var key in allPrices) {
845          cachedPriceByProductPath[key] = allPrices[key];
846        }
847        isFetchingPrices = false;
848        renderButtons();
849      }, function(error) {
850        isFetchingPrices = false;
851        console.error('MFS Pricing: Failed to load batch prices:', error);
852      });
853    } catch (e) {
854      isFetchingPrices = false;
855      console.error('MFS Pricing: Exception in updateButtonPrices:', e);
856      console.error('MFS Pricing: Exception details:', e.message, e.stack);
857    }
858  }
859
860  function setupPopupCheckout() {
861    console.log('MFS Pricing: Setting up popup checkout');
862
863    document.addEventListener('click', function(e) {
864      var link = closestElement(e.target, 'a[data-fsc-action*="Checkout"]');
865      if (link) {
866        var productPath = link.getAttribute('data-fsc-item-path-value');
867        if (!productPath) {
868          var productSpan = link.querySelector('[data-fsc-item-path]');
869          if (productSpan) {
870            productPath = productSpan.getAttribute('data-fsc-item-path');
871          }
872        }
873
874        if (typeof window.mfsTrackEvent === 'function') {
875          window.mfsTrackEvent('click_buy', {
876            product_path: productPath || '',
877            sku: productPath || ''
878          });
879        }
880
881        console.log('MFS Pricing: Pre-tagging checkout for:', productPath);
882
883        window._mfsCheckoutOpen = true;
884
885        if (typeof fastspring !== 'undefined' && fastspring.builder) {
886          if (typeof window.getMoForteTags === 'function') {
887            var tags = window.getMoForteTags(productPath);
888            console.log('MFS Pricing: Setting tags before FastSpring processes click:', tags);
889            fastspring.builder.tag(tags);
890          } else {
891            console.warn('MFS Pricing: getMoForteTags not available');
892          }
893
894          try {
895            var promoParam = new URLSearchParams(window.location.search).get('promo');
896            if (promoParam) {
897              console.log('MFS Pricing: Applying promo code from URL:', promoParam);
898              fastspring.builder.promo(promoParam);
899            }
900          } catch (promoErr) {
901            console.error('MFS Pricing: Failed to apply promo:', promoErr);
902          }
903        } else {
904          console.error('MFS Pricing: FastSpring builder not available');
905        }
906      }
907    }, true);
908  }
909
910  function init() {
911    console.log('MFS Pricing: Initializing...');
912    setupPopupCheckout();
913
914    // Discounts are driven entirely by FastSpring product discounts: SBL fills
915    // each button's data-fsc-item-price (original) and data-fsc-item-unitPrice
916    // (discounted) spans, already localized for the visitor's country. We render
917    // "Discounted to X (Y)" from those values — no client-side percentage math,
918    // and it auto-reverts to the plain price when a sale's dates lapse.
919    setupDiscountFromFscPriceSpans();
920    setupSaleReapplyObserver();
921    if (document.readyState !== 'complete') {
922      window.addEventListener('load', function() {
923        scheduleReapplySalePrices(50);
924      });
925    } else {
926      scheduleReapplySalePrices(50);
927    }
928
929    // Fallback: pages without FastSpring price spans use the server-side API.
930    if (document.querySelectorAll('span[data-fsc-item-price]').length === 0) {
931      updateButtonPrices();
932      scheduleScanForButtons(250);
933    }
934  }
935
936  if (document.readyState === 'loading') {
937    document.addEventListener('DOMContentLoaded', init);
938  } else {
939    init();
940  }
941
942  // Custom popup closed handler - polls for order completion then reloads
943  window.onFSPopupClosed = function(orderReference) {
944    console.log('MFS: onFSPopupClosed called with:', orderReference);
945    window._mfsCheckoutOpen = false;
946    if (!orderReference || !orderReference.id) {
947      var abandonedProduct = window._mfsLastCheckoutProduct || '';
948      console.log('MFS: Checkout abandoned for product:', abandonedProduct);
949      if (typeof window.mfsTrackEvent === 'function') {
950        window.mfsTrackEvent('checkout_abandon', {
951          product_path: abandonedProduct,
952          sku: abandonedProduct
953        });
954      }
955      return;
956    }
957    if (orderReference && orderReference.id) {
958      var orderRef = orderReference.reference || orderReference.id;
959      console.log('MFS: Order completed:', orderReference.id, 'reference:', orderRef);
960
961      var pollAttempts = 0;
962      var maxAttempts = 10;
963      var pollInterval = 1000;
964
965      function pollForOrder() {
966        pollAttempts++;
967        console.log('MFS: Polling for order (attempt ' + pollAttempts + '/' + maxAttempts + ')');
968
969        var url = getOrigin() + '/wp-json/mfs/v1/order-details/' + encodeURIComponent(orderRef);
970
971        var xhr = new XMLHttpRequest();
972        xhr.open('GET', url, true);
973        xhr.onreadystatechange = function() {
974          if (xhr.readyState !== 4) return;
975
976	          if (xhr.status >= 200 && xhr.status < 300) {
977	            try {
978	              var data = JSON.parse(xhr.responseText);
979	              console.log('MFS: Order details received:', data);
980	              if (data.success && data.sku) {
981	                if (typeof window.mfsTrackEvent === 'function') {
982	                  // GA4 ecommerce-compliant purchase: transaction_id (GA4's
983	                  // dedup key) + an items[] array so the order populates GA4's
984	                  // Monetization/revenue reports, not just a custom event.
985	                  // product_path/sku/order_id stay for the existing tagging.
986	                  var purchaseItem = { item_id: data.sku, item_name: data.sku, quantity: 1 };
987	                  var purchasePayload = {
988	                    transaction_id: orderRef || orderReference.id,
989	                    product_path: data.sku,
990	                    sku: data.sku,
991	                    order_id: orderReference.id,
992	                    items: [purchaseItem]
993	                  };
994	                  if (typeof data.value === 'number' && !isNaN(data.value)) {
995	                    purchasePayload.value = data.value;
996	                    purchaseItem.price = data.value;
997	                  }
998	                  if (data.currency) {
999	                    purchasePayload.currency = data.currency;
1000	                  }
1001	                  window.mfsTrackEvent('purchase', purchasePayload);
1002	                }
1003	                console.log('MFS: Order found! Reloading to show purchase...');
1004	                window.location.reload();
1005	                return;
1006	              }
1007	            } catch (e) {
1008              console.error('MFS: Failed to parse order details:', e);
1009            }
1010          } else {
1011            console.error('MFS: Order details response status:', xhr.status);
1012          }
1013
1014          if (pollAttempts < maxAttempts) {
1015            console.log('MFS: Order not ready yet, retrying in ' + pollInterval + 'ms...');
1016            setTimeout(pollForOrder, pollInterval);
1017          } else {
1018            console.log('MFS: Polling timeout reached, reloading anyway...');
1019            window.location.reload();
1020          }
1021        };
1022        xhr.send();
1023      }
1024
1025      pollForOrder();
1026    }
1027  };
1028
1029  function updateButtonToPurchased(sku, invoiceUrl) {
1030    console.log('MFS: Updating button for SKU:', sku, 'with invoice:', invoiceUrl);
1031
1032    var buttons = document.querySelectorAll('a.fs-buy[data-fsc-item-path-value="' + sku + '"]');
1033    console.log('MFS: Found', buttons.length, 'buttons to update');
1034
1035    for (var i = 0; i < buttons.length; i++) {
1036      var button = buttons[i];
1037      button.classList.remove('fs-buy');
1038      button.removeAttribute('data-fsc-action');
1039      button.removeAttribute('data-fsc-item-path-value');
1040
1041      button.classList.add('mfs-purchased-button');
1042
1043      while (button.firstChild) {
1044        button.removeChild(button.firstChild);
1045      }
1046      button.appendChild(document.createTextNode('Purchased'));
1047
1048      if (invoiceUrl) {
1049        button.setAttribute('href', invoiceUrl);
1050        button.setAttribute('target', '_blank');
1051        button.setAttribute('title', 'Click to view your invoice');
1052
1053        button.addEventListener('click', function(e) {
1054          e.preventDefault();
1055          e.stopPropagation();
1056          e.stopImmediatePropagation();
1057          window.open(invoiceUrl, '_blank');
1058        });
1059      } else {
1060        button.setAttribute('href', '#');
1061        button.addEventListener('click', function(e) {
1062          e.preventDefault();
1063          e.stopPropagation();
1064          alert('You own this product! Invoice URL not available.');
1065        });
1066      }
1067
1068      console.log('MFS: Button updated for', sku);
1069    }
1070  }
1071})();

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.