/** * Sparrow DNI v2 (Dynamic Number Insertion) * Performance-optimized script for phone number swapping. * Target: <500ms from page load to number display * @version 2.0.0 */ (function(window, document) { 'use strict'; // ============================================ // CONFIGURATION // ============================================ var config = { poolId: null, debug: false, autoDetect: true, dataAttribute: 'data-sparrow-phone', endpoint: null, tags: {}, onReady: null, onError: null, phonePattern: /(?:\+1[-.]?)?\(?[2-9]\d{2}\)?[-.]?[2-9]\d{2}[-.]?\d{4}/g, excludeSelectors: ['script', 'style', 'noscript', 'code', 'pre'], observeMutations: true, releaseGracePeriod: 30000, useBlur: true, maxTextNodes: 1000 }; // ============================================ // STATE // ============================================ var state = { initialized: false, session: null, visitorId: null, cacheToken: null, originalNumbers: new Map(), swappedElements: new Set(), observer: null, eventQueue: [], releaseTimer: null, sessionReleased: false, blurStyleInjected: false, storageAvailable: true, startTime: Date.now() }; // ============================================ // TIMING HELPERS // ============================================ function elapsed() { return (Date.now() - state.startTime) + 'ms'; } function log(message, data) { if (!config.debug) return; var prefix = '[Sparrow DNI +' + elapsed() + ']'; if (data !== undefined) { console.log(prefix, message, data); } else { console.log(prefix, message); } } // ============================================ // CONSTANTS // ============================================ var CACHE_KEY = 'sparrow_dni_session'; var VISITOR_KEY = 'sparrow_visitor_id'; // ============================================ // STORAGE HELPERS (safe for incognito) // ============================================ function testStorage() { try { var test = '__sparrow_test__'; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch (e) { return false; } } function safeGetItem(key) { if (!state.storageAvailable) return null; try { return localStorage.getItem(key); } catch (e) { state.storageAvailable = false; return null; } } function safeSetItem(key, value) { if (!state.storageAvailable) return; try { localStorage.setItem(key, value); } catch (e) { state.storageAvailable = false; } } function safeRemoveItem(key) { if (!state.storageAvailable) return; try { localStorage.removeItem(key); } catch (e) { state.storageAvailable = false; } } // ============================================ // CSS BLUR INJECTION // ============================================ function injectBlurStyles() { if (state.blurStyleInjected) return; var style = document.createElement('style'); style.id = 'sparrow-dni-styles'; style.textContent = [ '[data-sparrow-phone].sparrow-loading {', ' filter: blur(4px);', ' transition: filter 0.15s ease-out;', ' user-select: none;', '}', '[data-sparrow-phone].sparrow-ready {', ' filter: blur(0);', '}', '[data-sparrow-phone] {', ' display: inline-block;', ' min-width: 120px;', '}' ].join('\n'); var head = document.head || document.getElementsByTagName('head')[0]; if (head) { head.insertBefore(style, head.firstChild); state.blurStyleInjected = true; } } function applyBlur() { if (!config.useBlur) return; var elements = document.querySelectorAll('[' + config.dataAttribute + ']'); for (var i = 0; i < elements.length; i++) { elements[i].classList.add('sparrow-loading'); } } function removeBlur() { var elements = document.querySelectorAll('[' + config.dataAttribute + '].sparrow-loading'); for (var i = 0; i < elements.length; i++) { elements[i].classList.remove('sparrow-loading'); elements[i].classList.add('sparrow-ready'); } } // ============================================ // CACHING (localStorage for instant display) // ============================================ // localStorage caching enables instant number display for returning visitors. // The cached session is displayed immediately, then validated in the background // against the server KV (which remains the source of truth). If the server says // the cached session is invalid, we silently re-swap. function getCachedSession() { var cached = safeGetItem(CACHE_KEY); if (!cached) return null; try { var session = JSON.parse(cached); if (session.number && session.pool_id === config.poolId && new Date(session.expires_at) > new Date()) { return session; } } catch (e) {} safeRemoveItem(CACHE_KEY); return null; } function cacheSession(session) { safeSetItem(CACHE_KEY, JSON.stringify({ number: session.number, formatted_number: session.formatted_number || session.formatted, pool_id: config.poolId, expires_at: session.expires_at, session_id: session.session_id, visitor_id: state.visitorId, cache_token: session.cache_token })); } function clearCache() { safeRemoveItem(CACHE_KEY); } // ============================================ // VISITOR ID // ============================================ function getVisitorId() { var stored = safeGetItem(VISITOR_KEY); if (stored) return stored; var id = 'v_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); safeSetItem(VISITOR_KEY, id); return id; } // ============================================ // URL PARAMETERS // ============================================ function getUrlParams() { var params = {}; var search = window.location.search.substring(1); if (search) { var pairs = search.split('&'); for (var i = 0; i < pairs.length; i++) { var pair = pairs[i].split('='); if (pair.length === 2) { try { params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]); } catch (e) {} } } } return params; } // ============================================ // DEVICE INFO // ============================================ function getDeviceInfo() { var ua = navigator.userAgent; var info = { browser: 'unknown', os: 'unknown', device_type: 'desktop' }; if (ua.indexOf('Chrome') > -1 && ua.indexOf('Edg') === -1) info.browser = 'chrome'; else if (ua.indexOf('Safari') > -1 && ua.indexOf('Chrome') === -1) info.browser = 'safari'; else if (ua.indexOf('Firefox') > -1) info.browser = 'firefox'; else if (ua.indexOf('Edg') > -1) info.browser = 'edge'; if (ua.indexOf('Windows') > -1) info.os = 'windows'; else if (ua.indexOf('Mac') > -1) info.os = 'macos'; else if (ua.indexOf('Linux') > -1) info.os = 'linux'; else if (ua.indexOf('Android') > -1) info.os = 'android'; else if (/iPhone|iPad/.test(ua)) info.os = 'ios'; if (/Mobile|Android|iPhone|iPad/.test(ua)) { info.device_type = /iPad|Tablet/.test(ua) ? 'tablet' : 'mobile'; } return info; } // ============================================ // PHONE FORMATTING // ============================================ function formatPhone(number) { var digits = number.replace(/\D/g, ''); if (digits.length === 11 && digits.charAt(0) === '1') { digits = digits.substring(1); } if (digits.length === 10) { return '(' + digits.substring(0,3) + ') ' + digits.substring(3,6) + '-' + digits.substring(6); } return number; } // ============================================ // API CALLS // ============================================ function buildSessionUrl(cacheToken) { var params = getUrlParams(); var device = getDeviceInfo(); var query = []; query.push('pool_id=' + encodeURIComponent(config.poolId)); query.push('visitor_id=' + encodeURIComponent(state.visitorId)); if (cacheToken) { query.push('cache_token=' + encodeURIComponent(cacheToken)); } if (document.referrer) { query.push('referrer=' + encodeURIComponent(document.referrer)); } query.push('landing_page=' + encodeURIComponent(window.location.href)); var paramCount = 0; for (var key in params) { if (params.hasOwnProperty(key) && paramCount < 50) { if (key.length <= 50 && params[key].length <= 500) { query.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); paramCount++; } } } query.push('device_type=' + encodeURIComponent(device.device_type)); query.push('browser=' + encodeURIComponent(device.browser)); query.push('os=' + encodeURIComponent(device.os)); for (var tagKey in config.tags) { if (config.tags.hasOwnProperty(tagKey)) { query.push('tag_' + tagKey + '=' + encodeURIComponent(config.tags[tagKey])); } } return config.endpoint + '/v2/session?' + query.join('&'); } function fetchSession(cacheToken, callback) { var url = buildSessionUrl(cacheToken); log('Fetching session...'); if (window.fetch) { fetch(url, { method: 'GET', mode: 'cors' }) .then(function(r) { log('Response received, parsing...'); return r.json(); }) .then(function(data) { log('Session data ready'); callback(null, data); }) .catch(function(e) { callback(e); }); } else { var xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status === 200) { try { callback(null, JSON.parse(xhr.responseText)); } catch (e) { callback(new Error('Invalid JSON')); } } else { callback(new Error('HTTP ' + xhr.status)); } } }; xhr.send(); } } function trackEvent(type, name, data, url) { if (!state.session || !state.session.session_id) { state.eventQueue.push({ type: type, name: name, data: data, url: url }); return; } // Skip temp/fallback sessions if (state.session.session_id.indexOf('temp-') === 0 || state.session.session_id.indexOf('fallback-') === 0) { return; } var payload = JSON.stringify({ session_id: state.session.session_id, event_type: type, event_name: name || null, event_data: data || {}, page_url: url || window.location.href }); // Use sendBeacon with text/plain to avoid CORS preflight if (navigator.sendBeacon) { navigator.sendBeacon(config.endpoint + '/event', new Blob([payload], { type: 'text/plain' })); } else { var xhr = new XMLHttpRequest(); xhr.open('POST', config.endpoint + '/event', true); xhr.setRequestHeader('Content-Type', 'text/plain'); xhr.send(payload); } } // ============================================ // PHONE SWAPPING // ============================================ function swapNumbers(element) { if (!state.session || !state.session.number) return; if (state.session.no_swap) return; log('Swapping numbers...'); var swapStart = Date.now(); var dataElements = element.querySelectorAll('[' + config.dataAttribute + ']'); for (var i = 0; i < dataElements.length; i++) { var el = dataElements[i]; if (state.swappedElements.has(el)) continue; var original = el.textContent.trim(); if (!state.originalNumbers.has(el)) { state.originalNumbers.set(el, original); } if (el.tagName === 'A' && el.href && el.href.indexOf('tel:') === 0) { el.href = 'tel:' + state.session.number; } el.textContent = state.session.formatted_number; state.swappedElements.add(el); log('Swapped: ' + original + ' -> ' + state.session.formatted_number); } if (config.autoDetect) { swapTextNodes(element); } removeBlur(); log('Numbers swapped, blur removed - COMPLETE'); } function swapTextNodes(element) { if (!state.session || !state.session.number) return; var nodeCount = 0; var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (nodeCount >= config.maxTextNodes) return NodeFilter.FILTER_REJECT; var parent = node.parentNode; if (!parent) return NodeFilter.FILTER_REJECT; var tag = parent.tagName ? parent.tagName.toLowerCase() : ''; if (config.excludeSelectors.indexOf(tag) > -1) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); var nodes = []; while (walker.nextNode() && nodeCount < config.maxTextNodes) { nodes.push(walker.currentNode); nodeCount++; } for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; config.phonePattern.lastIndex = 0; if (config.phonePattern.test(node.textContent)) { if (!state.originalNumbers.has(node)) { state.originalNumbers.set(node, node.textContent); } config.phonePattern.lastIndex = 0; node.textContent = node.textContent.replace(config.phonePattern, state.session.formatted_number); } } var links = element.querySelectorAll('a[href^="tel:"]'); for (var j = 0; j < links.length; j++) { var link = links[j]; if (state.swappedElements.has(link)) continue; if (!state.originalNumbers.has(link)) { state.originalNumbers.set(link, link.href); } link.href = 'tel:' + state.session.number; state.swappedElements.add(link); } } // ============================================ // MUTATION OBSERVER // ============================================ function setupObserver() { if (!config.observeMutations || !window.MutationObserver) return; var timer = null; var pending = []; state.observer = new MutationObserver(function(mutations) { for (var i = 0; i < mutations.length; i++) { var added = mutations[i].addedNodes; for (var j = 0; j < added.length; j++) { if (added[j].nodeType === Node.ELEMENT_NODE) { pending.push(added[j]); } } } if (timer) clearTimeout(timer); timer = setTimeout(function() { var nodes = pending.slice(); pending = []; for (var k = 0; k < nodes.length; k++) { swapNumbers(nodes[k]); } }, 50); }); state.observer.observe(document.body, { childList: true, subtree: true }); } // ============================================ // CLICK TRACKING // ============================================ function setupClickTracking() { document.addEventListener('click', function(e) { var target = e.target; var isPhoneClick = false; if (target.tagName === 'A' && target.href && target.href.indexOf('tel:') === 0) { isPhoneClick = true; } if (target.hasAttribute && target.hasAttribute(config.dataAttribute)) { isPhoneClick = true; } // Check parent (for nested elements like ) if (!isPhoneClick && target.parentElement) { if (target.parentElement.tagName === 'A' && target.parentElement.href && target.parentElement.href.indexOf('tel:') === 0) { isPhoneClick = true; } } if (isPhoneClick && state.session) { var clickId = 'clk_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 6); var clickData = { click_id: clickId, number: state.session.number, clicked_at: new Date().toISOString(), page_url: window.location.href, page_title: document.title, visitor_id: state.visitorId, session_id: state.session.session_id }; trackEvent('phone_click', null, clickData); // Store last click locally for quick matching safeSetItem('sparrow_last_click', JSON.stringify(clickData)); // Also send dedicated click beacon to update session in KV // This lets the webhook handler match the call to this exact click var clickPayload = JSON.stringify({ pool_id: config.poolId, session_id: state.session.session_id, visitor_id: state.visitorId, click_id: clickId, number: state.session.number, clicked_at: clickData.clicked_at, page_url: clickData.page_url, page_title: clickData.page_title }); if (navigator.sendBeacon) { navigator.sendBeacon(config.endpoint + '/v2/click', new Blob([clickPayload], { type: 'text/plain' })); } } }, true); } // ============================================ // SESSION RELEASE // ============================================ function releaseSession() { log('releaseSession called', { hasSession: !!state.session, sessionId: state.session ? state.session.session_id : null, visitorId: state.visitorId }); if (!state.session || !state.session.session_id) { log('No session to release'); return; } if (state.session.is_fallback) { log('Skipping fallback session release'); return; } if (state.session.session_id.indexOf('fallback-') === 0) { log('Skipping fallback session release'); return; } // Note: We release temp sessions too - server will look up by visitor_id var payload = JSON.stringify({ pool_id: config.poolId, visitor_id: state.visitorId, session_id: state.session.session_id, cache_token: state.cacheToken, number: state.session.number }); log('Sending release beacon', payload); if (navigator.sendBeacon) { var sent = navigator.sendBeacon(config.endpoint + '/v2/session/release', new Blob([payload], { type: 'text/plain' })); log('Beacon sent: ' + sent); } else { log('sendBeacon not available'); } clearCache(); } function setupUnloadHandlers() { window.addEventListener('pagehide', function(e) { log('pagehide event, persisted: ' + e.persisted); // Release on pagehide regardless of persisted state // persisted=true means bfcache, but we still want to release releaseSession(); state.sessionReleased = true; }); document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'hidden') { if (!state.releaseTimer) { state.releaseTimer = setTimeout(function() { if (document.visibilityState === 'hidden') { releaseSession(); state.sessionReleased = true; } state.releaseTimer = null; }, config.releaseGracePeriod); } } else if (document.visibilityState === 'visible') { if (state.releaseTimer) { clearTimeout(state.releaseTimer); state.releaseTimer = null; } // Check if session was released OR has expired var needsRefresh = state.sessionReleased; if (!needsRefresh && state.session && state.session.expires_at) { var expiresAt = new Date(state.session.expires_at); if (expiresAt <= new Date()) { needsRefresh = true; log('Session expired, refreshing...'); } } if (needsRefresh) { refresh(); } } }); window.addEventListener('beforeunload', function() { log('beforeunload event'); releaseSession(); state.sessionReleased = true; }); // Also handle unload for older browsers window.addEventListener('unload', function() { log('unload event'); releaseSession(); state.sessionReleased = true; }); } function refresh() { log('Refreshing session...'); state.sessionReleased = false; state.swappedElements = new Set(); state.startTime = Date.now(); // Reset timer for refresh // Apply blur while fetching new session if (config.useBlur) { applyBlur(); } fetchSession(null, function(err, session) { if (err) { log('Refresh error: ' + err); removeBlur(); return; } handleSession(session); }); } // ============================================ // SESSION HANDLING // ============================================ function handleSession(session, options) { var opts = options || {}; log('handleSession called', session); // Validate response - check for error or missing number if (session.error || !session.number) { var errorMsg = session.error ? session.error.message || session.error.code : 'No number returned'; log('Session error: ' + errorMsg); // Clear any cached bad data clearCache(); // Remove blur so original numbers show removeBlur(); // Mark as initialized but with no session state.initialized = true; state.session = null; if (config.onError) { config.onError(new Error(errorMsg)); } return; } state.session = session; state.cacheToken = session.cache_token; state.initialized = true; // Cache session in localStorage for instant display on next visit cacheSession(session); trackEvent('session_start', null, { is_new: session.is_new }); for (var i = 0; i < state.eventQueue.length; i++) { var e = state.eventQueue[i]; trackEvent(e.type, e.name, e.data, e.url); } state.eventQueue = []; swapNumbers(document.body); setupObserver(); setupClickTracking(); setupUnloadHandlers(); setupHeartbeat(); // If this session came from cache, validate it in the background if (opts.fromCache) { validateCachedSession(session); } if (config.onReady) { config.onReady(session); } } // ============================================ // SESSION HEARTBEAT // Extends the session while the visitor is still on the page. // Without this, a visitor who reads for 15+ minutes before // clicking to call would have an expired session. // ============================================ function setupHeartbeat() { // Send heartbeat every 5 minutes while page is visible var HEARTBEAT_INTERVAL = 5 * 60 * 1000; // 5 minutes var heartbeatTimer = setInterval(function() { if (!state.session || !state.session.session_id) { clearInterval(heartbeatTimer); return; } // Only heartbeat if page is visible if (document.hidden) return; // Skip temp/fallback sessions if (state.session.session_id.indexOf('temp-') === 0 || state.session.session_id.indexOf('fallback-') === 0) { return; } log('Sending session heartbeat'); var payload = JSON.stringify({ pool_id: config.poolId, visitor_id: state.visitorId, session_id: state.session.session_id, cache_token: state.cacheToken }); // Use sendBeacon for non-blocking heartbeat if (navigator.sendBeacon) { navigator.sendBeacon(config.endpoint + '/v2/session/validate', new Blob([payload], { type: 'text/plain' })); } // Update local cache expiry if (state.session.expires_at) { var newExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString(); // extend 15 min state.session.expires_at = newExpiry; cacheSession(state.session); } }, HEARTBEAT_INTERVAL); } function validateCachedSession(session) { if (!session.session_id || !session.cache_token) return; log('Background validation of cached session...'); var payload = JSON.stringify({ pool_id: config.poolId, visitor_id: state.visitorId, cache_token: session.cache_token, attribution: getUrlParams() }); if (window.fetch) { fetch(config.endpoint + '/v2/session/validate', { method: 'POST', body: payload, headers: { 'Content-Type': 'text/plain' }, mode: 'cors' }) .then(function(r) { return r.json(); }) .then(function(result) { if (result && !result.valid) { log('Cached session invalid, refreshing...'); clearCache(); state.swappedElements = new Set(); fetchSession(null, function(err, newSession) { if (!err && newSession && newSession.number) { state.session = newSession; state.cacheToken = newSession.cache_token; cacheSession(newSession); swapNumbers(document.body); } }); } else if (result && result.expires_at) { // Update cached expiry session.expires_at = result.expires_at; cacheSession(session); } }) .catch(function(e) { log('Background validation failed: ' + e); }); } } /** * Sync attribution data for pre-loaded sessions (Edge Inject, JSONP bootstrap). * These sessions are created server-side without the page's URL params. * This sends the full URL params + device info to the validate endpoint * so the session attribution is populated for reporting. */ function syncAttribution(sessionId, cacheToken) { if (!sessionId || !config.poolId) return; log('Syncing attribution for pre-loaded session'); var params = getUrlParams(); var device = getDeviceInfo(); // Build attribution object with all URL params + device info var attribution = {}; for (var key in params) { if (params.hasOwnProperty(key)) { attribution[key] = params[key]; } } attribution.referrer = document.referrer || ''; attribution.landing_page = window.location.href; attribution.device_type = device.device_type; attribution.browser = device.browser; attribution.os = device.os; var payload = JSON.stringify({ pool_id: config.poolId, visitor_id: state.visitorId, cache_token: cacheToken || '', attribution: attribution }); // Non-blocking send if (navigator.sendBeacon) { navigator.sendBeacon(config.endpoint + '/v2/session/validate', new Blob([payload], { type: 'text/plain' })); } else if (window.fetch) { fetch(config.endpoint + '/v2/session/validate', { method: 'POST', body: payload, headers: { 'Content-Type': 'text/plain' }, mode: 'cors' }).catch(function() {}); } } // ============================================ // PUBLIC API // ============================================ var Sparrow = { init: function(poolId, options) { if (state.initialized) { if (config.debug) console.warn('[Sparrow DNI] Already initialized'); return; } if (!poolId) { console.error('[Sparrow DNI] Pool ID required'); return; } config.poolId = poolId; if (options) { for (var key in options) { if (options.hasOwnProperty(key)) { config[key] = options[key]; } } } // Test localStorage availability state.storageAvailable = testStorage(); if (!config.endpoint) { var scripts = document.getElementsByTagName('script'); for (var i = 0; i < scripts.length; i++) { var src = scripts[i].src; if (src && src.indexOf('sparrow-dni') > -1) { var base = src.substring(0, src.lastIndexOf('/')); if (base.indexOf('/v2') > -1) base = base.replace('/v2', ''); if (base.indexOf('/dni') > -1) base = base.replace('/dni', ''); config.endpoint = base + '/dni'; break; } } if (!config.endpoint) { config.endpoint = 'https://sparrow-dni.propelsys.workers.dev/dni'; } } state.visitorId = getVisitorId(); log('Init pool: ' + poolId); log('Endpoint: ' + config.endpoint); // Check if JSONP callback already fired (inline bootstrap or Edge Inject pre-loaded) if (window.__sparrow_cb && window.__sparrow_cb.result) { log('Using pre-loaded session'); var preloaded = window.__sparrow_cb.result; handleSession({ session_id: preloaded.session_id, number: preloaded.number, formatted_number: preloaded.formatted, cache_token: preloaded.cache_token, expires_at: preloaded.expires_at, is_new: !preloaded.cached }); // Send attribution data to server — pre-loaded sessions (Edge Inject, JSONP) // don't capture the page URL params, so we send them now. syncAttribution(preloaded.session_id, preloaded.cache_token); return; } // Inject blur styles immediately if (config.useBlur) { injectBlurStyles(); applyBlur(); } // Check localStorage cache for instant display var cached = getCachedSession(); if (cached && cached.number) { log('Using cached session (localStorage)'); handleSession(cached, { fromCache: true }); return; } // Fetch from server fetchSession(null, function(err, session) { if (err) { log('Fetch error: ' + err); removeBlur(); if (config.onError) config.onError(err); return; } handleSession(session); }); }, setTag: function(key, value) { config.tags[key] = value; }, track: function(name, data) { trackEvent('custom', name, data); }, getNumber: function() { return state.session ? state.session.number : null; }, getFormattedNumber: function() { return state.session ? state.session.formatted_number : null; }, getSessionId: function() { return state.session ? state.session.session_id : null; }, getVisitorId: function() { return state.visitorId; }, getCacheToken: function() { return state.cacheToken; }, isReady: function() { return state.initialized && state.session !== null; }, refresh: function() { if (!state.initialized) return; swapNumbers(document.body); } }; window.Sparrow = Sparrow; // Auto-init immediately (function() { var scripts = document.getElementsByTagName('script'); for (var i = 0; i < scripts.length; i++) { var poolId = scripts[i].getAttribute('data-sparrow-pool'); if (poolId) { Sparrow.init(poolId, { debug: scripts[i].getAttribute('data-sparrow-debug') === 'true', useBlur: scripts[i].getAttribute('data-sparrow-no-blur') !== 'true' }); return; } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', arguments.callee); } })(); })(window, document);