1/******/ (() => { // webpackBootstrap 2/******/ "use strict"; 3 4// UNUSED EXPORTS: applyProductPropertiesToMarketingProduct, fetchOffering, fetchProductPropertiesRow, getOfferingFromProduct 5 6;// ./src/Rendering/scripts/marketingApiAuth.ts 7/** 8 * Browser Authorization parity with PHP {@see \Colibri\MarketingApi\Service\BaseService::authorization_header_value}. 9 * Apply only when calling the upstream Marketing API host (`/marketing/...`), 10 * not the WordPress REST proxy (`/wp-json/.../wp-marketing-api/v1/...`). 11 */ 12 13/** 14 * True when fetch targets upstream Marketing paths; WordPress proxies do not get this header. 15 * @param url 16 */ 17function marketingApiUrlUsesDirectAuth(url) { 18 const s = String(url || ''); 19 if (!/\/?marketing(\/|$)/i.test(s)) { 20 return false; 21 } 22 return !/\/wp-json\/.*?wp-marketing-api\b/i.test(s); 23} 24 25/** 26 * Raw cookie lookup (readable, nonâHttpOnly names only). 27 * @param name 28 */ 29function cookieValue(name) { 30 if (typeof document === 'undefined') { 31 return ''; 32 } 33 const needle = `${name}=`; 34 const parts = document.cookie ? document.cookie.split(';') : []; 35 for (let i = 0; i < parts.length; i++) { 36 const part = parts[i].trim(); 37 if (!part.startsWith(needle)) { 38 continue; 39 } 40 const raw = part.slice(needle.length); 41 try { 42 return decodeURIComponent(raw).trim(); 43 } catch { 44 return raw.trim(); 45 } 46 } 47 return ''; 48} 49 50/** 51 * Same scheme as PHP + {@see CartTrafficSplit::insert_frontend_cart_experience_object}. 52 * 53 * Core cart (`cartExperience === 'core'`): upstream Marketing fetches use the readable 54 * `access_token` cookie (Bearer) only â not STYXKEY student cookies. Legacy uses 55 * `X-Credential` when Core token is absent. 56 */ 57function getMarketingAuthorizationHeaders() { 58 const accessToken = cookieValue('access_token'); 59 const xCredential = cookieValue('X-Credential'); 60 const ce = typeof window !== 'undefined' ? window.colibri?.cartExperience : undefined; 61 if (ce === 'core') { 62 return accessToken ? { 63 Authorization: `Bearer ${accessToken}` 64 } : {}; 65 } 66 if (ce === 'legacy') { 67 return xCredential ? { 68 Authorization: `xcred ${xCredential}` 69 } : {}; 70 } 71 if (accessToken) { 72 return { 73 Authorization: `Bearer ${accessToken}` 74 }; 75 } 76 if (xCredential) { 77 return { 78 Authorization: `xcred ${xCredential}` 79 }; 80 } 81 return {}; 82} 83 84/** 85 * Attach Marketing API Authorization when URL is upstream; merges with optional RequestInit.headers. 86 * @param url 87 * @param init 88 */ 89function mergeMarketingAuthHeadersForFetchUrl(url, init) { 90 if (!marketingApiUrlUsesDirectAuth(url)) { 91 return init ? { 92 ...init 93 } : {}; 94 } 95 const auth = getMarketingAuthorizationHeaders(); 96 if (Object.keys(auth).length === 0) { 97 return init ? { 98 ...init 99 } : {}; 100 } 101 const headers = new Headers(init?.headers ?? undefined); 102 for (const [k, v] of Object.entries(auth)) { 103 headers.set(k, v); 104 } 105 const out = init ? { 106 ...init 107 } : {}; 108 out.headers = headers; 109 return out; 110} 111;// ./src/Rendering/scripts/marketingProductQueryDefaults.ts 112/** 113 * Default query params for Marketing product endpoints that support promoted discount codes. 114 * Keep in sync with ProductsService::apply_default_discount_code_query_params(). 115 */ 116 117const MARKETING_PROMOTED_DISCOUNT_CODES_PARAM = 'includeMarketingPromotedDiscountCodes'; 118 119/** 120 * Append the default promoted-discount flag when not already set on the params object. 121 */ 122function applyMarketingPromotedDiscountCodesDefault(params) { 123 if (!params.has(MARKETING_PROMOTED_DISCOUNT_CODES_PARAM)) { 124 params.set(MARKETING_PROMOTED_DISCOUNT_CODES_PARAM, 'true'); 125 } 126 return params; 127} 128 129/** 130 * Merge the default promoted-discount flag into a plain query params record. 131 */ 132function withMarketingPromotedDiscountCodesDefault(params) { 133 if (!Object.prototype.hasOwnProperty.call(params, MARKETING_PROMOTED_DISCOUNT_CODES_PARAM)) { 134 return { 135 ...params, 136 [MARKETING_PROMOTED_DISCOUNT_CODES_PARAM]: true 137 }; 138 } 139 return params; 140} 141 142/** 143 * True when a URL targets a Marketing product endpoint that accepts the flag. 144 */ 145function urlShouldIncludeMarketingPromotedDiscountCodes(url) { 146 const path = String(url || '').split('?')[0] || ''; 147 return /\/marketing\/products(?:\/|$)/i.test(path) || /\/products(?:\/|$|-list|-get-by-code|-filters|-filters-and-counts|-with-filters|-related-offerings|-swap-options)/i.test(path); 148} 149 150/** 151 * Append `?includeMarketingPromotedDiscountCodes=true` to product detail URLs when missing. 152 */ 153function appendMarketingPromotedDiscountCodesToUrl(url) { 154 if (!urlShouldIncludeMarketingPromotedDiscountCodes(url)) { 155 return url; 156 } 157 const [base, query = ''] = String(url).split('?'); 158 const params = new URLSearchParams(query); 159 applyMarketingPromotedDiscountCodesDefault(params); 160 const qs = params.toString(); 161 return qs ? `${base}?${qs}` : `${base}?${MARKETING_PROMOTED_DISCOUNT_CODES_PARAM}=true`; 162} 163;// ./src/Gutenberg/Store/queryKey.ts 164/* unused harmony import specifier */ var DEFAULT_PRODUCT_LIST_SORT; 165 166const queryKey_MARKETING_PROMOTED_DISCOUNT_CODES_PARAM = 'includeMarketingPromotedDiscountCodes'; 167 168/** Sort filter bracket segments lexicographically for stable cache keys. */ 169function canonicalFilterSegments(filterParams) { 170 return (filterParams || []).map(segment =>
170 String(segment || '').trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)); 171} 172 173/** Numeric license type ids, sorted ascending. */ 174function canonicalLicenseTypeIds(value) { 175 if (Array.isArray(value)) { 176 return [...value].map(n => Number(n)).filter(n => Number.isFinite(n)).sort((a, b) => a - b); 177 } 178 if (typeof value === 'string' && value.trim() !== '') { 179 return value.split(',').map(part => Number(String(part || '').trim())).filter(n => Number.isFinite(n)).sort((a, b) => a - b); 180 } 181 return []; 182} 183 184/** State abbreviations, sorted lexicographically. */ 185function canonicalStateAbbvs(value) { 186 if (Array.isArray(value)) { 187 return [...value].map(abbv => String(abbv || '').trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)); 188 } 189 if (typeof value === 'string' && value.trim() !== '') { 190 return value.split(',').map(part => String(part || '').trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)); 191 } 192 return []; 193} 194function canonicalLicenseTypeIdsCsv(value) { 195 return canonicalLicenseTypeIds(value).join(','); 196} 197function canonicalStateAbbvsCsv(value) { 198 return canonicalStateAbbvs(value).join(','); 199} 200function normalizeSortCompare(a, b) { 201 if (typeof a === 'number' && typeof b === 'number') { 202 return a - b; 203 } 204 if (typeof a === 'string' && typeof b === 'string') { 205 return a.localeCompare(b); 206 } 207 if (typeof a === 'boolean' && typeof b === 'boolean') { 208 if (a === b) { 209 return 0; 210 } 211 return a ? 1 : -1; 212 } 213 try { 214 return JSON.stringify(a).localeCompare(JSON.stringify(b)); 215 } catch { 216 return 0; 217 } 218} 219function normalize(value) { 220 if (Array.isArray(value)) { 221 return value.map(normalize).sort(normalizeSortCompare); 222 } 223 if (value && typeof value === 'object') { 224 const keys = Object.keys(value).sort(); 225 const out = {}; 226 for (const k of keys) { 227 out[k] = normalize(value[k]); 228 } 229 return out; 230 } 231 return value; 232} 233function buildQueryKey(path, params) { 234 if (!params) return path; 235 const entries = Object.entries(params).filter(([_, v]) => v !== undefined && v !== null); 236 if (entries.length === 0) return path; 237 const norm = normalize(Object.fromEntries(entries)); 238 return `${path}?${JSON.stringify(norm)}`; 239} 240function applyDefaultProductListSort(params) { 241 const sort = typeof params.sort === 'string' && String(params.sort).trim() !== '' ? params.sort : DEFAULT_PRODUCT_LIST_SORT; 242 return { 243 ...params, 244 sort 245 }; 246} 247function applyDefaultPromotedDiscountCodes(params) { 248 if (Object.prototype.hasOwnProperty.call(params, queryKey_MARKETING_PROMOTED_DISCOUNT_CODES_PARAM)) { 249 return params; 250 } 251 return { 252 ...params, 253 [queryKey_MARKETING_PROMOTED_DISCOUNT_CODES_PARAM]: true 254 }; 255} 256 257/** 258 * Canonicalize products-list params so reads/writes use the same cache key. 259 * Sorts filter segments (tags, ranges, etc.), license types, and states. 260 */ 261function normalizeProductListQueryParams(params) { 262 if (!params) { 263 return undefined; 264 } 265 let q = { 266 ...params 267 }; 268 if (Array.isArray(q.licenseTypeIds)) { 269 q.licenseTypeIds = canonicalLicenseTypeIds(q.licenseTypeIds); 270 } 271 if (Array.isArray(q.stateAbbvs)) { 272 q.stateAbbvs = canonicalStateAbbvs(q.stateAbbvs); 273 } 274 if (Array.isArray(q.filter)) { 275 q.filter = canonicalFilterSegments(q.filter); 276 } 277 q = applyDefaultProductListSort(q); 278 q = applyDefaultPromotedDiscountCodes(q); 279 return q; 280} 281 282/** 283 * Canonicalize products-with-filters / filters-and-counts query params. 284 */ 285function normalizeProductsFacetQueryParams(params) { 286 if (!params) { 287 return undefined; 288 } 289 let q = { 290 ...params 291 }; 292 if (Array.isArray(q.licenseTypeIds)) { 293 q.licenseTypeIds = canonicalLicenseTypeIds(q.licenseTypeIds); 294 } 295 if (Array.isArray(q.stateAbbvs)) { 296 q.stateAbbvs = canonicalStateAbbvs(q.stateAbbvs); 297 } 298 if (Array.isArray(q.filter)) { 299 q.filter = canonicalFilterSegments(q.filter); 300 } 301 q = applyDefaultProductListSort(q); 302 q = applyDefaultPromotedDiscountCodes(q); 303 return q; 304} 305function buildProductsListQueryKey(params) { 306 return buildQueryKey('products-list', normalizeProductListQueryParams(params)); 307} 308function buildProductsWithFiltersQueryKey(params) { 309 return buildQueryKey('products-with-filters', normalizeProductsFacetQueryParams(params)); 310} 311function buildProductsFiltersAndCountsQueryKey(params) { 312 return buildQueryKey('products-filters-and-counts', normalizeProductsFacetQueryParams(params)); 313} 314;// ./src/Gutenberg/constants/productListIncludedOrder.ts 315/** 316 * Preserve ProductQuery / catalogue list order implied by {@link includedItems} 317 * (`itemId` pin & `[itemId==â¦]` / `[itemId==(â¦)]`), matching PHP 318 * `ProductQueryContext::order_products_by_included_item_ids` and filter parsing helpers. 319 */ 320 321function unescapeFilterValue(raw) { 322 return raw.replace(/\\(.)/g, '$1'); 323} 324 325/** 326 * Split a comma-separated tuple body where commas can be escaped with backslash, 327 * aligned with `ProductQueryContext::split_escaped_list`. 328 */ 329function splitEscapedCommaList(value) { 330 const out = []; 331 let buffer = ''; 332 let escaped = false;
333 for (let i = 0; i < value.length; i++) { 334 const ch = value.charAt(i); 335 if (escaped) { 336 buffer += ch; 337 escaped = false; 338 continue; 339 } 340 if (ch === '\\') { 341 escaped = true; 342 continue; 343 } 344 if (ch === ',') { 345 out.push(unescapeFilterValue(buffer)); 346 buffer = ''; 347 continue; 348 } 349 buffer += ch; 350 } 351 if (buffer !== '' || out.length === 0) { 352 out.push(unescapeFilterValue(buffer)); 353 } 354 return out.map(s => s.trim()).filter(s => s !== ''); 355} 356 357/** 358 * Parse ordered itemIds from a multi-value `[itemId==(â¦)]` or single `[itemId==â¦]` segment, 359 * aligned with PHP `ordered_included_item_ids_from_filter_segments`. 360 */ 361function orderedIncludedItemIdsFromFilterSegments(filterSegments) { 362 for (const rawUntyped of filterSegments) { 363 const raw = String(rawUntyped || '').trim(); 364 if (!raw || !raw.startsWith('[itemId==')) { 365 continue; 366 } 367 if (!raw.endsWith(']')) { 368 continue; 369 } 370 const core = raw.slice('[itemId=='.length, -1); 371 if (!core) { 372 continue; 373 } 374 if (core.startsWith('(') && core.endsWith(')')) { 375 const inner = core.slice(1, -1); 376 return splitEscapedCommaList(inner); 377 } 378 return [unescapeFilterValue(core).trim()].filter(id => id !== ''); 379 } 380 return []; 381} 382 383/** 384 * Deduped trimmed string ids preserving first-seen positions (aligned with PHP). 385 */ 386function normalizeIncludedItemIdsForOrder(source) { 387 if (!Array.isArray(source)) { 388 return []; 389 } 390 const ranks = new Map(); 391 let r = 0; 392 for (const item of source) { 393 if (typeof item !== 'string' && typeof item !== 'number') { 394 continue; 395 } 396 const key = String(item).trim(); 397 if (!key || ranks.has(key)) { 398 continue; 399 } 400 ranks.set(key, r++); 401 } 402 return Array.from(ranks.keys()); 403} 404 405/** 406 * Prefer explicit {@link includedItems}; otherwise read order from `[itemId==â¦]` in `filter[]`. 407 */ 408function resolveIncludedItemsOrderFromQueryParts(parts) { 409 const fromExplicit = normalizeIncludedItemIdsForOrder(parts.includedItems); 410 if (fromExplicit.length) { 411 return fromExplicit; 412 } 413 const segs = Array.isArray(parts.filter) ? parts.filter.map(s => String(s || '').trim()) : []; 414 return orderedIncludedItemIdsFromFilterSegments(segs); 415} 416 417/** 418 * `Record` payload (marketing product JSON): read top-level {@link itemId} / snake_case fallback. 419 */ 420function readMarketingProductItemId(product) { 421 if (!product || typeof product !== 'object') { 422 return ''; 423 } 424 const id = product.itemId ?? product.item_id ?? ''; 425 return typeof id === 'string' || typeof id === 'number' ? String(id).trim() : ''; 426} 427 428/** @see UnknownRecord variant for browser loader */ 429function orderUnknownRecordsByIncludedItemIds(products, orderedItemIds) { 430 if (!orderedItemIds.length) { 431 return products.slice(); 432 } 433 const ranks = new Map(); 434 let r = 0; 435 for (const id of orderedItemIds) { 436 const key = String(id || '').trim(); 437 if (!key || ranks.has(key)) { 438 continue; 439 } 440 ranks.set(key, r++); 441 } 442 if (ranks.size === 0) { 443 return products.slice(); 444 } 445 const max = Number.MAX_SAFE_INTEGER; 446 return products.slice().sort((a, b) => { 447 const ia = readMarketingProductItemId(a); 448 const ib = readMarketingProductItemId(b); 449 const ra = ia !== '' ? ranks.get(ia) ?? max : max; 450 const rb = ib !== '' ? ranks.get(ib) ?? max : max; 451 return ra === rb ? 0 : ra < rb ? -1 : 1; 452 }); 453} 454 455/** 456 * OpenAPI-aligned product shape (editor store). 457 */ 458function orderMarketingApiProductsByIncludedItemIds(products, orderedItemIds) { 459 if (!orderedItemIds.length) { 460 return products.slice(); 461 } 462 const ranks = new Map(); 463 let r = 0; 464 for (const id of orderedItemIds) { 465 const key = String(id || '').trim(); 466 if (!key || ranks.has(key)) { 467 continue; 468 } 469 ranks.set(key, r++); 470 } 471 if (ranks.size === 0) { 472 return products.slice(); 473 } 474 const max = Number.MAX_SAFE_INTEGER; 475 return products.slice().sort((a, b) => { 476 const ia = String((a.itemId ?? '') || '').trim(); 477 const ib = String((b.itemId ?? '') || '').trim(); 478 const ra = ia !== '' ? ranks.get(ia) ?? max : max; 479 const rb = ib !== '' ? ranks.get(ib) ?? max : max; 480 return ra === rb ? 0 : ra < rb ? -1 : 1; 481 }); 482} 483;// ./src/Gutenberg/constants/productListPackageSortOrder.ts 484/** 485 * Sort catalogue products by `offerings[0].properties.packageSortOrder` 486 * (mirrors PHP `ProductQueryContext::order_products_by_first_offering_package_sort_order` 487 * and `ProductRendererMethods::select_offering` using the first offering). 488 * 489 * Pinned roster order via `includedItems` takes precedence; see `productListIncludedOrder.ts`. 490 */ 491 492/** 493 * Read packageSortOrder from the first offering on a marketing product payload. 494 */
495function readFirstOfferingPackageSortOrder(product) { 496 if (!product || typeof product !== 'object') { 497 return ''; 498 } 499 const offerings = Array.isArray(product.offerings) ? product.offerings : []; 500 const first = offerings[0]; 501 if (!first || typeof first !== 'object') { 502 return ''; 503 } 504 const offering = first; 505 const props = offering.properties && typeof offering.properties === 'object' ? offering.properties : null; 506 const fromProps = props?.packageSortOrder; 507 if (typeof fromProps === 'string' || typeof fromProps === 'number') { 508 return String(fromProps).trim(); 509 } 510 const direct = offering.packageSortOrder; 511 if (typeof direct === 'string' || typeof direct === 'number') { 512 return String(direct).trim(); 513 } 514 return ''; 515} 516function comparePackageSortOrder(a, b) { 517 if (a === b) { 518 return 0; 519 } 520 if (a === '') { 521 return 1; 522 } 523 if (b === '') { 524 return -1; 525 } 526 const aNum = Number(a); 527 const bNum = Number(b); 528 if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { 529 return aNum === bNum ? 0 : aNum < bNum ? -1 : 1; 530 } 531 return a.localeCompare(b, undefined, { 532 numeric: true, 533 sensitivity: 'base' 534 }); 535} 536 537/** @see readFirstOfferingPackageSortOrder */ 538function orderUnknownRecordsByFirstOfferingPackageSortOrder(products) { 539 return products.slice().sort((a, b) => comparePackageSortOrder(readFirstOfferingPackageSortOrder(a), readFirstOfferingPackageSortOrder(b))); 540} 541;// ./src/Gutenberg/Blocks/AddToCart/inc/normalizeProductPropertiesResponse.ts 542function normalizeProductCode(code) { 543 if (code == null || code === '') { 544 return ''; 545 } 546 return String(code).trim(); 547} 548function unwrapProductPropertiesPayloadArray(data) { 549 if (Array.isArray(data)) { 550 return data; 551 } 552 if (data && typeof data === 'object') { 553 const keys = ['Products', 'products', 'data', 'Data', 'Items', 'items', 'rows', 'Rows']; 554 const o = data; 555 for (let i = 0; i < keys.length; i++) { 556 const v = o[keys[i]]; 557 if (Array.isArray(v)) { 558 return v; 559 } 560 } 561 } 562 return []; 563} 564 565/** 566 * Normalize casing/shape so merge + cacheCovers always see `ProductCode`. 567 */ 568function normalizeProductPropertiesApiRows(data) { 569 const raw = unwrapProductPropertiesPayloadArray(data); 570 const out = []; 571 for (let i = 0; i < raw.length; i++) { 572 const row = raw[i]; 573 if (!row || typeof row !== 'object') { 574 continue; 575 } 576 const r = row; 577 const pcRaw = r.ProductCode ?? r.productCode ?? r.Product_Code ?? r.product_code ?? ''; 578 const code = normalizeProductCode(pcRaw); 579 if (!code) { 580 continue; 581 } 582 let props = {}; 583 const pascal = r.Properties; 584 const camel = r.properties; 585 if (pascal && typeof pascal === 'object') { 586 props = { 587 ...pascal 588 }; 589 } else if (camel && typeof camel === 'object') { 590 props = { 591 ...camel 592 }; 593 } 594 out.push({ 595 ...r, 596 ProductCode: code, 597 Properties: props, 598 EnrollmentId: r.EnrollmentId ?? r.enrollmentId 599 }); 600 } 601 return out; 602} 603 604/** 605 * Marks every requested SKU as satisfied for cacheCovers even when the API omits a row. 606 */ 607function ensureProductPropsBucketTracksCodes(requestedCodes) { 608 const codes = [...new Set((requestedCodes || []).map(c => normalizeProductCode(c)).filter(Boolean))]; 609 if (!codes.length) { 610 return; 611 } 612 const w = window; 613 if (!w.__colibriProductProps) { 614 w.__colibriProductProps = { 615 response: [], 616 codes: [] 617 }; 618 } 619 const bucket = w.__colibriProductProps; 620 if (!Array.isArray(bucket.codes)) { 621 bucket.codes = []; 622 } 623 const set = new Set(bucket.codes.map(c => normalizeProductCode(c)).filter(Boolean)); 624 for (let i = 0; i < codes.length; i++) { 625 set.add(codes[i]); 626 } 627 bucket.codes = [...set]; 628} 629;// ./src/Gutenberg/Blocks/AddToCart/inc/productPropsCache.js 630/** 631 * Shared cache for ProductProperties (prefetch + mediator status checks). 632 * 633 * Shape matches AddToCart.php inline prefetch: { response, codes }. 634 */ 635 636/** 637 * @param {unknown} code 638 * @return {string} 639 */ 640function productPropsCache_normalizeProductCode(code) { 641 if (code == null || code === '') { 642 return ''; 643 } 644 return String(code).trim(); 645} 646 647/** 648 * Canonical key for matching DOM / API / prefetch product codes (trim + stable casing). 649 * SKUs are compared case-insensitively so `cacheCovers` stays aligned with PDP buttons. 650 * 651 * @param {unknown} code 652 * @return {string} 653 */ 654function normalizeProductCodeForCacheMatch(code) { 655 const t = productPropsCache_normalizeProductCode(code); 656 return t ? t.toLowerCase() : ''; 657} 658 659/** 660 * Returns true when the cache bucket has either a response row or an 661 * acknowledged "no row" entry (via bucket.codes) for every requested product 662 * code. Replaces the old cacheBucketSatisfiesProductCodes / responseRowsCoverProductCodes 663 * pair with a single pass. 664 * 665 * @param {{ response?: unknown, codes?: unknown }|null|undefined} bucket 666 * @param {string[]}
666 productCodes 667 * @return {boolean} 668 */ 669function cacheCovers(bucket, productCodes) { 670 if (!bucket || !Array.isArray(productCodes) || !productCodes.length) { 671 return false; 672 } 673 const requested = []; 674 for (let i = 0; i < productCodes.length; i++) { 675 const norm = normalizeProductCodeForCacheMatch(productCodes[i]); 676 if (norm) { 677 requested.push(norm); 678 } 679 } 680 if (!requested.length) { 681 return false; 682 } 683 const known = new Set(); 684 if (Array.isArray(bucket.codes)) { 685 for (const code of bucket.codes) { 686 const norm = normalizeProductCodeForCacheMatch(code); 687 if (norm) { 688 known.add(norm); 689 } 690 } 691 } 692 if (Array.isArray(bucket.response)) { 693 for (const row of bucket.response) { 694 const norm = normalizeProductCodeForCacheMatch(row?.ProductCode); 695 if (norm) { 696 known.add(norm); 697 } 698 } 699 } 700 for (const code of requested) { 701 if (!known.has(code)) { 702 return false; 703 } 704 } 705 return true; 706} 707 708/** 709 * Merge API ProductProperties rows into window.__colibriProductProps in place 710 * (mutating the existing bucket and preserving any prev.codes "requested but 711 * no row" entries). Avoids a full deep clone of the cached response on every 712 * mediator notify. 713 * 714 * @param {Array<{ ProductCode: string, Properties?: object, EnrollmentId?: * }>|null|undefined} response 715 * @return void 716 */ 717function mergeColibriProductPropsFromApiResponse(response) { 718 if (!Array.isArray(response) || !response.length) { 719 return; 720 } 721 if (!window.__colibriProductProps) { 722 window.__colibriProductProps = { 723 response: [], 724 codes: [] 725 }; 726 } 727 const bucket = window.__colibriProductProps; 728 if (!Array.isArray(bucket.response)) { 729 bucket.response = []; 730 } 731 if (!Array.isArray(bucket.codes)) { 732 bucket.codes = []; 733 } 734 const codeIndex = new Map(); 735 for (let i = 0; i < bucket.response.length; i++) { 736 const row = bucket.response[i]; 737 if (row?.ProductCode != null) { 738 codeIndex.set(row.ProductCode, i); 739 } 740 } 741 const codeSet = new Set(bucket.codes); 742 for (const row of response) { 743 if (!row?.ProductCode) { 744 continue; 745 } 746 const existingIdx = codeIndex.get(row.ProductCode); 747 if (existingIdx !== undefined) { 748 const existing = bucket.response[existingIdx]; 749 bucket.response[existingIdx] = { 750 ...existing, 751 ...row, 752 Properties: { 753 ...(existing.Properties || {}), 754 ...(row.Properties || {}) 755 }, 756 EnrollmentId: row.EnrollmentId ?? existing.EnrollmentId 757 }; 758 } else { 759 bucket.response.push({ 760 ProductCode: row.ProductCode, 761 ...row, 762 Properties: { 763 ...(row.Properties || {}) 764 } 765 }); 766 codeIndex.set(row.ProductCode, bucket.response.length - 1); 767 } 768 codeSet.add(row.ProductCode); 769 } 770 bucket.codes = [...codeSet]; 771} 772 773/** 774 * @param {string} productCode 775 * @param {Record<string, *>|null|undefined} productStatus 776 * @return void 777 */ 778function patchColibriProductPropsFromMediatorStatus(productCode, productStatus) { 779 if (!productCode || !productStatus || typeof productStatus !== 'object') { 780 return; 781 } 782 const bool = v => v ? 'True' : 'False'; 783 const props = {}; 784 if (productStatus.inMembership !== undefined) { 785 props.InMembership = bool(productStatus.inMembership); 786 } 787 if (productStatus.isEnrolled !== undefined) { 788 props.IsEnrolled = bool(productStatus.isEnrolled); 789 } 790 if (productStatus.inCart !== undefined) { 791 props.InCart = bool(productStatus.inCart); 792 } 793 if (productStatus.isInvalid !== undefined) { 794 props.Invalid = bool(productStatus.isInvalid); 795 } 796 if (productStatus.soldOutFromProductProps) { 797 if (props.SeatsRemaining === undefined) { 798 props.SeatsRemaining = '0'; 799 } 800 } 801 if (productStatus.seatsRemaining !== undefined) { 802 props.SeatsRemaining = productStatus.seatsRemaining === false ? '' : String(productStatus.seatsRemaining); 803 } 804 if (productStatus.upsell?.name) { 805 props.UpsellMembershipName = productStatus.upsell.name;
806 if (productStatus.upsell.productCode != null) { 807 props.UpsellMembershipProductCode = productStatus.upsell.productCode; 808 } 809 if (productStatus.upsell.monthlyPrice != null) { 810 props.UpsellMembershipMonthlyPrice = productStatus.upsell.monthlyPrice; 811 } 812 if (productStatus.upsell.months != null) { 813 props.UpsellMembershipMonths = productStatus.upsell.months; 814 } 815 } 816 if (!Object.keys(props).length && !productStatus.enrollmentId) { 817 return; 818 } 819 const row = { 820 ProductCode: productCode, 821 Properties: props 822 }; 823 if (productStatus.enrollmentId) { 824 row.EnrollmentId = productStatus.enrollmentId; 825 } 826 mergeColibriProductPropsFromApiResponse([row]); 827} 828function clearColibriProductPropsLoading() { 829 delete window.__colibriProductPropsLoading; 830} 831 832/** 833 * Drop the prefetch/merge bucket so a new ProductProperties round-trip (same URL/body 834 * as today) cannot satisfy cacheCovers with cart rows from a prior session. 835 */ 836function resetColibriProductPropsForSessionRecheck() { 837 if (window.__colibriProductProps) { 838 delete window.__colibriProductProps; 839 } 840 clearColibriProductPropsLoading(); 841} 842;// ./src/Gutenberg/Blocks/AddToCart/inc/utils.js 843/* unused harmony import specifier */ var parseGa4Price; 844/* unused harmony import specifier */ var pushGa4ViewItemListEvent; 845/* unused harmony import specifier */ var pushViewItemListAnalyticsCore; 846 847 848 849/** 850 * Read a cookie by name. 851 * @param {string} name - The name of the cookie. 852 * @return {string|null} - The value of the cookie or null if not found. 853 */ 854function getCookie(name) { 855 const prefix = `${name}=`; 856 const parts = document.cookie.split('; '); 857 for (let i = 0; i < parts.length; i += 1) { 858 const cookie = parts[i].trim(); 859 if (cookie.startsWith(prefix)) { 860 return cookie.slice(prefix.length); 861 } 862 } 863 return null; 864} 865 866/** 867 * Set a cookie with the given name, value, and expiration days. 868 * @param {string} name - The name of the cookie. 869 * @param {string} value - The value of the cookie. 870 * @param {number} days - The number of days until expiration. 871 */ 872function setCookie(name, value = '', days) { 873 const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : ''; 874 const domainParts = location.host.split('.').reverse(); 875 const domain = domainParts.length > 1 ? `; domain=.${domainParts[1]}.${domainParts[0]}` : ''; 876 document.cookie = `${name}=${value}${expires}${domain}; path=/`; 877} 878 879/** 880 * Delete a cookie by name. 881 * @param {string} name - The name of the cookie to delete. 882 */ 883function deleteCookie(name) { 884 document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; 885} 886let cachedUser = null; 887let cachedUserCookieString = null; 888 889/** 890 * Cached snapshot of the user's status derived from cookies. The cache is 891 * invalidated automatically whenever document.cookie changes (cheap string 892 * compare instead of re-parsing every cookie variable on every call). 893 */ 894function freshUserStatus() { 895 if (cachedUser !== null && cachedUserCookieString === document.cookie) { 896 return cachedUser; 897 } 898 cachedUserCookieString = document.cookie; 899 const studentId = getCookie('STYXKEY-McKissock-UserId'); 900 const cartId = getCookie('STYXKEY-shoppingcartid'); 901 const role = getCookie('STYXKEY-McKissock-Role'); 902 let isLoggedIn; 903 if (studentId && role && role !== 'Guest') { 904 isLoggedIn = true; 905 } else if (role === 'Guest') { 906 isLoggedIn = 'Guest'; 907 } else { 908 isLoggedIn = false; 909 } 910 cachedUser = { 911 studentId: studentId || '', 912 cartId: cartId || '', 913 role: role || '', 914 isLoggedIn 915 }; 916 return cachedUser; 917} 918const DATA_STORE_KEY = '__colibriProductButtonData'; 919function normalizeElements(value) { 920 if (!value) { 921 return []; 922 } 923 if (value instanceof Element || value === window || value === document) { 924 return [value]; 925 } 926 if (Array.isArray(value)) { 927 return value.filter(Boolean); 928 } 929 if (value instanceof NodeList || value instanceof HTMLCollection) { 930 return Array.from(value); 931 } 932 return [value]; 933} 934function getElementData(element, key) { 935 if (!element) { 936 return undefined; 937 } 938 const localData = element[DATA_STORE_KEY]; 939 if (localData && Object.prototype.hasOwnProperty.call(localData, key)) { 940 return localData[key]; 941 } 942 return undefined; 943} 944function setElementData(element, key, value) { 945 if (!element) { 946 return value; 947 } 948 const store = element[DATA_STORE_KEY] || (element[DATA_STORE_KEY] = {}); 949 store[key] = value; 950 return value; 951} 952function removeElementData(element, key) { 953 if (!element || !element[DATA_STORE_KEY]) { 954 return; 955 }
956 delete element[DATA_STORE_KEY][key]; 957} 958function parseJSONAttribute(element, attributeName, fallback = {}) { 959 const rawValue = element?.getAttribute(`data-${attributeName}`); 960 if (!rawValue) { 961 return fallback; 962 } 963 try { 964 return JSON.parse(rawValue); 965 } catch (error) { 966 console.error(`Unable to parse data-${attributeName}`, error); 967 return fallback; 968 } 969} 970function emitEvent(target, name, detail = null) { 971 normalizeElements(target).forEach(currentTarget => { 972 if (currentTarget?.dispatchEvent) { 973 currentTarget.dispatchEvent(new CustomEvent(name, { 974 detail, 975 bubbles: true 976 })); 977 } 978 }); 979} 980 981/** 982 * Marketing API origin for learner endpoints (no trailing slash). 983 * Prefers `window.colibri.marketingApi.baseUrl`, then `settings.baseUri`. 984 * 985 * @return {string} 986 */ 987function getMarketingApiBaseUrl() { 988 const m = window.colibri?.marketingApi; 989 const raw = m && (m.baseUrl || m.settings?.baseUri) || ''; 990 return String(raw || '').replace(/\/+$/, ''); 991} 992 993/** 994 * Headers for direct browser calls to Marketing / learner APIs: X-Host + Authorization 995 * (Bearer from access_token cookie, else xcred from X-Credential), aligned with 996 * server-side Marketing API config. 997 * 998 * @return {Record<string, string>} 999 */ 1000function buildMarketingLearnerRequestHeaders() { 1001 const settings = window.colibri?.marketingApi?.settings || {}; 1002 const xHost = settings.xHost || ''; 1003 const headers = { 1004 Accept: 'application/json', 1005 'Content-Type': 'application/json' 1006 }; 1007 if (xHost) { 1008 headers['X-Host'] = xHost; 1009 } 1010 const access = getCookie('access_token') || getCookie('STYXKEY-access_token'); 1011 const xcred = getCookie('X-Credential') || getCookie('STYXKEY-X-Credential'); 1012 if (access) { 1013 headers.Authorization = `Bearer ${access}`; 1014 } else if (xcred) { 1015 headers.Authorization = `xcred ${xcred}`; 1016 } 1017 return headers; 1018} 1019 1020/** 1021 * Cart CTAs must read {@see price}, {@see subscriptionId}, etc. from an **offering** row, 1022 * not a Marketing *product* wrapper. Mirrors `getOfferingFromProduct()` in theme 1023 * {@see cl2-pdp-offering.js}: match `offerings[].productCode` to `preferredProductCode`, 1024 * otherwise the first offering. 1025 * 1026 * @param {unknown} value 1027 * @param {string} [preferredProductCode] 1028 * @return {object|null} 1029 */ 1030function resolveMarketingProductOfferingRow(value, preferredProductCode = '') { 1031 if (!value || typeof value !== 'object') { 1032 return null; 1033 } 1034 const hint = String(preferredProductCode || '').trim(); 1035 const rowProductCode = o => { 1036 if (!o || typeof o !== 'object') { 1037 return ''; 1038 } 1039 const props = o.properties; 1040 const fromProps = props && typeof props === 'object' ? String(props.productCode ?? '').trim() : ''; 1041 return fromProps || String(o.productCode ?? '').trim(); 1042 }; 1043 if (Array.isArray(value.offerings) && value.offerings.length) { 1044 if (hint) { 1045 const match = value.offerings.find(o => rowProductCode(o) === hint); 1046 if (match) { 1047 return match; 1048 } 1049 } 1050 return /** @type {object} */value.offerings[0]; 1051 } 1052 return /** @type {object} */value; 1053} 1054 1055/** 1056 * Whether an offering payload matches the sku string against `sku`, `offeringId`, or `id`. 1057 * 1058 * @param {unknown} offering 1059 * @param {string} skuTrimmed Already normalized with {@see String.trim()}. 1060 * @return {boolean} 1061 */ 1062function marketingOfferingMatchesSkuKey(offering, skuTrimmed) { 1063 if (!offering || typeof offering !== 'object' || !skuTrimmed) { 1064 return false; 1065 } 1066 const keys = ['sku', 'offeringId', 'id']; 1067 for (const k of keys) { 1068 if (Object.prototype.hasOwnProperty.call(offering, k) && String(offering[k] ?? '').trim() === skuTrimmed) { 1069 return true; 1070 } 1071 } 1072 const props = offering.properties; 1073 if (props && typeof props === 'object') { 1074 for (const k of ['sku', 'offeringId']) { 1075 if (Object.prototype.hasOwnProperty.call(props, k) && String(props[k] ?? '').trim() === skuTrimmed) { 1076 return true; 1077 } 1078 } 1079 } 1080 return false; 1081} 1082 1083/** 1084 * Pick one offering row for CTA logic: honor {@see sku} when present, 1085 * otherwise match {@see preferredProductCode} / first listing (Marketing product wrapper).
1086 * 1087 * @param {unknown} wrapper Product `{ offerings[] }`, a single offering, or PDP map row. 1088 * @param {string} [sku] 1089 * @param {string} [preferredProductCode] 1090 * @return {object|null} 1091 */ 1092function resolveMarketingOfferingForCta(wrapper, sku = '', preferredProductCode = '') { 1093 if (!wrapper || typeof wrapper !== 'object') { 1094 return null; 1095 } 1096 const skuClean = String(sku || '').trim(); 1097 const hints = Array.isArray(wrapper.offerings) ? wrapper.offerings : []; 1098 if (hints.length) { 1099 if (skuClean) { 1100 const hit = hints.find(row => row && typeof row === 'object' && marketingOfferingMatchesSkuKey(row, skuClean)); 1101 if (hit) { 1102 return hit; 1103 } 1104 return null; 1105 } 1106 return resolveMarketingProductOfferingRow(wrapper, preferredProductCode); 1107 } 1108 if (skuClean) { 1109 return marketingOfferingMatchesSkuKey(wrapper, skuClean) ? (/** @type {object} */wrapper) : null; 1110 } 1111 return resolveMarketingProductOfferingRow(wrapper, preferredProductCode); 1112} 1113 1114/** 1115 * True when ProductProperties exposes a truthy "covered by plan" hint â supplementary only. 1116 * Absence or falseness here does **not** prove the SKU is outside a subscription catalog row: 1117 * {@see isMarketingOfferingIncludedInSubscriptionPlan} on Marketing offerings is authoritative for that case. 1118 * 1119 * @param {Record<string, *>|null|undefined} props 1120 * @return {boolean} 1121 */ 1122function isSubscriptionCoverageFromProductProps(props) { 1123 if (!props || typeof props !== 'object') { 1124 return false; 1125 } 1126 const truthyScalar = v => { 1127 if (v === true) { 1128 return true; 1129 } 1130 if (v === false || v == null || v === '') { 1131 return false; 1132 } 1133 if (typeof v === 'string') { 1134 const t = v.trim().toLowerCase(); 1135 return t === 'true' || t === '1' || t === 'yes' || t === 'y'; 1136 } 1137 return false; 1138 }; 1139 const keys = ['inMembership', 'includedInSubscription']; 1140 for (const key of keys) { 1141 if (Object.prototype.hasOwnProperty.call(props, key)) { 1142 if (truthyScalar(props[key])) { 1143 return true; 1144 } 1145 } 1146 } 1147 return false; 1148} 1149 1150/** 1151 * Subscription-catalog / included-in-plan SKU per Marketing API offering payload: 1152 * price === 0 and subscriptionId !== 0. 1153 * 1154 * @param {object|null|undefined} offering Resolved Marketing offering row (camelCase). 1155 * @return {boolean} 1156 */ 1157function isMarketingOfferingIncludedInSubscriptionPlan(offering) { 1158 if (!offering || typeof offering !== 'object') { 1159 return false; 1160 } 1161 const rawPrice = offering.price ?? offering.unitPrice; 1162 const rawSub = offering.subscriptionId; 1163 const price = rawPrice === '' || rawPrice == null ? NaN : Number.parseFloat(String(rawPrice).replace(/[^0-9.-]/g, ''), 10); 1164 const subscriptionId = Number(rawSub ?? 0); 1165 return Number.isFinite(price) && price === 0 && Number.isFinite(subscriptionId) && subscriptionId !== 0; 1166} 1167function requestJSON({ 1168 url, 1169 method = 'POST', 1170 data = null, 1171 withCredentials = false, 1172 headers = { 1173 'Content-Type': 'application/json' 1174 } 1175}) { 1176 return fetch(url, { 1177 method, 1178 headers, 1179 credentials: withCredentials ? 'include' : 'same-origin', 1180 body: data != null ? JSON.stringify(data) : null 1181 }).then(async response => { 1182 const xhr = { 1183 status: response.status, 1184 statusText: response.statusText 1185 }; 1186 if (!response.ok) { 1187 return Promise.reject({ 1188 statusText: 'error', 1189 xhr 1190 }); 1191 } 1192 const text = await response.text(); 1193 let body = null; 1194 if (text) { 1195 try { 1196 body = JSON.parse(text); 1197 } catch (error) { 1198 return Promise.reject({ 1199 statusText: 'error', 1200 xhr, 1201 error 1202 }); 1203 } 1204 } 1205 return { 1206 data: body, 1207 statusText: 'success', 1208 xhr 1209 }; 1210 }, error => Promise.reject({ 1211 statusText: 'error', 1212 xhr: null, 1213 error 1214 })); 1215} 1216function isVisible(element) { 1217 return !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); 1218} 1219function escapeSelectorValue(value) { 1220 if (window.CSS?.escape) { 1221 return window.CSS.escape(value); 1222 } 1223 return String(value).replace(/["\\]/g, '\\$&'); 1224} 1225function logDataLayerSkip(reason, detail = null) { 1226 console.warn('[dataLayer] skipped push', reason, detail); 1227} 1228 1229/** 1230 * Push a standardized GA4 `add_to_cart` event after a successful c
1230art action. 1231 * Payload fields are read directly from the Marketing offering (and product display name). 1232 * 1233 * @param {{ 1234 * productCode?: string, 1235 * offering?: object|null, 1236 * productName?: string, 1237 * quantity?: number, 1238 * }} [opts] 1239 * @return {boolean} Whether a dataLayer push was attempted. 1240 */ 1241function pushGa4AddToCartEvent(opts = {}) { 1242 const offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 1243 if (!offering) { 1244 logDataLayerSkip('add_to_cart: missing offering', opts); 1245 return false; 1246 } 1247 const item_id = String(offering.productCode ?? opts.productCode ?? '').trim(); 1248 const item_name = String(opts.productName ?? '').trim(); 1249 const price = parseGa4Price(offering.price ?? offering.originalPrice); 1250 const quantity = Number.isFinite(opts.quantity) && opts.quantity > 0 ? opts.quantity : 1; 1251 if (!item_id || !item_name || !Number.isFinite(price)) { 1252 logDataLayerSkip('add_to_cart: invalid GA4 item payload', { 1253 item_id, 1254 item_name, 1255 price, 1256 quantity,
1257 productCode: opts.productCode 1258 }); 1259 return false; 1260 } 1261 window.dataLayer = window.dataLayer || []; 1262 const value = Math.round(price * quantity * 100) / 100; 1263 window.dataLayer.push({ 1264 ecommerce: null 1265 }); 1266 window.dataLayer.push({ 1267 event: 'add_to_cart', 1268 ecommerce: { 1269 currency: 'USD', 1270 value, 1271 items: [{ 1272 item_id, 1273 item_name, 1274 price, 1275 quantity 1276 }] 1277 } 1278 }); 1279 return true; 1280} 1281 1282/** 1283 * Push GA4 `add_to_cart` from Marketing offering + product display name. 1284 * 1285 * @param {{ 1286 * productCode?: string, 1287 * offering?: object|null, 1288 * productName?: string, 1289 * quantity?: number, 1290 * }} [opts] 1291 * @return {boolean} Whether a dataLayer push was attempted. 1292 */ 1293function pushAddToCartAnalytics(opts = {}) { 1294 return pushGa4AddToCartEvent(opts); 1295} 1296const GA4_VIEW_ITEM_TYPES = new Set(['detail', 'quick', 'option']); 1297 1298/** 1299 * Push a standardized GA4 `view_item` event when a product is viewed. 1300 * 1301 * @param {{ 1302 * productCode?: string, 1303 * offering?: object|null, 1304 * productName?: string, 1305 * viewType?: 'detail'|'quick'|'option', 1306 * }} [opts] 1307 * @return {boolean} Whether a dataLayer push was attempted. 1308 */ 1309function pushGa4ViewItemEvent(opts = {}) { 1310 const offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 1311 if (!offering) { 1312 logDataLayerSkip('view_item: missing offering', opts); 1313 return false; 1314 } 1315 const viewType = GA4_VIEW_ITEM_TYPES.has(opts.viewType) ? opts.viewType : 'detail';
1316 const item_id = String(offering.productCode ?? opts.productCode ?? '').trim(); 1317 const item_name = String(opts.productName ?? '').trim(); 1318 const price = parseGa4Price(offering.price ?? offering.originalPrice); 1319 if (!item_id || !item_name || !Number.isFinite(price)) { 1320 logDataLayerSkip('view_item: invalid GA4 item payload', { 1321 item_id, 1322 item_name, 1323 price, 1324 viewType, 1325 productCode: opts.productCode 1326 }); 1327 return false; 1328 } 1329 window.dataLayer = window.dataLayer || []; 1330 window.dataLayer.push({ 1331 ecommerce: null 1332 }); 1333 window.dataLayer.push({ 1334 event: 'view_item', 1335 view_type: viewType, 1336 ecommerce: { 1337 currency: 'USD', 1338 items: [{ 1339 item_id, 1340 item_name, 1341 price 1342 }] 1343 } 1344 }); 1345 return true; 1346} 1347 1348/** 1349 * Push GA4 `view_item` from Marketing offering + product display name. 1350 * 1351 * @param {{ 1352 * productCode?: string, 1353 * offering?: object|null, 1354 * product?: object|null, 1355 * productName?: string, 1356 * viewType?: 'detail'|'quick'|'option', 1357 * }} [opts] 1358 * @return {boolean} Whether a dataLayer push was attempted. 1359 */ 1360function pushViewItemAnalytics(opts = {}) {
1361 const productCode = String(opts.productCode || '').trim(); 1362 let offering = opts.offering && typeof opts.offering === 'object' ? opts.offering : null; 1363 let productName = String(opts.productName ?? '').trim(); 1364 const product = opts.product && typeof opts.product === 'object' ? opts.product : null; 1365 if (product && (!offering || !productName)) { 1366 offering = offering || resolveMarketingOfferingForCta(product, '', productCode); 1367 if (!productName) { 1368 productName = String(product.name ?? '').trim(); 1369 } 1370 } 1371 return pushGa4ViewItemEvent({ 1372 productCode, 1373 offering, 1374 productName, 1375 viewType: opts.viewType 1376 }); 1377} 1378 1379/** 1380 * Push GA4 `view_item_list` from Marketing products (listing impressions). 1381 * 1382 * @param {{ 1383 * products?: Array<object>, 1384 * items?: Array<object>, 1385 * itemListName?: string, 1386 * resolveOffering?: function(object, string): object|null, 1387 * }} [opts] 1388 * @return {boolean} 1389 */ 1390function pushViewItemListAnalytics(opts = {}) { 1391 if (Array.isArray(opts.items) && opts.items.length) { 1392 return pushGa4ViewItemListEvent(opts); 1393 } 1394 return pushViewItemListAnalyticsCore({ 1395 ...opts, 1396 resolveOffering: opts.resolveOffering || ((product, code) => resolveMarketingOfferingForCta(product, '', code)) 1397 }); 1398} 1399function pushAnalyticsData(productCode, action, productsInList, options) { 1400 if (action === 'add_to_cart' && productCode && productCode !== 'list') { 1401 pushAddToCartAnalytics({ 1402 productCode, 1403 offering: options?.offering ?? null, 1404 productName: options?.productName ?? '', 1405 quantity: options?.quantity ?? 1 1406 }); 1407 return; 1408 } 1409 if (action === 'view_item' && productCode && productCode !== 'list') { 1410 pushViewItemAnalytics({ 1411 productCode, 1412 offering: options?.offering ?? null, 1413 product: options?.product ?? null, 1414 productName: options?.productName ?? '', 1415 viewType: options?.viewType ?? 'detail' 1416 }); 1417 return; 1418 } 1419 if (action === 'view_item_list' || productCode === 'list') { 1420 pushViewItemListAnalytics({ 1421 products: Array.isArray(productsInList) ? productsInList : [], 1422 items: options?.items, 1423 itemListName: options?.itemListName ?? options?.item_list_name ?? '', 1424 resolveOffering: options?.resolveOffering 1425 }); 1426 return; 1427 } 1428 window.colibri?.utils?.pushAnalyticsData?.(productCode, action, productsInList); 1429} 1430 1431/** 1432 * Student ProductProperties and marketing SKU prefetch must use the helpers in this 1433 * section (`shouldFetchMarketingApisForAddToCartDomScope`, `buildCartMarketingStudentPrefetchGateSets`, 1434 * and related exports) so `select.select-state` stays aligned with `button[data-product-code]`. 1435 * Batched prefetch in `product-loader.ts` builds a document snapshot via `buildCartMarketingStudentPrefetchGateSets`; 1436 * `ProductButtonMediator` applies the same gate per registered `[data-legacy-add-to-cart]` control 1437 * (`productCodesEligibleForMarketingStatusFetch`). 1438 */ 1439 1440/** 1441 * When the closest PDP cart wrapper contains `select.select-state`, defer Marketing 1442 * ProductProperties + product-by-code hydration until the select value matches the 1443 * cart button `data-product-code` (avoids fetching for a stale SSR code). 1444 * 1445 * @param {Element|null|undefined} scopeEl Element inside `[data-legacy-add-to-cart]`, `[data-core-add-to-cart]`, etc. 1446 * @param {Element|null|undefined} [buttonEl] Optional `button[data-product-code]` (Core passes the concrete button). 1447 * @return {boolean} `false` â omit ProductProperties / `products` GET-by-code for this scope. 1448 */ 1449function shouldFetchMarketingApisForAddToCartDomScope(scopeEl, buttonEl = null) { 1450 if (!(scopeEl instanceof Element)) { 1451 return true; 1452 } 1453 if (scopeEl.closest('.upsell-block') || buttonEl instanceof Element && buttonEl.closest('.upsell-block')) { 1454 return false; 1455 } 1456 const wrap = scopeEl.closest('.add-to-cart-container, .individual-add-to-cart-container'); 1457 if (!wrap) { 1458 return true; 1459 } 1460 if (wrap.closest('.upsell-block')) { 1461 return false; 1462 } 1463 const stateSelect = wrap.querySelector('select.select-state'); 1464 if (!stateSelect) { 1465 return true; 1466 } 1467 const btn = buttonEl instanceof Element ? buttonEl : wrap.querySelector('button[data-product-code]'); 1468 const buttonCode = String(btn?.getAttribute?.('data-product-code') ?? '').trim(); 1469 const selected = String(/** @type {HTMLSelectElement} */stateSelect.value ?? '').trim(); 1470 if (wrap.querySelector('select.select-seminar-offering, select.select-session, select.select-parent-seminar')) { 1471 return !!selected; 1472 } 1473 return selected === buttonCode; 1474} 1475 1476/** 1477 * Locate legacy `[data-legacy-add-to-cart]` or Core `[data-core-add-to-cart]` controls whose
1478 * primary SKU matches `productCode`, optionally scoped to a PDP subtree (`scopeRoot`). 1479 * 1480 * @param {string} productCode 1481 * @param {Element|null} scopeRoot e.g. `[data-colibri-pdp]` 1482 * @return {{ scope: Element, button: Element } | null} 1483 */ 1484function findPrimaryCartGateTargetsForProductCode(productCode, scopeRoot = null) { 1485 const code = String(productCode || '').trim(); 1486 if (!code) { 1487 return null; 1488 } 1489 const scanRoots = scopeRoot instanceof Element ? [scopeRoot] : []; 1490 if (!scanRoots.length) { 1491 return null; 1492 } 1493 for (let r = 0; r < scanRoots.length; r++) { 1494 const root = scanRoots[r]; 1495 const legacyRoots = root.querySelectorAll('[data-legacy-add-to-cart]'); 1496 for (let i = 0; i < legacyRoots.length; i++) { 1497 const lr = legacyRoots[i]; 1498 if (lr.closest('.upsell-block')) { 1499 continue; 1500 } 1501 const btn = lr.querySelector('button[data-product-code]'); 1502 const primary = String(btn && btn.getAttribute('data-product-code') || lr.getAttribute('data-product-code') || '').trim(); 1503 if (primary !== code) { 1504 continue; 1505 } 1506 if (btn instanceof Element) { 1507 return { 1508 scope: lr, 1509 button: btn 1510 }; 1511 } 1512 } 1513 const coreBtns = root.querySelectorAll('.individual-add-to-cart-container[data-cart-experience="core"] [data-core-add-to-cart] button[data-product-code]'); 1514 for (let i = 0; i < coreBtns.length; i++) { 1515 const btn = coreBtns[i]; 1516 if (!(btn instanceof Element)) { 1517 continue; 1518 } 1519 const host = btn.closest('.individual-add-to-cart-container'); 1520 if (!host || host.closest('.upsell-block')) { 1521 continue; 1522 } 1523 const primary = String(btn.getAttribute('data-product-code') || '').trim(); 1524 if (primary !== code) { 1525 continue; 1526 } 1527 const wrap = btn.closest('[data-core-add-to-cart]'); 1528 if (wrap instanceof Element) { 1529 return { 1530 scope: wrap, 1531 button: btn 1532 }; 1533 } 1534 } 1535 } 1536 return null; 1537} 1538 1539/** 1540 * One DOM pass for Student ProductProperties / prefetch eligibility: 1541 * SKUs tied to cart UI vs SKUs allowed while {@see shouldFetchMarketingApisForAddToCartDomScope} passes. 1542 * 1543 * Core rows use `getCoreButtonProductCode(btn)` so orchestrated PDP can follow the equivalent-offering 1544 * `<select>` (see `effectiveCorePdpButtonProductCodeForPrefetch` in product-loader). 1545 * 1546 * @param {(btn: Element) => string} [getCoreButtonProductCode] 1547 * @return {{ trackedCodes: Set<string>, eligibleCodes: Set<string> }} 1548 */ 1549function buildCartMarketingStudentPrefetchGateSets(getCoreButtonProductCode) { 1550 const trackedCodes = new Set(); 1551 const eligibleCodes = new Set(); 1552 const resolveCoreCode = typeof getCoreButtonProductCode === 'function' ? getCoreButtonProductCode : btn => String(btn.getAttribute?.('data-product-code') ?? '').trim(); 1553 const legacyRoots = typeof document !== 'undefined' ? document.querySelectorAll('[data-legacy-add-to-cart]') : []; 1554 for (let i = 0; i < legacyRoots.length; i++) { 1555 const root = legacyRoots[i]; 1556 const btn = root.querySelector?.('button[data-product-code]'); 1557 const alt = String(root.getAttribute?.('data-alt-product-code') || '').trim(); 1558 const primary = String(btn && btn.getAttribute('data-product-code') || root.getAttribute?.('data-product-code') || '').trim(); 1559 const passes = shouldFetchMarketingApisForAddToCartDomScope(root, btn instanceof Element ? btn : null); 1560 for (const code of [alt, primary].filter(Boolean)) { 1561 trackedCodes.add(code); 1562 if (passes) { 1563 eligibleCodes.add(code); 1564 } 1565 } 1566 } 1567 const upsellCoreBtns = typeof document !== 'undefined' ? document.querySelectorAll('.upsell-block [data-core-add-to-cart] button[data-product-code]') : []; 1568 for (let i = 0; i < upsellCoreBtns.length; i++) { 1569 const btn = upsellCoreBtns[i]; 1570 if (!(btn instanceof Element)) { 1571 continue; 1572 } 1573 const code = String(btn.getAttribute('data-product-code') || '').trim(); 1574 if (code) { 1575 trackedCodes.add(code); 1576 } 1577 } 1578 const coreBtns = typeof document !== 'undefined' ? document.querySelectorAll('.individual-add-to-cart-container[data-cart-experience
1578="core"] [data-core-add-to-cart] button[data-product-code]') : []; 1579 for (let i = 0; i < coreBtns.length; i++) { 1580 const btn = coreBtns[i]; 1581 if (!(btn instanceof Element)) { 1582 continue; 1583 } 1584 const host = btn.closest('.individual-add-to-cart-container'); 1585 if (!host || host.closest('.upsell-block')) { 1586 continue; 1587 } 1588 const effective = String(resolveCoreCode(btn) || '').trim(); 1589 if (!effective) { 1590 continue; 1591 } 1592 trackedCodes.add(effective); 1593 /* Core commerce CTAs resolve from Marketing product detail + Core cart â never Student ProductProperties. */ 1594 } 1595 return { 1596 trackedCodes, 1597 eligibleCodes 1598 }; 1599} 1600 1601/** 1602 * @param {string} code 1603 * @param {{ trackedCodes: Set<string>, eligibleCodes: Set<string> }} sets 1604 * @return {boolean} 1605 */ 1606function shouldStudentPrefetchProductPropertiesForProductCode(code, sets) { 1607 const c = String(code || '').trim(); 1608 if (!c) { 1609 return false; 1610 } 1611 if (!sets || !sets.trackedCodes || !sets.eligibleCodes) { 1612 return true; 1613 } 1614 if (!sets.trackedCodes.has(c)) { 1615 return true; 1616 } 1617 return sets.eligibleCodes.has(c); 1618} 1619 1620/** 1621 * @param {(btn: Element) => string} [getCoreButtonProductCode] 1622 * @return {string[]} 1623 */ 1624function collectEligibleStudentPrefetchCartProductCodes(getCoreButtonProductCode) { 1625 const { 1626 eligibleCodes 1627 } = buildCartMarketingStudentPrefetchGateSets(getCoreButtonProductCode); 1628 return [...eligibleCodes]; 1629} 1630 1631/** 1632 * Primary core add-to-cart control on an orchestrated PDP (`[data-colibri-pdp]`). 1633 * Mirrors the cart-host checks in `core-add-to-cart-view.js` / {@see maybeDispatchPdpProductResolvedFromLoader}: 1634 * used when `select.select-state` lags localStorage / SSR `data-product-code` so we still allow 1635 * Marketing loader + Student ProductProperties for the button SKU. 1636 * 1637 * @param {Element|null|undefined} buttonEl 1638 * @return {boolean} 1639 */ 1640function isPrimaryColibriPdpCoreCartButton(buttonEl) { 1641 if (!(buttonEl instanceof Element)) { 1642 return false; 1643 } 1644 if (!document.querySelector('[data-colibri-pdp]')) { 1645 return false; 1646 } 1647 const cart = buttonEl.closest('.individual-add-to-cart-container'); 1648 if (!cart || cart.closest('.upsell-block')) { 1649 return false; 1650 } 1651 if (String(cart.dataset.cartExperience || '').toLowerCase() !== 'core') { 1652 return false; 1653 } 1654 return !!cart.closest('[data-colibri-pdp]'); 1655} 1656 1657/** 1658 * Comparison grid SSR already resolves product + offering; enable Core CTAs when 1659 * `data-product-code` and `data-sku` are present without waiting on client catalogue hydration. 1660 * 1661 * @param {Element|null|undefined} buttonEl 1662 * @return {boolean} 1663 */ 1664function isComparisonGridListingCoreCartButton(buttonEl) { 1665 if (!(buttonEl instanceof Element)) { 1666 return false; 1667 } 1668 if (isPrimaryColibriPdpCoreCartButton(buttonEl)) { 1669 return false; 1670 } 1671 if (!buttonEl.closest('.product-comparison-view')) { 1672 return false; 1673 } 1674 const wrap = buttonEl.closest('[data-core-add-to-cart]'); 1675 const code = String(buttonEl.dataset.productCode || wrap && wrap.dataset.productCode || buttonEl.getAttribute('data-product-code') || '').trim(); 1676 const sku = String(buttonEl.dataset.sku || buttonEl.getAttribute('data-sku') || '').trim(); 1677 return !!code && !!sku; 1678} 1679const 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'])); 1680 1681// Capture cart cookies on partnership group pages 1682function registerCookieLogging() { 1683 const body = document.body; 1684 if (!body || !body.classList.contains('retail-group-template') && !body.classList.contains('university-group-template')) { 1685 return; 1686 } 1687 body.addEventListener('ColibriEvent:DO_ADD_TO_CART_SUCCESS', () => { 1688 COOKIE_DEBUG_NAMES.forEach(name => { 1689 const value = getCookie(name); 1690 console.log(name, ': ', value || 'Not Set'); 1691 }); 1692 }, { 1693 once: true 1694 }); 1695} 1696;// ./src/Rendering/scripts/marketingApiRouting.ts 1697/** 1698 * Browser routing: direct upstream Marketing API vs same-origin WP REST pass-through. 1699 * 1700 * Always use the WP REST proxy when the host includes `.local` (never call `/marketing/...` from the 1701 * browser). On other non-prod hosts (`dev.*`, `test.*`, `stage.*`), use the proxy 1702 * when the configured base URI is production Marketing API (`api-ms.<domain>`). 1703 * PHP exposes the decision via `window.colibriMarketingApiConfig.useRestProxy`. 1704 */ 1705 1706const WP_JSON_MARKETING_V1_SUFFIX = '/wp-json/wp-marketing-api/v1'; 1707 1708/** 1709 * Same-origin WP namespace for Marketing API routes. 1710 */ 1711function wpJsonMarketingV1Base() { 1712 return `${window.location.origin.replace(/\/+$/, '')}${WP_JSON_MARKETING_V1_SUFFIX}`; 1713} 1714 1715/** 1716 * True when the URL points at the WP REST proxy namespace. 1717 */ 1718function isWpJsonMarketingProxyBase(base) { 1719 return /\bwp-json\/.*?wp-marketing-api\/v1\b/i.test(base.replace(/^https?:\/\//i, '')); 1720} 1721 1722/** 1723 * Non-prod site hosts that may need CORS proxying to prod Marketing API. 1724 */ 1725function isNonProdMarketingProxyHost() { 1726 const host = String(window.location?.hostname || '').toLowerCase().replace(/:\d+$/, ''); 1727 if (!host) { 1728 return false;
1729 } 1730 if (host === 'local' || host.includes('.local')) { 1731 return true; 1732 } 1733 return /^(dev|test|stage)\./.test(host); 1734} 1735 1736/** 1737 * Whether browser-side Marketing API calls should use the WP REST pass-through. 1738 */ 1739function shouldUseMarketingRestProxy() { 1740 if (typeof window === 'undefined') { 1741 return false; 1742 } 1743 const host = String(window.location?.hostname || '').toLowerCase().replace(/:\d+$/, ''); 1744 // Local always uses REST passthrough â never hit /marketing/... directly. 1745 if (host === 'local' || host.includes('.local')) { 1746 return true; 1747 } 1748 const cfg = window.colibriMarketingApiConfig; 1749 if (typeof cfg?.useRestProxy === 'boolean') { 1750 return cfg.useRestProxy; 1751 } 1752 if (cfg?.isProdApiBase === true && isNonProdMarketingProxyHost()) { 1753 return true; 1754 } 1755 return false; 1756} 1757;// ./src/Rendering/scripts/fetchPromotedDiscountCodes.ts 1758 1759 1760 1761function readMarketingApiUpstreamBaseUri() { 1762 const raw = String(window.colibriMarketingApiConfig?.apiBaseUri || window.colibri?.marketingApi?.baseUrl || window.colibri?.marketingApi?.settings?.baseUri || '').trim(); 1763 return raw.replace(/\/+$/, ''); 1764} 1765function buildMetaFromResponse(response) { 1766 let pagination = null; 1767 let filter = null; 1768 try { 1769 const ph = response.headers.get('X-Pagination'); 1770 if (ph) { 1771 pagination = JSON.parse(ph); 1772 } 1773 } catch { 1774 /* ignore */ 1775 } 1776 try { 1777 const fh = response.headers.get('X-Filter'); 1778 if (fh) { 1779 filter = JSON.parse(fh); 1780 } 1781 } catch { 1782 /* ignore */ 1783 } 1784 return { 1785 status: response.status, 1786 pagination, 1787 filter 1788 }; 1789} 1790function buildPromotedDiscountCodesQuery(params) { 1791 const sp = new URLSearchParams(); 1792 const merged = withMarketingPromotedDiscountCodesDefault(params || {}); 1793 for (const [key, value] of Object.entries(merged)) { 1794 if (value === undefined || value === null) { 1795 continue; 1796 } 1797 if (typeof value === 'boolean') { 1798 sp.set(key, value ? 'true' : 'false'); 1799 continue; 1800 } 1801 sp.set(key, String(value)); 1802 } 1803 return sp.toString(); 1804} 1805 1806/** 1807 * Fetch promoted discount codes via the WP REST proxy when CORS requires it, 1808 * otherwise call the upstream Marketing API directly. 1809 */ 1810async function fetchPromotedDiscountCodes(params) { 1811 const query = buildPromotedDiscountCodesQuery(params); 1812 const useWpJsonProxy = shouldUseMarketingRestProxy(); 1813 const url = useWpJsonProxy ? `${wpJsonMarketingV1Base()}/discount-codes-promoted${query ? `?${query}` : ''}` : (() => { 1814 const base = readMarketingApiUpstreamBaseUri(); 1815 if (!base) { 1816 throw new Error('Marketing API base URI is not configured.'); 1817 } 1818 const root = base.replace(/\/marketing$/i, ''); 1819 return `${root.replace(/\/+$/, '')}/marketing/discountcodes/promoted${query ? `?${query}` : ''}`; 1820 })(); 1821 const response = await fetch(url, mergeMarketingAuthHeadersForFetchUrl(url, { 1822 method: 'GET' 1823 })); 1824 if (!response.ok) { 1825 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 1826 } 1827 const json = await response.json(); 1828 if (useWpJsonProxy && Array.isArray(json)) { 1829 return json; 1830 } 1831 return [json, buildMetaFromResponse(response)]; 1832} 1833;// ./src/Rendering/scripts/product-loader.ts 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845/** 1846 * PDP troubleshooting logs: `?debug_pdp_cta=1`, `localStorage colibri_debug_pdp_core_cta=1`, 1847 * or `window.__COLIBRI_DEBUG_PDP_CORE_CTA = true`. 1848 */ 1849function shouldDebugPdpCoreCta() { 1850 try { 1851 const w = window; 1852 if (w.__COLIBRI_DEBUG_PDP_CORE_CTA === true) { 1853 return true; 1854 } 1855 if (/[?&]debug_pdp_cta=1(?:&|$)/.test(String(window.location.search || ''))) { 1856 return true; 1857 } 1858 if (window.localStorage && window.localStorage.getItem('colibri_debug_pdp_core_cta') === '1') { 1859 return true; 1860 } 1861 } catch { 1862 /* optional PDP troubleshooting */ 1863 } 1864 return false; 1865} 1866function logColibriProductPropsDebugEvent(stage, payload) { 1867 if (!shouldDebugPdpCoreCta()) { 1868 return; 1869 } 1870 console.log('%c[Colibri product-props]%c ' + stage, 'color:#0a7;font-weight:bold', 'color:inherit', payload ?? ''); 1871} 1872 1873/** PDP loader / `fetchOffering`: same toggle as {@link logColibriProductPropsDebugEvent}. */ 1874function logColibriPdpLoaderDebug(stage, payload) { 1875 if (!shouldDebugPdpCoreCta()) { 1876 return; 1877 } 1878 console.log('%c[Colibri PDP loader]%c ' + stage, 'color:#156;font-weight:bold', 'color:inherit', payload ?? ''); 1879}
1880function readPrimaryPdpEquivalentOfferingProductCode() { 1881 const w = window; 1882 const fn = w.colibri?.readPrimaryPdpEquivalentOfferingProductCode; 1883 if (typeof fn !== 'function') { 1884 return ''; 1885 } 1886 try { 1887 return String(fn() ?? '').trim(); 1888 } catch { 1889 return ''; 1890 } 1891} 1892 1893/** 1894 * Orchestrated PDP + primary core CTA: follow the equivalent-offering `<select>` when set. 1895 * When the shopper is still on the placeholder (no `.value`), do **not** prefetch Student 1896 * ProductProperties for SSR `data-product-code` â wait until {@see window.colibri.readPrimaryPdpEquivalentOfferingProductCode} 1897 * returns a SKU (including single-offering SSR preselect). 1898 * 1899 * @param btn Core PDP cart button (`[data-core-add-to-cart] button`). 1900 */ 1901function effectiveCorePdpButtonProductCodeForPrefetch(btn) { 1902 const domCode = String(btn.getAttribute('data-product-code') || '').trim(); 1903 if (!document.querySelector('[data-colibri-pdp]')) { 1904 return domCode; 1905 } 1906 if (!isPrimaryColibriPdpCoreCartButton(btn)) { 1907 return domCode; 1908 } 1909 const selected = readPrimaryPdpEquivalentOfferingProductCode(); 1910 if (!selected) { 1911 return ''; 1912 } 1913 if (normalizeProductCodeForCacheMatch(selected) !== normalizeProductCodeForCacheMatch(domCode)) { 1914 return selected; 1915 } 1916 return domCode; 1917} 1918 1919/** 1920 * Browser catalogue loader: marketing product fetch/cache, ProductProperties POST, 1921 * `window.colibri.fetchProductProperties`, legacy CTA prefetch, and merged payloads. 1922 */ 1923 1924function readFetchProductProperties() { 1925 const colibri = window.colibri; 1926 const fn = colibri?.fetchProductProperties; 1927 return typeof fn === 'function' ? fn : undefined; 1928} 1929function getOfferingFromProduct(product, productCode) { 1930 if (!product || !Array.isArray(product.offerings)) { 1931 return null; 1932 } 1933 const offerings = product.offerings; 1934 if (productCode) { 1935 const match = offerings.find(o => o && typeof o === 'object' && String(o.productCode ?? '').trim() === productCode); 1936 if (match && typeof match === 'object') { 1937 return match; 1938 } 1939 } 1940 const first = offerings[0]; 1941 return first && typeof first === 'object' ? first : null; 1942} 1943 1944/** 1945 * Uses `window.colibri.fetchProductProperties` when present (AddToCart bootstrap). 1946 */ 1947async function fetchProductPropertiesRow(productCode) { 1948 const code = String(productCode || '').trim(); 1949 if (!code) { 1950 return null; 1951 } 1952 const fn = readFetchProductProperties(); 1953 if (typeof fn !== 'function') { 1954 return null; 1955 } 1956 try { 1957 const rows = await fn(code); 1958 const list = Array.isArray(rows) ? rows : []; 1959 const hit = list.find(r => r && typeof r === 'object' && String(r.ProductCode ?? '').trim() === code); 1960 return hit && typeof hit === 'object' ? hit : null; 1961 } catch { 1962 return null; 1963 } 1964} 1965 1966/** 1967 * Shallow-merge ProductProperties into the marketing offering used for PDP render 1968 * and `PDP_PRODUCT_RESOLVED` consumers. 1969 */ 1970function applyProductPropertiesToMarketingProduct(marketingProduct, productCode, propsRow) { 1971 if (!propsRow) { 1972 return marketingProduct; 1973 } 1974 const offering = getOfferingFromProduct(marketingProduct, productCode); 1975 if (!offering) { 1976 return marketingProduct; 1977 } 1978 let props = {}; 1979 const p = propsRow.Properties; 1980 if (p && typeof p === 'object') { 1981 const upsellNameRaw = p.UpsellMembershipName; 1982 const hasUpsell = upsellNameRaw !== undefined && upsellNameRaw !== null && String(upsellNameRaw).trim() !== ''; 1983 props = { 1984 inCart: (p.InCart ?? false) === 'True', 1985 isInvalid: (p.Invalid ?? false) === 'True', 1986 seatsRemaining: p.SeatsRemaining ?? null, 1987 upsell: hasUpsell ? { 1988 name: p.UpsellMembershipName, 1989 productCode: p.UpsellMembershipProductCode, 1990 monthlyPrice: p.UpsellMembershipMonthlyPrice, 1991 months: p.UpsellMembershipMonths 1992 } : null 1993 }; 1994 } 1995 const baseProps = offering.properties && typeof offering.properties === 'object' ? { 1996 ...offering.properties 1997 } : {}; 1998 const mergedOffering = { 1999 ...offering, 2000 properties: { 2001 ...baseProps, 2002 ...props 2003 } 2004 }; 2005 const codeToMatch = String(productCode ?? '').trim(); 2006 const result = { 2007 ...marketingProduct 2008 }; 2009 if (Array.isArray(result.offerings)) { 2010 result.offerings = result.offerings.map((o, idx) => { 2011 if (!o || typeof o !== 'object') { 2012 return o; 2013 }
2014 const row = o; 2015 if (codeToMatch) { 2016 const opc = String(row.productCode ?? '').trim(); 2017 return opc === codeToMatch ? mergedOffering : o; 2018 } 2019 return idx === 0 ? mergedOffering : o; 2020 }); 2021 } else { 2022 result.offerings = [mergedOffering]; 2023 } 2024 return result; 2025} 2026/** 2027 * Coalesce concurrent {@link fetchOffering} calls for the same SKU (multiple PDP hooks used to double-fetch). 2028 * Key includes merge flag â props merge changes the merged product/offering payload. 2029 */ 2030const fetchOfferingInflight = new Map(); 2031 2032/** PDP orchestrator: marketing GET plus optional ProductProperties merge. */ 2033async function fetchOffering(productCode, options) { 2034 const code = String(productCode || '').trim(); 2035 if (!code) { 2036 return null; 2037 } 2038 const opts = options ?? { 2039 whenRendererReady: () => Promise.resolve(), 2040 readPdpRoot: () => document.querySelector('[data-colibri-pdp]'), 2041 mergeStudentProductProperties: true 2042 }; 2043 const mergeStudentProductProperties = opts.mergeStudentProductProperties !== false; 2044 const ignoreCartFetchGate = opts.ignoreCartFetchGate === true; 2045 const inflightKey = `${code}\u0000mergeProps:${mergeStudentProductProperties ? '1' : '0'}\u0000ignoreGate:${ignoreCartFetchGate ? '1' : '0'}`; 2046 const existing = fetchOfferingInflight.get(inflightKey); 2047 if (existing) { 2048 logColibriPdpLoaderDebug('fetchOffering:join_inflight', { 2049 code, 2050 mergeStudentProductProperties 2051 }); 2052 return existing; 2053 } 2054 logColibriPdpLoaderDebug('fetchOffering:start', { 2055 code, 2056 mergeStudentProductProperties 2057 }); 2058 const pending = (async () => { 2059 await opts.whenRendererReady(); 2060 const loader = window.colibriProductLoader; 2061 const fetchByCode = loader?.fetchByCode; 2062 if (typeof fetchByCode !== 'function' || !loader) { 2063 logColibriPdpLoaderDebug('fetchOffering:abort_no_loader', { 2064 code 2065 }); 2066 return null; 2067 } 2068 const pdpRoot = opts.readPdpRoot(); 2069 const gateTargets = findPrimaryCartGateTargetsForProductCode(code, pdpRoot); 2070 const allowLoaderSku = ignoreCartFetchGate || !gateTargets || shouldFetchMarketingApisForAddToCartDomScope(gateTargets.scope, gateTargets.button) || isPrimaryColibriPdpCoreCartButton(gateTargets.button); 2071 logColibriPdpLoaderDebug('fetchOffering:gate', { 2072 code, 2073 allowLoaderSku, 2074 hasGateTargets: !!gateTargets, 2075 marketingScopeWouldFetch: gateTargets ? shouldFetchMarketingApisForAddToCartDomScope(gateTargets.scope, gateTargets.button) : null, 2076 isPrimaryPdpCoreButton: gateTargets ? isPrimaryColibriPdpCoreCartButton(gateTargets.button) : null 2077 }); 2078 const propsPromise = allowLoaderSku && mergeStudentProductProperties ? fetchProductPropertiesRow(code) : Promise.resolve(null); 2079 const [marketingProduct, propsRow] = await Promise.all([allowLoaderSku ? fetchByCode.call(loader, code, { 2080 sourceEl: pdpRoot 2081 }) : Promise.resolve(null), propsPromise]); 2082 if (!marketingProduct || typeof marketingProduct !== 'object') { 2083 logColibriPdpLoaderDebug('fetchOffering:done_no_marketing_product', { 2084 code, 2085 allowLoaderSku 2086 }); 2087 return null; 2088 } 2089 const product = applyProductPropertiesToMarketingProduct(marketingProduct, code, propsRow); 2090 const offering = getOfferingFromProduct(product, code); 2091 logColibriPdpLoaderDebug('fetchOffering:done', { 2092 code, 2093 hasOffering: !!offering, 2094 allowLoaderSku 2095 }); 2096 return { 2097 product, 2098 offering 2099 }; 2100 })(); 2101 fetchOfferingInflight.set(inflightKey, pending); 2102 pending.finally(() => { 2103 fetchOfferingInflight.delete(inflightKey); 2104 }); 2105 return pending; 2106} 2107(() => { 2108 'use strict'; 2109 2110 /** @see `ProductsService::DEFAULT_LIST_SORT` (PHP) and `Gutenberg/constants/productListSort.ts`. */ 2111 const DEFAULT_PRODUCT_LIST_SORT = 'deliveryMethodOrder,offerings.properties.packageSortOrder,offerings.isMandatory:desc,deliveryMethodSecondaryOrder,startTime,price,hasSchedule:desc,name,offerings.creditHours:desc'; 2112 const store = { 2113 requestedCodes: new Set(), 2114 requestedProductRequests: new Set(), 2115 pendingByCode: new Map(), 2116 pendingByMany: new Map(), 2117 pendingProductRequests: new Map(), 2118 cacheByComposite: new Map(), 2119 cacheByCode: new Map(), 2120 cacheProductRequests: new Map() 2121 }; 2122 const productPropsInflightRequests = new Map(); 2123 function productPropsInflightKey(url, codes) { 2124 return `${url}\x00${[...codes].sort().join('\x1f')}`; 2125 } 2126 2127 /** 2128 * Bumped on login/logout (or other session boundary) so list/detail cache keys 2129 * diverge and in-flight requests cannot repopulate stale auth-sensitive entries. 2130 */ 2131 let listRequestSessionEpoch = 0; 2132 function normalizeApiBaseUri(sourceEl) { 2133 if (sourceEl instanceof Element) { 2134 const ancestor = sourceEl.closest('.colibri-product-query-block, [data-api-base-uri], [data-colibri-pdp]'); 2135 const fromAncestor = String(ancestor?.dataset?.apiBaseUri || '').trim(); 2136 if (fromAncestor) { 2137 return fromAncestor.replace(/\/+$/, ''); 2138 } 2139 } 2140 return `${window.location.origin.replace(/\/+$/, '')}/wp-json/wp-marketing-api/v1`; 2141 } 2142 function isLocalBase(apiBaseUri) { 2143 return isWpJsonMarketingProxyBase(String(apiBaseUri || '')); 2144 } 2145 function readMarketingApiUpstreamBaseUri() { 2146 const colibri = window.colibri; 2147 const raw = String(colibri?.marketingApi?.baseUrl || colibri?.marketingApi?.settings?.baseUri || '').trim(); 2148 return raw.replace(/\/+$/, ''); 2149 } 2150 2151 /** 2152 * Upstream Marketing catalogue base (host or host + `/marketing` prefix) for filtered list calls. 2153 * Single-product-by-code uses {@see marketingProductDetailUrl}
2153 (`/marketing/products/{productCode}`). 2154 * Uses `data-api-base-uri` when it does not point at the WP JSON proxy; otherwise 2155 * `window.colibri.marketingApi` (baseUrl / settings.baseUri); last resort is {@see normalizeApiBaseUri}. 2156 */ 2157 function resolveUpstreamProductsBaseUri(sourceEl) { 2158 const fromDom = normalizeApiBaseUri(sourceEl).replace(/\/+$/, ''); 2159 if (fromDom && !isWpJsonMarketingProxyBase(fromDom)) { 2160 return fromDom; 2161 } 2162 const globalBase = readMarketingApiUpstreamBaseUri(); 2163 if (globalBase) { 2164 return globalBase; 2165 } 2166 return fromDom; 2167 } 2168 2169 /** 2170 * Marketing API path for fetching one product by code (browser â upstream Marketing only). 2171 * Strips a trailing `/marketing` from configured base once so callers are not doubled up. 2172 * 2173 * Path: `/marketing/products/{productCode}` (see plugin `samples/ProductsByProductCode.md`). 2174 * 2175 * @param upstreamBase Host or `{host}/marketing` from {@see resolveUpstreamProductsBaseUri} 2176 */ 2177 function normalizeMarketingApiRoot(upstreamBase) { 2178 return upstreamBase.replace(/\/+$/, '').replace(/\/marketing$/i, '').replace(/\/+$/, ''); 2179 } 2180 2181 /** 2182 * Upstream Marketing API list path: `/marketing/products/withfilters`. 2183 * Accepts bare host or `{host}/marketing` (see {@see marketingProductDetailUrl}). 2184 */ 2185 function marketingProductsListUrl(upstreamBase) { 2186 return `${normalizeMarketingApiRoot(upstreamBase)}/marketing/products/withfilters`; 2187 } 2188 2189 /** 2190 * Full GET URL for a product list request (upstream or WP JSON proxy). 2191 */ 2192 function resolveProductsListFetchUrl(base, queryString) { 2193 const trimmed = base.replace(/\/+$/, ''); 2194 if (isWpJsonMarketingProxyBase(trimmed)) { 2195 return `${trimmed}/products-with-filters?${queryString}`; 2196 } 2197 return `${marketingProductsListUrl(trimmed)}?${queryString}`; 2198 } 2199 2200 /** Page-scoped pool of itemIds shown by non-remaining ProductQuery / ComparisonGrid blocks. */ 2201 const displayedProductItemIdPool = new Set(); 2202 function contributeDisplayedProductItemIds(ids) { 2203 const added = []; 2204 if (!Array.isArray(ids)) { 2205 return added; 2206 } 2207 ids.forEach(raw => { 2208 const id = String(raw ?? '').trim(); 2209 if (!id || displayedProductItemIdPool.has(id)) { 2210 return; 2211 } 2212 displayedProductItemIdPool.add(id); 2213 added.push(id); 2214 }); 2215 return added; 2216 } 2217 function getDisplayedProductItemIds() { 2218 return Array.from(displayedProductItemIdPool); 2219 } 2220 function buildItemIdExclusionFilterSegment(ids) { 2221 const clean = Array.isArray(ids) ? ids.map(v => String(v ?? '').trim()).filter(Boolean) : []; 2222 const unique = Array.from(new Set(clean)); 2223 if (!unique.length) { 2224 return ''; 2225 } 2226 return buildFilterSegment('itemId', '!=', unique); 2227 } 2228 2229 /** 2230 * True for a catalog product row (not a withfilters list envelope). 2231 * Package products may also expose a nested `products` array of children. 2232 */ 2233 function isCatalogProductRow(value) { 2234 if (!value || typeof value !== 'object' || Array.isArray(value)) { 2235 return false; 2236 } 2237 const o = value; 2238 return 'offerings' in o || 'productCode' in o || 'itemId' in o || 'deliveryMethod' in o; 2239 } 2240 2241 /** 2242 * True for `{ products|items: [...] }` list envelopes (optionally wrapped in a 1-element array). 2243 */ 2244 function isWithFiltersEnvelope(value) { 2245 if (!value || typeof value !== 'object' || Array.isArray(value)) { 2246 return false; 2247 } 2248 if (isCatalogProductRow(value)) { 2249 return false; 2250 } 2251 const o = value; 2252 return Array.isArray(o.products) || Array.isArray(o.items); 2253 } 2254 function unwrapWithFiltersPayload(responseJson) { 2255 if (Array.isArray(responseJson) && responseJson.length >= 1) { 2256 const first = responseJson[0]; 2257 // Only unwrap 1-element envelopes. A bare product array must stay intact 2258 // (package rows often include a nested `products` key for children). 2259 if (isWithFiltersEnvelope(first)) { 2260 return first; 2261 } 2262 return null; 2263 } 2264 if (responseJson && typeof responseJson === 'object' && !Array.isArray(responseJson)) { 2265 const o = responseJson; 2266 if (isWithFiltersEnvelope(o) || Array.isArray(o.products) || Array.isArray(o.items)) { 2267 return o; 2268 } 2269 } 2270 return null; 2271 }
2272 function marketingProductDetailUrl(upstreamBase, productCode) { 2273 const code = encodeURIComponent(productCode); 2274 return appendMarketingPromotedDiscountCodesToUrl(`${normalizeMarketingApiRoot(upstreamBase)}/marketing/products/${code}`); 2275 } 2276 function buildFilterSegment(field, operator, values) { 2277 const clean = Array.isArray(values) ? values.map(v => String(v ?? '').trim()).filter(Boolean) : []; 2278 if (!clean.length) { 2279 return ''; 2280 } 2281 if (clean.length === 1) { 2282 return `[${field}${operator}${clean[0]}]`; 2283 } 2284 return `[${field}${operator}(${clean.join(',')})]`; 2285 } 2286 function attachParentSeminarName(product) { 2287 const loc = product.location; 2288 const locRecord = loc && typeof loc === 'object' && !Array.isArray(loc) ? loc : null; 2289 const seminarName = String(locRecord?.seminarName ?? locRecord?.seminar_name ?? product['location.seminarName'] ?? product.parentSeminarName ?? '').trim(); 2290 if (seminarName && !product.parentSeminarName) { 2291 product.parentSeminarName = seminarName; 2292 } 2293 return product; 2294 } 2295 function normalizeListResponsePayload(responseJson) { 2296 const withFilters = unwrapWithFiltersPayload(responseJson); 2297 if (withFilters) { 2298 if (Array.isArray(withFilters.products)) { 2299 return withFilters.products.map(attachParentSeminarName); 2300 } 2301 if (Array.isArray(withFilters.items)) { 2302 return withFilters.items.map(attachParentSeminarName); 2303 } 2304 } 2305 if (Array.isArray(responseJson)) { 2306 if (responseJson.length >= 1 && Array.isArray(responseJson[0])) { 2307 return responseJson[0].map(attachParentSeminarName); 2308 } 2309 // Bare product array (legacy /products, or already-unwrapped list). 2310 // Do not reject rows that happen to have a nested `products` key 2311 // (package/membership children) â that previously returned []. 2312 if (responseJson.length === 0 || isCatalogProductRow(responseJson[0])) { 2313 return responseJson.map(attachParentSeminarName); 2314 } 2315 } 2316 if (responseJson && typeof responseJson === 'object') { 2317 const o = responseJson; 2318 if (Array.isArray(o.products)) { 2319 return o.products.map(attachParentSeminarName); 2320 } 2321 if (Array.isArray(o.items)) { 2322 return o.items.map(attachParentSeminarName); 2323 } 2324 } 2325 return []; 2326 } 2327 function extractProductItemIds(responseJson) { 2328 const withFilters = unwrapWithFiltersPayload(responseJson); 2329 if (withFilters && Array.isArray(withFilters.productItemIds)) { 2330 return withFilters.productItemIds.map(v => String(v ?? '').trim()).filter(Boolean); 2331 } 2332 // Do not derive ids from products[] â remaining-items exclusion requires 2333 // the upstream productItemIds field on the withfilters payload. 2334 return []; 2335 } 2336 function readInlineListResponsePagination(responseJson) { 2337 if (Array.isArray(responseJson) && responseJson.length >= 2 && responseJson[1] && typeof responseJson[1] === 'object' && !Array.isArray(responseJson[1])) { 2338 const meta = responseJson[1]; 2339 // WP REST withfilters/list tuple: [payload, ResponseMeta]. 2340 if (meta.pagination && typeof meta.pagination === 'object' && !Array.isArray(meta.pagination)) { 2341 return meta.pagination; 2342 } 2343 return meta; 2344 } 2345 return null; 2346 } 2347 function normalizePagination(raw, page, pageSize) { 2348 const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; 2349 const ps = Number(data.pageSize ?? data.PageSize ?? pageSize ?? 24); 2350 const tc = Number(data.totalCount ?? data.TotalCount ?? data.total ?? data.Total ?? 0); 2351 let tp = Number(data.totalPages ?? data.TotalPages ?? 0); 2352 if (!tp && tc > 0 && ps > 0) { 2353 tp = Math.ceil(tc / ps); 2354 } 2355 tp = tp || 1; 2356 let cp = Number(data.currentPage ?? data.CurrentPage ?? 0); 2357 if (!cp) { 2358 const offset = Number(data.currentOffset ?? data.CurrentOffset ?? 0); 2359 if (offset > 0 && ps > 0) { 2360 cp = Math.floor(offset / ps) + 1; 2361 } else { 2362 cp = page || 1; 2363 } 2364 } 2365 return { 2366 currentPage: cp, 2367 totalPages: tp, 2368 totalCount: tc, 2369 pageSize: ps 2370 }; 2371 }
2372 function stableSerialize(value) { 2373 if (Array.isArray(value)) { 2374 return `[${value.map(stableSerialize).sort((a, b) => a.localeCompare(b)).join(',')}]`; 2375 } 2376 if (value && typeof value === 'object') { 2377 const obj = value; 2378 const keys = Object.keys(obj).sort(); 2379 return `{${keys.map(k => `${JSON.stringify(k)}:${stableSerialize(obj[k])}`).join(',')}}`; 2380 } 2381 return JSON.stringify(value); 2382 } 2383 function productsListComputedSlice(params) { 2384 const page = Math.max(1, Number(params.page || 1)); 2385 const pageSize = Math.max(1, Number(params.pageSize || 24)); 2386 const explicitOff = params.offset; 2387 const explicitLim = params.limit; 2388 const hasExplicitOffset = typeof explicitOff === 'number' && Number.isFinite(explicitOff) && explicitOff >= 0; 2389 const hasExplicitLimit = typeof explicitLim === 'number' && Number.isFinite(explicitLim) && explicitLim > 0; 2390 const offset = hasExplicitOffset ? Math.floor(explicitOff) : (page - 1) * pageSize; 2391 const limit = hasExplicitLimit ? Math.floor(explicitLim) : pageSize; 2392 return { 2393 page, 2394 pageSize, 2395 offset, 2396 limit 2397 }; 2398 } 2399 function buildProductsRequestKey(params) { 2400 const rawSort = String(params.sort || '').trim(); 2401 const { 2402 page, 2403 pageSize, 2404 offset, 2405 limit 2406 } = productsListComputedSlice(params); 2407 return stableSerialize({ 2408 listSessionEpoch: listRequestSessionEpoch, 2409 apiBaseUri: String(params.apiBaseUri || '').replace(/\/+$/, ''), 2410 page, 2411 pageSize, 2412 offset, 2413 limit, 2414 stateAbbvs: canonicalStateAbbvs(params.stateAbbvs), 2415 licenseTypeIds: canonicalLicenseTypeIds(params.licenseTypeIds), 2416 filter: canonicalFilterSegments(params.filter), 2417 urlFilterSegments: canonicalFilterSegments(params.urlFilterSegments), 2418 sort: rawSort || DEFAULT_PRODUCT_LIST_SORT, 2419 includeNotForIndividualSale: !!params.includeNotForIndividualSale 2420 }); 2421 } 2422 2423 /** 2424 * Call when cookies/session change so ProductQuery re-fetches are not served from 2425 * a cache built under a different user. 2426 */ 2427 function bumpListRequestSessionEpoch() { 2428 listRequestSessionEpoch += 1; 2429 store.cacheProductRequests.clear(); 2430 store.pendingProductRequests.clear(); 2431 store.cacheByCode.clear(); 2432 store.cacheByComposite.clear(); 2433 store.pendingByCode.clear(); 2434 store.pendingByMany.clear(); 2435 } 2436 function normalizeToken(value) { 2437 return String(value ?? '').trim().toLowerCase(); 2438 } 2439 function readProductField(product, camel, snake) { 2440 if (!product || typeof product !== 'object') { 2441 return ''; 2442 } 2443 return String(product[camel] ?? product[snake] ?? '').trim(); 2444 } 2445 function readOfferingField(offering, keys) { 2446 if (!offering || typeof offering !== 'object') { 2447 return ''; 2448 } 2449 for (const key of keys) { 2450 const value = offering[key]; 2451 if (value != null && String(value).trim() !== '') { 2452 return String(value).trim(); 2453 } 2454 } 2455 return ''; 2456 } 2457 function getProductCodes(product) { 2458 const codes = new Set(); 2459 const directCode = product && typeof product === 'object' ? String(product.productCode ?? '').trim() : ''; 2460 if (directCode) { 2461 codes.add(directCode); 2462 } 2463 const itemId = product && typeof product === 'object' ? String(product.itemId ?? product.item_id ?? '').trim() : ''; 2464 if (itemId) { 2465 codes.add(itemId); 2466 } 2467 const offerings = Array.isArray(product?.offerings) ? product.offerings : []; 2468 offerings.forEach(offering => { 2469 const code = readOfferingField(offering, ['productCode']); 2470 if (code) { 2471 codes.add(code); 2472 } 2473 }); 2474 return Array.from(codes); 2475 } 2476 function makeCompositeKey(itemId, deliveryMethod, licenseType) { 2477 return [normalizeToken(itemId), normalizeToken(deliveryMethod), normalizeToken(licenseType)].join('|'); 2478 } 2479 2480 /** 2481 * Broadcast SKU catalogue hydration so Core CTAs (SSR-disabled until Marketing payload exists) 2482 * can re-run {@see finalizeCoreCartButtonInteractiveState} in core-add-to-cart-view.js. 2483 * 2484 * Fired whenever {@link indexProduct} runs â covers single-code proxy/detail routes, 2485 * batched `/products` / `products-list`, and paginated list fetches. 2486 */ 2487 function dispatchMarketingCatalogIndexed(codes) { 2488 const unique = Array.from(new Set(codes.map(c =>
2488 String(c || '').trim()).filter(Boolean))); 2489 if (!unique.length) { 2490 return; 2491 } 2492 window.dispatchEvent(new CustomEvent('colibri:marketing-catalog-indexed', { 2493 detail: { 2494 productCodes: unique 2495 } 2496 })); 2497 } 2498 function putCacheByComposite(compositeKey, stateCode, product) { 2499 if (!compositeKey || compositeKey === '||') { 2500 return; 2501 } 2502 if (!store.cacheByComposite.has(compositeKey)) { 2503 store.cacheByComposite.set(compositeKey, new Map()); 2504 } 2505 const byState = store.cacheByComposite.get(compositeKey); 2506 byState.set(normalizeToken(stateCode), product); 2507 } 2508 function indexProduct(product) { 2509 if (!product || typeof product !== 'object') { 2510 return; 2511 } 2512 const rec = product; 2513 getProductCodes(rec).forEach(code => store.cacheByCode.set(code, rec)); 2514 const itemId = readProductField(rec, 'itemId', 'item_id'); 2515 const deliveryMethod = readProductField(rec, 'deliveryMethod', 'delivery_method'); 2516 const offerings = Array.isArray(rec.offerings) ? rec.offerings : []; 2517 if (!offerings.length) { 2518 const licenseType = readProductField(rec, 'licenseType', 'license_type'); 2519 putCacheByComposite(makeCompositeKey(itemId, deliveryMethod, licenseType), '', rec); 2520 dispatchMarketingCatalogIndexed(getProductCodes(rec)); 2521 return; 2522 } 2523 offerings.forEach(offering => { 2524 const licenseType = readOfferingField(offering, ['licenseType', 'license_type', 'licenseTypeId', 'license_type_id']); 2525 const stateCode = readOfferingField(offering, ['stateAbbv', 'state_abbv', 'stateCode', 'state_code', 'state']); 2526 putCacheByComposite(makeCompositeKey(itemId, deliveryMethod, licenseType), stateCode, rec); 2527 }); 2528 dispatchMarketingCatalogIndexed(getProductCodes(rec)); 2529 } 2530 function getCachedByComposite(itemId, deliveryMethod, licenseType, stateCode = '') { 2531 const key = makeCompositeKey(itemId, deliveryMethod, licenseType); 2532 const byState = store.cacheByComposite.get(key); 2533 if (!byState) { 2534 return null; 2535 } 2536 const stateKey = normalizeToken(stateCode); 2537 return byState.get(stateKey) || byState.get('') || null; 2538 } 2539 function productHasCode(product, productCode) { 2540 if (!product || !productCode) { 2541 return false; 2542 } 2543 const targetCode = String(productCode).trim(); 2544 return getProductCodes(product).some(code => code === targetCode); 2545 } 2546 function pickProductFromResponse(response, productCode) { 2547 const target = String(productCode || '').trim(); 2548 if (response && typeof response === 'object' && !Array.isArray(response)) { 2549 const r = response; 2550 if (!r.products && !r.items && !r.product) { 2551 return productHasCode(r, target) ? r : null; 2552 } 2553 } 2554 const collections = []; 2555 if (Array.isArray(response)) { 2556 collections.push(response); 2557 } 2558 if (response && typeof response === 'object' && !Array.isArray(response)) { 2559 const r = response; 2560 if (Array.isArray(r.products)) { 2561 collections.push(r.products); 2562 } 2563 if (Array.isArray(r.items)) { 2564 collections.push(r.items); 2565 } 2566 } 2567 for (const list of collections) { 2568 const exact = list.find(product => { 2569 if (!product || typeof product !== 'object') { 2570 return false; 2571 } 2572 return productHasCode(product, target); 2573 }); 2574 if (exact && typeof exact === 'object') { 2575 return exact; 2576 } 2577 } 2578 if (response && typeof response === 'object' && !Array.isArray(response)) { 2579 const r = response; 2580 if (r.product && typeof r.product === 'object') { 2581 const p = r.product; 2582 return productHasCode(p, target) ? p : null; 2583 } 2584 } 2585 const fallbackCandidates = []; 2586 if (response && typeof response === 'object' && !Array.isArray(response)) { 2587 const r = response; 2588 if (r.product && typeof r.product === 'object') { 2589 fallbackCandidates.push(r.product); 2590 } 2591 } 2592 collections.forEach(list => { 2593 if (Array.isArray(list)) { 2594 list.forEach(product => { 2595 if (product && typeof product === 'object') { 2596 fallbackCandidates.push(product); 2597 } 2598 }); 2599 } 2600 }); 2601 if (response && typeof response === 'object' && !Array.isArray(response)) { 2602 const r = response; 2603 if (!r.products && !r.items && !r.product) { 2604 fallbackCandidates.push(r); 2605 } 2606 } 2607 const normalized = fallbackCandidates.filter(product => product && typeof product === 'object'); 2608 return normalized.length === 1 ? normalized[0] : null; 2609 } 2610 function getCachedProductByCode(productCode) { 2611 const code = String(productCode ?? '').trim(); 2612 if (!code) { 2613 return null; 2614 } 2615 return store.cacheByCode.get(code) ?? null; 2616 } 2617 async function fetchByCode(productCode, options = {}) { 2618 const code = String(productCode || '').trim(); 2619 if (!code) { 2620 return null; 2621 } 2622 const cached = store.cacheByCode.get(code); 2623 if (cached) { 2624 return cached; 2625 } 2626 const existingPending = store.pendingByCode.get(code); 2627 if (existingPending) { 2628 return existingPending; 2629 } 2630 const useWpJsonProductDetailProxy = shouldUseMarketingRestProxy();
2631 const base = useWpJsonProductDetailProxy ? wpJsonMarketingV1Base() : resolveUpstreamProductsBaseUri(options.sourceEl).replace(/\/+$/, ''); 2632 const url = useWpJsonProductDetailProxy ? appendMarketingPromotedDiscountCodesToUrl(`${base}/products-get-by-code?product_code=${encodeURIComponent(code)}`) : marketingProductDetailUrl(base, code); 2633 store.requestedCodes.add(code); 2634 const pending = (async () => { 2635 try { 2636 const response = await fetch(url, mergeMarketingAuthHeadersForFetchUrl(url, { 2637 method: 'GET' 2638 })); 2639 if (!response.ok) { 2640 return null; 2641 } 2642 const json = await response.json(); 2643 const product = pickProductFromResponse(json, code); 2644 if (product) { 2645 indexProduct(product); 2646 } 2647 return product; 2648 } catch (_err) { 2649 return null; 2650 } finally { 2651 store.pendingByCode.delete(code); 2652 } 2653 })(); 2654 store.pendingByCode.set(code, pending); 2655 return pending; 2656 } 2657 async function fetchManyByCodes(productCodes, options = {}) { 2658 const codes = Array.from(new Set((Array.isArray(productCodes) ? productCodes : []).map(c => String(c || '').trim()).filter(Boolean))); 2659 if (!codes.length) { 2660 return []; 2661 } 2662 const cachedProducts = []; 2663 const missingCodes = []; 2664 codes.forEach(code => { 2665 const cached = store.cacheByCode.get(code); 2666 if (cached) { 2667 cachedProducts.push(cached); 2668 } else { 2669 missingCodes.push(code); 2670 } 2671 }); 2672 if (!missingCodes.length) { 2673 return Array.from(new Set(cachedProducts)); 2674 } 2675 if (missingCodes.length === 1) { 2676 const single = await fetchByCode(missingCodes[0], options); 2677 return Array.from(new Set(single ? [...cachedProducts, single] : cac
2677hedProducts)); 2678 } 2679 const useWpJsonProductDetailProxy = shouldUseMarketingRestProxy(); 2680 const base = useWpJsonProductDetailProxy ? wpJsonMarketingV1Base() : resolveUpstreamProductsBaseUri(options.sourceEl).replace(/\/+$/, ''); 2681 const pendingKey = `${base}|${missingCodes.slice().sort().join(',')}`; 2682 const existingMany = store.pendingByMany.get(pendingKey); 2683 if (existingMany) { 2684 const pendingProducts = await existingMany; 2685 return Array.from(new Set([...cachedProducts, ...pendingProducts])); 2686 } 2687 missingCodes.forEach(code => store.requestedCodes.add(code)); 2688 const pending = (async () => { 2689 const params = new URLSearchParams(); 2690 const filterSegment = buildFilterSegment('offerings.productCode', '==', missingCodes); 2691 if (filterSegment) { 2692 params.append('filter', filterSegment); 2693 } 2694 params.append('limit', String(Math.max(missingCodes.length, 24))); 2695 params.append('sort', DEFAULT_PRODUCT_LIST_SORT); 2696 applyMarketingPromotedDiscountCodesDefault(params); 2697 const url = resolveProductsListFetchUrl(base, params.toString()); 2698 try { 2699 const response = await fetch(url, mergeMarketingAuthHeadersForFetchUrl(url, { 2700 method: 'GET' 2701 })); 2702 if (!response.ok) { 2703 return []; 2704 } 2705 const json = await response.json(); 2706 const products = normalizeListResponsePayload(json); 2707 products.forEach(indexProduct); 2708 return products; 2709 } catch (_err) { 2710 return []; 2711 } finally { 2712 store.pendingByMany.delete(pendingKey); 2713 } 2714 })(); 2715 store.pendingByMany.set(pendingKey, pending); 2716 const fetchedProducts = await pending; 2717 return Array.from(new Set([...cachedProducts, ...fetchedProducts])); 2718 } 2719 async function resolveProducts(productsOrCodes, options = {}) { 2720 const input = Array.isArray(productsOrCodes) ? productsOrCodes : [productsOrCodes]; 2721 const codePositions = []; 2722 const objects = []; 2723 const codes = []; 2724 input.forEach((entry, index) => { 2725 if (entry && typeof entry === 'object' && !Array.isArray(entry)) { 2726 const value = entry; 2727 objects.push({ 2728 index, 2729 value 2730 }); 2731 indexProduct(value); 2732 return; 2733 } 2734 const code = String(entry || '').trim(); 2735 if (!code) { 2736 return; 2737 } 2738 codePositions.push({ 2739 index, 2740 code 2741 }); 2742 codes.push(code); 2743 }); 2744 const resolvedByIndex = new Map(); 2745 objects.forEach(({ 2746 index, 2747 value 2748 }) => resolvedByIndex.set(index, value)); 2749 if (codes.length === 1) { 2750 const product = await fetchByCode(codes[0], options); 2751 if (product) { 2752 resolvedByIndex.set(codePositions[0].index, product); 2753 } 2754 } else if (codes.length > 1) { 2755 await fetchManyByCodes(codes, options); 2756 codePositions.forEach(({ 2757 index, 2758 code 2759 }) => { 2760 const product = store.cacheByCode.get(code); 2761 if (product) { 2762 resolvedByIndex.set(index, product); 2763 } 2764 }); 2765 } 2766 return input.map((_, index) => resolvedByIndex.get(index) || null).filter(p => Boolean(p)); 2767 } 2768 async function fetchProductsPage(params = {}, options = {}) { 2769 const apiBaseUri = String(params.apiBaseUri || '').replace(/\/+$/, ''); 2770 const { 2771 page, 2772 pageSize, 2773 offset, 2774 limit 2775 } = productsListComputedSlice(params); 2776 const filters = Array.isArray(params.filter) ? params.filter : []; 2777 const urlFilters = Array.isArray(params.urlFilterSegments) ? params.urlFilterSegments : []; 2778 const uniqueFilters = []; 2779 [...filters, ...urlFilters].forEach(segment => { 2780 const clean = String(segment || '').trim(); 2781 if (clean && uniqueFilters.indexOf(clean) === -1) { 2782 uniqueFilters.push(clean); 2783 } 2784 }); 2785 if (options?.excludeDisplayedItemIds) { 2786 const poolIds = getDisplayedProductItemIds(); 2787 const excludeSeg = buildItemIdExclusionFilterSegment(poolIds); 2788 if (excludeSeg && uniqueFilters.indexOf(excludeSeg) === -1) { 2789 uniqueFilters.push(excludeSeg); 2790 } 2791 }
2792 const key = buildProductsRequestKey({ 2793 ...params, 2794 apiBaseUri, 2795 page, 2796 pageSize, 2797 offset, 2798 limit, 2799 filter: uniqueFilters 2800 }); 2801 const cached = store.cacheProductRequests.get(key); 2802 if (cached) { 2803 return { 2804 products: normalizeListResponsePayload(cached.products).slice(), 2805 pagination: cached.pagination || normalizePagination({}, page, limit), 2806 productItemIds: Array.isArray(cached.productItemIds) ? cached.productItemIds.slice() : [] 2807 }; 2808 } 2809 const existingPage = store.pendingProductRequests.get(key); 2810 if (!options?.signal && existingPage) { 2811 return existingPage; 2812 } 2813 const requestPromise = (async () => { 2814 const search = new URLSearchParams(); 2815 const stateAbbvs = Array.isArray(params.stateAbbvs) ? params.stateAbbvs.filter(Boolean) : []; 2816 const licenseTypeIds = Array.isArray(params.licenseTypeIds) ? params.licenseTypeIds.filter(Boolean) : []; 2817 if (stateAbbvs.length) { 2818 search.append('stateAbbvs', stateAbbvs.join(',')); 2819 } 2820 if (licenseTypeIds.length) { 2821 search.append('licenseTypeIds', licenseTypeIds.join(',')); 2822 } 2823 uniqueFilters.forEach(segment => search.append('filter', segment)); 2824 const sort = String(params.sort || '').trim() || DEFAULT_PRODUCT_LIST_SORT; 2825 search.append('sort', sort); 2826 search.append('offset', String(offset)); 2827 search.append('limit', String(limit)); 2828 if (params.includeNotForIndividualSale) { 2829 search.append('includeNotForIndividualSale', 'true'); 2830 } 2831 applyMarketingPromotedDiscountCodesDefault(search); 2832 const fetchOpts = {}; 2833 if (options.signal) { 2834 fetchOpts.signal = options.signal; 2835 } 2836 const url = resolveProductsListFetchUrl(apiBaseUri, search.toString()); 2837 store.requestedProductRequests.add(key); 2838 const resp = await fetch(url, mergeMarketingAuthHeadersForFetchUrl(url, fetchOpts)); 2839 if (!resp.ok) { 2840 throw new Error(`HTTP ${resp.status}`); 2841 } 2842 const productsJson = await resp.json(); 2843 const paginationHeader = resp.headers.get('X-Pagination'); 2844 const inlinePagination = readInlineListResponsePagination(productsJson); 2845 const rawPagination = paginationHeader ? JSON.parse(paginationHeader) : inlinePagination || {}; 2846 const pagination = normalizePagination(rawPagination, page, limit); 2847 let products = normalizeListResponsePayload(productsJson); 2848 const productItemIds = extractProductItemIds(productsJson); 2849 if (options?.contributeDisplayedItemIds) { 2850 contributeDisplayedProductItemIds(productItemIds); 2851 } 2852 // Prefer productItemIds length when present (withfilters). X-Pagination 2853 // totalCount can disagree (often +1) under itemId!= exclusion. 2854 if (productItemIds.length > 0) { 2855 pagination.totalCount = productItemIds.length; 2856 pagination.totalPages = Math.max(1, Math.ceil(productItemIds.length / Math.max(1, limit))); 2857 } 2858 const orderIds = resolveIncludedItemsOrderFromQueryParts({ 2859 includedItems: params.includedItems, 2860 filter: uniqueFilters 2861 }); 2862 products = orderUnknownRecordsByIncludedItemIds(products, orderIds); 2863 if (!orderIds.length) { 2864 products = orderUnknownRecordsByFirstOfferingPackageSortOrder(products); 2865 } 2866 products.forEach(p => { 2867 if (p && typeof p === 'object') { 2868 indexProduct(p); 2869 } 2870 }); 2871 const payload = { 2872 products, 2873 pagination, 2874 productItemIds 2875 }; 2876 store.cacheProductRequests.set(key, payload); 2877 return { 2878 products: payload.products.slice(), 2879 pagination: payload.pagination, 2880 productItemIds: payload.productItemIds ? payload.productItemIds.slice() : [] 2881 }; 2882 })(); 2883 if (!options?.signal) { 2884 store.pendingProductRequests.set(key, requestPromise); 2885 } 2886 try { 2887 return await requestPromise; 2888 } finally { 2889 if (!options?.signal) { 2890 store.pendingProductRequests.delete(key); 2891 } 2892 } 2893 } 2894 function readBrowserCookie(name) { 2895 const needle = `${name}=`; 2896 const parts = document.cookie.split(';'); 2897 for (let i = 0; i < parts.length; i++) { 2898 const c = parts[i].replace(/^\s+/, ''); 2899 if (c.indexOf(needle) === 0) { 2900 return c.substring(needle.length); 2901 } 2902 } 2903 return null; 2904 } 2905 function colibriProductPropsRequestContext() { 2906 const colibri = window.colibri; 2907 const med = colibri?.ctaMediator || null; 2908 const api = String(med && med.studentApiUrl || med && med.studentApi || colibri?.studentApi || '').replace(/\/+$/, '').trim(); 2909 const sub = String(med && med.subsidiaryId || colibri?.subsidiaryId || '').trim(); 2910 return { 2911 api, 2912 sub 2913 }; 2914 } 2915 function colibriProductPropsPostUrl(api, sub) { 2916 const sid = readBrowserCookie('STYXKEY-McKissock-UserId'); 2917 const cid = readBrowserCookie('STYXKEY-shoppingcartid'); 2918 const baseUrl = shouldUseMarketingRestProxy() ? '/wp-json/wp-marketing-api/v1/product-properties' : `${api}
2918/productSearch/ProductProperties`; 2919 let u = `${baseUrl}?subsidiaryId=${encodeURIComponent(sub)}`; 2920 if (sid) { 2921 u += `&studentId=${encodeURIComponent(sid)}`; 2922 } 2923 if (cid) { 2924 u += `&shoppingCartID=${encodeURIComponent(cid)}`; 2925 } 2926 return u; 2927 } 2928 function mergePropsRowsIntoLoaderCache(rows) { 2929 if (!Array.isArray(rows) || !rows.length) { 2930 return; 2931 } 2932 for (const row of rows) { 2933 if (!row || typeof row !== 'object') { 2934 continue; 2935 } 2936 const code = String(row.ProductCode ?? '').trim(); 2937 if (!code) { 2938 continue; 2939 } 2940 const cached = store.cacheByCode.get(code); 2941 if (cached && typeof cached === 'object') { 2942 store.cacheByCode.set(code, applyProductPropertiesToMarketingProduct(cached, code, row)); 2943 } 2944 } 2945 } 2946 function writeColibriProductPropsPrefetchBucket(data, codes) { 2947 const w = window; 2948 const arr = Array.isArray(data) && data.length ? data : []; 2949 w.__colibriProductProps = { 2950 response: arr, 2951 codes: [...codes] 2952 }; 2953 mergePropsRowsIntoLoaderCache(arr); 2954 } 2955 function installColibriFetchProductProperties() { 2956 const w = window; 2957 w.colibri = w.colibri || {}; 2958 w.colibri.fetchProductProperties = function (products) { 2959 const ctx = colibriProductPropsRequestContext(); 2960 if (!ctx.api || !ctx.sub) { 2961 return Promise.reject(new Error('fetchProductProperties: missing studentApi or subsidiaryId')); 2962 } 2963 const src = Array.isArray(products) ? products : [products]; 2964 const codes = []; 2965 const seen = {}; 2966 for (let i = 0; i < src.length; i++) { 2967 const v = String(src[i] == null ? '' : src[i]).trim(); 2968 if (v && !seen[v]) { 2969 seen[v] = true; 2970 codes.push(v); 2971 } 2972 } 2973 if (!codes.length) { 2974 return Promise.reject(new Error('fetchProductProperties: empty product codes')); 2975 } 2976 const url = colibriProductPropsPostUrl(ctx.api, ctx.sub); 2977 const dedupeKey = productPropsInflightKey(url, codes); 2978 const inflight = productPropsInflightRequests.get(dedupeKey); 2979 if (inflight) { 2980 return inflight; 2981 } 2982 const pending = fetch(url, { 2983 method: 'POST', 2984 headers: { 2985 'Content-Type': 'application/json' 2986 }, 2987 body: JSON.stringify({ 2988 products: codes 2989 }), 2990 priority: 'low' 2991 }).then(r => r.ok ? r.json() : Promise.reject(r.status)).then(data => { 2992 const arr = normalizeProductPropertiesApiRows(data); 2993 mergeColibriProductPropsFromApiResponse(arr); 2994 mergePropsRowsIntoLoaderCache(arr); 2995 ensureProductPropsBucketTracksCodes(codes); 2996 const w = window; 2997 logColibriProductPropsDebugEvent('fetchProductProperties:merged', { 2998 requestedCodes: codes, 2999 bucketCodesSample: Array.isArray(w.__colibriProductProps?.codes) ? w.__colibriProductProps?.codes?.slice(0, 16) : null, 3000 bucketCodesCount: Array.isArray(w.__colibriProductProps?.codes) ? w.__colibriProductProps.codes.length : 0 3001 }); 3002 window.queueMicrotask(() => { 3003 window.dispatchEvent(new CustomEvent('colibri:product-props-ready')); 3004 }); 3005 return arr; 3006 }).finally(() => { 3007 productPropsInflightRequests.delete(dedupeKey); 3008 }); 3009 productPropsInflightRequests.set(dedupeKey, pending); 3010 return pending; 3011 }; 3012 } 3013 function collectLegacyAddToCartProductCodes() { 3014 return collectEligibleStudentPrefetchCartProductCodes(effectiveCorePdpButtonProductCodeForPrefetch); 3015 } 3016 function prefetchLegacyAddToCartProductProperties() { 3017 const w = window; 3018 if (w.__colibriProductProps) { 3019 return; 3020 } 3021 const ctx = colibriProductPropsRequestContext(); 3022 if (!ctx.api || !ctx.sub) { 3023 return; 3024 } 3025 const codes = collectLegacyAddToCartProductCodes(); 3026 if (!codes.length) { 3027 return; 3028 } 3029 w.__colibriProductPropsLoading = true; 3030 window.setTimeout(() => { 3031 const fetchFn = window.colibri?.fetchProductProperties; 3032 if (typeof fetchFn !== 'function') { 3033 w.__colibriProductPropsLoading = false;
3034 writeColibriProductPropsPrefetchBucket(null, codes); 3035 window.dispatchEvent(new CustomEvent('colibri:product-props-ready')); 3036 return; 3037 } 3038 void fetchFn(codes).catch(() => { 3039 writeColibriProductPropsPrefetchBucket(null, codes); 3040 }).finally(() => { 3041 w.__colibriProductPropsLoading = false; 3042 window.dispatchEvent(new CustomEvent('colibri:product-props-ready')); 3043 }); 3044 }, 0); 3045 } 3046 function schedulePrefetchLegacyAddToCartProductProperties() { 3047 const run = () => { 3048 prefetchLegacyAddToCartProductProperties(); 3049 }; 3050 if (document.readyState === 'loading') { 3051 document.addEventListener('DOMContentLoaded', run, { 3052 once: true 3053 }); 3054 } else { 3055 window.setTimeout(run, 0); 3056 } 3057 } 3058 async function fetchOfferingsForCodes(productCodes, options = {}) { 3059 const codes = [...new Set(productCodes.map(c => String(c || '').trim()).filter(Boolean))]; 3060 const out = {}; 3061 if (!codes.length) { 3062 return out; 3063 } 3064 await fetchManyByCodes(codes, options); 3065 const prefetchGateSets = buildCartMarketingStudentPrefetchGateSets(effectiveCorePdpButtonProductCodeForPrefetch); 3066 const propsCodes = codes.filter(c => shouldStudentPrefetchProductPropertiesForProductCode(c, prefetchGateSets)); 3067 const colibri = window.colibri; 3068 const fetchFn = colibri?.fetchProductProperties; 3069 let rows = []; 3070 if (typeof fetchFn === 'function' && propsCodes.length) { 3071 try { 3072 const data = await fetchFn(propsCodes); 3073 rows = Array.isArray(data) ? data : []; 3074 } catch { 3075 rows = []; 3076 } 3077 } 3078 const rowByCode = new Map(); 3079 for (const row of rows) { 3080 const pc = String(row?.ProductCode ?? '').trim(); 3081 if (pc) { 3082 rowByCode.set(pc, row); 3083 } 3084 } 3085 for (const code of codes) { 3086 const marketing = store.cacheByCode.get(code) ?? null; 3087 if (!marketing) { 3088 out[code] = null; 3089 continue; 3090 } 3091 const propsRow = rowByCode.get(code) ?? null; 3092 const product = applyProductPropertiesToMarketingProduct(marketing, code, propsRow); 3093 const offering = getOfferingFromProduct(product, code); 3094 store.cacheByCode.set(code, product); 3095 out[code] = { 3096 product, 3097 offering 3098 }; 3099 } 3100 return out; 3101 } 3102 installColibriFetchProductProperties(); 3103 window.colibriProductLoader = window.colibriProductLoader || {}; 3104 window.colibriProductLoader.normalizeApiBaseUri = normalizeApiBaseUri; 3105 window.colibriProductLoader.isLocalBase = isLocalBase; 3106 window.colibriProductLoader.buildFilterSegment = buildFilterSegment; 3107 window.colibriProductLoader.normalizeListResponsePayload = normalizeListResponsePayload; 3108 window.colibriProductLoader.productHasCode = productHasCode; 3109 window.colibriProductLoader.pickProductFromResponse = pickProductFromResponse; 3110 window.colibriProductLoader.fetchByCode = fetchByCode; 3111 window.colibriProductLoader.fetchManyByCodes = fetchManyByCodes; 3112 window.colibriProductLoader.resolveProducts = resolveProducts; 3113 window.colibriProductLoader.fetchProductsPage = fetchProductsPage; 3114 window.colibriProductLoader.contributeDisplayedProductItemIds = contributeDisplayedProductItemIds; 3115 window.colibriProductLoader.getDisplayedProductItemIds = getDisplayedProductItemIds; 3116 window.colibriProductLoader.buildItemIdExclusionFilterSegment = buildItemIdExclusionFilterSegment; 3117 window.colibriProductLoader.getCachedProductByCode = getCachedProductByCode; 3118 window.colibriProductLoader.normalizePagination = normalizePagination; 3119 window.colibriProductLoader.buildProductsRequestKey = buildProductsRequestKey; 3120 window.colibriProductLoader.bumpListRequestSessionEpoch = bumpListRequestSessionEpoch; 3121 window.colibriProductLoader.getCachedByComposite = getCachedByComposite; 3122 window.colibriProductLoader.getRequestedCodes = () => Array.from(store.requestedCodes); 3123 window.colibriProductLoader.getRequestedProductRequests = () => Array.from(store.requestedProductRequests); 3124 window.colibriProductLoader._store = store; 3125 window.colibriProductLoader.getOfferingFromProduct = getOfferingFromProduct; 3126 window.colibriProductLoader.fetchProductPropertiesRow = fetchProductPropertiesRow; 3127 window.colibriProductLoader.applyProductPropertiesToMarketingProduct = applyProductPropertiesToMarketingProduct; 3128 window.colibriProductLoader.fetchOffering = fetchOffering; 3129 window.colibriProductLoader.fetchOfferingsForCodes = fetchOfferingsForCodes; 3130 window.colibriProductLoader.fetchPromotedDiscountCodes = fetchPromotedDiscountCodes; 3131 window.colibri = window.colibri || {}; 3132 window.colibri.fetchPromotedDiscountCodes = fetchPromotedDiscountCodes; 3133 window.colibriProductLoader.prefetchLegacyAddToCartProductProperties = prefetchLegacyAddToCartProductProperties; 3134 schedulePrefetchLegacyAddToCartProductProperties(); 3135})(); 3136/******/ })() 3137;
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.