PageSourceSearch

https://www.datagrail.io/wp-content/themes/datagrail/assets/js/attribution.js?ver=1789606990

js datagrail.io collected 2026-09-24 20:06:21 UTC 8,488 bytes, 154 lines download raw bytes

1/**
2 * Site-wide marketing attribution: capture, persist, forward.
3 *
4 * Problem this solves: campaign UTMs were dropped before HubSpot recorded them, so
5 * paid/ad visits logged as "direct". Three drop points were identified:
6 *   1) Internal CTAs carry a ?ir= internal-ref tag and were rebuilt as fresh paths
7 *      (e.g. "/demo/?ir=platform"), which did NOT carry the current URL's query string
8 *      forward. Clicking "Request a Demo" therefore wiped the utm_* params.
9 *   2) Nothing persisted the UTMs as a backup, so a single drop lost them for good.
10 *   3) (A host/CDN-level redirect can strip the query string on first hop; that is a
11 *      hosting-config fix, but the persistence + restore below make everything
12 *      downstream of a captured landing resilient to it.)
13 *
14 * Approach (all client-side, dependency-free, safe to run site-wide):
15 *   A. Read utm_* + click-ids from the current URL.
16 *   B. First-touch persist them to the dg_demo_utm cookie (90 days) + localStorage.
17 *      The cookie key is shared with the demo booking form (functions/demo_v1.php /
18 *      demo-v1.js already reads dg_demo_utm), so that form keeps working even when the
19 *      URL lost the params en route.
20 *   C. Backstop: if the current page has a form but the URL lost its UTMs while the store
21 *      still has them, re-append them to the URL (history.replaceState) before the form
22 *      libraries initialize, and populate any hidden utm_* inputs already in the DOM.
23 *   D. HubSpot's 50+ hbspt.forms.create() embeds across views/*.twig render async, after
24 *      DOMContentLoaded, so (C)'s one-time fillHiddenFields() pass can't reach their hidden
25 *      utm_* / click-id inputs. wireHubspotForms() listens once, globally, for HubSpot's
26 *      documented window "message" event (window.hsFormCallback / postMessage with
27 *      data.type === 'hsFormCallback'), and on each form's onFormReady fills that specific
28 *      form's fields, scoped by data-form-id so unrelated embeds on the same page are never
29 *      touched. This covers all 50 existing embeds uniformly with zero edits to those files.
30 *
31 * Note: a previous version of this script also rewrote every internal link's href with the
32 * stored UTM params ("decorateLinks"). That was removed as a confirmed anti-pattern
33 * (duplicate-content indexing risk, inflated GA4 pageview variants, no benefit for
34 * dynamically-loaded content) and because it was redundant: (C) above already guarantees
35 * correctness on the one page type that needs it (a page with a form), regardless of how the
36 * visitor arrived. Attribution across a CTA click now flows through the cookie/localStorage,
37 * not the URL - verified end to end, see the attached report.
38 */
39(function () {
40  'use strict';
41
42  var UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'msclkid', 'li_fat_id'];
43  var COOKIE = 'dg_demo_utm';
44  var LS_KEY = 'dg_attribution';
45  var COOKIE_DAYS = 90;
46
47  function readCookie(name) {
48    var m = document.cookie.match('(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\\/+^])/g, '\\$1') + '=([^;]*)');
49    return m ? decodeURIComponent(m[1]) : '';
50  }
51  function writeCookie(name, val, days) {
52    var d = new Date();
53    d.setTime(d.getTime() + days * 864e5);
54    document.cookie = name + '=' + encodeURIComponent(val) + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
55  }
56
57  // --- A. read UTMs present on this page's URL ---
58  var params = new URLSearchParams(location.search);
59  var current = {};
60  UTM_KEYS.forEach(function (k) { var v = params.get(k); if (v) current[k] = v; });
61  var hasCurrent = Object.keys(current).length > 0;
62
63  // --- B. first-touch persist (cookie shared with the demo form + localStorage mirror) ---
64  var stored = {};
65  try { stored = JSON.parse(readCookie(COOKIE) || '{}') || {}; } catch (e) { stored = {}; }
66  if (!Object.keys(stored).length) {
67    try { stored = JSON.parse(localStorage.getItem(LS_KEY) || '{}') || {}; } catch (e2) {}
68  }
69  if (!Object.keys(stored).length && hasCurrent) {
70    writeCookie(COOKIE, JSON.stringify(current), COOKIE_DAYS);
71    try { localStorage.setItem(LS_KEY, JSON.stringify(current)); } catch (e3) {}
72    stored = current;
73  } else if (Object.keys(stored).length) {
74    // keep the cookie/localStorage in sync if only one of them had the value
75    try { if (localStorage.getItem(LS_KEY) !== JSON.stringify(stored)) localStorage.setItem(LS_KEY, JSON.stringify(stored)); } catch (e4) {}
76    if (!readCookie(COOKIE)) writeCookie(COOKIE, JSON.stringify(stored), COOKIE_DAYS);
77  }
78
79  // active set to forward: first-touch (stored) wins; fall back to whatever is on this URL
80  var active = Object.keys(stored).length ? stored : current;
81  var activeKeys = Object.keys(active);
82  if (!activeKeys.length) return; // nothing to forward
83
84  // --- C. form backstop: restore UTMs onto this URL + into hidden fields ---
85  // Scoped to actual lead-capture forms only. A bare "form" selector also matched
86  // WordPress's own site-search box and other non-lead forms present on nearly
87  // every page, which made this fire almost everywhere and defeated the point of
88  // removing decorateLinks() - the URL looked just as UTM-cluttered as before,
89  // via a different mechanism. Confirmed live on /careers/ before this fix.
90  function hasForm() {
91    return !!document.querySelector('.hbspt-form, [class*="hbspt"], .sched-form');
92  }
93  function restoreUrl() {
94    if (hasCurrent || !hasForm() || !window.history || !history.replaceState) return;
95    var url = new URL(location.href);
96    var changed = false;
97    activeKeys.forEach(function (k) { if (!url.searchParams.has(k)) { url.searchParams.set(k, active[k]); changed = true; } });
98    if (changed) { try { history.replaceState(null, '', url.toString()); } catch (e) {} }
99  }
100  function fillHiddenFields() {
101    var inputs = document.querySelectorAll('input[type="hidden"], input[name^="utm_"]');
102    for (var i = 0; i < inputs.length; i++) {
103      var name = inputs[i].getAttribute('name');
104      if (name && active[name] && !inputs[i].value) inputs[i].value = active[name];
105    }
106  }
107
108  // --- D. HubSpot async-form backstop ---
109  //
110  // HubSpot's forms.js (v2, portalId 4326843, confirmed live against a real embed on
111  // datagrailstage.wpengine.com/demo/) posts a window "message" for every lifecycle event
112  // of every form on the page, shaped like:
113  //   { id: "<formId, e.g. 5518d22e-3bb2-4222-91f8-9ac68e6ea3d4>",
114  //     type: "hsFormCallback",
115  //     eventName: "onFormReady",   // also fires onAnalyticEvent, onFormDefinitionFetchSuccess,
116  //                                  // onBeforeFormInit, onReady, onBeforeValidationInit - all
117  //                                  // ignored here, only onFormReady matters for field-fill
118  //     data: {} }
119  // (event.data.id is NOT nested under "data" - it's a top-level sibling of "type"/"eventName".
120  // "data" itself was empty on every event observed, including onFormReady, contrary to some
121  // third-party docs suggesting a populated data.id/data.formId - do not rely on that.)
122  //
123  // The rendered <form> carries that same id as a data-form-id attribute (and duplicated into
124  // its id="hsForm_<id>" / class="hs-form-<id>"), which is what actually lets us scope the
125  // field-fill to the one form that just became ready, on pages with multiple embeds.
126  function wireHubspotForms() {
127    window.addEventListener('message', function (event) {
128      var msg = event.data;
129      if (!msg || msg.type !== 'hsFormCallback' || msg.eventName !== 'onFormReady' || !msg.id) return;
130      var formEl = document.querySelector('[data-form-id="' + msg.id + '"]');
131      if (!formEl) return;
132      var inputs = formEl.querySelectorAll('input');
133      for (var i = 0; i < inputs.length; i++) {
134        var name = inputs[i].getAttribute('name');
135        if (name && active[name] && !inputs[i].value) {
136          inputs[i].value = active[name];
137          inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
138        }
139      }
140    });
141  }
142
143  function onReady() {
144    restoreUrl();        // put params back on the URL before HubSpot form libs read it
145    fillHiddenFields();  // populate any pre-existing hidden utm_* inputs (e.g. demo-v1 forms)
146    wireHubspotForms();  // populate HubSpot embeds' hidden utm_* / click-id inputs as they render
147  }
148
149  if (document.readyState === 'loading') {
150    document.addEventListener('DOMContentLoaded', onReady);
151  } else {
152    onReady();
153  }
154})();

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.