1/** 2 * HRCK Visitor Tracker 3 * 4 * Lightweight (~3KB) vanilla JS that fires on every frontend page load. 5 * Persists a 1-year track_id cookie, captures UTM/gclid/fbclid params, 6 * detects page context, and sends a visit ping to the REST endpoint. 7 * 8 * No jQuery dependency. No external requests. 9 */ 10(function () { 11 'use strict'; 12 13 var COOKIE_NAME = 'hrck_track_id'; 14 var SESSION_KEY = 'hrck_track_session'; // sessionStorage new-session flag 15 var API_URL = (window.hrckTracker && window.hrckTracker.apiUrl) 16 ? window.hrckTracker.apiUrl 17 : '/wp-json/hrck/v1/track/visit'; 18 // URL cleanup is opt-in (#407): stripping utm_*/gclid/fbclid via 19 // replaceState races GA4/Ads/Meta tags that read location.href after 20 // DOMContentLoaded, killing their campaign attribution fleet-wide. 21 var CLEAN_URL = !!(window.hrckTracker && window.hrckTracker.cleanUrl); 22 23 // ------------------------------------------------------------------------- 24 // Cookie helpers 25 // ------------------------------------------------------------------------- 26 27 function getCookie(name) { 28 var match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')); 29 return match ? decodeURIComponent(match[1]) : null; 30 } 31 32 function setCookie(name, value, days) { 33 var expires = new Date(Date.now() + days * 864e5).toUTCString(); 34 // SameSite=Lax keeps cookies on same-site navigations while blocking 35 // cross-site requests (no Secure requirement for non-HTTPS dev sites). 36 document.cookie = name + '=' + encodeURIComponent(value) 37 + '; expires=' + expires 38 + '; path=/' 39 + '; SameSite=Lax'; 40 } 41 42 // ------------------------------------------------------------------------- 43 // UUID generation 44 // ------------------------------------------------------------------------- 45 46 function generateUUID() { 47 if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 48 return crypto.randomUUID(); 49 } 50 // Fallback for Safari < 15.4 and older browsers 51 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 52 var r = Math.random() * 16 | 0; 53 var v = c === 'x' ? r : (r & 0x3 | 0x8); 54 return v.toString(16); 55 }); 56 } 57 58 // ------------------------------------------------------------------------- 59 // Track ID management 60 // ------------------------------------------------------------------------- 61 62 function getTrackId() { 63 var existing = getCookie(COOKIE_NAME); 64 // Validate UUID v4 format before trusting stored value 65 if (existing && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(existing)) { 66 return existing; 67 } 68 var id = generateUUID(); 69 setCookie(COOKIE_NAME, id, 365); 70 return id; 71 } 72 73 // ------------------------------------------------------------------------- 74 // Session detection 75 // ------------------------------------------------------------------------- 76 77 function isNewSession() { 78 try { 79 if (sessionStorage.getItem(SESSION_KEY)) { 80 return false; 81 } 82 sessionStorage.setItem(SESSION_KEY, '1'); 83 return true; 84 } catch (e) { 85 // sessionStorage blocked (private mode edge cases) 86 return false; 87 } 88 } 89 90 // ------------------------------------------------------------------------- 91 // UTM / click-ID capture 92 // ------------------------------------------------------------------------- 93 94 // promo_id rides Promo Bar deep links (DeepLinkBuilder stamps it on every CTA). 95 // Adding it here threads it through getUtmParams/hasTrackedParams/cleanUrl and the 96 // payload-merge loop, so a promo click is captured into the visit row (M7). The key 97 // must stay 'promo_id' â the exact get_param() key TrackingController::track_visit reads. 98 // hrck_plan rides Trip Planner matched-property links (a signed plan-click 99 // credential, ckit#422 §18d). TrackingController verifies it and stamps 100 // trip_plan_id on the visit; the address bar is cleaned like the utm keys. 101 var TRACKED_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'promo_id', 'tb_src', 'hrck_plan']; 102 103 function getUtmParams() { 104 var params = new URLSearchParams(window.location.search); 105 var utm = {}; 106 TRACKED_PARAMS.forEach(function (key) { 107 var val = params.get(key); 108 if (val) { 109 utm[key] = val; 110 } 111 }); 112 return utm; 113 } 114 115 function hasTrackedParams() { 116 var search = window.location.search; 117 for (var i = 0; i < TRACKED_PARAMS.length; i++) { 118 if (search.indexOf(TRACKED_PARAMS[i]) > -1) { 119 return true; 120 } 121 } 122 return false;
123 } 124 125 function cleanUrl(utmKeys) { 126 if (!CLEAN_URL || !utmKeys.length || !window.history || !window.history.replaceState) { 127 return; 128 } 129 var url = new URL(window.location.href); 130 var removed = false; 131 utmKeys.forEach(function (key) { 132 if (url.searchParams.has(key)) { 133 url.searchParams.delete(key); 134 removed = true; 135 } 136 }); 137 if (removed) { 138 window.history.replaceState({}, '', url.toString()); 139 } 140 } 141 142 // ------------------------------------------------------------------------- 143 // Page context detection 144 // ------------------------------------------------------------------------- 145 146 function bodyHasClass(cls) { 147 return !!(document.body && document.body.classList && document.body.classList.contains(cls)); 148 } 149 150 // Detection markers verified against real fleet markup (ckit#143 â the old 151 // property/search selectors matched nothing, so ~99% of visits logged as 152 // 'other'): 153 // - property: WP core emits body.single-property for the 'property' CPT 154 // on every site; .homelocal-single-property kept as a fallback. 155 // - search: .hfse-properties is the Explorer results grid â the same 156 // marker cart-abandonment.js isBrowseContext() relies on. 157 // - landing: WP core body.home (the 'landing' value is already in the 158 // server's ALLOWED_PAGE_TYPES â the detector just never sent it). 159 // Checked BEFORE search so a homepage embedding a properties grid 160 // classifies as 'landing', not 'search'. 161 function getPageType() { 162 if (bodyHasClass('single-property') || document.querySelector('.homelocal-single-property')) { 163 return 'property'; 164 } 165 if (document.querySelector('.hrck-wishlist-page')) { 166 return 'wishlist'; 167 } 168 if (document.querySelector('.homelocal-checkout, .homerunner-checkout')) { 169 return 'checkout'; 170 } 171 if (bodyHasClass('home')) { 172 return 'landing'; 173 } 174 if (document.querySelector('.hfse-properties, .homelocal-search, .homelocal-explorer')) { 175 return 'search'; 176 } 177 return 'other'; 178 } 179 180 function getPropertyId() { 181 var el = document.querySelector('[data-property-id]'); 182 if (el) { 183 var id = parseInt(el.getAttribute('data-property-id'), 10); 184 return id > 0 ? id : null; 185 } 186 var match = document.body.className.match(/\bproperty-(\d+)\b/); 187 return match ? parseInt(match[1], 10) : null; 188 } 189 190 function getDeviceType() { 191 var w = window.innerWidth; 192 if (w < 768) { return 'mobile'; } 193 if (w < 960) { return 'tablet'; } 194 return 'desktop'; 195 } 196 197 // ------------------------------------------------------------------------- 198 // Session cookie helpers 199 // ------------------------------------------------------------------------- 200 201 function getSessionId() { 202 return getCookie('hrck_session_id') || getCookie('hrck_session') || null; 203 } 204 205 // ------------------------------------------------------------------------- 206 // Trip Planner click credential (ckit#422 §18d) 207 // ------------------------------------------------------------------------- 208 209 // The credential is (1) kept in a first-party cookie so booking attribution 210 // can resolve the plan even when this beacon is lost or the guest has no 211 // visit rows yet, and (2) stripped from the address bar UNCONDITIONALLY â 212 // independent of the opt-in CLEAN_URL â so it never lingers in history, 213 // referrers, or shared URLs. Set client-side on purpose: FlyWP replays 214 // cached Set-Cookie headers, so a server-set cookie could hand one guest's 215 // credential to the next visitor of a cached property page. 216 var PLAN_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; 217 218 function captureTripPlanClick(credential) { 219 try { 220 document.cookie = 'hrck_plan=' + encodeURIComponent(credential) 221 + '; Max-Age=' + PLAN_COOKIE_MAX_AGE + '; Path=/; SameSite=Lax' 222 + (window.location.protocol === 'https:' ? '; Secure' : ''); 223 } catch (e) { /* cookie blocked â the visit row is the fallback */ } 224 if (!window.history || !window.history.replaceState || typeof URL !== 'function') { 225 return; 226 } 227 try { 228 var url = new URL(window.location.href); 229 if (url.searchParams.has('hrck_plan')) { 230 url.searchParams.delete('hrck_plan'); 231 window.history.replaceState({}, '', url.toString()); 232 } 233 } catch (e) { /* malformed URL â nothing to strip */ } 234 } 235 236 // ------------------------------------------------------------------------- 237 // Main tracking function 238 // ------------------------------------------------------------------------- 239 240 function track() { 241 var trackId = getTrackId(); 242 var newSession = isNewSession();
243 var utmParams = {}; 244 245 // Capture UTM/click-ID params when they are present in the URL 246 // or when this is a brand-new session (first pageview â include referrer). 247 if (newSession || hasTrackedParams()) { 248 utmParams = getUtmParams(); 249 if (utmParams.hrck_plan) { 250 captureTripPlanClick(utmParams.hrck_plan); 251 } 252 if (Object.keys(utmParams).length > 0) { 253 cleanUrl(Object.keys(utmParams)); 254 } 255 } 256 257 var data = { 258 track_id: trackId, 259 page_url: window.location.pathname + window.location.search, 260 page_type: getPageType(), 261 device_type: getDeviceType() 262 }; 263 264 var propertyId = getPropertyId(); 265 if (propertyId) { 266 data.property_id = propertyId; 267 } 268 269 // Only include referrer on new sessions to avoid polluting every row 270 if (newSession && document.referrer) { 271 data.referrer = document.referrer; 272 } 273 274 // Merge UTM / click-ID params into payload 275 Object.keys(utmParams).forEach(function (key) { 276 data[key] = utmParams[key]; 277 }); 278 279 // Attach session_id if known 280 var sessionId = getSessionId(); 281 if (sessionId) { 282 data.session_id = sessionId; 283 } 284 285 // ----------------------------------------------------------------------- 286 // Send â prefer sendBeacon for reliability on page unload, fall back to 287 // fetch with keepalive so it survives navigations. 288 // ----------------------------------------------------------------------- 289 var payload = JSON.stringify(data); 290 291 if (typeof navigator.sendBeacon === 'function') { 292 navigator.sendBeacon(API_URL, new Blob([payload], {type: 'application/json'})); 293 } else if (typeof fetch === 'function') { 294 fetch(API_URL, { 295 method: 'POST', 296 headers: {'Content-Type': 'application/json'}, 297 body: payload, 298 keepalive: true 299 }).catch(function () { 300 // Intentionally silent â tracking must never break the page. 301 }); 302 } 303 } 304 305 // ------------------------------------------------------------------------- 306 // Boot â skip wp-admin; wait for DOM if not yet ready 307 // ------------------------------------------------------------------------- 308 309 if (document.body && document.body.classList.contains('wp-admin')) { 310 return; 311 } 312 313 if (document.readyState === 'loading') { 314 document.addEventListener('DOMContentLoaded', track); 315 } else { 316 track(); 317 } 318 319})();
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.