PageSourceSearch

https://vmppass.com/wp-content/themes/vmpPass/assets/js/vmp-autotrack.js?ver=1785655570

js vmppass.com collected 2026-10-02 01:48:17 UTC 35,053 bytes, 738 lines download raw bytes

1/* ===========================================================================
2   VMP Pass — Universal auto-capture engine  (GA4 + Meta Pixel)
3   ---------------------------------------------------------------------------
4   Captures EVERY button and EVERY user action site-wide with no per-element
5   wiring. It binds to `document`, not to markup, so it covers every theme
6   template automatically — including ones added later.
7
8   Four rules make this HIGH-QUALITY data rather than merely high-volume:
9
10     1. FEW EVENT NAMES, RICH PARAMS. GA4 caps a property at 500 distinct event
11        names; exceeding it silently drops data forever. We emit ~25 names and
12        put the detail in parameters.
13     2. EVERY event carries the same context block (page_type, template,
14        section, component, element_*, lang), so any event slices the same way
15        in GA4 Explorations and Meta breakdowns.
16     3. GA4 limits are enforced here (param name <=40 chars, value <=100 chars,
17        <=25 params) so events are never silently rejected server-side.
18     4. Consent-aware: GA rides Consent Mode; Meta is gated on `marketing`.
19
20   Transport is vmp-tracking.js (window.vmpTrack). That file is deferred, so if
21   it has not executed yet events buffer here and flush on arrival.
22   =========================================================================== */
23(function (w, d) {
24   'use strict';
25   if (w.__vmpAutoTrackLoaded) { return; }
26   w.__vmpAutoTrackLoaded = true;
27
28   var CFG   = w.vmpAnalyticsConfig || {};
29   var DEBUG = !!CFG.debug || /[?&]vmpdebug=1/.test(w.location.search);
30   var NOW   = function () { return Date.now ? Date.now() : new Date().getTime(); };
31
32   /* ── 0. utilities ────────────────────────────────────────────────────── */
33   function log() {
34      if (!DEBUG || !w.console) { return; }
35      try { console.log.apply(console, ['%c[vmp]', 'color:#5b8def;font-weight:700'].concat([].slice.call(arguments))); } catch (e) {}
36   }
37
38   function clamp(s, n) {
39      s = (s == null ? '' : String(s));
40      s = s.replace(/\s+/g, ' ').trim();   // "Get\n  started" -> "Get started"
41      return s.length > n ? s.slice(0, n) : s;
42   }
43
44   /** GA4 param names must be [a-z0-9_] and <= 40 chars. */
45   function safeKey(k) {
46      return String(k).toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 40);
47   }
48
49   function throttle(fn, ms) {
50      var last = 0, timer = null, lastArgs;
51      return function () {
52         lastArgs = arguments;
53         var now = NOW();
54         if (now - last >= ms) { last = now; fn.apply(null, lastArgs); }
55         else if (!timer) {
56            timer = setTimeout(function () { timer = null; last = NOW(); fn.apply(null, lastArgs); }, ms - (now - last));
57         }
58      };
59   }
60
61   function closest(el, sel) {
62      if (!el || el.nodeType !== 1) { return null; }
63      if (el.closest) { try { return el.closest(sel); } catch (e) { return null; } }
64      while (el && el.nodeType === 1) {
65         if (el.matches && el.matches(sel)) { return el; }
66         el = el.parentElement;
67      }
68      return null;
69   }
70
71   function attr(el, name) {
72      return (el && el.getAttribute) ? (el.getAttribute(name) || '') : '';
73   }
74
75   /* ── 1. consent — GA rides Consent Mode; Meta gated on `marketing` ───── */
76   function consent() {
77      try {
78         var m = d.cookie.match(/(?:^|;\s*)vmp_cookie_consent=([^;]*)/);
79         if (!m) { return null; }              // undecided
80         return JSON.parse(decodeURIComponent(m[1]));
81      } catch (e) { return null; }
82   }
83   function marketingAllowed() { var c = consent(); return !!(c && c.marketing); }
84
85   /* ── 2. session identity ─────────────────────────────────────────────── */
86   var SESSION_KEY = 'vmpSess';
87   var SESSION_TTL = 30 * 60 * 1000;          // GA4-style 30 min inactivity window
88
89   function uuid() {
90      // Correlation id for GA4 <-> Meta CAPI matching. Not security-sensitive.
91      try {
92         if (w.crypto && w.crypto.randomUUID) { return w.crypto.randomUUID(); }
93         if (w.crypto && w.crypto.getRandomValues) {
94            var b = new Uint8Array(16); w.crypto.getRandomValues(b);
95            b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
96            var h = []; for (var i = 0; i < 16; i++) { h.push((b[i] + 0x100).toString(16).slice(1)); }
97            return h.slice(0, 4).join('') + '-' + h.slice(4, 6).join('') + '-' + h.slice(6, 8).join('') + '-' + h.slice(8, 10).join('') + '-' + h.slice(10, 16).join('');
98         }
99      } catch (e) {}
100      return 'x' + NOW().toString(36) + Math.random().toString(36).slice(2, 10);
101   }
102
103   function session() {
104      var s = null;
105      try { s = JSON.parse(w.sessionStorage.getItem(SESSION_KEY) || 'null'); } catch (e) {}
106      var now = NOW();
107      if (!s || (now - s.last) > SESSION_TTL) { s = { id: uuid(), start: now, pages: 0, events: 0 }; }
108      s.last = now;
109      try { w.sessionStorage.setItem(SESSION_KEY, JSON.stringify(s)); } catch (e) {}
110      return s;
111   }
112   var SESS = session();
113
114   function deviceCtx() {
115      var nav = w.navigator || {};
116      var conn = nav.connection || nav.mozConnection || nav.webkitConnection || {};
117      var vw = w.innerWidth || 0;
118      return {
119         viewport_w: vw,
120         viewport_h: w.innerHeight || 0,
121         device_type: vw < 768 ? 'mobile' : (vw < 1024 ? 'tablet' : 'desktop'),
122         touch: ('ontouchstart' in w) ? 1 : 0,
123         connection: clamp(conn.effectiveType || '', 20)
124      };
125   }
126
127   /* ── 3. element context — the heart of the data quality ──────────────── */
128
129   /** Best human label for an element, in descending order of intent. */
130   function labelOf(el) {
131      if (!el) { return ''; }
132      var v = attr(el, 'data-vmp-label')
133           || attr(el, 'aria-label')
134           || clamp(el.textContent, 100)
135           || attr(el, 'title')
136           || attr(el, 'value')
137           || attr(el, 'name')
138           || attr(el, 'placeholder');
139      if (!v && el.querySelector) {
140         var img = el.querySelector('img[alt]');
141         if (img) { v = attr(img, 'alt'); }
142      }
143      if (!v && el.querySelector) {
144         // icon-only buttons still deserve an identity
145         var icon = el.querySelector('[class*="bi-"],[class*="fa-"]');
146         if (icon) {
147            var cls = (icon.className || '').toString().split(/\s+/).filter(function (c) { return /^(bi|fa)-/.test(c); })[0];
148            if (cls) { v = 'icon:' + cls; }
149         }
150      }
151      return clamp(v, 100);
152   }
153
154   /** What KIND of control was pressed. */
155   function elementKind(el) {
156      if (attr(el, 'data-vmp-kind')) { return clamp(attr(el, 'data-vmp-kind'), 40); }
157      var tag = (el.tagName || '').toLowerCase();
158      var type = (attr(el, 'type') || '').toLowerCase();
159      var bs = attr(el, 'data-bs-toggle');
160      if (bs) { return 'bs_' + clamp(bs, 24); }
161      if (tag === 'a') {
162         var href = attr(el, 'href');
163         if (/^mailto:/i.test(href)) { return 'mailto'; }
164         if (/^tel:/i.test(href)) { return 'tel'; }
165         if (/^#/.test(href)) { return 'anchor'; }
166         return 'link';
167      }
168      if (tag === 'button') { return type === 'submit' ? 'submit' : 'button'; }
169      if (tag === 'input') { return type || 'input'; }
170      if (tag === 'summary') { return 'disclosure'; }
171      if (tag === 'label') { return 'label'; }
172      if (tag === 'select') { return 'select'; }
173      var role = attr(el, 'role');
174      if (role) { return 'role_' + clamp(role, 24); }
175      return tag || 'element';
176   }
177
178   /** Nearest named region — explicit data-vmp-section wins, then landmarks. */
179   function sectionOf(el) {
180      var s = closest(el, '[data-vmp-section]');
181      if (s) { return clamp(attr(s, 'data-vmp-section'), 60); }
182      var node = closest(el, 'section[id],[data-section],header,footer,nav,main,aside,dialog,form[id]');
183      if (!node) { return 'unclassified'; }
184      var tag = (node.tagName || '').toLowerCase();
185      if (tag === 'header') { return 'site_header'; }
186      if (tag === 'footer') { return 'site_footer'; }
187      if (tag === 'nav')    { return 'navigation'; }
188      if (tag === 'aside')  { return 'sidebar'; }
189      if (tag === 'main')   { return 'main'; }
190      return clamp(attr(node, 'data-section') || node.id || tag, 60);
191   }
192
193   /** Nearest UI component wrapper. */
194   function componentOf(el) {
195      var c = closest(el, '[data-vmp-component]');
196      if (c) { return clamp(attr(c, 'data-vmp-component'), 40); }
197      var known = closest(el, '.modal,.offcanvas,.accordion,.tab-pane,.carousel,.dropdown-menu,.card,table,.navbar,.pagination,form');
198      if (!known) { return 'page'; }
199      var cl = (known.className || '').toString();
200      if (/\bmodal\b/.test(cl))         { return 'modal'; }
201      if (/\boffcanvas\b/.test(cl))     { return 'offcanvas'; }
202      if (/\baccordion\b/.test(cl))     { return 'accordion'; }
203      if (/\btab-pane\b/.test(cl))      { return 'tabs'; }
204      if (/\bcarousel\b/.test(cl))      { return 'carousel'; }
205      if (/\bdropdown-menu\b/.test(cl)) { return 'dropdown'; }
206      if (/\bnavbar\b/.test(cl))        { return 'navbar'; }
207      if (/\bpagination\b/.test(cl))    { return 'pagination'; }
208      if (/\bcard\b/.test(cl))          { return 'card'; }
209      var tag = (known.tagName || '').toLowerCase();
210      return tag === 'form' ? 'form' : (tag === 'table' ? 'table' : 'page');
211   }
212
213   /** Short CSS-ish path so you can find the exact control in the DOM later. */
214   function pathOf(el) {
215      var parts = [], node = el, hops = 0;
216      while (node && node.nodeType === 1 && hops < 4) {
217         var seg = (node.tagName || '').toLowerCase();
218         if (node.id) { parts.unshift(seg + '#' + node.id); break; }
219         var cl = (node.className || '').toString().split(/\s+/).filter(
219Boolean)[0];
220         if (cl) { seg += '.' + cl; }
221         parts.unshift(seg);
222         node = node.parentElement; hops++;
223      }
224      return clamp(parts.join('>'), 100);
225   }
226
227   /** Full context block attached to every element-scoped event. */
228   function ctxOf(el) {
229      var o = {
230         element_text:    labelOf(el),
231         element_kind:    elementKind(el),
232         element_id:      clamp(el.id || '', 60),
233         element_classes: clamp((el.className || '').toString().split(/\s+/).filter(Boolean).slice(0, 3).join(' '), 100),
234         element_path:    pathOf(el),
235         section:         sectionOf(el),
236         component:       componentOf(el)
237      };
238      var href = attr(el, 'href');
239      if (href) {
240         o.link_url = clamp(href, 100);
241         try { o.link_domain = clamp(new URL(href, w.location.href).hostname, 60); } catch (e) {}
242      }
243      var groupEl = closest(el, '[data-vmp-group]');
244      if (groupEl) { o.item_group = clamp(attr(groupEl, 'data-vmp-group'), 60); }
245      return o;
246   }
247
248   /* ── 4. emit: normalise, enforce GA4 limits, hand to vmp-tracking.js ─── */
249   var PAGE = {
250      page_type: clamp(CFG.pageType || 'unknown', 40),
251      template:  clamp(CFG.template || '', 60),
252      lang:      clamp(CFG.lang || d.documentElement.getAttribute('lang') || '', 12),
253      logged_in: CFG.loggedIn ? 1 : 0,
254      page_path: clamp(w.location.pathname, 100)
255   };
256
257   var buffer = [], flushTimer = null;
258
259   function deliver(name, params) {
260      if (typeof w.vmpTrack !== 'function') { return false; }
261      try { w.vmpTrack(name, params); } catch (e) { log('deliver failed', e); }
262      return true;
263   }
264
265   function flushBuffer() {
266      if (!buffer.length) { return; }
267      if (typeof w.vmpTrack !== 'function') {
268         if (!flushTimer) { flushTimer = setTimeout(function () { flushTimer = null; flushBuffer(); }, 400); }
269         return;
270      }
271      var pending = buffer; buffer = [];
272      for (var i = 0; i < pending.length; i++) { deliver(pending[i][0], pending[i][1]); }
273   }
274
275   /** track(name, params) — name from the fixed vocabulary, detail in params. */
276   function track(name, params) {
277      params = params || {};
278      var out = {};
279      // context first so it can never be truncated away by the 25-param cap
280      out.page_type  = PAGE.page_type;
281      out.template   = PAGE.template;
282      out.lang       = PAGE.lang;
283      out.logged_in  = PAGE.logged_in;
284      out.page_path  = PAGE.page_path;
285      out.session_id = SESS.id;
286      out.event_id   = uuid();          // pixel <-> Conversions API de-duplication
287
288      var keys = Object.keys(params), n = 0;
289      for (var i = 0; i < keys.length && n < 18; i++) {   // 7 context + 18 = GA4's 25
290         var v = params[keys[i]];
291         if (v === undefined || v === null || v === '') { continue; }
292         out[safeKey(keys[i])] = (typeof v === 'number' || typeof v === 'boolean') ? v : clamp(v, 100);
293         n++;
294      }
295
296      SESS.events++;
297      try { w.sessionStorage.setItem(SESSION_KEY, JSON.stringify(SESS)); } catch (e) {}
298
299      log(name, out);
300      if (!deliver(name, out)) { buffer.push([name, out]); flushBuffer(); }
301      return out.event_id;
302   }
303
304   w.vmpAutoTrack = {
305      track: track, ctxOf: ctxOf,
306      session: function () { return SESS; },
307      debug: function () { DEBUG = true; log('debug logging on'); }
308   };
309
310   /* ── 5. page view (enhanced) ─────────────────────────────────────────── */
311   (function pageView() {
312      var q = {};
313      try {
314         var sp = new URLSearchParams(w.location.search);
315         ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'gclid', 'fbclid'].forEach(function (k) {
316            var v = sp.get(k); if (v) { q[k] = clamp(v, 100); }
317         });
318      } catch (e) {}
319      var dev = deviceCtx();
320      track('page_view_enhanced', {
321         page_title:     clamp(d.title, 100),
322         page_referrer:  clamp(d.referrer, 100),
323         device_type:    dev.device_type,
324         viewport_w:     dev.viewport_w,
325         viewport_h:     dev.viewport_h,
326         connection:     dev.connection,
327         touch:          dev.touch,
328         utm_source:     q.utm_source,
329         utm_medium:     q.utm_medium,
330         utm_campaign:   q.utm_campaign,
331         ref:            q.ref,
332         is_new_session: SESS.pages === 0 ? 1 : 0
333      });
334      SESS.pages++;
335   })();
336
337   /* ── 6. CLICKS — every button and link on every template ─────────────── */
338   var INTERACTIVE = 'a,button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="switch"],[role="checkbox"],[role="radio"],[onclick],[data-bs-toggle],[data-vmp-event],.btn,.nav-link,.dropdown-item,.page-link';
339
340   /** Clicks that hit nothing interactive — a strong UX-friction signal. */
341   var deadClick = throttle(function (target) {
342      try {
343         if (w.getSelection && String(w.getSelection()).length > 0) { return; } // was selecting text
344         // only report clicks on things that LOOK clickable
345         if (!closest(target, 'img,svg,i,.card,[class*="icon"],[class*="btn"],[class*="tile"]')) { return; }
346         track('dead_click', ctxOf(target));
347      } catch (e) {}
348   }, 2000);
349
350   var lastClick = { key: '', t: 0, n: 0 };
351
352   d.addEventListener('click', function (ev) {
353      var target = ev.target;
354      if (!target || target.nodeType !== 1) { return; }
355      var el = closest(target, INTERACTIVE);
356      if (!el) { deadClick(target); return; }
357
358      var c = ctxOf(el);
359
360      // rage-click: same control 3+ times inside 1s => friction
361      var key = c.element_path + '|' + c.element_text, now = NOW();
362      if (key === lastClick.key && (now - lastClick.t) < 1000) {
363         if (++lastClick.n === 3) { track('rage_click', c); }
364      } else { lastClick.n = 1; }
365      lastClick.key = key; lastClick.t = now;
366
367      track('element_click', c);
368
369      // link-intent sub-events, kept separate so each can be a GA4 key event
370      var href = attr(el, 'href');
371      if (href) {
372         if (/^mailto:/i.test(href))   { track('mailto_click', c); }
373         else if (/^tel:/i.test(href)) { track('tel_click', c); }
374         else if (/\.(pdf|zip|dmg|exe|msi|pkg|apk|csv|xlsx?|docx?|pptx?|txt|rar|7z)(\?|#|$)/i.test(href)) {
375            track('file_download', {
376               file_name: clamp(href.split('/').pop(), 100),
377               file_extension: clamp((href.split('.').pop() || '').split(/[?#]/)[0], 10),
378               link_url: c.link_url, section: c.section, element_text: c.element_text
379            });
380         }
381         else if (/^https?:/i.test(href) && c.link_domain && c.link_domain !== w.location.hostname) {
382            track('outbound_click', c);
383         }
384         else if (/^#./.test(href)) { track('anchor_click', c); }
385      }
386
387      // explicit business intent declared in the template
388      var semantic = attr(el, 'data-vmp-event');
389      if (semantic) {
390         var p = {
391            element_text: c.element_text, section: c.section, component: c.component,
392            location: attr(el, 'data-vmp-label') || c.element_text
393         };
394         var val = attr(el, 'data-vmp-value');
395         if (val) { p.value = parseFloat(val) || 0; p.currency = attr(el, 'data-vmp-currency') || 'USD'; }
396         var item = attr(el, 'data-vmp-item'); if (item) { p.item_name = clamp(item, 100); }
397         track(semantic, p);
398      }
399   }, true);
400
401   /* Keyboard activation — accessibility users must not be invisible. */
402   d.addEventListener('keydown', function (ev) {
403      if (ev.key !== 'Enter' && ev.key !== ' ') { return; }
404      var el = closest(ev.target, '[role="button"],[role="tab"],[role="switch"],summary');
405      if (!el) { return; }
406      var c = ctxOf(el); c.input_method = 'keyboard';
407      track('element_click', c);
408   }, true);
409
410   /* ── 7. FORMS — start, field completion, errors, submit, abandonment ── */
411   var formStates = [];
412
413   /**
414    * The group a field belongs to. A real <form> wins; otherwise fall back to the
415    * nearest named region so form-less widgets (password generator, username
416    * generator, pricing filters, calculators) still report start/completion.
417    */
418   function fieldGroup(el) {
419      return closest(el, 'form,[data-vmp-form]') ||
420             closest(el, '[data-vmp-section],section[id],main') || d.body;
421   }
422
423   function formState(f) {
424      for (var i = 0; i < formStates.length; i++) { if (formStates[i].el === f) { return formStates[i]; } }
425      var isForm = (f.tagName || '').toLowerCase() === 'form';
426      var id = attr(f, 'data-vmp-form') || f.id || attr(f, 'name') ||
427               (isForm ? attr(f, 'action') : attr(f, 'data-vmp-section')) ||
428               (isForm ? 'form' : (f.tagName || 'group').toLowerCase());
429      var s = {
430         el: f,
431         isForm: isForm,
432         id: clamp((isForm ? '' : 'group_') + id, 60),
433         started: false, submitted: false, fields: {}, fieldCount: 0,
434         firstTouch: 0, section: sectionOf(f)
435      };
436      formStates.push(s);
437      return s;
438   }
439   function fieldName(el) {
440      return clamp(attr(el, 'name') || el.id || attr(el, 'placeholder') || attr(el, 'aria-label') || (el.tagName || '').toLowerCase(), 60);
441   }
442   /** Never send the VALUE of anything that could hold personal or secret data. */
443   function isSensitive(el) {
444      var t = (attr(el, 'type') || '').toLowerCase();
445      var n = (attr(el, 'name') + ' ' + (el.id || '') + ' ' + attr(el, 'autocomplete')).toLowerCase();
446      return t === 'password' || t === 'email' || t === 'tel' ||
447             /pass|pwd|secret|token|card|cvv|ssn|iban|email|phone|otp|code|address|name/.test(n);
448   }
449   function isField(el) { return el && /^(input|select|textarea)$/i.test(el.tagName || ''); }
450
451   d.addEventListener('focusin', function (ev) {
452      if (!isField(ev.target)) { return; }
453      var f = fieldGroup(ev.target); if (!f) { return; }
454      var s = formState(f);
455      if (s.started) { return; }
456      s.started = true;
456 s.firstTouch = NOW();
457      track('form_start', { form_id: s.id, section: s.section, first_field: fieldName(ev.target) });
458   }, true);
459
460   d.addEventListener('focusout', function (ev) {
461      var el = ev.target;
462      if (!isField(el)) { return; }
463      var f = fieldGroup(el); if (!f) { return; }
464      var s = formState(f), name = fieldName(el);
465      if (el.value && String(el.value).length && !s.fields[name]) {
466         s.fields[name] = 1; s.fieldCount++;
467         track('form_field_complete', {
468            form_id: s.id, field_name: name,
469            field_type: clamp(attr(el, 'type') || (el.tagName || '').toLowerCase(), 30),
470            section: s.section, field_index: s.fieldCount,
471            field_value: isSensitive(el) ? undefined : clamp(String(el.value), 60)
472         });
473      }
474      if (el.checkValidity && !el.checkValidity()) {
475         track('form_field_error', {
476            form_id: s.id, field_name: name,
477            error_type: clamp(el.validationMessage || 'invalid', 100), section: s.section
478         });
479      }
480   }, true);
481
482   d.addEventListener('submit', function (ev) {
483      var f = ev.target;
484      if (!f || (f.tagName || '').toLowerCase() !== 'form') { return; }
485      var s = formState(f);
486      s.submitted = true;
487      track('form_submit', {
488         form_id: s.id, section: s.section,
489         field_count: f.querySelectorAll('input,select,textarea').length,
490         filled_count: s.fieldCount,
491         time_to_submit: s.firstTouch ? Math.round((NOW() - s.firstTouch) / 1000) : 0
492      });
493   }, true);
494
495   /* Contact Form 7 outcomes — the theme's main lead capture. */
496   ['wpcf7mailsent', 'wpcf7invalid', 'wpcf7spam', 'wpcf7mailfailed'].forEach(function (evt) {
497      d.addEventListener(evt, function (e) {
498         var okay = (evt === 'wpcf7mailsent');
499         var id = (e.detail && (e.detail.contactFormId || e.detail.id)) || '';
500         track(okay ? 'form_submit_success' : 'form_submit_error', {
501            form_id: clamp('cf7_' + id, 60), status: evt.replace('wpcf7', ''), section: 'contact_form'
502         });
503         if (okay) { track('lead', { location: 'cf7_' + id }); }
504      }, false);
505   });
506
507   /* Choice controls. */
508   d.addEventListener('change', function (ev) {
509      var el = ev.target;
510      if (!isField(el)) { return; }
511      var type = (attr(el, 'type') || '').toLowerCase();
512      var c = ctxOf(el);
513      var tag = (el.tagName || '').toLowerCase();
514      if (tag === 'select') {
515         var opt = el.options && el.options[el.selectedIndex];
516         track('option_select', { control: fieldName(el), option_value: clamp(opt ? opt.text : el.value, 100), section: c.section, component: c.component });
517      } else if (type === 'checkbox' || type === 'radio') {
518         track('option_select', { control: fieldName(el), option_value: clamp(labelOf(closest(el, 'label')) || el.value, 100), checked: el.checked ? 1 : 0, section: c.section, component: c.component });
519      } else if (type === 'range') {
520         track('option_select', { control: fieldName(el), option_value: clamp(el.value, 20), section: c.section, component: c.component });
521      } else if (type === 'file') {
522         track('file_select', { control: fieldName(el), file_count: (el.files && el.files.length) || 0, section: c.section });
523      }
524   }, true);
525
526   /* ── 8. Bootstrap 5 components ───────────────────────────────────────── */
527   function bs(evt, fn) { d.addEventListener(evt, fn, true); }
528   bs('shown.bs.modal',      function (e) { track('modal_open',      { modal_id: clamp(e.target.id || labelOf(e.target), 60), section: sectionOf(e.target) }); });
529   bs('hidden.bs.modal',     function (e) { track('modal_close',     { modal_id: clamp(e.target.id || labelOf(e.target), 60), section: sectionOf(e.target) }); });
530   bs('shown.bs.offcanvas',  function (e) { track('offcanvas_open',  { panel_id: clamp(e.target.id || '', 60) }); });
531   bs('hidden.bs.offcanvas', function (e) { track('offcanvas_close', { panel_id: clamp(e.target.id || '', 60) }); });
532   bs('shown.bs.tab',        function (e) { track('tab_change',      { tab_name: labelOf(e.target), section: sectionOf(e.target), component: 'tabs' }); });
533   bs('shown.bs.collapse',   function (e) { track('accordion_open',  { panel_id: clamp(e.target.id || labelOf(e.target), 60), section: sectionOf(e.target) }); });
534   bs('hidden.bs.collapse',  function (e) { track('accordion_close', { panel_id: clamp(e.target.id || labelOf(e.target), 60), section: sectionOf(e.target) }); });
535   bs('shown.bs.dropdown',   function (e) { track('dropdown_open',   { menu_name: labelOf(e.target), section: sectionOf(e.target) }); });
536   bs('slid.bs.carousel',    function (e) { track('carousel_slide',  { carousel_id: clamp(e.target.id || '', 60), slide_index: (e.to != null ? e.to : -1), section: sectionOf(e.target) }); });
537
538   /* Native <details> disclosure (FAQ blocks that aren't Bootstrap). */
539   d.addEventListener('toggle', function (ev) {
540      var el = ev.target;
541      if (!el || (el.tagName || '').toLowerCase() !== 'details') { return; }
542      track(el.open ? 'accordion_open' : 'accordion_close', {
543         panel_id: clamp(labelOf(el.querySelector('summary')) || el.id, 60), section: sectionOf(el)
544      });
545   }, true);
546
547   /* ── 9. engagement time (active-only) ────────────────────────────────── */
548   var activeStart = NOW(), activeTotal = 0, isActive = !d.hidden, interactions = 0, maxDepth = 0;
549   function activeMs() { return activeTotal + (isActive ? (NOW() - activeStart) : 0); }
550   function setActive(on) {
551      if (on === isActive) { return; }
552      if (isActive) { activeTotal += NOW() - activeStart; } else { activeStart = NOW(); }
553      isActive = on;
554   }
555   w.addEventListener('blur',  function () { setActive(false); });
556   w.addEventListener('focus', function () { setActive(true); });
557   ['click', 'keydown', 'scroll', 'touchstart'].forEach(function (e) {
558      d.addEventListener(e, function () { interactions++; }, { passive: true, capture: true });
559   });
560
561   /* ── 10. scroll depth + section visibility + CTA impressions ─────────── */
562   var depthMarks = [10, 25, 50, 75, 90, 100], depthHit = {};
563   w.addEventListener('scroll', throttle(function () {
564      var docH = Math.max(d.body.scrollHeight, d.documentElement.scrollHeight) - w.innerHeight;
565      if (docH <= 0) { return; }
566      var pct = Math.min(100, Math.round((w.pageYOffset / docH) * 100));
567      if (pct > maxDepth) { maxDepth = pct; }
568      for (var i = 0; i < depthMarks.length; i++) {
569         var m = depthMarks[i];
570         if (pct >= m && !depthHit[m]) {
571            depthHit[m] = 1;
572            track('scroll_depth', { percent_scrolled: m, seconds_elapsed: Math.round(activeMs() / 1000) });
573         }
574      }
575   }, 350), { passive: true });
576
577   if (w.IntersectionObserver) {
578      var seen = {};
579      var secObs = new w.IntersectionObserver(function (entries) {
580         entries.forEach(function (en) {
581            if (!en.isIntersecting) { return; }
582            var el = en.target, name = attr(el, 'data-vmp-section') || el.id || pathOf(el);
583            secObs.unobserve(el);
584            if (seen[name]) { return; }
585            seen[name] = 1;
586            track('section_view', { section: clamp(name, 60), seconds_elapsed: Math.round(activeMs() / 1000), percent_scrolled: maxDepth });
587         });
588      }, { threshold: 0.4 });
589
590      var ctaObs = new w.IntersectionObserver(function (entries) {
591         entries.forEach(function (en) {
592            if (!en.isIntersecting) { return; }
593            var el = en.target, c = ctxOf(el);
594            ctaObs.unobserve(el);
595            track('cta_view', {
596               element_text: c.element_text, section: c.section, component: c.component,
597               link_url: c.link_url, promotion_name: attr(el, 'data-vmp-label') || c.element_text
598            });
599         });
600      }, { threshold: 0.6 });
601
602      var startObservers = function () {
603         try {
604            var secs = d.querySelectorAll('[data-vmp-section],section[id],main section');
605            for (var i = 0; i < secs.length && i < 120; i++) { secObs.observe(secs[i]); }
606            var ctas = d.querySelectorAll('[data-vmp-event],[data-vmp-impression]');
607            for (var j = 0; j < ctas.length && j < 120; j++) { ctaObs.observe(ctas[j]); }
608         } catch (e) {}
609      };
610      if (d.readyState === 'loading') { d.addEventListener('DOMContentLoaded', startObservers); } else { startObservers(); }
611   }
612
613   /* ── 11. time milestones + engaged session ───────────────────────────── */
614   var timeMarks = [10, 30, 60, 120, 300], timeHit = {}, engagedSent = false;
615   setInterval(function () {
616      var secs = Math.round(activeMs() / 1000);
617      for (var i = 0; i < timeMarks.length; i++) {
618         var m = timeMarks[i];
619         if (secs >= m && !timeHit[m]) {
620            timeHit[m] = 1;
621            track('time_on_page', { seconds_elapsed: m, percent_scrolled: maxDepth, interactions: interactions });
622         }
623      }
624      if (!engagedSent && (secs >= 10 || interactions >= 2 || maxDepth >= 50)) {
625         engagedSent = true;
626         track('engaged_session', { seconds_elapsed: secs, percent_scrolled: maxDepth, interactions: interactions });
627      }
628   }, 5000);
629
630   /* ── 12. media ───────────────────────────────────────────────────────── */
631   function bindMedia(v) {
632      if (v.__vmpBound) { return; }
633      v.__vmpBound = true;
634      var name = clamp(attr(v, 'data-vmp-label') || attr(v, 'title') || (v.currentSrc || '').split('/').pop(), 100);
635      var marks = {};
636      v.addEventListener('play',  function () { track('video_start',    { video_title: name, video_duration: Math.round(v.duration || 0), section: sectionOf(v) }); });
637      v.addEventListener('ended', function () { track('video_complete', { video_title: name, video_duration: Math.round(v.duration || 0), section: sectionOf(v) }); });
638      v.addEventListener('timeupdate', throttle(function () {
639         if (!v.duration) { return; }
640         var pct = Math.round((v.currentTime / v.duration) * 100);
641         [25, 50, 75].forEach(function (m) {
642            if (pct >= m && !marks[m]) { marks[m] = 1; track('video_progress', { video_title: name, video_percent: m, section: sectionOf(v) }); }
643         });
644      }, 1000));
645   }
646   function scanMedia() {
647      var v = d.querySelectorAll('video,audio');
648      for (var i = 0; i < v.length; i++) { bindMedia(v[i]); }
649   }
650   if (d.readyState === 'loading') { d.addEventListener('DOMContentLoaded', scanMedia); } else { scanMedia(); }
651
652   /* ── 13. content interactions ────────────────────────────────────────── */
653   d.addEventListener('copy', function () {
654      var sel = '';
655      try { sel = String(w.getSelection() || ''); } catch (e) {}
656      track('copy_text', { characters: sel.length, content_sample: clamp(sel, 60), section: sectionOf(d.activeElement || d.body) });
657   }, true);
658
659   d.addEventListener('mouseup', throttle(function () {
660      var sel = '';
661      try { sel = String(w.getSelection() || ''); } catch (e) {}
662      if (sel.length >= 25) { track('text_selection', { characters: sel.length, content_sample: clamp(sel, 60) }); }
663   }, 3000), true);
664
665   w.addEventListener('beforeprint', function () { track('print_page', {}); });
666
667   var exitSent = false;
668   d.addEventListener('mouseout', function (ev) {
669      if (exitSent || ev.clientY > 8 || ev.relatedTarget) { return; }
670      exitSent = true;
671      track('exit_intent', { seconds_elapsed: Math.round(activeMs() / 1000), percent_scrolled: maxDepth, interactions: interactions });
672   }, true);
673
674   /* ── 14. errors — broken UI is the costliest thing to not measure ────── */
675   w.addEventListener('error', function (ev) {
676      if (ev && ev.target && ev.target !== w && ev.target.nodeType === 1) {
677         var src = attr(ev.target, 'src') || attr(ev.target, 'href');
678         if (src) { track('resource_error', { resource_url: clamp(src, 100), resource_type: clamp((ev.target.tagName || '').toLowerCase(), 20) }); }
679         return;
680      }
681      track('js_error', {
682         error_message: clamp((ev && ev.message) || 'error', 100),
683         error_source:  clamp(String((ev && ev.filename) || '').split('/').pop(), 60),
684         error_line:    (ev && ev.lineno) || 0
685      });
686   }, true);
687
688   w.addEventListener('unhandledrejection', function (ev) {
689      var r = ev && ev.reason;
690      track('js_error', { error_message: clamp((r && (r.message || r)) || 'rejection', 100), error_source: 'promise' });
691   });
692
693   /* ── 15. page leave — the summary that makes every other event usable ── */
694   var leaveSent = false;
695   function pageLeave() {
696      if (leaveSent) { return; }
697      leaveSent = true;
698      setActive(false);
699      for (var i = 0; i < formStates.length; i++) {
700         var s = formStates[i];
701         if (s.isForm && s.started && !s.submitted) {
702            track('form_abandon', { form_id: s.id, section: s.section, filled_count: s.fieldCount, seconds_elapsed: Math.round((NOW() - s.firstTouch) / 1000) });
703         }
704      }
705      track('page_leave', {
706         engagement_time_msec: Math.round(activeMs()),
707         seconds_elapsed: Math.round(activeMs() / 1000),
708         percent_scrolled: maxDepth, interactions: interactions, events_in_session: SESS.events
709      });
710   }
711   w.addEventListener('pagehide', pageLeave);
712   d.addEventListener('visibilitychange', function () {
713      setActive(!d.hidden);
714      if (d.hidden) { pageLeave(); leaveSent = false; }
715   });
716
717   /* ── 16. history-API navigation ──────────────────────────────────────── */
718   (function () {
719      var push = w.history && w.history.pushState;
720      if (!push) { return; }
721      w.history.pushState = function () {
722         var r = push.apply(this, arguments);
723         setTimeout(function () {
724            PAGE.page_path = clamp(w.location.pathname, 100);
725            depthHit = {}; maxDepth = 0; leaveSent = false;
726            track('page_view_enhanced', { page_title: clamp(d.title, 100), navigation_type: 'spa' });
727         }, 30);
728         return r;
729      };
730      w.addEventListener('popstate', function () {
731         PAGE.page_path = clamp(w.location.pathname, 100);
732         track('page_view_enhanced', { page_title: clamp(d.title, 100), navigation_type: 'popstate' });
733      });
734   })();
735
736   flushBuffer();
737   log('auto-capture armed', PAGE, 'marketing consent:', marketingAllowed());
738})(window, document);

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.