1/** 2 * FedEx Ground Transit Time - Estimated Delivery Dates 3 * Calculates delivery estimates based on: 4 * - ESD (Est Ship Date) from backend token #SIESDTd 5 * - FedEx transit times from CSV data by destination zip 6 * - Range: earliestDate + 1 business day for upper bound 7 * 8 * Supports: PDP, Quickview, Cart, Minicart 9 */ 10 11class EDDHandler { 12 constructor() { 13 this.config = { 14 origin: { 15 zip: '40505', // FedEx origin zip (Lexington warehouse) 16 }, 17 18 cache: { 19 duration: 1000 * 60 * 60 * 24 * 7, // 7 days (CSV is static, updated annually) 20 }, 21 22 carrierData: { 23 fedex: { 24 csvPath: '/assets/fulfillment/delivery/edd-csv-imports/fedex-transit-times-11-26-25.csv', 25 key: 'fedexTransitData', 26 }, 27 }, 28 29 holidayConfig: { 30 csvPath: '/assets/fulfillment/delivery/edd-csv-imports/edd-2026_edd-holidays.csv', 31 key: 'holidaysData', 32 enabled: false, // Set to true when ready to load holidays 33 }, 34 35 deliveryRules: { 36 bufferBusinessDays: 1, 37 cutoffHourEST: 14 38 }, 39 checkoutDecisionTable: { 40 scenarios: [ 41 { condition: 'GQ ESD > 1', shipDateUsed: 'GQ ESD', standardDelivery: 'GQ ESD + Ground', secondDayDelivery: 'GQ ESD + 2', nextDayDelivery: 'GQ ESD + 1' }, 42 { condition: 'GQ ESD = 1, before 2PM weekday', shipDateUsed: 'Today', standardDelivery: 'Today + Ground', secondDayDelivery: 'Today + 2', nextDayDelivery: 'Tomorrow' }, 43 { condition: 'GQ ESD = 1, after 2PM', shipDateUsed: 'Tomorrow', standardDelivery: 'Tomorrow + Ground', secondDayDelivery: 'Tomorrow + 2', nextDayDelivery: 'Tomorrow + 1' }, 44 { condition: 'Friday before 2PM', shipDateUsed: 'Friday', standardDelivery: 'Friday + Ground', secondDayDelivery: 'Tuesday', nextDayDelivery: 'Monday' }, 45 { condition: 'Friday after 2PM', shipDateUsed: 'Monday', standardDelivery: 'Monday + Ground', secondDayDelivery: 'Wednesday', nextDayDelivery: 'Tuesday' }, 46 { condition: 'Weekend', shipDateUsed: 'Monday', standardDelivery: 'Monday + Ground', secondDayDelivery: 'Wednesday', nextDayDelivery: 'Tuesday' } 47 ], 48 shipDateRules: { 49 weekendShipDay: 'monday', 50 fridayAfterCutoffShipDay: 'monday', 51 afterCutoffDelayBusinessDays: 1 52 }, 53 deliveryBusinessDays: { 54 secondDay: 2, 55 nextDay: 1 56 } 57 }, 58 stockMsgDisplay: { 59 defaultTooltip: 'Customized items may take longer to ship.', 60 stockMsgText: { 61 block: { 62 esd: { 63 inStock: 'In Stock', 64 partnerFulfilled: 'Partner Fulfilled', 65 backordered: 'Backordered', 66 backorderDateOnly: '', 67 preOrder: 'Pre-Order' 68 }, 69 edd: { 70 inStock: 'In Stock', 71 partnerFulfilled: 'Partner Fulfilled', 72 backordered: 'Backordered', 73 backorderDateOnly: '', 74 preOrder: 'Pre-Order' 75 } 76 }, 77 inline: { 78 esd: { 79 inStock: 'In Stock', 80 partnerFulfilled: 'Partner Fulfilled', 81 backordered: 'Backordered', 82 backorderDateOnly: '', 83 preOrder: 'Pre-Order' 84 }, 85 edd: { 86 inStock: 'In Stock', 87 partnerFulfilled: 'Partner Fulfilled', 88 backordered: 'Backordered', 89 backorderDateOnly: '', 90 preOrder: 'Pre-Order' 91 } 92 } 93 }, 94 // Drawer messages displayed when user clicks the stock message (per status type per variation mode) 95 // Placeholders: {startDate} = "Wednesday, February 12", {endDate} = "Thursday, February 13" 96 drawerMessages: { 97 esd: { 98 inStock: 'This item is currently in stock and estimated to ship between {startDate} and {endDate} with standard shipping. Ship timing is based on your delivery ZIP code. Customized items may require additional processing time.', 99 partnerFulfilled: 'This item ships from a partner facility and is estimated to ship between {startDate} and {endDate}. Processing and transit times may differ from warehouse items.', 100 backordered: 'Due to high demand, this item is currently backordered and estimated to ship between {startDate} and {endDate} once inventory becomes available. Ship timing may adjust based on availability. We will notify you of any changes.', 101 backorderDateOnly: 'Estimated to ship between {startDate} and {endDate}. Ship timing may adjust based on availability.', 102 preOrder: 'This item is available for pre-order and estimated to ship between {startDate} and {endDate}
102. Ship timing is based on expected inventory arrival and may change. You will be notified of any updates.' 103 }, 104 edd: { 105 inStock: 'This item is in stock and estimated to arrive between {startDate} and {endDate} with standard shipping. Delivery timing is based on your ZIP code and carrier transit times. Customized items may require additional processing time.', 106 partnerFulfilled: 'This item ships from a partner facility and is estimated to arrive between {startDate} and {endDate}. Delivery timing may vary from warehouse-shipped items.', 107 backordered: 'Due to high demand, this item is currently backordered and estimated to arrive between {startDate} and {endDate} once inventory is available. Delivery timing may adjust if availability changes.', 108 backorderDateOnly: 'Estimated to arrive between {startDate} and {endDate}. Delivery timing may adjust based on availability.', 109 preOrder: 'This item is available for pre-order and estimated to arrive between {startDate} and {endDate}. Delivery timing is based on expected inventory arrival and standard transit times. Updates will be provided if dates change.' 110 }, 111 sameDay: { 112 esd: { 113 inStock: 'This item is currently in stock and estimated to ship on {date} with standard shipping. Ship timing is based on your delivery ZIP code. Customized items may require additional processing time.', 114 partnerFulfilled: 'This item ships from a partner facility and is estimated to ship on {date}. Processing and transit times may differ from warehouse items.', 115 backordered: 'Due to high demand, this item is currently backordered and estimated to ship on {date} once inventory becomes available. Ship timing may adjust based on availability. We will notify you of any changes.', 116 backorderDateOnly: 'Estimated to ship on {date}. Ship timing may adjust based on availability.', 117 preOrder: 'This item is available for pre-order and estimated to ship on {date}. Ship timing is based on expected inventory arrival and may change. You will be notified of any updates.' 118 }, 119 edd: { 120 inStock: 'This item is in stock and estimated to arrive on {date} with standard shipping. Delivery timing is based on your ZIP code and carrier transit times. Customized items may require additional processing time.', 121 partnerFulfilled: 'This item ships from a partner facility and is estimated to arrive on {date}. Delivery timing may vary from warehouse-shipped items.', 122 backordered: 'Due to high demand, this item is currently backordered and estimated to arrive on {date} once inventory is available. Delivery timing may adjust if availability changes.', 123 backorderDateOnly: 'Estimated to arrive on {date}. Delivery timing may adjust based on availability.', 124 preOrder: 'This item is available for pre-order and estimated to arrive on {date}. Delivery timing is based on expected inventory arrival and standard transit times. Updates will be provided if dates change.' 125 } 126 } 127 } 128 }, 129 abTestVariations: { 130 galls: { 131 stage: { 132 experimentId: 29279, 133 eddVariationId: 43901, 134 esdVariationId: 48269 135 }, 136 prod: { 137 experimentId: 33283, 138 eddVariationId: 49866, 139 esdVariationId: 666 140 } 141 }, 142 usp: { 143 stage: { 144 experimentId: 32405, 145 eddVariationId: 48565, 146 esdVariationId: 48566 147 }, 148 prod: { 149 experimentId: 32406, 150 eddVariationId: 48567, 151 esdVariationId: 48568 152 } 153 } 154 } 155 }; 156 // Loaded from CSV files 157 this.fedexTransitData = null; 158 this.holidaysData = new Set(); // Set of date strings "YYYY-MM-DD" for O(1) lookup 159 this.userZip = null; 160 } 161 162 async init() { 163 // Load FedEx transit data 164 await this.loadTransitData(); 165 166 // Load holiday data if enabled 167 if (this.config.holidayConfig.enabled) { 168 await this.loadHolidayData(); 169 } 170 171 // Get and store user's zip code 172 this.userZip = this.getUserZipCode(); 173 174 this.displayPDPDeliveryEstimate(); 175 176 // Listen for location changes and recalculate 177 this.setupLocationChangeListener(); 178 } 179 180 /** Set up listener for user location changes. Recalculates delivery estimate when user updates their zip code */ 181 setupLocationChangeListener() { 182 // Listen for storage events (works across tabs) 183 window.addEventListener('storage', (e) => { 184 if (e.key === 'userLocationData') { 185 this.userZip = this.getUserZipCode(); 186 this.displayPDPDeliveryEstimate(); 187 } 188 });
189 190 // Also listen for custom event (same-tab updates) 191 window.addEventListener('userLocationUpdated', (e) => { 192 this.userZip = this.getUserZipCode(); 193 194 // ESD mode: ship dates don't depend on user zip â skip date recalculation 195 const variationMode = window.edd?.getABVariationMode?.() || 'control'; 196 if (variationMode === 'esd') return; 197 198 this.displayPDPDeliveryEstimate(); 199 200 // Also update the dynamically injected EDD UI if it exists 201 this.updateDynamicEDDUI(); 202 }); 203 } 204 205 /** Update dynamically injected EDD UI (from stock-status.js) when location changes. Skipped in ESD mode since ship dates don't depend on user zip. */ 206 updateDynamicEDDUI() { 207 // ESD mode: ship dates don't depend on user zip â skip recalculation 208 const variationMode = window.edd?.getABVariationMode?.() || 'control'; 209 if (variationMode === 'esd') return; 210 211 const eddDateEl = document.querySelector('.pdp-edd [data-target="eddDeliveryDate"]'); 212 const eddZipEl = document.querySelector('.pdp-edd [data-target="locationTrackerText"]'); 213 214 if (!eddDateEl) { 215 return; 216 } 217 218 // Get new user zip 219 const userZip = this.getUserZipCode(); 220 221 if (!userZip) return; 222 223 // Get ESD from cached delivery estimate (not DOM - SIESDT field doesn't exist on page) 224 let esdDate = null; 225 try { 226 const cached = localStorage.getItem('currentDeliveryEstimate'); 227 if (cached) { 228 const parsedCache = JSON.parse(cached); 229 if (parsedCache.data && parsedCache.data.esdDate) { 230 // esdDate was stored as ISO string, convert back to Date 231 esdDate = new Date(parsedCache.data.esdDate); 232 } 233 } 234 } catch (e) {} 235 236 if (!esdDate || isNaN(esdDate.getTime())) { 237 return; 238 } 239 240 const transitDays = this.getTransitDays(userZip); 241 242 if (transitDays === null) return; 243 244 const deliveryInfo = this.calculateDeliveryDates(esdDate, transitDays); 245 246 // Update the date display (preserve <strong> wrapper for bold styling) 247 eddDateEl.innerHTML = `<strong>${deliveryInfo.rangeFormatted}</strong>`; 248 249 // Update the zip display 250 if (eddZipEl) { 251 eddZipEl.textContent = userZip; 252 } 253 254 // Also update cache 255 this.cacheDeliveryEstimate(deliveryInfo); 256 } 257 258 /** @returns {string|null} Product SKU */ 259 getCurrentProductSku() { 260 const skuElement = document.querySelector('#style[name="SISTYL"]'); 261 return skuElement ? skuElement.value : null; 262 } 263 264 /** @returns {string|null} User's zip code */ 265 getUserZipCode() { 266 try { 267 const locationData = localStorage.getItem('userLocationData'); 268 if (locationData) { 269 const location = JSON.parse(locationData); 270 return location.zip || null; 271 } 272 } catch (error) { 273 // Silent fail 274 } 275 return null; 276 } 277 278 /** @returns {string} User's country code (defaults to USA) */ 279 getUserCountry() { 280 try { 281 const locationData = localStorage.getItem('userLocationData'); 282 if (locationData) { 283 const location = JSON.parse(locationData); 284 return location.country === 'US' ? 'USA' : location.country; 285 } 286 } catch (error) { 287 // Silent fail 288 } 289 return 'USA'; 290 } 291 292 /** Get ESD (Est Ship Date) from backend hidden field @returns {Date|null} ESD as Date object or null if not available */ 293 getESDDate() { 294 // Look for hidden field - try both IDs (backend may use either) 295 // <span id="SIESDT"><input type="hidden" name="SIESDT" value="02/16/2026" id="SIESDTd"></span> 296 const esdInput = document.querySelector('#SIESDTd') || document.querySelector('#SIESDT input[name="SIESDT"]') || document.querySelector('#SIESDT'); 297 298 if (!esdInput || !esdInput.value) { 299 return null; 300 } 301 302 const esdValue = esdInput.value.trim(); 303 if (!esdValue) return null; 304 305 // Parse MM/DD/YYYY format 306 const parts = esdValue.split('/'); 307 if (parts.length !== 3) return null; 308
309 const month = parseInt(parts[0], 10) - 1; // JS months are 0-indexed 310 const day = parseInt(parts[1], 10); 311 const year = parseInt(parts[2], 10); 312 313 if (isNaN(month) || isNaN(day) || isNaN(year)) return null; 314 315 return new Date(year, month, day); 316 } 317 318 /** Load FedEx transit data. Delegates to window.edd.ensureTransitDataLoaded() (single source of truth) and syncs the parsed data back to this.fedexTransitData for EDDHandler methods. */ 319 async loadTransitData() { 320 if (window.edd && typeof window.edd.ensureTransitDataLoaded === 'function') { 321 await window.edd.ensureTransitDataLoaded(); 322 // Sync parsed data from the shared cache into EDDHandler memory 323 this.fedexTransitData = window.edd._transitDataCache || {}; 324 } 325 } 326 327 /** Check if a date is a weekend (Saturday or Sunday) @param {Date} date @returns {boolean} */ 328 isWeekend(date) { 329 const day = date.getDay(); 330 return day === 0 || day === 6; 331 } 332 333 /** Check if a date is a FedEx holiday @param {Date} date @returns {boolean} */ 334 isHoliday(date) { 335 // Only check if holiday logic is enabled 336 if (!this.config.holidayConfig.enabled) { 337 return false; 338 } 339 // Convert date to YYYY-MM-DD string for Set lookup 340 return this.holidaysData.has(this.formatDateKey(date)); 341 } 342 343 /** Format a Date to YYYY-MM-DD string @param {Date} date @returns {string} */ 344 formatDateKey(date) { 345 const year = date.getFullYear();
346 const month = String(date.getMonth() + 1).padStart(2, '0'); 347 const day = String(date.getDate()).padStart(2, '0'); 348 return `${year}-${month}-${day}`; 349 } 350 351 /** Load holiday data from CSV. Fetches and parses CSV, stores in memory and localStorage */ 352 async loadHolidayData() { 353 const cacheKey = this.config.holidayConfig.key; 354 355 // Check localStorage cache first 356 const cached = localStorage.getItem(cacheKey); 357 if (cached) { 358 try { 359 const parsedCache = JSON.parse(cached); 360 const now = Date.now(); 361 362 if (parsedCache.data && 363 parsedCache.timestamp && 364 (now - parsedCache.timestamp) < this.config.cache.duration) { 365 this.holidaysData = new Set(parsedCache.data); 366 return; 367 } 368 } catch (error) { 369 // Invalid cache, continue to fetch 370 } 371 } 372 373 // Fetch CSV file 374 try { 375 const response = await fetch(this.config.holidayConfig.csvPath); 376 377 if (!response.ok) { 378 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 379 } 380 381 const csvText = await response.text(); 382 383 // Parse CSV into Set of date strings 384 this.holidaysData = this.parseHolidayCSV(csvText); 385 386 // Cache the parsed data (convert Set to Array for JSON) 387 localStorage.setItem(cacheKey, JSON.stringify({ 388 data: Array.from(this.holidaysData), 389 timestamp: Date.now() 390 })); 391 } catch (error) { 392 this.holidaysData = new Set(); 393 } 394 } 395 396 /** Parse holiday CSV into Set of date strings @param {string} csvText @returns {Set<string>} Set of "YYYY-MM-DD" date strings */ 397 parseHolidayCSV(csvText) { 398 const lines = csvText.split(/\r?\n/); 399 const holidays = new Set(); 400 401 // Skip header row (index 0) 402 for (let i = 1; i < lines.length; i++) { 403 const line = lines[i].trim(); 404 if (!line) continue; 405 406 const columns = line.split(','); 407 if (columns.length < 3) continue; 408 409 // Column indices: 0: Date (MM/DD/YYYY), 1: Holiday name, 2: FedEx (Y/N) 410 const dateStr = columns[0]?.trim(); 411 const isFedExHoliday = columns[2]?.trim().toUpperCase() === 'Y'; 412 413 // Only include FedEx holidays 414 if (!isFedExHoliday) continue; 415 416 // Parse MM/DD/YYYY and convert to YYYY-MM-DD for consistent lookup 417 const parts = dateStr.split('/'); 418 if (parts.length !== 3) continue; 419
420 const month = parts[0].padStart(2, '0'); 421 const day = parts[1].padStart(2, '0'); 422 const year = parts[2]; 423 424 if (year && month && day) { 425 const dateKey = `${year}-${month}-${day}`; 426 holidays.add(dateKey); 427 428 // Federal observed date logic: if holiday falls on weekend, add observed weekday 429 const holidayDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); 430 const dayOfWeek = holidayDate.getDay(); 431 432 if (dayOfWeek === 6) { 433 // Saturday â observed on preceding Friday 434 const observedDate = new Date(holidayDate); 435 observedDate.setDate(observedDate.getDate() - 1); 436 holidays.add(this.formatDateKey(observedDate)); 437 } else if (dayOfWeek === 0) { 438 // Sunday â observed on following Monday 439 const observedDate = new Date(holidayDate); 440 observedDate.setDate(observedDate.getDate() + 1); 441 holidays.add(this.formatDateKey(observedDate)); 442 } 443 } 444 } 445 446 return holidays; 447 } 448 449 /** Get transit days for a destination zip code from FedEx CSV @param {string} zipCode @returns {number|null} Transit days or null if not found */ 450 getTransitDays(zipCode) { 451 if (!zipCode) return null; 452 453 // Clean zip code (remove hyphens, take first 5 digits) 454 const cleanZip = zipCode.replace(/\D/g, '').slice(0, 5); 455 456 const transitDays = this.fedexTransitData?.[cleanZip] ?? null; 457 return transitDays; 458 } 459 460 /** Add business days to a date (excludes weekends and holidays) @param {Date} startDate @param {number} businessDays @returns {Date} */ 461 addBusinessDays(startDate, businessDays) { 462 const result = new Date(startDate); 463 let daysAdded = 0; 464 465 while (daysAdded < businessDays) { 466 result.setDate(result.getDate() + 1); 467 468 // Skip weekends 469 if (!this.isWeekend(result) && !this.isHoliday(result)) { 470 daysAdded++; 471 } 472 } 473 474 return result; 475 } 476 477 /** Calculate delivery date range based on ESD + FedEx transit @param {Date} esdDate - Est Ship Date from backend @param {number} transitDays - FedEx transit days @returns {object} Delivery date information with range */ 478 calculateDeliveryDates(esdDate, transitDays) { 479 // Apply ship date cutoff (2PM EST, weekends, holidays) 480 const shipDate = window.edd?.calculateShipDate ? window.edd.calculateShipDate(esdDate) : esdDate; 481 // earliestDate = adjusted ship date + transit days 482 const earliestDelivery = this.addBusinessDays(shipDate, transitDays); 483 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 484 485 // latestDate = earliestDate + 1 business day 486 const latestDelivery = this.addBusinessDays(earliestDelivery, bufferBusinessDays); 487 488 // Format dates 489 const shortOptionsWithDay = { month: 'short', day: 'numeric' }; 490 const longOptions = { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }; 491 492 const earliestFormatted = earliestDelivery.toLocaleDateString('en-US', shortOptionsWithDay); 493 const latestFormatted = latestDelivery.toLocaleDateString('en-US', shortOptionsWithDay); 494 495 // Range format: "Nov 23 - 24" (same month) or "Nov 30 - Dec 1" (different month) 496 let rangeFormatted; 497 if (earliestDelivery.getFullYear() === latestDelivery.getFullYear() && earliestDelivery.getMonth() === latestDelivery.getMonth()) {
498 const month = earliestDelivery.toLocaleDateString('en-US', { month: 'short' }); 499 rangeFormatted = `${month} ${earliestDelivery.getDate()} - ${latestDelivery.getDate()}`; 500 } else { 501 rangeFormatted = `${earliestFormatted} - ${latestFormatted}`; 502 } 503 504 return { 505 transitDays: transitDays, 506 esdDate: esdDate, 507 earliestDelivery: earliestDelivery, 508 latestDelivery: latestDelivery, 509 earliestFormatted: earliestDelivery.toLocaleDateString('en-US', longOptions), 510 latestFormatted: latestDelivery.toLocaleDateString('en-US', longOptions), 511 rangeFormatted: rangeFormatted, 512 orderDate: new Date(), 513 orderDateFormatted: new Date().toLocaleDateString('en-US', longOptions) 514 }; 515 } 516 517 /** Main function to calculate and display delivery estimate (PDP ONLY) */ 518 async displayPDPDeliveryEstimate() { 519 const zipCode = this.userZip; 520 521 // Need user zip code 522 if (!zipCode) { 523 return; 524 } 525 526 // Get ESD from backend token 527 const esdDate = this.getESDDate(); 528 if (!esdDate) { 529 // No ESD available - display nothing 530 return; 531 } 532 533 // Get transit days from FedEx CSV 534 const transitDays = this.getTransitDays(zipCode); 535 if (transitDays === null) { 536 return; 537 } 538 539 // Calculate delivery dates 540 const deliveryInfo = this.calculateDeliveryDates(esdDate, transitDays); 541 542 // Update UI elements with delivery info 543 this.updateDeliveryUI(deliveryInfo); 544 } 545 546 /** Recalculate delivery estimate. Call this when location changes */ 547 recalculateDeliveryEstimate() { 548 this.displayPDPDeliveryEstimate(); 549 } 550 551 /** Cache delivery estimate data for use by other modules @param {object} deliveryInfo */ 552 cacheDeliveryEstimate(deliveryInfo) { 553 try { 554 const cacheObject = { 555 data: deliveryInfo, 556 timestamp: Date.now() 557 }; 558 localStorage.setItem('currentDeliveryEstimate', JSON.stringify(cacheObject)); 559 } catch (error) { 560 // Silent fail 561 } 562 } 563 564 /** Update UI with delivery information. Dispatches custom event for other modules @param {object} deliveryInfo */ 565 updateDeliveryUI(deliveryInfo) { 566 this.cacheDeliveryEstimate(deliveryInfo); 567 568 // Dispatch custom event for modules that need immediate notification 569 window.dispatchEvent(new CustomEvent('deliveryEstimateReady', { 570 detail: deliveryInfo 571 })); 572 573 // Update PDP EDD UI if it exists on page 574 this.updatePDPDeliveryDate(deliveryInfo); 575 } 576 577 /** Update the delivery date in the PDP EDD UI @param {object} deliveryInfo */ 578 updatePDPDeliveryDate(deliveryInfo) { 579 const eddStatusText = document.querySelector('[data-target="eddDeliveryDate"]'); 580 if (eddStatusText) { 581 eddStatusText.innerHTML = `<strong>${deliveryInfo.rangeFormatted}</strong>`; 582 } 583 } 584 585 /** Detect if cart item is in stock @param {string} stockMsg @returns {boolean} */ 586 detectItemInStock(stockMsg) { 587 return stockMsg && stockMsg.toLowerCase().includes('in stock'); 588 } 589 590 /** Calculate delivery estimate for a cart item @param {object} baseDelivery - Base delivery data @param {boolean} isInStock @returns {object|null} */ 591 calculateItemDelivery(baseDelivery, isInStock) { 592 if (!baseDelivery || !isInStock) { 593 return null; 594 } 595 596 const earliestDeliveryDate = baseDelivery.earliestDelivery instanceof Date 597 ? baseDelivery.earliestDelivery 598 : new Date(baseDelivery.earliestDelivery); 599 const latestDeliveryDate = baseDelivery.latestDelivery instanceof Date 600 ? baseDelivery.latestDelivery 601 : new Date(baseDelivery.latestDelivery); 602 603 if (isNaN(earliestDeliveryDate.getTime()) || isNaN(latestDeliveryDate.getTime())) { 604 return null; 605 } 606 607 return { 608 rangeFormatted: baseDelivery.rangeFormatted, 609 earliestDate: earliestDeliveryDate.toISOString().split('T')[0], 610 latestDate: latestDeliveryDate.toISOString().split('T')[0] 611 }; 612 } 613} 614 615/** 616 * EDD (Estimated Delivery Date) - Namespace for delivery estimate functionality 617 * Exposed as window.edd 618 * Supports: PDP, Quickview, Cart, Minicart 619 */ 620 621/** 622 * Centralized stock status configuration - shared between est-delivery-date.js and stock-status.js 623 * @type {{inStock: {label: string, color: string}, partnerFulfilled: {label: string, color: string}, backordered: {label: string, color: string}, preOrder: {label: string, color: string}}} 624 */ 625const eddStockStatusConfig = (new EDDHandler()).config || {}; 626const eddStockMsgDisplayConfig = eddStockStatusConfig.stockMsgDisplay || {}; 627const eddStockStatusLabels = (eddStockMsgDisplayConfig.stockMsgText || {})?.block?.edd || {}; 628 629/** Read a CSS custom property from :root. @param {string} varName @param {string} [fallback] @returns {string} */ 630const getEDDCSSColor = (varName, fallback = '') => { 631 try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback; } 632 catch (e) { return fallback; } 633}; 634 635window.stockStatusConfig = { 636 inStock: { label: eddStockStatusLabels.inStock, color: getEDDCSSColor('--edd-status-in-stock', '#2E7D32') }, 637 partnerFulfilled: { label: eddStockStatusLabels.partnerFulfilled, color: getEDDCSSColor('--edd-status-partner-fulfilled', '#2E7D32') }, 638 backordered: { label: eddStockStatusLabels.backordered, color: getEDDCSSColor('--edd-status-backordered', '#F4B400') }, 639 backorderDateOnly: { label: '', color: getEDDCSSColor('--edd-status-in-stock', '#2E7D32') }, 640 preOrder: { label: eddStockStatusLabels.preOrder, color: getEDDCSSColor('--edd-status-pre-order', '#5DADE2') } 641}; 642 643// Hide Backorder -- apply body class at script parse time (before DOM renders) 644// A/B test concluded on both brands. Galls: was exp 35803 / variant 53625. USP: was exp 36663 / variant 54905. 645(function() { 646 document.documentElement.classList.add('hide-backorder-msg'); 647})(); 648 649// Integration contract (stock-status.js): 650// 1) This file loads first and defines window.edd before DOMContentLoaded. 651// 2) Checkout/cart wrappers emitted here must use: data-stock-info-trigger + data-tip + data-original-stock-text. 652// 3) Avoid relying on parent inline color; stock-status.js and CSS handle style normalization. 653window.edd = { 654 config: {}, 655 656 // ===== CART API WRAPPER ===== 657 658 /** Fetch cart items from API @returns {Promise<Array>} */ 659 fetchCartItems: async function() { 660 try { 661 const response = await fetch('/CGBCCART?CARTDRAWER=Y', { 662 method: 'GET', 663 headers: { 'Content-Type': 'text/html' } 664 }); 665 666 if (!response.ok) { 667 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 668 } 669 670 const data = await response.text(); 671 const parsedData = JSON.parse(data); 672 if (Array.isArray(parsedData) && typeof this.setCartPayloadContext === 'function') { 673 this.setCartPayloadContext(parsedData); 674 } 675 return parsedData; 676 } catch (error) { 677 return []; 678 } 679 }, 680 681 // ===== STATE MANAGEMENT ===== 682 683 /** Generate hash from cart items to detect changes @param {Array} cartItems @returns {string} */ 684 generateCartHash: function(cartItems) { 685 if (!cartItems || !Array.isArray(cartItems)) return ''; 686 687 const itemKeys = this.getCartPayloadItems(cartItems) 688 .map(item => this.getCartPayloadCdno(item)) 689 .filter(key => key !== '') 690 .sort() 691 .join('|'); 692 693 return itemKeys; 694 }, 695 696 /** Get cdno/product key from a payload row. @param {object|Array} item @returns {string} */ 697 getCartPayloadCdno: function(item) { 698 if (!item) return ''; 699 if (Array.isArray(item)) { 700 return (item[14] || item[7] || '').toString().trim(); 701 } 702 return (item.cdno || '').toString().trim(); 703 }, 704 705 /** Normalize payload and remove trailing TO_QUALIFY row when present. @param {Array} cartItems @returns {Array} */ 706 getCartPayloadItems: function(cartItems) { 707 if (!Array.isArray(cartItems) || !cartItems.length) return []; 708 709 const items = cartItems.slice(); 710 const lastItem = items[items.length - 1]; 711 const lastCdno = this.getCartPayloadCdno(lastItem); 712 713 if (lastCdno === 'TO_QUALIFY') { 714 items.pop(); 715 } 716 717 return items; 718 }, 719 720 /** Extract ESD date token from stock message text. @param {string} stockMsg @returns {string} */ 721 extractESDFromStockMessage: function(stockMsg) { 722 if (!stockMsg) return ''; 723 const match = stockMsg.match(/ESD:\s*(\d{1,2}\/\d{1,2}\/\d{4})/i); 724 return match && match[1] ? match[1] : ''; 725 }, 726 727 /** Extract tooltip text from stock message markup (data-tip first, then title). @param {string} stockMsg @param {string} [fallback] @returns {string} */ 728 extractTooltipTextFromMarkup: function(stockMsg, fallback) { 729 if (!fallback) fallback = this.config?.stockMsgDisplay?.defaultTooltip || 'Customized items may take longer to ship.'; 730 if (!stockMsg || typeof stockMsg !== 'string') return fallback; 731 const dataTipMatch = stockMsg.match(/data-tip="([^"]*)"/i);
732 if (dataTipMatch && dataTipMatch[1]) return dataTipMatch[1]; 733 const titleMatch = stockMsg.match(/title="([^"]*)"/i); 734 if (titleMatch && titleMatch[1]) return titleMatch[1]; 735 return fallback; 736 }, 737 738 /** Parse MM/DD/YYYY into Date. @param {string} esdValue @returns {Date|null} */ 739 parseESDDateValue: function(esdValue) { 740 if (!esdValue) return null; 741 const parts = esdValue.split('/'); 742 if (parts.length !== 3) return null; 743 744 const month = parseInt(parts[0], 10) - 1; 745 const day = parseInt(parts[1], 10); 746 const year = parseInt(parts[2], 10); 747 748 if (isNaN(month) || isNaN(day) || isNaN(year)) return null; 749 return new Date(year, month, day); 750 }, 751 752 /** Get ship range info from ESD value. @param {string} esdValue @returns {{rangeEndDate: Date}|null} */ 753 getShipRangeInfoFromESD: function(esdValue) { 754 const esdDate = this.parseESDDateValue(esdValue); 755 if (!esdDate) return null; 756 const shipDate = this.calculateShipDate(esdDate); 757 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 758 const rangeEndDate = this.addBusinessDaysSimple(shipDate, bufferBusinessDays); 759 return { 760 rangeEndDate: rangeEndDate 761 }; 762 }, 763 764 /** Eligible original in-stock rule: only pure in-stock items aggregate. @param {string} stockMsg @returns {boolean} */ 765 isEligibleOriginalInStockStatus: function(stockMsg) { 766 const statusType = this.detectStockStatus(stockMsg); 767 return statusType === 'inStock'; 768 }, 769 770 /** Build and cache aggregate in-stock dates from API payload. @param {Array} cartItems @returns {object|null} */ 771 setCartPayloadContext: function(cartItems) { 772 if (!Array.isArray(cartItems)) return null; 773 774 const parsedItems = this.getCartPayloadItems(cartItems) 775 .map((item) => { 776 if (!item) return null; 777 778 if (Array.isArray(item)) { 779 return { 780 stockMsg: (item[3] || '').toString(), 781 esdValue: (item[17] || item[16] || item[18] || '').toString().trim() 782 }; 783 } 784 785 return { 786 stockMsg: (item.stock_msg || '').toString(),
787 esdValue: (item.esd || item.esdt || item.siesdt || '').toString().trim() 788 }; 789 }) 790 .filter(Boolean); 791 792 let latestESDAggregate = null; 793 let latestEDDAggregate = null; 794 795 parsedItems.forEach((item) => { 796 if (!item.stockMsg) return; 797 if (!this.isEligibleOriginalInStockStatus(item.stockMsg)) return; 798 799 const esdValue = item.esdValue || this.extractESDFromStockMessage(item.stockMsg); 800 if (!esdValue) return; 801 802 const shipRangeInfo = this.getShipRangeInfoFromESD(esdValue); 803 if (shipRangeInfo && (!latestESDAggregate || shipRangeInfo.rangeEndDate > latestESDAggregate.sortDate)) { 804 latestESDAggregate = { 805 esdValue: esdValue, 806 sortDate: shipRangeInfo.rangeEndDate 807 }; 808 } 809 810 const deliveryInfo = this.calculateEDDFromESD(esdValue); 811 if (deliveryInfo && deliveryInfo.latestDelivery && (!latestEDDAggregate || deliveryInfo.latestDelivery > latestEDDAggregate.sortDate)) { 812 latestEDDAggregate = { 813 rangeFormatted: deliveryInfo.rangeFormatted, 814 sortDate: deliveryInfo.latestDelivery 815 }; 816 } 817 }); 818 819 this._cartPayloadContext = { 820 timestamp: Date.now(), 821 esdAggregate: latestESDAggregate, 822 eddAggregate: latestEDDAggregate 823 }; 824 825 return this._cartPayloadContext; 826 }, 827 828 /** Get aggregated in-stock date info from latest payload context. @param {'esd'|'edd'} mode @returns {object|null} */ 829 getCartPayloadAggregate: function(mode) { 830 if (!this._cartPayloadContext) return null; 831 if (mode === 'esd') return this._cartPayloadContext.esdAggregate || null; 832 if (mode === 'edd') return this._cartPayloadContext.eddAggregate || null; 833 return null; 834 }, 835 836 /** Get cart state from sessionStorage @returns {object|null} */ 837 getCartState: function() { 838 try { 839 const stateData = sessionStorage.getItem('eddCartState'); 840 if (!stateData) return null; 841 842 const state = JSON.parse(stateData); 843 844 // Check if state is too old (1 hour max) 845 const now = Date.now(); 846 if (state.timestamp && (now - state.timestamp) > 3600000) { 847 sessionStorage.removeItem('eddCartState'); 848 return null; 849 } 850 851 return state; 852 } catch (error) { 853 sessionStorage.removeItem('eddCartState'); 854 return null; 855 } 856 }, 857 858 /** Save cart state to sessionStorage @param {object} state */ 859 saveCartState: function(state) { 860 try { 861 state.timestamp = Date.now(); 862 sessionStorage.setItem('eddCartState', JSON.stringify(state)); 863 } catch (error) { 864 // Silent fail 865 } 866 }, 867 868 /** Check if cart has changed @param {Array} currentCartItems @returns {boolean} */ 869 validateCartState: function(currentCartItems) { 870 const currentHash = this.generateCartHash(currentCartItems); 871 const savedState = this.getCartState(); 872 873 if (!savedState || savedState.cartHash !== currentHash) { 874 sessionStorage.removeItem('eddCartState'); 875 return true; 876 } 877 878 return false; 879 }, 880 881 /** Initialize cart state from cart items @param {Array} cartItems @returns {object} */ 882 initializeCartState: function(cartItems) { 883 const state = { 884 sessionId: Date.now().toString(36) + Math.random().toString(36), 885 timestamp: Date.now(), 886 userZip: this.getUserZipCode(), 887 items: {}, 888 cartHash: this.generateCartHash(cartItems) 889 }; 890 891 const baseDelivery = this.getBaseDeliveryData(); 892 if (!baseDelivery) { 893 return state; 894 } 895 896 // Ensure EDDHandler instance exists 897 if (!window.eddHandler) { 898 window.eddHandler = new EDDHandler(); 899 } 900 901 // Process each valid cart item 902 this.getCartPayloadItems(cartItems) 903 .forEach((item) => { 904 const cdno = this.getCartPayloadCdno(item); 905 if (!cdno) return; 906 907 const stockMsg = Array.isArray(item) 908 ? (item[3] || '').toString() 909 : (item?.stock_msg || '').toString(); 910 911 const isInStock = window.eddHandler.detectItemInStock(stockMsg); 912 const deliveryEstimate = window.eddHandler.calculateItemDelivery(baseDelivery, isInStock); 913 914 state.items[cdno] = { 915 isInStock: isInStock, 916 deliveryEstimate: deliveryEstimate 917 }; 918 });
919 920 return state; 921 }, 922 923 // ===== STOCK STATUS HELPERS ===== 924 925 /** Detect stock status type from message @param {string} stockMsg @returns {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} */ 926 detectStockStatus: function(stockMsg) { 927 if (!stockMsg) return 'inStock'; 928 const lower = stockMsg.toLowerCase(); 929 930 if (lower.includes('pre-order') || lower.includes('preorder') || lower.includes('available for pre-order')) { 931 return 'preOrder'; 932 } 933 if (lower.includes('backorder') || lower.includes('due to high demand') || lower.includes('extended')) { 934 if (this.shouldShowBackorderDateOnly()) return 'backorderDateOnly'; 935 return 'backordered'; 936 } 937 if (lower.includes('partner')) { 938 return 'partnerFulfilled'; 939 } 940 return 'inStock'; 941 }, 942 943 /** Check if user is in any "Hide Backorder" A/B test variation @returns {boolean} */ 944 isHideBackorderVariation: function() { 945 var cl = document.documentElement.classList; 946 return cl.contains('hide-backorder-msg') || cl.contains('hide-backorder-date-only'); 947 }, 948 949 shouldShowBackorderDateOnly: function() { 950 return document.documentElement.classList.contains('hide-backorder-date-only'); 951 }, 952 953 shouldHideBackorderEntirely: function() { 954 return document.documentElement.classList.contains('hide-backorder-msg'); 955 }, 956 957 isGiftCard: function() { 958 const styleInput = document.querySelector('input#style[value="GIFT"]'); 959 return !!styleInput; 960 }, 961 962 isGiftCardItem: function(sku, title) { 963 if (sku && (sku.toUpperCase() === 'GIFT' || sku.toUpperCase().startsWith('GIFT '))) return true; 964 if (title && title.toLowerCase().includes('gift card')) return true; 965 return false; 966 }, 967 968 /** Check if stock message should be enhanced by EDD/ESD UI. @param {string} stockMsg @returns {boolean} */ 969 isEnhanceableStockMessage: function(stockMsg) { 970 if (!stockMsg || typeof stockMsg !== 'string') return false; 971 const statusType = this.detectStockStatus(stockMsg); 972 const statusConfig = window.stockStatusConfig || {}; 973 974 if (statusType !== 'inStock') { 975 return !!statusConfig[statusType]; 976 } 977 978 return !!statusConfig.inStock && stockMsg.toLowerCase().includes('in stock'); 979 }, 980 981 /** Extract displayed action/date from existing stock message markup. @param {string} stockMsg @returns {{actionText: string, dateText: string}|null} */ 982 extractDisplayedActionDate: function(stockMsg) { 983 if (!stockMsg || typeof stockMsg !== 'string') return null; 984 const plainText = stockMsg 985 .replace(/<[^>]*>/g, ' ') 986 .replace(/\s+/g, ' ') 987 .trim(); 988 989 const actionDateMatch = plainText.match(/\b(?:Est\.\s*to\s*(arrive|ship)|(Arrives|Ships))\s+([A-Za-z]{3}\s+\d{1,2}(?:\s*-\s*(?:[A-Za-z]{3}\s+)?\d{1,2})?)/i); 990 if (!actionDateMatch) return null; 991 992 const actionToken = (actionDateMatch[1] || actionDateMatch[2] || '').toLowerCase(); 993 994 return { 995 actionText: actionToken.includes('arrive') ? 'Arrives' : 'Ships', 996 dateText: actionDateMatch[3].replace(/\s*-\s*/g, ' - ').trim() 997 }; 998 }, 999 1000 /** Normalize date to midnight for date-only comparison. @param {Date} date @returns {Date} */ 1001 normalizeToDateOnlyMidnight: function(date) { 1002 const normalized = new Date(date); 1003 normalized.setHours(0, 0, 0, 0); 1004 return normalized; 1005 }, 1006 1007 /** Check whether a date is before today in EST calendar time. @param {Date|null} date @returns {boolean} */ 1008 isDatePastToday: function(date) { 1009 if (!(date instanceof Date) || isNaN(date.getTime())) return false; 1010 const target = this.normalizeToDateOnlyMidnight(date); 1011 const todayEST = this.getTodayEST(); 1012 const today = this.normalizeToDateOnlyMidnight(new Date( 1013 todayEST.getFullYear(), 1014 todayEST.getMonth(), 1015 todayEST.getDate() 1016 )); 1017 return target < today; 1018 }, 1019 1020 /** Parse display date text into start and end Date objects. Handles single dates ("Feb 12") and ranges ("Feb 12 - 13", "Feb 12 - Mar 1"). @param {string} dateText @returns {{ start: Date|null, end: Date|null }} */ 1021 parseDisplayDateRange: function(dateText) { 1022 if (!dateText || typeof dateText !== 'string') return { start: null, end: null }; 1023 1024 const normalizedText = dateText.replace(/[ââ]/g, '-').replace(/\s+/g, ' ').trim(); 1025 if (!normalizedText) return { start: null, end: null }; 1026 1027 const monthMap = { 1028 jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, 1029 jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 1030 }; 1031 1032 const currentYear = this.getTodayEST().getFullYear(); 1033 1034 const parseMonthDay = (input, fallbackYear = currentYear) => { 1035 const match = input.match(/([A-Za-z]{3,9})\s+(\d{1,2})(?:,\s*(\d{4}))?/); 1036 if (!match) return null;
1037 const month = monthMap[match[1].slice(0, 3).toLowerCase()]; 1038 const day = parseInt(match[2], 10); 1039 const year = match[3] ? parseInt(match[3], 10) : fallbackYear; 1040 if (month === undefined || isNaN(day) || isNaN(year)) return null; 1041 return new Date(year, month, day); 1042 }; 1043 1044 if (!normalizedText.includes('-')) { 1045 const single = parseMonthDay(normalizedText, currentYear); 1046 return { start: single, end: single }; 1047 } 1048 1049 const parts = normalizedText.split('-'); 1050 const leftText = (parts[0] || '').trim(); 1051 const rightText = (parts.slice(1).join('-') || '').trim(); 1052 1053 const leftDate = parseMonthDay(leftText, currentYear); 1054 if (!leftDate) return { start: null, end: null }; 1055 1056 let rightDate = parseMonthDay(rightText, leftDate.getFullYear()); 1057 1058 if (!rightDate) { 1059 const rightDayMatch = rightText.match(/^(\d{1,2})(?:,\s*(\d{4}))?$/); 1060 if (!rightDayMatch) return { start: leftDate, end: leftDate }; 1061 const day = parseInt(rightDayMatch[1], 10); 1062 const year = rightDayMatch[2] ? parseInt(rightDayMatch[2], 10) : leftDate.getFullYear(); 1063 if (isNaN(day) || isNaN(year)) return { start: leftDate, end: leftDate }; 1064 rightDate = new Date(year, leftDate.getMonth(), day); 1065 } 1066 1067 if (rightDate < leftDate && !/,\s*\d{4}\s*$/.test(rightText)) { 1068 rightDate.setFullYear(rightDate.getFullYear() + 1); 1069 } 1070 1071 return { start: leftDate, end: rightDate }; 1072 }, 1073 1074 /** Parse display date text and return the ending date. Thin wrapper over parseDisplayDateRange. @param {string} dateText @returns {Date|null} */ 1075 parseDisplayDateTextEndDate: function(dateText) { 1076 return this.parseDisplayDateRange(dateText).end; 1077 }, 1078 1079 /** Check whether a display date text resolves to a past date. @param {string} dateText @returns {boolean} */ 1080 isDisplayDateTextPastToday: function(dateText) { 1081 const endDate = this.parseDisplayDateTextEndDate(dateText); 1082 return this.isDatePastToday(endDate); 1083 }, 1084 1085 /** Check whether an ESD value (MM/DD/YYYY) is in the past. @param {string} esdValue @returns {boolean} */ 1086 isESDValuePastToday: function(esdValue) { 1087 const esdDate = this.parseESDDateValue(esdValue); 1088 return this.isDatePastToday(esdDate); 1089 }, 1090 1091 /** Get configured label text for stock row. @param {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} statusType @param {'inline'|'block'} displayMode @param {'esd'|'edd'} variationMode @returns {string} */ 1092 getEnhancedStatusLabel: function(statusType, displayMode = 'inline', variationMode = 'edd') { 1093 const stockMsgText = this.config?.stockMsgDisplay?.stockMsgText || {}; 1094 const labelsByMode = stockMsgText[displayMode] || {}; 1095 const labels = labelsByMode[variationMode] || labelsByMode.edd || labelsByMode.esd || {}; 1096 return labels[statusType] || window.stockStatusConfig?.[statusType]?.label || window.stockStatusConfig?.inStock?.label || ''; 1097 }, 1098 1099 /** CSS variable map: statusType â pill color CSS var name */ 1100 _pillColorVars: { inStock: '--edd-pill-in-stock', partnerFulfilled: '--edd-pill-partner-fulfilled', backordered: '--edd-pill-backordered', backorderDateOnly: '--edd-pill-in-stock', preOrder: '--edd-pill-pre-order' }, 1101 /** CSS variable map: statusType â info icon color CSS var name */ 1102 _iconColorVars: { inStock: '--edd-icon-in-stock', partnerFulfilled: '--edd-icon-partner-fulfilled', backordered: '--edd-icon-backordered', backorderDateOnly: '--edd-icon-in-stock', preOrder: '--edd-icon-pre-order' }, 1103 1104 /** Get EDD/ESD pill color by status type (reads from CSS custom property). @param {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} statusType @returns {string} */ 1105 getVariationPillColor: function(statusType) { 1106 const varName = this._pillColorVars[statusType] || this._pillColorVars.inStock; 1107 return getEDDCSSColor(varName); 1108 }, 1109 1110 /** Get EDD/ESD info icon color by status type (reads from CSS custom property). @param {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} statusType @returns {string} */ 1111 getVariationInfoIconColor: function(statusType) { 1112 const varName = this._iconColorVars[statusType] || this._iconColorVars.inStock; 1113 return getEDDCSSColor(varName); 1114 }, 1115 1116 /** Convert action verb into A/B display copy. @param {string} actionText @returns {string} */ 1117 getEstimatedActionText: function(actionText) { 1118 if (!actionText) return ''; 1119 const lower = actionText.toLowerCase(); 1120 if (lower.includes('est. to')) return actionText; 1121 if (lower.includes('arrive')) return 'Est. to arrive'; 1122 if (lower.includes('ship')) return 'Est. to ship'; 1123 return actionText; 1124 }, 1125
1126 /** Build enhanced stock message row. @param {object} stockInfo @param {string} statusLabel @param {string} actionText @param {string} dateText @param {string} tooltipText @param {string} [wrapperClass] @param {Date|null} [drawerStartDate] - Optional start date for config-driven drawer message @param {Date|null} [drawerEndDate] - Optional end date for config-driven drawer message @returns {string} */ 1127 buildEnhancedStockMessage: function(stockInfo, statusLabel, actionText, dateText, tooltipText, wrapperClass = 'stock-message__wrapper stock-message__wrapper--edd', drawerStartDate = null, drawerEndDate = null) { 1128 const hasDate = !!(actionText && dateText); 1129 // Drawer message: EDD/ESD always use config templates, control uses backend tooltip 1130 const variationMode = this.getABVariationMode(); 1131 const resolvedTip = (variationMode === 'edd' || variationMode === 'esd') 1132 ? this.getDrawerMessage(stockInfo.type, variationMode, drawerStartDate, drawerEndDate) 1133 : tooltipText; 1134 return `<span class="${wrapperClass}" data-stock-type="${stockInfo.type}" data-stock-info-trigger data-tip="${resolvedTip}" tabindex="0" role="button" aria-label="Stock information"> 1135 <span class="stock-message__dot">${this.getColoredDotSVG()}</span>
1136 <span class="stock-message__status">${statusLabel}</span> 1137 ${hasDate ? `<span class="stock-message__sep">\u2022</span> 1138 <span class="stock-message__text">${this.getEstimatedActionText(actionText)} <strong>${dateText}</strong> <span class="stock-message__info-icon" aria-hidden="true">${this.getInfoIconSVG('inline')}</span></span>` : 1139 `<span class="stock-message__info-icon" aria-hidden="true">${this.getInfoIconSVG('inline')}</span>`} 1140 </span>`; 1141 }, 1142 1143 /** Build variation-styled pill without dates (used when dates are past or unavailable). @param {string} stockMsg - Original stock message text for status detection @param {string} [wrapperClass] @returns {string} */ 1144 buildDatelessVariationPill: function(stockMsg, wrapperClass = 'stock-message__wrapper stock-message__wrapper--edd') { 1145 const stockInfo = this.getStockStatusInfo(stockMsg); 1146 if (stockInfo.type === 'backorderDateOnly') return ''; 1147 const variationMode = this.getABVariationMode(); 1148 const statusLabel = this.getEnhancedStatusLabel(stockInfo.type, 'inline', variationMode); 1149 return this.buildEnhancedStockMessage(stockInfo, statusLabel, '', '', '', wrapperClass); 1150 }, 1151 1152 /** Shared PDP block-format pill. Omits date row when actionText/dateText are empty. Includes location tracker for EDD variation. @param {string} originalStockMsg @param {string} [actionText] @param {string} [dateText] @param {Date} [drawerStartDate] @param {Date} [drawerEndDate] @returns {string} */ 1153 buildPDPBlockPill: function(originalStockMsg, actionText = '', dateText = '', drawerStartDate = null, drawerEndDate = null) { 1154 const hasDate = !!(actionText && dateText); 1155 const variationMode = this.getABVariationMode(); 1156 const statusType = this.detectStockStatus(originalStockMsg); 1157 if (statusType === 'backorderDateOnly' && !hasDate) return '<div style="display:none"></div>'; 1158 const stockInfo = this.getStockStatusInfo(originalStockMsg); 1159 const statusLabel = this.getEnhancedStatusLabel(statusType, 'block', variationMode); 1160 const drawerTip = this.getDrawerMessage(statusType, variationMode, drawerStartDate, drawerEndDate); 1161 // EDD variation: include location tracker shipping section (arrival dates depend on user zip) 1162 let shippingSectionHTML = ''; 1163 if (variationMode === 'edd') { 1164 const { zip: userZip, city: userCity, state: userState } = this.getUserLocationData(); 1165 const cityStateMsg = (userCity && userState) 1166 ? `${userCity}, ${userState}` 1167 : 'Enter a zip to set your location'; 1168 shippingSectionHTML = this.getEDDShippingSectionHTML(userZip, cityStateMsg); 1169 } 1170 return ` 1171 <div class="b5-active pdp-edd" role="status" aria-live="polite"> 1172 <div class="pdp__status-pill" data-stock-type="${stockInfo.type}" data-stock-info-trigger data-tip="${drawerTip}" tabindex="0" role="button" aria-label="Stock information: ${statusLabel}"> 1173 <span class="pdp-edd__dot" aria-hidden="true">${this.getColoredDotSVG()}</span> 1174 <div class="pdp-edd__content"> 1175 <div class="pdp-edd__row"> 1176 <span class="pdp-edd__label">${statusLabel}</span> 1177 ${hasDate ? `<span class="pdp-edd__separator">\u2022</span> 1178 <span class="pdp-edd__action">${this.getEstimatedActionText(actionText)}</span> 1179 <span class="pdp-edd__date" data-target="eddDeliveryDate"><strong>${dateText}</strong></span>` : ''} 1180 </div> 1181 ${shippingSectionHTML} 1182 </div> 1183 <span class="pdp__status-pill-icon pdp-edd__info-icon" aria-hidden="true">${this.getInfoIconSVG('block')}</span> 1184 </div> 1185 </div>`; 1186 }, 1187 1188 /** Get stock info icon SVG. @param {'block'|'inline'} sizeMode @returns {string} */ 1189 getInfoIconSVG: function(sizeMode = 'inline') { 1190 const iconSize = sizeMode === 'block' ? 16 : 14; 1191 return `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 16 16" fill="none" focusable="false" aria-hidden="true"> 1192 <path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.3187
11925 14.6667 8.00065 14.6667Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 1193 <path d="M8 10.6667V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 1194 <path d="M8 5.33337H8.00583" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 1195 </svg>`; 1196 }, 1197 1198 /** Generate colored dot SVG. Uses currentColor so CSS controls the fill via parent color property. @param {string} [color] - Optional hex override (for non-variation contexts) @returns {string} SVG HTML */ 1199 getColoredDotSVG: function(color) { 1200 const fill = color || 'currentColor'; 1201 return `<svg class="pdp__status-pill-dot" width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg"> 1202 <circle cx="4" cy="4" r="4" fill="${fill}"/> 1203 </svg>`; 1204 }, 1205 1206 /** Get stock status label and color from centralized config @param {string} stockMsg @returns {{label: string, color: string, dotSVG: string}} */ 1207 getStockStatusInfo: function(stockMsg) { 1208 const statusType = this.detectStockStatus(stockMsg); 1209 const config = window.stockStatusConfig[statusType]; 1210 return { 1211 type: statusType, 1212 label: config.label, 1213 color: config.color, 1214 dotSVG: this.getColoredDotSVG(config.color) 1215 }; 1216 }, 1217 1218 // ===== PDP SHARED TEMPLATE HELPERS ===== 1219 1220 /** Build the EDD shipping section HTML with location tracker popup. @param {string} userZip @param {string} cityStateMsg @returns {string} */ 1221 getEDDShippingSectionHTML: function(userZip, cityStateMsg) { 1222 return ` 1223 <div class="pdp-edd__shipping"> 1224 <span class="pdp-edd__shipping-label">Standard shipping</span> 1225 <span class="pdp-edd__shipping-separator">\u2022</span> 1226 <div class="pdp-edd__location" data-target="locationTrackerTrigger"> 1227 <span class="pdp-edd__shipping-zip" data-target="locationTrackerText" role="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false" aria-label="Change delivery zip code">${userZip || '00000'}</span> 1228 <div class="location-tracker-popup position-absolute p-12 rounded-md shadow bg-light border" data-target="locationTrackerPopup" role="dialog" aria-modal="true" style="display: none;"> 1229 <div class="d-flex flex-column gap-12"> 1230 <div class="d-flex justify-content-between gap-6"> 1231 <div class="d-flex flex-column"> 1232 <span class="fs-6 fw-bold text-normal roboto">Deliver to</span> 1233 <p class="location-tracker-state-message roboto text-muted" data-target="locationTrackerLocationMsg">${cityStateMsg}</p> 1234 </div> 1235 <div class="location-tracker-close pointer" data-target="locationTrackerClosePopup" role="button" tabindex="0" aria-label="Close location tracker"> 1236 <svg class="feather-icon" style="width: 20px;height: 20px;" aria-hidden="true"> 1237 <use href="/images/icons/feather-sprite.svg#x"></use> 1238 </svg> 1239 </div> 1240 </div> 1241 <div> 1242 <div class="location-tracker-input-container d-flex rounded overflow-hidden align-items-center"> 1243 <div class="position-relative w-100"> 1244 <input type="text" class="form-control border-0 shadow-none w-100" placeholder="${userZip || 'Enter zip code'}" aria-label="Enter zip code"> 1245 <div class="location-tracker-err-icon position-absolute" style="display:none" data-target="locationTrackerErrorIcon"> 1246 <svg class="feather-icon" style="width: 20px; height: 18px;" aria-hidden="true"> 1247 <use href="/images/icons/feather-sprite.svg#alert-triangle"></use> 1248 </svg> 1249 </div> 1250 </div> 1251 <button class="btn btn-primary" type="button" aria-label="Save zip code"> 1252 <p class="fs-6 text-capitalize fw-regular">Save</p> 1253 </button> 1254 </div> 1255 <p class="location-tracker-error-message roboto pt-2" style="visibility: hidden;" data-target="locationTrackerErrorMsg" aria-live="polite"></p> 1256 </div> 1257 </div> 1258 </div> 1259 </div> 1260 </div>`; 1261 }, 1262 1263 // ===== TRANSIT DATA LOADING ===== 1264 1265 /** Ensure transit data is loaded â single source of truth for CSV fetch + parse + cache. Reads config from EDDHandler when available. Stores parsed {zip: transitDays} map in memory + localStorage. @returns {Promise<boolean>} */ 1266 ensureTransitDataLoaded: async function() { 1267 const cfg = window.eddHandler?.config; 1268 const cacheKey = cfg?.carrierData?.fedex?.key || 'fedexTransitData'; 1269 const cacheDuration = cfg?.cache?.duration || (1000 * 60 * 60 * 24 * 7); 1270 const csvPath = cfg?.carrierData?.fedex?.csvPath || '/assets/fulfillment/delivery/edd-csv-imports/fedex-transit-times-11-26-25.csv'; 1271 1272 try { 1273 const cached = localStorage.getItem(cacheKey); 1274 if (cached) { 1275 const parsedCache = JSON.parse(cached); 1276 if (parsedCache.timestamp && (Date.now() - parsedCache.timestamp) < cacheDuration) { 1277 if (parsedCache.data) this._transitDataCache = parsedCache.data; 1278 return true; 1279 } 1280 } 1281 1282 const response = await fetch(csvPath); 1283 if (!response.ok) throw new Error('Failed to fetch CSV'); 1284 1285 const csvText = await response.text(); 1286 const lines = csvText.split(/\r?\n/); 1287 const data = {}; 1288 1289 // Parse CSV (skip header row) 1290 // Columns: 0=Origin Zip, 1=Destination Zip, 2=State, 3=Zone, 4
1290=Ground Transit, 5=Population 1291 for (let i = 1; i < lines.length; i++) { 1292 const line = lines[i].trim(); 1293 if (!line) continue; 1294 const columns = line.split(','); 1295 if (columns.length < 5) continue; 1296 1297 const destinationZip = columns[1]?.trim(); 1298 const transitValue = columns[4]?.trim(); 1299 const groundTransit = parseInt(transitValue, 10); 1300 1301 if (destinationZip && /^\d{5}$/.test(destinationZip) && !isNaN(groundTransit) && groundTransit >= 0) { 1302 data[destinationZip] = groundTransit; 1303 } 1304 } 1305 1306 this._transitDataCache = data; 1307 localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: Date.now() })); 1308 return true; 1309 } catch (error) { 1310 return false; 1311 } 1312 }, 1313 1314 /** Get base delivery data from PDP calculation @returns {object|null} */ 1315 getBaseDeliveryData: function() { 1316 try { 1317 const cached = localStorage.getItem('currentDeliveryEstimate'); 1318 if (!cached) return null; 1319 1320 const parsedCache = JSON.parse(cached); 1321 return parsedCache.data; 1322 } catch (error) { 1323 return null; 1324 } 1325 }, 1326 1327 /** Get user's zip code. On checkout, reads from SISZIP field; otherwise delegates to eddHandler. @returns {string|null} */ 1328 getUserZipCode: function() { 1329 // On checkout page, read directly from delivery zip field 1330 const checkoutZipInput = document.getElementById('SISZIP'); 1331 if (checkoutZipInput && checkoutZipInput.value) { 1332 const checkoutZip = checkoutZipInput.value.trim(); 1333 if (/^\d{5}$/.test(checkoutZip)) { 1334 return checkoutZip; 1335 } 1336 } 1337 1338 // Delegate to eddHandler for localStorage lookup 1339 if (window.eddHandler && window.eddHandler.getUserZipCode) { 1340 return window.eddHandler.getUserZipCode(); 1341 } 1342 1343 // Fallback if eddHandler not initialized 1344 try { 1345 const locationData = localStorage.getItem('userLocationData'); 1346 if (locationData) { 1347 const location = JSON.parse(locationData); 1348 return location.zip || null; 1349 } 1350 } catch (error) { 1351 // Silent fail 1352 } 1353 return null; 1354 }, 1355 1356 /** Get user location data (zip, city, state) from localStorage. @returns {{zip: string, city: string, state: string}} */ 1357 getUserLocationData: function() { 1358 const result = { zip: '', city: '', state: '' }; 1359 try { 1360 const locationData = localStorage.getItem('userLocationData'); 1361 if (locationData) { 1362 const location = JSON.parse(locationData); 1363 result.zip = location.zip || ''; 1364 result.city = location.city || ''; 1365 result.state = location.state || ''; 1366 } 1367 } catch (e) { /* silent */ } 1368 return result; 1369 }, 1370 1371 /** Get FedEx transit days for a zip code. Tries eddHandler â localStorage cache in order. @param {string} zip - 5-digit zip code @param {boolean} [triggerLoad=true] - If true, triggers async CSV load when data unavailable @returns {number|null} Transit days or null if unavailable */ 1372 getTransitDaysForZip: function(zip, triggerLoad = true) { 1373 if (!zip) return null; 1374 const cleanZip = zip.replace(/\D/g, '').slice(0, 5); 1375 if (!cleanZip) return null; 1376 1377 // Try eddHandler in-memory data first 1378 if (window.eddHandler && window.eddHandler.fedexTransitData) { 1379 const transit = window.eddHandler.fedexTransitData[cleanZip]; 1380 if (transit !== null && transit !== undefined) return transit; 1381 } 1382 1383 // Try parsed cache (avoids re-parsing localStorage JSON on every call) 1384 if (this._transitDataCache) { 1385 const transit = this._transitDataCache[cleanZip]; 1386 if (transit !== null && transit !== undefined) { 1387 return transit; 1388 } 1389 } 1390 1391 // Try localStorage cache â parse once, store in memory for subsequent lookups 1392 try { 1393 const cached = localStorage.getItem('fedexTransitData'); 1394 if (cached) { 1395 const parsedCache = JSON.parse(cached); 1396 if (parsedCache.data) { 1397 this._transitDataCache = parsedCache.data; 1398 } 1399 const transit = parsedCache.data?.[cleanZip]; 1400 if (transit !== null && transit !== undefined) return transit; 1401 } 1402 } catch (e) { /* silent */ } 1403 1404 // Trigger async load if requested 1405 if (triggerLoad) { 1406 this.ensureTransitDataLoaded(); 1407 } 1408 1409 return null; 1410 }, 1411 1412 /** Get slowest delivery date across all cart items @param {object} cartState @returns {object|null} */
1413 getSlowestDelivery: function(cartState) { 1414 if (!cartState || !cartState.items) return null; 1415 1416 let slowest = null; 1417 let slowestDate = null; 1418 1419 Object.keys(cartState.items).forEach(cdno => { 1420 const item = cartState.items[cdno]; 1421 if (!item.deliveryEstimate || !item.isInStock) return; 1422 1423 const latestDate = new Date(item.deliveryEstimate.latestDate); 1424 1425 if (!slowestDate || latestDate > slowestDate) { 1426 slowestDate = latestDate; 1427 slowest = item.deliveryEstimate; 1428 } 1429 }); 1430 1431 return slowest; 1432 }, 1433 1434 /** Get delivery estimate for specific cart item @param {string} cdno @returns {object|null} */ 1435 getItemDelivery: function(cdno) { 1436 const cartState = this.getCartState(); 1437 if (!cartState || !cartState.items || !cartState.items[cdno]) { 1438 return null; 1439 } 1440 1441 return cartState.items[cdno].deliveryEstimate; 1442 }, 1443 1444 /** Process cart data and update state @param {Array} cartItems @returns {object} */ 1445 processCartData: function(cartItems) { 1446 const cartChanged = this.validateCartState(cartItems); 1447 1448 if (cartChanged) { 1449 const newState = this.initializeCartState(cartItems);
1450 this.saveCartState(newState); 1451 return newState; 1452 } 1453 1454 return this.getCartState(); 1455 }, 1456 1457 /** Add business days (excludes weekends) @param {Date} startDate @param {number} businessDays @returns {Date} */ 1458 addBusinessDays: function(startDate, businessDays) { 1459 if (window.eddHandler) { 1460 return window.eddHandler.addBusinessDays(startDate, businessDays); 1461 } 1462 1463 // Fallback without holiday checking 1464 const result = new Date(startDate); 1465 let daysAdded = 0; 1466 while (daysAdded < businessDays) { 1467 result.setDate(result.getDate() + 1); 1468 if (result.getDay() !== 0 && result.getDay() !== 6) { 1469 daysAdded++; 1470 } 1471 } 1472 return result; 1473 }, 1474 1475 // ===== PDP UI RENDERING ===== 1476 1477 /** Render EDD/ESD UI for PDP @param {object} options @param {string} options.mode - 'edd' or 'esd' @param {string} [options.esdValue] - ESD date string (MM/DD/YYYY) @returns {HTMLElement|null} */ 1478 renderPDPDeliveryUI: function(options = { mode: 'esd' }) { 1479 if (this.isGiftCard()) return null; 1480 1481 const isESD = options.mode === 'esd'; 1482 const stockInfo = this.getStockStatusInfo('In Stock'); 1483 const statusLabel = this.getEnhancedStatusLabel('inStock', 'block', isESD ? 'esd' : 'edd'); 1484 1485 let dateText = ''; 1486 let actionLabel = ''; 1487 let drawerStartDate = null; 1488 let drawerEndDate = null; 1489 1490 if (isESD) { 1491 let esdValue = options.esdValue || ''; 1492 1493 if (!esdValue) { 1494 const esdInput = document.querySelector('#SIESDTq') || document.querySelector('#SIESDTd'); 1495 if (esdInput && esdInput.value) { 1496 esdValue = esdInput.value.trim(); 1497 } 1498 } 1499 1500 if (!esdValue) { 1501 return null; 1502 } 1503 1504 const esdDate = this.parseESDDateValue(esdValue); 1505 if (!esdDate) return null; 1506 1507 // Apply cutoff logic for ship date (weekends, holidays, 2PM EST cutoff) 1508 const shipDate = this.calculateShipDate(esdDate); 1509 dateText = this.formatShipRange(shipDate); 1510 actionLabel = 'Ships'; 1511 // Track dates for drawer message 1512 drawerStartDate = shipDate; 1513 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1514 drawerEndDate = this.addBusinessDaysSimple(shipDate, bufferBusinessDays); 1515 } else { 1516 const deliveryInfo = this.getBaseDeliveryData(); 1517 if (!deliveryInfo) { 1518 return null; 1519 } 1520 dateText = deliveryInfo.rangeFormatted; 1521 actionLabel = 'Arrives'; 1522 // Track dates for drawer message 1523 drawerStartDate = deliveryInfo.earliestDelivery instanceof Date ? deliveryInfo.earliestDelivery : (deliveryInfo.earliestDelivery ? new Date(deliveryInfo.earliestDelivery) : null); 1524 drawerEndDate = deliveryInfo.latestDelivery instanceof Date ? deliveryInfo.latestDelivery : (deliveryInfo.latestDelivery ? new Date(deliveryInfo.latestDelivery) : null); 1525 } 1526 1527 actionLabel = this.getEstimatedActionText(actionLabel); 1528 1529 const container = document.createElement('div'); 1530 container.className = 'b5-active pdp-edd'; 1531 container.setAttribute('role', 'status'); 1532 container.setAttribute('aria-live', 'polite'); 1533 1534 // Build shipping section HTML only for EDD mode 1535 const { zip: userZip, city: userCity, state: userState } = this.getUserLocationData(); 1536 1537 const cityStateMsg = (userCity && userState) 1538 ? `${userCity}, ${userState}` 1539 : 'Enter a zip to set your location'; 1540 1541 const shippingSection = isESD ? '' : this.getEDDShippingSectionHTML(userZip, cityStateMsg); 1542 1543 // New unified design: [dot] Status ⢠Ships/Arrives DateRange 1544 // EDD only: second line is Standard shipping ⢠zip 1545 // Wrapped in pdp__status-pill for border styling, data-stock-info-trigger opens drawer 1546 const drawerMessage = this.getDrawerMessage('inStock', isESD ? 'esd' : 'edd', drawerStartDate, drawerEndDate); 1547 container.innerHTML = ` 1548 <div class="pdp__status-pill" data-stock-type="${stockInfo.type}" data-stock-info-trigger data-tip="${drawerMessage}" tabindex="0" role="button" aria-label="Stock information"> 1549 <div class="pdp-edd__content"> 1550 <div class="pdp-edd__row">
1551 <span class="pdp-edd__dot" aria-hidden="true">${this.getColoredDotSVG()}</span> 1552 <span class="pdp-edd__label">${statusLabel}</span> 1553 <span class="pdp-edd__separator">\u2022</span> 1554 <span class="pdp-edd__action">${actionLabel}</span> 1555 <span class="pdp-edd__date" data-target="eddDeliveryDate"><strong>${dateText}</strong></span> 1556 </div> 1557 ${shippingSection} 1558 </div> 1559 <span class="pdp__status-pill-icon pdp-edd__info-icon" aria-hidden="true">${this.getInfoIconSVG('block')}</span> 1560 </div> 1561 `; 1562 1563 return container; 1564 }, 1565 1566 // ===== A/B TEST HELPERS ===== 1567 1568 /** Resolve active Varify experiment config for current brand+environment. @returns {{experimentId:number,eddVariationId:number,esdVariationId:number}|null} */ 1569 getABExperimentConfig: function() { 1570 const configuredABTests = this.config?.abTestVariations || eddStockStatusConfig?.abTestVariations || {}; 1571 const brandName = typeof getBrandName === 'function' ? getBrandName() : ''; 1572 const brandKey = brandName === 'U.S. Patriot' ? 'usp' : 'galls'; 1573 1574 const baseUrl = typeof getBaseUrl === 'function' ? getBaseUrl() : window.location.origin; 1575 const envKey = baseUrl.includes('stage') ? 'stage' : 'prod'; 1576 1577 return configuredABTests?.[brandKey]?.[envKey] || configuredABTests?.galls?.stage || null; 1578 }, 1579 1580 /** Resolve active delivery variation mode from Varify experiment. @returns {'edd'|'esd'|'control'} */ 1581 getABVariationMode: function() { 1582 return 'edd'; // A/B test concluded â EDD always active 1583 }, 1584 1585 /** Check if user is in EDD A/B test variation @returns {boolean} */ 1586 isEDDABTestVariation: function() { 1587 return this.getABVariationMode() === 'edd'; 1588 }, 1589 1590 /** Check if user is in ESD A/B test variation @returns {boolean} */ 1591 isESDABTestVariation: function() { 1592 return this.getABVariationMode() === 'esd'; 1593 }, 1594 1595 /** Calculate delivery estimate on-demand using ESD from page and cached transit data @returns {object|null} Delivery info with rangeFormatted */ 1596 calculateDeliveryOnDemand: function() { 1597 // Get user zip 1598 const userZip = this.getUserZipCode(); 1599 if (!userZip) return null; 1600 1601 // Get ESD from page hidden field (check QV field first for quickview context) 1602 const esdInput = document.querySelector('#SIESDTq') || document.querySelector('#SIESDTd'); 1603 if (!esdInput || !esdInput.value) return null; 1604 1605 const esdValue = esdInput.value.trim(); 1606 if (!esdValue) return null; 1607 1608 const esdDate = this.parseESDDateValue(esdValue); 1609 if (!esdDate || this.isDatePastToday(esdDate)) return null; 1610 1611 // Get transit days via shared helper 1612 const transitDays = this.getTransitDaysForZip(userZip); 1613 if (transitDays === null) return null; 1614 1615 // Apply ship date cutoff (2PM EST, weekends, holidays) 1616 const shipDate = this.calculateShipDate(esdDate); 1617 // Calculate delivery dates from adjusted ship date 1618 const earliestDelivery = this.addBusinessDays(shipDate, transitDays); 1619 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1620 const latestDelivery = this.addBusinessDays(earliestDelivery, bufferBusinessDays); 1621 1622 const rangeFormatted = this.formatCompactRange(earliestDelivery, latestDelivery); 1623 1624 return { 1625 rangeFormatted: rangeFormatted, 1626 earliestDelivery: earliestDelivery, 1627 latestDelivery: latestDelivery, 1628 transitDays: transitDays, 1629 esdDate: esdDate 1630 }; 1631 }, 1632 1633 /** Get PDP stock message HTML for A/B test variation (called by stock-status.js) @param {string} [esdValue] - Optional ESD date string (MM/DD/YYYY) from Ajax response @returns {string|null} HTML string or null if no data */ 1634 getPDPStockMessageHTML: function(esdValue) { 1635 if (!this.isEDDABTestVariation()) { 1636 return null; 1637 } 1638 1639 let deliveryInfo = null; 1640 1641 // If ESD provided directly, calculate from it 1642 if (esdValue) { 1643 if (this.isESDValuePastToday(esdValue)) { 1644 return this.buildPDPBlockPill('In Stock'); 1645 } 1646 const userZip = this.getUserZipCode(); 1647 if (userZip) { 1648 deliveryInfo = this.calculateEDDFromESD(esdValue, userZip); 1649 } 1650 } 1651 1652 // Only use cache/on-demand fallbacks when ESD token exists (no-options product or options already selected). 1653 // Without esdValue, fallbacks could return stale data from a previously viewed product. 1654 if (esdValue) { 1655 if (!deliveryInfo || !deliveryInfo.rangeFormatted) { 1656 deliveryInfo = this.getBaseDeliveryData(); 1657 } 1658 if (!deliveryInfo || !deliveryInfo.rangeFormatted) { 1659 deliveryInfo = this.calculateDeliveryOnDemand();
1660 } 1661 } 1662 1663 if (!deliveryInfo || !deliveryInfo.rangeFormatted) { 1664 // No delivery data available (no zip or transit data) â show dateless variation pill 1665 // with location tracker so user can set zip and trigger recalculation 1666 if (esdValue) { 1667 return this.buildPDPBlockPill('In Stock'); 1668 } 1669 return null; 1670 } 1671 1672 if (this.isDisplayDateTextPastToday(deliveryInfo.rangeFormatted)) { 1673 return this.buildPDPBlockPill('In Stock'); 1674 } 1675 1676 // Cache the delivery info so updateDynamicEDDUI can access ESD when user changes zip 1677 try { 1678 const cacheObject = { 1679 data: { 1680 rangeFormatted: deliveryInfo.rangeFormatted, 1681 earliestDelivery: deliveryInfo.earliestDelivery ? deliveryInfo.earliestDelivery.toISOString() : null, 1682 latestDelivery: deliveryInfo.latestDelivery ? deliveryInfo.latestDelivery.toISOString() : null, 1683 transitDays: deliveryInfo.transitDays, 1684 esdDate: deliveryInfo.esdDate ? deliveryInfo.esdDate.toISOString() : null 1685 }, 1686 timestamp: Date.now() 1687 }; 1688 localStorage.setItem('currentDeliveryEstimate', JSON.stringify(cacheObject)); 1689 } catch (e) { 1690 // Silent fail 1691 } 1692 1693 const { zip: userZip, city: userCity, state: userState } = this.getUserLocationData(); 1694 1695 const cityStateMsg = (userCity && userState) 1696 ? `${userCity}, ${userState}` 1697 : 'Enter a zip to set your location'; 1698 1699 // Get stock status info for colored dot 1700 const stockInfo = this.getStockStatusInfo('In Stock'); 1701 const statusLabel = this.getEnhancedStatusLabel('inStock', 'block', 'edd'); 1702 1703 // Resolve drawer message from config with delivery dates 1704 const drawerStartDate = deliveryInfo.earliestDelivery instanceof Date ? deliveryInfo.earliestDelivery : (deliveryInfo.earliestDelivery ? new Date(deliveryInfo.earliestDelivery) : null); 1705 const drawerEndDate = deliveryInfo.latestDelivery instanceof Date ? deliveryInfo.latestDelivery : (deliveryInfo.latestDelivery ? new Date(deliveryInfo.latestDelivery) : null); 1706 const drawerMessage = this.getDrawerMessage('inStock', 'edd', drawerStartDate, drawerEndDate); 1707 1708 // Return HTML string - New unified design with shipping inside border 1709 const shippingSectionHTML = this.getEDDShippingSectionHTML(userZip, cityStateMsg); 1710 return ` 1711 <div class="b5-active pdp-edd" role="status" aria-live="polite"> 1712 <div class="pdp__status-pill" data-stock-type="${stockInfo.type}" data-stock-info-trigger data-tip="${drawerMessage}" tabindex="0" role="button" aria-label="Stock information"> 1713 <div class="pdp-edd__content"> 1714 <div class="pdp-edd__row"> 1715 <span class="pdp-edd__dot" aria-hidden="true">${this.getColoredDotSVG()}</span> 1716 <span class="pdp-edd__label">${statusLabel}</span> 1717 <span class="pdp-edd__separator">\u2022</span> 1718 <span class="pdp-edd__action">${this.getEstimatedActionText('Arrives')}</span> 1719 <span class="pdp-edd__date" data-target="eddDeliveryDate"><strong>${deliveryInfo.rangeFormatted}</strong></span> 1720 </div> 1721 ${shippingSectionHTML} 1722 </div> 1723 <span class="pdp__status-pill-icon pdp-edd__info-icon" aria-hidden="true">${this.getInfoIconSVG('block')}</span> 1724 </div> 1725 </div> 1726 `; 1727 }, 1728 1729 /** Get PDP stock message HTML for non-in-stock statuses in active variation. @param {string} originalStockMsg @param {string} [esdValue] @returns {string|null} */ 1730 getPDPNonInStockMessageHTML: function(originalStockMsg, esdValue = null) { 1731 if (this.isGiftCard()) return ''; 1732 1733 const variationMode = this.getABVariationMode(); 1734 if (variationMode !== 'edd' && variationMode !== 'esd') return null; 1735 1736 const statusType = this.detectStockStatus(originalStockMsg); 1737 if (statusType === 'inStock') return null; 1738 1739 if (statusType === 'backordered' && this.isHideBackorderVariation()) { 1740 return '<div style="display:none"></div>'; 1741 } 1742 1743 const displayedActionDate = this.extractDisplayedActionDate(originalStockMsg); 1744 if (displayedActionDate) { 1745 if (this.isDisplayDateTextPastToday(displayedActionDate.dateText)) { 1746 return this.buildPDPBlockPill(originalStockMsg); 1747 } 1748 // EDD variation + extracted "Ships" action: skip extracted date and fall through 1749 // to ESD-based path which properly computes arrival dates for EDD mode
1750 const isExtractedShipAction = displayedActionDate.actionText === 'Ships'; 1751 if (!(variationMode === 'edd' && isExtractedShipAction)) { 1752 const { start, end } = this.parseDisplayDateRange(displayedActionDate.dateText); 1753 return this.buildPDPBlockPill(originalStockMsg, displayedActionDate.actionText, displayedActionDate.dateText, start, end); 1754 } 1755 } 1756 1757 const effectiveESD = esdValue || this.extractESDFromStockMessage(originalStockMsg); 1758 if (this.isESDValuePastToday(effectiveESD)) return this.buildPDPBlockPill(originalStockMsg); 1759 const esdDate = this.parseESDDateValue(effectiveESD); 1760 if (!esdDate) return this.buildPDPBlockPill(originalStockMsg); 1761 1762 // Apply cutoff logic for ship date (weekends, holidays, 2PM EST cutoff) 1763 const shipDate = this.calculateShipDate(esdDate); 1764 1765 let actionText = 'Ships'; 1766 let dateText = this.formatShipRange(shipDate); 1767 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1768 let drawerStartDate = shipDate; 1769 let drawerEndDate = this.addBusinessDaysSimple(shipDate, bufferBusinessDays); 1770 1771 if (variationMode === 'edd') { 1772 const deliveryInfo = this.calculateEDDFromESD(effectiveESD, this.getUserZipCode()); 1773 if (deliveryInfo && deliveryInfo.rangeFormatted) { 1774 actionText = 'Arrives'; 1775 dateText = deliveryInfo.rangeFormatted; 1776 drawerStartDate = deliveryInfo.earliestDelivery; 1777 drawerEndDate = deliveryInfo.latestDelivery; 1778 } 1779 } 1780 1781 return this.buildPDPBlockPill(originalStockMsg, actionText, dateText, drawerStartDate, drawerEndDate); 1782 }, 1783 1784 /** Calculate EDD from a specific ESD value (for cart/minicart items) @param {string} esdValue - ESD in MM/DD/YYYY format @param {string} [userZip] - Optional user zip (defaults to cached) @returns {object|null} Delivery info with rangeFormatted */ 1785 calculateEDDFromESD: function(esdValue, userZip) { 1786 if (!esdValue) return null; 1787 1788 const esdDate = this.parseESDDateValue(esdValue); 1789 if (!esdDate || this.isDatePastToday(esdDate)) return null; 1790 1791 // Get user zip 1792 const zip = userZip || this.getUserZipCode(); 1793 if (!zip) return null; 1794 1795 // Get transit days via shared helper 1796 const transitDays = this.getTransitDaysForZip(zip); 1797 if (!transitDays) return null; 1798 1799 // Apply cutoff logic: adjusts ship date for weekends, holidays, 2PM EST cutoff 1800 const shipDate = this.calculateShipDate(esdDate); 1801 1802 // Calculate delivery dates from adjusted ship date 1803 const earliestDelivery = this.addBusinessDays(shipDate, transitDays); 1804 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1805 const latestDelivery = this.addBusinessDays(earliestDelivery, bufferBusinessDays); 1806 1807 const rangeFormatted = this.formatCompactRange(earliestDelivery, latestDelivery); 1808 1809 return { 1810 rangeFormatted: rangeFormatted, 1811 earliestDelivery: earliestDelivery, 1812 latestDelivery: latestDelivery, 1813 shipDate: shipDate, 1814 transitDays: transitDays, 1815 esdDate: esdDate 1816 }; 1817 }, 1818 1819 /** Get delivery estimate message for minicart/cart @param {string} originalStockMsg @param {boolean} includeTooltip @param {string} [esdValue] - Optional ESD value for per-item calculation @param {string} [sku] - Product SKU for per-item checks @param {string} [title] - Product title for per-item checks @returns {string} */ 1820 getMinicartStockMessage: function(originalStockMsg, includeTooltip = true, esdValue = null, sku = null, title = null) { 1821 if (this.isGiftCardItem(sku, title)) return ''; 1822 1823 if (this.detectStockStatus(originalStockMsg) === 'backordered' && this.isHideBackorderVariation()) { 1824 return ''; 1825 } 1826 1827 const variationMode = this.getABVariationMode(); 1828 const isEDDVariation = variationMode === 'edd'; 1829 const isESDVariation = variationMode === 'esd'; 1830 1831 if (!isEDDVariation && !isESDVariation) { 1832 return originalStockMsg; 1833 } 1834 1835 const tooltipText = this.extractTooltipTextFromMarkup(originalStockMsg); 1836 1837 const isEnhanceableStatus = this.isEnhanceableStockMessage(originalStockMsg); 1838 1839 if (!isEnhanceableStatus) { 1840 return originalStockMsg; 1841 } 1842 1843 const statusType = this.detectStockStatus(originalStockMsg); 1844 const statusLabel = this.getEnhancedStatusLabel(statusType, 'inline', isESDVariation ? 'esd' : 'edd'); 1845 const isEligibleInStock = this.isEligibleOriginalInStockStatus(originalStockMsg); 1846 const aggregatedESD = this.getCartPayloadAggregate('esd'); 1847 const aggregatedEDD = this.getCartPayloadAggregate('edd'); 1848 1849 // ESD variation: show ship date 1850 if (isESDVariation) { 1851 const effectiveESD = isEligibleInStock && aggregatedESD && aggregatedESD.esdValue ? aggregatedESD.esdValue : esdValue; 1852 if (this.isESDValuePastToday(effectiveESD)) { 1853 return this.buildDatelessVariationPill(originalStockMsg); 1854 } 1855 return this.getESDStockMessage(originalStockMsg, effectiveESD, tooltipText, true); 1856 } 1857 1858 try { 1859 let rangeFormatted = null; 1860 1861 // In-stock rule: use latest eligible in-stock range from API payload if available 1862 if (isEligibleInStock && aggregatedEDD && aggregatedEDD.rangeFormatted) { 1863 rangeFormatted = aggregatedEDD.rangeFormatted; 1864 } 1865 1866 // Non-in-stock rule: prefer currently displayed DOM date first 1867 if (!rangeFormatted && !isEligibleInStock) { 1868 const displayedActionDate = this.extractDisplayedActionDate(originalStockMsg); 1869 if (displayedActionDate) { 1870 if (this.isDisplayDateTextPastToday(displayedActionDate.dateText)) { 1871 return this.buildDatelessVariationPill(originalStockMsg); 1872 } 1873 // EDD variation + extracted "Ships" action: skip extracted date and fall through 1874 // to ESD-based path which properly computes arrival dates for EDD mode
1875 const isExtractedShipAction = displayedActionDate.actionText === 'Ships'; 1876 if (!(isEDDVariation && isExtractedShipAction)) { 1877 const stockInfo = this.getStockStatusInfo(originalStockMsg); 1878 const { start: dStartDate, end: dEndDate } = this.parseDisplayDateRange(displayedActionDate.dateText); 1879 return this.buildEnhancedStockMessage(stockInfo, statusLabel, displayedActionDate.actionText, displayedActionDate.dateText, tooltipText, undefined, dStartDate, dEndDate); 1880 } 1881 } 1882 } 1883 1884 // If ESD value provided, calculate per-item EDD 1885 if (!rangeFormatted && esdValue) { 1886 const deliveryInfo = this.calculateEDDFromESD(esdValue); 1887 if (deliveryInfo && deliveryInfo.rangeFormatted) { 1888 rangeFormatted = deliveryInfo.rangeFormatted; 1889 } 1890 } 1891 1892 // Non-in-stock fallback: if no EDD range could be calculated, still show ship range from ESD 1893 if (!rangeFormatted && !isEligibleInStock && esdValue) { 1894 if (this.isESDValuePastToday(esdValue)) { 1895 return this.buildDatelessVariationPill(originalStockMsg); 1896 } 1897 const esdDate = this.parseESDDateValue(esdValue); 1898 if (esdDate) { 1899 const stockInfo = this.getStockStatusInfo(originalStockMsg); 1900 const shipDate = this.calculateShipDate(esdDate); 1901 const bufferDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1902 return this.buildEnhancedStockMessage(stockInfo, statusLabel, 'Ships', this.formatShipRange(shipDate), tooltipText, undefined, shipDate, this.addBusinessDaysSimple(shipDate, bufferDays)); 1903 } 1904 } 1905 1906 // Fallback to cart state (slowest delivery across all items) - in-stock only 1907 if (!rangeFormatted && isEligibleInStock) { 1908 const cartState = this.getCartState(); 1909 if (cartState && cartState.items) { 1910 const slowestDelivery = this.getSlowestDelivery(cartState); 1911 if (slowestDelivery && slowestDelivery.rangeFormatted) { 1912 rangeFormatted = slowestDelivery.rangeFormatted; 1913 } 1914 } 1915 } 1916 1917 // Final fallback - base delivery data (in-stock only) 1918 if (!rangeFormatted && isEligibleInStock) { 1919 const baseDelivery = this.getBaseDeliveryData(); 1920 if (baseDelivery && baseDelivery.rangeFormatted) { 1921 rangeFormatted = baseDelivery.rangeFormatted; 1922 } 1923 } 1924 1925 if (!rangeFormatted) { 1926 return this.buildDatelessVariationPill(originalStockMsg); 1927 } 1928 if (this.isDisplayDateTextPastToday(rangeFormatted)) { 1929 return this.buildDatelessVariationPill(originalStockMsg); 1930 } 1931 1932 // Get stock status info for colored dot 1933 const stockInfo = this.getStockStatusInfo(originalStockMsg); 1934 const { start: arrStartDate, end: arrEndDate } = this.parseDisplayDateRange(rangeFormatted); 1935 return this.buildEnhancedStockMessage(stockInfo, statusLabel, 'Arrives', rangeFormatted, tooltipText, undefined, arrStartDate, arrEndDate); 1936 } catch (e) { 1937 return this.buildDatelessVariationPill(originalStockMsg); 1938 } 1939 }, 1940 1941 /** Get ESD (ship date) message for cart/minicart @param {string} originalStockMsg @param {string} [esdValue] @returns {string} */ 1942 getESDStockMessage: function(originalStockMsg, esdValue = null, tooltipText = null, skipPageFallback = false) { 1943 try { 1944 // Try to use provided esdValue or get from page 1945 let esdDateStr = esdValue; 1946 if (!esdDateStr && !skipPageFallback) { 1947 const esdInput = document.querySelector('#SIESDTd') || document.querySelector('#SIESDT input[name="SIESDT"]'); 1948 if (esdInput && esdInput.value) { 1949 esdDateStr = esdInput.value.trim(); 1950 } 1951 } 1952 1953 if (!esdDateStr) { 1954 return this.buildDatelessVariationPill(originalStockMsg); 1955 } 1956 if (this.isESDValuePastToday(esdDateStr)) { 1957 return this.buildDatelessVariationPill(originalStockMsg); 1958 } 1959 1960 const esdDate = this.parseESDDateValue(esdDateStr); 1961 if (!esdDate) return this.buildDatelessVariationPill(originalStockMsg); 1962 1963 // Apply cutoff logic for ship date (weekends, holidays, 2PM EST cutoff) 1964 const shipDate = this.calculateShipDate(esdDate); 1965 const formattedDate = this.formatShipRange(shipDate); 1966 const bufferDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 1967 const esdEndDate = this.addBusinessDaysSimple(shipDate, bufferDays); 1968 1969 // Get stock status info for colored dot
1970 const stockInfo = this.getStockStatusInfo(originalStockMsg); 1971 const statusType = this.detectStockStatus(originalStockMsg); 1972 const statusLabel = this.getEnhancedStatusLabel(statusType, 'inline', 'esd'); 1973 const tip = tooltipText || this.config?.stockMsgDisplay?.defaultTooltip || ''; 1974 return this.buildEnhancedStockMessage(stockInfo, statusLabel, 'Ships', formattedDate, tip, undefined, shipDate, esdEndDate); 1975 } catch (e) { 1976 return this.buildDatelessVariationPill(originalStockMsg); 1977 } 1978 }, 1979 1980 // ===== CHECKOUT DELIVERY CALCULATIONS ===== 1981 1982 /** Checkout shipping method values */ 1983 // Note: Shipping method values are backend tokens - we detect by radio button ID instead 1984 checkoutShippingMethods: { 1985 standard: 'StandardChecked', 1986 secondDay: '2ndDayAirChecked', 1987 nextDay: 'NextDayChecked', 1988 saturday: 'SaturdayChecked' 1989 }, 1990 1991 /** Get currently selected shipping method on checkout @returns {'standard'|'2day'|'nextday'|'saturday'|'none'|'other'} */ 1992 getSelectedShippingMethod: function() { 1993 const checkedInput = document.querySelector('input[name="SISHMT"]:checked'); 1994 if (!checkedInput) return 'none'; 1995 1996 const id = checkedInput.id || ''; 1997 const lowerId = id.toLowerCase(); 1998 let result = 'other'; 1999 if (id === this.checkoutShippingMethods.standard) result = 'standard'; 2000 else if (id === this.checkoutShippingMethods.secondDay) result = '2day'; 2001 else if (id === this.checkoutShippingMethods.nextDay) result = 'nextday'; 2002 else if (id === this.checkoutShippingMethods.saturday || lowerId.includes('saturday') || lowerId.includes('sat')) result = 'saturday'; 2003 else if (lowerId.includes('nextbusiness') || (lowerId.includes('next') && lowerId.includes('day')) || lowerId.includes('expedited')) result = 'nextday'; 2004 return result; 2005 }, 2006 2007 /** Check if current time is before 2PM EST @returns {boolean} */ 2008 isBeforeCutoff: function() { 2009 const now = new Date(); 2010 // Convert to EST (UTC-5) or EDT (UTC-4) 2011 const estOffset = this.isDST(now) ? -4 : -5; 2012 const utc = now.getTime() + (now.getTimezoneOffset() * 60000); 2013 const estDate = new Date(utc + (3600000 * estOffset)); 2014 const cutoffHourEST = this.config?.deliveryRules?.cutoffHourEST ?? 14; 2015 return estDate.getHours() < cutoffHourEST; 2016 }, 2017 2018 /** Get current date in EST timezone (warehouse timezone) @returns {Date} Today's date at midnight EST */ 2019 getTodayEST: function() { 2020 const now = new Date(); 2021 // Convert to EST (UTC-5) or EDT (UTC-4) 2022 const estOffset = this.isDST(now) ? -4 : -5; 2023 const utc = now.getTime() + (now.getTimezoneOffset() * 60000); 2024 const estDate = new Date(utc + (3600000 * estOffset)); 2025 // Return date at midnight (date only, no time component) 2026 estDate.setHours(0, 0, 0, 0); 2027 return estDate; 2028 }, 2029 2030 /** Check if date is during Daylight Saving Time @param {Date} date @returns {boolean} */ 2031 isDST: function(date) { 2032 const jan = new Date(date.getFullYear(), 0, 1); 2033 const jul = new Date(date.getFullYear(), 6, 1); 2034 const stdOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset()); 2035 return date.getTimezoneOffset() < stdOffset; 2036 }, 2037 2038 /** Check if date is a weekend (Saturday=6, Sunday=0) @param {Date} date @returns {boolean} */ 2039 isWeekend: function(date) { 2040 const day = date.getDay(); 2041 return day === 0 || day === 6; 2042 }, 2043 2044 /** Check if date is Friday @param {Date} date @returns {boolean} */ 2045 isFriday: function(date) { 2046 return date.getDay() === 5; 2047 }, 2048 2049 /** Get next Monday from a date @param {Date} date @returns {Date} */ 2050 getNextMonday: function(date) { 2051 const result = new Date(date); 2052 const day = result.getDay(); 2053 const daysUntilMonday = day === 0 ? 1 : (8 - day); 2054 result.setDate(result.getDate() + daysUntilMonday); 2055 return result; 2056 }, 2057 2058 /** Format date as EST calendar date (timezone-agnostic for display). These are calendar dates, not timestamps - user sees same date regardless of their timezone. @param {Date} date @param {object} options - Intl.DateTimeFormat options @returns {string} Formatted date string */ 2059 formatDateEST: function(date, options) { 2060 const defaultOptions = { weekday: 'short', month: 'short', day: 'numeric' }; 2061 const formatOptions = { ...defaultOptions, ...options, timeZone: 'America/Ne
2061w_York' }; 2062 return date.toLocaleDateString('en-US', formatOptions); 2063 }, 2064 2065 /** Format date range as "Nov 23 - 24" or "Nov 30 - Dec 1" @param {Date} startDate @param {Date} endDate @returns {string} */ 2066 formatCompactRange: function(startDate, endDate) { 2067 const startFormatted = startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 2068 const endFormatted = endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 2069 2070 if (startDate.getFullYear() === endDate.getFullYear() && startDate.getMonth() === endDate.getMonth()) { 2071 const month = startDate.toLocaleDateString('en-US', { month: 'short' }); 2072 return `${month} ${startDate.getDate()} - ${endDate.getDate()}`; 2073 } 2074 2075 return `${startFormatted} - ${endFormatted}`; 2076 }, 2077 2078 /** Format ship range as ESD to ESD+1 business day (e.g., "Feb 19 - 20") @param {Date} shipDate @returns {string} */ 2079 formatShipRange: function(shipDate) { 2080 if (!(shipDate instanceof Date) || isNaN(shipDate.getTime())) return ''; 2081 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2082 const bufferDate = this.addBusinessDaysSimple(shipDate, bufferBusinessDays); 2083 return this.formatCompactRange(shipDate, bufferDate); 2084 }, 2085 2086 /** Format date as long form for drawer messages (e.g., "Wednesday, February 12") @param {Date} date @returns {string} */ 2087 formatLongDate: function(date) { 2088 if (!(date instanceof Date) || isNaN(date.getTime())) return ''; 2089 return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); 2090 }, 2091 2092 /** Get drawer message for a given status type and variation mode with date placeholders resolved. @param {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} statusType @param {'esd'|'edd'} variationMode @param {Date|null} [startDate] - Range start date @param {Date|null} [endDate] - Range end date @returns {string} Resolved drawer message */ 2093 getDrawerMessage: function(statusType, variationMode, startDate = null, endDate = null) { 2094 const drawerConfig = this.config?.stockMsgDisplay?.drawerMessages || {}; 2095 const hasDates = startDate && endDate && startDate instanceof Date && endDate instanceof Date && !isNaN(startDate.getTime()) && !isNaN(endDate.getTime()); 2096 const isSameDay = hasDates && startDate.toDateString() === endDate.toDateString(); 2097 2098 // Pick same-day or date-range template 2099 let template = ''; 2100 if (isSameDay) { 2101 const sameDayTemplates = drawerConfig.sameDay?.[variationMode] || drawerConfig.sameDay?.esd || {}; 2102 template = sameDayTemplates[statusType] || sameDayTemplates.inStock || ''; 2103 } else { 2104 const modeTemplates = drawerConfig[variationMode] || drawerConfig.esd || {}; 2105 template = modeTemplates[statusType] || modeTemplates.inStock || ''; 2106 } 2107 2108 if (!template) return ''; 2109 2110 // Resolve date placeholders when available, otherwise strip date phrases 2111 if (hasDates) { 2112 if (isSameDay) { 2113 template = template.replace(/\{date\}/g, this.formatLongDate(startDate)); 2114 } else { 2115 template = template.replace(/\{startDate\}/g, this.formatLongDate(startDate)); 2116 template = template.replace(/\{endDate\}/g, this.formatLongDate(endDate)); 2117 } 2118 } else { 2119 template = template 2120 .replace(/\s*between\s*\{startDate\}\s*and\s*\{endDate\}/gi, '') 2121 .replace(/\s*on\s*\{date\}/gi, '') 2122 .replace(/\s{2,}/g, ' ') 2123 .trim(); 2124 } 2125 2126 return template; 2127 }, 2128 2129 /** Add business days (skips weekends only - holidays checked separately) @param {Date} startDate @param {number} days @returns {Date} */ 2130 addBusinessDaysSimple: function(startDate, days) { 2131 const result = new Date(startDate); 2132 let added = 0; 2133 while (added < days) { 2134 result.setDate(result.getDate() + 1); 2135 if (!this.isWeekend(result)) { 2136 added++; 2137 } 2138 } 2139 return result; 2140 }, 2141 2142 /** Calculate ship date based on ESD and current conditions (uses EST timezone) @param {Date} esdDate @returns {Date} Ship date to use for calculations */ 2143 calculateShipDate: function(esdDate) { 2144 // Use EST timezone for "today" to align with warehouse 2145 const today = this.getTodayEST(); 2146 2147 const esdNormalized = new Date(esdDate); 2148 esdNormalized.setHours(0, 0, 0, 0); 2149 2150 // GQ ESD > 1 (ESD is in the future relative to EST) 2151 if (esdNormalized > today) { 2152 return esdNormalized; 2153 } 2154 2155 // GQ ESD = 1 (ESD is today in EST) 2156 const isWeekendNow = this.isWeekend(today); 2157 const isFridayNow = this.isFriday(today); 2158 const beforeCutoff = this.isBeforeCutoff(); 2159 const afterCutoffDelayBusinessDays = this.config?.checkoutDecisionTable?.shipDateRules?.afterCutoffDelayBusinessDays ?? 1; 2160 2161 // Weekend: ship Monday 2162 if (isWeekendNow) { 2163 return this.getNextMonday(today); 2164 } 2165 2166 // Friday after 2PM: ship Monday 2167 if (isFridayNow && !beforeCutoff) { 2168 return this.getNextMonday(today); 2169 } 2170 2171 // Weekday after 2PM: ship next business day 2172 if (!beforeCutoff) { 2173 return this.addBusinessDaysSimple(today, afterCutoffDelayBusinessDays); 2174 } 2175 2176 // Weekday before 2PM: ship today 2177 return today; 2178 }, 2179 2180 /** Calculate checkout delivery date based on shipping method @param {string} esdValue - ESD in MM/DD/YYYY format @param {'standard'|'2day'|'nextday'|'saturday'|'none'|'other'} shippingMethod @returns {object|null} {shipDate, arrivalDate, arrivalFormatted, rangeFormatted, shippingMethod} */ 2181 calculateCheckoutDelivery: function(esdValue, shippingMethod) { 2182 if (!esdValue || shippingMethod === 'other' || shippingMethod === 'none' || shippingMethod === 'saturday') { 2183 return null; 2184 } 2185 2186 const esdDate = this.parseESDDateValue(esdValue); 2187 if (!esdDate) return null; 2188 2189 // Calculate ship date 2190 const shipDate = this.calculateShipDate(esdDate); 2191 2192 // Calculate arrival date based on shipping method 2193 let arrivalDate; 2194 const shortOptions = { month: 'short', day: 'numeric' }; 2195 2196 if (shippingMethod === 'standard') { 2197 // Standard: Ship date + Ground transit days (from FedEx CSV) 2198 const userZip = this.getUserZipCode(); 2199 const transitDays = this.getTransitDaysForZip(userZip, false); 2200 if (!transitDays) return null; 2201 2202 const earliestArrival = this.addBusinessDays(shipDate, transitDays); 2203 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2204 const latestArrival = this.addBusinessDays(earliestArrival, bufferBusinessDays); 2205 2206 return { 2207 shipDate: shipDate, 2208 arrivalDate: earliestArrival, 2209 latestArrivalDate: latestArrival, 2210 shipRangeEndDate: this.addBusinessDays(shipDate, bufferBusinessDays), 2211 shipFormatted: shipDate.toLocaleDateString('en-US', shortOptions), 2212 shipRangeFormatted: this.formatShipRange(shipDate), 2213 arrivalFormatted: earliestArrival.toLocaleDateString('en-US', shortOptions), 2214 rangeFormatted: this.formatCompactRange(earliestArrival, latestArrival), 2215 shippingMethod: 'standard' 2216 }; 2217 } 2218 2219 if (shippingMethod === '2day') { 2220 // 2nd Day: Ship date + 2 business days 2221 const secondDayBusinessDays = this.config?.checkoutDecisionTable?.deliveryBusinessDays?.secondDay ?? 2; 2222 arrivalDate = this.addBusinessDays(shipDate, secondDayBusinessDays); 2223 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2224 return { 2225 shipDate: shipDate, 2226 arrivalDate: arrivalDate, 2227 latestArrivalDate: arrivalDate, 2228 shipRangeEndDate: this.addBusinessDays(shipDate, bufferBusinessDays), 2229 shipFormatted: shipDate.toLocaleDateString('en-US', shortOptions), 2230 shipRangeFormatted: this.formatShipRange(shipDate), 2231 arrivalFormatted: arrivalDate.toLocaleDateString('en-US', shortOptions), 2232 shippingMethod: '2day' 2233 }; 2234 } 2235 2236 if (shippingMethod === 'nextday') { 2237 // Next Day: Ship date + 1 business day 2238 const nextDayBusinessDays = this.config?.checkoutDecisionTable?.deliveryBusinessDays?.nextDay ?? 1; 2239 arrivalDate = this.addBusinessDays(shipDate, nextDayBusinessDays); 2240 const bufferBusinessDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2241 return { 2242 shipDate: shipDate, 2243 arrivalDate: arrivalDate, 2244 latestArrivalDate: arrivalDate, 2245 shipRangeEndDate: this.addBusinessDays(shipDate, bufferBusinessDays), 2246 shipFormatted: shipDate.toLocaleDateString('en-US', shortOptions), 2247 shipRangeFormatted: this.formatShipRange(shipDate), 2248 arrivalFormatted: arrivalDate.toLocaleDateString('en-US', shortOptions), 2249 shippingMethod: 'nextday' 2250 }; 2251 } 2252 2253 return null; 2254 }, 2255 2256 /** Get cached variation display for checkout when no shipping option is selected yet. @param {'edd'|'esd'} variationMode @param {'inStock'|'partnerFulfilled'|'backordered'|'preOrder'} statusType @param {string} esdValue @param {object|null} inStockAggregate @returns {{actionText:string,dateText:string}|null} */
2257 getCheckoutCachedDisplayForNoSelection: function(variationMode, statusType, esdValue, inStockAggregate = null) { 2258 if (statusType !== 'inStock') return null; 2259 2260 if (variationMode === 'esd') { 2261 if (inStockAggregate && inStockAggregate.deliveryInfo && inStockAggregate.deliveryInfo.shipRangeFormatted) { 2262 return { actionText: 'Ships', dateText: inStockAggregate.deliveryInfo.shipRangeFormatted }; 2263 } 2264 2265 const aggregateESD = this.getCartPayloadAggregate('esd'); 2266 const cachedESDValue = aggregateESD && aggregateESD.esdValue ? aggregateESD.esdValue : esdValue; 2267 const esdDate = this.parseESDDateValue(cachedESDValue); 2268 if (!esdDate) return null; 2269 const shipDate = this.calculateShipDate(esdDate); 2270 return { actionText: 'Ships', dateText: this.formatShipRange(shipDate) }; 2271 } 2272 2273 if (variationMode === 'edd') { 2274 if (inStockAggregate && inStockAggregate.deliveryInfo && inStockAggregate.deliveryInfo.rangeFormatted) { 2275 return { actionText: 'Arrives', dateText: inStockAggregate.deliveryInfo.rangeFormatted }; 2276 } 2277 2278 // Always recalculate fresh from ESD + ZIP (same path as cart/minicart) 2279 // to ensure dates match across surfaces 2280 if (esdValue) { 2281 const freshZip = this.getUserZipCode(); 2282 const directDelivery = this.calculateEDDFromESD(esdValue, freshZip); 2283 if (directDelivery && directDelivery.rangeFormatted) { 2284 return { actionText: 'Arrives', dateText: directDelivery.rangeFormatted }; 2285 } 2286 } 2287 } 2288 2289 return null; 2290 }, 2291 2292 /** Check whether checkout shipping address is currently valid. @returns {boolean} */ 2293 isAddressValid: function() { 2294 const zipInput = document.getElementById('SISZIP'); 2295 if (!zipInput) return true; 2296 return !(zipInput.classList && zipInput.classList.contains('samOPC_error')); 2297 }, 2298 2299 /** Get checkout stock message HTML based on shipping method and A/B test @param {string} originalStockMsg - raw status text like "In Stock" @param {string} esdValue - ESD in MM/DD/YYYY format @param {string} [tooltipText] - Original tooltip text from page load @returns {string} Formatted stock message HTML */ 2300 getCheckoutStockMessage: function(originalStockMsg, esdValue, tooltipText, shippingMethodOverride = null, inStockAggregate = null, statusTypeOverride = null) { 2301 const statusType = statusTypeOverride || this.detectStockStatus(originalStockMsg); 2302 2303 if (statusType === 'backordered' && this.isHideBackorderVariation()) { 2304 return ''; 2305 } 2306 2307 // Get stock info based on status text (includes color from stockStatusConfig) 2308 const stockInfo = this.getStockStatusInfo(originalStockMsg); 2309 const fallbackStatusText = (typeof originalStockMsg === 'string' && originalStockMsg.trim()) 2310 ? originalStockMsg.trim().replace(/\s+/g, ' ') 2311 : (statusType === 'partnerFulfilled' ? 'Ships from a Trusted Partner' : stockInfo.label); 2312 const tip = tooltipText || this.extractTooltipTextFromMarkup(originalStockMsg); 2313 2314 // Helper to return stock message with FA icon and drawer trigger (non-EDD format). 2315 // Drawer message: EDD/ESD always use config templates (dates stripped if unavailable), control uses backend tooltip. 2316 const getOriginalStockHTML = () => { 2317 const variationMode = this.getABVariationMode(); 2318 const resolvedTip = (variationMode === 'edd' || variationMode === 'esd') 2319 ? this.getDrawerMessage(statusType, variationMode) 2320 : tip; 2321 return `<span class="stock-message__wrapper" data-stock-info-trigger data-tip="${resolvedTip}" tabindex="0" role="button" aria-label="Stock information: ${fallbackStatusText}"> 2322 <span class="stock-message__row"> 2323 <span class="stock-message__text">${fallbackStatusText}</span> 2324 <i class="fa-solid fa-circle-info stock-message__icon"></i> 2325 </span> 2326 </span>`; 2327 }; 2328 2329 const isEnhanceableStatus = this.isEnhanceableStockMessage(originalStockMsg); 2330 2331 // Rule 1: keep original prod status message for non-enhanceable states 2332 if (!isEnhanceableStatus) { 2333 return getOriginalStockHTML(); 2334 } 2335 2336 const variationMode = this.getABVariationMode(); 2337 const isEDDVariation = variationMode === 'edd'; 2338 const isESDVariation = variationMode === 'esd'; 2339 const enhancedStatusLabel = this.getEnhancedStatusLabel(statusType, 'inline', variationMode); 2340 2341 if (!isEDDVariation && !isESDVariation) { 2342 return getOriginalStockHTML(); 2343 } 2344 2345 if (this.isESDValuePastToday(esdValue)) { 2346 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2347 } 2348
2349 const shippingMethod = shippingMethodOverride || this.getSelectedShippingMethod(); 2350 2351 // Non-in-stock: dates come from BE text or ESD, independent of shipping method / zip selection 2352 if (statusType !== 'inStock') { 2353 const displayedActionDate = this.extractDisplayedActionDate(originalStockMsg); 2354 if (displayedActionDate) { 2355 if (this.isDisplayDateTextPastToday(displayedActionDate.dateText)) { 2356 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2357 } 2358 // EDD variation + extracted "Ships" action: skip extracted date and fall through 2359 // to ESD-based path which properly computes arrival dates for EDD mode 2360 const isExtractedShipAction = displayedActionDate.actionText === 'Ships'; 2361 if (!(isEDDVariation && isExtractedShipAction)) { 2362 const { start: chkStartDate, end: chkEndDate } = this.parseDisplayDateRange(displayedActionDate.dateText); 2363 return this.buildEnhancedStockMessage( 2364 stockInfo, 2365 enhancedStatusLabel, 2366 displayedActionDate.actionText, 2367 displayedActionDate.dateText, 2368 tip, 2369 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout', 2370 chkStartDate, 2371 chkEndDate 2372 ); 2373 } 2374 } 2375 2376 const esdDate = this.parseESDDateValue(esdValue); 2377 if (!esdDate) { 2378 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2379 } 2380 2381 // Apply cutoff logic for ship date (weekends, holidays, 2PM EST cutoff) 2382 const shipDate = this.calculateShipDate(esdDate); 2383 2384 if (isESDVariation) { 2385 const bufferDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2386 return this.buildEnhancedStockMessage( 2387 stockInfo, 2388 enhancedStatusLabel, 2389 'Ships', 2390 this.formatShipRange(shipDate), 2391 tip, 2392 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout', 2393 shipDate, 2394 this.addBusinessDaysSimple(shipDate, bufferDays) 2395 ); 2396 } 2397 2398 // EDD variation: resolve arrival date once, then render 2399 // Priority: expedited method â standard/ground transit â fallback to ship date 2400 let nonInStockActionText = 'Ships'; 2401 let nonInStockDateText = this.formatShipRange(shipDate); 2402 const bufferDays = this.config?.deliveryRules?.bufferBusinessDays || 1; 2403 let nonInStockStart = shipDate; 2404 let nonInStockEnd = this.addBusinessDaysSimple(shipDate, bufferDays); 2405 2406 if (shippingMethod === '2day' || shippingMethod === 'nextday') { 2407 const secondDayBusinessDays = this.config?.checkoutDecisionTable?.deliveryBusinessDays?.secondDay ?? 2; 2408 const nextDayBusinessDays = this.config?.checkoutDecisionTable?.deliveryBusinessDays?.nextDay ?? 1; 2409 const arrivalDate = this.addBusinessDays(shipDate, shippingMethod === '2day' ? secondDayBusinessDays : nextDayBusinessDays); 2410 nonInStockActionText = 'Arrives'; 2411 nonInStockDateText = arrivalDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); 2412 nonInStockStart = arrivalDate; 2413 nonInStockEnd = arrivalDate; 2414 } else { 2415 // Standard, none, or unsupported: use ground transit calculation (same as cart/minicart) 2416 const directDelivery = this.calculateEDDFromESD(esdValue, this.getUserZipCode()); 2417 if (directDelivery && directDelivery.rangeFormatted) { 2418 nonInStockActionText = 'Arrives'; 2419 nonInStockDateText = directDelivery.rangeFormatted; 2420 nonInStockStart = directDelivery.earliestDelivery; 2421 nonInStockEnd = directDelivery.latestDelivery; 2422 } 2423 } 2424 2425 if (this.isDisplayDateTextPastToday(nonInStockDateText)) { 2426 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2427 } 2428 return this.buildEnhancedStockMessage( 2429 stockInfo, 2430 enhancedStatusLabel, 2431 nonInStockActionText, 2432 nonInStockDateText, 2433 tip, 2434 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout', 2435 nonInStockStart, 2436 nonInStockEnd 2437 ); 2438 } 2439 2440 const zipInput = document.getElementById('SISZIP'); 2441 const hasZipValue = !!(zipInput && zipInput.value && zipInput.value.trim()); 2442 if (!hasZipValue && shippingMethod !== 'none') { 2443 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2444 } 2445 2446 if (statusType === 'inStock' && hasZipValue && !this.isAddressValid()) {
2447 const cachedDisplay = this.getCheckoutCachedDisplayForNoSelection(variationMode, statusType, esdValue, inStockAggregate); 2448 if (cachedDisplay) { 2449 if (this.isDisplayDateTextPastToday(cachedDisplay.dateText)) { 2450 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2451 } 2452 const { start: cStart, end: cEnd } = this.parseDisplayDateRange(cachedDisplay.dateText); 2453 return this.buildEnhancedStockMessage( 2454 stockInfo, 2455 enhancedStatusLabel, 2456 cachedDisplay.actionText, 2457 cachedDisplay.dateText, 2458 tip, 2459 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout', 2460 cStart, 2461 cEnd 2462 ); 2463 } 2464 } 2465 2466 if (shippingMethod === 'none') { 2467 const cachedDisplay = this.getCheckoutCachedDisplayForNoSelection(variationMode, statusType, esdValue, inStockAggregate); 2468 if (cachedDisplay) { 2469 if (this.isDisplayDateTextPastToday(cachedDisplay.dateText)) { 2470 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2471 } 2472 const { start: cStart2, end: cEnd2 } = this.parseDisplayDateRange(cachedDisplay.dateText); 2473 return this.buildEnhancedStockMessage( 2474 stockInfo, 2475 enhancedStatusLabel, 2476 cachedDisplay.actionText, 2477 cachedDisplay.dateText, 2478 tip, 2479 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout', 2480 cStart2, 2481 cEnd2 2482 ); 2483 } 2484 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2485 } 2486 2487 // Unsupported shipping methods: show dateless variation pill 2488 if (!this.isSupportedCheckoutShippingMethod(shippingMethod)) { 2489 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2490 } 2491 2492 const deliveryInfo = this.calculateCheckoutDelivery(esdValue, shippingMethod); 2493 2494 // If delivery calculation failed and no aggregate available, show dateless variation pill 2495 if (!deliveryInfo && !(statusType === 'inStock' && inStockAggregate && inStockAggregate.deliveryInfo)) { 2496 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2497 } 2498 2499 const effectiveDeliveryInfo = (statusType === 'inStock' && inStockAggregate && inStockAggregate.deliveryInfo) 2500 ? inStockAggregate.deliveryInfo 2501 : deliveryInfo; 2502 2503 let actionText, dateText; 2504 2505 if (shippingMethod === 'standard') { 2506 // Standard shipping: use A/B test variation phrasing 2507 if (isESDVariation) { 2508 // ESD variation: "Ships [range]" 2509 actionText = 'Ships'; 2510 dateText = effectiveDeliveryInfo.shipRangeFormatted || this.formatShipRange(effectiveDeliveryInfo.shipDate); 2511 } else { 2512 // EDD variation: "Arrives [range]" 2513 actionText = 'Arrives'; 2514 dateText = effectiveDeliveryInfo.rangeFormatted; 2515 } 2516 } else { 2517 // 2nd Day / Next Day: always use "Arrives [single date]" 2518 actionText = 'Arrives'; 2519 dateText = effectiveDeliveryInfo.arrivalFormatted; 2520 } 2521 2522 if (this.isDisplayDateTextPastToday(dateText)) { 2523 return this.buildDatelessVariationPill(originalStockMsg, 'stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout'); 2524 } 2525 2526 const formattedActionText = this.getEstimatedActionText(actionText); 2527 2528 // Resolve drawer message for checkout inline template 2529 let checkoutDrawerStartDate = null; 2530 let checkoutDrawerEndDate = null; 2531 if (shippingMethod === 'standard') { 2532 if (isESDVariation && effectiveDeliveryInfo.shipDate) { 2533 checkoutDrawerStartDate = effectiveDeliveryInfo.shipDate; 2534 checkoutDrawerEndDate = effectiveDeliveryInfo.shipRangeEndDate || this.addBusinessDaysSimple(effectiveDeliveryInfo.shipDate, this.config?.deliveryRules?.bufferBusinessDays || 1); 2535 } else { 2536 checkoutDrawerStartDate = effectiveDeliveryInfo.earliestDelivery || effectiveDeliveryInfo.earliestArrivalDate || null; 2537 checkoutDrawerEndDate = effectiveDeliveryInfo.latestDelivery || effectiveDeliveryInfo.latestArrivalDate || null; 2538 } 2539 } else { 2540 checkoutDrawerStartDate = effectiveDeliveryInfo.arrivalDate || null; 2541 checkoutDrawerEndDate = effectiveDeliveryInfo.arrivalDate || null; 2542 } 2543 const drawerMessage = this.getDrawerMessage(stockInfo.type, variationMode, checkoutDrawerStartDate, checkoutDrawerEndDate); 2544 2545 // Format: [dot] Status ⢠Ships/Arrives DateRange 2546 // data-stock-info-trigger opens drawer on click 2547 return `<span class="stock-message__wrapper stock-message__wrapper--edd stock-message__wrapper--checkout" data-stock-type="${stockInfo.type}" data-stock-info-trigger data-tip="${drawerMessage}" tabindex="0" role="button" aria-label="Stock information">
2548 <span class="stock-message__row"> 2549 <span class="stock-message__dot">${this.getColoredDotSVG()}</span> 2550 <span class="stock-message__status">${enhancedStatusLabel}</span> 2551 <span class="stock-message__sep">\u2022</span> 2552 <span class="stock-message__text">${formattedActionText} <strong>${dateText}</strong> <span class="stock-message__info-icon" aria-hidden="true">${this.getInfoIconSVG('inline')}</span></span> 2553 </span> 2554 </span>`; 2555 }, 2556 2557 /** Check if shipping method should display delivery dates @param {'standard'|'2day'|'nextday'|'saturday'|'none'|'other'} shippingMethod @returns {boolean} */ 2558 isSupportedCheckoutShippingMethod: function(shippingMethod) { 2559 return shippingMethod === 'standard' || shippingMethod === '2day' || shippingMethod === 'nextday'; 2560 }, 2561 2562 /** Pick comparison date for checkout aggregate selection. @param {object} deliveryInfo @param {string} shippingMethod @param {boolean} isESDVariation @returns {Date|null} */ 2563 getCheckoutDeliverySortDate: function(deliveryInfo, shippingMethod, isESDVariation) { 2564 if (!deliveryInfo) return null; 2565 2566 if (shippingMethod === 'standard') { 2567 if (isESDVariation) { 2568 return deliveryInfo.shipRangeEndDate || null; 2569 } 2570 return deliveryInfo.latestArrivalDate || deliveryInfo.arrivalDate || null; 2571 } 2572 2573 return deliveryInfo.arrivalDate || null; 2574 }, 2575 2576 /** Build latest eligible in-stock checkout delivery aggregate. @param {Array} lineItems @param {string} shippingMethod @param {boolean} isESDVariation @returns {{deliveryInfo: object, sortDate: Date}|null} */ 2577 getCheckoutInStockAggregate: function(lineItems, shippingMethod, isESDVariation) { 2578 if (!Array.isArray(lineItems) || !lineItems.length) return null; 2579 2580 let latestAggregate = null; 2581 2582 lineItems.forEach((item) => { 2583 if (!item.isEligibleInStock || !item.deliveryInfoForAggregate) return; 2584 const sortDate = this.getCheckoutDeliverySortDate(item.deliveryInfoForAggregate, shippingMethod, isESDVariation); 2585 if (!sortDate) return; 2586 2587 if (!latestAggregate || sortDate > latestAggregate.sortDate) { 2588 latestAggregate = { 2589 deliveryInfo: item.deliveryInfoForAggregate, 2590 sortDate: sortDate 2591 }; 2592 } 2593 }); 2594 2595 return latestAggregate; 2596 }, 2597 2598 /** Debounce checkout stock message updates to avoid repeated render bursts during init. */ 2599 scheduleCheckoutStockMessagesUpdate: function() { 2600 if (this._checkoutUpdateTimer) { 2601 clearTimeout(this._checkoutUpdateTimer); 2602 } 2603 2604 this._checkoutUpdateTimer = setTimeout(() => { 2605 this._checkoutUpdateTimer = null; 2606 this.updateCheckoutStockMessages(); 2607 }, 60); 2608 }, 2609 2610 /** Called by checkout flow after dynamic data updates. @param {string} actn @param {string} fieldId */ 2611 onCheckoutFormUpdated: function(actn, fieldId) { 2612 const normalizedActn = (actn || '').toUpperCase(); 2613 const normalizedFieldId = (fieldId || '').toUpperCase(); 2614 const isInitialLoad = normalizedActn === 'INIT'; 2615 const isZipChange = normalizedActn === 'SHIPPING' && normalizedFieldId === 'SISZIP'; 2616 const isShippingMethodChange = normalizedActn === 'SHMT' || normalizedFieldId === 'SISHMT'; 2617 2618 if (isZipChange) { 2619 const zipInput = document.getElementById('SISZIP'); 2620 const hasZipValidationError = !!(zipInput && zipInput.classList && zipInput.classList.contains('samOPC_error')); 2621 if (hasZipValidationError) { 2622 return; 2623 } 2624 } 2625 2626 if (!isInitialLoad && !isZipChange && !isShippingMethodChange) { 2627 return; 2628 } 2629 2630 this.scheduleCheckoutStockMessagesUpdate(); 2631 }, 2632 2633 /** Backward-compatible wrapper used by template calls. */ 2634 onShippingMethodChange: function() { 2635 this.onCheckoutFormUpdated('SHMT', 'SISHMT'); 2636 }, 2637 2638 /** Called by saveCheckoutForm() when address validation fails. @param {string} fieldId - Field ID that failed validation (e.g., 'SISZIP') */ 2639 onAddressValidationError: function(fieldId) { 2640 // Only handle shipping zip errors for now 2641 if (fieldId !== 'SISZIP') return; 2642 2643 const zipInput = document.getElementById('SISZIP'); 2644 const zipValue = zipInput && zipInput.value ? zipInput.value.trim() : '';
2645 // Empty ZIP: nothing to recalculate â INIT render already handled this state 2646 if (!zipValue) return; 2647 2648 // Non-empty zip with backend validation error: remove EDD/ESD dates 2649 const stockMessages = document.querySelectorAll('.newStockMessage'); 2650 stockMessages.forEach(stockMsgEl => { 2651 const cartRow = stockMsgEl.closest('.samOPC_row_flex'); 2652 if (cartRow) { 2653 const titleEl = cartRow.querySelector('.samOPC_row_flex_desc b'); 2654 const itemTitle = titleEl?.textContent?.trim() || null; 2655 if (this.isGiftCardItem(null, itemTitle)) { 2656 stockMsgEl.style.display = 'none'; 2657 return; 2658 } 2659 } 2660 const originalText = stockMsgEl.getAttribute('data-original-stock-text'); 2661 const originalTip = stockMsgEl.getAttribute('data-original-tip') || this.config?.stockMsgDisplay?.defaultTooltip || 'Customized items may take longer to ship.'; 2662 if (originalText) { 2663 const stockInfo = this.getStockStatusInfo(originalText); 2664 const displayText = originalText.trim(); 2665 stockMsgEl.innerHTML = `<span class="stock-message__wrapper" data-stock-info-trigger data-tip="${originalTip}" tabindex="0" role="button" aria-label="Stock information: ${displayText}"> 2666 <span class="stock-message__row"> 2667 <span class="stock-message__dot">${stockInfo.dotSVG}</span> 2668 <span class="stock-message__status">${displayText}</span> 2669 </span> 2670 </span>`; 2671 stockMsgEl.removeAttribute('style'); 2672 var p = stockMsgEl.parentElement; 2673 while (p) { 2674 if (p.style.display === 'none') { p.style.display = ''; break; } 2675 p = p.parentElement; 2676 } 2677 } 2678 }); 2679 }, 2680 2681 /** Update checkout stock messages when shipping method changes */ 2682 updateCheckoutStockMessages: function() { 2683 const variationMode = this.getABVariationMode(); 2684 const isEDDVariation = variationMode === 'edd'; 2685 const isESDVariation = variationMode === 'esd'; 2686 2687 const pageType = document.getElementById("checkoutForm"); 2688 if (!pageType) { 2689 return; 2690 } 2691 2692 const stockMessages = document.querySelectorAll('.newStockMessage'); 2693 if (!stockMessages.length) return; 2694 2695 const selectedShippingMethod = this.getSelectedShippingMethod(); 2696 2697 const lineItems = []; 2698 2699 stockMessages.forEach((stockMsgEl) => { 2700 // Find per-item ESD from hidden field 2701 let esdValue = null; 2702 const cartRow = stockMsgEl.closest('.samOPC_row_flex'); 2703 if (cartRow) { 2704 const esdInput = cartRow.querySelector('input[name^="SIESDT_"]'); 2705 if (esdInput && esdInput.value) { 2706 esdValue = esdInput.value.trim(); 2707 } 2708 const titleEl = cartRow.querySelector('.samOPC_row_flex_desc b'); 2709 const itemTitle = titleEl?.textContent?.trim() || null; 2710 if (this.isGiftCardItem(null, itemTitle)) { 2711 stockMsgEl.style.display = 'none'; 2712 return; 2713 } 2714 } 2715 2716 const existingOriginalText = stockMsgEl.getAttribute('data-original-stock-text'); 2717 const existingOriginalTip = stockMsgEl.getAttribute('data-original-tip'); 2718 const existingOriginalStyle = stockMsgEl.getAttribute('data-original-style'); 2719 2720 const textContent = (stockMsgEl.textContent || '').replace(/\s+/g, ' ').trim(); 2721 const stockTextEl = stockMsgEl.querySelector('.stock-message__text'); 2722 const stockStatusEl = stockMsgEl.querySelector('.stock-message__status'); 2723 const extractedOriginalText = (stockTextEl && stockTextEl.textContent ? stockTextEl.textContent : (stockStatusEl && stockStatusEl.textContent ? stockStatusEl.textContent : textContent)).replace(/\s+/g, ' ').trim(); 2724 const tipElement = stockMsgEl.querySelector('[data-tip]'); 2725 const extractedOriginalTip = tipElement 2726 ? (tipElement.getAttribute('data-tip') || '') 2727 : this.extractTooltipTextFromMarkup(stockMsgEl.innerHTML || '', ''); 2728 2729 const originalText = (existingOriginalText || extractedOriginalText || (window.stockStatusConfig?.inStock?.label || '')).replace(/\s+/g, ' ').trim(); 2730 const originalTip = (existingOriginalTip !== null ? existingOriginalTip : extractedOriginalTip) || this.config?.stockMsgDisplay?.defaultTooltip || 'Customized items may take longer to ship.'; 2731 const statusType = this.detectStockStatus(originalText); 2732 const isEligibleInStock = statusType === 'inStock'; 2733 const deliveryInfoForAggregate = isEligibleInStock 2734 ? this.calculateCheckoutDelivery(esdValue, selectedShippingMethod) 2735 : null; 2736 2737 lineItems.push({ 2738 stockMsgEl, 2739 setOriginalText: !existingOriginalText, 2740 originalText, 2741 setOriginalTip: existingOriginalTip === null, 2742 originalTip, 2743 setOriginalStyle: existingOriginalStyle === null, 2744 originalStyle: existingOriginalStyle !== null ? existingOriginalStyle : (stockMsgEl.getAttribute('style') || ''), 2745 statusType, 2746 isEligibleInStock, 2747 esdValue, 2748 deliveryInfoForAggregate 2749 }); 2750 });
2751 2752 let inStockAggregate = this.getCheckoutInStockAggregate(lineItems, selectedShippingMethod, isESDVariation); 2753 2754 // If primary aggregate is empty (no shipping method selected, or transit data unavailable), 2755 // build a fallback aggregate using calculateEDDFromESD / getShipRangeInfoFromESD 2756 // (same logic cart/minicart use) so all in-stock items show the same slowest date. 2757 if (!inStockAggregate) { 2758 let latestFallback = null; 2759 lineItems.forEach((item) => { 2760 if (!item.isEligibleInStock || !item.esdValue) return; 2761 if (isESDVariation) { 2762 const shipRangeInfo = this.getShipRangeInfoFromESD(item.esdValue); 2763 if (shipRangeInfo && (!latestFallback || shipRangeInfo.rangeEndDate > latestFallback.sortDate)) { 2764 const shipDate = this.calculateShipDate(this.parseESDDateValue(item.esdValue)); 2765 latestFallback = { 2766 deliveryInfo: { shipRangeFormatted: this.formatShipRange(shipDate), shipDate: shipDate }, 2767 sortDate: shipRangeInfo.rangeEndDate 2768 }; 2769 } 2770 } else { 2771 const deliveryInfo = this.calculateEDDFromESD(item.esdValue, this.getUserZipCode()); 2772 if (deliveryInfo && deliveryInfo.latestDelivery && (!latestFallback || deliveryInfo.latestDelivery > latestFallback.sortDate)) { 2773 latestFallback = { 2774 deliveryInfo: deliveryInfo, 2775 sortDate: deliveryInfo.latestDelivery 2776 }; 2777 } 2778 } 2779 }); 2780 if (latestFallback) { 2781 inStockAggregate = latestFallback; 2782 } 2783 } 2784 2785 const pendingUpdates = []; 2786 2787 lineItems.forEach((entry) => { 2788 const updatedMsg = this.getCheckoutStockMessage( 2789 entry.originalText, 2790 entry.esdValue, 2791 entry.originalTip, 2792 selectedShippingMethod, 2793 inStockAggregate, 2794 entry.statusType 2795 ); 2796 const currentMarkup = (entry.stockMsgEl.innerHTML || '').replace(/\s+/g, ' ').trim(); 2797 const nextMarkup = (updatedMsg || '').replace(/\s+/g, ' ').trim(); 2798 2799 pendingUpdates.push({ 2800 stockMsgEl: entry.stockMsgEl, 2801 nextMarkup, 2802 updatedMsg, 2803 shouldWriteMarkup: currentMarkup !== nextMarkup, 2804 setOriginalText: entry.setOriginalText, 2805 originalText: entry.originalText, 2806 setOriginalTip: entry.setOriginalTip, 2807 originalTip: entry.originalTip, 2808 setOriginalStyle: entry.setOriginalStyle, 2809 originalStyle: entry.originalStyle 2810 }); 2811 }); 2812 2813 pendingUpdates.forEach((entry) => { 2814 if (entry.setOriginalText) { 2815 entry.stockMsgEl.setAttribute('data-original-stock-text', entry.originalText); 2816 } 2817 2818 if (entry.setOriginalTip) { 2819 entry.stockMsgEl.setAttribute('data-original-tip', entry.originalTip); 2820 } 2821 2822 if (entry.setOriginalStyle) { 2823 entry.stockMsgEl.setAttribute('data-original-style', entry.originalStyle); 2824 } 2825 2826 if (entry.shouldWriteMarkup) { 2827 entry.stockMsgEl.innerHTML = entry.updatedMsg; 2828 const isEDDMarkup = entry.updatedMsg && entry.updatedMsg.includes('stock-message__wrapper--edd'); 2829 if (isEDDMarkup) { 2830 // EDD variation controls its own colors via CSS variables â remove backend inline style 2831 entry.stockMsgEl.removeAttribute('style'); 2832 } else if (entry.originalStyle) { 2833 // Non-EDD (original format): restore backend inline style (e.g., color:GREEN) 2834 entry.stockMsgEl.setAttribute('style', entry.originalStyle); 2835 } 2836 } 2837 2838 // Hide Backorder A/B: explicit display overrides CSS .hide-backorder-msg rule 2839 if (entry.updatedMsg) { 2840 entry.stockMsgEl.style.display = this.isHideBackorderVariation() ? 'block' : ''; 2841 var p = entry.stockMsgEl.parentElement; 2842 while (p) { 2843 if (p.style.display === 'none') { p.style.display = ''; break; } 2844 p = p.parentElement; 2845 } 2846 } 2847 }); 2848 2849 // Note: updateCheckoutESDFields is intentionally NOT called here. 2850 // It overwrites SIESDT_* hidden fields with HTML, corrupting ESD date values 2851 // needed by subsequent renders. It runs only on submitOrder() in CGBCCART2.html. 2852 }, 2853 2854 /** Update cart stock messages with EDD/ESD (reads from #SIESDT_X hidden fields) */ 2855 updateCartStockMessages: function() { 2856 const variationMode = this.getABVariationMode(); 2857 const isEDDVariation = variationMode === 'edd'; 2858 const isESDVariation = variationMode === 'esd'; 2859 2860 if (!isEDDVariation && !isESDVariation) { 2861 return; 2862 } 2863 2864 const cartTable = document.querySelector('.shopping-cart__table'); 2865 if (!cartTable) { 2866 return; 2867 } 2868 2869 const stockMessages = document.getElementsByClassName('newStockMessage'); 2870 if (!stockMessages.length) { 2871 return; 2872 } 2873 2874 let latestESDAggregate = null; 2875 let latestEDDAggregate = null; 2876 2877 for (let i = 0; i < stockMessages.length; i++) { 2878 const stockMsgEl = stockMessages[i]; 2879 const sourceStockMsg = stockMsgEl.getAttribute('data-control-stock-msg') || stockMsgEl.innerHTML || ''; 2880 2881 if (!this.isEligibleOriginalInStockStatus(sourceStockMsg)) { 2882 continue; 2883 } 2884 2885 let esdValue = null; 2886 const cartRow = stockMsgEl.closest('.shopping-cart__table--row'); 2887 if (cartRow) { 2888 const esdInput = cartRow.querySelector('input[name^="SIESDT_"]'); 2889 if (esdInput && esdInput.value) { 2890 esdValue = esdInput.value.trim(); 2891 } 2892 } 2893 2894 if (!esdValue) { 2895 continue; 2896 } 2897
2898 const shipRangeInfo = this.getShipRangeInfoFromESD(esdValue); 2899 if (shipRangeInfo && (!latestESDAggregate || shipRangeInfo.rangeEndDate > latestESDAggregate.sortDate)) { 2900 latestESDAggregate = { 2901 esdValue: esdValue, 2902 sortDate: shipRangeInfo.rangeEndDate 2903 }; 2904 } 2905 2906 const deliveryInfo = this.calculateEDDFromESD(esdValue); 2907 if (deliveryInfo && deliveryInfo.latestDelivery && (!latestEDDAggregate || deliveryInfo.latestDelivery > latestEDDAggregate.sortDate)) { 2908 latestEDDAggregate = { 2909 rangeFormatted: deliveryInfo.rangeFormatted, 2910 sortDate: deliveryInfo.latestDelivery 2911 }; 2912 } 2913 } 2914 2915 this._cartPayloadContext = { 2916 ...(this._cartPayloadContext || {}), 2917 timestamp: Date.now(), 2918 esdAggregate: latestESDAggregate, 2919 eddAggregate: latestEDDAggregate 2920 }; 2921 2922 for (let i = 0; i < stockMessages.length; i++) { 2923 const stockMsgEl = stockMessages[i]; 2924 const controlStockMsg = stockMsgEl.getAttribute('data-control-stock-msg'); 2925 const currentStockMsg = controlStockMsg || stockMsgEl.innerHTML; 2926 2927 // Find per-item ESD and SKU from cart row 2928 let esdValue = null; 2929 let itemSku = null; 2930 const cartRow = stockMsgEl.closest('.shopping-cart__table--row'); 2931 if (cartRow) { 2932 const esdInput = cartRow.querySelector('input[name^="SIESDT_"]'); 2933 if (esdInput && esdInput.value) { 2934 esdValue = esdInput.value.trim(); 2935 } 2936 const itemInfoLIs = cartRow.querySelectorAll('.shopping-cart__item--info li'); 2937 itemInfoLIs.forEach(li => { 2938 if (li.textContent.includes('Item#:')) { 2939 itemSku = li.textContent.replace('Item#:', '').trim(); 2940 } 2941 }); 2942 } 2943 2944 if (this.isGiftCardItem(itemSku, null)) { 2945 stockMsgEl.style.display = 'none'; 2946 continue; 2947 } 2948 2949 const eddStockMsg = this.getMinicartStockMessage(currentStockMsg, true, esdValue); 2950 const currentMarkup = (stockMsgEl.innerHTML || '').replace(/\s+/g, ' ').trim(); 2951 const nextMarkup = (eddStockMsg || '').replace(/\s+/g, ' ').trim(); 2952 if (currentMarkup !== nextMarkup) { 2953 stockMsgEl.innerHTML = eddStockMsg; 2954 if (eddStockMsg && eddStockMsg.includes('stock-message__wrapper--edd')) { 2955 stockMsgEl.removeAttribute('style'); 2956 } 2957 } 2958 // Hide Backorder A/B: explicit display overrides CSS .hide-backorder-msg rule 2959 if (eddStockMsg && this.isHideBackorderVariation()) { 2960 stockMsgEl.style.display = 'block'; 2961 } 2962 var p = stockMsgEl.parentElement; 2963 while (p) { 2964 if (p.style.display === 'none') { p.style.display = ''; break; } 2965 p = p.parentElement; 2966 } 2967 } 2968 }, 2969 2970 /** Update checkout SIESDT hidden fields for BE round-trip to OC. Called by submitOrder() in CGBCCART2.html before form submission. Only processes mobile section (inside <form>) â desktop section is outside <form> and not submitted. @returns {void} */ 2971 updateCheckoutESDFields: function() { 2972 const checkoutForm = document.querySelector('form[name="f"]') || document.getElementById('checkoutForm'); 2973 if (!checkoutForm) return; 2974 2975 const stockMessages = checkoutForm.querySelectorAll('.newStockMessage'); 2976 if (!stockMessages.length) return; 2977 2978 const temp = document.createElement('textarea'); 2979 2980 stockMessages.forEach((stockMsgEl) => { 2981 const cartRow = stockMsgEl.closest('.samOPC_row_flex'); 2982 if (!cartRow) return; 2983 2984 const esdInput = cartRow.querySelector('input[name^="SIESDT_"]'); 2985 if (!esdInput) return; 2986 2987 const renderedHTML = stockMsgEl.innerHTML || ''; 2988 if (!renderedHTML.trim()) return; 2989 2990 // Strip SVGs before encoding â they break BE parsing. OC re-injects them. 2991 const htmlToEncode = renderedHTML.replace(/<svg[\s\S]*?<\/svg>/gi, ''); 2992 2993 // HTML-encode so angle brackets survive BE storage (BE strips raw < >) 2994 temp.textContent = htmlToEncode; 2995 esdInput.value = temp.innerHTML; 2996 });
2997 }, 2998 2999 // ===== ORDER CONFIRMATION ===== 3000 3001 /** Decode HTML-encoded stock messages on OC, re-inject SVGs stripped during checkout, and unhide. BE is the sole data source â FE only decodes entities, restores icons, and unhides. @returns {void} */ 3002 processOrderConfirmation: function() { 3003 const stockMsgElements = document.querySelectorAll('[data-target="orderConfirmStockMsg"]'); 3004 if (!stockMsgElements.length) return; 3005 3006 /** Decode HTML entities (handles multiple encoding layers from BE). @param {string} html @returns {string} */ 3007 function decodeHTMLEntities(html) { 3008 if (!html) return html; 3009 const temp = document.createElement('textarea'); 3010 let decoded = html; 3011 let prev = ''; 3012 for (let i = 0; i < 5 && decoded !== prev; i++) { 3013 prev = decoded; 3014 temp.innerHTML = decoded; 3015 decoded = temp.value; 3016 } 3017 return decoded; 3018 } 3019 3020 stockMsgElements.forEach((el) => { 3021 try { 3022 const rawContent = el.innerHTML.trim(); 3023 if (!rawContent) { 3024 el.classList.remove('order-confirm__stock-msg--hidden'); 3025 return; 3026 } 3027 3028 // Decode BE HTML-encoded content back to markup and display 3029 const decoded = decodeHTMLEntities(rawContent); 3030 if (decoded !== rawContent) el.innerHTML = decoded; 3031 3032 // Hide gift card stock messages on order confirmation 3033 const confirmRow = el.closest('.samFlexRow'); 3034 if (confirmRow) { 3035 const titleEl = confirmRow.querySelector('.review__item--name b'); 3036 const itemTitle = titleEl?.textContent?.trim() || null; 3037 if (this.isGiftCardItem(null, itemTitle)) return; 3038 } 3039 3040 // Set stock type on container so CSS can target it (e.g. green for in-stock) 3041 const stockType = window.edd.detectStockStatus(el.textContent || ''); 3042 el.dataset.stockType = stockType; 3043 3044 if (stockType === 'backordered' && this.isHideBackorderVariation()) { 3045 return; 3046 } 3047 3048 // Re-inject SVGs stripped during checkout to prevent BE parsing issues 3049 el.querySelectorAll('.stock-message__dot').forEach(dot => { 3050 if (!dot.querySelector('svg')) dot.innerHTML = window.edd.getColoredDotSVG(); 3051 }); 3052 el.querySelectorAll('.stock-message__info-icon').forEach(icon => { 3053 if (!icon.querySelector('svg')) icon.innerHTML = window.edd.getInfoIconSVG('inline'); 3054 }); 3055 } catch (e) { 3056 // Silent fail â show whatever BE rendered 3057 } 3058 el.classList.remove('order-confirm__stock-msg--hidden'); 3059 }); 3060 }, 3061 3062}; 3063 3064// Initialize on DOM ready 3065(function() { 3066 3067 /** Get or create shared EDDHandler instance without auto-initializing network work. @returns {EDDHandler} */ 3068 function ensureEDDHandler() { 3069 if (!window.eddHandler) { 3070 window.eddHandler = new EDDHandler(); 3071 } 3072 return window.eddHandler; 3073 } 3074 3075 /** Sync window.edd config from constructor-defined EDDHandler config. @param {EDDHandler|null} handlerInstance */ 3076 function syncEDDConfigFromConstructor(handlerInstance = null) { 3077 if (!window.edd) return; 3078 3079 const handler = handlerInstance || ensureEDDHandler(); 3080 const stockMsgDisplayConfig = handler.config?.stockMsgDisplay || {}; 3081 const configSlice = { 3082 deliveryRules: handler.config?.deliveryRules || {}, 3083 checkoutDecisionTable: handler.config?.checkoutDecisionTable || {}, 3084 stockMsgDisplay: { 3085 defaultTooltip: stockMsgDisplayConfig.defaultTooltip || '', 3086 stockMsgText: stockMsgDisplayConfig.stockMsgText || {}, 3087 drawerMessages: stockMsgDisplayConfig.drawerMessages || {} 3088 }, 3089 abTestVariations: handler.config?.abTestVariations || {} 3090 }; 3091 3092 window.edd.config = JSON.parse(JSON.stringify(configSlice)); 3093 } 3094 3095 function init() { 3096 const handler = ensureEDDHandler(); 3097 syncEDDConfigFromConstructor(handler); 3098 3099 window.recalculateEDD = () => handler.recalculateDeliveryEstimate(); 3100 3101 const pageTypeInput = document.querySelector('input[name="PAGETYPE"]'); 3102 const isPDP = pageTypeInput && pageTypeInput.value === 'PDP'; 3103 3104 // Async: refresh transit CSV if expired (non-blocking) 3105 window.edd.ensureTransitDataLoaded(); 3106 3107 if (!isPDP) { 3108 return; 3109 } 3110 3111 if (!handler._isEDDInitialized) { 3112 handler._isEDDInitialized = true; 3113 handler.init(); 3114 } 3115 } 3116 3117 function initCartPageEDD() { 3118 const checkoutForm = document.getElementById("checkoutForm"); 3119 const cartTable = document.querySelector('.shopping-cart__table'); 3120 if (cartTable && window.edd && typeof window.edd.updateCartStockMessages === 'function') { 3121 window.edd.updateCartStockMessages();
3122 return; 3123 } 3124 3125 // Checkout: skip direct updateCheckoutStockMessages() call here. 3126 // CGBCCART2.html already calls onCheckoutFormUpdated('INIT') on $(document).ready, 3127 // which triggers the debounced update. Running both causes a duplicate render pass. 3128 if (checkoutForm) { 3129 return; 3130 } 3131 } 3132 3133 /** Re-render cart/minicart EDD when user updates location in the header tracker */ 3134 function initLocationChangeListener() { 3135 window.addEventListener('userLocationUpdated', () => { 3136 if (!window.edd) return; 3137 3138 // Cart page: recalculate aggregates and re-render all row stock messages 3139 const cartTable = document.querySelector('.shopping-cart__table'); 3140 const checkoutForm = document.getElementById('checkoutForm'); 3141 if (cartTable && !checkoutForm && typeof window.edd.updateCartStockMessages === 'function') { 3142 window.edd.updateCartStockMessages(); 3143 } 3144 }); 3145 } 3146 3147 function initOrderConfirmation() { 3148 if (document.getElementById('confirm__container_top') && window.edd && typeof window.edd.processOrderConfirmation === 'function') { 3149 window.edd.processOrderConfirmation(); 3150 } 3151 } 3152 3153 // Populate transit data cache synchronously at script-eval time so 3154 // getTransitDaysForZip never re-parses localStorage during renders. 3155 // Must run BEFORE DOMContentLoaded â jQuery's $(document).ready in 3156 // CGBCCART2.html may fire onCheckoutFormUpdated('INIT') before init(). 3157 try { 3158 const transitCached = localStorage.getItem('fedexTransitData'); 3159 if (transitCached) { 3160 const parsed = JSON.parse(transitCached); 3161 if (parsed.data) window.edd._transitDataCache = parsed.data; 3162 } 3163 } catch (e) { /* silent */ } 3164 3165 if (document.readyState === 'loading') { 3166 document.addEventListener('DOMContentLoaded', function() { 3167 init(); 3168 initCartPageEDD(); 3169 initLocationChangeListener(); 3170 initOrderConfirmation(); 3171 }); 3172 } else { 3173 init(); 3174 initCartPageEDD(); 3175 initLocationChangeListener(); 3176 initOrderConfirmation(); 3177 } 3178})();
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.