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.