1(function() { 2 'use strict'; 3 4 const globalConfig = (typeof window !== 'undefined' && window.PBReferralTrackingConfig) 5 ? window.PBReferralTrackingConfig 6 : {}; 7 const isPlainObject = value => value && typeof value === 'object' && !Array.isArray(value); 8 const mergeObject = (defaults, overrides) => { 9 const output = Object.assign({}, defaults); 10 if (isPlainObject(overrides)) { 11 Object.keys(overrides).forEach(key => { 12 output[key] = overrides[key]; 13 }); 14 } 15 return output; 16 }; 17 const normalizeArray = (value, fallback) => (Array.isArray(value) ? value : fallback); 18 19 const defaultConfig = { 20 cookiePrefix: 'pb-ref-tracking-', 21 cookieExpiry: 7, 22 debug: false, 23 defaultValue: 'Unknown', 24 appendOrganicPaid: true, 25 trackingFieldBaseId: '', 26 sessionOnlyMode: false, 27 // `cookieSecure: undefined` lets the script auto-derive Secure from page protocol. 28 cookieSameSite: 'Lax', 29 cookieSecure: undefined, 30 cookiePath: '/', 31 // When `consentRequired` is true, capture is held in sessionStorage until consent 32 // is signalled via `window.PBReferralTrackingConsent`, a `consentGranted` callback, 33 // or a `pb-ref-tracking:consent-granted` window event. 34 consentRequired: false, 35 consentGranted: null, 36 // Web-storage fallback when cookies are blocked: 'none' | 'local'. 37 fallbackStorage: 'none', 38 // Opt-in last-touch attribution. When true, the most recent source is 39 // captured into a parallel `last-` cookie set (overwritten on every new 40 // arrival) alongside the always-on first-touch data. Default OFF. 41 enableLastTouch: false, 42 // Opt-in: with last-touch on, a direct return visit keeps the stored last-touch source. 43 lastTouchIgnoreDirect: false, 44 // Opt-in GA4 custom event on form confirmation, delivered via the site's 45 // own Google tag: gtag() directly when the standard GA4 tag is present, 46 // otherwise a dataLayer push for GTM to forward. Default OFF. 47 enableDataLayerPush: false, 48 dataLayerEventName: 'pb_referral_submit', 49 }; 50 51 // Matched against a bare lowercased hostname, so every pattern anchors to a 52 // label boundary â an unanchored /x\.com/ would also match box.com. 53 const defaultSourcePatterns = { 54 search_engines: [ 55 { pattern: /(^|\.)google\.(com|[a-z]{2,3}(\.[a-z]{2})?)$/i, name: 'Google' }, 56 { pattern: /(^|\.)googleadservices\.com$/i, name: 'Google' }, 57 { pattern: /(^|\.)googlesyndication\.com$/i, name: 'Google' }, 58 { pattern: /(^|\.)bing\.com$/i, name: 'Bing' }, 59 { pattern: /(^|\.)yahoo\.(com|[a-z]{2,3}(\.[a-z]{2})?)$/i, name: 'Yahoo' }, 60 { pattern: /(^|\.)duckduckgo\.com$/i, name: 'DuckDuckGo' }, 61 { pattern: /(^|\.)yandex\.(ru|com|[a-z]{2,3}(\.[a-z]{2})?)$/i, name: 'Yandex' } 62 ], 63 social_media: [ 64 { pattern: /(^|\.)facebook\.com$/i, name: 'Facebook' }, 65 { pattern: /(^|\.)fb\.me$/i, name: 'Facebook' }, 66 { pattern: /(^|\.)instagram\.com$/i, name: 'Instagram' }, 67 { pattern: /(^|\.)linkedin\.com$/i, name: 'LinkedIn' }, 68 { pattern: /(^|\.)lnkd\.in$/i, name: 'LinkedIn' }, 69 { pattern: /(^|\.)reddit\.com$/i, name: 'Reddit' }, 70 { pattern: /(^|\.)redd\.it$/i, name: 'Reddit' }, 71 { pattern: /(^|\.)reddit\.app\.link$/i, name: 'Reddit' }, 72 { pattern: /(^|\.)(?:x\.com|twitter\.com)$/i, name: 'X' }, 73 { pattern: /(^|\.)t\.co$/i, name: 'X' }, 74 { pattern: /(^|\.)tiktok\.com$/i, name: 'TikTok' }, 75 { pattern: /(^|\.)pinterest\.com$/i, name: 'Pinterest' }, 76 { pattern: /(^|\.)pin\.it$/i, name: 'Pinterest' }, 77 { pattern: /(^|\.)snapchat\.com$/i, name: 'Snapchat' }, 78 { pattern: /(^|\.)youtube\.com$/i, name: 'YouTube' }, 79 { pattern: /(^|\.)youtu\.be$/i, name: 'YouTube' } 80 ], 81 ai_assistants: [ 82 { pattern: /(^|\.)chat\.openai\.com$/i, name: 'ChatGPT' }, 83 { pattern: /(^|\.)chatgpt\.com$/i, name: 'ChatGPT' }, 84 { pattern: /(^|\.)perplexity\.ai$/i, name: 'Perplexity' }, 85 { pattern: /(^|\.)pplx\.ai$/i, name: 'Perplexity' }, 86 { pattern: /(^|\.)copilot\.microsoft\.com$/i, name: 'Copilot' }, 87 { pattern: /(^|\.)claude\.ai$/i, name: 'Claude' }, 88 { pattern: /(^|\.)anthropic\.com$/i, name: 'Claude' }, 89 { pattern: /(^|\.)deepseek\.(com|ai)$/i, name: 'DeepSeek' }, 90 { pattern: /(^|\.)x\.ai$/i, name: 'Grok' } 91 ], 92 email_marketing: [ 93 { pattern: /(^|\.)mailchimp\.com$/i, name: 'Mailchimp' }, 94 { pattern: /(^|\.)mailchi\.mp$/i, name: 'Mailchimp' }, 95 { pattern: /(^|\.)constantcontact\.com$/i, name: 'Constant Contact' }, 96 { pattern: /(^|\.)ccsend\.com$/i, name: 'Constant Contact' }, 97 { pattern: /(^|\.)r20\.rs6\.net$/i, name: 'Constant Contact' }, 98 { pattern: /(^|\.)pardot\.com$/i, name: 'Salesforce' }, 99 { pattern: /(^|\.)hubspot\.com$/i, name: 'HubSpot' }, 100 { pattern: /(^|\.)hs-sites\.com$/i, name: 'HubSpot' }, 101 { pattern: /(^|\.)hsforms\.com$/i, name: 'HubSpot' } 102 ] 103 }; 104 105 // Deep-merge per category so overrides extend rather than replace: entries in 106 // e.g. overrides.social_media are appended to the defaults for that category. 107 const mergeSourcePatterns = source => { 108 const merged = {};
109 const overrides = isPlainObject(source) ? source : {}; 110 Object.keys(defaultSourcePatterns).forEach(category => { 111 const extra = Array.isArray(overrides[category]) ? overrides[category] : []; 112 merged[category] = defaultSourcePatterns[category].concat(extra); 113 }); 114 // Also add any brand-new categories provided by the override 115 Object.keys(overrides).forEach(category => { 116 if (!merged[category] && Array.isArray(overrides[category])) { 117 merged[category] = overrides[category]; 118 } 119 }); 120 return merged; 121 }; 122 123 // UTM parameter mapping 124 const defaultUtmFields = ['utm_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; 125 126 // Click ID patterns for source and referrer attribution; detectPaidTraffic decides "Ads". 127 const defaultClickIdPatterns = { 128 google: ['gclid', 'gbraid', 'wbraid', 'campaignid', 'gad_campaignid', 'gad_source'], 129 facebook: ['fbclid'], 130 microsoft: ['msclkid'], 131 linkedin: ['li_fat_id'], 132 reddit: ['rdt_cid', 'rd_ad'], 133 tiktok: ['ttclid'], 134 pinterest: ['pin_cid'], 135 snapchat: ['snap_cid'], 136 openai: ['oppref'], 137 mailchimp: ['mc_cid', 'mc_eid'], 138 constantcontact: ['cc_cid', 'cc_eid', 'cc_campaignid'], 139 salesforce: ['pi_cid', 'pi_eid', 'pi_uid', 'pi_oid', 'pi_opt_in'], 140 hubspot: ['_hsenc', '_hssc', '_hsmi', '_hstc', '_hsml'], 141 other: ['dclid'] 142 }; 143 144 // UTM source mapping for normalized labels 145 const defaultUtmSourceMap = { 146 'google': 'Google', 147 'facebook': 'Facebook', 148 'instagram': 'Instagram', 149 'youtube': 'YouTube', 150 'linkedin': 'LinkedIn', 151 'twitter': 'X', 152 'reddit': 'Reddit', 153 'tiktok': 'TikTok', 154 'pinterest': 'Pinterest', 155 'snapchat': 'Snapchat', 156 'mailchimp': 'Mailchimp', 157 'hs_email': 'HubSpot', 158 'bing': 'Bing', 159 'yahoo': 'Yahoo', 160 'chatgpt': 'ChatGPT', 161 'chatgpt.com': 'ChatGPT', 162 'openai': 'ChatGPT', 163 'perplexity': 'Perplexity', 164 'pplx': 'Perplexity', 165 'gemini': 'Google', 166 'copilot': 'Copilot', 167 'claude': 'Claude', 168 'anthropic': 'Claude', 169 'deepseek': 'DeepSeek', 170 'grok': 'Grok', 171 'xai': 'Grok' 172 }; 173 174 // UTM referrer mapping for effective referrers 175 const defaultUtmReferrerMap = { 176 'google': 'https://www.google.com/', 177 'facebook': 'https://www.facebook.com/', 178 'instagram': 'https://www.instagram.com/', 179 'youtube': 'https://www.youtube.com/', 180 'linkedin': 'https://www.linkedin.com/', 181 'bing': 'https://www.bing.com/', 182 'reddit': 'https://www.reddit.com/', 183 'twitter': 'https://x.com/', 184 'tiktok': 'https://www.tiktok.com/', 185 'pinterest': 'https://www.pinterest.com/', 186 'snapchat': 'https://www.snapchat.com/', 187 'mailchimp': 'https://mailchimp.com/', 188 'hs_email': 'https://www.hubspot.com/', 189 'yahoo': 'https://www.yahoo.com/', 190 'chatgpt': 'https://chat.openai.com/', 191 'chatgpt.com': 'https://chatgpt.com/', 192 'openai': 'https://chat.openai.com/', 193 'perplexity': 'https://www.perplexity.ai/', 194 'pplx': 'https://www.perplexity.ai/', 195 'gemini': 'https://www.google.com/', 196 'copilot': 'https://copilot.microsoft.com/', 197 'claude': 'https://claude.ai/', 198 'anthropic': 'https://claude.ai/', 199 'deepseek': 'https://www.deepseek.com/', 200 'grok': 'https://x.ai/', 201 'xai': 'https://x.ai/' 202 }; 203 204 const defaultPaidClickIds = ['gclid', 'gbraid', 'wbraid', 'msclkid', 'li_fat_id', 'rdt_cid', 'ttclid', 'pin_cid', 'snap_cid', 'oppref']; 205 const defaultKnownSources = ['Google', 'Facebook', 'Instagram', 'YouTube', 'LinkedIn', 'Bing', 'Yahoo', 'DuckDuckGo', 'Yandex', 'X', 'Reddit', 'TikTok', 'Pinterest', 'Snapchat', 'Mailchimp', 'Constant Contact', 'Salesforce', 'HubSpot', 'Direct', 'ChatGPT', 'Perplexity', 'Copilot', 'Claude', 'DeepSeek', 'Grok']; 206 const defaultCustomSources = []; 207 const defaultEmailMarketingSources = ['Mailchimp', 'Constant Contact', 'Salesforce', 'HubSpot']; 208 209 const PBReferralTracker = { 210 // Configuration object for source detection 211 config: Object.assign({}, defaultConfig, globalConfig), 212 213 sourcePatterns: mergeSourcePatterns(globalConfig.sourcePatterns), 214 215 // UTM parameter mapping 216 utmFields: normalizeArray(globalConfig.utmFields, defaultUtmFields), 217 218 // Click ID patterns for source and referrer attribution 219 clickIdPatterns: mergeObject(defaultClickIdPatterns, globalConfig.clickIdPatterns), 220 221 // UTM source/referrer mappings 222 utmSourceMap: mergeObject(defaultUtmSourceMap, globalConfig.utmSourceMap), 223 utmReferrerMap: mergeObject(defaultUtmReferrerMap, globalConfig.utmReferrerMap), 224 225 // Paid traffic/known source configuration 226 paidClickIds: normalizeArray(globalConfig.paidClickIds, defaultPaidClickIds), 227 knownSources: normalizeArray(globalConfig.knownSources, defaultKnownSources), 228 customSources: normalizeArray(globalConfig.customSources, defaultCustomSources), 229 emailMarketingSources: normalizeArray(globalConfig.emailMarketingSources, defaultEmailMarketingSources), 230 231 // Set once refreshConfigFromGlobal() has taken a late-arriving config. 232 configRefreshed: false, 233 234 /** 235 * Re-derive everything from the live global when the IIFE captured an empty 236 * snapshot â an optimizer can run this script before its inline `before` config. 237 */ 238 refreshConfigFromGlobal() { 239 if (this.configRefreshed) { 240 return; 241 } 242 const live = typeof window !== 'undefined' ? window.PBReferralTrackingConfig : null; 243 // Same object reference means the IIFE already merged it. 244 if (!isPlainObject(live) || live === globalConfig) { 245 return; 246 } 247 248 Object.assign(this.config, live); 249 this.sourcePatterns = mergeSourcePatterns(live.sourcePatterns); 250 this.utmFields = normalizeArray(live.utmFields, this.utmFields); 251 this.clickIdPatterns = mergeObject(defaultClickIdPatterns, live.clickIdPatterns); 252 this.utmSourceMap = mergeObject(defaultUtmSourceMap, live.utmSourceMap); 253 this.utmReferrerMap = mergeObject(defaultUtmReferrerMap, live.utmReferrerMap); 254 this.paidClickIds = normalizeArray(live.paidClickIds, this.paidClickIds); 255 this.knownSources = normalizeArray(live.knownSources, this.knownSources); 256 this.customSources = normalizeArray(live.customSources, this.customSources); 257 this.emailMarketingSources = normalizeArray(live.emailMarketingSources, this.emailMarketingSources); 258 this.configRefreshed = true; 259 this.log('Late-arriving config merged', 'info'); 260 }, 261 262 /** 263 * Initialize tracking system 264 */ 265 init() { 266 this.refreshConfigFromGlobal(); 267 268 // Bind first so a fast cookie banner dispatching the consent 269 // event during DOMContentLoaded never misses the listener. 270 this.bindConsentGrantedEvent(); 271 272 if (typeof Cookies === 'undefined') {
273 this.log('Cookies library not available; falling back to sessionStorage when possible', 'warn'); 274 } 275 276 if (this.config.sessionOnlyMode) { 277 this.migrateLegacyAdminTrackingStorage(); 278 } 279 280 this.promoteHeldValues(); 281 this.captureReferralData(); 282 this.populateFormFields(); 283 this.bindGravityFormsEvents(); 284 this.initDataLayerPush(); 285 }, 286 287 /** 288 * Re-run capture/population when a cookie banner signals consent. 289 */ 290 bindConsentGrantedEvent() { 291 window.addEventListener('pb-ref-tracking:consent-granted', () => { 292 this.promoteHeldValues(); 293 this.captureReferralData(); 294 this.populateFormFields(); 295 }); 296 }, 297 298 /** 299 * Move a pre-consent sessionStorage hold into the durable tier once consent 300 * lands â capture short-circuits on an existing value and would never promote it. 301 */ 302 promoteHeldValues() { 303 if (!this.config.consentRequired || 'none' === this.config.fallbackStorage) { 304 return; 305 } 306 if (!this.isConsentGranted() || !this.canUseSessionStorage()) { 307 return; 308 } 309 310 const prefix = this.config.cookiePrefix; 311 const flagKey = this.dataLayerFlagKey(); 312 const held = []; 313 314 try { 315 for (let i = 0; i < window.sessionStorage.length; i += 1) { 316 const key = window.sessionStorage.key(i); 317 // The dataLayer flag is transient control state, not attribution. 318 if (key && key.startsWith(prefix) && key !== flagKey) { 319 held.push([key, window.sessionStorage.getItem(key)]); 320 } 321 } 322 } catch (error) { 323 this.log('sessionStorage scan failed during consent promotion: ' + error.message, 'warn'); 324 return; 325 } 326 327 held.forEach(([key, value]) => { 328 if (null === value) { 329 return; 330 } 331 this.setCookie(key.slice(prefix.length), value); 332 this.removeSessionValue(key); 333 }); 334 }, 335 336 /** 337 * Whether persistent cookie storage is allowed. Always true unless 338 * `consentRequired` has been opted into via the JS config filter. 339 */ 340 isConsentGranted() { 341 if (!this.config.consentRequired) { 342 return true; 343 } 344 const granted = this.config.consentGranted; 345 if (typeof granted === 'boolean') { 346 return granted; 347 } 348 if (typeof granted === 'function') { 349 try { 350 return !!granted(); 351 } catch (error) { 352 this.log('consentGranted callback threw: ' + error.message, 'warn'); 353 return false; 354 } 355 } 356 return typeof window !== 'undefined' && window.PBReferralTrackingConsent === true; 357 }, 358 359 /** 360 * Capture and store referral information 361 */ 362 captureReferralData() { 363 const currentUrl = new URL(window.location.href); 364 const clickIds = this.extractClickIds(currentUrl.searchParams); 365 const hasClickId = Object.keys(clickIds).length > 0; 366 const hasExistingData = !!this.getCookie('source'); 367 const hasLastTouch = !!this.getCookie('last-source'); 368 369 // First-touch is set once and held for the attribution window. A later ad 370 // click is a last touch; letting a click ID replace it erases the real origin. 371 const captureFirst = !hasExistingData; 372 // Last-touch attribution (opt-in): overwrite the parallel cookie set on every 373 // new arrival. A click between pages of this site is the same visit, not a new touch. 374 const captureLast = !!this.config.enableLastTouch 375 && (!hasLastTouch || !this.isInternalNavigation(currentUrl, hasClickId)); 376 377 if (!captureFirst && !captureLast) { 378 return; 379 } 380 381 const referralData = this.analyzeReferralSource(); 382 383 if (captureFirst) { 384 this.storeReferralData(referralData); 385 } 386 // Opt-in: a direct return keeps the source that last brought the visitor in. 387 // Check what the visit carried too: UTMs without utm_source are also labelled Direct. 388 const isDirectArrival = referralData.source === 'Direct' && !hasClickId && Object.keys(referralData.utm).length === 0; 389 const keepLastTouch = hasLastTouch && !!this.config.lastTouchIgnoreDirect && isDirectArrival; 390 if (captureLast && !keepLastTouch) { 391 this.storeReferralData(referralData, 'last-'); 392 } 393 }, 394 395 /** 396 * True when the page was reached from this site with no tracking parameters. 397 */
398 isInternalNavigation(currentUrl, hasClickId) { 399 if (hasClickId || Object.keys(this.extractUtmParameters(currentUrl.searchParams)).length > 0 || !document.referrer) { 400 return false; 401 } 402 try { 403 return this.isSelfReferrer(new URL(document.referrer).hostname.toLowerCase(), window.location.hostname); 404 } catch (e) { 405 return false; 406 } 407 }, 408 409 /** 410 * Analyze current page referral source 411 */ 412 analyzeReferralSource() { 413 const currentUrl = new URL(window.location.href); 414 const referrerUrl = document.referrer; 415 const urlParams = currentUrl.searchParams; 416 417 // Extract UTM parameters 418 const utmData = this.extractUtmParameters(urlParams); 419 420 // Extract click IDs 421 const clickIds = this.extractClickIds(urlParams); 422 423 // Determine traffic source 424 const trafficSource = this.determineTrafficSource(referrerUrl, utmData, clickIds); 425 426 // Determine appropriate referrer (override with click ID source if present) 427 const effectiveReferrer = this.determineEffectiveReferrer(referrerUrl, clickIds, utmData); 428 429 return { 430 source: trafficSource, 431 referrer: effectiveReferrer, 432 utm: utmData, 433 clickIds: clickIds, 434 refParams: currentUrl.search.substring(1) // Remove leading '?' 435 }; 436 }, 437 438 /** 439 * Extract UTM parameters from URL 440 */ 441 extractUtmParameters(searchParams) { 442 const utmData = {}; 443 444 this.utmFields.forEach(field => { 445 const value = searchParams.get(field); 446 if (value) { 447 utmData[field] = value; 448 } 449 }); 450 451 return utmData; 452 }, 453 454 /** 455 * Extract click IDs from URL parameters 456 */ 457 extractClickIds(searchParams) { 458 const clickIds = {}; 459 460 // Check all click ID patterns 461 Object.values(this.clickIdPatterns).flat().forEach(param => { 462 const value = searchParams.get(param); 463 if (value) { 464 clickIds[param] = value; 465 } 466 }); 467 468 return clickIds; 469 }, 470 471 /** 472 * Check custom source rules before built-in detection. 473 */ 474 findCustomSource(utmData, referrerUrl) { 475 const customSources = this.customSources || []; 476 if (!customSources.length) { 477 return null; 478 } 479 480 let referrerHost = ''; 481 let referrerUrlLower = ''; 482 if (referrerUrl) { 483 try { 484 const referrer = new URL(referrerUrl); 485 referrerHost = this.normalizeHostname(referrer.hostname); 486 referrerUrlLower = referrerUrl.toLowerCase(); 487 } catch (e) { 488 referrerUrlLower = String(referrerUrl).toLowerCase(); 489 } 490 } 491 492 const lookup = { 493 referrer: referrerHost, 494 referrer_url: referrerUrlLower, 495 utm_source: utmData.utm_source ? utmData.utm_source.toLowerCase() : '', 496 utm_medium: utmData.utm_medium ? utmData.utm_medium.toLowerCase() : '', 497 utm_campaign: utmData.utm_campaign ? utmData.utm_campaign.toLowerCase() : '', 498 utm_term: utmData.utm_term ? utmData.utm_term.toLowerCase() : '', 499 utm_content: utmData.utm_content ? utmData.utm_content.toLowerCase() : '' 500 }; 501 502 for (const rule of customSources) { 503 if (!rule || !rule.label || !rule.matchField || !rule.matchType || !rule.matchValue) { 504 continue; 505 } 506 const candidate = lookup[rule.matchField]; 507 if (!candidate) { 508 continue; 509 } 510 const matchValue = String(rule.matchValue).toLowerCase(); 511 if (!matchValue) { 512 continue; 513 } 514 if (rule.matchType === 'equals' && candidate === matchValue) { 515 return rule.label; 516 } 517 if (rule.matchType === 'contains' && candidate.includes(matchValue)) { 518 return rule.label; 519 } 520 } 521 522 return null; 523 }, 524 525 /** 526 * Determine traffic source using multiple detection methods 527 */ 528 determineTrafficSource(referrerUrl, utmData, clickIds) { 529 const customSource = this.findCustomSource(utmData, referrerUrl); 530 if (customSource) { 531 return this.enhanceSourceWithAdInfo(customSource, utmData, clickIds); 532 } 533 534 // Method 1: UTM source override (highest priority) 535 if (utmData.utm_source) { 536 return this.categorizeUtmSource(utmData.utm_source, utmData, clickIds); 537 } 538 539 // Method 2: Click ID detection (platform inferred from the click ID parameter) 540 if (Object.keys(clickIds).length > 0) { 541 const sourceFromClickId = this.detectSourceFromClickId(clickIds, referrerUrl);
542 return this.enhanceSourceWithAdInfo(sourceFromClickId, utmData, clickIds); 543 } 544 545 // Method 3: Referrer domain analysis 546 if (referrerUrl) { 547 try { 548 const referrerDomain = new URL(referrerUrl).hostname.toLowerCase(); 549 // Treat same-domain referrers as Direct to avoid self-referrals 550 if (this.isSelfReferrer(referrerDomain, window.location.hostname)) { 551 return 'Direct'; 552 } 553 const sourceInfo = this.analyzeDomain(referrerDomain); 554 555 if (sourceInfo) { 556 return this.enhanceSourceWithAdInfo(sourceInfo, utmData, clickIds); 557 } 558 // If sourceInfo is null, continue to fallback 559 } catch (e) { 560 this.log('Error parsing referrer URL: ' + e.message, 'warn'); 561 } 562 } 563 564 // Method 4: Direct traffic (no referrer, no UTM, no click IDs) 565 if (!referrerUrl) { 566 return 'Direct'; 567 } 568 569 // Method 5: Fallback - we have a referrer (Method 4 already returned for the 570 // no-referrer case) but couldn't identify the source. 571 return 'Referral'; 572 }, 573 574 /** 575 * Determine the effective referrer URL 576 */ 577 determineEffectiveReferrer(originalReferrer, clickIds, utmData) { 578 // If we have click IDs, override referrer based on the platform 579 if (Object.keys(clickIds).length > 0) { 580 for (const [platform, patterns] of Object.entries(this.clickIdPatterns)) { 581 for (const pattern of patterns) { 582 if (clickIds[pattern]) { 583 switch (platform) { 584 case 'google': 585 return 'https://www.google.com/'; 586 case 'facebook': 587 return this.metaPlatform(originalReferrer, utmData) === 'Instagram' 588 ? 'https://www.instagram.com/' 589 : 'https://www.facebook.com/'; 590 case 'microsoft': 591 return 'https://www.bing.com/'; 592 case 'linkedin': 593 return 'https://www.linkedin.com/'; 594 case 'reddit': 595 return 'https://www.reddit.com/'; 596 case 'tiktok': 597 return 'https://www.tiktok.com/'; 598 case 'pinterest': 599 return 'https://www.pinterest.com/'; 600 case 'snapchat': 601 return 'https://www.snapchat.com/'; 602 case 'openai': 603 return 'https://chatgpt.com/'; 604 case 'mailchimp': 605 return 'https://mailchimp.com/'; 606 case 'constantcontact': 607 return 'https://www.constantcontact.com/'; 608 case 'salesforce': 609 return 'https://www.salesforce.com/'; 610 case 'hubspot': 611 return 'https://www.hubspot.com/'; 612 default: 613 // Includes the 'other' platform (e.g. dclid): keep the real referrer. 614 return originalReferrer || 'Direct'; 615 } 616 } 617 } 618 } 619 } 620 621 // If UTM source exists, try to map it to a referrer 622 if (utmData.utm_source) { 623 const mappedReferrer = this.utmReferrerMap[utmData.utm_source.toLowerCase()]; 624 if (mappedReferrer) { 625 return mappedReferrer; 626 } 627 } 628 629 // Avoid reporting self-referrals; if referrer is same-domain, treat as Direct 630 try { 631 if (originalReferrer) { 632 const refDomain = new URL(originalReferrer).hostname.toLowerCase(); 633 if (this.isSelfReferrer(refDomain, window.location.hostname)) { 634 return 'Direct'; 635 } 636 } 637 } catch (e) { 638 // no-op 639 } 640 641 // Default to original referrer or 'Direct' 642 return originalReferrer || 'Direct'; 643 }, 644 645 /** 646 * Categorize traffic based on UTM source 647 */ 648 categorizeUtmSource(utmSource, utmData, clickIds) { 649 const source = utmSource.toLowerCase(); 650 651 const mappedSource = this.utmSourceMap[source] || this.capitalizeFirst(source); 652 return this.enhanceSourceWithAdInfo(mappedSource, utmData, clickIds); 653 }, 654 655 /** 656 * Detect source from click ID parameters 657 */ 658 detectSourceFromClickId(clickIds, referrerUrl) { 659 // Check for specific click ID patterns 660 for (const [platform, patterns] of Object.entries(this.clickIdPatterns)) { 661 for (const pattern of patterns) { 662 if (clickIds[pattern]) { 663 switch (platform) { 664 case 'google': 665 return 'Google'; 666 case 'facebook': 667 return this.metaPlatform(referrerUrl); 668 case 'microsoft': 669 return 'Bing'; 670 case 'linkedin': 671 return 'LinkedIn'; 672 case 'reddit': 673 return 'Reddit'; 674 case 'tiktok': 675 return 'TikTok'; 676 case 'pinterest': 677 return 'Pinterest'; 678 case 'snapchat': 679 return 'Snapchat'; 680 case 'openai': 681 return 'ChatGPT'; 682 case 'mailchimp': 683 return 'Mailchimp'; 684 case 'constantcontact': 685 return 'Constant Contact'; 686 case 'salesforce': 687 return 'Salesforce'; 688 case 'hubspot': 689 return 'HubSpot'; 690 default: 691 return 'Unknown Ads'; 692 } 693 } 694 } 695 } 696 697 // Fallback if click ID found but not recognized 698 return 'Unknown Ads'; 699 }, 700 701 /** 702 * fbclid rides Instagram outbound links too, so the referrer or utm_source names the Meta platform. 703 */ 704 metaPlatform(referrerUrl, utmData = {}) { 705 if (utmData.utm_source && this.utmSourceMap[utmData.utm_source.toLowerCase()] === 'Instagram') { 706 return 'Instagram'; 707 } 708 try { 709 if (referrerUrl && this.analyzeDomain(new URL(referrerUrl).hostname.toLowerCase()) === 'Instagram') { 710 return 'Instagram'; 711 } 712 } catch (e) { 713 // no-op 714 } 715 return 'Facebook'; 716 }, 717 718 /** 719 * Analyze domain to determine source 720 */ 721 analyzeDomain(domain) { 722 this.log('Analyzing domain: ' + domain, 'info'); 723
724 // Check search engines 725 for (const engine of this.sourcePatterns.search_engines) { 726 if (engine.pattern.test(domain)) { 727 this.log('Found search engine: ' + engine.name, 'info'); 728 return engine.name; 729 } 730 } 731 732 // Check social media 733 for (const social of this.sourcePatterns.social_media) { 734 if (social.pattern.test(domain)) { 735 this.log('Found social media: ' + social.name, 'info'); 736 return social.name; 737 } 738 } 739 740 // Check AI assistants 741 for (const ai of this.sourcePatterns.ai_assistants) { 742 if (ai.pattern.test(domain)) { 743 this.log('Found AI assistant: ' + ai.name, 'info'); 744 return ai.name; 745 } 746 } 747 748 // Check email marketing platforms 749 for (const email of this.sourcePatterns.email_marketing) { 750 if (email.pattern.test(domain)) { 751 this.log('Found email marketing: ' + email.name, 'info'); 752 return email.name; 753 } 754 } 755 756 this.log('No matching source found for domain: ' + domain, 'info'); 757 return null; 758 }, 759 760 /** 761 * Enhance source name with organic/ads classification 762 */ 763 enhanceSourceWithAdInfo(baseName, utmData, clickIds) { 764 if (!baseName) { 765 return 'Unknown'; 766 } 767 768 // Direct traffic should remain as "Direct" without modification 769 if (baseName === 'Direct') { 770 return 'Direct'; 771 } 772 773 const isEmailMarketing = this.isEmailMarketingSource(baseName); 774 if (isEmailMarketing) { 775 return baseName; 776 } 777 778 const isCustom = this.isCustomSourceLabel(baseName); 779 if (isCustom && this.config.appendOrganicPaid === false) { 780 return baseName; 781 } 782 783 // 'Unknown Ads' is already a complete label, as is any custom label a user 784 // ended with a suffix word; appending again yields 'Unknown Ads Ads'. 785 if (/ (Ads|Organic)$/.test(baseName)) { 786 return baseName; 787 } 788 789 const isPaidTraffic = this.detectPaidTraffic(utmData, clickIds); 790 791 if (isPaidTraffic) { 792 return baseName + ' Ads'; 793 } else if (this.isKnownSource(baseName)) { 794 return baseName + ' Organic'; 795 } 796 797 return baseName; 798 }, 799 800 /** 801 * Detect if traffic is from paid campaigns 802 */ 803 detectPaidTraffic(utmData, clickIds) { 804 // Only a paid click ID proves an ad click; a hand-written utm_medium does not. 805 const paidClickIds = this.paidClickIds || []; 806 if (paidClickIds.some(id => clickIds[id])) { 807 return true; 808 } 809 810 // fbclid rides organic Facebook links too, so it only counts when the 811 // advertiser also tagged the URL with a UTM parameter. 812 const facebookIds = this.clickIdPatterns.facebook || []; 813 return facebookIds.some(id => clickIds[id]) && Object.keys(utmData).length > 0; 814 }, 815 816 /** 817 * Check if source is a known traffic source 818 */ 819 isKnownSource(source) { 820 if (!source) { 821 return false; 822 }
823 const knownSources = this.knownSources || []; 824 return knownSources.includes(source) || this.isCustomSourceLabel(source); 825 }, 826 827 /** 828 * Check if source is an email marketing label (no suffix). 829 */ 830 isEmailMarketingSource(source) { 831 if (!source) { 832 return false; 833 } 834 const emailSources = this.emailMarketingSources || []; 835 const normalized = String(source).toLowerCase(); 836 return emailSources.some(label => String(label).toLowerCase() === normalized); 837 }, 838 839 /** 840 * Check if source matches a custom rule label. 841 */ 842 isCustomSourceLabel(source) { 843 if (!source) { 844 return false; 845 } 846 const customSources = this.customSources || []; 847 const normalized = String(source).toLowerCase(); 848 return customSources.some(rule => { 849 if (!rule || !rule.label) { 850 return false; 851 } 852 return String(rule.label).toLowerCase() === normalized; 853 }); 854 }, 855 856 /** 857 * Store referral data in cookies. 858 * 859 * @param {Object} data Analyzed referral data. 860 * @param {string} [keyPrefix] Cookie-key prefix selecting which data set to 861 * write: '' for first-touch (default), 'last-' for last-touch. The full 862 * cookie name is still config.cookiePrefix + keyPrefix + key. 863 */ 864 storeReferralData(data, keyPrefix = '') { 865 // Store main data 866 this.setCookie(keyPrefix + 'source', data.source); 867 this.setCookie(keyPrefix + 'referrer', data.referrer); 868 this.setCookie(keyPrefix + 'ref-params', data.refParams); 869 870 // Store UTM parameters â explicitly clear any field not present in the 871 // current visit so stale values from a previous session don't bleed through. 872 this.utmFields.forEach(field => { 873 if (data.utm[field]) { 874 this.setCookie(keyPrefix + field, data.utm[field]); 875 } else { 876 this.removeCookie(keyPrefix + field); 877 } 878 }); 879 880 // Store the first/primary click ID, clearing it when this visit has none â 881 // same reason as the UTM fields above. First-touch is unaffected: see captureFirst. 882 const firstClickId = Object.entries(data.clickIds)[0]; 883 if (firstClickId) { 884 this.setCookie(keyPrefix + 'clid', firstClickId[1]); 885 } else { 886 this.removeCookie(keyPrefix + 'clid'); 887 } 888 }, 889 890 /** 891 * Populate form fields with stored data 892 */ 893 populateFormFields() { 894 try { 895 // gform_post_render can reach here before init on a delayed script. 896 this.refreshConfigFromGlobal(); 897 898 // Map data to form fields. First-touch always; last-touch only when 899 // opted in (its hidden fields aren't injected otherwise). 900 const fieldMappings = this.buildFieldMappings(''); 901 if (this.config.enableLastTouch) { 902 fieldMappings.push(...this.buildFieldMappings('last-')); 903 } 904 905 // Only stamp the fallback default once tracking is permitted. Writing it 906 // before consent would strand the field: populateField() refuses to 907 // overwrite a non-empty value, so an early "Unknown" survives even after 908 // consent is granted and the real source is captured. When consent isn't 909 // required this is always true, so default (non-consent) behavior is unchanged. 910 const canApplyDefault = this.isConsentGranted(); 911 912 // Populate fields
913 fieldMappings.forEach(mapping => { 914 let value = this.getCookie(mapping.cookie); 915 if ((!value || value === '') && mapping.applyDefault && canApplyDefault) { 916 value = this.config.defaultValue !== undefined ? this.config.defaultValue : 'Unknown'; 917 } 918 if (value) { 919 this.populateField(mapping.selector, value); 920 } 921 }); 922 } catch (error) { 923 this.log('Error populating form fields: ' + error.message, 'error'); 924 } 925 }, 926 927 /** 928 * Build the cookie -> field-selector mappings for one attribution data set. 929 * 930 * @param {string} keyPrefix '' for first-touch, 'last-' for last-touch. The 931 * prefix is applied to both the cookie key and the CSS class so the two 932 * sets stay parallel (e.g. `.pb-ref-tracking-last-source`). 933 * @returns {Array<{cookie: string, selector: string, applyDefault: boolean}>} 934 */ 935 buildFieldMappings(keyPrefix) { 936 const cssPrefix = '.pb-ref-tracking-' + keyPrefix; 937 const mappings = [ 938 { cookie: keyPrefix + 'source', selector: `${cssPrefix}source input`, applyDefault: true }, 939 { cookie: keyPrefix + 'ref-params', selector: `${cssPrefix}ref-params input`, applyDefault: false }, 940 { cookie: keyPrefix + 'referrer', selector: `${cssPrefix}referrer input`, applyDefault: true } 941 ]; 942 943 this.utmFields.forEach(field => { 944 mappings.push({ 945 cookie: keyPrefix + field, 946 selector: `${cssPrefix}${field} input`, 947 applyDefault: false 948 }); 949 }); 950 951 mappings.push({ 952 cookie: keyPrefix + 'clid', 953 selector: `${cssPrefix}clid input`, 954 applyDefault: false 955 }); 956 957 return mappings; 958 }, 959 960 /** 961 * Populate specific form field 962 */ 963 populateField(selector, value) { 964 // Choke point for the base-ID check below, and reachable directly, so 965 // a late-arriving config has to be picked up here too. 966 this.refreshConfigFromGlobal(); 967 968 const fields = document.querySelectorAll(selector); 969 fields.forEach(field => { 970 if (field && this.isPluginTrackingInput(field) && !field.value) { 971 field.value = value; 972 } 973 }); 974 }, 975 976 /** 977 * Parse the Gravity Forms field ID from an input element. 978 */ 979 getGravityFormsFieldId(input) { 980 if (!input) { 981 return ''; 982 } 983 984 const candidates = [ 985 typeof input.name === 'string' ? input.name : '', 986 typeof input.id === 'string' ? input.id : '' 987 ]; 988 989 for (const candidate of candidates) { 990 if (!candidate) { 991 continue; 992 } 993 let match = candidate.match(/^input_(\d+)$/); 994 if (match && match[1]) { 995 return match[1]; 996 } 997 match = candidate.match(/^input_\d+_(\d+)$/); 998 if (match && match[1]) { 999 return match[1]; 1000 } 1001 } 1002 1003 return ''; 1004 }, 1005 1006 /** 1007 * Only populate inputs that belong to this plugin's tracking namespace. 1008 * A late-arriving base ID is handled by refreshConfigFromGlobal(), which 1009 * every path into here runs first via populateFormFields(). 1010 */ 1011 isPluginTrackingInput(input) { 1012 const baseId = String( ( this.config && this.config.trackingFieldBaseId ) || '' ).replace(/\D+/g, ''); 1013 if (!baseId) { 1014 return false; 1015 } 1016 1017 const fieldId = this.getGravityFormsFieldId(input); 1018 return !!fieldId && fieldId.startsWith(baseId) && fieldId.length > baseId.length; 1019 }, 1020 1021 /** 1022 * Bind to Gravity Forms events 1023 */ 1024 bindGravityFormsEvents() { 1025 // jQuery-based event binding 1026 if (typeof jQuery !== 'undefined') { 1027 jQuery(document).on('gform_post_render', () => { 1028 this.populateFormFields(); 1029 }); 1030 } 1031 }, 1032 1033 /** 1034 * Opt-in GA4 custom event (config.enableDataLayerPush). 1035 * 1036 * Emits the captured parameters as a custom event on form confirmation, 1037 * delivered through the site's own Google tag (see sendReferralEvent). 1038 * Three confirmation types are covered: 1039 * - AJAX: GF fires `gform_confirmation_loaded`. 1040 * - Non-AJAX text: the page reloads with the confirmation wrapper in 1041 * the DOM. 1042 * - Redirect: the thank-you page has no GF markup, so a one-shot 1043 * sessionStorage flag written as the submitting page unloads 1044 * identifies it. The flag is always consumed on the next page load, 1045 * which also makes it the refresh guard (reloading a thank-you page 1046 * won't re-push). 1047 */ 1048 initDataLayerPush() { 1049 if (!this.config.enableDataLayerPush) { 1050 return; 1051 } 1052 1053 if (typeof jQuery !== 'undefined') { 1054 jQuery(document).on('gform_confirmation_loaded', (event, formId) => { 1055 this.sendReferralEvent(formId); 1056 }); 1057 1058 // GF 2.9+ submits through jQuery's trigger(), which reaches the form's 1059 // native submit() and so dispatches no event the listener below can see. 1060 jQuery(document).on('submit', event => { 1061 this.armSubmitFlag(event.target); 1062 }); 1063 1064 // GF re-renders for a validation failure, a page change and a successful 1065 // redirect alike, so only the absence of a navigation tells them apart. 1066 jQuery(document).on('gform_post_render', () => { 1067 clearTimeout(this.submitArmedTimer); 1068 this.submitArmedTimer = setTimeout(() => { 1069 this.submitArmed = false;
1070 }, 1500); 1071 }); 1072 } 1073 1074 // A submit button GF does not own still fires a real event; capture beats 1075 // GF's form-level listener to it. 1076 document.addEventListener('submit', event => { 1077 this.armSubmitFlag(event.target); 1078 }, true); 1079 1080 // Writing at unload, not at submit time, is what stops a failed attempt 1081 // leaving a flag behind: it re-renders in place and never unloads. 1082 window.addEventListener('pagehide', () => { 1083 if (this.submitArmed) { 1084 this.setSessionValue(this.dataLayerFlagKey(), '1'); 1085 } 1086 }); 1087 1088 // A back/forward-cache restore brings the armed state back with the page. 1089 window.addEventListener('pageshow', event => { 1090 if (event.persisted) { 1091 this.submitArmed = false; 1092 } 1093 }); 1094 1095 const flagSet = this.getSessionValue(this.dataLayerFlagKey()) === '1'; 1096 const hasConfirmation = !!document.querySelector('.gform_confirmation_wrapper, .gform_confirmation_message'); 1097 const hasValidationError = !!document.querySelector('.gform_validation_error, .gform_validation_errors'); 1098 1099 if (hasConfirmation) { 1100 // Non-AJAX text confirmation rendered in place. 1101 this.sendReferralEvent(); 1102 } else if (flagSet) { 1103 this.removeSessionValue(this.dataLayerFlagKey()); 1104 // Without AJAX a failed submission is itself a navigation, so the flag 1105 // survives it and the re-rendered form arrives carrying error markup. 1106 if (!hasValidationError) { 1107 this.sendReferralEvent(); 1108 } 1109 } 1110 }, 1111 1112 /** 1113 * Arm the flag when one of this plugin's tracked forms is submitted. 1114 * 1115 * @param {EventTarget} form Submit event target. 1116 */ 1117 armSubmitFlag(form) { 1118 if (form && form.querySelector && form.querySelector('[class*="pb-ref-tracking-"]')) { 1119 this.submitArmed = true; 1120 } 1121 }, 1122 1123 dataLayerFlagKey() { 1124 return this.config.cookiePrefix + 'dl-submit-pending'; 1125 }, 1126 1127 /** 1128 * Emit the captured parameters as a GA4 custom event, once per pageview. 1129 * 1130 * Delivery prefers gtag() when the standard GA4 tag is on the page â 1131 * the event reaches GA4 directly with zero setup. Otherwise the event 1132 * is pushed into window.dataLayer for a GTM trigger to forward. Exactly 1133 * one delivery happens per conversion, so sites running gtag.js inside 1134 * GTM never double-send. Safe without any Google tag installed â the 1135 * fallback push just appends to an array nothing reads. 1136 * 1137 * Parameter keys mirror the plugin's merge tags / entry meta keys 1138 * (pb_ref_source, pb_ref_utm_source, â¦, pb_ref_params); the parallel 1139 * last-touch set (pb_ref_last_*) is included when last-touch is enabled. 1140 * Only non-empty values are included. 1141 * 1142 * @param {number|string} [formId] GF form ID when known (AJAX confirmations). 1143 */ 1144 sendReferralEvent(formId) { 1145 if (this.dataLayerPushed) { 1146 return; 1147 } 1148 this.dataLayerPushed = true; 1149 // dataLayerPushed only guards this pageview; disarming stops the pagehide 1150 // handler flagging an in-place confirmation the visitor then navigates off. 1151 this.submitArmed = false; 1152 this.removeSessionValue(this.dataLayerFlagKey()); 1153 1154 const eventName = this.config.dataLayerEventName || 'pb_referral_submit'; 1155 const params = {}; 1156 if (formId) { 1157 params.pb_ref_form_id = String(formId); 1158 } 1159 1160 this.collectDataLayerValues('', 'pb_ref_', params); 1161 if (this.config.enableLastTouch) { 1162 this.collectDataLayerValues('last-', 'pb_ref_last_', params); 1163 } 1164 1165 if (typeof window.gtag === 'function') { 1166 window.gtag('event', eventName, params); 1167 this.log('gtag event sent: ' + eventName, 'info'); 1168 return; 1169 } 1170 1171 window.dataLayer = window.dataLayer || []; 1172 window.dataLayer.push(Object.assign({ event: eventName }, params)); 1173 this.log('dataLayer push: ' + eventName, 'info'); 1174 }, 1175 1176 /** 1177 * Read one attribution data set from storage into an event's parameters. 1178 * 1179 * @param {string} keyPrefix '' for first-touch, 'last-' for last-touch. 1180 * @param {string} payloadPrefix Parameter key prefix ('pb_ref_' / 'pb_ref_last_'). 1181 * @param {Object} payload Mutated in place; empty values are skipped. 1182 */ 1183 collectDataLayerValues(keyPrefix, payloadPrefix, payload) { 1184 // 'ref-params' maps to pb_ref_params (not pb_ref_ref_params), matching 1185 // the merge-tag / entry-meta naming.
1186 const keys = { 1187 'source': 'source', 1188 'referrer': 'referrer', 1189 'clid': 'clid', 1190 'ref-params': 'params', 1191 }; 1192 this.utmFields.forEach(field => { 1193 keys[field] = field; 1194 }); 1195 1196 Object.keys(keys).forEach(cookieKey => { 1197 const value = this.getCookie(keyPrefix + cookieKey); 1198 if (value) { 1199 payload[payloadPrefix + keys[cookieKey]] = value; 1200 } 1201 }); 1202 }, 1203 1204 /** 1205 * Cookie management utilities 1206 */ 1207 setCookie(name, value) { 1208 const fullName = this.config.cookiePrefix + name; 1209 1210 // Hold attribution in-session until consent so the data isn't lost mid-visit. 1211 // This pre-consent hold is always transient (sessionStorage) and never written 1212 // to localStorage â persistent storage only happens once consent is granted. 1213 // When the fallback is disabled ('none') nothing is held before consent. 1214 if (!this.isConsentGranted()) { 1215 if (this.config.fallbackStorage !== 'none') { 1216 this.setSessionValue(fullName, value); 1217 } 1218 return; 1219 } 1220 1221 let storedInCookies = false; 1222 1223 if (typeof Cookies !== 'undefined') { 1224 try { 1225 const cookieDays = Number(this.config.cookieExpiry); 1226 const onHttps = typeof location !== 'undefined' && location.protocol === 'https:'; 1227 const cookieOptions = { 1228 path: this.config.cookiePath || '/', 1229 sameSite: this.config.cookieSameSite || 'Lax', 1230 secure: this.config.cookieSecure === undefined 1231 ? onHttps 1232 : !!this.config.cookieSecure, 1233 }; 1234 1235 // In session-only mode (used for admin QA), avoid long-lived cookies. 1236 if (!this.config.sessionOnlyMode && Number.isFinite(cookieDays) && cookieDays > 0) { 1237 cookieOptions.expires = cookieDays; 1238 } 1239 1240 Cookies.set(fullName, value, cookieOptions); 1241 storedInCookies = Cookies.get(fullName) === String(value); 1242 } catch (error) { 1243 this.log('Cookie write failed for ' + fullName + ': ' + error.message, 'warn'); 1244 } 1245 } 1246 1247 // Fallback for restricted-cookie environments, using the configured tier. 1248 if (!storedInCookies) { 1249 this.fallbackSet(fullName, value); 1250 } 1251 }, 1252 1253 getCookie(name) { 1254 const fullName = this.config.cookiePrefix + name; 1255 if (typeof Cookies !== 'undefined') { 1256 try { 1257 const cookieValue = Cookies.get(fullName); 1258 if (cookieValue !== undefined) { 1259 return cookieValue; 1260 } 1261 } catch (error) { 1262 this.log('Cookie read failed for ' + fullName + ': ' + error.message, 'warn'); 1263 } 1264 } 1265 1266 const fallbackValue = this.fallbackGet(fullName); 1267 if (fallbackValue !== undefined) { 1268 return fallbackValue; 1269 } 1270 1271 // With the persistent tier, a value held in sessionStorage before consent 1272 // won't live in localStorage yet â check the transient store as a last resort. 1273 if (this.config.fallbackStorage === 'local') { 1274 return this.getSessionValue(fullName); 1275 } 1276 return undefined; 1277 }, 1278 1279 removeCookie(name) { 1280 const fullName = this.config.cookiePrefix + name; 1281 if (typeof Cookies !== 'undefined') { 1282 try { 1283 const path = this.config.cookiePath || '/'; 1284 Cookies.remove(fullName); 1285 Cookies.remove(fullName, { path }); 1286 } catch (error) { 1287 this.log('Cookie remove failed for ' + fullName + ': ' + error.message, 'warn'); 1288 } 1289 } 1290 // Clear both stores regardless of tier so switching tiers never strands data. 1291 this.removeSessionValue(fullName); 1292 this.removeLocalValue(fullName); 1293 }, 1294 1295 /** 1296 * Fallback-storage dispatcher. Routes to localStorage (the only offered 1297 * tier) or nowhere based on the configured `fallbackStorage` tier. Used 1298 * when cookies are blocked. The persistent ('local') tier is only reached 1299 * after consent (see setCookie), matching cookies' legal footing under 1300 * GDPR/ePrivacy, and is scoped to the attribution window (see 1301 * setLocalTrackingValue / getLocalTrackingValue). 1302 */ 1303 fallbackSet(key, value) { 1304 if (this.config.fallbackStorage === 'local') { 1305 this.setLocalTrackingValue(key, value); 1306 } 1307 }, 1308 1309 fallbackGet(key) { 1310 if (this.config.fallbackStorage === 'local') { 1311 return this.getLocalTrackingValue(key); 1312 } 1313 return undefined; 1314 }, 1315 1316 fallbackRemove(key) { 1317 if (this.config.fallbackStorage === 'local') { 1318 this.removeLocalValue(key); 1319 } 1320 }, 1321 1322 /** 1323 * Attribution-window in milliseconds, derived from `cookieExpiry` (days). 1324 * Returns 0 when no positive expiry is configured, meaning "no TTL". 1325 */ 1326 attributionWindowMs() { 1327 const days = Number(this.config.cookieExpiry); 1328 return (Number.isFinite(days) && days > 0) ? days * 24 * 60 * 60 * 1000 : 0; 1329 }, 1330 1331 /** 1332 * Write a tracking value to localStorage wrapped with a timestamp, so the 1333 * persistent fallback honors the attribution window (localStorage has no 1334 * native expiry). Kept separate from the generic setLocalValue() so 1335 * non-tracking keys (e.g. the admin migration flag) stay plain strings. 1336 */ 1337 setLocalTrackingValue(key, value) { 1338 let payload; 1339 try { 1340 payload = JSON.stringify({ v: String(value), t: Date.now() }); 1341 } catch (error) { 1342 this.log('localStorage payload encode failed for ' + key + ': ' + error.message, 'warn'); 1343 return; 1344 } 1345 this.setLocalValue(key, payload); 1346 }, 1347 1348 /** 1349 * Read a timestamped tracking value from localStorage, enforcing the 1350 * attribution window. Expired or unrecognized entries are removed and 1351 * treated as absent so stale attribution never leaks past the window. 1352 */ 1353 getLocalTrackingValue(key) { 1354 const raw = this.getLocalValue(key); 1355 if (raw === undefined) { 1356 return undefined; 1357 } 1358 1359 let parsed; 1360 try { 1361 parsed = JSON.parse(raw); 1362 } catch (error) { 1363 // Foreign / legacy plain value â clear it and treat as absent. 1364 this.removeLocalValue(key); 1365 return undefined; 1366 } 1367 1368 if (!parsed || typeof parsed !== 'object' || typeof parsed.t !== 'number' || typeof parsed.v === 'undefined') { 1369 this.removeLocalValue(key); 1370 return undefined; 1371 } 1372 1373 const windowMs = this.attributionWindowMs(); 1374 if (windowMs > 0 && (Date.now() - parsed.t) > windowMs) { 1375 this.removeLocalValue(key); 1376 return undefined; 1377 } 1378 1379 return parsed.v; 1380 }, 1381 1382 /** 1383 * One-time cleanup for admin QA sessions after enabling session-only mode. 1384 * 1385 * Removes any legacy long-lived tracking storage so testers get an immediate 1386 * clean slate after upgrading, without waiting for old cookies to expire. 1387 */ 1388 migrateLegacyAdminTrackingStorage() { 1389 const flagKey = this.config.cookiePrefix + 'admin-session-only-migrated-v1'; 1390 if (this.getLocalValue(flagKey) === '1') { 1391 this.log('Admin session-only migration already completed; skipping cleanup', 'info'); 1392 return; 1393 } 1394 1395 this.clearAllTrackingStorage(); 1396 this.setLocalValue(flagKey, '1'); 1397 this.log('Admin session-only migration completed; cleared legacy tracking storage', 'info'); 1398 }, 1399 1400 clearAllTrackingStorage() {
1401 const keys = ['source', 'ref-params', 'referrer', 'clid'].concat(this.utmFields || []); 1402 1403 keys.forEach(key => { 1404 this.removeCookie(key); 1405 }); 1406 1407 // Also remove any other prefixed cookies/session keys added by filters. 1408 this.removeByPrefix(this.config.cookiePrefix); 1409 }, 1410 1411 removeByPrefix(prefix) { 1412 if (!prefix) { 1413 return; 1414 } 1415 1416 if (typeof Cookies !== 'undefined') { 1417 try { 1418 const path = this.config.cookiePath || '/'; 1419 const allCookies = Cookies.get() || {}; 1420 Object.keys(allCookies).forEach(cookieName => { 1421 if (cookieName.startsWith(prefix)) { 1422 Cookies.remove(cookieName); 1423 Cookies.remove(cookieName, { path }); 1424 } 1425 }); 1426 } catch (error) { 1427 this.log('Cookie prefix cleanup failed for ' + prefix + ': ' + error.message, 'warn'); 1428 } 1429 } 1430 1431 if (this.canUseSessionStorage()) { 1432 try { 1433 for (let i = window.sessionStorage.length - 1; i >= 0; i -= 1) { 1434 const key = window.sessionStorage.key(i); 1435 if (key && key.startsWith(prefix)) { 1436 window.sessionStorage.removeItem(key); 1437 } 1438 } 1439 } catch (error) { 1440 this.log('sessionStorage prefix cleanup failed for ' + prefix + ': ' + error.message, 'warn'); 1441 } 1442 } 1443 1444 // Sweep localStorage too so disabling the persistent tier (or cleanup) 1445 // never strands tracking data, regardless of the active fallback tier. 1446 if (this.canUseLocalStorage()) { 1447 try { 1448 for (let i = window.localStorage.length - 1; i >= 0; i -= 1) { 1449 const key = window.localStorage.key(i); 1450 if (key && key.startsWith(prefix)) { 1451 window.localStorage.removeItem(key); 1452 } 1453 } 1454 } catch (error) { 1455 this.log('localStorage prefix cleanup failed for ' + prefix + ': ' + error.message, 'warn'); 1456 } 1457 } 1458 }, 1459 1460 setLocalValue(key, value) { 1461 if (!this.canUseLocalStorage()) { 1462 return; 1463 } 1464 try { 1465 window.localStorage.setItem(key, String(value)); 1466 } catch (error) { 1467 this.log('localStorage write failed for ' + key + ': ' + error.message, 'warn'); 1468 } 1469 }, 1470 1471 getLocalValue(key) { 1472 if (!this.canUseLocalStorage()) { 1473 return undefined; 1474 } 1475 try { 1476 return window.localStorage.getItem(key) || undefined; 1477 } catch (error) { 1478 this.log('localStorage read failed for ' + key + ': ' + error.message, 'warn'); 1479 return undefined; 1480 } 1481 }, 1482 1483 removeLocalValue(key) { 1484 if (!this.canUseLocalStorage()) { 1485 return; 1486 } 1487 try { 1488 window.localStorage.removeItem(key); 1489 } catch (error) { 1490 this.log('localStorage remove failed for ' + key + ': ' + error.message, 'warn'); 1491 } 1492 }, 1493 1494 setSessionValue(key, value) { 1495 if (!this.canUseSessionStorage()) { 1496 return; 1497 } 1498 try { 1499 window.sessionStorage.setItem(key, String(value)); 1500 } catch (error) { 1501 this.log('sessionStorage write failed for ' + key + ': ' + error.message, 'warn'); 1502 } 1503 }, 1504 1505 getSessionValue(key) { 1506 if (!this.canUseSessionStorage()) { 1507 return undefined; 1508 } 1509 try { 1510 return window.sessionStorage.getItem(key) || undefined; 1511 } catch (error) { 1512 this.log('sessionStorage read failed for ' + key + ': ' + error.message, 'warn'); 1513 return undefined; 1514 } 1515 }, 1516 1517 removeSessionValue(key) { 1518 if (!this.canUseSessionStorage()) { 1519 return; 1520 } 1521 try { 1522 window.sessionStorage.removeItem(key); 1523 } catch (error) { 1524 this.log('sessionStorage remove failed for ' + key + ': ' + error.message, 'warn'); 1525 } 1526 }, 1527 1528 canUseSessionStorage() { 1529 if (typeof window === 'undefined' || !window.sessionStorage) { 1530 return false; 1531 } 1532 try { 1533 const testKey = 'pb-ref-tracking-storage-test'; 1534 window.sessionStorage.setItem(testKey, '1'); 1535 window.sessionStorage.removeItem(testKey); 1536 return true; 1537 } catch (error) { 1538 return false; 1539 } 1540 }, 1541 1542 canUseLocalStorage() { 1543 if (typeof window === 'undefined' || !window.localStorage) { 1544 return false; 1545 } 1546 try { 1547 const testKey = 'pb-ref-tracking-local-test'; 1548 window.localStorage.setItem(testKey, '1'); 1549 window.localStorage.removeItem(testKey); 1550 return true; 1551 } catch (error) { 1552 return false; 1553 } 1554 }, 1555 1556 /** 1557 * Utility functions 1558 */ 1559 capitalizeFirst(str) { 1560 return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 1561 }, 1562 1563 // Normalize hostnames for reliable comparison 1564 normalizeHostname(hostname) { 1565 if (!hostname) return ''; 1566 return hostname.toLowerCase().replace(/^www\./, ''); 1567 }, 1568 1569 // Determine if a referrer is the same site (including subdomains) 1570 isSelfReferrer(referrerHostname, currentHostname) { 1571 const ref = this.normalizeHostname(referrerHostname); 1572 const cur = this.normalizeHostname(currentHostname); 1573 if (!ref || !cur) return false;
1574 return ref === cur || ref.endsWith('.' + cur) || cur.endsWith('.' + ref); 1575 }, 1576 1577 log(message, level = 'info') { 1578 if (this.config.debug) { 1579 console[level]('PB Referral Tracker: ' + message); 1580 } 1581 } 1582 }; 1583 1584 // Initialize when DOM is ready 1585 if (document.readyState === 'loading') { 1586 document.addEventListener('DOMContentLoaded', () => PBReferralTracker.init()); 1587 } else { 1588 PBReferralTracker.init(); 1589 } 1590 1591})();
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.