PageSourceSearch

https://www.galls.com/assets/cart/cart-promo-bar/cart-promo-bar.js?v=0.0.4

js galls.com collected 2026-09-24 19:40:03 UTC 22,564 bytes, 550 lines download raw bytes

1/** Cart Promo Bar - Free Shipping progress bar. @see cart-promo-bar.css */
2'use strict';
3
4class BuyMoreSaveMoreHandler {
5  constructor() {
6    this.CONFIG = {
7      containerSelector: '[data-target="buyMoreSaveMoreContainer"]',
8      enabledSites: ['USP'],
9      // Free shipping threshold - single milestone
10      expectedTiers: [
11        { threshold: 150 }
12      ],
13      exclusionsCacheKey: 'bmsm_exclusions_cache'
14    };
15    this.backendTiers = []; // Tiers returned from backend (only unreached tiers)
16    this.subtotal = 0;
17    this.exclusionText = ''; // Exclusion text from tooltip titles
18  }
19
20  /** Detect current site @returns {string} 'USP' or 'GALLS' */
21  getCurrentSite() {
22    const hostname = window.location.hostname;
23    return hostname.includes('uspatriottactical') || hostname.includes('triottactic') ? 'USP' : 'GALLS';
24  }
25
26  /** Check if feature is enabled for current site @returns {boolean} */
27  isEnabledForSite() {
28    return this.CONFIG.enabledSites.includes(this.getCurrentSite());
29  }
30
31  /** Parse error_msg HTML and render the shipping bar @param {string} errorMsgHtml @param {string|number} subtotal */
32  renderFromHTML(errorMsgHtml, subtotal) {
33    if (!this.isEnabledForSite()) {
34      // Remove shipping bar skeleton if it was injected by cart-drawer.js
35      var skelContainer = document.querySelector('[data-target="cartDrawerShippingBar"]');
36      if (skelContainer) skelContainer.remove();
37      return;
38    }
39
40    this.subtotal = this.parseSubtotal(subtotal);
41    
42    if (!errorMsgHtml) {
43      this.backendTiers = [];
44      // Use cached exclusions when BE doesn't send data
45      this.exclusionText = this.getCachedExclusions();
46      this.render();
47      return;
48    }
49
50    const tempDiv = document.createElement('div');
51    tempDiv.innerHTML = errorMsgHtml;
52    const text = (tempDiv.textContent || '').replace(/\s+/g, ' ').trim();
53
54    // Extract exclusion text from first tooltip title attribute
55    const tooltipEl = tempDiv.querySelector('.tipClass[title], [title*="Excluding"]');
56    const rawExclusion = tooltipEl?.getAttribute('title') || '';
57    
58    // Cache exclusions when available, use cached as fallback
59    if (rawExclusion) {
60      this.exclusionText = rawExclusion;
61      this.cacheExclusions(rawExclusion);
62    } else {
63      this.exclusionText = this.getCachedExclusions();
64    }
65
66    // Extract amount to spend for FREE shipping from backend
67    // Backend format: "Spend an additional $125.01 to get FREE shipping"
68    const shippingRegex = /Spend an additional\s*\$([0-9,.]+)\s*to get\s*FREE\s*shipping/i;
69    const backendTiers = [];
70    const match = shippingRegex.exec(text);
71
72    if (match) {
73      backendTiers.push({
74        amount: parseFloat(match[1].replace(/,/g, ''))
75      });
76    }
77
78    this.backendTiers = backendTiers;
79    this.render();
80  }
81
82  /** Render the shipping bar - new milestone badge design */
83  render() {
84    const container = document.querySelector(this.CONFIG.containerSelector);
85    // Variation cart drawer: no [data-target="buyMoreSaveMoreContainer"] in the
86    // variation title, so render the Figma-style bar into #samCD_Content instead.
87    if (!container && typeof isCrossSellVariation === 'function' && isCrossSellVariation()) {
88      this._renderVariationBar();
89      return;
90    }
91
92    if (!container) return;
93
94    // AB test active: use truck-style bar on cart page too
95    if (typeof isCrossSellVariation === 'function' && isCrossSellVariation()) {
96      this._renderTruckBarInto(container);
97      return;
98    }
99
100    const expectedTiers = this.CONFIG.expectedTiers;
101    const maxThreshold = expectedTiers[expectedTiers.length - 1].threshold;
102
103    // Free shipping reached when BE returns nothing AND subtotal >= threshold
104    const allTiersReached = !this.backendTiers?.length && this.subtotal >= maxThreshold;
105    
106    if (!this.backendTiers?.length && !allTiersReached) {
107      container.innerHTML = '';
108      return;
109    }
110
111    const backendTiers = this.backendTiers;
112
113    // Determine state of milestone: 'reached', 'current', or 'unreached'
114    const tierStates = expectedTiers.map(() => {
115      if (allTiersReached) {
116        return 'reached';
117      } else if (backendTiers.length > 0) {
118        return 'current';
119      } else {
120        return 'unreached';
121      }
122    });
123
124    // Message depends on whether free shipping is reached
125    const firstUnreachedTier = backendTiers[0];
126    const message = allTiersReached
127      ? `Congrats! You unlocked <strong class="bmsm__highlight">FREE SHIPPING!</strong>`
128      : `Add $${firstUnreachedTier.amount.toFixed(2)} to unlock <strong class="bmsm__highlight">FREE SHIPPING</strong>`;
129
130    const progressPercent = this.computeProgressPercent({ expectedTiers, tierStates, firstUnreachedTier, allTiersReached, container });
131
132    // Check if bar already exists - update in place for smooth transitions
133    const existingBar = container.querySelector('#bmsm-promo-bar');
134    if (existingBar) {
135      // Update message
136      const msgEl = existingBar.querySelector('.bmsm__message');
137      if (msgEl) msgEl.innerHTML = message;
138
139      // Update progress bar width
140      const progressEl = existingBar.querySelector('.bmsm__progress');
141      if (progressEl) progressEl.style.width = `${progressPercent}%`;
142
143      // Update milestone badge states
144      const milestones = existingBar.querySelectorAll('.bmsm__milestone');
145      milestones.forEach((milestone, index) => {
146        const state = tierStates[index];
147        const badge = milestone.querySelector('.bmsm__milestone-badge');
148        const threshold = milestone.querySelector('.bmsm__milestone-threshold');
149        
150        if (badge) {
151          badge.className = `bmsm__milestone-badge bmsm__milestone-badge--${state}`;
152        }
153        if (threshold) {
154          threshold.className = `bmsm__milestone-threshold bmsm__milestone-threshold--${state}`;
155        }
156      });
157
158      // Update exclusions section - always show if we have data (fresh or cached)
159      let exclusionsEl = existingBar.querySelector('.bmsm__exclusions');
160      if (this.exclusionText && !exclusionsEl) {
161        // Add exclusions link if it doesn't exist but we have data
162        exclusionsEl = document.createElement('div');
163        exclusionsEl.className = 'bmsm__exclusions';
164        exclusionsEl.innerHTML = '<span href="javascript:void(0);" data-target="bmsmExclusionsLink">Exclusions apply</span>';
165        existingBar.appendChild(exclusionsEl);
166        this.bindExclusionsClick(existingBar);
167      }
168
169      return;
170    }
171
172    // Build milestone badges HTML (initial render only)
173    let milestonesHTML = '';
174    expectedTiers.forEach((tier, index) => {
175      const state = tierStates[index];
176      milestonesHTML += `
177        <div class="bmsm__milestone">
178          <div class="bmsm__milestone-badge bmsm__milestone-badge--${state}">
179            $${tier.threshold}
180          </div>
181          <div class="bmsm__milestone-threshold bmsm__milestone-threshold--${state}">
182            Free Shipping
183          </div>
184        </div>`;
185    });
186
187    // Build exclusions link if we have exclusion text
188    const exclusionsHTML = this.exclusionText 
189      ? `<div class="bmsm__exclusions"><span href="javascript:void(0);" data-target="bmsmExclusionsLink">Exclusions apply</span></div>`
190      : '';
191
192    const html = `
193      <div class="bmsm" id="bmsm-promo-bar">
194        <div class="bmsm__message">${message}</div>
195        <div class="bmsm__bar-container">
196          <div class="bmsm__bar">
197            <div class="bmsm__progress" style="width: ${progressPercent}%"></div>
198          </div>
199          <div class="bmsm__milestones">
200            ${milestonesHTML}
201          </div>
202        </div>
203        ${exclusionsHTML}
204      </div>`;
205
206    container.innerHTML = html;
207    this.bindExclusionsClick(container);
208  }
209
210  /** Bind click handler for exclusions link @param {HTMLElement} container */
211  bindExclusionsClick(container) {
212    const link = container.querySelector('[data-target="bmsmExclusionsLink"]');
213    if (!link) return;
214
215    link.addEventListener('click', (e) => {
216      e.preventDefault();
217      this.openExclusionsDrawer();
218    });
219  }
220
221  /** Compute badge guard as % of bar width to keep progress from overlapping badges */
222  getBadgeGuardPct(container) {
223    try {
224      const bar = container?.querySelector('.bmsm__bar');
225      if (!bar) return 4;
226      const badge = container.querySelector('.bmsm__milestone-badge');
227      const badgeWidth = badge?.offsetWidth || 38.24;
228      const padPx = 6;
229      const pct = ((badgeWidth + padPx) / bar.offsetWidth) * 100;
230      return Math.min(10, Math.max(2, pct));
231    } catch (e) {
232      return 4;
233    }
234  }
235
236  /** Calculate progress percent for the promo bar */
237  computeProgressPercent({ expectedTiers, tierStates, firstUnreachedTier, allTiersReached, container }) {
238    if (allTiersReached) return 100;
239    
240    // Single milestone - progress is linear from 0 to threshold
241    if (!firstUnreachedTier) return 0;
242
243    const maxThreshold = expectedTiers[0].threshold;
244    const amountNeeded = Math.max(0, firstUnreachedTier.amount);
245    const amountSpent = maxThreshold - amountNeeded;
246
247    const linearProgress = Math.min(amountSpent / maxThreshold, 1); // 0..1
248
249    // Logistic ease: slow start, fast middle, slow near badge
250    const k = 4;
251    const logistic = 1 / (1 + Math.exp(-k * (linearProgress - 0.5)));
252    const log0 = 1 / (1 + Math.exp(k * 0.5));
253    const log1 = 1 / (1 + Math.exp(-k * 0.5));
254    const easedProgress = (logistic - log0) / (log1 - log0);
255
256    // Cap fill at 80% so bar never looks full before reaching the milestone
257    const maxFill = 0.80;
258    const progressPercent = easedProgress * 100 * maxFill;
259
260    return Math.max(0, progressPercent);
261  }
262
263  /** Open SlideUpDrawer with exclusion details */
264  openExclusionsDrawer() {
265    if (!this.exclusionText || typeof window.SlideUpDrawer === 'undefined') return;
266
267    // Format exclusion text for display
268    const formattedContent = this.formatExclusionContent(this.exclusionText);
269
270    window.SlideUpDrawer.open({
271      contentHtml: formattedContent,
272      position: 'right',
273      animation: 'gsap',
274      size: { width: '380px' },
275      responsive: [{
276        mq: '(max-width: 991px)',
277        position: 'bottom',
278        size: { height: '70vh' }
279      }]
280    });
281  }
282
283  /** Format exclusion text into readable HTML @param {string} text @returns {string} */
284  formatExclusionContent(text) {
285    if (!text) return '';
286
287    // Clean up the text - replace <br> tags with newlines, normalize whitespace
288    let cleaned = text.replace(/<br\s*\/?>/gi, '\n').replace(/\s+/g, ' ').trim();
289
290    // Split by commas to get individual exclusions
291    const items = cleaned.split(',').map(s => s.trim()).filter(Boolean);
292
293    // Parse first item: "Excluding X" becomes "X" as first list item
294    // Example: "Excluding gift certificates" -> list starts with "Gift certificates"
295    let listItems = [];
296    const firstItem = items[0] || '';
297    const excludingMatch = firstItem.match(/^excluding\s+(.+)$/i);
298    
299    if (excludingMatch) {
300      const extractedItem = excludingMatch[1].charAt(0).toUpperCase() + excludingMatch[1].slice(1);
301      listItems.push(extractedItem);
302      listItems = listItems.concat(items.slice(1));
303    } else {
304      listItems = items;
305    }
306
307    // Build list HTML
308    const listItemsHTML = listItems.map(item => `<li>${item}</li>`).join('');
309
310    return `
311      <div class="bmsm-exclusions-drawer">
312        <p class="bmsm-exclusions-drawer__title">Exclusions</p>
313        <ul class="bmsm-exclusions-drawer__list">${listItemsHTML}</ul>
314      </div>
315    `;
316  }
317
318  /** Cache exclusions to localStorage @param {string} exclusionText */
319  cacheExclusions(exclusionText) {
320    if (!exclusionText) return;
321    try {
322      localStorage.setItem(this.CONFIG.exclusionsCacheKey, exclusionText);
323    } catch (e) {}
324  }
325
326  /** Get cached exclusions from localStorage @returns {string} */
327  getCachedExclusions() {
328    try {
329      return localStorage.getItem(this.CONFIG.exclusionsCacheKey) || '';
330    } catch (e) {
331      return '';
332    }
333  }
334
335  /** Parse subtotal string to number @param {string|number} subtotal @returns {number} */
336  parseSubtotal(subtotal) {
337    if (typeof subtotal === 'number') return subtotal;
338    if (!subtotal) return 0;
339    return parseFloat(String(subtotal).replace(/[^0-9.]/g, '')) || 0;
340  }
341
342  update() { this.render(); }
343
344  /**
345   * Render the Figma-style shipping bar for the variation cart drawer.
346   *
347   * Called from render() when the normal container is absent and the
348   * variation drawer is active. Creates or reuses a container inside
349   * #samCD_Content (above cart items) and renders the truck + thin bar
350   * + label + exclusions design via _renderTruckBarInto().
351   *
352   * Design: Truck SVG → 2px black-on-gray progress bar → "FREE Shipping at $150 • Exclusions"
353   * CSS: .cart-drawer__ship* in cart-drawer.css.
354   */
355  _renderVariationBar() {
356    if (!this.isEnabledForSite()) {
357      // Remove shipping bar skeleton if it was injected by cart-drawer.js
358      var skelContainer = document.querySelector('[data-target="cartDrawerShippingBar"]');
359      if (skelContainer) skelContainer.remove();
360      return;
361    }
362
363    const content = document.getElementById('samCD_Content');
364    if (!content) return;
365
366    // Find or create the wrapper
367    let container = content.querySelector('[data-target="cartDrawerShippingBar"]');
368    if (!container) {
369      container = document.createElement('div');
370      container.setAttribute('data-target', 'cartDrawerShippingBar');
371      const itemsEl = content.querySelector('#samCDIS_row_flex_wrapper');
372      if (itemsEl) {
373        itemsEl.parentNode.insertBefore(container, itemsEl);
374      } else {
375        content.insertBefore(container, content.firstChild);
376      }
377    }
378
379    this._renderTruckBarInto(container);
380  }
381
382  /**
383   * Render the truck-style shipping bar into a given container.
384   * Shared by the variation cart drawer (_renderVariationBar) and
385   * the cart page (render) when the AB test is active.
386   *
387   * Design: Truck SVG → 2px progress bar → label + exclusions.
388   * CSS: .cart-drawer__ship* in cart-drawer.css.
389   * @param {HTMLElement} container
390   */
391  _renderTruckBarInto(container) {
392    const threshold = this.CONFIG.expectedTiers[this.CONFIG.expectedTiers.length - 1].threshold;
393    const subtotal = this.subtotal || 0;
394    const progress = Math.min((subtotal / threshold) * 100, 100);
395    const isUnlocked = subtotal >= threshold;
396
397    // Label text
398    const labelHTML = isUnlocked
399      ? '<span class="cart-drawer__ship-label">FREE Shipping unlocked!</span>'
400      : '<span class="cart-drawer__ship-label">FREE Shipping at $' + threshold + '</span>';
401
402    // Exclusions link (reuses openExclusionsDrawer)
403    const hasExclusions = this.exclusionText || this.getCachedExclusions();
404    const exclusionsHTML = hasExclusions
405      ? '<span class="cart-drawer__ship-dot">&bull;</span>' +
406        '<a href="javascript:void(0);" class="cart-drawer__ship-exclusions" data-target="cartDrawerShipExclusions">Exclusions</a>'
407      : '';
408
409    // Truck icon (positioned at fill %) + thin progress bar + info row
410    container.innerHTML =
411      '<div class="cart-drawer__ship">' +
412      '  <div class="cart-drawer__ship-track">' +
413      '    <div class="cart-drawer__ship-icon-wrap" style="left: ' + progress + '%">' +
414      '      <svg class="cart-drawer__ship-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">' +
415      '        <path d="M11.6667 15V5C11.6667 4.55798 11.4911 4.13405 11.1785 3.82149C10.866 3.50893 10.442 3.33334 10 3.33334H3.33335C2.89133 3.33334 2.4674 3.50893 2.15484 3.82149C1.84228 4.13405 1.66669 4.55798 1.66669 5V14.1667C1.66669 14.3877 1.75448 14.5996 1.91076 14.7559C2.06704 14.9122 2.27901 15 2.50002 15H4.16669" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>' +
416      '        <path d="M12.5 15H7.5" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>' +
417      '        <path d="M15.8334 15H17.5C17.721 15 17.933 14.9122 18.0893 14.7559C18.2456 14.5996 18.3334 14.3877 18.3334 14.1667V11.125C18.333 10.9359 18.2684 10.7525 18.15 10.605L15.25 6.98C15.1721 6.8824 
41715.0732 6.80357 14.9607 6.74933C14.8482 6.69509 14.7249 6.66684 14.6 6.66666H11.6667" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>' +
418      '        <path d="M14.1667 16.6667C15.0871 16.6667 15.8333 15.9205 15.8333 15C15.8333 14.0795 15.0871 13.3333 14.1667 13.3333C13.2462 13.3333 12.5 14.0795 12.5 15C12.5 15.9205 13.2462 16.6667 14.1667 16.6667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>' +
419      '        <path d="M5.83335 16.6667C6.75383 16.6667 7.50002 15.9205 7.50002 15C7.50002 14.0795 6.75383 13.3333 5.83335 13.3333C4.91288 13.3333 4.16669 14.0795 4.16669 15C4.16669 15.9205 4.91288 16.6667 5.83335 16.6667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>' +
420      '      </svg>' +
421      '    </div>' +
422      '    <div class="cart-drawer__ship-bar">' +
423      '      <div class="cart-drawer__ship-fill" style="width: ' + progress + '%"></div>' +
424      '    </div>' +
425      '  </div>' +
426      '  <div class="cart-drawer__ship-info">' +
427      labelHTML +
428      exclusionsHTML +
429      '  </div>' +
430      '</div>';
431
432    // Bind exclusions click
433    const exLink = container.querySelector('[data-target="cartDrawerShipExclusions"]');
434    if (exLink) {
435      exLink.addEventListener('click', (e) => {
436        e.preventDefault();
437        this.openExclusionsDrawer();
438      });
439    }
440  }
441
442  hide() {
443    const c = document.querySelector(this.CONFIG.containerSelector);
444    if (c) c.style.display = 'none';
445  }
446  show() {
447    const c = document.querySelector(this.CONFIG.containerSelector);
448    if (c) c.style.display = '';
449  }
450  getConfig() { return this.CONFIG; }
451
452
453  /** @returns {number} Total original price × qty for all cart items */
454  getCartPageOriginalSubtotal() {
455    let total = 0;
456    const cartRows = document.querySelectorAll('.shopping-cart__table--row');
457    
458    cartRows.forEach(row => {
459      // Get quantity from input
460      const qtyInput = row.querySelector('input[name^="SIQTY"], input[id^="GSIQTY"]');
461      const qty = qtyInput ? parseInt(qtyInput.value) || 1 : 1;
462      
463      // Try to find original price span, fall back to discount or any price
464      const priceContainer = row.querySelector('.shopping-cart__item--price');
465      if (!priceContainer) return;
466      
467      let price = 0;
468      const originalSpan = priceContainer.querySelector('.original');
469      const discountSpan = priceContainer.querySelector('.discount');
470      
471      if (originalSpan) {
472        // Item is on sale - use original price
473        price = this.parseSubtotal(originalSpan.textContent);
474      } else if (discountSpan) {
475        // Only discount shown (shouldn't happen but fallback)
476        price = this.parseSubtotal(discountSpan.textContent);
477      } else {
478        // No sale - parse first price found
479        price = this.parseSubtotal(priceContainer.textContent);
480      }
481      
482      total += price * qty;
483    });
484    
485    return total;
486  }
487
488  /** Initialize on cart page - replace [data-target="buy-more-save-more"] with new widget */
489  initCartPage() {
490    const path = window.location.pathname.toLowerCase();
491    if (!path.includes('/cart') && !path.includes('/cgbccart')) return;
492    if (!this.isEnabledForSite()) return;
493
494    // Target by data attribute, not inside sidecart
495    const toQualify = document.querySelector('[data-target="buy-more-save-more"]:not(#samCartDrawer_Right [data-target="buy-more-save-more"])');
496    if (!toQualify) return;
497
498    const html = toQualify.innerHTML;
499    const subtotal = this.getCartPageOriginalSubtotal();
500
501    // Replace parent .alert div (or toQualify if no .alert parent)
502    const alertParent = toQualify.closest('.alert');
503    const targetEl = alertParent || toQualify;
504
505    // Update parent column class from col-xs-12 to col-xs-12 col-md-9
506    const colParent = targetEl.parentElement;
507    if (colParent && colParent.classList.contains('col-xs-12')) {
508      colParent.classList.add('col-md-9');
509    }
510
511    // Create container and replace target
512    const container = document.createElement('div');
513    container.setAttribute('data-target', 'buyMoreSaveMoreContainerCartPage');
514    targetEl.replaceWith(container);
515
516    // Render the widget using cart page specific container
517    const originalSelector = this.CONFIG.containerSelector;
518    this.CONFIG.containerSelector = '[data-target="buyMoreSaveMoreContainerCartPage"]';
519    this.renderFromHTML(html, subtotal);
520    this.CONFIG.containerSelector = originalSelector;
521
522    // Render mobile version in placeholder (visibility handled by CSS classes in template)
523    const mobilePlaceholder = document.querySelector('[data-target="bmsmMobilePlaceholder"]');
524    if (mobilePlaceholder) {
525      const mobileContainer = document.createElement('div');
526      mobileContainer.setAttribute('data-target', 'buyMoreSaveMoreContainerMobile');
527      mobilePlaceholder.appendChild(mobileContainer);
528      
529      this.CONFIG.containerSelector = '[data-target="buyMoreSaveMoreContainerMobile"]';
530      this.renderFromHTML(html, subtotal);
531      this.CONFIG.containerSelector = originalSelector;
532    }
533
534    // BE adds display:none to parent .row when all tiers reached - remove it only at max threshold
535    const maxThreshold = this.CONFIG.expectedTiers[this.CONFIG.expectedTiers.length - 1].threshold;
536    if (subtotal >= maxThreshold) {
537      const rowParent = container.closest('.row');
538      if (rowParent) rowParent.style.display = '';
539    }
540  }
541}
542
543window.BuyMoreSaveMore = new BuyMoreSaveMoreHandler();
544
545// Auto-init on cart page
546if (document.readyState === 'loading') {
547  document.addEventListener('DOMContentLoaded', () => window.BuyMoreSaveMore.initCartPage());
548} else {
549  window.BuyMoreSaveMore.initCartPage();
550}

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.