PageSourceSearch

https://www.alliedschools.com/wp-content/plugins/wp-marketing-api/…/colibri-product-preview.js?ver=1789733782

js alliedschools.com collected 2026-09-29 07:55:34 UTC 78,256 bytes, 2,314 lines download raw bytes

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">&times;</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.