1/******/ (() => { // webpackBootstrap 2/******/ "use strict"; 3 4;// ./src/Gutenberg/analytics/ga4-ecommerce.js 5/** 6 * Shared GA4 ecommerce dataLayer helpers (list impressions). 7 * Kept separate from AddToCart/inc/utils.js so ProductQuery can import 8 * without bundling the full cart utility module. 9 */ 10 11function parseGa4Price(raw) { 12 if (raw === '' || raw == null) { 13 return NaN; 14 } 15 return Number.parseFloat(String(raw).replace(/[^0-9.-]/g, ''), 10); 16} 17function logDataLayerSkip(reason, detail = null) { 18 console.warn('[dataLayer] skipped push', reason, detail); 19} 20 21/** 22 * Offering productCode, including properties.productCode fallback. 23 * 24 * @param {object|null|undefined} offering 25 * @return {string} 26 */ 27function offeringProductCode(offering) { 28 if (!offering || typeof offering !== 'object') { 29 return ''; 30 } 31 const props = offering.properties; 32 const fromProps = props && typeof props === 'object' ? String(props.productCode ?? '').trim() : ''; 33 return fromProps || String(offering.productCode ?? '').trim(); 34} 35 36/** 37 * First listing offering, or the one matching preferredProductCode. 38 * 39 * @param {object|null|undefined} product 40 * @param {string} [preferredProductCode] 41 * @return {object|null} 42 */ 43function firstOffering(product, preferredProductCode = '') { 44 if (!product || typeof product !== 'object') { 45 return null; 46 } 47 const hint = String(preferredProductCode || '').trim(); 48 const offerings = Array.isArray(product.offerings) ? product.offerings : []; 49 if (offerings.length) { 50 if (hint) { 51 const match = offerings.find(row => offeringProductCode(row) === hint); 52 if (match) { 53 return match; 54 } 55 } 56 return offerings[0]; 57 } 58 return offeringProductCode(product) ? product : null; 59} 60 61/** 62 * Build one GA4 list item. Returns null when id/name/price are incomplete. 63 * 64 * @param {{ 65 * item_id?: string, 66 * item_name?: string, 67 * price?: number|string, 68 * index?: number, 69 * item_list_name?: string, 70 * offering?: object|null,
71 * productCode?: string, 72 * }} raw 73 * @return {object|null} 74 */ 75function buildGa4ListItem(raw = {}) { 76 const offering = raw.offering && typeof raw.offering === 'object' ? raw.offering : null; 77 const item_id = String(offeringProductCode(offering) || raw.item_id || raw.productCode || '').trim(); 78 const item_name = String(raw.item_name ?? '').trim(); 79 const price = parseGa4Price(raw.price ?? offering?.price ?? offering?.originalPrice); 80 const index = Number.isFinite(raw.index) && raw.index >= 0 ? raw.index : 0; 81 const item_list_name = String(raw.item_list_name ?? '').trim(); 82 if (!item_id || !item_name || !Number.isFinite(price)) { 83 return null; 84 } 85 const item = { 86 item_id, 87 item_name, 88 price, 89 index, 90 quantity: 1 91 }; 92 if (item_list_name) { 93 item.item_list_name = item_list_name; 94 } 95 return item; 96} 97 98/** 99 * Push a standardized GA4 `view_item_list` event. 100 * 101 * @param {{ 102 * items?: Array<object>, 103 * itemListName?: string, 104 * }} [opts] 105 * @return {boolean} Whether a dataLayer push was attempted. 106 */ 107function pushGa4ViewItemListEvent(opts = {}) { 108 const itemListName = String(opts.itemListName ?? '').trim(); 109 const rawItems = Array.isArray(opts.items) ? opts.items : []; 110 const items = []; 111 for (let i = 0; i < rawItems.length; i += 1) { 112 const row = rawItems[i]; 113 if (!row || typeof row !== 'object') { 114 continue; 115 } 116 const item = buildGa4ListItem({ 117 ...row, 118 index: Number.isFinite(row.index) ? row.index : items.length, 119 item_list_name: row.item_list_name || itemListName 120 }); 121 if (item) { 122 items.push(item); 123 } 124 } 125 if (!items.length) { 126 logDataLayerSkip('view_item_list: no valid GA4 items', { 127 itemListName, 128 rawCount: rawItems.length 129 }); 130 return false; 131 } 132 window.dataLayer = window.dataLayer || []; 133 window.dataLayer.push({ 134 ecommerce: null 135 }); 136 window.dataLayer.push({ 137 event: 'view_item_list', 138 ecommerce: { 139 item_list_name: itemListName, 140 items 141 } 142 }); 143 return true; 144} 145 146/** 147 * Push GA4 `view_item_list` from Marketing product wrappers. 148 * 149 * @param {{ 150 * products?: Array<object>, 151 * itemListName?: string, 152 * resolveOffering?: function(object, string): object|null, 153 * }} [opts] 154 * @return {boolean} Whether a dataLayer push was attempted. 155 */ 156function pushViewItemListAnalytics(opts = {}) { 157 const products = Array.isArray(opts.products) ? opts.products : []; 158 const itemListName = String(opts.itemListName ?? '').trim(); 159 const resolve = typeof opts.resolveOffering === 'function' ? opts.resolveOffering : firstOffering; 160 const items = products.map((product, index) => { 161 if (!product || typeof product !== 'object') { 162 return null; 163 } 164 const preferred = String(product.productCode ?? product.offerings?.[0]?.productCode ?? '').trim(); 165 const offering = resolve(product, preferred); 166 return { 167 offering, 168 productCode: offeringProductCode(offering) || preferred, 169 item_name: String(product.name ?? '').trim(), 170 index, 171 item_list_name: itemListName 172 }; 173 }); 174 return pushGa4ViewItemListEvent({ 175 items, 176 itemListName 177 }); 178} 179;// ./src/Gutenberg/Blocks/AddToCart/inc/utils.js 180/* unused harmony import specifier */ var utils_parseGa4Price; 181/* unused harmony import specifier */ var utils_pushGa4ViewItemListEvent; 182/* unused harmony import specifier */ var pushViewItemListAnalyticsCore; 183 184 185 186/** 187 * Read a cookie by name. 188 * @param {string} name - The name of the cookie. 189 * @return {string|null} - The value of the cookie or null if not found. 190 */ 191function getCookie(name) { 192 const prefix = `${name}=`; 193 const parts = document.cookie.split('; '); 194 for (let i = 0; i < parts.length; i += 1) { 195 const cookie = parts[i].trim(); 196 if (cookie.startsWith(prefix)) { 197 return cookie.slice(prefix.length); 198 } 199 } 200 return null; 201} 202 203/** 204 * Set a cookie with the given name, value, and expiration days. 205 * @param {string} name - The name of the cookie. 206 * @param {string} value - The value of the cookie. 207 * @param {number} days - The number of days until expiration. 208 */ 209function setCookie(name, value = '', days) { 210 const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : ''; 211 const domainParts = location.host.split('.').reverse(); 212 const domain = domainParts.length > 1 ? `; domain=.${domainParts[1]}.${domainParts[0]}` : ''; 213 document.cookie = `${name}=${value}${expires}${domain}; path=/`; 214} 215 216/** 217 * Delete a cookie by name. 218 * @param {string} name - The name of the cookie to delete. 219 */ 220function deleteCookie(name) { 221 document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; 222} 223let cachedUser = null; 224let cachedUserCookieString = null; 225 226/**
227 * Cached snapshot of the user's status derived from cookies. The cache is 228 * invalidated automatically whenever document.cookie changes (cheap string 229 * compare instead of re-parsing every cookie variable on every call). 230 */ 231function freshUserStatus() { 232 if (cachedUser !== null && cachedUserCookieString === document.cookie) { 233 return cachedUser; 234 } 235 cachedUserCookieString = document.cookie; 236 const studentId = getCookie('STYXKEY-McKissock-UserId'); 237 const cartId = getCookie('STYXKEY-shoppingcartid'); 238 const role = getCookie('STYXKEY-McKissock-Role'); 239 let isLoggedIn; 240 if (studentId && role && role !== 'Guest') { 241 isLoggedIn = true; 242 } else if (role === 'Guest') { 243 isLoggedIn = 'Guest'; 244 } else { 245 isLoggedIn = false; 246 } 247 cachedUser = { 248 studentId: studentId || '', 249 cartId: cartId || '', 250 role: role || '', 251 isLoggedIn 252 }; 253 return cachedUser; 254} 255const DATA_STORE_KEY = '__colibriProductButtonData'; 256function normalizeElements(value) { 257 if (!value) { 258 return []; 259 } 260 if (value instanceof Element || value === window || value === document) { 261 return [value]; 262 } 263 if (Array.isArray(value)) { 264 return value.filter(Boolean); 265 } 266 if (value instanceof NodeList || value instanceof HTMLCollection) { 267 return Array.from(value); 268 } 269 return [value]; 270} 271function getElementData(element, key) { 272 if (!element) { 273 return undefined; 274 } 275 const localData = element[DATA_STORE_KEY]; 276 if (localData && Object.prototype.hasOwnProperty.call(localData, key)) { 277 return localData[key]; 278 } 279 return undefined; 280} 281function setElementData(element, key, value) { 282 if (!element) { 283 return value; 284 } 285 const store = element[DATA_STORE_KEY] || (element[DATA_STORE_KEY] = {}); 286 store[key] = value; 287 return value; 288} 289function removeElementData(element, key) { 290 if (!element || !element[DATA_STORE_KEY]) { 291 return; 292 } 293 delete element[DATA_STORE_KEY][key]; 294} 295function parseJSONAttribute(element, attributeName, fallback = {}) { 296 const rawValue = element?.getAttribute(`data-${attributeName}`); 297 if (!rawValue) { 298 return fallback; 299 } 300 try { 301 return JSON.parse(rawValue); 302 } catch (error) { 303 console.error(`Unable to parse data-${attributeName}`, error); 304 return fallback; 305 } 306} 307function emitEvent(target, name, detail = null) { 308 normalizeElements(target).forEach(currentTarget => { 309 if (currentTarget?.dispatchEvent) { 310 currentTarget.dispatchEvent(new CustomEvent(name, { 311 detail, 312 bubbles: true 313 })); 314 } 315 }); 316} 317 318/** 319 * Marketing API origin for learner endpoints (no trailing slash). 320 * Prefers `window.colibri.marketingApi.baseUrl`, then `settings.baseUri`. 321 * 322 * @return {string} 323 */ 324function getMarketingApiBaseUrl() { 325 const m = window.colibri?.marketingApi; 326 const raw = m && (m.baseUrl || m.settings?.baseUri) || ''; 327 return String(raw || '').replace(/\/+$/, ''); 328} 329 330/** 331 * Headers for direct browser calls to Marketing / learner APIs: X-Host + Authorization 332 * (Bearer from access_token cookie, else xcred from X-Credential), aligned with 333 * server-side Marketing API config. 334 * 335 * @return {Record<string, string>} 336 */ 337function buildMarketingLearnerRequestHeaders() { 338 const settings = window.colibri?.marketingApi?.settings || {}; 339 const xHost = settings.xHost || ''; 340 const headers = { 341 Accept: 'application/json', 342 'Content-Type': 'application/json' 343 }; 344 if (xHost) { 345 headers['X-Host'] = xHost; 346 } 347 const access = getCookie('access_token') || getCookie('STYXKEY-access_token'); 348 const xcred = getCookie('X-Credential') || getCookie('STYXKEY-X-Credential'); 349 if (access) { 350 headers.Authorization = `Bearer ${access}`; 351 } else if (xcred) { 352 headers.Authorization = `xcred ${xcred}`; 353 } 354 return headers; 355} 356 357/** 358 * Cart CTAs must read {@see price}, {@see subscriptionId}, etc. from an **offering** row, 359 * not a Marketing *product* wrapper. Mirrors `getOfferingFromProduct()` in theme 360 * {@see cl2-pdp-offering.js}: match `offerings[].productCode` to `preferredProductCode`, 361 * otherwise the first offering. 362 * 363 * @param {unknown} value 364 * @param {string} [preferredProductCode] 365 * @return {object|null} 366 */
367function resolveMarketingProductOfferingRow(value, preferredProductCode = '') { 368 if (!value || typeof value !== 'object') { 369 return null; 370 } 371 const hint = String(preferredProductCode || '').trim(); 372 const rowProductCode = o => { 373 if (!o || typeof o !== 'object') { 374 return ''; 375 } 376 const props = o.properties; 377 const fromProps = props && typeof props === 'object' ? String(props.productCode ?? '').trim() : ''; 378 return fromProps || String(o.productCode ?? '').trim(); 379 }; 380 if (Array.isArray(value.offerings) && value.offerings.length) { 381 if (hint) { 382 const match = value.offerings.find(o => rowProductCode(o) === hint); 383 if (match) { 384 return match; 385 } 386 } 387 return /** @type {object} */value.offerings[0]; 388 } 389 return /** @type {object} */value; 390} 391 392/** 393 * Whether an offering payload matches the sku string against `sku`, `offeringId`, or `id`. 394 * 395 * @param {unknown} offering 396 * @param {string} skuTrimmed Already normalized with {@see String.trim()}. 397 * @return {boolean} 398 */ 399function marketingOfferingMatchesSkuKey(offering, skuTrimmed) { 400 if (!offering || typeof offering !== 'object' || !skuTrimmed) { 401 return false; 402 } 403 const keys = ['sku', 'offeringId', 'id']; 404 for (const k of keys) { 405 if (Object.prototype.hasOwnProperty.call(offering, k) && String(offering[k] ?? '').trim() === skuTrimmed) { 406 return true; 407 } 408 } 409 const props = offering.properties; 410 if (props && typeof props === 'object') { 411 for (const k of ['sku', 'offeringId']) { 412 if (Object.prototype.hasOwnProperty.call(props, k) && String(props[k] ?? '').trim() === skuTrimmed) { 413 return true; 414 } 415 } 416 } 417 return false; 418} 419 420/** 421 * Pick one offering row for CTA logic: honor {@see sku} when present, 422 * otherwise match {@see preferredProductCode} / first listing (Marketing product wrapper). 423 * 424 * @param {unknown} wrapper Product `{ offerings[] }`, a single offering, or PDP map row. 425 * @param {string} [sku] 426 * @param {string} [preferredProductCode] 427 * @return {object|null} 428 */ 429function resolveMarketingOfferingForCta(wrapper, sku = '', preferredProductCode = '') { 430 if (!wrapper || typeof wrapper !== 'object') { 431 return null; 432 } 433 const skuClean = String(sku || '').trim(); 434 const hints = Array.isArray(wrapper.offerings) ? wrapper.offerings : []; 435 if (hints.length) { 436 if (skuClean) { 437 const hit = hints.find(row => row && typeof row === 'object' && marketingOfferingMatchesSkuKey(row, skuClean)); 438 if (hit) { 439 return hit; 440 } 441 return null; 442 } 443 return resolveMarketingProductOfferingRow(wrapper, preferredProductCode); 444 } 445 if (skuClean) { 446 return marketingOfferingMatchesSkuKey(wrapper, skuClean) ? (/** @type {object} */wrapper) : null; 447 } 448 return resolveMarketingProductOfferingRow(wrapper, preferredProductCode); 449} 450 451/** 452 * True when ProductProperties exposes a truthy "covered by plan" hint â supplementary only. 453 * Absence or falseness here does **not** prove the SKU is outside a subscription catalog row: 454 * {@see isMarketingOfferingIncludedInSubscriptionPlan} on Marketing offerings is authoritative for that case. 455 * 456 * @param {Record<string, *>|null|undefined} props 457 * @return {boolean} 458 */ 459function isSubscriptionCoverageFromProductProps(props) { 460 if (!props || typeof props !== 'object') { 461 return false; 462 } 463 const truthyScalar = v => { 464 if (v === true) { 465 return true; 466 } 467 if (v === false || v == null || v === '') { 468 return false; 469 } 470 if (typeof v === 'string') { 471 const t = v.trim().toLowerCase(); 472 return t === 'true' || t === '1' || t === 'yes' || t === 'y'; 473 } 474 return false; 475 }; 476 const keys = ['inMembership', 'includedInSubscription']; 477 for (const key of keys) { 478 if (Object.prototype.hasOwnProperty.call(props, key)) { 479 if (truthyScalar(props[key])) { 480 return true; 481 } 482 } 483 } 484 return false; 485} 486 487/** 488 * Subscription-catalog / included-in-plan SKU per Marketing API offering payload: 489 * price === 0 and subscriptionId !== 0. 490 * 491 * @param {object|null|undefined} offering Resolved Marketing offering row (camelCase). 492 * @return {boolean} 493 */ 494function isMarketingOfferingIncludedInSubscriptionPlan(offering) { 495 if (!offering || typeof offering !== 'object') { 496 return false;
497 } 498 const rawPrice = offering.price ?? offering.unitPrice; 499 const rawSub = offering.subscriptionId; 500 const price = rawPrice === '' || rawPrice == null ? NaN : Number.parseFloat(String(rawPrice).replace(/[^0-9.-]/g, ''), 10); 501 const subscriptionId = Number(rawSub ?? 0); 502 return Number.isFinite(price) && price === 0 && Number.isFinite(subscriptionId) && subscriptionId !== 0; 503} 504function requestJSON({ 505 url, 506 method = 'POST', 507 data = null, 508 withCredentials = false, 509 headers = { 510 'Content-Type': 'application/json' 511 } 512}) { 513 return fetch(url, { 514 method, 515 headers, 516 credentials: withCredentials ? 'include' : 'same-origin', 517 body: data != null ? JSON.stringify(data) : null 518 }).then(async response => { 519 const xhr = { 520 status: response.status, 521 statusText: response.statusText 522 }; 523 if (!response.ok) { 524 return Promise.reject({ 525 statusText: 'error', 526 xhr 527 }); 528 } 529 const text = await response.text(); 530 let body = null; 531 if (text) { 532 try { 533 body = JSON.parse(text); 534 } catch (error) { 535 return Promise.reject({ 536 statusText: 'error', 537 xhr, 538 error 539 }); 540 } 541 } 542 return { 543 data: body, 544 statusText: 'success', 545 xhr 546 }; 547 }, error => Promise.reject({ 548 statusText: 'error', 549 xhr: null, 550 error 551 })); 552} 553function isVisible(element) { 554 return !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); 555} 556function escapeSelectorValue(value) { 557 if (window.CSS?.escape) { 558 return window.CSS.escape(value); 559 } 560 return String(value).replace(/["\\]/g, '\\$&'); 561} 562function utils_logDataLayerSkip(reason, detail = null) { 563 console.warn('[dataLayer] skipped push', reason, detail); 564} 565 566/** 567 * Push a standardized GA4 `add_to_cart` event after a successful cart action. 568 * Payload fields are read directly from the Marketing offering (and product display name). 569 * 570 * @param {{
571 * productCode?: string, 572 * offering?: object|null, 573 * productName?: string, 574 * quantity?: number, 575 * }} [opts] 576 * @return {boolean} Whether a dataLayer push was attempted. 577 */ 578function pushGa4AddToCartEvent(opts = {}) { 579 const offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 580 if (!offering) { 581 utils_logDataLayerSkip('add_to_cart: missing offering', opts); 582 return false; 583 } 584 const item_id = String(offering.productCode ?? opts.productCode ?? '').trim(); 585 const item_name = String(opts.productName ?? '').trim(); 586 const price = utils_parseGa4Price(offering.price ?? offering.originalPrice); 587 const quantity = Number.isFinite(opts.quantity) && opts.quantity > 0 ? opts.quantity : 1; 588 if (!item_id || !item_name || !Number.isFinite(price)) { 589 utils_logDataLayerSkip('add_to_cart: invalid GA4 item payload', { 590 item_id, 591 item_name, 592 price, 593 quantity, 594 productCode: opts.productCode 595 }); 596 return false; 597 } 598 window.dataLayer = window.dataLayer || []; 599 const value = Math.round(price * quantity * 100) / 100; 600 window.dataLayer.push({ 601 ecommerce: null 602 }); 603 window.dataLayer.push({ 604 event: 'add_to_cart', 605 ecommerce: { 606 currency: 'USD', 607 value, 608 items: [{ 609 item_id, 610 item_name, 611 price, 612 quantity 613 }] 614 } 615 }); 616 return true; 617} 618 619/** 620 * Push GA4 `add_to_cart` from Marketing offering + product display name. 621 * 622 * @param {{
623 * productCode?: string, 624 * offering?: object|null, 625 * productName?: string, 626 * quantity?: number, 627 * }} [opts] 628 * @return {boolean} Whether a dataLayer push was attempted. 629 */ 630function pushAddToCartAnalytics(opts = {}) { 631 return pushGa4AddToCartEvent(opts); 632} 633const GA4_VIEW_ITEM_TYPES = new Set(['detail', 'quick', 'option']); 634 635/** 636 * Push a standardized GA4 `view_item` event when a product is viewed. 637 * 638 * @param {{ 639 * productCode?: string, 640 * offering?: object|null, 641 * productName?: string, 642 * viewType?: 'detail'|'quick'|'option', 643 * }} [opts] 644 * @return {boolean} Whether a dataLayer push was attempted. 645 */ 646function pushGa4ViewItemEvent(opts = {}) { 647 const offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 648 if (!offering) { 649 utils_logDataLayerSkip('view_item: missing offering', opts); 650 return false; 651 } 652 const viewType = GA4_VIEW_ITEM_TYPES.has(opts.viewType) ? opts.viewType : 'detail'; 653 const item_id = String(offering.productCode ?? opts.productCode ?? '').trim(); 654 const item_name = String(opts.productName ?? '').trim(); 655 const price = parseGa4Price(offering.price ?? offering.originalPrice); 656 if (!item_id || !item_name || !Number.isFinite(price)) { 657 utils_logDataLayerSkip('view_item: invalid GA4 item payload', { 658 item_id, 659 item_name, 660 price, 661 viewType,
662 productCode: opts.productCode 663 }); 664 return false; 665 } 666 window.dataLayer = window.dataLayer || []; 667 window.dataLayer.push({ 668 ecommerce: null 669 }); 670 window.dataLayer.push({ 671 event: 'view_item', 672 view_type: viewType, 673 ecommerce: { 674 currency: 'USD', 675 items: [{ 676 item_id, 677 item_name, 678 price 679 }] 680 } 681 }); 682 return true; 683} 684 685/** 686 * Push GA4 `view_item` from Marketing offering + product display name. 687 * 688 * @param {{ 689 * productCode?: string, 690 * offering?: object|null, 691 * product?: object|null, 692 * productName?: string, 693 * viewType?: 'detail'|'quick'|'option', 694 * }} [opts] 695 * @return {boolean} Whether a dataLayer push was attempted. 696 */ 697function pushViewItemAnalytics(opts = {}) { 698 const productCode = String(opts.productCode || '').trim(); 699 let offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 700 let productName = String(opts.productName ?? '').trim(); 701 const product = opts.product && typeof opts.product === 'object' ? opts.product : null; 702 if (product && (!offering || !productName)) { 703 offering = offering || resolveMarketingOfferingForCta(product, '', productCode); 704 if (!productName) { 705 productName = String(product.name ?? '').trim(); 706 } 707 } 708 return pushGa4ViewItemEvent({ 709 productCode, 710 offering, 711 productName, 712 viewType: opts.viewType 713 }); 714} 715 716/** 717 * Push GA4 `view_item_list` from Marketing products (listing impressions). 718 * 719 * @param {{ 720 * products?: Array<object>, 721 * items?: Array<object>, 722 * itemListName?: string, 723 * resolveOffering?: function(object, string): object|null, 724 * }} [opts] 725 * @return {boolean} 726 */ 727function utils_pushViewItemListAnalytics(opts = {}) { 728 if (Array.isArray(opts.items) && opts.items.length) { 729 return utils_pushGa4ViewItemListEvent(opts); 730 } 731 return pushViewItemListAnalyticsCore({ 732 ...opts, 733 resolveOffering: opts.resolveOffering || ((product, code) => resolveMarketingOfferingForCta(product, '', code)) 734 }); 735} 736function pushAnalyticsData(productCode, action, productsInList, options) { 737 if (action === 'add_to_cart' && productCode && productCode !== 'list') { 738 pushAddToCartAnalytics({ 739 productCode, 740 offering: options?.offering ?? null, 741 productName: options?.productName ?? '', 742 quantity: options?.quantity ?? 1 743 }); 744 return; 745 } 746 if (action === 'view_item' && productCode && productCode !== 'list') { 747 pushViewItemAnalytics({ 748 productCode, 749 offering: options?.offering ?? null, 750 product: options?.product ?? null, 751 productName: options?.productName ?? '', 752 viewType: options?.viewType ?? 'detail' 753 }); 754 return; 755 } 756 if (action === 'view_item_list' || productCode === 'list') { 757 utils_pushViewItemListAnalytics({ 758 products: Array.isArray(productsInList) ? productsInList : [], 759 items: options?.items, 760 itemListName: options?.itemListName ?? options?.item_list_name ?? '', 761 resolveOffering: options?.resolveOffering 762 }); 763 return; 764 } 765 window.colibri?.utils?.pushAnalyticsData?.(productCode, action, productsInList); 766} 767 768/** 769 * Student ProductProperties and marketing SKU prefetch must use the helpers in this 770 * section (`shouldFetchMarketingApisForAddToCartDomScope`, `buildCartMarketingStudentPrefetchGateSets`, 771 * and related exports) so `select.select-state` stays aligned with `button[data-product-code]`. 772 * Batched prefetch in `product-loader.ts` builds a document snapshot via `buildCartMarketingStudentPrefetchGateSets`; 773 * `ProductButtonMediator` applies the same gate per registered `[data-legacy-add-to-cart]` control 774 * (`productCodesEligibleForMarketingStatusFetch`). 775 */ 776 777/** 778 * When the closest PDP cart wrapper contains `select.select-state`, defer Marketing 779 * ProductProperties + product-by-code hydration until the select value matches the 780 * cart button `data-product-code` (avoids fetching for a stale SSR code). 781 * 782 * @param {Element|null|undefined} scopeEl Element inside `[data-legacy-add-to-cart]`, `[data-core-add-to-cart]`, etc. 783 * @param {Element|null|undefined} [buttonEl] Optional `button[data-product-code]` (Core passes the concrete button). 784 * @return {boolean} `false` â omit ProductProperties / `products` GET-by-code for this scope. 785 */ 786function shouldFetchMarketingApisForAddToCartDomScope(scopeEl, buttonEl = null) { 787 if (!(scopeEl instanceof Element)) { 788 return true; 789 } 790 if (scopeEl.closest('.upsell-block') || buttonEl instanceof Element && buttonEl.closest('.upsell-block')) { 791 return false;
792 } 793 const wrap = scopeEl.closest('.add-to-cart-container, .individual-add-to-cart-container'); 794 if (!wrap) { 795 return true; 796 } 797 if (wrap.closest('.upsell-block')) { 798 return false; 799 } 800 const stateSelect = wrap.querySelector('select.select-state'); 801 if (!stateSelect) { 802 return true; 803 } 804 const btn = buttonEl instanceof Element ? buttonEl : wrap.querySelector('button[data-product-code]'); 805 const buttonCode = String(btn?.getAttribute?.('data-product-code') ?? '').trim(); 806 const selected = String(/** @type {HTMLSelectElement} */stateSelect.value ?? '').trim(); 807 if (wrap.querySelector('select.select-seminar-offering, select.select-session, select.select-parent-seminar')) { 808 return !!selected; 809 } 810 return selected === buttonCode; 811} 812 813/** 814 * Locate legacy `[data-legacy-add-to-cart]` or Core `[data-core-add-to-cart]` controls whose 815 * primary SKU matches `productCode`, optionally scoped to a PDP subtree (`scopeRoot`). 816 * 817 * @param {string} productCode 818 * @param {Element|null} scopeRoot e.g. `[data-colibri-pdp]` 819 * @return {{ scope: Element, button: Element } | null} 820 */ 821function findPrimaryCartGateTargetsForProductCode(productCode, scopeRoot = null) { 822 const code = String(productCode || '').trim(); 823 if (!code) { 824 return null; 825 } 826 const scanRoots = scopeRoot instanceof Element ? [scopeRoot] : []; 827 if (!scanRoots.length) { 828 return null; 829 } 830 for (let r = 0; r < scanRoots.length; r++) { 831 const root = scanRoots[r]; 832 const legacyRoots = root.querySelectorAll('[data-legacy-add-to-cart]'); 833 for (let i = 0; i < legacyRoots.length; i++) { 834 const lr = legacyRoots[i]; 835 if (lr.closest('.upsell-block')) { 836 continue; 837 } 838 const btn = lr.querySelector('button[data-product-code]'); 839 const primary = String(btn && btn.getAttribute('data-product-code') || lr.getAttribute('data-product-code') || '').trim(); 840 if (primary !== code) { 841 continue; 842 } 843 if (btn instanceof Element) { 844 return { 845 scope: lr, 846 button: btn 847 }; 848 } 849 } 850 const coreBtns = root.querySelectorAll('.individual-add-to-cart-container[data-cart-experience="core"] [data-core-add-to-cart] button[data-product-code]'); 851 for (let i = 0; i < coreBtns.length; i++) { 852 const btn = coreBtns[i]; 853 if (!(btn instanceof Element)) { 854 continue; 855 } 856 const host = btn.closest('.individual-add-to-cart-container'); 857 if (!host || host.closest('.upsell-block')) { 858 continue; 859 } 860 const primary = String(btn.getAttribute('data-product-code') || '').trim(); 861 if (primary !== code) { 862 continue; 863 } 864 const wrap = btn.closest('[data-core-add-to-cart]'); 865 if (wrap instanceof Element) { 866 return { 867 scope: wrap, 868 button: btn 869 }; 870 } 871 } 872 } 873 return null; 874} 875 876/** 877 * One DOM pass for Student ProductProperties / prefetch eligibility: 878 * SKUs tied to cart UI vs SKUs allowed while {@see shouldFetchMarketingApisForAddToCartDomScope} passes. 879 * 880 * Core rows use `getCoreButtonProductCode(btn)` so orchestrated PDP can follow the equivalent-offering 881 * `<select>` (see `effectiveCorePdpButtonProductCodeForPrefetch` in product-loader). 882 * 883 * @param {(btn: Element) => string} [getCoreButtonProductCode] 884 * @return {{ trackedCodes: Set<string>, eligibleCodes: Set<string> }} 885 */ 886function buildCartMarketingStudentPrefetchGateSets(getCoreButtonProductCode) { 887 const trackedCodes = new Set(); 888 const eligibleCodes = new Set(); 889 const resolveCoreCode = typeof getCoreButtonProductCode === 'function' ? getCoreButtonProductCode : btn => String(btn.getAttribute?.('data-product-code') ?? '').trim(); 890 const legacyRoots = typeof document !== 'undefined' ? document.querySelectorAll('[data-legacy-add-to-cart]') : []; 891 for (let i = 0; i < legacyRoots.length; i++) { 892 const root = legacyRoots[i]; 893 const btn = root.querySelector?.('button[data-product-code]'); 894 const alt = String(root.getAttribute?.('data-alt-product-code') || '').trim(); 895 const primary = String(btn && btn.getAttribute('data-product-code') || root.getAttribute?.('data-product-code') || '').trim(); 896 const passes = shouldFetchMarketingApisForAddToCartDomScope(root, btn instanceof Element ? btn : null);
897 for (const code of [alt, primary].filter(Boolean)) { 898 trackedCodes.add(code); 899 if (passes) { 900 eligibleCodes.add(code); 901 } 902 } 903 } 904 const upsellCoreBtns = typeof document !== 'undefined' ? document.querySelectorAll('.upsell-block [data-core-add-to-cart] button[data-product-code]') : []; 905 for (let i = 0; i < upsellCoreBtns.length; i++) { 906 const btn = upsellCoreBtns[i]; 907 if (!(btn instanceof Element)) { 908 continue; 909 } 910 const code = String(btn.getAttribute('data-product-code') || '').trim(); 911 if (code) { 912 trackedCodes.add(code); 913 } 914 } 915 const coreBtns = typeof document !== 'undefined' ? document.querySelectorAll('.individual-add-to-cart-container[data-cart-experience="core"] [data-core-add-to-cart] button[data-product-code]') : []; 916 for (let i = 0; i < coreBtns.length; i++) { 917 const btn = coreBtns[i]; 918 if (!(btn instanceof Element)) { 919 continue; 920 } 921 const host = btn.closest('.individual-add-to-cart-container'); 922 if (!host || host.closest('.upsell-block')) { 923 continue; 924 } 925 const effective = String(resolveCoreCode(btn) || '').trim(); 926 if (!effective) { 927 continue; 928 } 929 trackedCodes.add(effective); 930 /* Core commerce CTAs resolve from Marketing product detail + Core cart â never Student ProductProperties. */ 931 } 932 return { 933 trackedCodes, 934 eligibleCodes 935 }; 936} 937 938/** 939 * @param {string} code 940 * @param {{ trackedCodes: Set<string>, eligibleCodes: Set<string> }} sets 941 * @return {boolean} 942 */ 943function shouldStudentPrefetchProductPropertiesForProductCode(code, sets) { 944 const c = String(code || '').trim(); 945 if (!c) { 946 return false; 947 } 948 if (!sets || !sets.trackedCodes || !sets.eligibleCodes) { 949 return true; 950 } 951 if (!sets.trackedCodes.has(c)) { 952 return true; 953 } 954 return sets.eligibleCodes.has(c); 955} 956 957/** 958 * @param {(btn: Element) => string} [getCoreButtonProductCode] 959 * @return {string[]} 960 */ 961function collectEligibleStudentPrefetchCartProductCodes(getCoreButtonProductCode) { 962 const { 963 eligibleCodes 964 } = buildCartMarketingStudentPrefetchGateSets(getCoreButtonProductCode); 965 return [...eligibleCodes]; 966} 967 968/** 969 * Primary core add-to-cart control on an orchestrated PDP (`[data-colibri-pdp]`). 970 * Mirrors the cart-host checks in `core-add-to-cart-view.js` / {@see maybeDispatchPdpProductResolvedFromLoader}: 971 * used when `select.select-state` lags localStorage / SSR `data-product-code` so we still allow 972 * Marketing loader + Student ProductProperties for the button SKU. 973 * 974 * @param {Element|null|undefined} buttonEl 975 * @return {boolean} 976 */ 977function isPrimaryColibriPdpCoreCartButton(buttonEl) { 978 if (!(buttonEl instanceof Element)) { 979 return false; 980 } 981 if (!document.querySelector('[data-colibri-pdp]')) { 982 return false; 983 } 984 const cart = buttonEl.closest('.individual-add-to-cart-container'); 985 if (!cart || cart.closest('.upsell-block')) { 986 return false; 987 } 988 if (String(cart.dataset.cartExperience || '').toLowerCase() !== 'core') { 989 return false; 990 } 991 return !!cart.closest('[data-colibri-pdp]'); 992} 993 994/** 995 * Comparison grid SSR already resolves product + offering; enable Core CTAs when 996 * `data-product-code` and `data-sku` are present without waiting on client catalogue hydration. 997 * 998 * @param {Element|null|undefined} buttonEl 999 * @return {boolean} 1000 */ 1001function isComparisonGridListingCoreCartButton(buttonEl) { 1002 if (!(buttonEl instanceof Element)) { 1003 return false; 1004 } 1005 if (isPrimaryColibriPdpCoreCartButton(buttonEl)) { 1006 return false; 1007 } 1008 if (!buttonEl.closest('.product-comparison-view')) { 1009 return false; 1010 } 1011 const wrap = buttonEl.closest('[data-core-add-to-cart]'); 1012 const code = String(buttonEl.dataset.productCode || wrap && wrap.dataset.productCode || buttonEl.getAttribute('data-product-code') || '').trim(); 1013 const sku = String(buttonEl.dataset.sku || buttonEl.getAttribute('data-sku') || '').trim(); 1014 return !!code && !!sku; 1015} 1016const COOKIE_DEBUG_NAMES = (/* unused pure expression or super */ null && (['McKissock-LongExpiration', 'McKissock-Role', 'McKissock-Token', 'McKissock-UserId', 'shoppingcartid', 'STYXKEY-McKissock-LongExpiration', 'STYXKEY-McKissock-Role', 'STYXKEY-McKissock-Token', 'STYXKEY-McKissock-UserId', 'STYXKEY-partnershipid', 'STYXKEY-shoppingcartid', 'X-Credential'])); 1017 1018// Capture cart cookies on partnership group pages 1019function registerCookieLogging() { 1020 const body = document.body; 1021 if (!body || !body.classList.contains('retail-group-template') && !body.classList.contains('university-group-template')) { 1022 return; 1023 } 1024 body.addEventListener('ColibriEvent:DO_ADD_TO_CART_SUCCESS', () => { 1025 COOKIE_DEBUG_NAMES.forEach(name => { 1026 const value = getCookie(name); 1027 console.log(name, ': ', value || 'Not Set'); 1028 }); 1029 }, { 1030 once: true 1031 }); 1032} 1033;// ./src/Gutenberg/Blocks/AddToCart/inc/mode-cta-overlay-navigation.js 1034 1035 1036/** 1037 * Resolve the overlay `<a.mode-cta>` when the native target is nested (e.g. glyph/pseudo-node). 1038 * 1039 * @param {Event} event Click event. 1040 * @return {Element|null} 1041 */ 1042function resolveModeCtaOverlayFromEvent(event) { 1043 const matchSelector = '.mode-cta.is-dashboard, .mode-cta.is-checkout'; 1044 if (typeof event.composedPath === 'function') { 1045 for (const node of event.composedPath()) { 1046 if (node instanceof Element && node.matches(matchSelector)) { 1047 return node; 1048 } 1049 if (node === document) { 1050 break; 1051 } 1052 } 1053 return null; 1054 } 1055 const raw = event.target; 1056 const base = raw instanceof Element ? raw : raw?.parentElement ?? null; 1057 if (!base) { 1058 return null; 1059 } 1060 return base.closest('.mode-cta.is-dashboard') ?? base.closest('.mode-cta.is-checkout'); 1061} 1062 1063/** 1064 * @return {{ med: Record<string, unknown>, settings: Record<string, unknown> }} 1065 */
1066function getOverlayNavSettings() { 1067 const med = window.colibri?.ctaMediator && typeof window.colibri.ctaMediator === 'object' ? window.colibri.ctaMediator : {}; 1068 const settings = window.colibri?.marketingApi?.settings && typeof window.colibri.marketingApi.settings === 'object' ? window.colibri.marketingApi.settings : {}; 1069 return { 1070 med, 1071 settings 1072 }; 1073} 1074 1075/** 1076 * @param {Element|null} queryBlock 1077 * @param {HTMLButtonElement|null} coreButton 1078 * @param {Record<string, unknown>} med 1079 * @param {Record<string, unknown>} settings 1080 * @return {string} 1081 */ 1082function resolveOverlayCheckoutUrl(queryBlock, coreButton, med, settings) { 1083 const isCoreCart = window.colibri?.cartExperience === 'core'; 1084 return isCoreCart ? typeof settings.cartPageUri === 'string' ? settings.cartPageUri : '' : typeof settings.legacyCartPageUrl === 'string' ? settings.legacyCartPageUrl : ''; 1085} 1086 1087/** 1088 * @param {Element|null} queryBlock 1089 * @param {HTMLButtonElement|null} coreButton 1090 * @param {Element|null} coreCtrl 1091 * @param {Record<string, unknown>} med 1092 * @param {Record<string, unknown>} settings 1093 * @return {string} 1094 */ 1095function resolveOverlayDashboardUrl(queryBlock, coreButton, coreCtrl, med, settings) { 1096 const enrollmentId = Number(coreButton?.dataset?.enrollmentId ?? coreCtrl?.dataset?.enrollmentId ?? 0); 1097 const productCode = coreButton?.dataset?.productCode ?? ''; 1098 const isMembership = productCode.split('-')[1] === '8'; 1099 const isCoreCart = window.colibri?.cartExperience === 'core'; 1100 const dashboardBaseUrl = isCoreCart ? typeof settings.dashboardUri === 'string' ? settings.dashboardUri : '' : typeof settings.legacyDashboardUrl === 'string' ? settings.legacyDashboardUrl : ''; 1101 if (isCoreCart && !isMembership && Number.isFinite(enrollmentId) && enrollmentId !== 0 && dashboardBaseUrl) { 1102 return `${String(dashboardBaseUrl).replace(/\/+$/, '')}/course/${enrollmentId}`; 1103 } 1104 return dashboardBaseUrl; 1105} 1106 1107/** 1108 * @param {HTMLButtonElement} coreButton 1109 * @param {Element|null} coreCtrl 1110 * @param {Element|null} queryBlock 1111 * @param {boolean} checkoutAnchor 1112 * @param {boolean} dashboardAnchor 1113 * @return {boolean} 1114 */ 1115function navigateCoreModeCtaOverlay(coreButton, coreCtrl, queryBlock, checkoutAnchor, dashboardAnchor) { 1116 const { 1117 med, 1118 settings 1119 } = getOverlayNavSettings(); 1120 if (checkoutAnchor) { 1121 const url = resolveOverlayCheckoutUrl(queryBlock, coreButton, med, settings); 1122 if (url) { 1123 window.location.assign(url); 1124 return true; 1125 } 1126 return false; 1127 } 1128 if (dashboardAnchor) { 1129 const url = resolveOverlayDashboardUrl(queryBlock, coreButton, coreCtrl, med, settings); 1130 if (url) { 1131 window.location.assign(url); 1132 return true; 1133 } 1134 } 1135 return false; 1136} 1137 1138/** 1139 * Card done-state for overlay CTAs (`data-product-status` or legacy `data-mode`). 1140 * 1141 * @param {Element} card 1142 * @return {string} 1143 */ 1144function getCardOverlayDoneStatus(card) { 1145 return card.getAttribute('data-product-status') || card.getAttribute('data-mode') || ''; 1146} 1147 1148/** 1149 * Grid/list square cards: full-overlay `.mode-cta` links use `href="#"` historically. 1150 * Must load on **core-only** listings too (not only `add-to-cart-view.js` / legacy bootstrap). 1151 * 1152 * @return {void} 1153 */ 1154function bindModeCtaOverlayNavigation() { 1155 if (window.__colibriModeCtaOverlayBound) { 1156 return; 1157 } 1158 window.__colibriModeCtaOverlayBound = true; 1159 document.addEventListener('click', event => { 1160 const anchor = resolveModeCtaOverlayFromEvent(event); 1161 if (!anchor) { 1162 return; 1163 } 1164 const card = anchor.closest('.card'); 1165 if (!card) { 1166 return; 1167 } 1168 const status = getCardOverlayDoneStatus(card); 1169 let checkoutAnchor = false; 1170 let dashboardAnchor = false; 1171 if (anchor.classList.contains('is-checkout')) { 1172 checkoutAnchor = status === 'sellable_done'; 1173 } 1174 if (anchor.classList.contains('is-dashboard')) { 1175 dashboardAnchor = status === 'enrollable_done'; 1176 } 1177 if (!checkoutAnchor && !dashboardAnchor) { 1178 return; 1179 } 1180 event.preventDefault(); 1181 event.stopPropagation(); 1182 event.stopImmediatePropagation(); 1183 const legacyCtrl = card.querySelector('[data-legacy-add-to-cart]'); 1184 const instance = legacyCtrl ? getElementData(legacyCtrl, 'productButtonInstance') : null; 1185 if (instance) { 1186 if (checkoutAnchor) { 1187 instance.handleGoToCheckout(event); 1188 } else { 1189 instance.handleGoToDashboard(event); 1190 } 1191 return; 1192 } 1193 const queryBlock = card.closest('.colibri-product-query-block'); 1194 const coreCtrl = card.querySelector('[data-core-add-to-cart]'); 1195 const coreButton = coreCtrl?.querySelector?.('button') ?? null; 1196 if (coreButton && navigateCoreModeCtaOverlay(coreButton, coreCtrl, queryBlock, checkoutAnchor, dashboardA
1196nchor)) { 1197 return; 1198 } 1199 const { 1200 med, 1201 settings 1202 } = getOverlayNavSettings(); 1203 if (checkoutAnchor) { 1204 const url = resolveOverlayCheckoutUrl(queryBlock, null, med, settings); 1205 if (url) { 1206 window.location.assign(url); 1207 } 1208 return; 1209 } 1210 const dashUrl = resolveOverlayDashboardUrl(queryBlock, null, null, med, settings); 1211 if (dashUrl) { 1212 window.location.assign(dashUrl); 1213 } 1214 }, true); 1215} 1216;// ./src/Rendering/ProductPreview/product-preview.js 1217 1218 1219(() => { 1220 let isInitialized = false; 1221 const nativeModalState = new WeakMap(); 1222 let originalBodyPaddingRight = null; 1223 function getScrollbarWidth() { 1224 const viewportDelta = window.innerWidth - document.documentElement.clientWidth; 1225 if (viewportDelta > 0) { 1226 return viewportDelta; 1227 } 1228 const scrollDiv = document.createElement('div'); 1229 scrollDiv.style.position = 'absolute'; 1230 scrollDiv.style.top = '-9999px'; 1231 scrollDiv.style.width = '50px'; 1232 scrollDiv.style.height = '50px'; 1233 scrollDiv.style.overflow = 'scroll'; 1234 document.body.appendChild(scrollDiv); 1235 const width = scrollDiv.offsetWidth - scrollDiv.clientWidth; 1236 document.body.removeChild(scrollDiv); 1237 return width; 1238 } 1239 function adjustBodyPaddingForModal() { 1240 if (document.body.classList.contains('modal-open')) { 1241 return; 1242 } 1243 const scrollbarWidth = getScrollbarWidth(); 1244 if (scrollbarWidth <= 0) { 1245 return; 1246 } 1247 originalBodyPaddingRight = document.body.style.paddingRight; 1248 const computedPadding = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0; 1249 document.body.style.paddingRight = `${computedPadding + scrollbarWidth}px`; 1250 } 1251 function resetBodyPaddingAfterModal() { 1252 if (originalBodyPaddingRight === null) { 1253 return; 1254 } 1255 document.body.style.paddingRight = originalBodyPaddingRight; 1256 originalBodyPaddingRight = null; 1257 } 1258 function ensureCache() { 1259 const colibri = window.colibri = window.colibri || {}; 1260 colibri.qv_cache = colibri.qv_cache || { 1261 loading: {}, 1262 failed: {}, 1263 ready: {}, 1264 last_clicked: null 1265 }; 1266 return colibri.qv_cache; 1267 } 1268 function toElement(trigger) { 1269 if (!trigger) { 1270 return null; 1271 } 1272 if (trigger instanceof Element) { 1273 return trigger; 1274 } 1275 if (typeof trigger === 'object' && typeof trigger.get === 'function') { 1276 return trigger.get(0) || null; 1277 } 1278 return null; 1279 } 1280 function closestMatch(target, selector) { 1281 if (!(target instanceof Element)) { 1282 return null; 1283 } 1284 return target.matches(selector) ? target : target.closest(selector); 1285 } 1286 function shouldSkipQuickViewFromDashboardCheckoutCta(event) { 1287 const selector = '.mode-cta.is-dashboard, .mode-cta.is-checkout'; 1288 if (typeof event?.composedPath === 'function') { 1289 for (const node of event.composedPath()) { 1290 if (node instanceof Element && node.matches(selector)) { 1291 return true; 1292 } 1293 if (node === document) { 1294 break; 1295 } 1296 } 1297 return false; 1298 } 1299 const raw = event?.target; 1300 const el = raw instanceof Element ? raw : raw?.parentElement ?? null; 1301 return !!closestMatch(el, selector); 1302 } 1303 function maybeUpdateTimezones(containerEl) { 1304 const fn = window.colibri?.utils?.maybeUpdateTimezones || window.maybeUpdateTimezones || window.colibri?.updateTimezones; 1305 if (typeof fn === 'function') { 1306 try { 1307 fn(containerEl); 1308 } catch (_e) { 1309 // no-op 1310 } 1311 } 1312 } 1313 function triggerCoreAddToCartActivate(scopeEl, addToCartNodes) { 1314 const nodes = Array.from(addToCartNodes || []); 1315 window.dispatchEvent(new CustomEvent('colibri:core-add-to-cart:activate', { 1316 detail: { 1317 scope: scopeEl || document, 1318 element: nodes[0] || scopeEl || document, 1319 requestId: Date.now() 1320 } 1321 })); 1322 } 1323 function releaseModalFocus(modalEl, restoreTo) { 1324 const active = document.activeElement; 1325 if (!(active instanceof Element) || active !== modalEl && !modalEl.contains(active)) { 1326 return; 1327 } 1328 if (restoreTo instanceof Element && document.contains(restoreTo) && typeof restoreTo.focus === 'function') { 1329 restoreTo.focus(); 1330 return; 1331 } 1332 active.blur(); 1333 } 1334 function concealModal(modalEl) { 1335 modalEl.inert = true; 1336 modalEl.setAttribute('aria-hidden', 'true'); 1337 } 1338 function revealModal(modalEl) { 1339 modalEl.inert = false;
1340 modalEl.removeAttribute('inert'); 1341 modalEl.removeAttribute('aria-hidden'); 1342 } 1343 function getModal(modalClass) { 1344 const selector = 'body > .modal' + (modalClass ? '.' + modalClass : '') + ':not(.modal--map)'; 1345 let modal = document.querySelector(selector); 1346 if (modal) { 1347 modal.querySelector('.modal-dialog')?.classList.add('modal-dialog-scrollable'); 1348 return modal; 1349 } 1350 modal = document.createElement('div'); 1351 modal.className = 'modal' + (modalClass ? ' ' + modalClass : ''); 1352 modal.tabIndex = -1; 1353 modal.setAttribute('role', 'dialog'); 1354 concealModal(modal); 1355 modal.innerHTML = '<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">' + '<div class="modal-content">' + '<div class="modal-header">' + '<h5 class="modal-title"></h5>' + '<button type="button" class="close" data-dismiss="modal" aria-label="Close" title="Close" tabindex="0">' + '<span aria-hidden="true">×</span>' + '</button>' + '</div>' + '<div class="modal-body"></div>' + '</div>' + '</div>'; 1356 document.body.appendChild(modal); 1357 return modal; 1358 } 1359 function showBootstrapModal(modalEl, opener = null) { 1360 if (window.jQuery && window.jQuery.fn && typeof window.jQuery.fn.modal === 'function') { 1361 window.jQuery(modalEl).modal({}); 1362 return; 1363 } 1364 showNativeModal(modalEl, opener); 1365 } 1366 function bindIframeCleanup(modalEl) { 1367 if (window.jQuery && window.jQuery.fn && typeof window.jQuery.fn.modal === 'function') { 1368 window.jQuery(modalEl).off('hidden.bs.modal.productPreview').on('hidden.bs.modal.productPreview', () => { 1369 modalEl.querySelectorAll('iframe').forEach(iframe => iframe.removeAttribute('src')); 1370 }); 1371 return; 1372 } 1373 if (modalEl.dataset.productPreviewIframeCleanupBound === '1') { 1374 return; 1375 } 1376 modalEl.addEventListener('hidden.bs.modal', () => { 1377 modalEl.querySelectorAll('iframe').forEach(iframe => iframe.removeAttribute('src')); 1378 }); 1379 modalEl.dataset.productPreviewIframeCleanupBound = '1'; 1380 } 1381 function hideNativeModal(modalEl) { 1382 const state = nativeModalState.get(modalEl); 1383 if (!state) { 1384 return; 1385 } 1386 modalEl.removeEventListener('click', state.onRootClick); 1387 document.removeEventListener('keydown', state.onKeydown); 1388 state.backdrop?.remove(); 1389 releaseModalFocus(modalEl, state.opener); 1390 modalEl.classList.remove('show'); 1391 modalEl.style.display = 'none'; 1392 concealModal(modalEl); 1393 modalEl.removeAttribute('aria-modal'); 1394 modalEl.removeAttribute('role'); 1395 document.body.classList.remove('modal-open'); 1396 resetBodyPaddingAfterModal(); 1397 modalEl.dispatchEvent(new CustomEvent('hidden.bs.modal', { 1398 bubbles: true 1399 })); 1400 nativeModalState.delete(modalEl); 1401 } 1402 function isModalCardTemplateSource(el) { 1403 return el instanceof Element && el.hasAttribute('data-card-template'); 1404 } 1405 1406 /** 1407 * Hash-to-template open is opt-in only (`<a rel="modal" href="#id">`). 1408 * In-page anchors (sticky nav, hero scroll) must not open a preview. 1409 */ 1410 function isModalRelLink(link) { 1411 return link instanceof Element && link.matches('[rel~="modal"]'); 1412 } 1413 function getHashTargetId(href) { 1414 const raw = String(href || '').trim(); 1415 if (!raw.startsWith('#') || raw.length < 2) { 1416 return ''; 1417 } 1418 try { 1419 return decodeURIComponent(raw.slice(1)); 1420 } catch (_e) { 1421 return raw.slice(1); 1422 } 1423 } 1424 function resolveModalSourceFromLink(link) { 1425 if (!isModalRelLink(link)) { 1426 return null; 1427 } 1428 const href = link.getAttribute('href') || ''; 1429 const id = getHashTargetId(href); 1430 if (!id) { 1431 return null; 1432 } 1433 let source = null; 1434 try { 1435 source = document.getElementById(id); 1436 } catch (_e) { 1437 source = null; 1438 } 1439 if (source && isModalCardTemplateSource(source)) { 1440 return source; 1441 } 1442 if (source && source.querySelector) { 1443 const nested = source.querySelector('script[data-card-template], template[data-card-template]'); 1444 if (nested && isModalCardTemplateSource(nested)) { 1445 return nested; 1446 } 1447 } 1448 return null; 1449 } 1450 function getModalSourceHtml(source) { 1451 if (source instanceof HTMLTemplateElement) { 1452 return serializeTemplate(source); 1453 } 1454 if (source && source.tagName === 'SCRIPT') { 1455 return source.textContent || source.innerHTML || ''; 1456 } 1457 return source?.innerHTML || ''; 1458 } 1459 function openFromModalSource(source, link, event) { 1460 if (event) { 1461 if (event.type === 'keydown' && event.key === 'Tab') { 1462 return false;
1463 } 1464 const isActivationEvent = event.type === 'click' || event.type === 'keydown' && (event.key === 'Enter' || event.keyCode === 13); 1465 if (!isActivationEvent) { 1466 return false; 1467 } 1468 if (event.__productPreviewHandled) { 1469 return false; 1470 } 1471 event.preventDefault(); 1472 event.stopImmediatePropagation(); 1473 event.__productPreviewHandled = true; 1474 } 1475 const productCode = String(source.dataset?.productCode || '').trim(); 1476 const cache = ensureCache(); 1477 if (productCode) { 1478 cache.last_clicked = productCode; 1479 cache.last_clicked_slot = previewCacheSlot(productCode, 'modal'); 1480 } 1481 const modalEl = getModal('modal--product'); 1482 const title = source.dataset?.title || link?.dataset?.modalTitle || link?.dataset?.title || link?.getAttribute?.('title') || (link?.textContent || '').trim(); 1483 const titleEl = modalEl.querySelector('.modal-title'); 1484 if (titleEl) { 1485 titleEl.textContent = title; 1486 } 1487 showModalContent(modalEl, getModalSourceHtml(source), link); 1488 return false; 1489 } 1490 function onModalSourceActivate(event) { 1491 const link = closestMatch(event.target, '[href^="#"][rel~="modal"]'); 1492 if (!link) { 1493 return; 1494 } 1495 const source = resolveModalSourceFromLink(link); 1496 if (!source) { 1497 return; 1498 } 1499 openFromModalSource(source, link, event); 1500 } 1501 function showNativeModal(modalEl, opener = null) { 1502 if (nativeModalState.has(modalEl)) { 1503 return; 1504 } 1505 const backdrop = document.createElement('div'); 1506 backdrop.className = 'modal-backdrop fade show'; 1507 document.body.appendChild(backdrop); 1508 const onRootClick = event => { 1509 const dismissBtn = event.target.closest("[data-dismiss='modal']"); 1510 if (dismissBtn && modalEl.contains(dismissBtn)) { 1511 event.preventDefault(); 1512 hideNativeModal(modalEl); 1513 return; 1514 } 1515 if (event.target === modalEl) { 1516 hideNativeModal(modalEl); 1517 } 1518 }; 1519 const onKeydown = event => { 1520 if (event.key === 'Escape' || event.keyCode === 27) { 1521 hideNativeModal(modalEl); 1522 } 1523 }; 1524 modalEl.addEventListener('click', onRootClick); 1525 document.addEventListener('keydown', onKeydown); 1526 nativeModalState.set(modalEl, { 1527 backdrop, 1528 onRootClick, 1529 onKeydown, 1530 opener 1531 }); 1532 adjustBodyPaddingForModal(); 1533 document.body.classList.add('modal-open'); 1534 modalEl.style.display = 'block'; 1535 modalEl.classList.add('show'); 1536 revealModal(modalEl); 1537 modalEl.setAttribute('aria-modal', 'true'); 1538 modalEl.setAttribute('role', 'dialog'); 1539 modalEl.focus(); 1540 } 1541 function escapeHtml(value) { 1542 const span = document.createElement('span'); 1543 span.textContent = String(value ?? ''); 1544 return span.innerHTML; 1545 } 1546 function showErrorInModal(modalEl, phase, err) { 1547 const bodyEl = modalEl.querySelector('.modal-body'); 1548 if (!bodyEl) { 1549 return; 1550 } 1551 const reason = String(err?.message || 'Unknown error'); 1552 bodyEl.innerHTML = `<div class="product-preview-error">` + `<p style="color:#b00020; margin:0 0 8px 0;"><strong>Could not render product preview.</strong></p>` + `<p style="margin:0 0 6px 0;">Stage: ${escapeHtml(phase)}</p>` + `<p style="margin:0; color:#555;">Reason: ${escapeHtml(reason)}</p>` + `</div>`; 1553 } 1554 function preloadImagesFromHtml(html, timeoutMs = 1500) { 1555 const markup = String(html || '').trim(); 1556 if (!markup) { 1557 return Promise.resolve(); 1558 } 1559 const container = document.createElement('div'); 1560 container.innerHTML = markup; 1561 const urls = Array.from(container.querySelectorAll('img[src]')).map(img => String(img.getAttribute('src') || '').trim()).filter(Boolean); 1562 const uniqueUrls = Array.from(new Set(urls)); 1563 if (!uniqueUrls.length) { 1564 return Promise.resolve(); 1565 } 1566 const preloadOne = src => new Promise(resolve => { 1567 const img = new Image(); 1568 let done = false; 1569 const finish = () => { 1570 if (done) { 1571 return; 1572 } 1573 done = true; 1574 resolve(); 1575 }; 1576 const timer = window.setTimeout(finish, timeoutMs); 1577 img.onload = () => { 1578 window.clearTimeout(timer); 1579 finish(); 1580 }; 1581 img.onerror = () => { 1582 window.clearTimeout(timer); 1583 finish(); 1584 }; 1585 img.src = src; 1586 if (img.complete) { 1587 window.clearTimeout(timer); 1588 finish(); 1589 } 1590 }); 1591 return Promise.all(uniqueUrls.map(src => preloadOne(src))).then(() => undefined); 1592 } 1593 function getLegacyCodesForControl(controlEl) { 1594 if (!(controlEl instanceof Element)) { 1595 return []; 1596 } 1597 const button = controlEl.querySelector('button[data-product-code]');
1598 const primary = String(button?.dataset?.productCode || controlEl.dataset?.productCode || '').trim(); 1599 const alt = String(controlEl.dataset?.altProductCode || button?.dataset?.altProductCode || '').trim(); 1600 return Array.from(new Set([primary, alt].filter(Boolean))); 1601 } 1602 function applyLegacyControlPresentation(targetControl, sourceControl) { 1603 const targetBtn = targetControl?.querySelector?.('button'); 1604 const sourceBtn = sourceControl?.querySelector?.('button'); 1605 if (!(targetBtn instanceof Element) || !(sourceBtn instanceof Element)) { 1606 return; 1607 } 1608 const sourceMode = sourceControl.getAttribute('data-mode'); 1609 if (sourceMode) { 1610 targetControl.setAttribute('data-mode', sourceMode); 1611 } 1612 targetBtn.textContent = sourceBtn.textContent || targetBtn.textContent; 1613 targetBtn.className = sourceBtn.className || ''; 1614 targetBtn.disabled = !!sourceBtn.disabled; 1615 if (sourceBtn.disabled) { 1616 targetBtn.setAttribute('disabled', ''); 1617 } else { 1618 targetBtn.removeAttribute('disabled'); 1619 } 1620 ['data-action', 'title'].forEach(attr => { 1621 const value = sourceBtn.getAttribute(attr); 1622 if (value === null) { 1623 targetBtn.removeAttribute(attr); 1624 } else { 1625 targetBtn.setAttribute(attr, value); 1626 } 1627 }); 1628 } 1629 function syncModalLegacyAddToCartState(root, sourceTrigger) { 1630 if (!(root instanceof Element)) { 1631 return; 1632 } 1633 const modalControls = Array.from(root.querySelectorAll('.product-actions [data-legacy-add-to-cart], .product-modal [data-legacy-add-to-cart]')); 1634 if (!modalControls.length) { 1635 return; 1636 } 1637 const sourceCard = sourceTrigger?.closest?.('.card'); 1638 const sourceScope = sourceCard || document; 1639 const sourceControls = Array.from(sourceScope.querySelectorAll('[data-legacy-add-to-cart]')).filter(el => !el.closest('.modal--quick-view')); 1640 if (!sourceControls.length) { 1641 return; 1642 } 1643 modalControls.forEach(modalControl => { 1644 const modalCodes = getLegacyCodesForControl(modalControl); 1645 if (!modalCodes.length) { 1646 return; 1647 } 1648 const sourceControl = sourceControls.find(candidate => { 1649 const sourceCodes = getLegacyCodesForControl(candidate); 1650 return sourceCodes.some(code => modalCodes.includes(code)); 1651 }); 1652 if (!sourceControl) { 1653 return; 1654 } 1655 applyLegacyControlPresentation(modalControl, sourceControl); 1656 }); 1657 } 1658 async function resolveMarketingProductForQuickView(productCode) { 1659 const code = String(productCode || '').trim(); 1660 if (!code) { 1661 return null; 1662 } 1663 const loader = window.colibriProductLoader; 1664 if (loader && typeof loader.getCachedProductByCode === 'function') { 1665 const cached = loader.getCachedProductByCode(code); 1666 if (cached) { 1667 return cached; 1668 } 1669 } 1670 const renderer = window.colibriProductRenderer; 1671 if (renderer && typeof renderer.fetchByCode === 'function') { 1672 try { 1673 return await renderer.fetchByCode(code, {}); 1674 } catch (_err) { 1675 return null; 1676 } 1677 } 1678 return null; 1679 } 1680 function trackQuickViewItemAnalytics(productCode, knownProduct = null) { 1681 const code = String(productCode || '').trim(); 1682 if (!code) { 1683 return; 1684 } 1685 const pushFromProduct = product => { 1686 if (!product || typeof product !== 'object') { 1687 return; 1688 } 1689 pushViewItemAnalytics({ 1690 productCode: code, 1691 offering: resolveMarketingOfferingForCta(product, '', code), 1692 productName: product.name, 1693 viewType: 'quick' 1694 }); 1695 }; 1696 if (knownProduct) { 1697 pushFromProduct(knownProduct); 1698 return; 1699 } 1700 void resolveMarketingProductForQuickView(code).then(pushFromProduct); 1701 } 1702 function showModalContent(modalEl, content, sourceTrigger = null, knownProduct = null) { 1703 const bodyEl = modalEl.querySelector('.modal-body'); 1704 if (bodyEl) { 1705 bodyEl.innerHTML = content || ''; 1706 } 1707 syncModalLegacyAddToCartState(bodyEl || document.body, sourceTrigger); 1708 setTimeout(() => { 1709 const payload = { 1710 container: bodyEl || document.body 1711 }; 1712 document.body.dispatchEvent(new CustomEvent('ColibriEvent:AFTER_MODAL_UPDATED', { 1713 detail: payload 1714 })); 1715 }, 0); 1716 setTimeout(() => { 1717 const root = bodyEl || document.body; 1718 const addToCartNodes = root.querySelectorAll('.product-actions [data-legacy-add-to-cart], .product-modal [data-legacy-add-to-cart]'); 1719 const coreAddToCartNodes = root.querySelectorAll('[data-core-add-to-cart]'); 1720 maybeUpdateTimezones(root); 1721 if (addToCartNodes.length) { 1722 window.dispatchEvent(new CustomEvent('ColibriEvent:INIT_ADD_TO_CART', { 1723 detail: Array.from(addToCartNodes) 1724 })); 1725 } 1726 if (coreAddToCartNodes.length) { 1727 triggerCoreAddToCartActivate(root, coreAddToCartNodes); 1728 } 1729 const previewCode = ensureCache().last_clicked; 1730 window.colibri?.upsell?.handlePreviewModalUpdated?.(root, previewCode); 1731 window.affirm?.ui?.ready?.(() => window.affirm.ui.refresh?.()); 1732 }, 1); 1733 showBootstrapModal(modalEl, sourceTrigger); 1734 bindIframeCleanup(modalEl); 1735 const previewCode = ensureCache().last_clicked; 1736 if (previewCode) { 1737 trackQuickViewItemAnalytics(previewCode, knownProduct); 1738 } 1739 } 1740 function resolveProductCodeFromContext(el) {
1741 const own = String(el?.dataset?.productCode || '').trim(); 1742 if (own) { 1743 return own; 1744 } 1745 const ancestor = el?.closest?.('[data-product-code]'); 1746 const ancCode = String(ancestor?.dataset?.productCode || '').trim(); 1747 if (ancCode) { 1748 return ancCode; 1749 } 1750 const card = el?.closest?.('.card'); 1751 const inCard = String(card?.querySelector?.('[data-product-code]')?.dataset?.productCode || '').trim(); 1752 if (inCard) { 1753 return inCard; 1754 } 1755 return ''; 1756 } 1757 function serializeTemplate(templateEl) { 1758 if (!(templateEl instanceof HTMLTemplateElement)) { 1759 return templateEl?.innerHTML || ''; 1760 } 1761 const container = document.createElement('div'); 1762 container.appendChild(templateEl.content.cloneNode(true)); 1763 return container.innerHTML || ''; 1764 } 1765 function getInlineQuickViewHtml(el, productCode) { 1766 const candidates = []; 1767 candidates.push(...el.querySelectorAll('template.product-quick-view-template')); 1768 let sib = el.nextElementSibling; 1769 while (sib) { 1770 if (sib.matches('template.product-quick-view-template')) { 1771 candidates.push(sib); 1772 } 1773 sib = sib.nextElementSibling; 1774 } 1775 const card = el.closest('.card'); 1776 if (card) { 1777 candidates.push(...card.querySelectorAll('template.product-quick-view-template')); 1778 } 1779 if (!candidates.length) { 1780 return ''; 1781 } 1782 let picked = candidates[0]; 1783 if (productCode) { 1784 const exact = candidates.find(node => String(node.dataset?.productCode || '') === String(productCode)); 1785 if (exact) { 1786 picked = exact; 1787 } 1788 } 1789 return serializeTemplate(picked); 1790 } 1791 function pickQuickViewFromScripts(scripts, templateKey, deliveryMethod) { 1792 if (!scripts.length) { 1793 return ''; 1794 } 1795 const key = String(templateKey || '').trim() || 'default'; 1796 const deliveryKey = String(deliveryMethod || '').trim(); 1797 const byTemplateKey = scripts.find(s => s.dataset.templateKey === key); 1798 if (byTemplateKey) { 1799 return byTemplateKey.innerHTML || ''; 1800 } 1801 const byKeyDelivery = scripts.find(s => s.dataset.deliveryMethod === key); 1802 if (byKeyDelivery) { 1803 return byKeyDelivery.innerHTML || ''; 1804 } 1805 if (deliveryKey) { 1806 const byDelivery = scripts.find(s => s.dataset.deliveryMethod === deliveryKey); 1807 if (byDelivery) { 1808 return byDelivery.innerHTML || ''; 1809 } 1810 } 1811 const byDefault = scripts.find(s => s.dataset.deliveryMethod === 'default'); 1812 if (byDefault) { 1813 return byDefault.innerHTML || ''; 1814 } 1815 return scripts[0].innerHTML || ''; 1816 } 1817 function getQuickViewTemplate(el, templateKey, deliveryMethod) { 1818 const scopes = []; 1819 const queryBlock = el.closest('.colibri-product-query-block'); 1820 if (queryBlock) { 1821 scopes.push(queryBlock); 1822 } 1823 const templateBlock = el.closest('.colibri-product-template-block'); 1824 if (templateBlock && !scopes.includes(templateBlock)) { 1825 scopes.push(templateBlock); 1826 } 1827 for (const scope of scopes) { 1828 const scripts = Array.from(scope.querySelectorAll('script[data-quick-view-template]')); 1829 const html = pickQuickViewFromScripts(scripts, templateKey, deliveryMethod); 1830 if (html) { 1831 return html; 1832 } 1833 } 1834 const globalTpl = document.getElementById('colibri-marketing-quick-view-template'); 1835 if (globalTpl?.innerHTML?.trim()) { 1836 return globalTpl.innerHTML; 1837 } 1838 return ''; 1839 } 1840 function resolveQuickViewTemplate(el, templateKey, deliveryMethod, productCode) { 1841 const fromScripts = getQuickViewTemplate(el, templateKey, deliveryMethod); 1842 if (fromScripts) { 1843 return { 1844 template: fromScripts, 1845 source: 'quick-view.php' 1846 }; 1847 } 1848 const inline = getInlineQuickViewHtml(el, productCode); 1849 if (inline) { 1850 return { 1851 template: inline, 1852 source: 'inline-product-quick-view-template' 1853 }; 1854 } 1855 return { 1856 template: getDefaultQuickViewTemplate(), 1857 source: 'builtin-default-quick-view' 1858 }; 1859 } 1860 1861 /** 1862 * Modal body `{product.*}` fragment (aligned with Marketing API templates/product-modal.php body). 1863 * @param {*} el 1864 * @param templateKey Selector semantics mirror {@see getQuickViewTemplate}. 1865 * @param {*} deliveryMethod 1866 */ 1867 function getProductModalTemplate(el, templateKey, deliveryMethod) { 1868 const scripts = []; 1869 const queryBlock = el.closest('.colibri-product-query-block'); 1870 if (queryBlock) { 1871 scripts.push(...queryBlock.querySelectorAll('script[data-product-modal-template]')); 1872 } 1873 if (!scripts.length) { 1874 scripts.push(...document.querySelectorAll('script[data-product-modal-template]')); 1875 } 1876 if (!scripts.length) { 1877 return ''; 1878 }
1879 const key = String(templateKey || '').trim() || 'default'; 1880 const deliveryKey = String(deliveryMethod || '').trim(); 1881 const byTemplateKey = scripts.find(s => s.dataset.templateKey === key); 1882 if (byTemplateKey) { 1883 return byTemplateKey.innerHTML || ''; 1884 } 1885 const byKeyDelivery = scripts.find(s => s.dataset.deliveryMethod === key); 1886 if (byKeyDelivery) { 1887 return byKeyDelivery.innerHTML || ''; 1888 } 1889 if (deliveryKey) { 1890 const byDelivery = scripts.find(s => s.dataset.deliveryMethod === deliveryKey); 1891 if (byDelivery) { 1892 return byDelivery.innerHTML || ''; 1893 } 1894 } 1895 const byDefault = scripts.find(s => s.dataset.deliveryMethod === 'default'); 1896 if (byDefault) { 1897 return byDefault.innerHTML || ''; 1898 } 1899 return scripts[0].innerHTML || ''; 1900 } 1901 function getDefaultProductModalTemplate() { 1902 return ` 1903 <div class="cg-ajax-content product-modal"> 1904 <!-- Product: {product.productCode} --> 1905 <div class="product-modal__top"> 1906 <div class="product-heading"> 1907 <h3 class="product-title">{product.name}</h3> 1908 {product.quickViewSubtitle} 1909 </div> 1910 <div class="product__title d-none">{product.name}</div> 1911 {product.pricing} 1912 {product.addToCart} 1913 </div> 1914 {product.upsellBlock} 1915 <div class="product-modal__bottom"> 1916 {product.descriptionPartial} 1917 </div> 1918 </div> 1919 `; 1920 } 1921 1922 /**
1923 * `data-product-preview` carries {@see details_link_behavior} (`quickview`|`modal`). 1924 * Legacy card triggers used template keys (`default`, `membership`, â¦) on this attribute; 1925 * prefer `data-preview-template-key` for that going forward. 1926 * 1927 * @param {DOMStringMap} dataset 1928 * @return {{ previewKind: 'quickview' | 'modal' | '', templateKey: string }} 1929 */ 1930 function resolvePreviewModesFromDataset(dataset) { 1931 const ds = dataset || {}; 1932 const rawPreview = String(ds.productPreview || '').trim(); 1933 const templatePick = String(ds.previewTemplateKey || '').trim(); 1934 let previewKind = ''; 1935 let templateKey = templatePick || 'default'; 1936 const lowerBehavior = rawPreview.toLowerCase(); 1937 if (lowerBehavior === 'modal') { 1938 previewKind = 'modal'; 1939 } else if (lowerBehavior === 'quickview') { 1940 previewKind = 'quickview'; 1941 } else if (rawPreview !== '') { 1942 previewKind = 'quickview'; 1943 templateKey = rawPreview; 1944 if (templatePick !== '') { 1945 templateKey = templatePick; 1946 } 1947 } 1948 return { 1949 previewKind, 1950 templateKey 1951 }; 1952 } 1953 function previewCacheSlot(productCode, previewKind) { 1954 return `${String(productCode)}::${previewKind}`; 1955 } 1956 function getDefaultQuickViewTemplate() { 1957 return ` 1958 <div class="quick-view" tabindex="-1" aria-hidden="true"> 1959 <div class="product-heading"> 1960 <h3 class="product-title">{product.name}</h3> 1961 {product.quickViewSubtitle} 1962 {product.pricing} 1963 {product.rating} 1964 </div> 1965 {product.quickViewImage} 1966 <div class="product-vitals"> 1967 <div class="product-type product-type--{product.deliveryMethodSlug}"> 1968 <span class="product-type__label">{product.deliveryMethod}</span> 1969 </div> 1970 {product.creditTypeFragment} 1971 {product.isRequiredPartnershipGroupItem} 1972 <div class="product-hours hours-container"> 1973 {product.creditHours} 1974 {product.secondaryCreditHours} 1975 </div> 1976 {product.productTimesFragment} 1977 {product.classScheduleDescription} 1978 {product.expirationDate} 1979 {product.authorsFragment} 1980 {product.instructorFragment} 1981 {product.locationFragment} 1982 </div> 1983 <div class="product-content"> 1984 {product.abridgedDescription|just-intro} 1985 {product.productChildrenFragment} 1986 {product.swapCoursesFragment} 1987 {product.quickViewDetailsLinkInContent} 1988 </div> 1989 <div class="product-vitals product-vitals--desktop"> 1990 <div class="product-type product-type--{product.deliveryMethodSlug}"> 1991 <span class="product-type__label">{product.deliveryMethod}</span> 1992 </div> 1993 {product.creditTypeFragment} 1994 {product.isRequiredPartnershipGroupItem} 1995 <div class="product-hours hours-container"> 1996 {product.creditHoursNonMembership} 1997 {product.secondaryCreditHours} 1998 </div> 1999 {product.productTimesFragment} 2000 {product.classScheduleDescription} 2001 {product.expirationDate} 2002 {product.authorsFragment} 2003 {product.instructorFragment} 2004 {product.locationFragment} 2005 </div> 2006 <div class="product-details"> 2007 {product.sponsorCodeFragment} 2008 {product.approvalNumber} 2009 {product.commentOnWeb} 2010 {product.affiliationSeal} 2011 </div> 2012 {product.upsellBlock} 2013 <div class="product-actions {product.quickViewActionsClass}"> 2014 {product.quickViewDetailsLink|View Full Details} 2015 {product.addToCart} 2016 </div> 2017 </div> 2018 `; 2019 } 2020 2021 // Token rendering and product fetch are delegated to ProductRenderer. 2022 2023 function isProductRendererReady(renderer) { 2024 return !!renderer && typeof renderer.fetchByCode === 'function' && typeof renderer.renderTemplate === 'function'; 2025 } 2026 function whenProductRendererReady(timeoutMs = 10000) { 2027 if (isProductRendererReady(window.colibriProductRenderer)) { 2028 return Promise.resolve(window.colibriProductRenderer); 2029 } 2030 const waitForMethods = () => new Promise((resolve, reject) => { 2031 const deadline = Date.now() + timeoutMs; 2032 const tick = () => { 2033 if (isProductRendererReady(window.colibriProductRenderer)) { 2034 resolve(window.colibriProductRenderer); 2035 return; 2036 } 2037 if (Date.now() >= deadline) { 2038 reject(new Error('ProductRenderer unavailable')); 2039 return; 2040 } 2041 window.requestAnimationFrame(tick); 2042 }; 2043 tick(); 2044 }); 2045 if (typeof window.colibri?.whenRendererReady === 'function') { 2046 return window.colibri.whenRendererReady().then(() => { 2047 if (isProductRendererReady(window.colibriProductRenderer)) { 2048 return window.colibriProductRenderer; 2049 } 2050 return waitForMethods(); 2051 }).catch(() => waitForMethods()); 2052 } 2053 return waitForMethods(); 2054 } 2055 async function openFromTrigger(trigger, event) { 2056 const el = toElement(trigger); 2057 if (!el) { 2058 return false;
2059 } 2060 const overlayDoneStatus = el.getAttribute?.('data-product-status'); 2061 if (overlayDoneStatus === 'sellable_done' || overlayDoneStatus === 'enrollable_done') { 2062 return false; 2063 } 2064 const isActivationEvent = !event || event.type === 'click' || event.type === 'keydown' && (event.key === 'Enter' || event.keyCode === 13); 2065 if (!isActivationEvent) { 2066 return false; 2067 } 2068 if (event && event.__productPreviewHandled) { 2069 return false; 2070 } 2071 if (event) { 2072 event.preventDefault(); 2073 event.__productPreviewHandled = true; 2074 } 2075 const { 2076 previewKind, 2077 templateKey 2078 } = resolvePreviewModesFromDataset(el.dataset); 2079 if (previewKind !== 'quickview' && previewKind !== 'modal') { 2080 return false; 2081 } 2082 const productCode = resolveProductCodeFromContext(el); 2083 if (!productCode) { 2084 return false; 2085 } 2086 const modalClass = previewKind === 'modal' ? 'modal--product' : 'modal--quick-view'; 2087 const modalEl = getModal(modalClass); 2088 const title = el.dataset.modalTitle || el.dataset.title || el.getAttribute('title') || (el.textContent || '').trim(); 2089 const titleEl = modalEl.querySelector('.modal-title'); 2090 if (titleEl) { 2091 titleEl.textContent = title; 2092 } 2093 const bodyEl = modalEl.querySelector('.modal-body'); 2094 const cacheSlot = previewCacheSlot(productCode, previewKind); 2095 const cache = ensureCache(); 2096 cache.last_clicked = productCode; 2097 cache.last_clicked_slot = cacheSlot; 2098 if (cache.ready[cacheSlot]) { 2099 const cached = cache.ready[cacheSlot]; 2100 await preloadImagesFromHtml(cached.qv ?? cached); 2101 showModalContent(modalEl, cached.qv ?? cached, el); 2102 return false; 2103 } 2104 const inline = previewKind === 'quickview' ? getInlineQuickViewHtml(el, productCode) : ''; 2105 const inlineNeedsTokens = !!inline && inline.indexOf('{product.') !== -1; 2106 if (inline && !inlineNeedsTokens) { 2107 cache.ready[cacheSlot] = { 2108 qv: inline, 2109 $card: el 2110 }; 2111 await preloadImagesFromHtml(inline); 2112 showModalContent(modalEl, inline, el); 2113 return false; 2114 } 2115 if (cache.loading[cacheSlot]) { 2116 window.setTimeout(() => { 2117 if (!cache.loading[cacheSlot]) { 2118 return; 2119 } 2120 if (bodyEl) { 2121 bodyEl.innerHTML = '<p>Loading...</p>'; 2122 } 2123 showBootstrapModal(modalEl, el); 2124 }, 2000); 2125 return false; 2126 } 2127 cache.loading[cacheSlot] = true; 2128 let loadingTimer = window.setTimeout(() => { 2129 if (!cache.loading[cacheSlot]) { 2130 return; 2131 } 2132 if (bodyEl) { 2133 bodyEl.innerHTML = '<p>Loading...</p>'; 2134 } 2135 showBootstrapModal(modalEl, el); 2136 }, 2000); 2137 const queryBlock = el.closest('.colibri-product-query-block'); 2138 const isStateContextual = queryBlock?.dataset?.stateContextual === '1'; 2139 let pdpViewerState = ''; 2140 if (isStateContextual) { 2141 try { 2142 const fromQuery = String(new URL(window.location.href).searchParams.get('state') || '').trim().toUpperCase(); 2143 if (fromQuery.length === 2) { 2144 pdpViewerState = fromQuery; 2145 } 2146 } catch (e) { 2147 /* ignore */ 2148 } 2149 if (!pdpViewerState) { 2150 try { 2151 const overrides = JSON.parse(queryBlock?.dataset?.contextualOverrides || 'null'); 2152 const ov = overrides?.stateAbbvs; 2153 pdpViewerState = String(Array.isArray(ov) ? ov[0] : ov || '').trim().toUpperCase(); 2154 } catch (e) { 2155 /* ignore */ 2156 } 2157 } 2158 } 2159 const context = { 2160 productCode, 2161 ctaLabel: String(el.dataset.ctaLabel || 'Add to Cart'), 2162 studentapi: String(queryBlock?.dataset?.studentapi || ''), 2163 subsidiary: String(queryBlock?.dataset?.subsidiary || ''), 2164 cartUrl: String(queryBlock?.dataset?.cartUrl || ''), 2165 myAccountUrl: String(queryBlock?.dataset?.myAccountUrl || ''), 2166 detailsUrl: String(el.dataset.detailsUrl || el.getAttribute('href') || ''), 2167 isStateContextual, 2168 pdpViewerState, 2169 stateAbbv: pdpViewerState 2170 }; 2171 let failurePhase = 'init'; 2172 try { 2173 failurePhase = 'renderer'; 2174 const renderer = await whenProductRendererReady(); 2175 if (!isProductRendererReady(renderer)) { 2176 throw new Error('ProductRenderer unavailable'); 2177 } 2178 failurePhase = 'fetchByCode'; 2179 const apiProduct = await renderer.fetchByCode(productCode, { 2180 sourceEl: el 2181 }); 2182 if (!apiProduct) { 2183 throw new Error('No product from preview endpoint'); 2184 } 2185 failurePhase = 'resolveTemplate'; 2186 const deliveryMethod = apiProduct.deliveryMethod; 2187 let template = ''; 2188 if (previewKind === 'modal') { 2189 const fromBlock = getProductModalTemplate(el, templateKey, deliveryMethod); 2190 template = fromBlock || getDefaultProductModalTemplate(); 2191 } else if (inlineNeedsTokens) { 2192 template = inline; 2193 } else { 2194 template = resolveQuickViewTemplate(el, templateKey, deliveryMethod, productC
2194ode).template; 2195 } 2196 failurePhase = 'renderTemplate'; 2197 const rendered = renderer.renderTemplate(template, apiProduct, { 2198 ...context, 2199 sourceEl: el, 2200 studentapiUrl: context.studentapi, 2201 subsidiaryId: context.subsidiary, 2202 addToCartExtraClasses: '', 2203 quick_view_upsell_enabled: true, 2204 detailUrlPattern: String(queryBlock?.dataset?.detailUrlPattern || '{o.profession}/courses/{p.slug}') 2205 }); 2206 if (!rendered) { 2207 throw new Error(previewKind === 'modal' ? 'Product modal render returned empty html' : 'Quick view render returned empty html'); 2208 } 2209 cache.ready[cacheSlot] = { 2210 qv: rendered, 2211 $card: el 2212 }; 2213 delete cache.loading[cacheSlot]; 2214 if (loadingTimer) { 2215 window.clearTimeout(loadingTimer); 2216 loadingTimer = null; 2217 } 2218 await preloadImagesFromHtml(rendered); 2219 showModalContent(modalEl, rendered, el, apiProduct); 2220 } catch (err) { 2221 delete cache.loading[cacheSlot]; 2222 if (loadingTimer) { 2223 window.clearTimeout(loadingTimer); 2224 loadingTimer = null; 2225 } 2226 cache.failed[cacheSlot] = err; 2227 console.error('[ProductPreview] product-preview render failed', { 2228 productCode, 2229 previewKind, 2230 templateKey, 2231 failurePhase, 2232 error: err 2233 }); 2234 if (context.detailsUrl) { 2235 window.location.href = context.detailsUrl; 2236 return false; 2237 } 2238 showErrorInModal(modalEl, failurePhase, err); 2239 showBootstrapModal(modalEl, el); 2240 } 2241 return false; 2242 } 2243 function onAfterModalUpdated(event) { 2244 const container = event?.detail?.container || document.body; 2245 const productCode = ensureCache().last_clicked; 2246 window.colibri?.upsell?.handlePreviewModalUpdated?.(container, productCode); 2247 if (!productCode) { 2248 return; 2249 } 2250 const slot = ensureCache().last_clicked_slot || previewCacheSlot(productCode, 'quickview'); 2251 const cardRef = ensureCache().ready?.[slot]?.$card; 2252 const cardEl = toElement(cardRef); 2253 if (!cardEl) { 2254 return; 2255 } 2256 if (String(cardEl.getAttribute('data-memb') || '') === 'true') { 2257 container.querySelectorAll('.regular-price > .price-value').forEach(node => { 2258 node.textContent = '0.00'; 2259 }); 2260 container.querySelectorAll('.price-tag .price').forEach(node => { 2261 node.textContent = '0.00'; 2262 }); 2263 } 2264 } 2265 function init() { 2266 if (isInitialized) { 2267 return; 2268 } 2269 isInitialized = true; 2270 bindModeCtaOverlayNavigation(); 2271 document.body.addEventListener('click', onModalSourceActivate, true); 2272 document.body.addEventListener('keydown', onModalSourceActivate, true); 2273 const marketingProductModalDelegates = '[data-product-preview][data-product-code]'; 2274 document.body.addEventListener('click', event => { 2275 if (shouldSkipQuickViewFromDashboardCheckoutCta(event)) { 2276 return; 2277 } 2278 const trigger = closestMatch(event.target, marketingProductModalDelegates); 2279 if (!trigger) { 2280 return; 2281 } 2282 void openFromTrigger(trigger, event); 2283 }); 2284 document.body.addEventListener('keydown', event => { 2285 if (shouldSkipQuickViewFromDashboardCheckoutCta(event)) { 2286 return; 2287 } 2288 const trigger = closestMatch(event.target, marketingProductModalDelegates); 2289 if (!trigger) { 2290 return; 2291 } 2292 void openFromTrigger(trigger, event); 2293 }); 2294 document.body.addEventListener('ColibriEvent:AFTER_MODAL_UPDATED', onAfterModalUpdated); 2295 } 2296 window.colibriProductPreview = window.colibriProductPreview || {}; 2297 window.colibriProductPreview.openFromTrigger = openFromTrigger; 2298 window.colibriProductPreview.openFromModalSource = openFromModalSource; 2299 window.colibriProductPreview.init = init; 2300 window.colibriProductPreview.getModal = getModal; 2301 window.colibriProductPreview.showModalContent = showModalContent; 2302 const boot = () => { 2303 init(); 2304 }; 2305 if (document.readyState === 'loading') { 2306 document.addEventListener('DOMContentLoaded', boot, { 2307 once: true 2308 }); 2309 } else { 2310 boot(); 2311 } 2312})(); 2313/******/ })() 2314;
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.