1/* 2 * RentBetter site-wide custom code (D-1926) â THE canonical version. 3 * Ported from Webflow's "Site Settings â Custom Code" script 4 * (portal/webflow-custom-code.html, now frozen â the Webflow site is not 5 * maintained; fix forward here only). 6 * 7 * Loaded on every page from src/app/(frontend)/layout.tsx as a deferred 8 * script at the end of <body> â the same placement semantics as Webflow's 9 * footer custom code (DOM parsed when it runs, before DOMContentLoaded 10 * listeners fire, before the Intercom widget loads at window load). 11 * 12 * Intentional adaptations vs the Webflow original: 13 * - dataLayer init dropped â the GTM head snippet in layout.tsx does it. 14 * - Intercom widget boot + GTM <noscript> dropped â both live in 15 * layout.tsx; this file only merges the portal login identity 16 * (localStorage rb_website_login) into window.intercomSettings. 17 * - data-api-form redirect reads data-redirect, falling back to redirect: 18 * BrandCta.tsx emits only data-redirect; Webflow-exported forms carry both. 19 * - Form success/error blocks located via nextElementSibling (not 20 * nextSibling) â page HTML here contains whitespace text nodes. 21 * - Submit-button label swap writes .value for <input type="submit"> 22 * (innerHTML is a no-op on <input>; all our forms use inputs). 23 * - Email-capture and data-api-form handlers listen on document/window 24 * instead of binding to each element at load, so markup React re-renders 25 * keeps them. 26 * - A data-api-form success sets the leadId cookie from the lead the 27 * response names; the original left the cookie to the email field's 28 * blur ping. 29 * - deviceId client mint is a FALLBACK â the edge experiment function 30 * (D-1769, core-stack) mints it first on apex traffic; same cookie 31 * name/path, only written when absent. 32 * - video initPlayer attaches to a pre-existing YT player (YT.get + 33 * addEventListener) when GTM's video trigger wrapped the iframe first â 34 * the Webflow original loses that race and its inline video pings 35 * never fire. 36 */ 37 38/*! js-cookie v3.0.5 | MIT */ !(function (e, t) { 39 "object" == typeof exports && "undefined" != typeof module 40 ? (module.exports = t()) 41 : "function" == typeof define && define.amd 42 ? define(t) 43 : ((e = "undefined" != typeof globalThis ? globalThis : e || self), 44 (function () { 45 var n = e.Cookies, 46 o = (e.Cookies = t()); 47 o.noConflict = function () { 48 return (e.Cookies = n), o; 49 }; 50 })()); 51})(this, function () { 52 "use strict"; 53 function e(e) { 54 for (var t = 1; t < arguments.length; t++) { 55 var n = arguments[t]; 56 for (var o in n) e[o] = n[o]; 57 } 58 return e; 59 } 60 var t = (function t(n, o) { 61 function r(t, r, i) { 62 if ("undefined" != typeof document) { 63 "number" == typeof (i = e({}, o, i)).expires && 64 (i.expires = new Date(Date.now() + 864e5 * i.expires)), 65 i.expires && (i.expires = i.expires.toUTCString()), 66 (t = encodeURIComponent(t) 67 .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent) 68 .replace(/[()]/g, escape)); 69 var c = ""; 70 for (var u in i) 71 i[u] && 72 ((c += "; " + u), !0 !== i[u] && (c += "=" + i[u].split(";")[0])); 73 return (document.cookie = t + "=" + n.write(r, t) + c); 74 } 75 } 76 return Object.create( 77 { 78 set: r, 79 get: function (e) { 80 if ("undefined" != typeof document && (!arguments.length || e)) { 81 for ( 82 var t = document.cookie ? document.cookie.split("; ") : [], 83 o = {}, 84 r = 0; 85 r < t.length; 86 r++ 87 ) { 88 var i = t[r].split("="), 89 c = i.slice(1).join("="); 90 try { 91 var u = decodeURIComponent(i[0]); 92 if (((o[u] = n.read(c, u)), e === u)) break; 93 } catch (e) {} 94 } 95 return e ? o[e] : o; 96 } 97 }, 98 remove: function (t, n) { 99 r(t, "", e({}, n, { expires: -1 })); 100 }, 101 withAttributes: function (n) { 102 return t(this.converter, e({}, this.attributes, n)); 103 }, 104 withConverter: function (n) { 105 return t(e({}, this.converter, n), this.attributes); 106 }, 107 }, 108 { 109 attributes: { value: Object.freeze(o) }, 110 converter: { value: Object.freeze(n) }, 111 } 112 ); 113 })( 114 { 115 read: function (e) { 116 return ( 117 '"' === e[0] && (e = e.slice(1, -1)), 118 e.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent) 119 ); 120 }, 121 write: function (e) { 122 return encodeURIComponent(e).replace( 123 /%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g, 124 decodeURIComponent 125 ); 126 }, 127 }, 128 { path: "/" } 129 ); 130 return t; 131}); 132 133/* Storage access can THROW, not merely return null: Safari raises 134 * SecurityError (DOMException 18) on ANY localStorage/sessionStorage touch 135 * when the visitor blocks site data ("Block All Cookies"), and a full quota 136 * makes setItem throw anywhere. D-2294 â the same class of bug that 137 * reload-looped the whole site from the theme script. Every access below goes 138 * through these, so a blocked visitor loses the stored value and nothing 139 * else: attribution, the demo-signup form handler and the Intercom identity 140 * merge all keep running. */ 141function readStore(store, key) { 142 try { 143 return window[store].getItem(key); 144 } catch (e) { 145 return null; 146 } 147} 148 149function writeStore(store, key, value) { 150 try { 151 window[store].setItem(key, value); 152 return true; 153 } catch (e) { 154 return false;
155 } 156} 157 158// Get or set a unique device (browser) UUID for use with lead tracking. 159// Attributes mirror the edge experiment-cookie function (Path=/, ~10y, 160// SameSite=Lax, Secure, host-only) so a client-minted id is adopted by the 161// edge assignment layer and vice versa (D-1894). 162var deviceId; 163try { 164 deviceId = Cookies.get("deviceId"); 165 if (!deviceId) { 166 Cookies.set("deviceId", (deviceId = crypto.randomUUID()), { 167 expires: 3650, 168 sameSite: "Lax", 169 secure: true, 170 }); 171 } 172} catch (e) {} 173 174// D-1894: first-party anonymous pageview beacon into the flow lake. 175// Fires once per page load (the static export navigates by full page load, 176// so no history hooks). Only the hosts below send, so a visit to the site 177// distribution's own hostname or a local build never reaches the flow lake. 178// did is the same host-only deviceId cookie the portal and the edge 179// experiment layer use; that identity match is what makes exposure joins 180// work. 181(function () { 182 var TRACK_ENDPOINTS = { 183 "rentbetter.com.au": "https://ws.rentbetter.com.au/track", 184 "www.rentbetter.com.au": "https://ws.rentbetter.com.au/track", 185 "staging.ptgs.net.au": "https://ws.staging.ptgs.net.au/track", 186 }; 187 188 var endpoint = TRACK_ENDPOINTS[window.location.hostname]; 189 if (!endpoint || !deviceId) return; 190 191 // A tab id we can't persist is worse than none: it would look like a brand 192 // new tab on every pageview. Fall back to the shared sentinel instead. 193 var tabId = readStore("sessionStorage", "rb_tid"); 194 if (!tabId) { 195 tabId = crypto.randomUUID(); 196 if (!writeStore("sessionStorage", "rb_tid", tabId)) tabId = "tab-unknown"; 197 } 198 199 var payload = JSON.stringify({ 200 did: deviceId, 201 tid: tabId, 202 e: "pv", 203 p: window.location.pathname, 204 ttl: document.title, 205 ref: document.referrer || undefined, 206 }); 207 208 try { 209 // String body -> text/plain -> CORS-safelisted, no preflight 210 if (!(navigator.sendBeacon && navigator.sendBeacon(endpoint, payload))) { 211 fetch(endpoint, { method: "POST", body: payload, keepalive: true }).catch( 212 function () {} 213 ); 214 } 215 } catch (e) {} 216})(); 217 218let URL_PARAMS = new URLSearchParams(window.location.search); 219 220// How long a visitor's first touch â the visit that first brought them to the 221// site â is kept and sent with their pings. 222const REFERRAL_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days 223 224// The stored first touch, or null when there is none or it has expired. 225function readFirstTouch() { 226 try { 227 const stored = JSON.parse( 228 readStore("localStorage", "rb_referral_data") || "null" 229 ); 230 if ( 231 stored && 232 new Date().getTime() - new Date(stored.timestamp).getTime() < 233 REFERRAL_MAX_AGE_MS 234 ) { 235 return stored; 236 } 237 } catch (e) {} 238 return null; 239} 240 241// Add the stored first touch to a ping's params or a form's body under 242// firstTouch[...] names. The API reads the UTMs of the page a ping is sent 243// from off its URL; a first touch sent under the same names would overwrite 244// them. URLSearchParams and FormData both take append(name, value). 245function appendFirstTouch(target) { 246 const firstTouch = readFirstTouch(); 247 if (!firstTouch) return; 248 Object.keys(firstTouch).forEach(function (key) { 249 const value = firstTouch[key]; 250 if (null !== value && undefined !== value && "" !== value) { 251 target.append("firstTouch[" + key + "]", value); 252 } 253 }); 254} 255 256// Handle referral/utm lead tracking 257(function () { 258 // Helper function to determine traffic source and create referral data 259 function determineTrafficSource() { 260 const referralData = { 261 landingPage: window.location.href, 262 timestamp: new Date().toISOString(), 263 }; 264 265 // Check for UTM parameters first. A blank one (utm_source=) tags nothing, 266 // and counting it would hide the referrer for the whole 30 days. 267 const utmParams = {}; 268 let hasUtmParams = false; 269 URL_PARAMS.forEach((value, key) => { 270 if (key.startsWith("utm_") && "" !== value) {
271 utmParams[key] = value; 272 hasUtmParams = true; 273 } 274 }); 275 276 if (hasUtmParams) { 277 return { 278 ...referralData, 279 ...utmParams, 280 trafficSource: "utm", 281 }; 282 } 283 284 const referrer = document.referrer; 285 try { 286 const currentHost = window.location.hostname; 287 const referrerHost = referrer ? new URL(referrer).hostname : null; 288 289 if (!referrer || referrerHost === currentHost) { 290 return { 291 ...referralData, 292 referrer: null, 293 trafficSource: "direct", 294 }; 295 } 296 297 return { 298 ...referralData, 299 referrer: referrer, 300 trafficSource: "organic", 301 }; 302 } catch (e) { 303 // If there's any error parsing the referrer URL, ignore 304 return null; 305 } 306 } 307 308 try { 309 // Store new referral data if there is none, or it has expired. A failed 310 // write must NOT abort the block â the UTM/gclid lead pings below are the 311 // point of this IIFE, and an unguarded throw here used to skip them 312 // entirely. 313 if (null === readFirstTouch()) { 314 const referralData = determineTrafficSource(); 315 if (null !== referralData) { 316 writeStore( 317 "localStorage", 318 "rb_referral_data", 319 JSON.stringify(referralData) 320 ); 321 } 322 } 323 324 // Handle mailchimp lead ping if present 325 const utmTerm = URL_PARAMS.get("utm_term"); 326 if (utmTerm) { 327 const match = utmTerm.match(/^[0-9a-f_]+-[0-9a-f]+-(\d+)$/); 328 if (match) { 329 setLead({ m: match[1] }); 330 return; 331 } 332 } 333 334 // Handle regular UTM lead tracking 335 if ( 336 URL_PARAMS.get("utm_source") || 337 URL_PARAMS.get("gclid") || 338 URL_PARAMS.get("fbclid") || 339 URL_PARAMS.get("gbraid") || 340 URL_PARAMS.get("wbraid") 341 ) { 342 setLead(); 343 } 344 } catch (e) { 345 // Error handling referral data 346 console.error("Error handling referral data:", e); 347 } 348})(); 349 350// The visitor's lead, read by the pages after a ping or a form: the 351// /get-started/<leadId> link that hands the lead to the portal, and the demo 352// quiz's answers. One writer, so every write carries the same name and 353// attributes and replaces the one before it. 354function rememberLead(id) { 355 Cookies.set("leadId", id, { expires: 365 }); 356} 357 358function setLead(params) { 359 const finalParams = { deviceId: deviceId }; 360 361 // Send existing leadId to link pings to same lead 362 const existingLeadId = Cookies.get("leadId"); 363 if (existingLeadId) { 364 finalParams.leadId = existingLeadId; 365 } 366 367 // Pass click IDs directly from current URL (more reliable than parsing landingPage) 368 ["gclid", "fbclid", "gbraid", "wbraid"].forEach(function (k) { 369 var v = URL_PARAMS.get(k); 370 if (v) finalParams[k] = v; 371 }); 372 373 // Capture click timestamp (now, since we don't have actual click time) 374 if (finalParams.gclid || finalParams.fbclid || finalParams.gbraid || finalParams.wbraid) { 375 finalParams.clickTime = new Date().toISOString(); 376 } 377 378 // The page this ping is sent from. Its UTMs reach the API on the ping URL 379 // (the Referer, hence unsafe-url below); the first touch goes alongside. 380 finalParams.landingPage = window.location.href; 381 382 // Merge with provided params (which take precedence) 383 Object.assign(finalParams, params || {}); 384 385 const query = new URLSearchParams(finalParams); 386 appendFirstTouch(query); 387 388 return fetch("/api/tr/lead?" + query.toString(), { 389 referrerPolicy: "unsafe-url", 390 }) 391 .then(function (response) { 392 return response.json(); 393 }) 394 .then(function (data) { 395 if ("id" in data) { 396 rememberLead(data.id); 397 } 398 }) 399 .catch(function (e) { 400 console.error("Lead tracking error:", e); 401 }); 402} 403 404document.addEventListener("DOMContentLoaded", function () { 405 try { 406 // Remove any empty FAQ wrappers to silence Google's search console "Missing field 'mainEntity'" error. 407 document 408 .querySelector( 409 '[itemtype="https://schema.org/FAQPage"].w-condition-invisible' 410 ) 411 .remove(); 412 } catch (e) {} 413}); 414 415/* The email-capture and data-api-form handlers below listen on document and 416 read the element they fire for, rather than binding to each input or form 417 at load. React owns that markup: when it recovers from a hydration error it 418 rebuilds the page from JSX, and listeners bound to the old elements would 419 go with them â a demo form would then do a native POST and navigate to the 420 API's JSON. Delegated, they cover whatever elements are on the page. */ 421 422// Email capture: a lead ping when any email field loses focus. `focusout` is 423// the bubbling twin of `blur`, so one listener sees every field. 424document.addEventListener("focusout", function (event) { 425 var input = event.target; 426 if (!(input instanceof HTMLInputElement) || "email" !== input.type) { 427 return; 428 } 429 var params = { 430 e: input.value, 431 }; 432 433 var form = input.form && input.form.getAttribute("data-track-form"); 434 if (form) { 435 params.form = form; 436 } 437 438 setLead(params); 439}); 440 441// API form submission. 442var apiFormsSubmitting = new WeakSet(); 443 444// Clear a field's validity message as soon as it is edited. input as well as 445// keydown: choosing a select option or a pasted value fires no keydown. 446["keydown", "input"].forEach(function (type) { 447 document.addEventListener(type, function (event) { 448 var el = event.target; 449 if ( 450 el && 451 el.form && 452 el.form.hasAttribute("data-api-form") && 453 "function" === typeof el.setCustomValidity 454 ) { 455 el.setCustomValidity(""); 456 } 457 }); 458}); 459 460// Capture phase on window, so this runs before any other submit listener â 461// GTM's form trigger included â and they all see the default already 462// prevented, as they did when this was bound to the form itself. It does not 463// stop propagation: GTM's generate_lead still has to fire. 464window.addEventListener( 465 "submit", 466 function (event) { 467 var formEl = event.target; 468 if ( 469 !(formEl instanceof HTMLFormElement) || 470 !formEl.hasAttribute("data-api-form") 471 ) { 472 return; 473 } 474 event.preventDefault(); 475 476 if (apiFormsSubmitting.has(formEl)) { 477 return; 478 } 479 480 // Webflow-exported forms carry both attributes; BrandCta emits only data-redirect 481 var redirectUrl = 482 formEl.getAttribute("data-redirect") || formEl.getAttribute("redirect"); 483 var submitButton = formEl.querySelector('[type="submit"]'); 484 // <input type="submit"> labels live on .value; innerHTML is a no-op there 485 var isInputButton = submitButton && "INPUT" === submitButton.tagName; 486 var getButtonLabel = function () { 487 if (!submitButton) return ""; 488 return isInputButton ? submitButton.value : submitButton.innerHTML; 489 }; 490 var setButtonLabel = function (label) { 491 if (!submitButton) return; 492 if (isInputButton) { 493 submitButton.value = label; 494 } else { 495 submitButton.innerHTML = label; 496 } 497 }; 498 var submitButtonLabelOriginal = getButtonLabel(); 499 var submitButtonLabelLoading = "Please wait..."; 500 // .w-form structure: <form> + .w-form-done + .w-form-fail element siblings 501 var successEl = formEl.nextElementSibling; 502 var errorEl = successEl && successEl.nextElementSibling; 503 504 // Hide/show section 505 var hide = function (el) { 506 if (el) el.style.display = "none"; 507 }; 508 var show = function (el) { 509 if (el) el.style.display = "block"; 510 }; 511 var showError = function () { 512 hide(formEl); 513 hide(successEl); 514 show(errorEl); 515 }; 516 var flag = function (input, message) { 517 input.setCustomValidity(message); 518 input.reportValidity(); 519 }; 520 521 // Address forms: the visible field is only search text. The address the 522 // API needs is the hidden address[...] set, which the page's Google Places 523 // script fills when a suggestion is chosen. 524 var addressSearch = formEl.querySelector("[data-address-search]"); 525 var streetAddress = formEl.elements["address[streetAddress]"]; 526 var addressMessage = "Please choose your address from the suggestions."; 527 528 // The API names nested fields with dots (address.streetAddress); the form 529 // names them with brackets (address[streetAddress]). Address errors go on
530 // the search field, the only address input a visitor can see. 531 var fieldFor = function (path) { 532 if (addressSearch && /^address(\.|$)/.test(path)) return addressSearch; 533 var el = 534 formEl.elements[path] || 535 formEl.elements[path.replace(/\.([^.]+)/g, "[$1]")]; 536 // A radio group comes back as a RadioNodeList 537 if (el && !el.setCustomValidity && el.length) el = el[0]; 538 if (!el || !el.setCustomValidity || "hidden" === el.type) return null; 539 return el; 540 }; 541 542 if (addressSearch && streetAddress && !streetAddress.value) { 543 if (window.google && window.google.maps && window.google.maps.places) { 544 flag(addressSearch, addressMessage); 545 } else { 546 // Places never loaded, so no suggestion can be chosen 547 showError(); 548 } 549 return; 550 } 551 552 apiFormsSubmitting.add(formEl); 553 setButtonLabel(submitButtonLabelLoading); 554 555 // The form carries the visitor's first touch itself: the email field's 556 // ping may never have fired (autofill then Enter never blurs it). 557 var body = new FormData(formEl); 558 appendFirstTouch(body); 559 560 fetch(formEl.getAttribute("action"), { 561 method: formEl.getAttribute("method"), 562 body: body, 563 }) 564 .then(function (res) { 565 apiFormsSubmitting.delete(formEl); 566 setButtonLabel(submitButtonLabelOriginal); 567 return res.json().then(function (data) { 568 return { ok: res.ok, status: res.status, data: data }; 569 }); 570 }) 571 .then(function (res) { 572 if (res.ok) { 573 // Lead forms answer with the lead they were filed against: keep it 574 // for the pages after the redirect. The email field's ping can't be 575 // relied on for that. Autofill, Enter in the email field or a quick 576 // submit never blur the field, and a tagged landing's ping may have 577 // left the device's anonymous lead in place of the one the form 578 // matched by email. 579 if (res.data && res.data.lead && "string" === typeof res.data.lead.id) { 580 rememberLead(res.data.lead.id); 581 } 582 hide(formEl); 583 hide(errorEl); 584 show(successEl); 585 if (redirectUrl) { 586 window.location.href = redirectUrl; 587 } 588 } else if (res.status === 422 && "errors" in res.data) { 589 var unplaced = false; 590 res.data.errors.forEach(function (error) { 591 if (!("path" in error) || !("message" in error)) return; 592 var input = fieldFor(error.path); 593 if (!input) { 594 unplaced = true; 595 } else if (input === addressSearch) { 596 flag(input, addressMessage); 597 } else { 598 flag(input, error.message); 599 } 600 }); 601 // An error the visitor can't see on a field would leave them 602 // with no feedback at all 603 if (unplaced) { 604 showError(); 605 } 606 } else { 607 showError(); 608 } 609 }) 610 .catch(function () { 611 // Network failure, or a response that isn't JSON (an edge or 612 // proxy error page) 613 apiFormsSubmitting.delete(formEl); 614 setButtonLabel(submitButtonLabelOriginal); 615 showError(); 616 }); 617 }, 618 true 619); 620 621// Webflow form-API fallthrough guard (D-1966). Forms with no action and no 622// data-api-form are client-side widgets (savings calculator, multi-step 623// sliders) â they recalc on input events and never legitimately submit. But 624// data-wf-site arms webflow.js, which would POST an Enter-key submit to 625// webflow.com/api/v1/form/<site-id>: an endpoint that dies with the Webflow 626// subscription (and junk data meanwhile). Capture phase on document so this 627// runs before webflow.js's delegated (bubble-phase) handler; stopPropagation 628// keeps the event from ever reaching it. Forms with an action are untouched â 629// /search navigates natively, data-api-form posts are handled above. 630document.addEventListener( 631 "submit", 632 function (event) { 633 var form = event.target; 634 if ( 635 form.tagName === "FORM" && 636 !form.getAttribute("action") && 637 !form.hasAttribute("data-api-form") 638 ) { 639 event.preventDefault(); 640 event.stopPropagation(); 641 } 642 }, 643 true 644); 645 646function objectToSnakeCase(obj) { 647 const toSnake = (str) => 648 str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); 649 return Object.fromEntries( 650 Object.entries(obj).map(([key, value]) => [toSnake(key), value]) 651 ); 652} 653 654/* Intercom identity â the widget boot lives in layout.tsx; here we only 655 merge the portal login identity (localStorage rb_website_login, written 656 by portal setWebsiteLogin()) into the settings the boot script already 657 declared, and send a signed-in visitor from the homepage to their 658 dashboard. The rest of the signed-in state (header Dashboard button, 659 My Dashboard CTAs) is rendered by React from the same key â see 660 src/components/WebsiteLogin. */ 661window.intercomSettings = window.intercomSettings || {
662 api_base: "https://api-iam.intercom.io", 663 app_id: "u4slvu0i", 664}; 665 666try { 667 let data = JSON.parse(readStore("localStorage", "rb_website_login") || "{}"); 668 if ("object" === typeof data) { 669 if ("intercom" in data) { 670 Object.assign( 671 window.intercomSettings, 672 objectToSnakeCase(data.intercom) 673 ); 674 } 675 if ( 676 "loggedIn" in data && 677 data.loggedIn && 678 "/" === window.location.pathname 679 ) { 680 window.location = "/portal"; 681 } 682 } 683} catch (err) { 684 // Rethrown from a load listener rather than swallowed: the load event waits 685 // for the async Next bundle, which is where Sentry is initialised, so its 686 // global error handler is listening by then â and the rest of this file 687 // still runs. 688 window.addEventListener("load", function () { 689 throw err; 690 }); 691} 692 693/* ââ YouTube video engagement tracking ââ 694 * 695 * Tracks play, pause, progress milestones (25/50/75/90%), and completion 696 * for all YouTube iframes with class="rb-track-video". Fires lead pings 697 * to /api/tr/lead so video engagement is stored against the lead. 698 * 699 * Supports two patterns: 700 * 701 * 1. INLINE â iframe has src, inits on page load: 702 * <iframe class="rb-track-video" 703 * src="https://www.youtube.com/embed/ID?enablejsapi=1&origin=https://rentbetter.com.au" 704 * ...></iframe> 705 * 706 * 2. LIGHTBOX â iframe has data-src, inits when modal opens: 707 * <iframe class="rb-track-video" 708 * data-src="https://www.youtube.com/embed/ID?enablejsapi=1&autoplay=1&origin=https://rentbetter.com.au" 709 * ...></iframe> 710 * Trigger with: onclick="rbOpenVideoModal(el)" 711 * Close with: onclick="rbCloseVideoModal(el)" 712 */ 713(function () { 714 var MILESTONES = [25, 50, 75, 90]; 715 var playerCounter = 0; 716 var players = {}; 717 var ytReady = false; 718 719 function extractVideoId(src) { 720 var match = src.match(/embed\/([A-Za-z0-9_-]+)/); 721 return match ? match[1] : "unknown"; 722 } 723 724 function trackVideoEvent(videoId, eventName, extra) { 725 var params = { 726 deviceId: deviceId, 727 _source: "video", 728 videoId: videoId, 729 videoEvent: eventName, 730 videoPage: window.location.pathname, 731 }; 732 if (extra) { 733 Object.keys(extra).forEach(function (k) { 734 params[k] = extra[k]; 735 }); 736 } 737 var existingLeadId = Cookies.get("leadId"); 738 if (existingLeadId) { 739 params.leadId = existingLeadId; 740 } 741 742 fetch( 743 "/api/tr/lead?" + new URLSearchParams(params).toString(), 744 { referrerPolicy: "unsafe-url" } 745 ) 746 .then(function (r) { return r.json(); }) 747 .then(function (data) { 748 if ("id" in data) { 749 rememberLead(data.id); 750 } 751 }) 752 .catch(function () {}); 753 } 754 755 function checkProgress(player, videoId, state) { 756 if (!player || typeof player.getDuration !== "function") return; 757 var duration = player.getDuration(); 758 var current = player.getCurrentTime(); 759 if (!duration || duration <= 0) return; 760 761 var pct = Math.floor((current / duration) * 100); 762 763 MILESTONES.forEach(function (m) { 764 if (pct >= m && !state.milestonesHit[m]) { 765 state.milestonesHit[m] = true; 766 trackVideoEvent(videoId, "progress", { videoPercent: m }); 767 } 768 }); 769 770 if (pct >= 95 && !state.hasTrackedComplete) { 771 state.hasTrackedComplete = true; 772 trackVideoEvent(videoId, "complete", { videoPercent: 100 }); 773 } 774 } 775 776 function initPlayer(iframe) { 777 if (players[iframe.id]) return; 778 779 var videoId = extractVideoId(iframe.src); 780 var state = { 781 hasTrackedPlay: false, 782 hasTrackedComplete: false, 783 milestonesHit: {}, 784 progressTimer: null, 785 }; 786 787 var player; 788 var onStateChange = function (event) { 789 switch (event.data) { 790 case YT.PlayerState.PLAYING: 791 if (!state.hasTrackedPlay) { 792 state.hasTrackedPlay = true; 793 trackVideoEvent(videoId, "play"); 794 } 795 if (!state.progressTimer) { 796 state.progressTimer = setInterval(function () { 797 checkProgress(player, videoId, state); 798 }, 1000); 799 } 800 break; 801 802 case YT.PlayerState.PAUSED: 803 clearInterval(state.progressTimer); 804 state.progressTimer = null; 805 var d = player.getDuration(); 806 var c = player.getCurrentTime(); 807 if (d > 0) { 808 trackVideoEvent(videoId, "pause", { 809 videoPercent: Math.floor((c / d) * 100), 810 }); 811 } 812 break; 813 814 case YT.PlayerState.ENDED: 815 clearInterval(state.progressTimer); 816 state.progressTimer = null; 817 if (!state.hasTrackedComplete) { 818 state.hasTrackedComplete = true; 819 trackVideoEvent(videoId, "complete", { videoPercent: 100 }); 820 } 821 break; 822 } 823 }; 824 825 // GTM's built-in video trigger races us on inline embeds and may have 826 // already constructed a player on this iframe â constructing a second 827 // YT.Player then returns the existing instance and silently IGNORES the 828 // events config. Attach to the existing player instead when one exists. 829 var existing = typeof YT.get === "function" ? YT.get(iframe.id) : null; 830 if (existing) { 831 player = existing; 832 player.addEventListener("onStateChange", onStateChange); 833 } else { 834 player = new YT.Player(iframe.id, { 835 events: { onStateChange: onStateChange }, 836 }); 837 } 838 839 players[iframe.id] = { player: player, state: state }; 840 } 841 842 // ââ Modal open/close ââ 843 844 function openVideoModal(modal) { 845 modal.style.display = "flex"; 846 document.body.style.overflow = "hidden"; 847 848 var iframe = modal.querySelector("iframe.rb-track-video"); 849 if (!iframe || !iframe.dataset.src) return; 850 851 if (!iframe.id) iframe.id = "rb-video-" + playerCounter++; 852 853 // First open: load the video 854 if (!players[iframe.id]) { 855 iframe.src = iframe.dataset.src; 856 iframe.addEventListener("load", function handler() { 857 iframe.removeEventListener("load", handler); 858 if (ytReady) initPlayer(iframe); 859 }); 860 } 861 } 862 863 function closeVideoModal(modal) { 864 modal.style.display = "none"; 865 document.body.style.overflow = ""; 866 867 var iframe = modal.querySelector("iframe.rb-track-video"); 868 if (!iframe) return; 869 870 // Clear progress timer if running 871 if (iframe.id && players[iframe.id]) { 872 var p = players[iframe.id]; 873 if (p.state.progressTimer) { 874 clearInterval(p.state.progressTimer); 875 p.state.progressTimer = null; 876 } 877 delete players[iframe.id]; 878 } 879 880 // Stop playback â navigates iframe away; reloads from data-src on next open 881 iframe.src = "about:blank"; 882 } 883 884 // ââ Event delegation (no inline onclick needed in Embed HTML) ââ 885
886 // Click [data-video-modal="VIDEO_ID"] â open matching modal 887 document.addEventListener("click", function (e) { 888 var trigger = e.target.closest("[data-video-modal]"); 889 if (trigger) { 890 e.preventDefault(); 891 var videoId = trigger.getAttribute("data-video-modal"); 892 var modal = document.querySelector('.rb-video-modal[data-video-id="' + videoId + '"]'); 893 if (modal) openVideoModal(modal); 894 return; 895 } 896 897 // Click [data-video-close] â close parent modal 898 if (e.target.closest("[data-video-close]")) { 899 var modal = e.target.closest(".rb-video-modal"); 900 if (modal) closeVideoModal(modal); 901 return; 902 } 903 904 // Click backdrop (the modal overlay itself, not its children) â close 905 if (e.target.classList && e.target.classList.contains("rb-video-modal")) { 906 closeVideoModal(e.target); 907 } 908 }); 909 910 // Escape key â close any open modal 911 document.addEventListener("keydown", function (e) { 912 if (e.key !== "Escape") return; 913 document.querySelectorAll(".rb-video-modal").forEach(function (modal) { 914 if (modal.style.display === "flex") { 915 closeVideoModal(modal); 916 } 917 }); 918 }); 919 920 window.onYouTubeIframeAPIReady = function () { 921 ytReady = true; 922 923 // Init inline videos (those with src already set) 924 document.querySelectorAll("iframe.rb-track-video").forEach(function (iframe) { 925 if (iframe.src && iframe.src.indexOf("youtube.com") !== -1) { 926 if (!iframe.id) iframe.id = "rb-video-" + playerCounter++; 927 initPlayer(iframe); 928 } 929 }); 930 }; 931 932 // Load the YouTube IFrame API â but only when this page actually embeds a 933 // video. Both patterns above carry class="rb-track-video" (inline via src, 934 // lightbox via data-src), and this script runs deferred so the DOM is fully 935 // parsed. Loading unconditionally put ~100KB of YouTube player JS and its 936 // third-party cookies on every page, homepage included (D-2076). 937 if (document.querySelector("iframe.rb-track-video")) { 938 var tag = document.createElement("script"); 939 tag.src = "https://www.youtube.com/iframe_api"; 940 var first = document.getElementsByTagName("script")[0]; 941 first.parentNode.insertBefore(tag, first); 942 } 943 944 // Readiness signal for anything that has to run AFTER this file (D-2031). 945 // WebflowRuntime injects preserved Webflow page code from a React effect, and 946 // some of that code reads globals defined here â window.Cookies, for the 947 // leadId href rewrite on the demo-registered funnel. There is no ordering 948 // guarantee to lean on: Next's bootstrap chunks are `async`, so hydration is 949 // not sequenced against this `defer` script, and the insertion mode differs 950 // between the dev server and the static export. So state it explicitly 951 // rather than inferring it from readyState or from a global's existence 952 // (js-cookie is defined at the TOP of this file, so `window.Cookies` says 953 // nothing about whether the rest of it has run). 954 // 955 // Both halves matter: the flag for anyone who arrives late, the event for 956 // anyone already waiting. 957 window.rbGlobalReady = true; 958 window.dispatchEvent(new Event("rb-global-ready")); 959})();
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.