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.