PageSourceSearch

https://www.orcana.ai/js/analytics.js

js orcana.ai collected 2026-10-05 12:22:30 UTC 14,162 bytes, 376 lines download raw bytes

1/**
2 * GA4 custom event tracking for the Orcana marketing site.
3 *
4 * Loaded with `defer` on every page, after the gtag snippet in <head>.
5 * Event reference and GA4 setup notes: docs/ANALYTICS.md
6 *
7 * This file only observes. It never calls preventDefault, never mutates the
8 * DOM, and registers only passive listeners for scroll -- so it cannot
9 * interfere with the wheel/touch scroll hijacking in custom.js, or with
10 * Bootstrap's own handlers. Every handler body runs inside safely(), so a
11 * failure here can never throw into page code.
12 *
13 * Custom parameters (cta_location, percent_scrolled, ...) do NOT appear in
14 * GA4 reports until they are registered as custom dimensions in the GA4 UI.
15 * docs/ANALYTICS.md lists every one that needs registering.
16 */
17(function () {
18  "use strict";
19
20  var SCROLL_THRESHOLDS = [25, 50, 75, 100];
21  var CALENDLY_HOST = "calendly.com";
22  var MAX_PARAM_LENGTH = 100; // GA4 truncates parameter values beyond this.
23
24  var DEBUG =
25    /[?&]ga_debug=1/.test(window.location.search) ||
26    window.location.hostname === "localhost" ||
27    window.location.hostname === "127.0.0.1";
28
29  /** Send one event to GA4. Silently no-ops if gtag was blocked or failed. */
30  function track(name, params) {
31    var payload = params || {};
32    payload.page_path = window.location.pathname;
33    if (DEBUG) console.log("[ga4]", name, payload);
34    if (typeof window.gtag === "function") window.gtag("event", name, payload);
35  }
36
37  /** Wrap a handler so an analytics error can never break the page. */
38  function safely(handler) {
39    return function (event) {
40      try {
41        handler(event);
42      } catch (error) {
43        if (DEBUG) console.warn("[ga4] handler failed", error);
44      }
45    };
46  }
47
48  /**
49   * A usable label for an element, whitespace-collapsed and length-capped.
50   *
51   * Several links on this site wrap an image and contain no text at all --
52   * the footer social icons, the navbar logo. Falling back through the
53   * accessible name keeps those from being recorded as empty strings.
54   */
55  function textOf(element) {
56    var text = (element.textContent || "").replace(/\s+/g, " ").trim();
57    if (!text) text = element.getAttribute("aria-label") || element.getAttribute("title") || "";
58    if (!text) {
59      var image = element.querySelector("img");
60      if (image) text = image.getAttribute("alt") || fileNameOf(image.getAttribute("src"));
61    }
62    return text.trim().slice(0, MAX_PARAM_LENGTH);
63  }
64
65  /** Last path segment of a URL -- enough to identify a video or asset. */
66  function fileNameOf(url) {
67    if (!url) return "unknown";
68    return decodeURIComponent(url.split("/").pop().split("?")[0]);
69  }
70
71  function isExternal(anchor) {
72    return !!anchor.hostname && anchor.hostname !== window.location.hostname;
73  }
74
75  /**
76   * Where on the page an element sits, used to attribute clicks.
77   *
78   * The navbar and the mobile collapse menu are the same markup; what
79   * separates them is whether the hamburger is currently open, so the
80   * `show` class is the only reliable signal.
81   */
82  function locationOf(element) {
83    if (element.closest(".navbar")) {
84      var collapse = element.closest(".navbar-collapse");
85      if (collapse && collapse.classList.contains("show")) return "mobile_menu";
86      return "navbar";
87    }
88    if (element.closest(".modal")) return "modal";
89    if (element.closest(".site-footer")) return "footer";
90    if (element.closest(".hero-section")) return "hero";
91    return "section";
92  }
93
94  // ---------------------------------------------------------------------
95  // Clicks: navigation, CTAs, outbound links and Bootstrap widgets.
96  // One delegated listener classifies every click on the page.
97  // ---------------------------------------------------------------------
98
99  document.addEventListener(
100    "click",
101    safely(function (event) {
102      // event.target is not always an Element -- clicks can land on the
103      // document itself, which has no closest().
104      if (!event.target || typeof event.target.closest !== "function") return;
105      var element = event.target.closest("a, button");
106      if (!element) return;
107
108      var where = locationOf(element);
109      var label = textOf(element);
110      // Collapses, modals and tabs are handled by their Bootstrap events
111      // below, not here -- see the comment on that block.
112      var toggle = element.getAttribute("data-bs-toggle");
113      if (toggle === "collapse" || toggle === "modal" || toggle === "tab" || toggle === "pill") return;
114
115      if (element.closest(".owl-next, .owl-prev, .owl-dot")) {
116        var direction = element.closest(".owl-prev") ? "previous" : element.closest(".owl-next") ? "next" : "dot";
117        track("carousel_advance", { direction: direction, click_location: where });
118        return;
119      }
120
121      if (element.tagName !== "A") return;
122
123      var href = element.getAttribute("href") || "";
124
125      // The Calendly booking is the whole funnel: track it before the
126      // generic outbound branch so it never gets folded in with blog links.
127      if (element.hostname === CALENDLY_HOST || href.indexOf(CALENDLY_HOST) !== -1) {
128        track("cta_click", { cta_location: where, cta_text: label, link_url: element.href });
129        return;
130      }
131      if (href.indexOf("mailto:") === 0) {
132        track("email_click", { email_address: href.replace("mailto:", ""), click_location: where });
133        return;
134      }
135      if (isExternal(element)) {
136        track("outbound_click", { link_domain: element.hostname, link_url: element.href, link_text: label, click_location: where });
137        return;
138      }
139      // Everything remaining is internal navigation: nav bar, footer links,
140      // in-page anchors and links between pages.
141      track("navigation_click", {
142        nav_location: where,
143        link_text: label,
144        link_url: href,
145        link_type: href.indexOf("#") === 0 ? "anchor" : "page"
146      });
147    })
148  );
149
150  // ---------------------------------------------------------------------
151  // Bootstrap widgets.
152  //
153  // These listen to Bootstrap's own state events rather than to clicks.
154  // Bootstrap registers its delegated click handler before this module
155  // loads, so by the time a click listener here runs, aria-expanded has
156  // already been flipped -- reading it gives exactly the wrong answer.
157  // The show/hide events carry unambiguous state.
158  //
159  // These are the "about to" events, not their shown/hidden counterparts,
160  // which only fire once a CSS transition completes -- and so never fire
161  // at all when transitions are suppressed, as they are in a background
162  // tab or under prefers-reduced-motion.
163  // ---------------------------------------------------------------------
164
165  /** The control that opens a collapsible panel, for its label. */
166  function triggerFor(panel) {
167    if (!panel.id) return null;
168    return document.querySelector(
169      '[data-bs-target="#' + panel.id + '"], [aria-controls="' + panel.id + '"]'
170    );
171  }
172
173  document.addEventListener(
174    "show.bs.collapse",
175    safely(function (event) {
176      var trigger = triggerFor(event.target);
177      // The hamburger is a collapse too, but it is navigation, not content.
178      if (trigger && trigger.classList.contains("navbar-toggler")) {
179        track("mobile_menu_toggle", { menu_state: "open" });
180        return;
181      }
182      track("accordion_expand", {
183        section_label: trigger ? textOf(trigger) : event.target.id || "unnamed_section",
184        section_target: event.target.id || "unnamed_section"
185      });
186    })
187  );
188
189  document.addEventListener(
190    "hide.bs.collapse",
191    safely(function (event) {
192      var trigger = triggerFor(event.target);
193      if (trigger && trigger.classList.contains("navbar-toggler")) {
194        track("mobile_menu_toggle", { menu_state: "close" });
195      }
196    })
197  );
198
199  document.addEventListener(
200    "show.bs.tab",
201    safely(function (event) {
202      track("tab_select", {
203        tab_label: textOf(event.target),
204        click_location: locationOf(event.target)
205      });
206    })
207  );
208
209  document.addEventListener(
210    "show.bs.modal",
211    safely(function (event) {
212      var trigger = event.relatedTarget;
213      track("modal_open", {
214        modal_id: event.target.id || "unnamed_modal",
215        link_text: trigger ? textOf(trigger) : "",
216        click_location: trigger ? locationOf(trigger) : "unknown"
217      });
218    })
219  );
220
221  // ---------------------------------------------------------------------
222  // Scroll depth at 25 / 50 / 75 / 100 percent, once each per pageview.
223  // GA4's built-in enhanced measurement only reports a single 90% event.
224  // ---------------------------------------------------------------------
225
226  var reachedDepth = {};
227  var scrollTimer = null;
228
229  function measureScrollDepth() {
230    var scrollable = document.documentElement.scrollHeight - window.innerHeight;
231    // A page shorter than the viewport is fully seen the moment it loads.
232    var percent = scrollable <= 0 ? 100 : (window.pageYOffset / scrollable) * 100;
233
234    for (var i = 0; i < SCROLL_THRESHOLDS.length; i++) {
235      var threshold = SCROLL_THRESHOLDS[i];
236      // Half a percent of tolerance: smooth scrolling and the pinned
237      // highlights section rarely land exactly on the final pixel.
238      if (!reachedDepth[threshold] && percent >= threshold - 0.5) {
239        reachedDepth[threshold] = true;
240        track("scroll_depth", { percent_scrolled: threshold });
241      }
242    }
243  }
244
245  window.addEventListener(
246    "scroll",
247    safely(function () {
248      // Trailing throttle on a timer rather than requestAnimationFrame:
249      // rAF is suspended entirely while a tab is in the background, which
250      // silently strands the measurement.
251      if (scrollTimer) return;
252      scrollTimer = window.setTimeout(function () {
253        scrollTimer = null;
254        measureScrollDepth();
255      }, 100);
256    }),
257    { passive: true } // custom.js owns the non-passive wheel handler.
258  );
259
260  // ---------------------------------------------------------------------
261  // Video engagement. Media events do not bubble, so these listen on the
262  // capture phase, which is the only way to catch them at document level.
263  // ---------------------------------------------------------------------
264
265  document.addEventListener(
266    "play",
267    safely(function (event) {
268      var video = event.target;
269      if (!video || video.tagName !== "VIDEO") return;
270      // Hero and background loops autoplay on load. Firing on those would
271      // record an engagement the visitor never chose.
272      if (video.hasAttribute("autoplay")) return;
273      track("video_play", { video_name: fileNameOf(video.currentSrc || video.src) });
274    }),
275    true
276  );
277
278  document.addEventListener(
279    "ended",
280    safely(function (event) {
281      var video = event.target;
282      if (!video || video.tagName !== "VIDEO") return;
283      if (video.hasAttribute("autoplay")) return;
284      track("video_complete", { video_name: fileNameOf(video.currentSrc || video.src) });
285    }),
286    true
287  );
288
289  // ---------------------------------------------------------------------
290  // Forms. form_start fires on first interaction with any field, which is
291  // the number that matters -- it is the gap between form_start and
292  // form_submit that tells you the form is being abandoned.
293  // ---------------------------------------------------------------------
294
295  var startedForms = [];
296
297  function formNameOf(form) {
298    return form.getAttribute("id") || form.getAttribute("name") || (form.closest(".modal") ? form.closest(".modal").id : "") || "unnamed_form";
299  }
300
301  document.addEventListener(
302    "focusin",
303    safely(function (event) {
304      if (!event.target || typeof event.target.closest !== "function") return;
305      var field = event.target.closest("input, textarea, select");
306      if (!field) return;
307      var form = field.form || field.closest("form");
308      if (!form || startedForms.indexOf(form) !== -1) return;
309      startedForms.push(form);
310      track("form_start", { form_id: formNameOf(form) });
311    })
312  );
313
314  document.addEventListener(
315    "submit",
316    safely(function (event) {
317      if (!event.target || event.target.tagName !== "FORM") return;
318      track("form_submit", { form_id: formNameOf(event.target) });
319    })
320  );
321
322  // ---------------------------------------------------------------------
323  // Progress through the pinned highlights section.
324  //
325  // That section hijacks wheel and touch scrolling, so a visitor who gives
326  // up part way through simply stops -- there is no other signal for it.
327  // custom.js drives it by toggling `.active`, and this watches that class
328  // rather than modifying custom.js.
329  // ---------------------------------------------------------------------
330
331  function trackHighlightsProgress() {
332    var items = document.querySelectorAll(".feature-item");
333    if (!items.length) return;
334
335    var seenSteps = {};
336
337    function recordStep(item) {
338      var index = Array.prototype.indexOf.call(items, item);
339      if (index < 0 || seenSteps[index]) return;
340      seenSteps[index] = true;
341      track("highlights_step", {
342        step_index: index + 1,
343        step_total: items.length,
344        step_label: textOf(item).slice(0, 60)
345      });
346    }
347
348    var observer = new MutationObserver(
349      safely(function (mutations) {
350        mutations.forEach(function (mutation) {
351          if (mutation.target.classList.contains("active")) recordStep(mutation.target);
352        });
353      })
354    );
355
356    Array.prototype.forEach.call(items, function (item) {
357      observer.observe(item, { attributes: true, attributeFilter: ["class"] });
358      // custom.js activates the first step on DOMContentLoaded, which may
359      // already have happened by the time this runs.
360      if (item.classList.contains("active")) recordStep(item);
361    });
362  }
363
364  function initialise() {
365    trackHighlightsProgress();
366    // A page that fits the viewport fires no scroll event, so it would
367    // otherwise never register as read at all.
368    measureScrollDepth();
369  }
370
371  if (document.readyState === "loading") {
372    document.addEventListener("DOMContentLoaded", safely(initialise));
373  } else {
374    safely(initialise)();
375  }
376})();

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.