1/** 2 * Podhoc â Presentational Web GA4 Event Tracking 3 * 4 * Provides declarative (data-attribute) and programmatic event tracking. 5 * 6 * Declarative usage â add attributes to any element: 7 * <a href="/pricing" 8 * data-ga-event="cta_click" 9 * data-ga-category="engagement" 10 * data-ga-label="nav_pricing">Pricing</a> 11 * 12 * The script auto-binds click listeners via event delegation so it works for 13 * elements rendered after DOMContentLoaded (e.g. cookie banner, modals). 14 * 15 * All calls are wrapped in try/catch so analytics failures never break the site. 16 */ 17 18(function () { 19 'use strict'; 20 21 // Consent / Do-Not-Track gate ----------------------------------------- 22 // Respect navigator.doNotTrack and the consent decision stored by the 23 // cookie banner in main.js. Source-of-truth order: 24 // 1. Do-Not-Track browser signal (hard precedence â never tracks). 25 // 2. Shared `podhoc_consent` cookie (cross-subdomain, written by both 26 // www and app.podhoc.com). Format: 27 // `v1:analytics=<0|1>,marketing=<0|1>,ts=<unix-seconds>`. 28 // 3. Legacy per-origin localStorage preference (backward compat). 29 var PREFS_KEY = 'podhoc_cookie_preferences'; 30 31 function isTrackingAllowed() { 32 // Respect Do-Not-Track browser signal (unchanged precedence) 33 if ( 34 navigator.doNotTrack === '1' || 35 (window.doNotTrack && window.doNotTrack === '1') || 36 navigator.msDoNotTrack === '1' 37 ) { 38 return false; 39 } 40 // Shared consent cookie wins over localStorage when present. Parsed with 41 // the STRICT shared parser (window.podhocParseConsent, consent-parse.js) so 42 // this surface accepts/rejects EXACTLY what the webapp's parseConsentValue 43 // does â never grant where the webapp denies. A garbled / unknown-key 44 // cookie â null â fall through to the legacy localStorage check. 45 try { 46 var c = document.cookie.match(/(?:^|; )podhoc_consent=([^;]+)/); 47 if (c && typeof window.podhocParseConsent === 'function') { 48 var parsed = window.podhocParseConsent(decodeURIComponent(c[1])); 49 if (parsed) return parsed.analytics === true; 50 } 51 } catch { 52 /* ignore */ 53 } 54 // Legacy localStorage fallback (analytics must be opted-in) 55 try { 56 var prefs = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); 57 if (prefs.analytics === false) return false; 58 } catch { 59 /* ignore */ 60 } 61 return true; 62 } 63 64 // Safe gtag wrapper --------------------------------------------------- 65 // 66 // A/B enrichment: baseof.html's <head> inlines 67 // assets/js/experiment-event-params.js, which installs 68 // window.__podhocAbParams â the SAME helper baseof.html's own funnel-event 69 // IIFE calls as ab(). Routing this file's fourteen events through it too 70 // means EVERY marketing event (faq_toggle, sample_play, nav_click, 71 // pricing_plan_click, extension_download_click, â¦) carries the active 72 // assignment: flat experiment_id/variant_id with one experiment running, 73 // the composite `experiments` param with several. Before this, only the 74 // three events defined in baseof.html were enriched, so every other event 75 // was readable per-variant ONLY through a user-scoped funnel â correct, but 76 // it ruled out the event-level quick table for most experiments. 77 // 78 // Guarded, not assumed: the helper ships inside the prod-only GA block, so 79 // in dev (and if the head script ever fails) this degrades to exactly the 80 // previous behaviour. Never throws â the whole call is inside the try below. 81 function sendEvent(eventName, params) { 82 try { 83 if (!isTrackingAllowed()) return; 84 if (typeof gtag === 'function') { 85 var enriched = params || {}; 86 if (typeof window.__podhocAbParams === 'function') { 87 enriched = window.__podhocAbParams(enriched); 88 } 89 gtag('event', eventName, enriched); 90 } 91 } catch { 92 /* analytics must never break the site */ 93 } 94 } 95 96 // ===================================================================== 97 // 1. Declarative data-attribute tracking (event delegation) 98 // ===================================================================== 99 document.addEventListener( 100 'click', 101 function (e) { 102 // Walk up to find the closest element with data-ga-event 103 var target = e.target; 104 while (target && target !== document) { 105 if (target.getAttribute && target.getAttribute('data-ga-event')) { 106 sendEvent(target.getAttribute('data-ga-event'), { 107 event_category: 108 target.getAttribute('data-ga-category') || 'general', 109 event_label: target.getAttribute('data-ga-label') || '', 110 link_url: target.href || target.getAttribute('href') || '', 111 }); 112 return; 113 } 114 target = target.parentElement; 115 } 116 }, 117 { capture: true, passive: true }, 118 ); 119 120 // ===================================================================== 121 // 2â10. All DOMContentLoaded setup consolidated into a single listener 122 // ===================================================================== 123 document.addEventListener('DOMContentLoaded', function () { 124 // --- 2. Navigation link tracking (header + mobile menu + footer) --- 125 126 // Header nav links 127 var navLinks = document.querySelectorAll('.header__nav-link'); 128 navLinks.forEach(function (link) { 129 link.addEventListener('click', function () { 130 // Skip if already handled by declarative data-attribute tracking 131 if (link.hasAttribute('data-ga-event')) return; 132 sendEvent('nav_click', { 133 event_category: 'navigation', 134 event_label: link.textContent.trim(), 135 link_url: link.href || '', 136 }); 137 }); 138 }); 139 140 // Mobile menu links 141 var mobileLinks = document.querySelectorAll('.mobile-menu a'); 142 mobileLinks.forEach(function (link) { 143 link.addEventListener('click', function () { 144 if (link.hasAttribute('data-ga-event')) return; 145 sendEvent('nav_click', { 146 event_category: 'navigation', 147 event_label: 'mobile_' + link.textContent.trim(), 148 link_url: link.href || '', 149 }); 150 }); 151 }); 152 153 // Footer links 154 var footerLinks = document.querySelectorAll('footer a'); 155 footerLinks.forEach(function (link) { 156 link.addEventListener('click', function () { 157 if (link.hasAttribute('data-ga-event')) return; 158 sendEvent('footer_link_click', { 159 event_category: 'navigation', 160 event_label: link.textContent.trim(), 161 link_url: link.href || '', 162 }); 163 }); 164 }); 165 166 // Social media links (detect by href containing known social domains) 167 var socialDomains = [ 168 'twitter.com', 169 'x.com', 170 'linkedin.com', 171 'github.com', 172 'youtube.com', 173 'facebook.com', 174 'instagram.com', 175 ]; 176 var allLinks = document.querySelectorAll('a[href]'); 177 allLinks.forEach(function (link) { 178 var href = link.href || ''; 179 socialDomains.forEach(function (domain) { 180 if (href.indexOf(domain) !== -1) { 181 link.addEventListener('click', function () { 182 if (link.hasAttribute('data-ga-event')) return; 183 sendEvent('social_link_click', { 184 event_category: 'social', 185 event_label: domain.replace('.com', ''), 186 link_url: href, 187 }); 188 }); 189 } 190 }); 191 }); 192 193 // --- 3. CTA / Get Started button tracking --- 194 195 // Primary CTA buttons (btn--accent in hero, pricing, etc.) 196 var ctaButtons = document.querySelectorAll( 197 '.hero__cta .btn, .pricing .btn, [data-cta]', 198 ); 199 ctaButtons.forEach(function (btn) { 200 btn.addEventListener('click', function () { 201 if (btn.hasAttribute('data-ga-event')) return; 202 // Also skip anything the EXPERIMENT-AWARE emitter in baseof.html 203 // already handles. That handler keys on `data-ga-cta` and sends a 204 // stable semantic label (hero_start_creating, pricing_free, â¦) with 205 // the A/B params merged in; this one derives its label from the live 206 // DOM and merges nothing. Without this guard BOTH fire on every hero 207 // and pricing CTA click. Measured over 2026-07-21 -> 2026-09-04 208 // (docs/research/2026-09-04-ab-experiments-wave1-results.md §4.2): 209 // hero_start_creating 46 semantic events vs 46 text-labelled ones, 210 // hero_hear_examples 4 vs 4 â every such click counted twice. 211 // 212 // The duplicate was not merely redundant, it was corrupting: 213 // - `textContent` on an A/B anchor returns BOTH arms fused, because 214 // the CSS-toggle mechanism ships every variant in the DOM. Real 215 // labels recorded: "Comenzar GratisReclama tus 50 créditos 216 // gratis", "Inizia gratisRichiedi i tuoi 50 crediti gratis". 217 // - The DOM is read at click time, so a browser-translated page 218 // rewrites the label. GA4 holds a "å è´¹å¼å§" label on a site with 219 // no zh locale. 220 // A label derived from rendered text is not a stable identifier. 221 if (btn.hasAttribute('data-ga-cta')) return; 222 sendEvent('cta_click', { 223 event_category: 'engagement', 224 event_label: btn.textContent.trim().substring(0, 60), 225 link_url: btn.href || btn.getAttribute('href') || '', 226 }); 227 }); 228 }); 229 230 // --- 4. Language switcher tracking --- 231 232 var langSelectors = document.querySelectorAll('.header__lang');
233 langSelectors.forEach(function (select) { 234 select.addEventListener('change', function () { 235 sendEvent('language_switch', { 236 event_category: 'engagement', 237 event_label: select.value, 238 }); 239 }); 240 }); 241 242 // --- 5. FAQ accordion tracking --- 243 244 var faqQuestions = document.querySelectorAll('.faq-item__question'); 245 faqQuestions.forEach(function (question) { 246 question.addEventListener('click', function () { 247 if (question.hasAttribute('data-ga-event')) return; 248 var parent = question.closest('.faq-item'); 249 var wasActive = parent && parent.classList.contains('active'); 250 sendEvent('faq_toggle', { 251 event_category: 'engagement', 252 event_label: question.textContent.trim().substring(0, 80), 253 value: wasActive ? 0 : 1, // 1 = opening, 0 = closing 254 }); 255 }); 256 }); 257 258 // --- 6. Sample audio player tracking --- 259 260 var playButtons = document.querySelectorAll('.sample-card__play-btn'); 261 playButtons.forEach(function (btn) { 262 btn.addEventListener('click', function () { 263 if (btn.hasAttribute('data-ga-event')) return; 264 var card = btn.closest('.sample-card'); 265 var style = card ? card.getAttribute('data-style') || '' : ''; 266 sendEvent('sample_play', { 267 event_category: 'engagement', 268 event_label: style, 269 }); 270 }); 271 }); 272 273 // Source selector (content source switch) 274 var sourceCards = document.querySelectorAll('.source-card'); 275 sourceCards.forEach(function (card) { 276 card.addEventListener('click', function () { 277 if (card.hasAttribute('data-ga-event')) return; 278 sendEvent('source_select', { 279 event_category: 'engagement', 280 event_label: card.getAttribute('data-folder') || '', 281 }); 282 }); 283 }); 284 285 // Example filter tabs 286 var filters = document.querySelectorAll('.examples__filter'); 287 filters.forEach(function (filter) { 288 filter.addEventListener('click', function () { 289 if (filter.hasAttribute('data-ga-event')) return; 290 sendEvent('example_filter', { 291 event_category: 'engagement', 292 event_label: filter.getAttribute('data-filter') || '', 293 }); 294 }); 295 }); 296 297 // --- 8. Cookie banner interaction tracking --- 298 299 var banner = document.querySelector('.cookie-banner'); 300 if (banner) { 301 var actions = banner.querySelectorAll('[data-action]'); 302 actions.forEach(function (btn) { 303 btn.addEventListener('click', function () { 304 // Defer the event send so the cookie banner handler stores the 305 // preference in localStorage before isTrackingAllowed() checks it. 306 setTimeout(function () { 307 sendEvent('cookie_consent', { 308 event_category: 'privacy', 309 event_label: btn.getAttribute('data-action') || '', 310 }); 311 }, 0); 312 }); 313 }); 314 } 315 316 // --- 9. Pricing plan click tracking --- 317 318 var pricingButtons = document.querySelectorAll( 319 '.pricing-card .btn, .pricing-card__cta', 320 ); 321 pricingButtons.forEach(function (btn) { 322 btn.addEventListener('click', function () { 323 if (btn.hasAttribute('data-ga-event')) return; 324 var card = btn.closest('.pricing-card'); 325 var planName = card 326 ? (card.querySelector('.pricing-card__name') || {}).textContent || '' 327 : ''; 328 sendEvent('pricing_plan_click', { 329 event_category: 'conversion', 330 event_label: planName.trim(), 331 }); 332 }); 333 }); 334 335 // --- 10. Extension download/install button tracking --- 336 337 var extButtons = document.querySelectorAll( 338 '.extension-card .btn, .extension-card a, [data-extension-download]', 339 ); 340 extButtons.forEach(function (btn) { 341 btn.addEventListener('click', function () { 342 if (btn.hasAttribute('data-ga-event')) return; 343 sendEvent('extension_download_click', { 344 event_category: 'conversion', 345 event_label: btn.textContent.trim().substring(0, 60), 346 link_url: btn.href || '', 347 }); 348 }); 349 }); 350 }); 351 352 // ===================================================================== 353 // 7. Scroll depth tracking (25%, 50%, 75%, 100%) 354 // ===================================================================== 355 (function () { 356 var thresholds = [25, 50, 75, 100]; 357 var reached = {}; 358 var ticking = false;
359 360 function getScrollPercent() { 361 var docHeight = Math.max( 362 document.body.scrollHeight, 363 document.documentElement.scrollHeight, 364 ); 365 var winHeight = window.innerHeight; 366 var scrollTop = window.scrollY; 367 if (docHeight <= winHeight) return 100; 368 return Math.round((scrollTop / (docHeight - winHeight)) * 100); 369 } 370 371 function checkScroll() { 372 var pct = getScrollPercent(); 373 thresholds.forEach(function (threshold) { 374 if (pct >= threshold && !reached[threshold]) { 375 reached[threshold] = true; 376 sendEvent('scroll_depth', { 377 event_category: 'engagement', 378 event_label: threshold + '%', 379 value: threshold, 380 }); 381 } 382 }); 383 ticking = false; 384 } 385 386 window.addEventListener( 387 'scroll', 388 function () { 389 if (!ticking) { 390 requestAnimationFrame(checkScroll); 391 ticking = true; 392 } 393 }, 394 { passive: true }, 395 ); 396 })(); 397})();
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.