PageSourceSearch

https://rentbetter.com.au/js/rb-global.js?v=fe3471bb

js rentbetter.com.au collected 2026-10-08 09:01:05 UTC 32,814 bytes, 959 lines download raw bytes

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.