PageSourceSearch

https://fairwaygreen.com/wp-content/plugins/tmb-site-manager-clien…/blocks/phone-swap/view.js?ver=1790356478

js fairwaygreen.com collected 2026-10-09 02:50:23 UTC 16,381 bytes, 442 lines download raw bytes

1/**
2 * Nexus Phone Swap — frontend applier.
3 *
4 * UNCONDITIONALLY turns any plain-text phone number on the page (no tel: link
5 * at all — a header or footer a theme never made clickable) into a real
6 * `<a href="tel:...">`, on every Nexus Client page whether or not a campaign
7 * is active. Then, if this page load resolves an "active" campaign number,
8 * rewrites every clickable tel: link on the page (now including the ones just
9 * created) — href, visible phone-number text, and aria-label/title — to that
10 * number. A MutationObserver keeps both passes running against content
11 * injected after load (sticky footers, modals, lazy sections).
12 *
13 * Active-number precedence (per page load):
14 *   1. FRESH OVERRIDE — the URL carries a trigger param (ntm_location, then
15 *      location) whose value matches a mapping in THIS page's config tag(s).
16 *      If that mapping row was configured with an optional utm_content, the
17 *      URL's ?utm_content= must ALSO match for it to count; a row configured
18 *      WITHOUT utm_content still matches by location alone (today's default
19 *      behavior, unchanged). See resolveMappingKey() below. That number is
20 *      applied AND written to the persistence cookie.
21 *   2. PERSISTED — no fresh match, but the session cookie holds a valid number
22 *      (set on an earlier landing page). It is applied here. This is what makes
23 *      the swap follow the visitor across every page in the session.
24 *   3. DEFAULT — neither: the page is left untouched (native numbers).
25 *
26 * The cookie is a session cookie by default (cleared when the browser session
27 * ends). It is written and read only here — the server never sees it, so
28 * full-page caching is unaffected. Stored values are re-sanitized on read so a
29 * tampered cookie can never inject markup or a non-tel href.
30 *
31 * Runs site-wide (enqueued on every page) but returns immediately unless a
32 * fresh match resolves or a persisted value exists.
33 */
34( function () {
35	'use strict';
36
37	var CFG = ( typeof window !== 'undefined' && window.TMBPhoneSwapCfg ) || {};
38	var COOKIE = ( CFG.cookie && String( CFG.cookie ) ) || 'nexus_phone_swap';
39	var PARAMS = ( Array.isArray( CFG.params ) && CFG.params.length ) ? CFG.params : [ 'ntm_location', 'location' ];
40	var CONTENT_PARAM = ( CFG.contentParam && String( CFG.contentParam ) ) || 'utm_content';
41	var KEY_DELIMITER = ( CFG.keyDelimiter && String( CFG.keyDelimiter ) ) || '|';
42	var MAX_AGE = parseInt( CFG.maxAge, 10 ) || 0; // 0 = session cookie
43
44	var PROCESSED = 'nexusPhoneSwapped';
45	// Phone-looking text: optional +, then ≥7 digits allowing (), ., -, spaces.
46	var PHONE_TEXT_RE = /\+?\(?\d[\d\s().\-]{5,}\d/g;
47	// A usable phone number for schema/injection needs at least 7 digits.
48	var MIN_DIGITS = 7;
49
50	// ── Trigger params ──────────────────────────────────────────────────────────
51
52	// Normalize identically to the PHP slug sanitizer: lowercase [a-z0-9-].
53	// Pure/self-contained (no outer closure) so it can be extracted and unit
54	// tested on its own — see tests/nexus-phone-swap.test.js.
55	function normalizeTriggerValue( raw ) {
56		if ( ! raw ) {
57			return '';
58		}
59		return String( raw ).trim().toLowerCase().replace( /[^a-z0-9\-]/g, '' );
60	}
61
62	function getTriggerSlug() {
63		var params;
64		try {
65			params = new URLSearchParams( window.location.search );
66		} catch ( e ) {
67			return '';
68		}
69		for ( var i = 0; i < PARAMS.length; i++ ) {
70			var slug = normalizeTriggerValue( params.get( PARAMS[ i ] ) );
71			if ( slug ) {
72				return slug;
73			}
74		}
75		return '';
76	}
77
78	function getTriggerContent() {
79		var params;
80		try {
81			params = new URLSearchParams( window.location.search );
82		} catch ( e ) {
83			return '';
84		}
85		return normalizeTriggerValue( params.get( CONTENT_PARAM ) );
86	}
87
88	// ── Composite-key resolution ────────────────────────────────────────────────
89	//
90	// A mapping row without utm_content is stored (by the PHP sanitizer) under
91	// its bare location key and matches by location alone. A row WITH
92	// utm_content is stored under "location|utm_content" and only matches when
93	// BOTH the location and utm_content triggers match — so two rows (or two
94	// block/shortcode instances) can share the same location and resolve to
95	// different phone numbers independently. A location match whose
96	// utm_content doesn't match anything configured for it does NOT fall back
97	// to some other row's number — it simply doesn't match.
98	//
99	// Pure/self-contained (no outer closure) so it can be extracted and unit
100	// tested on its own — see tests/nexus-phone-swap.test.js.
101	function resolveMappingKey( mappings, slug, content, delimiter ) {
102		var d = delimiter || '|';
103		if ( ! slug || ! mappings || typeof mappings !== 'object' ) {
104			return '';
105		}
106		if ( content && Object.prototype.hasOwnProperty.call( mappings, slug + d + content ) ) {
107			return slug + d + content;
108		}
109		if ( Object.prototype.hasOwnProperty.call( mappings, slug ) ) {
110			return slug;
111		}
112		return '';
113	}
114
115	// ── Config tags (mappings) ──────────────────────────────────────────────────
116
117	function readMappings() {
118		var merged = {};
119		var tags   = document.querySelectorAll( 'script.nexus-phone-swap-config' );
120
121		Array.prototype.forEach.call( tags, function ( tag ) {
122			try {
123				var config = JSON.parse( tag.textContent || '{}' );
124				var map    = config && config.mappings;
125				if ( ! map || typeof map !== 'object' ) {
126					return;
127				}
128				Object.keys( map ).forEach( function ( key ) {
129					if ( ! ( key in merged ) && typeof map[ key ] === 'string' ) {
130						merged[ key ] = map[ key ];
131					}
132				} );
133			} catch ( e ) {
134				/* malformed config tag — ignore */
135			}
136		} );
137
138		return merged;
139	}
140
141	// ── Number normalization + validation ───────────────────────────────────────
142
143	function countDigits( value ) {
144		var m = String( value ).match( /\d/g );
145		return m ? m.length : 0;
146	}
147
148	function toDialable( phone ) {
149		var digits = String( phone ).replace( /[^\d]/g, '' );
150		return String( phone ).charAt( 0 ) === '+' ? '+' + digits : digits;
151	}
152
153	// Accept only a display string that looks like a phone number; return a
154	// {display, dialable} pair or null. Used for BOTH fresh mappings and cookie
155	// reads, so a poisoned cookie is rejected exactly like bad config.
156	function normalizeNumber( raw ) {
157		if ( typeof raw !== 'string' ) {
158			return null;
159		}
160		var display = raw.replace( /[^0-9()+\-.\s]/g, '' ).trim();
161		if ( countDigits( display ) < MIN_DIGITS ) {
162			return null;
163		}
164		var dialable = toDialable( display );
165		if ( countDigits( dialable ) < MIN_DIGITS ) {
166			return null;
167		}
168		return { display: display, dialable: dialable };
169	}
170
171	// ── Persistence cookie ──────────────────────────────────────────────────────
172
173	function readCookieRaw( name ) {
174		var target = name + '=';
175		var parts  = ( document.cookie || '' ).split( ';' );
176		for ( var i = 0; i < parts.length; i++ ) {
177			var c = parts[ i ].trim();
178			if ( c.indexOf( target ) === 0 ) {
179				return c.substring( target.length );
180			}
181		}
182		return '';
183	}
184
185	function readPersisted() {
186		var raw = readCookieRaw( COOKIE );
187		if ( ! raw ) {
188			return null;
189		}
190		try {
191			var obj = JSON.parse( decodeURIComponent( raw ) );
192			// Re-sanitize the stored display; recompute dialable (never trust it).
193			return obj && typeof obj.d === 'string' ? normalizeNumber( obj.d ) : null;
194		} catch ( e ) {
195			return null;
196		}
197	}
198
199	function writePersisted( active, slug ) {
200		var value = encodeURIComponent( JSON.stringify( { d: active.display, s: slug || '' } ) );
201		var attrs = '; path=/; SameSite=Lax';
202		if ( window.location.protocol === 'https:' ) {
203			attrs += '; Secure';
204		}
205		if ( MAX_AGE > 0 ) {
206			attrs += '; Max-Age=' + MAX_AGE;
207		}
208		try {
209			document.cookie = COOKIE + '=' + value + attrs;
210		} catch ( e ) {
211			/* cookie write blocked — swap still applies for this page */
212		}
213	}
214
215	// ── DOM swapping (unchanged behavior) ───────────────────────────────────────
216
217	function replacePhoneText( node, displayPhone ) {
218		var walker  = document.createTreeWalker( node, NodeFilter.SHOW_TEXT, null, false );
219		var current = walker.nextNode();
220
221		while ( current ) {
222			if ( PHONE_TEXT_RE.test( current.nodeValue ) ) {
223				current.nodeValue = current.nodeValue.replace( PHONE_TEXT_RE, displayPhone );
224			}
225			PHONE_TEXT_RE.lastIndex = 0;
226			current = walker.nextNode();
227		}
228	}
229
230	function swapLink( link, displayPhone, dialable ) {
231		if ( link.dataset[ PROCESSED ] ) {
232			return;
233		}
234		link.dataset[ PROCESSED ] = '1';
235
236		link.setAttribute( 'href', 'tel:' + dialable );
237		replacePhoneText( link, displayPhone );
238
239		[ 'aria-label', 'title' ].forEach( function ( attr ) {
240			var value = link.getAttribute( attr );
241			if ( value && PHONE_TEXT_RE.test( value ) ) {
242				link.setAttribute( attr, value.replace( PHONE_TEXT_RE, displayPhone ) );
243			}
244			PHONE_TEXT_RE.lastIndex = 0;
245		} );
246	}
247
248	function swapAll( root, displayPhone, dialable ) {
249		if ( ! root || ! root.querySelectorAll ) {
250			return;
251		}
252
253		var candidates = Array.prototype.slice.call( root.querySelectorAll( 'a[href]' ) );
254		if ( root.matches && root.matches( 'a[href]' ) ) {
255			candidates.push( root );
256		}
257
258		candidates.forEach( function ( link ) {
259			var href = ( link.getAttribute( 'href' ) || '' ).trim().toLowerCase();
260			if ( href.indexOf( 'tel:' ) === 0 ) {
261				swapLink( link, displayPhone, dialable );
262			}
263		} );
264	}
265
266	// ── Linkify (numbers with no tel: link at all) ──────────────────────────────
267	//
268	// swapAll()/swapLink() only ever touch an existing `<a href="tel:...">` —
269	// a theme that renders its phone number as plain, unlinked text (confirmed
270	// on more than one live site) is invisible to it, campaign or no campaign.
271	// Rather than special-casing that in the swap logic, this makes plain-text
272	// numbers real tel: links UNCONDITIONALLY — every Nexus Client page, whether
273	// or not any phone-swap campaign is active on it — so click-to-call always
274	// works and, whenever a campaign number IS active, swapAll() picks the new
275	// link up like any other and swaps it exactly the same way.
276	//
277	// This runs against the whole page, so it deliberately requires a much
278	// stricter shape than PHONE_TEXT_RE (which only ever runs inside an
279	// already-matched tel: link): a real 10-digit NANP number with a separator
280	// between every group (area code / exchange / line), which a ZIP+4
281	// ("12345-6789", one separator, 5+4 digits) or most other digit runs on a
282	// site never happen to form.
283
284	// Pure/self-contained (regex literal inlined, no outer closure) so it can be
285	// extracted and unit tested on its own — see tests/nexus-phone-swap.test.js.
286	// No lookbehind: Safari < 16.4 (notably older iOS — exactly what a lot of
287	// Facebook ad-click traffic arrives on) throws a SyntaxError on `(?<!...)`,
288	// which would silently kill this entire IIFE. The leading boundary is
289	// captured instead and re-emitted as its own text run.
290	//
291	// Returns an array of pieces describing how to rebuild the text node:
292	// { type: 'text', value } for a plain run, { type: 'tel', value, dialable }
293	// for a phone match. A text node with no match returns a single 'text'
294	// piece equal to the original string, so callers can treat every result
295	// uniformly instead of special-casing "no match."
296	function splitPlainTextPhones( text ) {
297		var re = /(^|[^\d])((?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4})(?!\d)/g;
298		var pieces = [];
299		var lastIndex = 0;
300		var match;
301
302		while ( ( match = re.exec( text ) ) ) {
303			var boundary   = match[ 1 ];
304			var phoneText  = match[ 2 ];
305			var phoneStart = match.index + boundary.length;
306
307			if ( phoneStart > lastIndex ) {
308				pieces.push( { type: 'text', value: text.slice( lastIndex, phoneStart ) } );
309			}
310			pieces.push( { type: 'tel', value: phoneText, dialable: toDialable( phoneText ) } );
311			lastIndex = re.lastIndex;
312		}
313
314		if ( ! pieces.length ) {
315			return [ { type: 'text', value: text } ];
316		}
317		if ( lastIndex < text.length ) {
318			pieces.push( { type: 'text', value: text.slice( lastIndex ) } );
319		}
320		return pieces;
321	}
322
323	// True if any ancestor (including the node itself) is ALREADY a link (of
324	// any kind — never nest an <a> inside an <a>) or an element whose text
325	// shouldn't be touched at all (script/style/form input/contenteditable).
326	function isSkippableForLinkify( el ) {
327		while ( el ) {
328			if ( el.nodeType === 1 ) {
329				var tag = el.tagName;
330				if ( tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT' ) {
331					return true;
332				}
333				if ( el.isContentEditable ) {
334					return true;
335				}
336			}
337			el = el.parentElement;
338		}
339		return false;
340	}
341
342	function linkifyTextNode( node ) {
343		var pieces = splitPlainTextPhones( node.nodeValue );
344		if ( pieces.length === 1 && pieces[ 0 ].type === 'text' ) {
345			return; // no phone-shaped text in this node — nothing to do
346		}
347
348		var frag = document.createDocumentFragment();
349		pieces.forEach( function ( piece ) {
350			if ( piece.type === 'tel' ) {
351				var a = document.createElement( 'a' );
352				a.setAttribute( 'href', 'tel:' + piece.dialable );
353				a.textContent = piece.value;
354				frag.appendChild( a );
355			} else {
356				frag.appendChild( document.createTextNode( piece.value ) );
357			}
358		} );
359
360		node.parentNode.replaceChild( frag, node );
361	}
362
363	function linkifyPlainText( root ) {
364		if ( ! root || typeof document.createTreeWalker !== 'function' ) {
365			return;
366		}
367		var walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, null, false );
368		// Snapshot first — replacing nodes mid-traversal confuses the walker.
369		var textNodes = [];
370		var current = walker.nextNode();
371		while ( current ) {
372			textNodes.push( current );
373			current = walker.nextNode();
374		}
375
376		textNodes.forEach( function ( node ) {
377			if ( node.parentNode && ! isSkippableForLinkify( node.parentElement ) ) {
378				linkifyTextNode( node );
379			}
380		} );
381	}
382
383	// ── Resolve active number + run ─────────────────────────────────────────────
384
385	function resolveActive() {
386		var slug = getTriggerSlug();
387
388		// 1. Fresh override: param matches a mapping on this page — exact
389		//    location+utm_content match wins, else a location-only row.
390		if ( slug ) {
391			var mappings = readMappings();
392			var content  = getTriggerContent();
393			var key      = resolveMappingKey( mappings, slug, content, KEY_DELIMITER );
394
395			if ( key ) {
396				var fresh = normalizeNumber( mappings[ key ] );
397				if ( fresh ) {
398					writePersisted( fresh, slug );
399					return fresh;
400				}
401			}
402		}
403
404		// 2. Persisted value from an earlier landing page (carries across pages).
405		//    An unmatched param never clears this.
406		return readPersisted();
407	}
408
409	function init() {
410		// Always linkify plain-text numbers first — independent of whether a
411		// campaign override resolves below, every page gets clickable numbers.
412		linkifyPlainText( document );
413
414		var active = resolveActive();
415		if ( active ) {
416			swapAll( document, active.display, active.dialable );
417		}
418		// else: 3. Default — no campaign override on this page; the numbers
419		// stay as their own (now-linked) native values.
420
421		if ( typeof MutationObserver === 'function' && document.body ) {
422			new MutationObserver( function ( records ) {
423				records.forEach( function ( record ) {
424					Array.prototype.forEach.call( record.addedNodes, function ( node ) {
425						if ( node.nodeType === 1 ) {
426							linkifyPlainText( node );
427							if ( active ) {
428								swapAll( node, active.display, active.dialable );
429							}
430						}
431					} );
432				} );
433			} ).observe( document.body, { childList: true, subtree: true } );
434		}
435	}
436
437	if ( document.readyState === 'loading' ) {
438		document.addEventListener( 'DOMContentLoaded', init );
439	} else {
440		init();
441	}
442} )();

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.