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">•</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.