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.