PageSourceSearch

https://www.bamboohr.com/scripts/scripts.js

js bamboohr.com collected 2026-10-01 07:04:51 UTC 157,910 bytes, 4,369 lines download raw bytes

1/*
2 * Copyright 2021 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import { isAdvertisingCookieAllowed, isFunctionalCookieAllowed } from './consent.js';
14
15// eslint-disable-next-line import/no-cycle
16import {
17  analyticsTrackCWV,
18  analyticsTrackFormSubmission,
19  analyticsTrackLinkClicks,
20  getAlloyConfiguration,
21  analyticsTrackScrollDepth, 
22  analyticsTrackPageViews,
23} from './lib-analytics.js';
24import AnalyticsQueue from "./_classes/analytics-queue.js";
25
26import { setupScrollTracking } from './scroll-tracking.js';
27import { testIdManager, TestIdManager } from './data-testid.js';
28
29/**
30 * Author-driven animations: hold and reveal (ZZ-6821).
31 *
32 * Declared up here rather than beside loadEager: `loadPage(document)` runs at
33 * module top level further down this file and reaches holdAnimated()
34 * synchronously through loadEager, so a const/let declared below that call sits
35 * in the temporal dead zone and throws on every page load.
36 *
37 * The animation runtime (ZZ-6500) starts in the lazy phase, so an element
38 * carrying an animation class paints fully visible and only snaps to its
39 * `gsap.from()` start state once the sheet, GSAP, and the compiled snippet are
40 * all in. Above the fold that reads as a flicker. `animations-pending` holds
41 * those elements at `opacity: 0` from before first paint (see the rule in
42 * `styles/styles.css`) until the runtime settles.
43 *
44 * Content MUST fail open. Two independent guarantees:
45 *   - `revealAnimated` runs from a `.finally()` on the runtime, so it fires on
46 *     every early return and on every rejection.
47 *   - the timer below covers the one case a promise chain cannot -- a fetch or
48 *     import that hangs without ever settling.
49 * Reduced motion is handled in the stylesheet, so it holds at paint time
50 * whether or not any of this runs.
51 *
52 * The timeout matches the Adobe Target prehide in initPrehiding() below, so the
53 * repo has one prehide ceiling rather than two. Measured on /uk/zz-6821, the
54 * runtime settles at ~880ms on a fast connection, so the timer is a backstop
55 * rather than the normal path.
56 *
57 * When the timer does win -- a slow connection, where the runtime can take many
58 * seconds -- revealing alone is not enough. The runtime would still arrive later
59 * and pull an element the visitor is already reading back to hidden so it could
60 * animate in, which is the very flicker this ticket exists to remove, just
61 * delayed. So giving up on the hold also means giving up on animating anything
62 * that has been seen. Elements still below the fold are untouched and animate on
63 * scroll as usual.
64 */
65const ANIMATION_HOLD_TIMEOUT_MS = 3000;
66let animationHoldTimer = null;
67let animationHoldExpired = false;
68
69function holdAnimated() {
70  document.documentElement.classList.add('animations-pending');
71  animationHoldTimer = setTimeout(() => {
72    animationHoldExpired = true;
73    // eslint-disable-next-line no-use-before-define
74    revealAnimated();
75  }, ANIMATION_HOLD_TIMEOUT_MS);
76}
77
78function revealAnimated() {
79  clearTimeout(animationHoldTimer);
80  document.documentElement.classList.remove('animations-pending');
81}
82
83/**
84 * False once the hold has been abandoned and this element is one the visitor can
85 * already see -- anywhere at or above the bottom of the viewport, which covers
86 * both what is on screen now and what has been scrolled past.
87 */
88function shouldAnimateElement(el) {
89  if (!animationHoldExpired) return true;
90  return el.getBoundingClientRect().top >= window.innerHeight;
91}
92
93function setBooleanAudienceCookie(cookieName){
94  // Consent (Advertising): managed by DataGrail
95  if(!document.cookie.includes(cookieName)){
96    document.cookie = `${cookieName}=1;path=/;`;
97  }
98}
99
100/**
101 * Retrieves the content of a metadata tag.
102 * @param {string} name The metadata name (or property)
103 * @returns {string} The metadata value
104 */
105export function getMetadata(name) {
106  const attr = name && name.includes(':') ? 'property' : 'name';
107  const meta = document.head.querySelector(`meta[${attr}="${name}"]`);
108  return meta && meta.content;
109}
110
111const AUDIENCES = {
112  'is-customer-ep-ben-admin': () => {
113	if(new URLSearchParams(window.location.search).get('epid') === "1"){
114	  // Consent (Functional): managed by DataGrail
115	  sessionStorage.setItem('bhr_audience_source','UTM');
116	  return true;
117	}
118	return false;
119  },
120  'is-customer-ep-payroll': () => {
121	if(new URLSearchParams(window.location.search).get('epid') === "2"){
122	  // Consent (Functional): managed by DataGrail
123	  sessionStorage.setItem('bhr_audience_source','UTM');
124	  return true;
125	}
126	return false;
127  },
128  'is-customer-ep-performance-mgmt': () => {
129	if(new URLSearchParams(window.location.search).get('epid') === "3"){
130	  // Consent (Functional): managed by DataGrail
131	  sessionStorage.setItem('bhr_audience_source','UTM');
132	  return true;
133	}
134	return false;
135  },
136  'is-customer-ep-time-tracking': () => {
137	if(new URLSearchParams(window.location.search).get('epid') === "4"){
138	  // Consent (Functional): managed by DataGrail
139	  sessionStorage.setItem('bhr_audience_source','UTM');
140	  return true;
141	}
142	return false;
143  },
144  /* customer ADMIN */
145  'is-customer': () => {
146    // eslint-disable-next-line no-use-before-define
147    const features = getBhrFeaturesCookie();
148    if (!features) { return false; }
149    if(features.is_admin && !features.bhr_user && features.subscriber) {
150      // Consent (Functional): managed by DataGrail
151      sessionStorage.setItem('bhr_audience_source','Cookie');
152      setBooleanAudienceCookie('bhr_audience_customer_admin');
153      return true;
154    }
155    return false;
156  },
157  'is-customer-non-admin': () => {
158	// eslint-disable-next-line no-use-before-define
159	const features = getBhrFeaturesCookie();
160	if (!features) { return false; }
161	if(!features.is_admin && features.bhr_user) {
162	  // Consent (Functional): managed by DataGrail
163	  sessionStorage.setItem('bhr_audience_source','Cookie');
164      setBooleanAudienceCookie('bhr_audience_customer_non_admin');
165	  return true;
166	}
167	return false;
168  },
169  'not-customer': () => {
170	// eslint-disable-next-line no-use-before-define
171    if (!getBhrFeaturesCookie()) {
172	  // Consent (Functional): managed by DataGrail
173	  sessionStorage.setItem('bhr_audience_source','Cookie');
174      setBooleanAudienceCookie('bhr_audience_non_customer');
175	  return true;
176	}
177    return false;
178  },
179  /*
180   * visitor currently on a free trial, signed in on the platform (ZZ-6331).
181   *
182   * Deliberately side-effect free: unlike the audiences above, this one sets no
183   * cookie and no `bhr_audience_source`, so it can be evaluated on every page
184   * view for reporting without claiming to be the audience that drove
185   * personalization. It is also kept out of the `allSlots` map in
186   * getAllApplicableAudiences(), so the `matchedAudiences` string keeps its
187   * documented nine-slot shape and existing Adobe reports are unaffected.
188   */
189  // eslint-disable-next-line no-use-before-define
190  'trial-active': () => getAccountType() === 'trial-active',
191  'paid-social': () => {
192	if(new URLSearchParams(window.location.search).get('utm_medium') === "paid-social"){
193	  // Consent (Functional): managed by DataGrail
194	  sessionStorage.setItem('bhr_audience_source','UTM');
195	  return true;
196	}
197	return false;
198  },
199  'paid-search': () => {
200	if(new URLSearchParams(window.location.search).get('utm_medium') === "cpc"){
201	  // Consent (Functional): managed by DataGrail
202	  sessionStorage.setItem('bhr_audience_source','UTM');
203	  return true;
204	}
205	return false;
206  },
207  'review-site': () => {
208	if(new URLSearchParams(window.location.search).get('utm_medium') === "review-sites"){
209	  // Consent (Functional): managed by DataGrail
210	  sessionStorage.setItem('bhr_audience_source','UTM');
211	  return true;
212	}
213	return false;
214  },
215  'geo-us':  () => {
216	const location = window.geoLocation;
217	if(location.country_code === 'US'){
218      setBooleanAudienceCookie('bhr_audience_geo_us');
219	  return true;
220	}
221	return false;
222  },
223  'geo-ca':  () => {
224	const location = window.geoLocation;
225	if(location.country_code === 'CA'){
226      setBooleanAudienceCookie('bhr_audience_geo_ca');
227	  return true;
228	}
229	return false;
230  },
231  'geo-gb':  () => {
232	const location = window.geoLocation;
233	if(location.country_code === 'GB'){
234      setBooleanAudienceCookie('bhr_audience_geo_gb');
235	  return true;
236	}
237	return false;
238  }, 
239  'geo-international': () => {
240	const location = window.geoLocation;
241	if(location.country_code !== 'US'){
242      setBooleanAudienceCookie('bhr_audience_geo_int');
243	  return true;
244	}
245	return false;
246  },
247  'geo-domestic': () => {
248    const location = window.geoLocation;
249    if (location.country_code === 'US') {
250      setBooleanAudienceCookie('bhr_audience_geo_domestic');
251      return true;
252    }
253    return false;
254  },
255  'lead-submitter': () => {
256	// eslint-disable-next-line no-use-before-define
257	if (readCookie('bhr_prefill')) { return true; }
258	return false;
259  },
260  'region-apac': () => {
261    const location = window.geoLocation;
262    const regionCountries = ["AS", "AU", "BD", "BT", "IO", "BN", "KH", "CN", "CX", "CC", "CK", "FJ", "TF", "HK", "IN", "ID", "JP", "KI", "KP", "KR", "KG", "LA", "MO", "MY", "MV", "MH", "MN", "MM", "NR", "NP", "NC", "NZ", "NU", "NF", "PW", "PG", "PH", "WS", "SG", "SB", "LK", "TW", "TJ", "TH", "TL", "TK", "TO", "TV", "VU", "VN", "WF"];
263    if(regionCountries.includes(location.country_code)){
264      setBooleanAudienceCookie('bhr_audience_region_apac');
265      return true;
266    }
267    return false;
268  },
269  'region-emea': () => {
270    const location = window.geoLocation;
271    const regionCountries = ["AF", "AX", "AL", "DZ", "AD", "AO", "AM", "AT", "AZ", "BH", "BY", "BE", "BJ", "BA", "BW", "BG", "BF", "BI", "CM", "CF", "TD", "KM", "CG", "CD", "CI", "HR", "CY", "CZ", "DK", "DJ", "EG", "GQ", "ER", "EE", "SZ", "ET", "FO", "FI", "FR", "GA", "GM", "GE", "DE", "GH", "GI", "GR", "GG", "GN", "GW", "VA", "HU", "IS", "IR", "IQ", "IE", "IM", "IL", "IT", "JE", "JO", "KZ", "KE", "XK", "KW", "LV", "LB", "LS", "LR", "LY", "LI", "LT", "LU", "MK", "MG", "MW", "ML", "MT", "MR", "MU", "YT", "MD", "MC", "ME", "MA", "MZ", "NA", "NL", "NE", "NG", "NO", "OM", "PK", "PS", "PL", "PT", "QA", "RE", "RO", "RU", "RW", "SM", "ST", "SA", "SN", "RS", "SC", "SL", "SK", "SI", "SO", "ZA", "SS", "ES", "SD", "SE", "CH", "SY", "TZ", "TG", "TN", "TR", "TM", "UG", "UA", "AE", "GB", "UZ", "EH", "YE", "ZM", "ZW"];
272    if(regionCountries.includes(location.country_code)){
273      setBooleanAudienceCookie('bhr_audience_region_emea');
274      return true;
275    }
276    return false;
277  },
278  'region-ca-plus': () => {
279    const location = window.geoLocation;
280    const regionCountries = ["AI", "AG", "AR", "AW", "BS", "BB", "BZ", "BM", "BO", "BO", "BQ", "BR", "CA", "CV", "KY", "CL", "CO", "CR", "CU", "CW", "DM", "DO", "EC", "SV", "FK", "GF", "PF", "GL", "GD", "GP", "GT", "GY", "HT", "HN", "JM", "MQ", "MX", "MS", "NI", "PA", "PY", "PE", "PN", "BL", "KN", "LC", "MF", "PM", "VC", "SX", "GS", "SR", "TT", "TC", "UY", "VE", "VG"];
281    if(regionCountries.includes(location.country_code)){
282      setBooleanAudienceCookie('bhr_audience_region_ca_plus');
283      return true;
284    }
285    return false;
286  },
287  'region-other': () => {
288    const location = window.geoLocation;
289    const regionCountries = ["AQ", "BV", "HM", "SJ", "SH", "CW", "SX", "BQ"];
290    if(regionCountries.includes(location.country_code)){
291      setBooleanAudienceCookie('bhr_audience_region_other');
292      return true;
293    }
294    return false;
295  }, 
296  'region-lc': () => {
297    const location = window.geoLocation;
298    const regionCountries = ["AF", "AL", "DZ", "AD", "AO", "BD", "BY", "BW", "BN", "KH", "CM", "CC", "CD", "CK", "CI", "EG", "GQ", "SZ", "FO", "FI", "GM", "GH", "GT", "GN", "HT", "IN", "ID", "IR", "IQ", "JE", "JO", "KZ", "KE", "KI", "KG", "MG", "MW", "MY", "MV", "ML", "MR", "MD", "MN", "ME", "MA", "MZ", "MM", "NA", "NP", "NE", "NG", "OM", "PK", "PW", "PS", "PH", "RU", "SM", "SC", "SL", "SB", "GS", "SS", "LK", "SD", "SY", "TZ", "TN", "TR", "TV", "UG", "UZ", "VU", "VE", "VN", "ZM", "ZW"];
299    if(regionCountries.includes(location.country_code)){
300      setBooleanAudienceCookie('bhr_audience_region_lc');
301      return true;
302    }
303    return false;
304  },
305  'paid-search-desktop': () => {
306    if(new URLSearchParams(window.location.search).get('utm_medium') === "cpc" && window.innerWidth >= 600){
307      // Consent (Functional): managed by DataGrail
308      sessionStorage.setItem('bhr_audience_source','UTM');
309      return true;
310    }
311    return false;
312  },
313  'non-paid-search-mobile': () => {
314    if(new URLSearchParams(window.location.search).get('utm_medium') !== "cpc" && window.innerWidth < 600){
315      // Consent (Functional): managed by DataGrail
316      sessionStorage.setItem('bhr_audience_source','UTM');
317      return true;
318    }
319    return false;
320  },
321  'mobile-viewport': () => {
322    if(window.innerWidth < 600){
323      // Consent (Functional): managed by DataGrail
324      sessionStorage.setItem('bhr_audience_source','Viewport');
325      return true;
326    }
327    return false;
328  },
329  'branded-campaigns': () => {
330    
331    const brandedCampaignIds = [
332      'BAMB-DG-Search_AdAi-2024EG',
333      'BAMB-DG-Search-BambooHR-2024EG',
334      'BAMB-DG-Search-BambooHRTrademark-2024EG',
335      'BAMB-DG-Call-BambooHR-2024EG'
336    ];
337    if(brandedCampaignIds.includes(new URLSearchParams(window.location.search).get('utm_campaign'))){
338        // Consent (Functional): managed by DataGrail
339        sessionStorage.setItem('bhr_audience_source','UTM');
340        return true;
341    }    
342    return false;
343  },
344  'non-branded-campaigns': () => {
345
346    const nonBrandedCampaignIds = [
347      'BAMB-DG-BG-SER-PRO-2022EG',
348      'BAMB-DG-Search-Competitors-MS-2024EG',
349      'BAMB-DG-Search-HR-MS-2024EG',
350      'BAMB-DG-Search-TopPerformingKW-Non-brandP-E-2024EG',
351      'BAMB-DG-SER-PRO-2022EG',
352      'BAMBDG-Search-TopPerformingKW-2024EG',
353      'BAMB-DG-Search-Onboarding-MS-2024EG',
354      'BAMB-DG-Search-HRSoftware-2024EG',
355      'BAMB-DG-PerformanceMax-2024EG',
356      'BAMB-DG-GOOGLE-2022EG',
357    ];    
358    if(nonBrandedCampaignIds.includes(new URLSearchParams(window.location.search).get('utm_campaign'))){
359      // Consent (Functional): managed by DataGrail
360      sessionStorage.setItem('bhr_audience_source','UTM');
361      return true;
362    }
363    return false;
364  },
365  'branded-page': () => {    
366    const brandedPage = getMetadata('branded') ?? "";    
367    return brandedPage.toLowerCase().trim() === "true";
368  },
369  'non-branded-page': () => {
370    const brandedPage = getMetadata('branded') ?? "";
371    return brandedPage.toLowerCase().trim() === "false";
372  },
373  'desktop': () => window.innerWidth > 1024,
374  'production-only': () => window.location.hostname !== 'www.bamboostage.com',
375};
376
377export function isAudience(audience) {
378  try {
379    return AUDIENCES[audience]();
380  }catch (e){
381    return false;
382  }  
383}
384
385/**
386 * Gets all applicable audiences and formats them into a single, comma-delimited string.
387 * The function uses a fixed 9-slot structure to ensure a consistent format for analytics.
388 *
389 * The 9 slots, in order, are:
390 * 1. Customer Status
391 * 2. Country
392 * 3. Market (Domestic/International)
393 * 4. Geographic Region
394 * 5. Attribute Region (e.g., low-conversion)
395 * 6. Device
396 * 7. Lead Status
397 * 8. Traffic Source
398 * 9. Campaign Type
399 *
400 * For each slot, the function finds the first matching audience. If no audience
401 * matches for a slot, it remains empty, but the comma delimiter is kept to
402 * maintain the structure.
403 *
404 * @returns {Promise<string>} A promise that resolves to the structured 9-slot audience string.
405 * @example
406 * // Example 1: A user from a country in both APAC and a low-conversion region.
407 * // Returns: "not-customer,,geo-international,region-apac,region-lc,desktop,,,"
408 *
409 * // Example 2: A US customer on mobile who is a lead submitter and came from a branded campaign.
410 * // Returns: "is-customer,geo-us,geo-domestic,,,mobile-viewport,lead-submitter,,branded-campaigns"
411 *
412 * // Example 3: A non-customer from Great Britain on desktop who came from a review site.
413 * // Returns: "not-customer,geo-gb,geo-international,region-emea,,desktop,,review-site,"
414 */
415export async function getAllApplicableAudiences() {
416  // Define all audience slots in the desired order.
417  const allSlots = {
418    customer: ['is-customer', 'not-customer', 'is-customer-non-admin'],
419    country: ['geo-us', 'geo-ca', 'geo-gb'],
420    market: ['geo-international', 'geo-domestic'],
421    geoRegion: ['region-ca-plus', 'region-apac', 'region-emea', 'region-other'],
422    attributeRegion: ['region-lc'],
423    device: ['desktop', 'mobile-viewport'],
424    lead: ['lead-submitter'],
425    traffic: ['paid-social', 'paid-search', 'review-site', 'non-paid-search-mobile', 'paid-search-desktop'],
426    campaign: ['branded-campaigns', 'non-branded-campaigns'],
427  };
428
429  // Get all audiences that are currently active.
430  const allActiveAudiences = Object.keys(AUDIENCES).filter((key) => AUDIENCES[key] && AUDIENCES[key]());
431
432  // Build the final string by finding the first match for each slot.
433  const result = [
434    allSlots.customer.find((aud) => allActiveAudiences.includes(aud)) || '',
435    allSlots.country.find((aud) => allActiveAudiences.includes(aud)) || '',
436    allSlots.market.find((aud) => allActiveAudiences.includes(aud)) || '',
437    allSlots.geoRegion.find((aud) => allActiveAudiences.includes(aud)) || '',
438    allSlots.attributeRegion.find((aud) => allActiveAudiences.includes(aud)) || '',
439    allSlots.device.find((aud) => allActiveAudiences.includes(aud)) || '',
440    allSlots.lead.find((aud) => allActiveAudiences.includes(aud)) || '',
441    allSlots.traffic.find((aud) => allActiveAudiences.includes(aud)) || '',
442    allSlots.campaign.find((aud) => allActiveAudiences.includes(aud)) || '',
443  ];
444
445  return result.join(',');
446}
447/**
448 * Gets the value for the specific cookie
449 * @param {string} name The name of the cookie
450 * @returns {string} the cookie value, or null
451 */
452export function readCookie(name) {
453  const [value] = document.cookie
454    .split('; ')
455    .filter((cookieString) => cookieString.split('=')[0] === name)
456    .map((cookieString) => cookieString.split('=')[1]);
457  return value || null;
458}
459
460/**
461 * Gets the BHR Features from the cookie
462 * @returns {object} the BHR features, or an empty object
463 */
464export function getBhrFeaturesCookie() {
465  const value = readCookie('bhr_features');
466  try {
467    const decryptedValue = atob(value);
468    return JSON.parse(decryptedValue);
469  } catch (err1) {
470    try {
471      return JSON.parse(value);
472    } catch (err2) {
473      return {};
474    }
475  }
476}
477
478/**
479 * Derives the visitor's free trial state for the `accountType` Adobe dimension
480 * (ZZ-6331), from the platform's trial expiry date in the features cookie.
481 *
482 * Contract for `fte`, set by the platform in CONV-692:
483 *   - Unix epoch in **seconds**, at midnight UTC of the expiry date
484 *   - present whenever a usable trial date exists, *regardless* of status
485 *   - the key is omitted entirely when there is no trial or the stored date was
486 *     unparseable; it is never sent as null
487 *
488 * A date rather than a status because the cookie lives 30 days, is only
489 * rewritten at login, and a trial runs about 7 — so any status string it carried
490 * would keep asserting a state weeks after that state changed. A date does not
491 * decay, so the state is derived here at read time instead.
492 *
493 * The date is not cleared when a trial converts, so `fte` alone cannot say
494 * whether someone is still trialling; pairing it with `subscriber` resolves that
495 * and is the platform's intended reading.
496 *
497 * Note this only describes people who have signed in. The cookie is written by
498 * the platform once a user has a session, so someone who signed up and never
499 * followed their magic link produces no signal here at all — absence is not
500 * evidence that they failed to convert.
501 *
502 * @returns {string} 'trial-active', 'trial-ended', 'trial-converted', or '0'
503 */
504export function getAccountType() {
505  const features = getBhrFeaturesCookie();
506  // Returns null, not {}, when the cookie is absent: atob(null) decodes the
507  // string "null" to garbage, and the JSON.parse(null) fallback yields null.
508  if (!features) { return '0'; }
509  // Seconds, not milliseconds. Date.now() is milliseconds, so comparing the two
510  // directly would place every expiry in 1970 and report every trial as ended.
511  const expirySeconds = Number(features.fte);
512  // The platform omits the key for "no trial" and for dates it could not parse,
513  // so anything non-numeric or non-positive means no trial on record.
514  if (!Number.isFinite(expirySeconds) || expirySeconds <= 0) { return '0'; }
515  if (features.subscriber) { return 'trial-converted'; }
516  // Access ends as the expiry day begins: the platform reports a company expired
517  // once the current date is >= the stored date, so the boundary is exclusive.
518  return Date.now() < expirySeconds * 1000 ? 'trial-active' : 'trial-ended';
519}
520
521
522export function getUidAndCidFromFeatureCookie() {
523
524  const ids = {uid: null, cid: null};  
525  const features = getBhrFeaturesCookie();
526
527  if(features){
528      ids.uid = features.lluid ?? null;
529      ids.cid = features.llcid ?? null;
530  }
531    
532  return ids;
533}
534
535export function getCountryNameByCode(code, countriesJson) {
536  const countries = countriesJson.data;
537  // eslint-disable-next-line no-plusplus
538  for (let i = 0; i < countries.length; i++) {
539    if (countries[i]["2 letter code"] === code) {
540      return countries[i].name;
541    }
542  }
543  return "Country not found";
544}
545
546
547export async function geoLookup() {
548  const value = readCookie('bhr_geolookup');
549  if(value){
550	  window.geoLocation = JSON.parse(value);
551  }else{
552	const resp = await fetch("https://bamboohr.com/geolookup/");
553	window.geoLocation = await resp.json();
554	// Consent (Functional): managed by DataGrail
555	document.cookie = `bhr_geolookup=${JSON.stringify(window.geoLocation)};path=/`;
556  }
557}
558
559/**
560 * check if user is BHR customer
561 * @returns {boolean}
562 */
563export function isBhrCustomer() {
564  const features = getBhrFeaturesCookie();
565  if (features) return features.is_admin && !features.bhr_user;
566  return false;
567}
568
569/**
570 * check if the block audience: matches is-customer or not customer
571 * @param {Element} block The block element
572 * @returns {boolean}
573 */
574function matchAudience(block) {
575  let matches = true;
576  const audiences = [...block.classList].filter(c => c.startsWith('audience-'));
577  const audienceTypes = audiences.length ? Object.keys(AUDIENCES) : null;
578  audiences.some(i => {
579    block.classList.remove(i);
580
581    const audienceType = i.slice(9);
582    if (audienceTypes.includes(audienceType)) {
583      matches = AUDIENCES[audienceType]()
584    } else matches = true;
585
586    return matches;
587  });
588
589  return matches;
590}
591
592/**
593 * log RUM if part of the sample.
594 * @param {string} checkpoint identifies the checkpoint in funnel
595 * @param {Object} data additional data for RUM sample
596 */
597const referrer = new URL(window.location.href);
598export function sampleRUM(checkpoint, data = {}) {
599  const referrerOverride = window.sessionStorage.getItem('rum-referrer-override');
600  if (referrerOverride) {
601    Object.defineProperty(document, 'referrer', {
602      value: referrerOverride,
603      configurable: true
604    });
605    window.sessionStorage.removeItem('rum-referrer-override');
606  }
607  sampleRUM.defer = sampleRUM.defer || [];
608  const defer = (fnname) => {
609    sampleRUM[fnname] = sampleRUM[fnname] || ((...args) => sampleRUM.defer.push({ fnname, args }));
610  };
611  sampleRUM.drain =
612    sampleRUM.drain ||
613    ((dfnname, fn) => {
614      sampleRUM[dfnname] = fn;
615      sampleRUM.defer
616        .filter(({ fnname }) => dfnname === fnname)
617        .forEach(({ fnname, args }) => sampleRUM[fnname](...args));
618    });
619  sampleRUM.always = sampleRUM.always || [];
620  sampleRUM.always.on = (chkpnt, fn) => {
621    sampleRUM.always[chkpnt] = fn;
622  };
623  sampleRUM.on = (chkpnt, fn) => {
624    sampleRUM.cases[chkpnt] = fn;
625  };
626  defer('observe');
627  defer('cwv');
628  defer('convert');
629  try {
630    window.hlx = window.hlx || {};
631    if (!window.hlx.rum) {
632      const usp = new URLSearchParams(window.location.search);
633      const weight = usp.get('rum') === 'on' ? 1 : 100; // with parameter, weight is 1. Defaults to 100.
634      // eslint-disable-next-line no-bitwise
635      const hashCode = (s) => s.split('').reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0);
636      const id = `${hashCode(window.location.href)}-${new Date().getTime()}-${Math.random()
637        .toString(16)
638        .substr(2, 14)}`;
639      const random = Math.random();
640      const isSelected = random * weight < 1;
641      // eslint-disable-next-line object-curly-newline
642      window.hlx.rum = { weight, id, random, isSelected, sampleRUM };
643    }
644    const { weight, id } = window.hlx.rum;
645    if (window.hlx && window.hlx.rum && window.hlx.rum.isSelected) {
646      const sendPing = (/* pdata = data */) => {
647        // eslint-disable-next-line object-curly-newline, max-len, no-u
647se-before-define
648        const body = JSON.stringify(
649          {
650            weight,
651            id,
652            referer: referrer.href,
653            generation: window.hlx.RUM_GENERATION,
654            checkpoint,
655            ...data,
656          },
657          (key, value) => (key === 'element' ? undefined : value)
658        );
659        const url = `https://rum.hlx.page/.rum/${weight}`;
660        // eslint-disable-next-line no-unused-expressions
661        navigator.sendBeacon(url, body);
662        // eslint-disable-next-line no-console
663        // console.debug(`ping:${checkpoint}`, pdata);
664      };
665      sampleRUM.cases = sampleRUM.cases || {
666        cwv: () => sampleRUM.cwv(data) || true,
667        lazy: () => {
668          // use classic script to avoid CORS issues
669          const script = document.createElement('script');
670          script.src = 'https://rum.hlx.page/.rum/@adobe/helix-rum-enhancer@^1/src/index.js';
671          document.head.appendChild(script);
672          return true;
673        },
674      };
675      sendPing(data);
676      if (sampleRUM.cases[checkpoint]) {
677        sampleRUM.cases[checkpoint]();
678      }
679    }
680    if (sampleRUM.always[checkpoint]) {
681      sampleRUM.always[checkpoint](data);
682    }
683  } catch (error) {
684    // something went wrong
685  }
686}
687
688/**
689 * Loads a CSS file.
690 * @param {string} href The path to the CSS file
691 */
692export function loadCSS(href, callback) {
693  if (!document.querySelector(`head > link[href="${href}"]`)) {
694    const link = document.createElement('link');
695    link.setAttribute('rel', 'stylesheet');
696    link.setAttribute('href', href);
697    if (typeof callback === 'function') {
698      link.onload = (e) => callback(e.type);
699      link.onerror = (e) => callback(e.type);
700    }
701    document.head.appendChild(link);
702  } else if (typeof callback === 'function') {
703    callback('noop');
704  }
705}
706
707/**
708 * Loads a non module JS file.
709 * @param {string} src URL to the JS file
710 * @param {Object} attrs additional optional attributes
711 */
712
713export async function loadScript(src, attrs) {
714  return new Promise((resolve, reject) => {
715    if (!document.querySelector(`head > script[src="${src}"]`)) {
716      const script = document.createElement('script');
717      script.src = src;
718      if (attrs) {
719      // eslint-disable-next-line no-restricted-syntax, guard-for-in
720        for (const attr in attrs) {
721          script.setAttribute(attr, attrs[attr]);
722        }
723      }
724      script.onload = resolve;
725      script.onerror = reject;
726      document.head.append(script);
727    } else {
728      resolve();
729    }
730  });
731}
732
733/**
734 * Sanitizes a name for use as class name.
735 * @param {string} name The unsanitized name
736 * @returns {string} The class name
737 */
738export function toClassName(name) {
739  return name && typeof name === 'string' ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-') : '';
740}
741
742/**
743 * Gets all the metadata elements that are in the given scope.
744* @param {String} scope The scope/prefix for the metadata
745* @returns an array of HTMLElement nodes that match the given scope
746*/
747export function getAllMetadata(scope) {
748  return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)]
749    .reduce((res, meta) => {
750      const id = toClassName(meta.name
751        ? meta.name.substring(scope.length + 1)
752        : meta.getAttribute('property').split(':')[1]);
753      res[id] = meta.getAttribute('content');
754      return res;
755    }, {});
756}
757
758/**
759 * Gets all the metadata elements for a given URL
760* @param {String} scope The scope/prefix for the metadata
761* @returns an array of HTMLElement nodes that match the given scope
762*/
763export async function getPageMetadata(url) {
764  const response = await fetch(url);
765  const html = await response.text();
766  const parser = new DOMParser();
767  const htmlDoc = parser.parseFromString(html, 'text/html');
768  const meta = {};
769  htmlDoc.querySelectorAll('meta').forEach(el => {
770    meta[el.getAttribute('name') || el.getAttribute('property')] = el.getAttribute('content');
771  });
772  return meta;
773}
774
775/**
776 * Template alias mapping - allows multiple template names to use the same implementation.
777 * This enables better semantic naming without duplicating template code.
778 */
779const TEMPLATE_ALIASES = {
780  'ungated-content': 'hr-glossary',
781};
782
783/**
784 * Resolves a template name to its implementation, handling aliases.
785 * @param {string} template - The template name from metadata
786 * @returns {string} The resolved template name to use for implementation
787 */
788function resolveTemplateAlias(template) {
789  return TEMPLATE_ALIASES[template] || template;
790}
791
792/**
793 * Loads a template specific CSS file.
794 */
795function loadTemplateCSS() {
796  let template = toClassName(getMetadata('template'));
797  template = resolveTemplateAlias(template);
798  if (template) {
799    const templates = [
800      'awareness',
801      'bhr-comparison',
802      'bhr-home',
803      'ee-solution',
804      'hr-glossary',
805      'hr-software',
806      'hr-software-payroll',
807      'hr-unplugged',
808      'hrvs-listing',
809      'hrvs-session',
810      'industry',
811      'industry-category',
812      'live-demo-webinars',
813      'performance-reviews',
814      'pricing-quote',
815      'content-library',
816      'webinar',
817      'paid-landing-page',
818      'paid-landing-page-b',
819      'product-updates',
820      'live-demo-webinar-lp',
821      'hr-101-guide',
822      'customers',
823      'trade-show',
824      'events',
825      'focused-form',
826      'partner',
827      'plan-migration-page',
828      'customer-homepage'
829    ];
830    if (templates.includes(template)) {
831      const cssBase = `${window.hlx.serverPath}${window.hlx.codeBasePath}`;
832      loadCSS(`${cssBase}/styles/templates/${template}.css`);
833    }
834  }
835}
836
837/**
838 * Adds one or more URLs to the dependencies for publishing.
839 * @param {string|[string]} url The URL(s) to add as dependencies
840 */
841export function addPublishDependencies(url) {
842  const urls = Array.isArray(url) ? url : [url];
843  window.hlx = window.hlx || {};
844  if (window.hlx.dependencies && Array.isArray(window.hlx.dependencies)) {
845    window.hlx.dependencies = window.hlx.dependencies.concat(urls);
846  } else {
847    window.hlx.dependencies = urls;
848  }
849}
850
851/*
852 * Sanitizes a name for use as a js property name.
853 * @param {string} name The unsanitized name
854 * @returns {string} The camelCased name
855 */
856export function toCamelCase(name) {
857  return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
858}
859
860/**
861 * Replace icons with inline SVG and prefix with codeBasePath.
862 * @param {Element} element
863 */
864function replaceIcons(element) {
865  element.querySelectorAll('img.icon').forEach((img) => {
866    const span = document.createElement('span');
867    span.className = img.className;
868    img.replaceWith(span);
869  });
870}
871
872/**
873 * Replace icons with inline SVG and prefix with codeBasePath.
874 * @param {Element} element
875 */
876export function decorateIcons(element) {
877  // prepare for forward compatible icon handling
878  replaceIcons(element);
879
880  const missingIcons = [
881    'sync-direction-both',
882    'sync-direction-right',
883    'sync-direction-left'
884  ];
885
886  const fetchBase = window.hlx.serverPath;
887  element.querySelectorAll('span.icon').forEach((span) => {
888    const iconName = span.className.split('icon-')[1].split(' ')[0];
889    if (!missingIcons.includes(iconName)) {
890      fetch(`${fetchBase}${window.hlx.codeBasePath}/icons/${iconName}.svg`).then((resp) => {
891        if (resp.status === 200)
892          resp.text().then((svg) => {
893            const parent = span.firstElementChild?.tagName === 'A' ? span.firstElementChild : span;
894            parent.innerHTML = svg;
895          });
896      });
897    }
898  });
899}
900
901/**
902 * Decorate an AEM-authored "Cookie Preferences" link: wires the click handler
903 * to DataGrail's reopen API and, when `withIcon` is set, appends the CCPA
904 * privacy-choices icon (footer legal row only). Inline body links stay icon-free.
905 * @param {HTMLAnchorElement} link
906 * @param {{ withIcon?: boolean }} [options]
907 */
908export function decorateCookiePreferencesLink(link, { withIcon = false } = {}) {
909  // Idempotent: decorateMain re-runs for fragment roots, and the footer wires
910  // its own link, so guard against double icons / duplicate click handlers.
911  if (link.dataset.cookiePrefsDecorated) return;
912  link.dataset.cookiePrefsDecorated = 'true';
913  link.href = '#';
914  link.addEventListener('click', (event) => {
915    event.preventDefault();
916    window.DG_BANNER_API?.showLayer('default-layer');
917  });
918  if (!withIcon) return;
919  const icon = document.createElement('img');
920  icon.src = '/icons/privacy-choices.svg';
921  icon.width = 30;
922  icon.height = 14;
923  icon.alt = '';
924  icon.style.cssText = 'display:inline-block;vertical-align:middle;margin-left:.25rem;margin-bottom:.2rem;';
925  link.appendChild(icon);
926}
927
928/**
929 * Replace country shortcodes with icon and name tag
930 * @param {Element} element
931 */
932export function countryTagShortcode(element) {
933  const countryTags = [
934    {
935      code: 'global',
936      shortcodes: ['global'],
937      label: ['Global']
938    },
939    {
940      code: 'us',
941      shortcodes: ['us', 'united-states', 'unitedstates'],
942      label: ['United States']
943    },
944    {
945      code: 'ca',
946      shortcodes: ['ca', 'canada'],
947      label: ['Canada']
948    },
949    {
950      code: 'uk',
951      shortcodes: ['uk', 'united-kingdom', 'unitedkingdom'],
952      label: ['United Kingdom']
953    },
954    {
955      code: 'au',
956      shortcodes: ['au', 'australia'],
957      label: ['Australia']
958    },
959    {
960      code: 'nz',
961      shortcodes: ['nz', 'new-zealand', 'newzealand'],
962      label: ['New Zealand']
963    },
964    {
965      code: 'mx',
966      shortcodes: ['mx', 'mexico'],
967      label: ['Mexico']
968    },
969    {
970      code: 'ch',
971      shortcodes: ['ch', 'switzerland'],
972      label: ['Switzerland']
973    },
974    {
975      code: 'de',
976      shortcodes: ['de', 'germany'],
977      label: ['Germany']
978    },
979    {
980      code: 'se',
981      shortcodes: ['se', 'sweden'],
982      label: ['Sweden']
983    },
984    {
985      code: 'za',
986      shortcodes: ['za', 'south-africa', 'southafrica'],
987      label: ['South Africa']
988    }
989  ];
990  
991  countryTags.forEach(country => {
992    country.shortcodes.forEach(shortcode => {
993      const shortcodeTag = `[${shortcode}]`;
994      if(element.innerHTML.indexOf(shortcodeTag) !== -1) {
995        const shortcodeHTML = `<span class="country-tag"><span><span class="icon icon-flag-${country.code}"></span></span> ${country.label}</span>`;
996        element.innerHTML = element.innerHTML.replaceAll(shortcodeTag, shortcodeHTML);
997      }
998    });
999  });
1000}
1001
1002/**
1003 * Gets placeholders object
1004 * @param {string} prefix
1005 */
1006export async function fetchPlaceholders(prefix = 'default') {
1007  window.placeholders = window.placeholders || {};
1008  const loaded = window.placeholders[`${prefix}-loaded`];
1009  if (!loaded) {
1010    window.placeholders[`${prefix}-loaded`] = new Promise((resolve, reject) => {
1011      try {
1012        fetch(`${prefix === 'default' ? '' : prefix}/placeholders.json`)
1013          .then((resp) => resp.json())
1014          .then((json) => {
1015            const placeholders = {};
1016            json.data.forEach((placeholder) => {
1017              placeholders[toCamelCase(placeholder.Key)] = placeholder.Text;
1018            });
1019            window.placeholders[prefix] = placeholders;
1020            resolve();
1021          });
1022      } catch (e) {
1023        // error loading placeholders
1024        window.placeholders[prefix] = {};
1025        reject();
1026      }
1027    });
1028  }
1029  await window.placeholders[`${prefix}-loaded`];
1030  return window.placeholders[prefix];
1031}
1032
1033/**
1034 * Add default spacing to block.
1035 * @param {Element} block The block element
1036 * @param {string} blockName
1037 * @param {Array} classes
1038 */
1039function addDefaultSpacing(block, blockName, classes) {
1040  const defaultSpacingBlocks = ['app-cards', 'app-cards-header', 'app-download', 'article-feed',
1041    'awards', 'callout', 'call-to-action', 'cards', 'carousel', 'center-page-cta', 'columns',
1042    'featured-articles', 'form', 'gallery', 'hero', 'image', 'logos', 'listing', 'listing-cards',
1043    'multi-cta-cards', 'multi-element', 'quote', 'resources-library', 'reviews', 'search',
1044    'tabs', 'title', 'wistia'];
1045
1046  if (defaultSpacingBlocks.includes(blockName)) {
1047    const hasSpacing = classes.find(c => c.startsWith('spacing-') && !c.endsWith('-none'));
1048    const isBambooable = block.classList.contains('bambooable');
1049
1050    if (!hasSpacing && !isBambooable) {
1051      if (document.body.classList.contains('blog-post')
1052          || document.body.classList.contains('resources-guide')
1053          || document.body.classList.contains('hr-glossary')
1054          || (window.location.pathname.startsWith('/customers/')
1055              && window.location.pathname.length > 11)
1056          || window.location.pathname.startsWith('/legal/terms-of-service-faq/')
1057          || window.location.pathname.startsWith('/blog/author/')) {
1058        block.classList.add('spacing-sm');
1059      } else if (document.body.classList.contains('paid-landing-page')) {
1060        block.classList.add('spacing-lg');
1061      } else {
1062        block.classList.add('spacing-xl');
1063      }
1064    }
1065  }
1066}
1067
1068/**
1069 * Sets min-height on a section from an authoring value (e.g. "48rem", "720px", "min(100vh, 900px)").
1070 * @param {Element} section
1071 * @param {string|undefined|null} value
1072 */
1073function applySectionMinHeight(section, value) {
1074  if (!section || value == null) return;
1075  const trimmed = String(value).trim();
1076  if (!trimmed) return;
1077  section.style.setProperty('min-height', trimmed);
1078}
1079
1080/**
1081 * Decorates a block.
1082 * @param {Element} block The block element
1083 */
1084export function decorateBlock(block) {
1085  const trimDashes = (str) => str.replace(/(^\s*-)|(-\s*$)/g, '');
1086  const classes = Array.from(block.classList.values());
1087  const blockName = classes[0];
1088  if (!blockName) return;
1089  const section = block.closest('.section');
1090  if (section) {
1091    section.classList.add(`${blockName}-container`.replace(/--/g, '-'));
1092  }
1093  const blockWithVariants = blockName.split('--');
1094  const shortBlockName = trimDashes(blockWithVariants.shift());
1095  const variants = blockWithVariants.map((v) => trimDashes(v));
1096  block.classList.add(shortBlockName);
1097  block.classList.add(...variants);
1098
1099  block.classList.add('block');
1100  block.setAttribute('data-block-name', shortBlockName);
1101  block.setAttribute('data-block-status', 'initialized');
1102
1103  addDefaultSpacing(block, blockName, classes);
1104
1105  const blockWrapper = block.parentElement;
1106  blockWrapper.classList.add(`${shortBlockName}-wrapper`);
1107  const regex = /\b(?:tablet-|laptop-|desktop)?(?:content-)?width-(?:xxs|xs|sm|md|lg|xl|xxl|xxxl|full)\b/g;
1108
1109  [...block.classList]
1110    .filter((filter) => filter.match(regex))
1111    .forEach((style) => {
1112      block.parentElement.classList.add(style);
1113      block.classList.remove(style);
1114    });
1115  // eslint-disable-next-line no-use-before-define
1116  addClassesToParent(block);
1117}
1118
1119/**
1120 * Extracts the config from a block.
1121 * @param {Element} block The block element
1122 * @returns {object} The block config
1123 */
1124export function readBlockConfig(block) {
1125  const config = {};
1126  block.querySelectorAll(':scope>div').forEach((row) => {
1127    if (row.children) {
1128      const cols = [...row.children];
1129      if (cols[1]) {
1130        const col = cols[1];
1131        const name = toClassName(cols[0].textContent);
1132        let value = '';
1133        if (col.querySelector('a')) {
1134          const as = [...col.querySelectorAll('a')];
1135          if (as.length === 1) {
1136            value = as[0].href;
1137          } else {
1138            value = as.map((a) => a.href);
1139          }
1140        } else if (col.querySelector('p')) {
1141          const ps = [...col.querySelectorAll('p')];
1142          if (ps.length === 1) {
1143            value = ps[0].textContent;
1144          } else {
1145            value = ps.map((p) => p.textContent);
1146          }
1147        } else if (col.querySelector('picture')) {
1148          const imgEl = col.querySelector('picture');
1149          const imagePath = imgEl.firstElementChild.srcset;
1150          value = imagePath.substr(0, imagePath.indexOf('?'));
1151        } else value = row.children[1].textContent;
1152        config[name] = value;
1153      }
1154    }
1155  });
1156  return config;
1157}
1158
1159/**
1160 * Decorates backgrounds in sections.
1161 * @param {Element} $section The section element
1162 */
1163export function decorateBackgrounds($section) {
1164  const missingBgs = [
1165    'bg-animated-hero-animation',
1166    'bg-animated-hero-animation-laptop',
1167    'bg-animated-hero-animation-tablet',
1168    'bg-animated-hero-animation-mobile',
1169    'bg-bottom-cap-3-tint-laptop',
1170    'bg-bottom-cap-3-tint-mobile',
1171    'bg-bottom-cap-3-tint-tablet',
1172    'bg-top-cap-3-laptop',
1173    'bg-top-cap-3-mobile',
1174    'bg-top-cap-3-tablet',
1175    'bg-top-cap-4-laptop',
1176    'bg-top-cap-4-tablet',
1177    'bg-top-cap-4-mobile',
1178    'bg-top-cap-5-laptop',
1179    'bg-top-cap-5-tablet',
1180    'bg-top-cap-5-mobile',
1181    'bg-top-multi-7',
1182    'bg-bottom-multi-3',
1183    'bg-center-multi-3',
1184    'bg-block-center-page-cta',
1185    'bg-block-benefits-laptop',
1186    'bg-block-benefits-tablet',
1187    'bg-block-benefits-mobile',
1188    'bg-block-center-left-single-1-laptop',
1189    'bg-block-center-left-single-1-tablet',
1190    'bg-block-center-left-single-1-mobile',
1191    'bg-block-center-left-single-2-laptop',
1192    'bg-block-center-left-single-2-tablet',
1193    'bg-block-center-left-single-2-mobile',
1194    'bg-block-center-right-double-1-laptop',
1195    'bg-block-center-right-double-1-tablet',
1196    'bg-block-center-right-single-3-laptop',
1197    'bg-block-center-right-single-3-tablet',
1198    'bg-block-center-right-single-3-mobile',
1199    'bg-block-ee-solutions-quote-laptop',
1200    'bg-block-ee-solutions-quote-tablet',
1201    'bg-block-ee-solutions-quote-mobile',
1202    'bg-bottom-cap-1-laptop',
1203    'bg-bottom-cap-1-tablet',
1204    'bg-bottom-cap-1-mobile',
1205    'bg-bottom-cap-2-laptop',
1206    'bg-bottom-cap-2-tablet',
1207    'bg-bottom-cap-2-mobile',
1208    'bg-bottom-cap-3-laptop',
1209    'bg-bottom-cap-3-tablet',
1210    'bg-bottom-cap-3-mobile',
1211    'bg-bottom-cap-4-laptop',
1212    'bg-bottom-cap-4-tablet',
1213    'bg-bottom-cap-4-mobile',
1214    'bg-bottom-cap-5-laptop',
1215    'bg-bottom-cap-5-tablet',
1216    'bg-bottom-cap-5-mobile',
1217    'bg-bottom-cap-6-laptop',
1218    'bg-bottom-cap-6-tablet',
1219    'bg-bottom-cap-6-mobile',
1220    'bg-bottom-cap-7-laptop',
1221    'bg-bottom-cap-7-tablet',
1222    'bg-bottom-cap-7-mobile',
1223    'bg-cover-green-patterns-laptop',
1224    'bg-cover-green-patterns-tablet',
1225    'bg-cover-green-patterns-mobile',
1226    'bg-left-single-1-laptop',
1227    'bg-left-single-1-tablet',
1228    'bg-left-single-1-mobile',
1229    'bg-left-single-2-tablet',
1230    'bg-left-single-2-mobile',
1231    'bg-right-multi-2-mobile',
1232    'bg-top-cap-1-laptop',
1233    'bg-top-cap-1-tablet',
1234    'bg-top-cap-1-mobile',
1235    'bg-top-cap-2-laptop',
1236    'bg-top-cap-2-tablet',
1237    'bg-top-cap-2-mobile',
1238    'bg-top-multi-7-tint-10',
1239    'bg-top-multi-7-tint-15',
1240    'bg-top-multi-11-laptop',
1241    'bg-top-multi-11-tablet',
1242    'bg-top-multi-11-mobile',
1243    'bg-block-center-right-single-4-mobile',
1244    'bg-block-center-right-single-4-tablet',
1245    'bg-block-center-right-single-4-laptop',
1246    'bg-block-center-left-single-3-mobile',
1247    'bg-block-center-left-single-3-tablet',
1248    'bg-block-center-left-single-3-laptop',
1249    'bg-right-multi-4-mobile',
1250    'bg-right-multi-4-tablet',
1251    'bg-right-multi-4-laptop',
1252    'bg-right-multi-6-mobile',
1253    'bg-simple',
1254    'bg-simple-mobile',
1255    'bg-simple-tablet',
1256    'bg-simple-laptop',
1257    'bg-bottom-cap-8-mobile',
1258    'bg-bottom-cap-8-tablet',
1259    'bg-bottom-cap-8-laptop',
1260    'bg-bottom-cap-9-mobile',
1261    'bg-bottom-cap-9-tablet',
1262    'bg-bottom-cap-9-laptop',
1263    'bg-bottom-leaf-1-cityscape-mobile-mobile',
1264    'bg-bottom-leaf-1-cityscape-mobile-tablet',
1265    'bg-bottom-leaf-1-cityscape-mobile-laptop',
1266    'bg-bottom-leaf-1-cityscape-desktop-mobile',
1267    'bg-bottom-leaf-1-cityscape-desktop-tablet',
1268    'bg-bottom-leaf-1-cityscape-desktop-laptop',
1269    'bg-gray-swirl-laptop',
1270    'bg-gray-swirl-tablet',
1271    'bg-gray-swirl-mobile',
1272  ];
1273  const sectionKey = [...$section.parentElement.children].indexOf($section);
1274  [...$section.classList]
1275    .filter((filter) => filter.match(/^bg-/g))
1276    .filter((filter) => !filter.startsWith('bg-content-width-'))
1277    .forEach((style, bgKey) => {
1278      const background = document.createElement('span');
1279      const fetchBase = window.hlx.serverPath;
1280      let sizes = [''];
1281      if (!style.startsWith('bg-leaf')) sizes = ['', 'laptop', 'tablet', 'mobile'];
1282
1283      background.classList.add('bg', style);
1284
1285      if (!style.startsWith('bg-gradient') && !style.startsWith('bg-solid') && !style.startsWith('bg-image') && !style.includes('-multi')) {
1286        // get svgs
1287        sizes.forEach((size, sizeKey) => {
1288          let name = style;
1289
1290          if (size) name += `-${size}`;
1291
1292          if (!missingBgs.includes(name)) {
1293            fetch(`${fetchBase}${window.hlx.codeBasePath}/styles/backgrounds/${name}.svg`).then(
1294              (resp) => {
1295                // skip if not success
1296                if (resp.status !== 200) return;
1297
1298                // put the svg in the span
1299                resp.text().then((output) => {
1300                  const element = document.createElement('div');
1301                  let html = output;
1302
1303                  // get IDs
1304                  const matches = html.matchAll(/id="([^"]+)"/g);
1305                  // replace IDs
1306                  [...matches].forEach(([, match], matchKey) => {
1307                    html = html.replaceAll(
1308                      match,
1309                      `${match}-id-${sectionKey}-${bgKey}-${sizeKey}-${matchKey}`
1310                    );
1311                  });
1312
1313                  element.innerHTML = html;
1314                  const svg = element.firstChild;
1315
1316                  svg.classList.add(size || 'desktop');
1317
1318                  background.append(svg);
1319                  $section.classList.add('has-bg');
1320                });
1321              }
1322            );
1323          }
1324        });
1325      }
1326
1327      const isPng = style.startsWith('bg-image-') && style.endsWith('-png');
1328      const isWebP = style.startsWith('bg-image-') && style.endsWith('-webp');
1329
1330      if (isPng || isWebP) {
1331        background.classList.add('bg-image');
1332        sizes.forEach((size) => {
1333          const ext = isPng ? 'png' : 'webp';
1334          const suffixLen = isPng ? 4 : 5; // '-png' is 4 chars, '-webp' is 5 chars
1335          const baseName = style.substring(9, style.length - suffixLen); // remove 'bg-image-' and extension suffix
1336          const fileName = size ? `${baseName}-${size}` : baseName;
1337
1338          const img = document.createElement('img');
1339          img.src = `${fetchBase}${window.hlx.codeBasePath}/styles/backgrounds/${fileName}.${ext}`;
1340          img.alt = "";
1341          img.loading = "lazy";
1342          img.classList.add(size || 'desktop');
1343          background.append(img);
1344          if (!$section.classList.contains('has-bg')) {
1345            $section.classList.add('has-bg');
1346          }
1347        });
1348      }
1349
1350      if (style.startsWith('bg-gradient') || style.startsWith('bg-solid')) {
1351        $section.classList.add('has-bg');
1352      }
1353
1354      // Handle animated backgrounds (Lottie JSON)
1355      if (style.startsWith('bg-animated-')) {
1356        const animationName = style.replace('bg-animated-', '');
1357        const animationPath = `${fetchBase}${window.hlx.codeBasePath}/styles/backgrounds/bg-${animationName}.json`;
1358
1359        background.classList.add('bg-animated');
1360        $section.classList.add('has-bg');
1361
1362        // Lazy load animation when section is visible
1363        const observer = new IntersectionObserver(
1364          (entries) => {
1365            entries.forEach((entry) => {
1366              if (entry.isIntersecting) {
1367                import('./lottie-background.js').then(({ initAnimatedBackground }) => {
1368                  initAnimatedBackground(background, animationPath);
1369                });
1370                observer.disconnect();
1371              }
1372            });
1373          },
1374          { threshold: 0.1 },
1375        );
1376
1377        observer.observe($section);
1378      }
1379
1380      $section.prepend(background);
1381    });
1382}
1383
1384/**
1385 * Decorates all sections in a container element.
1386 * @param {Element} $main The container element
1387 */
1388export function decorateSections($main) {
1389  $main.querySelectorAll(':scope > div').forEach((section) => {
1390    const wrappers = [];
1391    let defaultContent = false;
1392    [...section.children].forEach((e) => {
1393      if (e.tagName === 'DIV' || !defaultContent) {
1394        const wrapper = document.createElement('div');
1395        wrappers.push(wrapper);
1396        defaultContent = e.tagName !== 'DIV';
1397        if (defaultContent) wrapper.classList.add('default-content-wrapper');
1398      }
1399      wrappers[wrappers.length - 1].append(e);
1400    });
1401    wrappers.forEach((wrapper) => section.append(wrapper));
1402    section.classList.add('section');
1403    section.setAttribute('data-section-status', 'initialized');
1404
1405    /* process section metadata */
1406    const sectionMeta = section.querySelector('div.section-metadata');
1407    if (sectionMeta) {
1408      const meta = readBlockConfig(sectionMeta);
1409      const keys = Object.keys(meta);
1410      keys.forEach((key) => {
1411        if (key === 'style') {
1412          section.classList.add(...meta.style.split(', ').map(toClassName));
1413        } else if (key === 'anchor') {
1414          section.id = toClassName(meta.anchor);
1415        } else if (key === 'bg-image') {
1416          const bgImg = meta['bg-image'];
1417          section.setAttribute('data-bg-image', bgImg);
1418          // eslint-disable-next-line no-use-before-define
1419          const bgPicture = createOptimizedPicture(bgImg, 'Background Image', false, [
1420            { media: '(min-width: 1025px)', width: '2000' },
1421            { media: '(min-width: 600px)', width: '1200' },
1422          ]);
1423          bgPicture.classList.add('bg', 'bg-image');
1424          if (!section.classList.contains('has-bg')) section.classList.add('has-bg');
1425          section.prepend(bgPicture);
1426        } else if (key === 'min-height') {
1427          applySectionMinHeight(section, meta[key]);
1428        } else {
1429          section.dataset[toCamelCase(key)] = meta[key];
1430        }
1431      });
1432      decorateBackgrounds(section);
1433      sectionMeta.remove();
1434    }
1435  });
1436}
1437
1438/**
1439 * Updates all section status in a container element.
1440 * @param {Element} main The container element
1441 */
1442export function updateSectionsStatus(main) {
1443  const sections = [...main.querySelectorAll(':scope > div.section')];
1444  for (let i = 0; i < sections.length; i += 1) {
1445    const section = sections[i];
1446    const status = section.getAttribute('data-section-status');
1447    if (status !== 'loaded') {
1448      const loadingBlock = section.querySelector(
1449        '.block[data-block-status="initialized"], .block[data-block-status="loading"]'
1450      );
1451      if (loadingBlock) {
1452        section.setAttribute('data-section-status', 'loading');
1453        break;
1454      } else {
1455        section.setAttribute('data-section-status', 'loaded');
1456        const event = new CustomEvent('section-display', { detail: { section } });
1457        document.body.dispatchEvent(event);
1458      }
1459    }
1460  }
1461}
1462
1463/**
1464 * Decorates all blocks in a container element.
1465 * @param {Element} main The container element
1466 */
1467export function decorateBlocks(main) {
1468  main
1469    .querySelectorAll('div.section div[class]:not(.default-content-wrapper)')
1470    .forEach((block) => decorateBlock(block));
1471}
1472
1473/**
1474 * Builds a block DOM Element from a two dimensional array
1475 * @param {string} blockName name of the block
1476 * @param {any} content two dimensional array or string or object of content
1477 */
1478export function buildBlock(blockName, content) {
1479  const table = Array.isArray(content) ? content : [[content]];
1480  const blockEl = document.createElement('div');
1481  // build image block nested div structure
1482  blockEl.classList.add(blockName);
1483  table.forEach((row) => {
1484    const rowEl = document.createElement('div');
1485    row.forEach((col) => {
1486      const colEl = document.createElement('div');
1487      const vals = col?.elems ? col.elems : [col];
1488      vals.forEach((val) => {
1489        if (val) {
1490          if (typeof val === 'string') {
1491            colEl.innerHTML += val;
1492          } else {
1493            colEl.appendChild(val);
1494          }
1495        }
1496      });
1497      rowEl.appendChild(colEl);
1498    });
1499    blockEl.appendChild(rowEl);
1500  });
1501  return blockEl;
1502}
1503
1504/**
1505 * Gets the configuration for the given block, and also passes
1506 * the config through all custom patching helpers added to the project.
1507 *
1508 * @param {Element} block The block element
1509 * @returns {Object} The block config (blockName, cssPath and jsPath)
1510 */
1511function getBlockConfig(block) {
1512  const { blockName } = block.dataset;
1513  const cssPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`;
1514  const jsPath = `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.js`;
1515  const original = { blockName, cssPath, jsPath };
1516  return window.hlx.patchBlockConfig
1517    .filter((fn) => typeof fn === 'function')
1518    .reduce(
1519      (config, fn) => fn(config, original),
1520      { blockName, cssPath, jsPath },
1521    );
1522}
1523
1524/**
1525 * Loads JS and CSS for a block.
1526 * @param {Element} block The block element
1527 */
1528export async function loadBlock(block, eager = false) {
1529  if (window.location.hostname !== 'localhost' && !window.location.hostname.endsWith('.page') && !matchAudience(block)) block.remove();
1530  else if (
1531    !(
1532      block.getAttribute('data-block-status') === 'loading' ||
1533      block.getAttribute('data-block-status') === 'loaded'
1534    )
1535  ) {
1536    block.setAttribute('data-block-status', 'loading');
1537    const { blockName, cssPath, jsPath } = getBlockConfig(block);
1538    try {
1539      const cssLoaded = new Promise((resolve) => {
1540        loadCSS(cssPath, resolve);
1541      });
1542      const decorationComplete = new Promise((resolve) => {
1543        (async () => {
1544          try {
1545            const mod = await import(jsPath);
1546            if (mod.default) {
1547              await mod.default(block, blockName, document, eager);
1548            }
1549          } catch (err) {
1550            // eslint-disable-next-line no-console
1551            console.log(`failed to load module for ${blockName}`, err);
1552          }
1553          resolve();
1554        })();
1555      });
1556      await Promise.all([cssLoaded, decorationComplete]);
1557    } catch (err) {
1558      // eslint-disable-next-line no-console
1559      console.log(`failed to load block ${blockName}`, err);
1560    }
1561    block.setAttribute('data-block-status', 'loaded');
1562  }
1563}
1564
1565/**
1566 * Loads JS and CSS for all blocks in a container element.
1567 * @param {Element} main The container element
1568 */
1569export async function loadBlocks(main) {
1570  updateSectionsStatus(main);
1571  const blocks = [...main.querySelectorAll('div.block')];
1572  for (let i = 0; i < blocks.length; i += 1) {
1573    // eslint-disable-next-line no-await-in-loop
1574    await loadBlock(blocks[i]);
1575    updateSectionsStatus(main);
1576  }
1577}
1578
1579/**
1580 * Returns the true origin of the current page in the browser.
1581 * If the page is running in a iframe with srcdoc, the ancestor origin is returned.
1582 * @returns {String} The true origin
1583 */
1584export function getOrigin() {
1585  const { location } = window;
1586  return location.href === 'about:srcdoc' ? window.parent.location.origin : location.origin;
1587}
1588
1589/**
1590 * Returns the true of the current page in the browser.mac
1591 * If the page is running in a iframe with srcdoc,
1592 * the ancestor origin + the path query param is returned.
1593 * @returns {String} The href of the current page or the href of the block running in the library
1594 */
1595export function getHref() {
1596  if (window.location.href !== 'about:srcdoc') return window.location.href;
1597
1598  const { location: parentLocation } = window.parent;
1599  const urlParams = new URLSearchParams(parentLocation.search);
1600  const path = urlParams.get('path');
1601  if (path && (path.startsWith('http://') || path.startsWith('https://'))) return path;
1602  return `${parentLocation.origin}${path}`;}
1603
1604/**
1605 * Returns a picture element with webp and fallbacks
1606 * @param {string} src The image URL
1607 * @param {boolean} eager load image eager
1608 * @param {Array} breakpoints breakpoints and corresponding params (eg. width)
1609 */
1610export function createOptimizedPicture(
1611  src,
1612  alt = '',
1613  eager = false,
1614  breakpoints = [{ media: '(min-width: 400px)', width: '2000' }, { width: '750' }]
1615) {
1616  const url = new URL(src, getHref());
1617  const picture = document.createElement('picture');
1618  const { pathname } = url;
1619  const ext = pathname.substring(pathname.lastIndexOf('.') + 1);
1620
1621  // webp
1622  breakpoints.forEach((br) => {
1623    const source = document.createElement('source');
1624    if (br.media) source.setAttribute('media', br.media);
1625    source.setAttribute('type', 'image/webp');
1626    source.setAttribute('srcset', `${br.img || pathname}?width=${br.width}&format=webply&optimize=medium`);
1627    picture.appendChild(source);
1628  });
1629
1630  // fallback
1631  breakpoints.forEach((br, i) => {
1632    if (i < breakpoints.length - 1) {
1633      const source = document.createElement('source');
1634      if (br.media) source.setAttribute('media', br.media);
1635      source.setAttribute('srcset', `${br.img || pathname}?width=${br.width}&format=${ext}&optimize=medium`);
1636      picture.appendChild(source);
1637    } else {
1638      const img = document.createElement('img');
1639      img.setAttribute('loading', eager ? 'eager' : 'lazy');
1640      img.setAttribute('alt', alt);
1641      picture.appendChild(img);
1642      img.setAttribute('src', `${br.img || pathname}?width=${br.width}&format=${ext}&optimize=medium`);
1643    }
1644  });
1645
1646  return picture;
1647}
1648
1649/**
1650 * Normalizes all headings within a container element.
1651 * @param {Element} el The container element
1652 * @param {[string]]} allowedHeadings The list of allowed headings (h1 ... h6)
1653 */
1654export function normalizeHeadings(el, allowedHeadings) {
1655  const allowed = allowedHeadings.map((h) => h.toLowerCase());
1656  el.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((tag) => {
1657    const h = tag.tagName.toLowerCase();
1658    if (allowed.indexOf(h) === -1) {
1659      // current heading is not in the allowed list -> try first to "promote" the heading
1660      let level = parseInt(h.charAt(1), 10) - 1;
1661      while (allowed.indexOf(`h${level}`) === -1 && level > 0) {
1662        level -= 1;
1663      }
1664      if (level === 0) {
1665        // did not find a match -> try to "downgrade" the heading
1666        while (allowed.indexOf(`h${level}`) === -1 && level < 7) {
1667          level += 1;
1668        }
1669      }
1670      if (level !== 7) {
1671        tag.outerHTML = `<h${level} id="${tag.id}">${tag.textContent}</h${level}>`;
1672      }
1673    }
1674  });
1675}
1676
1677function isNonDefaultColor(color) {
1678  const nonDefaultColors = ['red', 'orange', 'yellow', 'blue', 'purple', 'pink', 'teal'];
1679
1680  return nonDefaultColors.includes(color.toLowerCase());
1681}
1682
1683/**
1684 * Set template (page structure) and theme (page styles).
1685 */
1686function decorateTemplateAndTheme() {
1687  let template = getMetadata('template');
1688  if (template) {
1689    template = resolveTemplateAlias(toClassName(template));
1690    document.body.classList.add(template);
1691  }
1692  const theme = getMetadata('theme');
1693  if (theme) {
1694    const themeValues = theme.split(',').map((t) => t.trim());
1695    themeValues.forEach((t) => {
1696      if (t.toLowerCase() === 'base') document.querySelector('main')?.setAttribute('id', 'base');
1697      if (isNonDefaultColor(t)) document.body.classList.add('green');
1698      else document.body.classList.add(toClassName(t));
1699      // document.body.classList.add(toClassName(t));
1700    });
1701  }
1702}
1703
1704/**
1705 * Adds the favicon.
1706 * @param {string} href The favicon URL
1707 */
1708export function addFavIcon(href) {
1709  const link = document.createElement('link');
1710  link.rel = 'icon';
1711  link.type = 'image/svg+xml';
1712  link.href = href;
1713  const existingLink = document.querySelector('head link[rel="icon"]');
1714  if (existingLink) {
1715    existingLink.parentElement.replaceChild(link, existingLink);
1716  } else {
1717    document.getElementsByTagName('head')[0].appendChild(link);
1718  }
1719}
1720
1721/**
1722 * load LCP block and/or wait for LCP in default content.
1723 */
1724async function waitForLCP() {
1725  // eslint-disable-next-line no-use-before-define
1726  const lcpBlocks = LCP_BLOCKS;
1727  const block = document.querySelector('.block');
1728  const hasLCPBlock = block && lcpBlocks.includes(block.getAttribute('data-block-name'));
1729  if (hasLCPBlock) await loadBlock(block, true);
1730
1731  document.querySelector('body').classList.add('appear');
1732  const lcpCandidate = document.querySelector('main img');
1733  await new Promise((resolve) => {
1734    if (lcpCandidate && !lcpCandidate.complete) {
1735      lcpCandidate.setAttribute('loading', 'eager');
1736      lcpCandidate.addEventListener('load', () => resolve());
1737      lcpCandidate.addEventListener('error', () => resolve());
1738    } else {
1739      resolve();
1740    }
1741  });
1742}
1743
1744function initWebSDK(path, config) {
1745  
1746  if(!window.trackingQueue){
1747    window.trackingQueue = new AnalyticsQueue();    
1748  }
1749  
1750  // Preparing the alloy queue
1751  if (!window.alloy) {
1752    // eslint-disable-next-line no-underscore-dangle
1753    (window.__alloyNS ||= []).push('alloy');
1754    window.alloy = (...args) => new Promise((resolve, reject) => {
1755      window.setTimeout(() => {
1756        window.alloy.q.push([resolve, reject, args]);
1757      });
1758    });
1759    window.alloy.q = [];
1760  }
1761
1762  // Loading and configuring the websdk
1763  return new Promise((resolve) => {
1764    import(path)
1765        .then(() => {
1766          if(!window.alloyConfigured){            
1767            window.alloy('configure', config).then(() => { window.alloyConfigured = true;});
1768          }
1769        })
1770        .then(resolve);
1771  });
1772
1773}
1774
1775function onDecoratedElement(fn) {
1776  // Apply propositions to all already decorated blocks/sections
1777  if (document.querySelector('[data-block-status="loaded"],[data-section-status="loaded"]')) {
1778    fn();
1779  }
1780  // Observe the DOM for changes
1781  const observer = new MutationObserver((mutations) => {
1782    if (mutations.some((m) => m.target.tagName === 'BODY'
1783        || m.target.dataset.sectionStatus === 'loaded'
1784        || m.target.dataset.blockStatus === 'loaded')) {
1785      fn();
1786    }
1787  });
1788  // Watch sections and blocks being decorated async
1789  observer.observe(document.querySelector('body'), {
1790    subtree: true,
1791    attributes: true,
1792    attributeFilter: ['data-block-status', 'data-section-status'],
1793  });
1794  // Watch anything else added to the body
1795  observer.observe(document.querySelector('body'), { childList: true });  
1796}
1797
1798function toCssSelector(selector) {
1799  return selector.replace(/(\.\S+)?:eq\((\d+)\)/g, (_, clss, i) => `:nth-child(${Number(i) + 1}${clss ? ` of ${clss})` : ''}`);
1800}
1801async function getElementForProposition(proposition) {
1802  const selector = proposition.data.prehidingSelector
1803      || toCssSelector(proposition.data.selector);
1804  return document.querySelector(selector);
1805}
1806async function getAndApplyRenderDecisions() {
1807  // Get the decisions, but don't render them automatically
1808  // so we can hook up into the AEM EDS page load sequence
1809  const response = await window.alloy('sendEvent', { renderDecisions: false });
1810  if (!response) return;
1811  const { propositions } = response;
1812
1813  onDecoratedElement(async () => {
1814    await window.alloy('applyPropositions', { propositions });    
1815    // filter propositions that have been rendered
1816    propositions.forEach((p) => {
1817      p.items = p.items.filter((i) => i.schema !== 'https://ns.adobe.com/personalization/dom-action' || !getElementForProposition(i));
1818    });
1819    // Changes should be applied now, so we can unhide
1820    const styleElement = document.getElementById('alloy-prehiding');
1821    if (styleElement) {
1822      styleElement.remove();
1823    }
1824  });
1825
1826  // Reporting is deferred to avoid long tasks
1827  window.setTimeout(() => {
1828    // Report shown decisions
1829    window.alloy('sendEvent', {
1830      xdm: {
1831        eventType: 'decisioning.propositionDisplay',
1832        _experience: {
1833          decisioning: { propositions },
1834        },
1835      },
1836    });
1837  });
1838}
1839
1840/**
1841 * Decorates the page.
1842 */
1843async function loadPage(doc) {
1844  
1845  // eslint-disable-next-line no-use-before-define
1846  await loadEager(doc);
1847  // eslint-disable-next-line no-use-before-define
1848  await loadLazy(doc);
1849  // eslint-disable-next-line no-use-before-define
1850  loadDelayed(doc);  
1851  // eslint-disable-next-line no-use-before-define
1852  analyticsTrackPageViews();  
1853  if (document.querySelector('aem-sidekick') !== null) {
1854    // eslint-disable-next-line no-use-before-define
1855    loadInitSideKick();
1856  } else {
1857    // The sidekick extension injects <aem-sidekick> asynchronously, often after
1858    // loadPage has already run. Without this fallback the publish/unpublish
1859    // listeners never get attached when the sidekick loads late, so the
1860    // publish-complete webhook never fires. loadInitSideKick is idempotent.
1861    document.addEventListener('sidekick-ready', () => {
1862      // eslint-disable-next-line no-use-before-define
1863      loadInitSideKick();
1864    }, { once: true });
1865  }
1866}
1867
1868// Define an execution context for plugins
1869export const executionContext = {
1870  createOptimizedPicture,
1871  getAllMetadata,
1872  getMetadata,
1873  decorateBlock,
1874  decorateIcons,
1875  loadBlock,
1876  loadCSS,
1877  loadScript,
1878  sampleRUM,
1879  toCamelCase,
1880  toClassName,
1881};
1882
1883export function initHlx(forceMultiple = false) {
1884  if (!window.hlx || forceMultiple) {
1885    window.hlx = window.hlx || {};
1886    window.hlx.lighthouse = new URLSearchParams(window.location.search).get('lighthouse') === 'on';
1887    window.hlx.codeBasePath = '';
1888    window.hlx.serverPath = '';
1889    window.hlx.patchBlockConfig = [];
1890
1891    const scriptEl = document.querySelector('script[src$="/scripts/scripts.js"]');
1892    if (scriptEl) {
1893      try {
1894        [window.hlx.codeBasePath] = new URL(scriptEl.src).pathname.split('/scripts/scripts.js');
1895      } catch (e) {
1896        // eslint-disable-next-line no-console
1897        console.log(e);
1898      }
1899    }
1900  }
1901}
1902
1903initHlx();
1904
1905/*
1906 * ------------------------------------------------------------
1907 * Edit above at your own risk
1908 * ------------------------------------------------------------
1909 */
1910
1911const LCP_BLOCKS = ['hero', 'featured-articles', 'columns']; // add your LCP blocks to the list
1912window.hlx.RUM_GENERATION = 'bamboo-rum-conversion-1'; // add your RUM generation information here
1913
1914sampleRUM('top');
1915
1916window.addEventListener('unhandledrejection', (event) => {
1917  sampleRUM('error', { source: event.reason.sourceURL, target: event.reason.line });
1918});
1919
1920window.addEventListener('error', (event) => {
1921  sampleRUM('error', { source: event.filename, target: event.lineno });
1922});
1923
1924window.addEventListener('load', () => sampleRUM('load'));
1925
1926let cwv = {};
1927
1928// Forward the RUM CWV cached measurements to edge using WebSDK before the page unloads
1929window.addEventListener('beforeunload', () => {
1930  if (Object.keys(cwv).length > 0) {
1931    analyticsTrackCWV(cwv);
1932  }
1933});
1934
1935// Callback to RUM CWV checkpoint in order to cache the measurements
1936sampleRUM.always.on('cwv', async (data) => {
1937  if (data.cwv) {
1938    cwv = {
1939      ...cwv,
1940      ...data.cwv,
1941    };
1942  }
1943});
1944
1945await geoLookup();
1946
1947if (!window.hlx.suppressLoadPage) loadPage(document);
1948
1949/**
1950 * Converts a date string (YYYY-MM-DD) to a formatted string like 'June 4, 2024'.
1951 * Returns the original string if invalid or empty.
1952 * @param {string} dateString - The date string to format.
1953 * @returns {string} The formatted date string.
1954 */
1955export function formatDate(dateString) {
1956  if (!dateString) return '';
1957  
1958  // Parse date components directly to avoid timezone conversion issues
1959  // This prevents new Date(dateString) from interpreting as UTC midnight
1960  const parts = dateString.split('-');
1961  if (parts.length !== 3) return dateString;
1962  
1963  const year = parseInt(parts[0], 10);
1964  const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed
1965  const day = parseInt(parts[2], 10);
1966  
1967  // Check for invalid parsed values
1968  if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return dateString;
1969  
1970  // Create date in local timezone instead of UTC to avoid timezone shift
1971  const date = new Date(year, month, day);
1972  
1973  // Check for invalid date
1974  if (Number.isNaN(date)) return dateString;
1975  
1976  return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
1977}
1978
1979export function decorateButtons(block = document) {
1980  const noButtonBlocks = [];
1981  block.querySelectorAll(':scope a').forEach(($a) => {
1982    $a.title = $a.title || $a.textContent.trim();
1983    const $block = $a.closest('div.section > div > div');
1984    let blockName;
1985    if ($block) {
1986      blockName = $block.className;
1987    }
1988    if (!noButtonBlocks.includes(blockName) && $a.href !== $a.textContent) {
1989      const $up = $a.parentElement;
1990      const $twoup = $a.parentElement.parentElement;
1991      if (!$a.querySelector('img')) {
1992        if ($up.childNodes.length === 1 && ($up.tagName === 'P' || $up.tagName === 'DIV')) {
1993          $a.className = 'button accent'; // default
1994          $up.classList.add('button-container');
1995        }
1996        if (
1997          $up.childNodes.length === 1 &&
1998          $up.tagName === 'STRONG' &&
1999          $twoup.childNodes.length === 1 &&
2000          $twoup.tagName === 'P'
2001        ) {
2002          $a.className = 'button accent';
2003          $twoup.classList.add('button-container');
2004        }
2005        if (
2006          $up.childNodes.length === 1 &&
2007          $up.tagName === 'EM' &&
2008          $twoup.childNodes.length === 1 &&
2009          $twoup.tagName === 'P'
2010        ) {
2011          $a.className = 'button accent light';
2012          $twoup.classList.add('button-container');
2013        }
2014      }
2015    }
2016  });
2017}
2018
2019export function toCategory(category) {
2020  let categoryName = toClassName(category);
2021  while (categoryName.includes('--')) categoryName = categoryName.replace('--', '-');
2022  return categoryName;
2023}
2024
2025function setCategory() {
2026  let category = getMetadata('category');
2027  if (!category && window.location.pathname.includes('/category/')) {
2028    // eslint-disable-next-line prefer-destructuring
2029    category = window.location.pathname.split('/category/')[1];
2030  }
2031
2032  const categoryName = toCategory(category);
2033  if (category) {
2034    document.body.classList.add(`category-${categoryName}`);
2035  }
2036}
2037
2038/**
2039 * Build figcaption element
2040 * @param {Element} pEl The original element to be placed in figcaption.
2041 * @returns figCaptionEl Generated figcaption
2042 */
2043
2044export function buildCaption(pEl) {
2045  const figCaptionEl = document.createElement('figcaption');
2046  pEl.classList.add('caption');
2047  figCaptionEl.append(pEl);
2048  return figCaptionEl;
2049}
2050
2051/**
2052 * Build figure element
2053 * @param {Element} blockEl The original element to be placed in figure.
2054 * @returns figEl Generated figure
2055 */
2056export function buildFigure(blockEl) {
2057  const figEl = document.createElement('figure');
2058  figEl.classList.add('figure');
2059  // content is picture only, no caption or link
2060  if (blockEl?.firstElementChild) {
2061    if (
2062      blockEl.firstElementChild.nodeName === 'PICTURE' ||
2063      blockEl.firstElementChild.nodeName === 'VIDEO'
2064    ) {
2065      figEl.append(blockEl.firstElementChild);
2066    } else if (blockEl.firstElementChild.nodeName === 'P') {
2067      const pEls = Array.from(blockEl.children);
2068      pEls.forEach((pEl) => {
2069        if (pEl.firstElementChild) {
2070          if (
2071            pEl.firstElementChild.nodeName === 'PICTURE' ||
2072            pEl.firstElementChild.nodeName === 'VIDEO'
2073          ) {
2074            figEl.append(pEl.firstElementChild);
2075          } else if (pEl.firstElementChild.nodeName === 'EM') {
2076            const figCapEl = buildCaption(pEl);
2077            figEl.append(figCapEl);
2078          } else if (pEl.firstElementChild.nodeName === 'A') {
2079            const picEl = figEl.querySelector('picture');
2080            if (picEl) {
2081              pEl.firstElementChild.textContent = '';
2082              pEl.firstElementChild.append(picEl);
2083            }
2084            figEl.prepend(pEl.firstElementChild);
2085          }
2086        }
2087      });
2088      // catch link-only figures (like embed blocks);
2089    } else if (blockEl.firstElementChild.nodeName === 'A') {
2090      figEl.append(blockEl.firstElementChild);
2091    }
2092  }
2093  return figEl;
2094}
2095
2096export async function readIndex(indexPath, collectionCache) {
2097  window.pageIndex = window.pageIndex || {};
2098  if (!window.pageIndex[collectionCache]) {
2099    let readPath = indexPath;
2100    let readInTheNews = false;
2101    if (indexPath.startsWith('/about-bamboohr/media-featured/query-index')) {
2102      readPath = '/about-bamboohr/press-release/query-index.json';
2103      readInTheNews = true;
2104    }
2105
2106    const resp = await fetch(readPath);
2107    if (resp.status === 200) {
2108      const json = await resp.json();
2109      const lookup = {};
2110      json.data.forEach((row) => {
2111        lookup[row.path] = row;
2112      });
2113      let { data } = json;
2114      if (indexPath === '/webinars/query-index.json' || indexPath === '/webinars/query-index.json?sheet=default') {
2115        // Include/read live-demo-webinars when reading webinars index.
2116        const resp2 = await fetch('/live-demo-webinars/query-index.json?sheet=default');
2117        const json2 = await resp2.json();
2118
2119        json2.data.forEach((row) => {
2120          lookup[row.path] = row;
2121        });
2122
2123        data = [...json.data, ...json2.data];
2124      } else if (readInTheNews) {
2125        // Include/read lin-the-news when reading media-featured index.
2126        const resp2 = await fetch('/about-bamboohr/in-the-news/query-index.json');
2127        const json2 = await resp2.json();
2128
2129        json2.data.forEach((row) => {
2130          lookup[row.path] = row;
2131        });
2132
2133        data = [...json.data, ...json2.data];
2134      }
2135      else if(indexPath === '/integrations/query-index.json?sheet=listings') {
2136        // Standardise business sizes
2137        // eslint-disable-next-line no-return-assign
2138        data.forEach(el => {
2139          const matches = el.businessSize.match(/\((.*?)\)/);
2140          el.businessSize = matches ? matches[1] : el.businessSize;
2141          el.businessSize = el.businessSize.replace(' employees', '').trim();
2142        })
2143      }
2144      window.pageIndex[collectionCache] = { data, lookup };
2145    } else {
2146      const lookup = {};
2147      const data = [];
2148      window.pageIndex[collectionCache] = { data, lookup };
2149    }
2150  }
2151}
2152
2153/**
2154 * Increment Webinar Registration
2155 *
2156 * NOTE (ZZ-6848): the read side of this feature — getWebinarRegistrationCount, which
2157 * fetched /xhr/webinars.php on prelogin, and the count-badge block it fed — has been
2158 * removed. Nothing now reads the counter this increments.
2159 *
2160 * It is left in place deliberately rather than removed in the same change: it posts
2161 * to a *different* Make webhook than the read path did, and what consumes that
2162 * scenario has not been established. Removing it blind could silently drop something
2163 * marketing relies on. Retire it once that is confirmed.
2164 *
2165 * @param {String} pathToWebinar is the path to the webinar starting with /webinars/
2166 */
2167export async function incrementWebinarRegistration(pathToWebinar) {
2168  await fetch('https://hook.us1.make.celonis.com/6fmmwewv1fq1lmuig782tnwde6mo1d9g', {
2169    method: 'POST',
2170    headers: {'Content-Type': 'application/json'},
2171    body: JSON.stringify({webinarName:pathToWebinar})
2172  });
2173}
2174
2175const addDepartment = (departments, deptName, deptJobCount) => {
2176  const deptPath = deptName === 'Internships' ? '/careers/internships' : `/careers/${toCategory(deptName)}-team`;
2177  departments.push({department: deptName, deptJobCount, path: deptPath});
2178};
2179
2180const getIsInternship = ((job) => job?.metadata?.some(m => m.name === 'Is this an internship?' && m.value));
2181
2182const getSiblingDept = (jobs) => {
2183  const values = jobs.map((j) =>
2184    (Array.isArray(j.metadata)
2185      ? j.metadata.find((m) => m.name === 'Careers Page Categorization')
2186      : null)?.value
2187  );
2188  const first = values.find((v) => v);
2189  return first ?? null;
2190};
2191
2192// Greenhouse returns job.location.name as a `;`-delimited list of
2193// `state | type` pairs (e.g. "Arizona | Remote; Florida | Remote; Utah | Hybrid").
2194// Single-location jobs may omit the type ("London"). This parser produces:
2195//   - state:   comma-joined unique location names (feeds the Location facet)
2196//   - remote:  comma-joined unique normalized types from {Remote, Hybrid, In Office}
2197//   - display: deduped, grouped string for the card, e.g. "REMOTE | ARIZONA, FLORIDA; HYBRID | UTAH"
2198const normalizeLocationType = (raw) => {
2199  const lower = raw.toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim();
2200  if (lower === 'remote') return 'Remote';
2201  if (lower === 'hybrid') return 'Hybrid';
2202  if (lower === 'in office' || lower === 'inoffice' || lower === 'on site' || lower === 'onsite') return 'In Office';
2203  return '';
2204};
2205
2206const parseGreenhouseLocation = (name) => {
2207  if (!name) return { state: '', remote: '', display: '' };
2208  const states = [];
2209  const typeOrder = [];
2210  const typeStates = new Map();
2211  const untyped = [];
2212  name.split(';').forEach((pair) => {
2213    const [rawState, rawType] = pair.split('|').map(part => (part || '').trim());
2214    if (!rawState) return;
2215    if (!states.includes(rawState)) states.push(rawState);
2216    const type = rawType ? normalizeLocationType(rawType) : '';
2217    if (type) {
2218      if (!typeStates.has(type)) {
2219        typeStates.set(type, []);
2220        typeOrder.push(type);
2221      }
2222      const list = typeStates.get(type);
2223      if (!list.includes(rawState)) list.push(rawState);
2224    } else if (!untyped.includes(rawState)) {
2225      untyped.push(rawState);
2226    }
2227  });
2228  const segments = typeOrder.map(type => `${type.toUpperCase()} | ${typeStates.get(type).map(s => s.toUpperCase()).join(', ')}`);
2229  if (untyped.length) segments.push(untyped.map(s => s.toUpperCase()).join(', '));
2230  return {
2231    state: states.join(', '),
2232    remote: typeOrder.join(', '),
2233    display: segments.join('; '),
2234  };
2235};
2236
2237const getJobListings = (jobs, subDeptName, data, departments) => {
2238  if (!jobs) return;
2239
2240  const fallbackDeptValue = getSiblingDept(jobs);
2241
2242  jobs.forEach(job => {
2243    const metadata = Array.isArray(job?.metadata) ? job.metadata : [];
2244    const collapsedDept = metadata.find(metaDept => metaDept.name === 'Careers Page Categorization');
2245    const rawDeptValue = collapsedDept?.value ?? fallbackDeptValue;
2246    if (!rawDeptValue) return;
2247    const deptValue = rawDeptValue === 'Internship' ? 'Internships' : rawDeptValue;
2248    const existingDept = departments.find(d => d.department === deptValue);
2249    if (existingDept) existingDept.deptJobCount += 1;
2250    else addDepartment(departments, deptValue, 1);
2251
2252    const isInternship = getIsInternship(job);
2253    // Only add to Internships via isInternship flag if not already categorized as an internship
2254    if (isInternship && deptValue !== 'Internships') {
2255      const internshipDept = 'Internships';
2256      const existingInternshipDept = departments.find(d => d.department === internshipDept);
2257      if (existingInternshipDept) existingInternshipDept.deptJobCount += 1;
2258      else addDepartment(departments, internshipDept, 1);
2259    }
2260
2261    const jobListingId = job.absolute_url.match(/\d+$/)[0];
2262    const jobListingURL = `/careers/application?gh_jid=${jobListingId}`;
2263    const { state: jobLocState, remote: jobLocRemote, display: jobLocation } = parseGreenhouseLocation(job.location?.name || '');
2264
2265    data.push({path: jobListingURL, department: deptValue, jobTitle: job.title,
2266      jobLocation, jobLocState, jobLocRemote, isInternship});
2267  });
2268};
2269
2270const initDepartments = (departments) => {
2271  const deptNames = ['Business Operations', 'Customer Experience & Support', 'Engineering & IT', 'Finance & Legal', 'Human Resources', 'Internships', 'Marketing', 'Product', 'Sales'];
2272
2273  deptNames.forEach(deptName => addDepartment(departments, deptName, 0));
2274}
2275
2276const cacheJobListingsData = (collectionCache, json) => {
2277  const data = [];
2278  const lookup = {};
2279  const departments = [];
2280
2281  initDepartments(departments);
2282
2283  Object.values(json).forEach(dept => {
2284    Object.keys(dept.subDepartments).forEach(subDeptName => {
2285      const { subDepartments, jobs } = dept.subDepartments[subDeptName];
2286
2287      if (subDepartments) {
2288        Object.values(subDepartments).forEach(subDeptLevel2 => {
2289          getJobListings(subDeptLevel2.jobs, subDeptName, data, departments);
2290        });
2291      } else getJobListings(jobs, subDeptName, data, departments);
2292    });
2293  });
2294
2295  window.pageIndex[collectionCache] = { data, lookup, departments };
2296};
2297
2298export async function readJobListings(collectionCache) {
2299  window.pageIndex = window.pageIndex || {};
2300  if (!window.pageIndex[collectionCache]) {
2301
2302    // Live:
2303    const resp = await fetch('/xhr/careers.php');
2304    // Stage: const resp = await fetch('https://www.bamboostage.com/xhr/careers.php');
2305    // Local: const resp = await fetch('https://www.bamboolocal.com/xhr/careers.php');
2306    if (resp.status === 200) {
2307      const json = await resp.json();
2308      cacheJobListingsData(collectionCache, json);
2309    } else {
2310      const data = [];
2311      const lookup = {};
2312      const departments = [];
2313      window.pageIndex[collectionCache] = { data, lookup, departments };
2314    }
2315  }
2316}
2317
2318export async function lookupPages(pathnames, collection, sheet = '') {
2319  const indexPaths = {
2320    blog: '/blog/fixtures/blog-query-index.json',
2321    ukBlog: '/uk/blog/fixtures/blog-query-index.json',
2322    caBlog: '/ca/blog/fixtures/blog-query-index.json',
2323    integrations: '/integrations/query-index.json?sheet=listings',
2324    hrGlossary: '/resources/hr-glossary/query-index.json',
2325    hrSoftware: '/hr-software/query-index.json',
2326    hrvs: '/resources/events/hr-virtual/2022/query-index.json',
2327    liveDemoWebinars: '/live-demo-webinars/query-index.json?sheet=default',
2328    blockInventory: '/blocks/query-index.json',
2329    blockTracker: `/website-marketing-resources/block-inventory-tracker2.json?sheet=${sheet}`,
2330    resources: `/resources/query-index.json?sheet=resources`,
2331    speakers: `/speakers/query-index.json`,
2332    productUpdates: '/product-updates/query-index.json',
2333    webinars: '/webinars/query-index.json?sheet=default',
2334    jobDescription: '/job-description/query-index.json?sheet=default',
2335    listingCategory: '/integrations/query-index.json?sheet=listing-category'
2336  };
2337  if (collection === 'blog') {
2338    if (window.location.pathname.startsWith('/ca/blog/')) {
2339      indexPaths.blog = '/ca/blog/fixtures/blog-query-index.json';
2340    } 
2341    else if (window.location.pathname.startsWith('/uk/blog/')) {
2342      indexPaths.blog = '/uk/blog/fixtures/blog-query-index.json';
2343    }
2344  }
2345  const indexPath = indexPaths[collection];
2346  const collectionCache = `${collection}${sheet}`;
2347  await readIndex(indexPath, collectionCache);
2348
2349  /* guard for legacy URLs */
2350  pathnames.forEach((path, i) => {
2351    if (path.endsWith('/')) pathnames[i] = path.substr(0, path.length - 1);
2352  });
2353  const { lookup } = window.pageIndex[collectionCache];
2354  const result = pathnames.map((path) => lookup[path]).filter((e) => e);
2355  return result;
2356}
2357
2358// Builds the global header block and attaches data-testid attributes
2359// for automated testing (site-header wrapper, nav, and nav-login).
2360export async function loadHeader(header) {
2361  const queryParams = new Proxy(new URLSearchParams(window.location.search), {
2362    get: (searchParams, prop) => searchParams.get(prop),
2363  });
2364  const headerblockName = queryParams.header === 'meganav' ? 'meganav' : 'header';
2365
2366  const headerBlock = buildBlock(headerblockName, '');
2367  header.append(headerBlock);
2368  
2369  decorateBlock(headerBlock);
2370  await loadBlock(headerBlock);
2371  
2372  // Initialize header test IDs using TestIdManager
2373  TestIdManager.initializeHeaderTestIds(header);
2374  
2375  // Patch logo URL for is-customer audience
2376  if (getBhrFeaturesCookie()) {
2377	const usp = new URLSearchParams(window.location.search);
2378    if (AUDIENCES['is-customer']() && !usp.has('audience')) {
2379      usp.append('audience', 'default');
2380      document.querySelector('.nav-brand a').href += `?${usp.toString()}`;
2381    }
2382  }
2383
2384}
2385
2386// Builds the global footer block and attaches a data-testid attribute
2387// on the footer wrapper (site-footer) for automated testing.
2388function loadFooter(footer) {
2389  const queryParams = new Proxy(new URLSearchParams(window.location.search), {
2390    get: (searchParams, prop) => searchParams.get(prop),
2391  });
2392  const footerBlockName = queryParams.header ? 'megafooter' : 'footer';
2393
2394  const footerBlock = buildBlock(footerBlockName, '');
2395  if (footer) {
2396    footer.append(footerBlock);
2397    
2398    // Initialize footer test IDs using TestIdManager
2399    TestIdManager.initializeFooterTestIds(footer);
2400  }
2401  
2402  decorateBlock(footerBlock);
2403  loadBlock(footerBlock);
2404}
2405
2406function buildPageHeader(main, type) {
2407  const section = document.createElement('div');
2408  let content = [];
2409  if (type === 'resources-guides') {
2410    const picture = document.querySelector('h1 + h5 + p > picture');
2411    const h1 = document.querySelector('h1');
2412    const h5 = h1.nextElementSibling?.tagName === 'H5' ? h1.nextElementSibling : null;
2413    content = [[picture], [h1], [h5]].filter((e) => e[0]);
2414  }
2415  const header = buildBlock('page-header', content);
2416  header.setAttribute('data-header-location', toClassName(type));
2417  section.append(header);
2418  main.prepend(section);
2419}
2420
2421/**
2422 * True for article pages that appear in the content library listing —
2423 * any /resources/<sub>/<slug> path, excluding sections that have their own
2424 * landing experience (data-at-work, hr-glossary, events) and the listing
2425 * page itself.
2426 */
2427function isContentLibraryArticle() {
2428  const { pathname } = window.location;
2429  if (!pathname.startsWith('/resources/')) return false;
2430  if (pathname.startsWith('/resources/data-at-work/')) return false;
2431  if (pathname.startsWith('/resources/hr-glossary/')) return false;
2432  if (pathname.startsWith('/resources/events/')) return false;
2433  const segments = pathname.split('/').filter(Boolean);
2434  return segments.length >= 3;
2435}
2436
2437function buildContentLibraryBreadcrumb(main) {
2438  if (main.querySelector('.breadcrumb')) return;
2439  // Place the breadcrumb in the same parent as the article H1 so it inherits
2440  // the article header's container width and left edge. We use the standard
2441  // `breadcrumb` block class so Helix's block decorator loads breadcrumb.css
2442  // for us; breadcrumb.js detects the explicit anchor content and skips its
2443  // path-based generator.
2444  const h1 = main.querySelector('h1');
2445  if (!h1) return;
2446  const breadcrumb = document.createElement('div');
2447  breadcrumb.className = 'breadcrumb content-library';
2448  breadcrumb.innerHTML = '<ul><li><a href="/resources/" title="Content Library">Content Library</a></li></ul>';
2449  h1.parentElement.prepend(breadcrumb);
2450}
2451
2452function buildContentLibraryRelatedPosts(main) {
2453  // Author override: if the doc already includes a related-posts block, leave it.
2454  if (main.querySelector('.related-posts')) return;
2455
2456  // Without a topic on the current page there's nothing to match against.
2457  const topic = getMetadata('topic');
2458  if (!topic) return;
2459
2460  // One `topic: <value>` clause per topic — repeated keys are OR-ed by
2461  // related-posts (see filterAndSort docs in blocks/related-posts/related-posts.js).
2462  const topicFilters = topic
2463    .split(',')
2464    .map((t) => t.trim())
2465    .filter(Boolean)
2466    .map((t) => `topic: ${t}`)
2467    .join(', ');
2468  const rowText = `content type: resources, ${topicFilters}, sort by: publicationDate, card style: simple`;
2469  const block = buildBlock('related-posts', [[rowText], [rowText], [rowText]]);
2470  block.classList.add('flexible-content');
2471
2472  const section = document.createElement('div');
2473  section.append(block);
2474  main.append(section);
2475
2476  // If the auto-injected block ends up with no matching articles (unique topic,
2477  // or current page is the only match) the related-posts CSS still renders a
2478  // "You might also like..." heading + 100px top margin — a blank section.
2479  // Watch for the block to finish decorating and remove the section if empty.
2480  const observer = new MutationObserver(() => {
2481    if (block.dataset.blockStatus === 'loaded') {
2482      observer.disconnect();
2483      if (!block.querySelector('.related-posts-card')) {
2484        section.remove();
2485      }
2486    }
2487  });
2488  observer.observe(block, { attributes: true, attributeFilter: ['data-block-status'] });
2489}
2490
2491/**
2492 * Builds all synthetic blocks in a container element.
2493 * @param {Element} main The container element
2494 */
2495// eslint-disable-next-line no-unused-vars
2496async function buildAutoBlocks(main) {
2497  try {
2498    let template = toClassName(getMetadata('template'));
2499    if ((window.location.pathname.startsWith('/blog/') || window.location.pathname.startsWith('/ca/blog/') || window.location.pathname.startsWith('/uk/blog/'))&& !template) template = 'blog';
2500    template = resolveTemplateAlias(template);
2501
2502    const templates = [
2503      'blog',
2504      'integrations-listing',
2505      'content-library',
2506      'webinar',
2507      'product-updates',
2508      'live-demo-webinar-lp',
2509      'press-release',
2510      'awards',
2511      'in-the-news',
2512      'hr-glossary',
2513      'hrvs-session',
2514      'plan-migration-page',
2515    ];
2516    if (templates.includes(template)) {
2517      const mod = await import(`./${template}.js`);
2518      if (mod.default) {
2519        await mod.default(main);
2520      }
2521    }
2522
2523    if (
2524      template === 'resources-guides' ||
2525      template === 'performance-reviews'
2526    ) {
2527      buildPageHeader(main, template);
2528    }
2529
2530    if (isContentLibraryArticle()) {
2531      buildContentLibraryBreadcrumb(main);
2532      buildContentLibraryRelatedPosts(main);
2533    }
2534  } catch (error) {
2535    // eslint-disable-next-line no-console
2536    console.error('Auto Blocking failed', error);
2537  }
2538}
2539
2540function linkImages(main) {
2541  main.querySelectorAll(':scope > div > p > picture').forEach((picture) => {
2542    const p = picture.parentElement;
2543    const a = p.querySelector('a');
2544    if (a && a.textContent.includes('://')) {
2545      a.textContent = '';
2546      a.append(picture);
2547    }
2548  });
2549}
2550
2551/**
2552 * Decorates the main element.
2553 * @param {Element} main The main element
2554 */
2555export async function decorateMain(main) {
2556  linkImages(main);
2557
2558  await buildAutoBlocks(main);
2559  setCategory();
2560  decorateSections(main);
2561  decorateBlocks(main);
2562  decorateButtons(main);
2563
2564  // Wire any author-placed "Cookie Preferences" link to reopen the consent
2565  // menu on any page. Matches the vendor-neutral /cookie-preferences sentinel
2566  // or the legacy TrustArc URL, mirroring the footer's own decoration so
2567  // editorial can drop the link anywhere in a Google Doc.
2568  main
2569    .querySelectorAll(
2570      'a[href$="/cookie-preferences"], a[href^="https://consent-pref.trustarc.com/"]',
2571    )
2572    .forEach((link) => decorateCookiePreferencesLink(link));
2573
2574  sampleRUM.observe(main.querySelectorAll('div[data-block-name]'));
2575  window.setTimeout(() => sampleRUM.observe(main.querySelectorAll('picture > img')), 1000);
2576}
2577
2578/**
2579 * Returns the label used for tracking link clicks
2580 * @param {Element} element link element
2581 * @returns link label used for tracking converstion
2582 */
2583function getLinkLabel(element) {
2584  return element.title ? toClassName(element.title) : toClassName(element.textContent);
2585}
2586
2587function findConversionValue(parent, fieldName) {
2588  // Try to find the element by Id or Name
2589  const valueElement =
2590    document.getElementById(fieldName) || parent.querySelector(`[name='${fieldName}']`);
2591  if (valueElement) {
2592    return valueElement.value;
2593  }
2594  // Find the element by the inner text of the label
2595  return Array.from(parent.getElementsByTagName('label'))
2596    .filter((l) => l.innerText.trim().toLowerCase() === fieldName.toLowerCase())
2597    .map((label) => document.getElementById(label.htmlFor))
2598    .filter((field) => !!field)
2599    .map((field) => field.value)
2600    .pop();
2601}
2602
2603/**
2604 * Registers conversion listeners according to the metadata configured in the document.
2605 * @param {Element} parent element where to find potential event conversion sources
2606 * @param {string} path fragment path when the parent element is coming from a fragment
2607 * @param {Element} ctaElement element used as CTA for conversion
2608 */
2609export async function initConversionTracking(parent, path, ctaElement) {
2610  const conversionElements = {
2611    form: () => {
2612      // Track all forms
2613      parent.querySelectorAll('form').forEach((element) => {
2614        const section = element.closest('div.section');
2615        if (section && section.dataset.conversionValueField) {
2616          const cvField = section.dataset.conversionValueField.trim();
2617          // this will track the value of the element with the id specified in the "Conversion Element" field.
2618          // ideally, this should not be an ID, but the case-insensitive name label of the element.
2619          sampleRUM.convert(
2620            undefined,
2621            (cvParent) => findConversionValue(cvParent, cvField),
2622            element,
2623            ['submit']
2624          );
2625        }
2626        const formConversionName =
2627          element.dataset.conversionName ||
2628          (section ? section.dataset.conversionName : '') ||
2629          (ctaElement ? getMetadata(`conversion-name--${getLinkLabel(ctaElement)}-`) : '') ||
2630          getMetadata('conversion-name');
2631
2632        if (formConversionName) {
2633          sampleRUM.convert(formConversionName, undefined, element, ['submit']);
2634        } else {
2635          // if no conversion name is specified, use the form path or id
2636          sampleRUM.convert(path ? toClassName(path) : element.i
2636d, undefined, element, ['submit']);
2637        }
2638      });
2639    },
2640    link: () => {
2641      // track all links
2642      Array.from(parent.querySelectorAll('a[href]'))
2643        .map((element) => ({
2644          element,
2645          cevent:
2646            getMetadata(`conversion-name--${getLinkLabel(element)}-`) ||
2647            getMetadata('conversion-name') ||
2648            getLinkLabel(element),
2649        }))
2650        .forEach(({ element, cevent }) => {
2651          sampleRUM.convert(cevent, undefined, element, ['click']);
2652        });
2653    },
2654    'labeled-link': () => {
2655      // track only the links configured in the metadata
2656      const linkLabels = getMetadata('conversion-link-labels') || '';
2657      const trackedLabels = linkLabels
2658        .split(',')
2659        .map((p) => p.trim())
2660        .map(toClassName);
2661
2662      Array.from(parent.querySelectorAll('a[href]'))
2663        .filter((element) => trackedLabels.includes(getLinkLabel(element)))
2664        .map((element) => ({
2665          element,
2666          cevent:
2667            getMetadata(`conversion-name--${getLinkLabel(element)}-`) ||
2668            getMetadata('conversion-name') ||
2669            getLinkLabel(element),
2670        }))
2671        .forEach(({ element, cevent }) => {
2672          sampleRUM.convert(cevent, undefined, element, ['click']);
2673        });
2674    },
2675  };
2676
2677  const declaredConversionElements = getMetadata('conversion-element')
2678    ? getMetadata('conversion-element')
2679        .split(',')
2680        .map((ce) => toClassName(ce.trim()))
2681    : [];
2682
2683  Object.keys(conversionElements)
2684    .filter((ce) => declaredConversionElements.includes(ce))
2685    .forEach((cefn) => conversionElements[cefn]());
2686}
2687/**
2688 * Repurposed `loadMartech()` function to load Adobe Target's prehiding script.
2689 * This is gated through a  metadata field called 'target' and it must equal 'on' for this to get called.
2690 */
2691async function initPrehiding() {
2692
2693  const targetSelector = getMetadata('target-selector') || 'header, main, div';
2694  /* Adobe Target Prehiding Snippet */
2695  // eslint-disable-next-line
2696  ;(function (win, doc, style, timeout) {
2697    const STYLE_ID = 'alloy-prehiding';
2698
2699    function getParent() {
2700      return doc.head;
2701    }
2702
2703    function addStyle(parent, id, def) {
2704      if (parent) {
2705        const styleElement = doc.createElement('style');
2706        styleElement.id = id;
2707        styleElement.innerText = def;
2708        parent.appendChild(styleElement);
2709      }
2710    }
2711
2712    function removeStyle(parent, id) {
2713      if (parent) {
2714        const styleElement = doc.getElementById(id);
2715        if (styleElement) {
2716          parent.removeChild(styleElement);
2717        }
2718      }
2719    }
2720
2721    addStyle(getParent(), STYLE_ID, style);
2722    setTimeout(() => {
2723      removeStyle(getParent(), STYLE_ID);
2724    }, timeout);
2725  }(window, document, `${targetSelector} {opacity: 0 !important}`, 3000));
2726}
2727
2728// eslint-disable-next-line no-unused-vars
2729async function setCSP() {
2730  const resp = await fetch(`/scripts/csp.json`);
2731  const json = await resp.json();
2732  const directives = Object.keys(json);
2733  const policy = directives.map((directive) => `${directive} ${json[directive].join(' ')}`).join('; ');
2734  const meta = document.createElement('meta');
2735  meta.setAttribute('http-equiv', 'Content-Security-Policy');
2736  meta.setAttribute('content', policy);
2737  document.addEventListener('securitypolicyviolation', (e) => sampleRUM('csperror', { source: `${e.documentURI}:${e.lineNumber}:${e.columnNumber}`, target: e.blockedURI }));
2738  document.head.appendChild(meta);
2739}
2740
2741const pluginContext = {
2742  buildBlock,
2743  getAllMetadata,
2744  getMetadata,
2745  loadCSS,
2746  loadScript,
2747  sampleRUM,
2748  toCamelCase,
2749  toClassName,
2750};
2751
2752/**
2753 * Dynamically injects the CHEQ bot-check tag once per session, gated on
2754 * advertising consent. On subsequent page views within the same session the
2755 * cached request ID is restored instead of making another network call.
2756 */
2757function loadCheqTag() {
2758  try {
2759    if (sessionStorage.getItem('cheq_session_done')) {
2760      if (typeof window.restoreCheqData === 'function') {
2761        window.restoreCheqData();
2762      }
2763      return;
2764    }
2765  } catch (e) { /* storage unavailable */ }
2766
2767  window.cheqConsentAllowed = true;
2768  const s = document.createElement('script');
2769  s.src = 'https://spoon.thebrightforks.com/i/47dc90ad568b2c4fffe90ca4469d6479.js';
2770  s.async = true;
2771  s.dataset.ch = 'cheq4ppc';
2772  s.className = 'ct_clicktrue_27244';
2773  s.dataset.jsonp = 'onCheqResponse';
2774  document.head.appendChild(s);
2775}
2776
2777/**
2778 * loads everything needed to get to LCP.
2779 */
2780async function loadEager(doc) {
2781
2782  // Hold animated elements before anything can paint. Applied unconditionally:
2783  // block-level animation classes are in the delivered HTML, but section
2784  // `style:` metadata only becomes a class during decorateMain, so a query here
2785  // would miss those. The rule is inert on pages with no animated elements.
2786  holdAnimated();
2787
2788  // Add skip-to-main link for keyboard accessibility (WCAG 2.4.1)
2789  const skipLink = document.createElement('a');
2790  skipLink.href = '#main-content';
2791  skipLink.className = 'skip-to-main';
2792  skipLink.textContent = 'Skip to main content';
2793  document.body.insertBefore(skipLink, document.body.firstChild);
2794
2795  // Add id to main element for skip link target
2796  const mainElement = doc.querySelector('main');
2797  if (mainElement && !mainElement.id) {
2798    mainElement.id = 'main-content';
2799  }
2800
2801  // Load header FIRST for performance improvement
2802  const header = doc.querySelector('header');
2803  if (header) {
2804    const queryParams = new Proxy(new URLSearchParams(window.location.search), {
2805      get: (searchParams, prop) => searchParams.get(prop),
2806    });
2807    if (queryParams.header === 'meganav') header.classList.add('header-meganav');
2808    // Start header loading immediately - don't await to avoid blocking
2809    loadHeader(header);
2810  }
2811
2812  // Wait for consent to be resolved before loading consent-gated scripts.
2813  // Skip consent gate entirely in the block renderer iframe — the CMP never
2814  // loads there so the callback would hang forever, blocking decorateMain.
2815  if (window.location.href !== 'about:srcdoc') {
2816    await new Promise((resolve) => {
2817      window.addConsentListener(async () => {
2818        // Only load Adobe Alloy when advertising consent is granted to avoid setting cookies (AMCV, kndctr_*, mbox) before consent
2819        let alloyLoadedPromise;
2820        if (isAdvertisingCookieAllowed()) {
2821          alloyLoadedPromise = initWebSDK('./alloy.min.js', getAlloyConfiguration(document));
2822          alloyLoadedPromise.then(() => {
2823            window.analyticsLoaded = true;
2824            window.trackingQueue.processQueue();
2825          });
2826
2827          if (getMetadata('target') === 'on') {
2828            await initPrehiding();
2829            alloyLoadedPromise.then(() => getAndApplyRenderDecisions());
2830          }
2831        } else {
2832          if (!window.trackingQueue) {
2833            window.trackingQueue = new AnalyticsQueue();
2834          }
2835          if (!window.alloy) {
2836            window.alloy = () => Promise.resolve();
2837            window.alloy.q = [];
2838          }
2839          alloyLoadedPromise = Promise.resolve();
2840        }
2841
2842        loadCheqTag();
2843
2844        if (getMetadata('experiment')
2845          || getMetadata('experiment-control')
2846          || Object.keys(getAllMetadata('campaign')).length
2847          || Object.keys(getAllMetadata('audience')).length) {
2848
2849          if (isFunctionalCookieAllowed()) {
2850            // eslint-disable-next-line import/no-relative-packages
2851            const { loadEager: runEager } = await import('../plugins/experience-decisioning/src/index.js');
2852            await runEager.call(pluginContext, { audiences: AUDIENCES });
2853
2854            // Redirect to experiment-control when variants are accessed directly
2855            if (getMetadata('experiment-control')) {
2856              const url = new URL(getMetadata('experiment-control'));
2857              if (window.location.pathname !== url.pathname) {
2858                if (!sessionStorage.getItem('served-experiment')) {
2859                  window.location.replace(url.pathname + window.location.search);
2860                } else {
2861                  sessionStorage.removeItem('served-experiment');
2862                }
2863              }
2864            }
2865          }
2866        }
2867
2868        resolve();
2869      });
2870    });
2871  }
2872  
2873  decorateTemplateAndTheme();
2874  loadTemplateCSS();
2875  
2876  document.documentElement.lang = 'en';   // todo - figure out what this is used for 
2877  
2878  const main = doc.querySelector('main');
2879  
2880  if (main) {    
2881    
2882    await decorateMain(main);
2883     
2884    // todo - can this be refactored?
2885    if (window.innerWidth >= 900)
2886      loadCSS(`${window.hlx.codeBasePath}/styles/fonts/early-fonts.css`);
2887    if (window.lazyStylesLoaded) {
2888      loadCSS(`${window.hlx.codeBasePath}/styles/fonts/early-fonts.css`);
2889      loadCSS(`${window.hlx.codeBasePath}/styles/lazy-styles.css`);
2890    }
2891    
2892    if(window.requestAnimationFrame){
2893      await new Promise((res) => {
2894        window.requestAnimationFrame(async () => {
2895          await waitForLCP(LCP_BLOCKS);
2896          res();
2897        });
2898      });      
2899    } else {
2900      await waitForLCP(LCP_BLOCKS);     
2901    }
2902  }
2903  
2904  // Header loading moved to beginning of loadEager() for maximum performance gain
2905}
2906
2907/**
2908 * Schema markup functions
2909 */
2910function createProductSchemaMarkup() {
2911  const pageTitle = document.querySelector('h1').textContent;
2912  const pageUrl = document.querySelector('link[rel="canonical"]').getAttribute('href');
2913  const socialImage = document.querySelector('meta[property="og:image"]').getAttribute('content');
2914
2915  const quoteAuthorElement = document.querySelector('.product-schema p:last-of-type');
2916  let quoteAuthor = '';
2917  if (quoteAuthorElement) quoteAuthor = quoteAuthorElement.textContent;
2918
2919  const quoteTextElement = document.querySelector('.product-schema div div p:first-of-type');
2920  let quoteText = '';
2921  if (quoteTextElement) quoteText = quoteTextElement.textContent.replace(/["]+/g, '');
2922
2923  const pageDescription = document
2924    .querySelector('meta[property="og:description"]')
2925    .getAttribute('content');
2926  const quotePublishDate = document.lastModified;
2927  const productSchema = {
2928    '@context': 'http://schema.org/',
2929    '@type': 'Product',
2930    name: pageTitle,
2931    url: pageUrl,
2932    image: socialImage,
2933    description: pageDescription,
2934    brand: 'Bamboohr',
2935    aggregateRating: {
2936      '@type': 'aggregateRating',
2937      ratingValue: '4.3',
2938      reviewCount: '593',
2939    },
2940    review: [
2941      {
2942        '@type': 'Review',
2943        author: quoteAuthor,
2944        datePublished: quotePublishDate,
2945        reviewBody: quoteText,
2946      },
2947    ],
2948  };
2949  const $productSchema = document.createElement('script');
2950  $productSchema.innerHTML = JSON.stringify(productSchema, null, 2);
2951  $productSchema.setAttribute('type', 'application/ld+json');
2952  const $head = document.head;
2953  $head.append($productSchema);
2954}
2955
2956function createVideoObjectSchemaMarkup() {
2957  const videoName = document.querySelector('h1').textContent;
2958  const wistiaThumb = getMetadata('wistia-video-thumbnail');
2959  const wistiaVideoId = getMetadata('wistia-video-id');
2960  const wistiaVideoUrl = `https://fast.wistia.net/embed/iframe/${wistiaVideoId}`;
2961  const videoDescription = document
2962    .querySelector('meta[property="og:description"]')
2963    .getAttribute('content');
2964  const videoUploadDate = document.lastModified;
2965  const videoObjectSchema = {
2966    '@context': 'http://schema.org/',
2967    '@type': 'VideoObject',
2968    name: videoName,
2969    thumbnailUrl: wistiaThumb,
2970    embedUrl: wistiaVideoUrl,
2971    uploadDate: videoUploadDate,
2972    description: videoDescription,
2973  };
2974  const $videoObjectSchema = document.createElement('script');
2975  $videoObjectSchema.innerHTML = JSON.stringify(videoObjectSchema, null, 2);
2976  $videoObjectSchema.setAttribute('type', 'application/ld+json');
2977  const $head = document.head;
2978  $head.append($videoObjectSchema);
2979}
2980
2981function createFaqPageSchemaMarkup() {
2982  const faqPageSchema = {
2983    '@context': 'https://schema.org',
2984    '@type': 'FAQPage',
2985    mainEntity: [],
2986  };
2987  document.querySelectorAll('.faq-page-schema .accordion').forEach((tab) => {
2988    const q = tab.querySelector('h2').textContent.trim();
2989    const a = tab
2990      .querySelector('.tabs-content')
2991      .textContent.replace(/(\n|\n|\r)/gm, '')
2992      .trim();
2993    if (q && a) {
2994      faqPageSchema.mainEntity.push({
2995        '@type': 'Question',
2996        name: q,
2997        acceptedAnswer: {
2998          '@type': 'Answer',
2999          text: a,
3000        },
3001      });
3002    }
3003  });
3004  const $faqPageSchema = document.createElement('script');
3005  $faqPageSchema.innerHTML = JSON.stringify(faqPageSchema, null, 2);
3006  $faqPageSchema.setAttribute('type', 'application/ld+json');
3007  const $head = document.head;
3008  $head.append($faqPageSchema);
3009}
3010
3011/**
3012 * Gets schema markup for the current page from the JSON file
3013 * @param {string} jsonPath - Path to the JSON file containing schema markup data
3014 * @returns {Promise<Array>} - Tuple containing [faqSchema, webPageSchema]
3015 */
3016async function getSchemaForCurrentPage(jsonPath) {
3017  try {
3018    const currentPath = window.location.pathname;
3019    let schemaCache;
3020    try {
3021      const cachedData = sessionStorage.getItem('bamboohr_schema_cache');
3022      schemaCache = cachedData ? JSON.parse(cachedData) : null;
3023    } catch (e) {
3024      schemaCache = null;
3025    }
3026    
3027    if (!schemaCache) {
3028      const response = await fetch(jsonPath);
3029      if (!response.ok) return [null, null];
3030      
3031      const jsonData = await response.json();
3032      const schemaData = jsonData.data || [];
3033      
3034      // Create a path-based lookup map for faster access O(1)
3035      schemaCache = {};
3036      
3037      schemaData.forEach(entry => {
3038        if (entry["Original Url"]) {
3039          let path;
3040          try {
3041            if (entry["Original Url"].startsWith('http')) {
3042              const url = new URL(entry["Original Url"]);
3043              path = url.pathname;
3044            } else {
3045              path = entry["Original Url"];
3046            }
3047            
3048            if (!path.startsWith('/')) {
3049              path = `/${path}`;
3050            }
3051            
3052            schemaCache[path] = {
3053              "FAQ Schema": entry["FAQ Schema"] || null,
3054              "WebPage Schema": entry["WebPage Schema"] || null
3055            };
3056          } catch (e) {
3057            // Safe logging: pass variables as separate arguments instead of using template literals
3058            // eslint-disable-next-line no-console
3059            console.error('Error processing URL:', entry["Original Url"], e);
3060          }
3061        }
3062      });
3063      
3064      // Save the cache to sessionStorage for future page loads
3065      try {
3066        // Consent (Functional): managed by DataGrail
3067        sessionStorage.setItem('bamboohr_schema_cache', JSON.stringify(schemaCache));
3068      } catch (e) {
3069        // eslint-disable-next-line no-console
3070        console.warn('Failed to save schema cache to sessionStorage:', e);
3071      }
3072    }
3073    
3074    // Direct path lookup O(1)
3075    if (schemaCache[currentPath]) {
3076      const entry = schemaCache[currentPath];
3077      return [entry["FAQ Schema"], entry["WebPage Schema"]];
3078    }
3079    
3080    // No match found
3081    return [null, null];
3082  } catch (error) {
3083    // Safe logging: consistent with other error handling
3084    // eslint-disable-next-line no-console
3085    console.error('Error getting schema from JSON:', error);
3086    return [null, null];
3087  }
3088}
3089
3090/**
3091 * Applies schema markup to the page
3092 * @param {string} schemaJson - JSON string containing schema markup
3093 * @param {string} schemaType - Type of schema being applied (for logging)
3094 */
3095function applySchemaMarkup(schemaJson, schemaType) {
3096  if (!schemaJson || !schemaJson.trim()) return;
3097  
3098  try {
3099    const schema = JSON.parse(schemaJson);
3100    // checks for array of schemas since it's best practice to have multiple schemas on a page instead of one large schema
3101    if (Array.isArray(schema)) {
3102      schema.forEach(item => {
3103        const schemaScript = document.createElement('script');
3104        schemaScript.setAttribute('type', 'application/ld+json');
3105        schemaScript.innerHTML = JSON.stringify(item, null, 2);
3106        document.head.appendChild(schemaScript);
3107      });
3108    } else {
3109      const schemaScript = document.createElement('script');
3110      schemaScript.setAttribute('type', 'application/ld+json');
3111      schemaScript.innerHTML = JSON.stringify(schema, null, 2);
3112      document.head.appendChild(schemaScript);
3113    }
3114  } catch (e) {
3115    // Safe logging: pass variables as separate arguments instead of using template literals
3116    // eslint-disable-next-line no-console
3117    console.error('Error parsing schema JSON:', schemaType, e);
3118  }
3119}
3120
3121/** True for numeric strings only; empty/whitespace excluded (Number('') is 0 in JS). */
3122function isNumericCompanyStatValue(str) {
3123  if (str == null || String(str).trim() === '') return false;
3124  return !Number.isNaN(Number(str));
3125}
3126
3127/**
3128 * Replaces `[key]` using an allowlist (longest keys first). Uses string ops only so we do not
3129 * build RegExp from index data (SAST ReDoS rules; keys still come from trusted `/company-stats.json`).
3130 *
3131 * @param {string} text
3132 * @param {string[]} keysSorted Longest-first
3133 * @param {Map<string, string>} keyToDisplay
3134 * @returns {string}
3135 */
3136function replaceBracketTokensWithCompanyStats(text, keysSorted, keyToDisplay) {
3137  let next = text;
3138  keysSorted.forEach((key) => {
3139    const val = keyToDisplay.get(key);
3140    if (val == null || val === '') return;
3141    const token = `[${key}]`;
3142    if (!next.includes(token)) return;
3143    next = next.split(token).join(val);
3144  });
3145  return next;
3146}
3147
3148/**
3149 * Resolves `[key]` placeholders in authored page HTML to values from `/company-stats.json`.
3150 *
3151 * Authors use square-bracket tokens (e.g. `[companyCount]`) for any block, including Title.
3152 * Only keys present in the sheet with non-blank `text` are replaced; unknown keys stay unchanged.
3153 *
3154 * Uses `readIndex` (cached). Non-200 responses leave `data` empty: placeholders stay visible, no throw.
3155 * Sheet rows with missing or blank `text` are skipped so the token remains until content fills the cell.
3156 *
3157 * Walks Text nodes only so inline elements (links, bold) are not flattened. Skips script/style/noscript/code/pre/textarea.
3158 * Invoked from `loadLazy` and `loadFragment` after blocks are decorated.
3159 *
3160 * @param {HTMLElement | null} main
3161 */
3162async function replaceCompanyStatVariableTagsInMain(main) {
3163  if (!main?.textContent?.includes('[')) return;
3164
3165  try {
3166    await readIndex('/company-stats.json', 'companyStats');
3167  } catch (error) {
3168    // eslint-disable-next-line no-console
3169    console.error('replaceCompanyStatVariableTagsInMain:', error);
3170    return;
3171  }
3172
3173  const companyStats = window.pageIndex?.companyStats?.data;
3174  if (!Array.isArray(companyStats)) return;
3175
3176  const keyToDisplay = new Map();
3177  companyStats.forEach((row) => {
3178    if (row?.key == null) return;
3179    const key = String(row.key).trim();
3180    if (key === '') return;
3181    const { text } = row;
3182    if (text == null) return;
3183    const trimmed = String(text).trim();
3184    if (trimmed === '') return; // no map entry → `[key]` left unchanged
3185    let display = trimmed;
3186    if (isNumericCompanyStatValue(trimmed)) {
3187      const n = parseFloat(trimmed);
3188      if (!Number.isFinite(n)) return;
3189      display = n.toLocaleString();
3190    }
3191    keyToDisplay.set(key, display);
3192  });
3193
3194  if (keyToDisplay.size === 0) return;
3195
3196  const keysSorted = Array.from(keyToDisplay.keys()).sort((a, b) => b.length - a.length);
3197
3198  // SHOW_TEXT keeps replacements inside individual text runs (preserves sibling elements).
3199  const walker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT, {
3200    acceptNode(node) {
3201      if (!node.parentElement) return NodeFilter.FILTER_REJECT;
3202      if (node.parentElement.closest('script, style, noscript, code, pre, textarea')) {
3203        return NodeFilter.FILTER_REJECT;
3204      }
3205      if (!node.nodeValue.includes('[')) return NodeFilter.FILTER_SKIP;
3206      return NodeFilter.FILTER_ACCEPT;
3207    },
3208  });
3209
3210  const nodes = [];
3211  let current = walker.nextNode();
3212  while (current) {
3213    nodes.push(current);
3214    current = walker.nextNode();
3215  }
3216
3217  nodes.forEach((textNode) => {
3218    const original = textNode.nodeValue;
3219    const next = replaceBracketTokensWithCompanyStats(original, keysSorted, keyToDisplay);
3220    if (next !== original) textNode.nodeValue = next;
3221  });
3222}
3223
3224const ALT_TEXT_MANIFEST_PATH = '/scripts/alt-text-manifest.json';
3225const ALT_TEXT_MEDIA_HASH_RE = /media_([a-f0-9]+)\./i;
3226
3227/**
3228 * Applies AI-generated alt text to any <img> in `main` that ships without an
3229 * alt attribute or with alt="". Looks each image's media_<hash> up in the
3230 * manifest at `/scripts/alt-text-manifest.json` (generated from the SEO
3231 * inventory by tools/alt-text-bulk-apply/build-manifest.js).
3232 *
3233 * No-ops if there are no alt-empty images on the page. Network failures
3234 * are logged and ignored.
3235 *
3236 * Implementation of ZZ-6558. See docs/zz-6558-spike-notes/README.md for why
3237 * we're applying alt text at runtime instead of writing back to source Docs.
3238 *
3239 * @param {HTMLElement} main
3240 */
3241async function applyAltTextManifest(main) {
3242  if (!main) return;
3243  const empties = main.querySelectorAll('img:not([alt]), img[alt=""]');
3244  if (!empties.length) return;
3245  const res = await fetch(`${window.hlx.codeBasePath}${ALT_TEXT_MANIFEST_PATH}`);
3246  if (!res.ok) return;
3247  const manifest = await res.json();
3248  let applied = 0;
3249  empties.forEach((img) => {
3250    const m = ALT_TEXT_MEDIA_HASH_RE.exec(img.getAttribute('src') || '');
3251    if (!m) return;
3252    const alt = manifest[m[1]];
3253    if (alt) {
3254      img.setAttribute('alt', alt);
3255      applied += 1;
3256    }
3257  });
3258  if (applied && window.hlx?.lighthouse !== true) {
3259    // eslint-disable-next-line no-console
3260    console.debug(`[alt-text] applied ${applied}/${empties.length} from manifest`);
3261  }
3262}
3263
3264/**
3265 * loads everything that doesn't need to be delayed.
3266 */
3267async function loadLazy(doc) {
3268  // eslint-disable-next-line no-use-before-define
3269  loadDelayedOnClick();
3270
3271  // Header loading moved to loadEager() for performance
3272  const main = doc.querySelector('main');
3273  await loadBlocks(main);
3274  decorateIcons(main);
3275
3276  // `[key]` company stats → `/company-stats.json` (after main HTML is in the DOM)
3277  await replaceCompanyStatVariableTagsInMain(main);
3278
3279  // Apply AI-generated alt text to images that ship without one (ZZ-6558).
3280  // Reads /scripts/alt-text-manifest.json keyed by media_<hash>. Skips the
3281  // fetch entirely on pages with no alt-empty images.
3282  applyAltTextManifest(main).catch((e) => {
3283    // eslint-disable-next-line no-console
3284    console.error('[alt-text] manifest pass failed', e);
3285  });
3286
3287  // Author-driven GSAP animations (ZZ-6500). Fire-and-forget — runtime bails
3288  // early on prefers-reduced-motion or when no class on the page matches the
3289  // sheet, so this is cheap on pages without animations.
3290  import('./animations/runtime.js')
3291    // beforeApply releases the hold in the same task that creates the tweens, so
3292    // GSAP reads the real end state rather than the held one. .finally is the
3293    // backstop for every path that never reaches the tweens at all.
3294    .then(({ initAnimations }) => initAnimations(main, {
3295      beforeApply: revealAnimated,
3296      shouldAnimate: shouldAnimateElement,
3297    }))
3298    // eslint-disable-next-line no-console
3299    .catch((e) => console.error('[animations] runtime failed to load', e))
3300    .finally(revealAnimated);
3301
3302  // Initialize all page test IDs using TestIdManager
3303  testIdManager.initializePageTestIds(doc);
3304
3305  const { hash } = window.location;
3306  const element = hash ? main.querySelector(hash) : false;
3307  if (hash && element) {
3308    // first scroll gets lazy-loaded elements (like wistia) to start loading
3309    element.scrollIntoView();
3310    // re-correct scroll position as lazy-loaded elements shift the layout:
3311    // checks every 500ms whether the target element has drifted away from the
3312    // top of the viewport (> 5px tolerance). if it has, re-scroll to it.
3313    // stops after 4 attempts (2s total) to avoid interfering with user scrolling.
3314    let retries = 4;
3315    const correct = setInterval(() => {
3316      // getBoundingClientRect().top is relative to the viewport, so ~0 means
3317      // the element is correctly positioned at the top. a non-zero value means
3318      // layout shifts have pushed it away and we need to correct.
3319      const dist = Math.abs(element.getBoundingClientRect().top);
3320      if (dist > 5) element.scrollIntoView();
3321      retries -= 1;
3322      if (retries <= 0) clearInterval(correct);
3323    }, 500);
3324  }
3325
3326  /**
3327   * Calls the Schema markup functions
3328   */
3329  const schemaJsonPath = '/seo/page-schema.json';
3330  
3331  const [faqSchema, webPageSchema] = await getSchemaForCurrentPage(schemaJsonPath);
3332  
3333  if (getMetadata('schema')) {
3334    const schemaVals = getMetadata('schema').split(',');
3335    schemaVals.forEach((val) => {
3336      switch (val.trim()) {
3337        case 'Product':
3338          createProductSchemaMarkup();
3339          break;
3340        case 'VideoObject':
3341          createVideoObjectSchemaMarkup();
3342          break;
3343        case 'FAQPage':
3344          if (faqSchema) {
3345            applySchemaMarkup(faqSchema, 'FAQ Schema');
3346          } else {
3347            createFaqPageSchemaMarkup();
3348          }
3349          break;
3350        default:
3351          break;
3352      }
3353    });
3354  } else if (faqSchema) {
3355    applySchemaMarkup(faqSchema, 'FAQ Schema');
3356  }
3357  
3358  if (webPageSchema) {
3359    applySchemaMarkup(webPageSchema, 'WebPage Schema');
3360  }
3361
3362  // Header loading moved to loadEager() - load footer here
3363  loadFooter(doc.querySelector('footer'));
3364
3365  loadCSS(`${window.hlx.codeBasePath}/styles/fonts/early-fonts.css`);
3366  loadCSS(`${window.hlx.codeBasePath}/styles/lazy-styles.css`);
3367  if (!window.location.hostname.includes('localhost'))
3368    window.lazyStylesLoaded = true;
3369  addFavIcon('https://www.bamboohr.com/favicon.ico');
3370
3371  if (document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]')
3372    || document.head.querySelector('[property^="campaign:"],[property^="audience:"]')
3373    || document.querySelector('.section[class*="experiment-"],.section[class*="audience-"],.section[class*="campaign-"]')
3374    || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i))
3375    && (window.location.hostname.endsWith('hlx.page') || window.location.hostname === ('localhost'))) {
3376    // eslint-disable-next-line import/no-relative-packages
3377    const { loadLazy: runLazy } = await import('../plugins/experimentation/src/index.js');
3378    await runLazy.call(pluginContext, { audiences: AUDIENCES });
3379  }
3380
3381  sampleRUM('lazy');
3382  // Header await removed - header loads in loadEager() now
3383  initConversionTracking(document);
3384}
3385
3386function loadDelayedOnClick() {
3387  // eslint-disable-next-line no-use-before-define
3388  document.body.addEventListener('click', handleLoadDelayed);
3389}
3390
3391async function handleLoadDelayed() {
3392  if (!window.hlx.delayedJSLoaded) {
3393    window.hlx.delayedJSLoaded = true;
3394    // eslint-disable-next-line import/no-cycle
3395    import('./delayed.js').then((mod) => {
3396      window.setTimeout(() => mod.default(), 2000);
3397      document.body.removeEventListener('click', handleLoadDelayed);
3398    });
3399  }
3400}
3401
3402/**
3403 * loads everything that happens a lot later, without impacting
3404 * the user experience.
3405 */
3406function loadDelayed() {
3407  const testPaths = [
3408    '/c0/'
3409  ];
3410  const isOnTestPath = testPaths.includes(window.location.pathname);
3411
3412  if (!isOnTestPath) handleLoadDelayed(); // import without delay (for testing page performance)
3413  else if (!window.hlx.performance) window.setTimeout(() => handleLoadDelayed(), 4000);
3414
3415  // load anything that can be postponed to the latest here
3416  
3417  // Load Send a Lead form validation
3418  if (window.location.pathname.includes('send-a-lead') || 
3419      document.querySelector('form[data-action="/integrations/forms/send-a-lead"]') ||
3420      document.querySelector('input[name="repName"]')) {
3421    import('./send-a-lead-form.js').catch(() => {
3422      // Error loading Send a Lead form validation - silently continue
3423    });
3424  }
3425}
3426
3427async function loadInitSideKick() {
3428  if (!window.hlx.sideKickLoaded) {
3429    window.hlx.sideKickLoaded = true;
3430    // eslint-disable-next-line import/no-cycle
3431    import('../milo-utils/utils.js').then((mod) => {
3432      mod.initSidekick();
3433    });
3434    import('../tools/sidekick/plugins/localization/localization-notify.js').then((mod) => {
3435      mod.default();
3436    })
3437  }
3438}
3439
3440export async function loadFragment(path) {
3441  const resp = await fetch(`${path}.plain.html`);
3442  const main = document.createElement('main');
3443  if (resp.ok) {
3444    main.innerHTML = await resp.text();
3445    await decorateMain(main);
3446    await loadBlocks(main);
3447    decorateIcons(main);
3448    await replaceCompanyStatVariableTagsInMain(main); // same as loadLazy for fragment roots
3449  }
3450  return main;
3451}
3452
3453export function lockBody() {
3454  const bs = document.body.style;
3455  bs.position = 'fixed';
3456  bs.top = `-${window.scrollY}px`;
3457  bs.left = 0;
3458  bs.right = 0;
3459}
3460
3461export function unlockBody() {
3462  const bs = document.body.style;
3463  const scrollY = bs.top;
3464  bs.position = '';
3465  bs.top = '';
3466  bs.left = '';
3467  bs.right = '';
3468  window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
3469}
3470
3471// eslint-disable-next-line camelcase
3472export function newsletterSubscribe(email, UTM_Medium_Capture__c, UTM_Source_Capture__c, UTM_Campaign_Capture__c, UTM_Content_Capture__c, UTM_Term_Capture__c, UTM_Landing_Page_Capture__c, UTM_Conversion_Page_Capture__c) {
3473  const action = 'https://www.bamboohr.com/ajax/blog-newsletter-form.php';
3474  // eslint-disable-next-line camelcase
3475  const body = `email=${encodeURIComponent(email)}&UTM_Medium_Capture__c=${encodeURIComponent(UTM_Medium_Capture__c)}&UTM_Source_Capture__c=${encodeURIComponent(UTM_Source_Capture__c)}&UTM_Campaign_Capture__c=${encodeURIComponent(UTM_Campaign_Capture__c)}&UTM_Content_Capture__c=${encodeURIComponent(UTM_Content_Capture__c)}&UTM_Term_Capture__c=${encodeURIComponent(UTM_Term_Capture__c)}&UTM_Landing_Page_Capture__c=${encodeURIComponent(UTM_Landing_Page_Capture__c)}&UTM_Conversion_Page_Capture__c=${encodeURIComponent(UTM_Conversion_Page_Capture__c)}`;
3476  fetch(action, {
3477    method: 'POST',
3478    mode: 'no-cors',
3479    headers: {
3480      'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
3481    },
3482    body,
3483  });
3484}
3485
3486/**
3487* Stores UTM parameters in sessionStorage if they are not already set
3488*/
3489const setUtmsInSession = () => {
3490  const searchString = window.location.search;
3491  const urlParams = new URLSearchParams(searchString);
3492  const utmFields = {
3493    utm_source: urlParams.get("utm_source"),
3494    utm_medium: urlParams.get("utm_medium"),
3495    utm_campaign: urlParams.get("utm_campaign"),
3496    utm_content: urlParams.get("utm_content"),
3497    utm_term: urlParams.get("utm_term"),
3498    gclid: urlParams.get("gclid"),
3499    msclkid: urlParams.get("msclkid"),
3500    landing_page: `${window.location.origin}${window.location.pathname}`,
3501    referrer: document.referrer,
3502    bhr_pvid: urlParams.get("bhr_pvid"),
3503    bhr_team: urlParams.get("bhr_team"),
3504    uid: urlParams.get("uid"),
3505    cid: urlParams.get("cid"),
3506    eid: urlParams.get("eid"),
3507    full_query_string: searchString.startsWith('?') ? searchString.substring(1) : searchString,
3508    pse_ad_group: urlParams.get("PSE_Ad_Group__c"),
3509    pse_keyword: urlParams.get("PSE_Keyword__c"),
3510    pse_match_type: urlParams.get("PSE_Match_Type__c"),
3511    utm_ct: urlParams.get("utm_ct"),
3512    referral_code: urlParams.get("referral_code"),
3513    routing_owner_id: urlParams.get("routing_owner_id")
3514  };
3515
3516  // Consent (Advertising): managed by DataGrail
3517  Object.entries(utmFields).forEach(([key, value]) => {
3518      if (!sessionStorage.getItem(key) && value) {
3519          sessionStorage.setItem(key, value);
3520      }
3521  });
3522};
3523
3524window.addConsentListener(() => {
3525  setUtmsInSession();
3526});
3527
3528// Hidden fields Marketo populates from URL params at render time. Keys are
3529// hidden-input names; values are the sessionStorage keys written by
3530// setUtmsInSession.
3531const UTM_CAPTURE_FIELDS = {
3532  UTM_Source_Capture__c: 'utm_source',
3533  UTM_Medium_Capture__c: 'utm_medium',
3534  UTM_Campaign_Capture__c: 'utm_campaign',
3535  UTM_Content_Capture__c: 'utm_content',
3536  UTM_Term_Capture__c: 'utm_term',
3537  GCLID__c: 'gclid',
3538  Microsoft_Click_ID__c: 'msclkid',
3539  UTM_Landing_Page_Capture__c: 'landing_page',
3540  sessionReferrerCapture: 'referrer',
3541  UTM_Partner_Vendor_Capture__c: 'bhr_pvid',
3542  UTM_Team_Capture__c: 'bhr_team',
3543  Learning_Labs_UserID__c: 'uid',
3544  companyIDformfill: 'cid',
3545  Full_Query_String_Capture__c: 'full_query_string',
3546  Partner_EID__c: 'eid',
3547  PSE_Ad_Group__c: 'pse_ad_group',
3548  PSE_Keyword__c: 'pse_keyword',
3549  PSE_Match_Type__c: 'pse_match_type',
3550  UTM_CT_Capture__c: 'utm_ct',
3551  Referred_By_Code__c: 'referral_code',
3552  Routing_Owner_ID__c: 'routing_owner_id',
3553};
3554
3555// Hidden fields Marketo populates from cookies at render time. Keys are
3556// hidden-input names; values are the cookie names. ECID uses a null cookie
3557// name — it is included so clearMarketoSourcedHiddenFields wipes it, but
3558// addMarketoCookieHiddenFields skips it (null is falsy) since it is restored
3559// via alloy rather than a direct cookie read.
3560const MARKETO_COOKIE_FIELDS = {
3561  GCLID__c: 'gclid',
3562  Sam_F_Client_ID__c: '_ga',
3563  clickID: '_fbc',
3564  browserID: '_fbp',
3565  clickIDLI: 'li_fat_id',
3566  // null = must be cleared like other cookie fields, but restored via alloy
3567  // (see addConsentListener in form.js) rather than a direct cookie read
3568  ECID: null,
3569};
3570
3571export function addUtmParametersFromSessionStorage() {
3572  if (!isAdvertisingCookieAllowed()) return;
3573  document.querySelectorAll('input[type="hidden"]').forEach((input) => {
3574    const sessionKey = UTM_CAPTURE_FIELDS[input.name];
3575    if (sessionKey && (!input.value || input.value === 'null')) {
3576      const value = sessionStorage.getItem(sessionKey);
3577      if (value) input.value = value;
3578    }
3579  });
3580}
3581
3582/**
3583 * Populates Marketo cookie-sourced hidden fields from current cookie values.
3584 * Pairs with clearMarketoSourcedHiddenFields — call after consent fires so
3585 * fields wiped at render time get restored. Bails when advertising consent
3586 * isn't granted; the consent listener fires on resolve (granted OR denied),
3587 * so this guard is what keeps cookies from leaking back into the form.
3588 */
3589export function addMarketoCookieHiddenFields() {
3590  if (!isAdvertisingCookieAllowed()) return;
3591  document.querySelectorAll('input[type="hidden"]').forEach((input) => {
3592    const cookieName = MARKETO_COOKIE_FIELDS[input.name];
3593    if (cookieName && (!input.value || input.value === 'null')) {
3594      const cookieValue = readCookie(cookieName);
3595      if (cookieValue) input.value = cookieValue;
3596    }
3597  });
3598}
3599
3600/**
3601 * Clears Marketo-pre-populated hidden fields. Marketo's forms2.min.js fills
3602 * fields configured with InputSourceChannel = "url" or "cookie" at form render
3603 * time, bypassing our consent gate. Call this before consent is granted; the
3604 * matching populate functions will refill from sessionStorage / cookies once
3605 * consent fires.
3606 */
3607export function clearMarketoSourcedHiddenFields() {
3608  document.querySelectorAll('input[type="hidden"]').forEach((input) => {
3609    if (input.name in UTM_CAPTURE_FIELDS || input.name in MARKETO_COOKIE_FIELDS) {
3610      input.value = '';
3611    }
3612  });
3613}
3614
3615/**
3616 * Sets the UTM_Conversion_Page_Capture__c hidden input field to the page URL user filled out the form on.
3617 */
3618export function setUtmConversionPage() {
3619  const conversionInput = document.querySelector('input[name="UTM_Conversion_Page_Capture__c"]');
3620  if (conversionInput) conversionInput.value = `${window.location.origin}${window.location.pathname}`;
3621}
3622
3623export function insertNewsletterForm(elem, submitCallback) {
3624  const action = 'https://www.bamboohr.com/ajax/blog-newsletter-form.php';
3625  const relative = new URL(action).pathname;
3626  elem.querySelectorAll(`a[href="${action}"], a[href="${relative}"]`).forEach((a) => {
3627    const formDiv = document.createElement('div');
3628    formDiv.innerHTML = `
3629    <form class="nav-form" __bizdiag="96619420" __biza="WJ__">
3630      <input type="email" name="email" placeholder="Email Address" aria-label="email" autocomplete="off">
3631      <input type="hidden" name="UTM_Medium_Capture__c" value="">
3632      <input type="hidden" name="UTM_Source_Capture__c" value="">
3633      <input type="hidden" name="UTM_Campaign_Capture__c" value="">
3634      <input type="hidden" name="UTM_Content_Capture__c" value="">
3635      <input type="hidden" name="UTM_Term_Capture__c" value="">
3636      <input type="hidden" name="UTM_Landing_Page_Capture__c" value="">
3637      <input type="hidden" name="UTM_Conversion_Page_Capture__c" value="">
3638      <button class="">${a.textContent}</button>
3639    </form>
3640    `;
3641    const button = formDiv.querySelector('button');
3642    const input = formDiv.querySelector('input');
3643    button.addEventListener('click', (e) => {
3644      // eslint-disable-next-line camelcase
3645      const UTM_Medium_Capture__c = formDiv.querySelector('input[name="UTM_Medium_Capture__c"]').value;
3646      // eslint-disable-next-line camelcase
3647      const UTM_Source_Capture__c = formDiv.querySelector('input[name="UTM_Source_Capture__c"]').value;
3648      // eslint-disable-next-line camelcase
3649      const UTM_Campaign_Capture__c = formDiv.querySelector('input[name="UTM_Campaign_Capture__c"]').value;
3650      // eslint-disable-next-line camelcase
3651      const UTM_Content_Capture__c = formDiv.querySelector('input[name="UTM_Content_Capture__c"]').value;
3652      // eslint-disable-next-line camelcase
3653      const UTM_Term_Capture__c = formDiv.querySelector('input[name="UTM_Term_Capture__c"]').value;
3654      // eslint-disable-next-line camelcase
3655      const UTM_Landing_Page_Capture__c = formDiv.querySelector('input[name="UTM_Landing_Page_Capture__c"]').value;
3656      // eslint-disable-next-line camelcase
3657      const UTM_Conversion_Page_Capture__c = formDiv.querySelector('input[name="UTM_Conversion_Page_Capture__c"]').value;
3658      newsletterSubscribe(input.value, UTM_Medium_Capture__c, UTM_Source_Capture__c, UTM_Campaign_Capture__c, UTM_Content_Capture__c, UTM_Term_Capture__c, UTM_Landing_Page_Capture__c, UTM_Conversion_Page_Capture__c);
3659      e.preventDefault();
3660      submitCallback();
3661      input.value = '';
3662    });
3663    a.replaceWith(formDiv);
3664    // Wait for consent so setUtmsInSession populates sessionStorage before we read from it
3665    window.addConsentListener(() => {
3666      addUtmParametersFromSessionStorage();
3667    });
3668  });
3669}
3670
3671/**
3672 * Return whether or not this element has a class that starts with the given string
3673 * @param {HtmlElement} elem
3674 * @param {string} classNameStart
3675 * @returns {boolean}
3676 */
3677export function hasClassStartsWith(elem, classNameStart) {
3678  const classNames = [...elem.classList];
3679  let isClassStartsWith = false;
3680
3681  classNames.forEach((className) => {
3682    if (className.startsWith(classNameStart)) {
3683      isClassStartsWith = true;
3684    }
3685  });
3686
3687  return isClassStartsWith;
3688}
3689
3690/**
3691 * Gets array of parameterized values given a class that starts with a name
3692 * @param {string} className
3693 * @param {string} classNameStart
3694 * @return {string[]} Array of remaining items split on the hyphen (-)
3695 */
3696export function getValuesFromClassName(className, classNameStart) {
3697  const params = className.substring(classNameStart.length);
3698
3699  return params.split('-');
3700}
3701
3702/**
3703 * Creates an element with optional class and type
3704 * @param {string} elemType type of element to create
3705 * @param {...string} [cssClass] CSS class(es) to apply to element
3706 * @returns {Element}
3707 */
3708export function createElem(elemType, ...cssClass) {
3709  const elem = document.createElement(elemType);
3710  if (cssClass != null && cssClass.length) {
3711    elem.classList.add(...cssClass.join(' ').split(' '));
3712  }
3713
3714  return elem;
3715}
3716
3717/**
3718 * Add class to a block's parent.
3719 * @param {Element} block The block element
3720 * @param string parentClass The class to move from the block level to the parent level.
3721 */
3722export function addClassToParent(block, parentClass) {
3723  if (block.classList.contains(parentClass)) {
3724    block.parentElement.classList.add(parentClass);
3725    block.classList.remove(parentClass);
3726  }
3727}
3728
3729/**
3730 * Add class to a block's parent.
3731 * @param {Element} block The block element
3732 */
3733function addClassesToParent(block) {
3734  const classes = [
3735    'full-width',
3736    'med-width',
3737    'normal-width',
3738    'small-width',
3739    'medium-width',
3740    'extra-wide',
3741    'extra-small-width',
3742    'top-section-top-margin',
3743    'bottom-margin',
3744    'top-margin',
3745    'laptop-small-width',
3746    'variable-width',
3747  ];
3748  classes.some((c) => {
3749    const found = block.classList.contains(c);
3750    if (found) {
3751      block.parentElement.classList.add(c);
3752      block.classList.remove(c);
3753    }
3754    return found;
3755  });
3756}
3757
3758const params = new URLSearchParams(window.location.search);
3759if (params.get('performance')) {
3760  window.hlx.performance = true;
3761  import('./performance.js').then((mod) => {
3762    if (mod.default) mod.default();
3763  });
3764}
3765
3766/**
3767 * Registers the 'convert' function to `sampleRUM` which sends
3768 * variant and convert events upon conversion.
3769 * The function will register a listener for an element if listenTo parameter is provided.
3770 * listenTo supports 'submit' and 'click'.
3771 * If listenTo is not provided, the information is used to track a conversion event.
3772 */
3773sampleRUM.drain('convert', (cevent, cvalueThunk, element, listenTo = []) => {
3774  async function trackConversion(celement) {
3775    const MAX_SESSION_LENGTH = 1000 * 60 * 60 * 24 * 30; // 30 days
3776    try {
3777      // get all stored experiments from local storage (unified-decisioning-experiments)
3778      const experiments = JSON.parse(localStorage.getItem('unified-decisioning-experiments'));
3779      if (experiments) {
3780        Object.entries(experiments)
3781          .map(([experiment, { treatment, date }]) => ({ experiment, treatment, date }))
3782          .filter(({ date }) => Date.now() - new Date(date) < MAX_SESSION_LENGTH)
3783          .forEach(({ experiment, treatment }) => {
3784            // send conversion event for each experiment that has been seen by this visitor
3785            sampleRUM('variant', { source: experiment, target: treatment });
3786          });
3787      }
3788      // send conversion event
3789      const cvalue = typeof cvalueThunk === 'function' ? await cvalueThunk(element) : cvalueThunk;
3790      sampleRUM('convert', { source: cevent, target: cvalue, element: celement });
3791    } catch (e) {
3792      // eslint-disable-next-line no-console
3793      console.log('error reading experiments', e);
3794    }
3795  }
3796
3797  function registerConversionListener(elements) {
3798    // if elements is an array or nodelist, register a conversion event for each element
3799    if (Array.isArray(elements) || elements instanceof NodeList) {
3800      elements.forEach((e) => registerConversionListener(e, listenTo, cevent, cvalueThunk));
3801    } else {
3802      listenTo.forEach((eventName) =>
3803        element.addEventListener(eventName, (e) => trackConversion(e.target))
3804      );
3805    }
3806  }
3807
3808  if (element && listenTo.length) {
3809    registerConversionListener(element, listenTo, cevent, cvalueThunk);
3810  } else {
3811    trackConversion(element, cevent, cvalueThunk);
3812  }
3813});
3814
3815// Declare conversionEvent, bufferTimeoutId and tempConversionEvent outside the convert function to persist them for buffering between
3816// subsequent convert calls
3817let bufferTimeoutId;
3818let conversionEvent;
3819let tempConversionEvent;
3820
3821// call upon conversion events, sends them to alloy
3822sampleRUM.always.on('convert', async (data) => {
3823  const { element } = data;
3824  // eslint-disable-next-line no-undef
3825  if (element && alloy) {
3826    if (element.tagName === 'FORM') {
3827      conversionEvent = {
3828        event: 'Form Complete',
3829        ...(data.source ? { conversionName: data.source } : {}),
3830        ...(data.target ? { conversionValue: data.target } : {}),
3831      };
3832
3833      if (
3834        conversionEvent.event === 'Form Complete' &&
3835        (data.target === undefined || data.source === undefined)
3836      ) {
3837        // If a buffer has already been set and tempConversionEvent exists, merge the two conversionEvent objects to send to alloy
3838        if (bufferTimeoutId !== undefined && tempConversionEvent !== undefined) {
3839          conversionEvent = { ...tempConversionEvent, ...conversionEvent };
3840        } else {
3841          // Temporarily hold the conversionEvent object until the timeout is complete
3842          tempConversionEvent = { ...conversionEvent };
3843
3844          // If there is partial form conversion data, set the timeout buffer to wait for additional data
3845          bufferTimeoutId = setTimeout(async () => {
3846            await analyticsTrackFormSubmission(element, {
3847              conversion: {
3848                ...(conversionEvent.conversionName
3849                  ? { conversionName: `${conversionEvent.conversionName}` }
3850                  : {}),
3851                ...(conversionEvent.conversionValue
3852                  ? { conversionValue: `${conversionEvent.conversionValue}` }
3853                  : {}),
3854              },
3855            });
3856            tempConversionEvent = undefined;
3857            conversionEvent = undefined;
3858          }, 100);
3859        }
3860      }
3861    } else if (element.tagName === 'A') {
3862      conversionEvent = {
3863        event: 'Link Click',
3864        ...(data.source ? { conversionName: data.source } : {}),
3865        ...(data.target ? { conversionValue: data.target } : {}),
3866      };
3867      await analyticsTrackLinkClicks(element, 'other', {
3868        conversion: {
3869          ...(conversionEvent.conversionName
3870            ? { conversionName: `${conversionEvent.conversionName}` }
3871            : {}),
3872          ...(conversionEvent.conversionValue
3873            ? { conversionValue: `${conversionEvent.conversionValue}` }
3874            : {}),
3875        },
3876      });
3877      tempConversionEvent = undefined;
3878      conversionEvent = undefined;
3879    }
3880  }
3881});
3882
3883/**
3884 * Converts a Google Sheets serial number to a date string (YYYY-MM-DD)
3885 * without any time zone adjustments.
3886 * This function ensures consistent date handling across all time zones.
3887 * 
3888 * @param {number} serialNumber - The Google Sheets serial number.
3889 * @returns {string} - The date in YYYY-MM-DD format.
3890 */
3891export function convertSerialNumberToDateNoTZ(serialNumber) {
3892  // Constants for Google Sheets date conversion
3893  const millisecondsPerDay = 24 * 60 * 60 * 1000;
3894  
3895  // Create a Date object at UTC midnight for the Google Sheets epoch date
3896  const epochDate = new Date(Date.UTC(1899, 11, 30));
3897  
3898  // Calculate milliseconds and create a new UTC date
3899  const dateInMilliseconds = epochDate.getTime() + serialNumber * millisecondsPerDay;
3900  const utcDate = new Date(dateInMilliseconds);
3901  
3902  // Extract year, month, day from the UTC date
3903  const year = utcDate.getUTCFullYear();
3904  const month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
3905  const day = String(utcDate.getUTCDate()).padStart(2, '0');
3906  
3907  // Return formatted date string
3908  return `${year}-${month}-${day}`;
3909}
3910
3911/**
3912 * Converts a Google Sheets time value (fraction of a day) from Mountain Time (MT)
3913 * to a specified target timezone.
3914 * 
3915 * @param {number} sheetValue - The time value from Google Sheets (e.g., 0.4166667 for 10:00 AM).
3916 * @param {string} targetTimezone - The IANA timezone identifier (e.g., "America/Los_Angeles").
3917 * @returns {string} - The converted time in HH:mm:ss format.
3918 */
3919export function convertMountainTimeTo(sheetValue, targetTimezone) {
3920  const totalSeconds = sheetValue * 24 * 60 * 60;
3921  const hours = Math.floor(totalSeconds / 3600);
3922  const minutes = Math.floor((totalSeconds % 3600) / 60);
3923
3924  const currentDate = new Date();
3925  const baseDate = new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth(), currentDate.getUTCDate(), hours, minutes));
3926
3927  const timeZone = "America/Denver";
3928  const timeZoneOffset = new Date(baseDate.toLocaleString("en-US", { timeZone })).getHours() - baseDate.getUTCHours();
3929  const timeZoneOffsetMinutes = timeZoneOffset * 60;
3930
3931  const mtDate = new Date(baseDate.getTime() - timeZoneOffsetMinutes * 60 * 1000);
3932
3933  const targetTime = new Intl.DateTimeFormat("en-US", {
3934    timeZone: targetTimezone,
3935    hour: "2-digit",
3936    minute: "2-digit",
3937    hour12: true
3938  }).format(mtDate);
3939
3940  return targetTime;
3941}
3942
3943/**
3944 * Gets the user's timezone using browser detection
3945 * @returns {string} The user's IANA timezone identifier (e.g., "America/Chicago")
3946 */
3947export function getUserTimezone() {
3948  try {
3949    return Intl.DateTimeFormat().resolvedOptions().timeZone;
3950  } catch (error) {
3951    // Fallback to Mountain Time if detection fails
3952    // eslint-disable-next-line no-console
3953    console.warn('Unable to detect user timezone, falling back to Mountain Time:', error);
3954    return 'America/Denver';
3955  }
3956}
3957
3958/**
3959 * Determines if a user is in a US timezone and should see PT/ET format
3960 * @param {string} userTimezone - User's IANA timezone identifier
3961 * @returns {boolean} True if user should see PT/ET format instead of local time
3962 */
3963export function shouldShowPTET(userTimezone) {
3964  const usTimezones = [
3965    // Eastern Time
3966    'America/New_York',
3967    'America/Detroit',
3968    'America/Kentucky/Louisville',
3969    'America/Kentucky/Monticello',
3970    'America/Indiana/Indianapolis',
3971    'America/Indiana/Vincennes',
3972    'America/Indiana/Winamac',
3973    'America/Indiana/Marengo',
3974    'America/Indiana/Petersburg',
3975    'America/Indiana/Vevay',
3976    'America/Toronto', // Eastern Canada
3977    'America/Montreal',
3978    
3979    // Central Time
3980    'America/Chicago',
3981    'America/Indiana/Tell_City',
3982    'America/Indiana/Knox',
3983    'America/Menominee',
3984    'America/North_Dakota/Center',
3985    'America/North_Dakota/New_Salem',
3986    'America/North_Dakota/Beulah',
3987    'America/Winnipeg', // Central Canada
3988    
3989    // Mountain Time
3990    'America/Denver',
3991    'America/Boise',
3992    'America/Phoenix', // Arizona (no DST)
3993    'America/Edmonton', // Mountain Canada
3994    'America/Calgary',
3995    
3996    // Pacific Time
3997    'America/Los_Angeles',
3998    'America/Tijuana',
3999    'America/Vancouver', // Pacific Canada
4000    
4001    // Alaska & Hawaii
4002    'America/Anchorage',
4003    'America/Juneau',
4004    'America/Sitka',
4005    'America/Metlakatla',
4006    'America/Yakutat',
4007    'America/Nome',
4008    'America/Adak',
4009    'Pacific/Honolulu'
4010  ];
4011  
4012  return usTimezones.includes(userTimezone);
4013}
4014
4015/**
4016 * Checks if a date is in Daylight Saving Time for a given timezone
4017 * @param {Date} date - Date to check
4018 * @param {string} timezone - IANA timezone identifier
4019 * @returns {boolean} True if date is in DST
4020 */
4021function isDST(date, timezone) {
4022  const jan = new Date(date.getFullYear(), 0, 1);
4023  const jul = new Date(date.getFullYear(), 6, 1);
4024  
4025  const janOffset = new Date(jan.toLocaleString("en-US", { timeZone: timezone })).getTimezoneOffset();
4026  const julOffset = new Date(jul.toLocaleString("en-US", { timeZone: timezone })).getTimezoneOffset();
4027  const dateOffset = new Date(date.toLocaleString("en-US", { timeZone: timezone })).getTimezoneOffset();
4028  
4029  return dateOffset < Math.max(janOffset, julOffset);
4030}
4031
4032/**
4033 * Gets a user-friendly timezone abbreviation, preferring named abbreviations over GMT offsets
4034 * @param {string} userTimezone - User's IANA timezone identifier
4035 * @param {Date} date - Date object to check for DST
4036 * @returns {string} Timezone abbreviation
4037 */
4038export function getTimezoneAbbreviation(userTimezone, date) {
4039  // Comprehensive global timezone mappings for consistent user-friendly abbreviations
4040  const timezoneMap = {
4041    // Europe
4042    'Europe/London': isDST(date, userTimezone) ? 'BST' : 'GMT',
4043    'Europe/Paris': isDST(date, userTimezone) ? 'CEST' : 'CET',
4044    'Europe/Berlin': isDST(date, userTimezone) ? 'CEST' : 'CET',
4045    'Europe/Rome': isDST(date, userTimezone) ? 'CEST' : 'CET',
4046    'Europe/Madrid': isDST(date, userTimezone) ? 'CEST' : 'CET',
4047    'Europe/Amsterdam': isDST(date, userTimezone) ? 'CEST' : 'CET',
4048    'Europe/Brussels': isDST(date, userTimezone) ? 'CEST' : 'CET',
4049    'Europe/Vienna': isDST(date, userTimezone) ? 'CEST' : 'CET',
4050    'Europe/Zurich': isDST(date, userTimezone) ? 'CEST' : 'CET',
4051    'Europe/Stockholm': isDST(date, userTimezone) ? 'CEST' : 'CET',
4052    'Europe/Oslo': isDST(date, userTimezone) ? 'CEST' : 'CET',
4053    'Europe/Copenhagen': isDST(date, userTimezone) ? 'CEST' : 'CET',
4054    'Europe/Helsinki': isDST(date, userTimezone) ? 'EEST' : 'EET',
4055    'Europe/Warsaw': isDST(date, userTimezone) ? 'CEST' : 'CET',
4056    'Europe/Prague': isDST(date, userTimezone) ? 'CEST' : 'CET',
4057    'Europe/Budapest': isDST(date, userTimezone) ? 'CEST' : 'CET',
4058    'Europe/Istanbul': 'TRT', // Turkey Time (no DST since 2016)
4059    'Europe/Athens': isDST(date, userTimezone) ? 'EEST' : 'EET',
4060    'Europe/Moscow': 'MSK', // Moscow Standard Time (no DST since 2014)
4061    
4062    // North America
4063    'America/New_York': isDST(date, userTimezone) ? 'EDT' : 'EST',
4064    'America/Chicago': isDST(date, userTimezone) ? 'CDT' : 'CST',
4065    'America/Denver': isDST(date, userTimezone) ? 'MDT' : 'MST',
4066    'America/Los_Angeles': isDST(date, userTimezone) ? 'PDT' : 'PST',
4067    'America/Toronto': isDST(date, userTimezone) ? 'EDT' : 'EST',
4068    'America/Vancouver': isDST(date, userTimezone) ? 'PDT' : 'PST',
4069    'America/Mexico_City': isDST(date, userTimezone) ? 'CDT' : 'CST',
4070    'America/Phoenix': 'MST', // Arizona (no DST)
4071    'America/Anchorage': isDST(date, userTimezone) ? 'AKDT' : 'AKST',
4072    'Pacific/Honolulu': 'HST', // Hawaii (no DST)
4073    
4074    // Asia-Pacific
4075    'Asia/Tokyo': 'JST',        // Japan Standard Time
4076    'Asia/Seoul': 'KST',        // Korea Standard Time
4077    'Asia/Shanghai': 'CST',     // China Standard Time
4078    'Asia/Hong_Kong': 'HKT',    // Hong Kong Time
4079    'Asia/Singapore': 'SGT',    // Singapore Time
4080    'Asia/Bangkok': 'ICT',      // Indochina Time
4081    'Asia/Jakarta': 'WIB',      // Western Indonesian Time
4082    'Asia/Manila': 'PST',       // Philippine Standard Time
4083    'Asia/Kolkata': 'IST',      // India Standard Time
4084    'Asia/Calcutta': 'IST',     // India Standard Time (legacy Mumbai identifier)
4085    'Asia/Karachi': 'PKT',      // Pakistan Standard Time
4086    'Australia/Sydney': isDST(date, userTimezone) ? 'AEDT' : 'AEST',
4087    'Australia/Melbourne': isDST(date, userTimezone) ? 'AEDT' : 'AEST',
4088    'Australia/Perth': 'AWST',  // Western Australia (no DST)
4089    'Pacific/Auckland': isDST(date, userTimezone) ? 'NZDT' : 'NZST',
4090    
4091    // Middle East & Africa
4092    'Asia/Dubai': 'GST',        // Gulf Standard Time (UAE)
4093    'Asia/Riyadh': 'AST',       // Arabia Standard Time (Saudi Arabia)
4094    'Asia/Kuwait': 'AST',       // Arabia Standard Time (Kuwait)
4095    'Asia/Qatar': 'GST',        // Gulf Standard Time (Qatar)
4096    'Asia/Bahrain': 'AST',      // Arabia Standard Time (Bahrain)
4097    'Asia/Tehran': 'IRST',      // Iran Standard Time
4098    'Africa/Cairo': 'EET',      // Eastern European Time
4099    'Africa/Johannesburg': 'SAST', // South Africa Standard Time
4100    'Africa/Lagos': 'WAT',      // West Africa Time
4101    'Africa/Nairobi': 'EAT',    // East Africa Time
4102    
4103    // South America
4104    'America/Sao_Paulo': 'BRT', // Brasilia Time
4105    'America/Buenos_Aires': 'ART', // Argentina Time
4106    'America/Santiago': isDST(date, userTimezone) ? 'CLST' : 'CLT',
4107    'America/Lima': 'PET',      // Peru Time
4108    'America/Bogota': 'COT',    // Colombia Time
4109  };
4110  
4111  // Check if we have a custom mapping
4112  if (timezoneMap[userTimezone]) {
4113    return timezoneMap[userTimezone];
4114  }
4115  
4116  // Fall back to browser's default behavior
4117  const browserAbbr = new Intl.DateTimeFormat("en-US", {
4118    timeZone: userTimezone,
4119    timeZoneName: "short"
4120  }).formatToParts(date).find(part => part.type === 'timeZoneName')?.value || '';
4121  
4122  return browserAbbr;
4123}
4124
4125/**
4126 * Converts a Mountain Time date and time to the user's local timezone
4127 * @param {string} dateString - Date in YYYY-MM-DD format
4128 * @param {number} mtTimeValue - Mountain Time as fraction of day from Google Sheets
4129 * @param {string} userTimezone - User's IANA timezone identifier
4130 * @returns {object} Object containing formatted date, time, and ISO string for the user's timezone
4131 */
4132export function convertMountainTimeToUserTimezone(dateString, mtTimeValue, userTimezone) {
4133  // Use the existing working convertMountainTimeTo function
4134  const userTimeFormatted = convertMountainTimeTo(mtTimeValue, userTimezone);
4135  
4136  // Parse date components for date formatting
4137  const [year, month, day] = dateString.split('-').map(Number);
4138  
4139  // Convert MT time fraction to hours and minutes for original time storage
4140  const totalSeconds = mtTimeValue * 24 * 60 * 60;
4141  const hours = Math.floor(totalSeconds / 3600);
4142  const minutes = Math.floor((totalSeconds % 3600) / 60);
4143  
4144  // Create a date object for the user's timezone to get the date in their timezone
4145  const tempDate = new Date(year, month - 1, day, hours, minutes);
4146  
4147  // Format the date in the user's timezone
4148  const userDate = new Intl.DateTimeFormat("en-US", {
4149    timeZone: userTimezone,
4150    year: "numeric",
4151    month: "long",
4152    day: "numeric"
4153  }).format(tempDate);
4154  
4155  // Get enhanced timezone abbreviation
4156  const timeZoneAbbr = getTimezoneAbbreviation(userTimezone, tempDate);
4157  
4158  // For form submission, create the original Mountain Time ISO string
4159  // This preserves the original MT time for backend processing
4160  const originalMTForSubmission = new Date(Date.UTC(year, month - 1, day, hours + 6, minutes)); // +6 to convert MT to UTC
4161  
4162  return {
4163    formattedDate: userDate,
4164    formattedTime: userTimeFormatted.replace(/^0/, ''), // Remove leading zero
4165    timeZoneAbbr,
4166    isoTime: originalMTForSubmission.toISOString(), // Original MT time for form submission
4167    displayText: `${userDate} - ${userTimeFormatted.replace(/^0/, '')} ${timeZoneAbbr}`
4168  };
4169}
4170
4171/**
4172 * Formats date to standard blog listing date format
4173 * @param {object} date - Date object
4174 * @returns {string} Date in text format e.g. "Sep 5, 2025"
4175 */
4176export function formatShortBlogDate(date) {
4177  const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4178  return `${monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
4179}
4180
4181/**
4182 * Format date for UK blog cards (full month name)
4183 * @param {object} date - Date object
4184 * @returns {string} Date in UK format e.g. "12 January 2026"
4185 */
4186export function formatUKBlogDate(date) {
4187  const monthNames = ["January", "February", "March", "April", "May", "June", 
4188                      "July", "August", "September", "October", "
4188November", "December"];
4189  return `${date.getDate()} ${monthNames[date.getMonth()]} ${date.getFullYear()}`;
4190}
4191
4192/**
4193 * Format date for UK blog article headers (full month name)
4194 * @param {string} dateString - Date string in YYYY-MM-DD format
4195 * @returns {string} Date in UK format e.g. "12 January 2026"
4196 */
4197export function formatUKDate(dateString) {
4198  if (!dateString) return '';
4199  const [year, month, day] = dateString.split('-');
4200  const date = new Date(year, month - 1, day);
4201  const monthNames = ["January", "February", "March", "April", "May", "June", 
4202                      "July", "August", "September", "October", "November", "December"];
4203  return `${date.getDate()} ${monthNames[date.getMonth()]} ${date.getFullYear()}`;
4204}
4205
4206/**
4207 * Get the locale-aware blog base path based on current URL
4208 * @returns {string} Blog base path e.g. "/blog", "/uk/blog", "/ca/blog"
4209 */
4210export function getLocaleBlogPath() {
4211  const path = window.location.pathname;
4212  if (path.startsWith('/uk/')) return '/uk/blog';
4213  if (path.startsWith('/ca/')) return '/ca/blog';
4214  return '/blog';
4215}
4216
4217export function toSlug(name) {
4218  return name && typeof name === 'string'
4219    ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
4220    : '';
4221}
4222
4223/**
4224 * Capitalize first letter of object keys
4225 * @param {object} obj - The object to capitalize keys for
4226 * @returns {object} The object with capitalized keys
4227 */
4228const capitalizeKeys = (obj) => {
4229  const modifiedObj = {};
4230  Object.keys(obj).forEach((key) => {
4231    let modifiedKey = key;
4232    if(!key.includes('emailPreferences')){
4233      modifiedKey = key.charAt(0).toUpperCase() + key.slice(1);
4234    }
4235    modifiedObj[modifiedKey] = obj[key];      
4236  });
4237  return modifiedObj;
4238};
4239
4240function base64DecodeUnicode(base64) {
4241  const binaryString = atob(base64);
4242  const bytes = new Uint8Array(binaryString.length);
4243  // eslint-disable-next-line no-plusplus
4244  for (let i = 0; i < binaryString.length; i++) {
4245    bytes[i] = binaryString.charCodeAt(i);
4246  }
4247  const decoder = new TextDecoder('utf-8');
4248  return decoder.decode(bytes);
4249}
4250
4251export function decodeFormFillDataFromURLifExists(paramName) {
4252  try {
4253    // eslint-disable-next-line no-shadow
4254    const params = new URLSearchParams(window.location.search);
4255    const base64String = params.get(paramName);
4256    if (base64String) {    
4257      const decodedString = base64DecodeUnicode(decodeURIComponent(base64String));  
4258      return JSON.parse(decodedString);
4259    }
4260  } catch (error) {
4261    // eslint-disable-next-line no-console
4262    console.error("Error decoding form fill data from query parameter:", error.message);    
4263    return null;
4264  }  
4265  return null;
4266}
4267
4268/**
4269 * Get prefill fields from marketo cookie
4270 * @returns {Promise<object|null>} The prefill fields object or null if there was an error
4271 */
4272export const getPrefillFields = async (useMarketoCookie = false) => {
4273  const cookieName = 'bhr_prefill';
4274  const cookie = document.cookie.match(`(^|;)\\s*${cookieName}\\s*=\\s*([^;]+)`)?.pop();
4275  // eslint-disable-next-line no-shadow
4276  const params = new URLSearchParams(window.location.search);
4277  const marketoId = params.get('mid');
4278
4279  if(cookie && !useMarketoCookie){
4280    try {
4281      const cookieValue = JSON.parse(atob(cookie));
4282      document.hasFormPrefillCookie = true;
4283      return cookieValue;
4284    } catch (error) {
4285      // eslint-disable-next-line no-console
4286      console.error(error);
4287      return null;
4288    }
4289  } else if (useMarketoCookie) {
4290    try {
4291      const formFillRequestData = new FormData();
4292      formFillRequestData.append('marketoCookie', readCookie('_mkto_trk'));
4293      const response = await fetch('/xhr/formfill.php', {
4294        method: 'POST',
4295        body: formFillRequestData
4296      });
4297      if (!response.ok) {
4298        // eslint-disable-next-line no-console
4299        console.error(`Prefill request failed with status: ${response.status}`);
4300        return null;
4301      }
4302      const data = await response.json();
4303      const {formData} = data;
4304      return (formData ? capitalizeKeys(formData) : null);
4305    } catch (error) {
4306      // eslint-disable-next-line no-console
4307      console.error(error);
4308      return null;
4309    }
4310  } else if (marketoId) {
4311    // check passed in ID and use it to perform lead lookup
4312    try {
4313      const response = await fetch(`/xhr/formfill.php?mid=${marketoId}`);
4314      if (!response.ok) {
4315        // eslint-disable-next-line no-console
4316        console.error(`Prefill request failed with status: ${response.status}`);
4317        return null;
4318      }
4319      const data = await response.json();
4320      const {formData} = data;
4321      return (formData ? capitalizeKeys(formData) : null);
4322    } catch (error) {
4323      // eslint-disable-next-line no-console
4324      console.error(error);
4325      return null;
4326    }
4327  }
4328
4329  return null;
4330}
4331
4332export const sha256hash = val =>
4333  crypto.subtle
4334    .digest('SHA-256', new TextEncoder('utf-8').encode(val))
4335    .then(h => {
4336      const hexes = [];
4337      const view = new DataView(h);
4338      for (let i = 0; i < view.byteLength; i += 4)
4339        // eslint-disable-next-line prefer-template
4340        hexes.push(('00000000' + view.getUint32(i).toString(16)).slice(-8));
4341      return hexes.join('');
4342    });
4343
4344// eslint-disable-next-line default-param-last
4345export function setCookie(name, value, path = '/', expirationInSeconds) {
4346 
4347  let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
4348  
4349  if (path) {
4350    cookieString += `; path=${path}`;
4351  }
4352
4353  if (expirationInSeconds) {
4354    const expirationDate = new Date();
4355    expirationDate.setSeconds(expirationDate.getSeconds() + expirationInSeconds);
4356    cookieString += `; expires=${expirationDate.toUTCString()}`;
4357  }
4358
4359  document.cookie = cookieString;
4360}
4361
4362setupScrollTracking(analyticsTrackScrollDepth);
4363
4364export const formatReadtime = (readtime) =>
4365  readtime !== '' ? `${parseInt(readtime, 10)} min` : '';
4366
4367export function getSFID(){
4368  return (window.location.hostname === "www.bamboohr.com" ? 2137 : 2137);
4369}

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.