PageSourceSearch

https://snowtechnology.ie/wp-content/plugins/pb-gforms-referral-tracking/assets/js/front-end.js?ver=1.7.1

js snowtechnology.ie collected 2026-10-04 01:46:15 UTC 52,015 bytes, 1,591 lines download raw bytes

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.