Skip to content

Instantly share code, notes, and snippets.

@joshuafredrickson
Created October 1, 2025 21:28
Show Gist options
  • Select an option

  • Save joshuafredrickson/285a3b3da8218529e296e7052cc49a11 to your computer and use it in GitHub Desktop.

Select an option

Save joshuafredrickson/285a3b3da8218529e296e7052cc49a11 to your computer and use it in GitHub Desktop.

Revisions

  1. joshuafredrickson created this gist Oct 1, 2025.
    363 changes: 363 additions & 0 deletions phone-swap.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,363 @@
    /*
    CONFIGURE HERE
    - TARGET_TEXTS: all text variants of the phone number on your site to replace.
    - TARGET_TEL: digits-only version of the phone number (for tel: links).
    - CAMPAIGN_NUMBERS: map of se_campaign value => replacement number display format (tel is auto-generated).
    - STORAGE_KEY/STORAGE_DAYS: storage settings (uses localStorage if available, cookie fallback).
    */
    (function () {
    var TARGET_TEXTS = [
    "(555) 555-1212",
    ];
    var TARGET_TEL = "5555551212";

    var CAMPAIGN_NUMBERS = {
    // Example mappings — edit to yours (case insensitive)
    // se_campaign=google => number below
    yelp: "555-123-4567",
    google: "555-456-7890",
    };

    var STORAGE_KEY = "se_campaign";
    var STORAGE_DAYS = 30;

    // ---------------- no edits needed below ----------------

    /**
    * Extracts a query parameter value from the current URL
    * @param {string} name - The parameter name to look for
    * @returns {string|null} The parameter value or null if not found
    */
    function getQueryParam(name) {
    var params = window.location.search.substring(1).split("&");
    for (var i = 0; i < params.length; i++) {
    var pair = params[i].split("=");
    if (decodeURIComponent(pair[0]) === name) {
    return typeof pair[1] === "undefined" ? "" : decodeURIComponent(pair[1] || "");
    }
    }
    return null;
    }

    /**
    * Tests if localStorage is available and working
    * @returns {boolean} True if localStorage is supported
    */
    function supportsLocalStorage() {
    try {
    var k = "__test__";
    window.localStorage.setItem(k, "1");
    window.localStorage.removeItem(k);
    return true;
    } catch (e) {
    return false;
    }
    }

    /**
    * Sets a cookie with an expiration date
    * @param {string} name - Cookie name
    * @param {string} value - Cookie value
    * @param {number} days - Number of days until expiration
    */
    function setCookie(name, value, days) {
    var expires = "";
    if (days) {
    var d = new Date();
    d.setTime(d.getTime() + days * 24 * 60 * 60 * 1000);
    expires = "; expires=" + d.toUTCString();
    }
    document.cookie = name + "=" + encodeURIComponent(value) + expires + "; path=/; SameSite=Lax";
    }

    /**
    * Retrieves a cookie value by name
    * @param {string} name - Cookie name to retrieve
    * @returns {string|null} The cookie value or null if not found
    */
    function getCookie(name) {
    var nameEQ = name + "=";
    var ca = document.cookie.split(";");
    for (var i = 0; i < ca.length; i++) {
    var c = ca[i].trim();
    if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length));
    }
    return null;
    }

    /**
    * Saves the phone number configuration to storage (localStorage or cookie fallback)
    * Includes expiry timestamp for automatic cleanup
    * @param {Object} obj - Object with display and tel properties
    */
    function saveNumberConfig(obj) {
    if (supportsLocalStorage()) {
    try {
    var dataWithExpiry = {
    data: obj,
    expiry: new Date().getTime() + (STORAGE_DAYS * 24 * 60 * 60 * 1000)
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(dataWithExpiry));
    } catch (e) {}
    } else {
    setCookie(STORAGE_KEY, JSON.stringify(obj), STORAGE_DAYS);
    }
    }

    /**
    * Loads the phone number configuration from storage
    * Automatically removes expired entries
    * @returns {Object|null} The stored config object or null if not found/expired
    */
    function loadNumberConfig() {
    var raw = null;
    if (supportsLocalStorage()) {
    try {
    raw = localStorage.getItem(STORAGE_KEY);
    if (raw) {
    var parsed = JSON.parse(raw);
    // Check if it has expiry field (new format)
    if (parsed && parsed.expiry) {
    if (new Date().getTime() > parsed.expiry) {
    // Expired, remove it
    localStorage.removeItem(STORAGE_KEY);
    return null;
    }
    return parsed.data;
    }
    // Old format without expiry, return as-is
    return parsed;
    }
    } catch (e) {
    return null;
    }
    }
    // Fallback to cookie
    raw = getCookie(STORAGE_KEY);
    if (!raw) return null;
    try {
    return JSON.parse(raw);
    } catch (e) {
    return null;
    }
    }

    /**
    * Escapes special regex characters in a string for safe use in RegExp
    * @param {string} str - String to escape
    * @returns {string} Escaped string
    */
    function escapeForRegex(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }

    /**
    * Strips all non-digit characters from a string
    * @param {string} str - String to normalize
    * @returns {string} String containing only digits
    */
    function normalizeDigits(str) {
    return String(str || "").replace(/\D+/g, "");
    }

    var targetTextRegex = new RegExp("(" + TARGET_TEXTS.map(escapeForRegex).join("|") + ")", "g");
    var targetTelDigits = normalizeDigits(TARGET_TEL);

    /**
    * Checks if a text node contains any target phone numbers to replace
    * @param {string} text - Text content to check
    * @returns {boolean} True if text contains a target phone number
    */
    function shouldReplaceTextNode(text) {
    targetTextRegex.lastIndex = 0; // Reset regex state for global flag
    return targetTextRegex.test(text);
    }

    /**
    * Replaces target phone numbers in a text node with the replacement number
    * @param {Node} node - Text node to modify
    * @param {string} replacementDisplay - The replacement phone number to display
    */
    function replaceInTextNode(node, replacementDisplay) {
    targetTextRegex.lastIndex = 0; // Reset regex state for global flag
    node.nodeValue = node.nodeValue.replace(targetTextRegex, replacementDisplay);
    }

    /**
    * Finds and replaces tel: links that match the target phone number
    * Updates both href attribute and visible text
    * @param {string} replacementTel - Digits-only replacement number for href
    * @param {string} replacementDisplay - Formatted replacement number for display
    */
    function replaceTelAnchors(replacementTel, replacementDisplay) {
    var anchors = document.querySelectorAll('a[href^="tel:"]');
    for (var i = 0; i < anchors.length; i++) {
    var a = anchors[i];
    var href = a.getAttribute("href") || "";
    var digits = normalizeDigits(href);
    if (digits === targetTelDigits) {
    a.setAttribute("href", "tel:" + replacementTel);
    // If the visible text equals one of the target variants, update it
    var text = (a.textContent || "").trim();
    if (TARGET_TEXTS.indexOf(text) > -1 || normalizeDigits(text) === targetTelDigits) {
    a.textContent = replacementDisplay;
    }
    }
    }
    }

    // Shared filter for TreeWalker to skip script/style/noscript tags
    var SKIP_TAGS = { script: 1, style: 1, noscript: 1 };

    var textNodeFilter = {
    acceptNode: function (node) {
    var p = node.parentNode;
    if (!p || SKIP_TAGS[p.nodeName.toLowerCase()]) {
    return NodeFilter.FILTER_REJECT;
    }
    return shouldReplaceTextNode(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
    }
    };

    /**
    * Performs initial replacement of all phone numbers on the page
    * Replaces both text nodes and tel: links
    * @param {Object} replacement - Object with display and tel properties
    */
    function replaceEverywhere(replacement) {
    if (!replacement || !replacement.display || !replacement.tel) return;

    // Text nodes
    var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, textNodeFilter);
    var current;
    var nodesToUpdate = [];
    while ((current = walker.nextNode())) {
    nodesToUpdate.push(current);
    }
    for (var i = 0; i < nodesToUpdate.length; i++) {
    replaceInTextNode(nodesToUpdate[i], replacement.display);
    }

    // tel: anchors
    replaceTelAnchors(replacement.tel, replacement.display);
    }

    /**
    * Sets up a MutationObserver to watch for dynamically added content
    * Automatically replaces phone numbers in new DOM nodes
    * @param {Object} replacement - Object with display and tel properties
    */
    function setupObserver(replacement) {
    if (!("MutationObserver" in window)) return;
    var observer = new MutationObserver(function (mutations) {
    for (var i = 0; i < mutations.length; i++) {
    var m = mutations[i];
    for (var j = 0; j < m.addedNodes.length; j++) {
    var n = m.addedNodes[j];
    if (n.nodeType === 3) {
    // text node
    if (shouldReplaceTextNode(n.nodeValue)) {
    replaceInTextNode(n, replacement.display);
    }
    } else if (n.nodeType === 1) {
    // element: scan its subtree quickly for text matches and tel anchors
    try {
    // Text nodes under this element
    var walker = document.createTreeWalker(n, NodeFilter.SHOW_TEXT, textNodeFilter);
    var node;
    while ((node = walker.nextNode())) {
    replaceInTextNode(node, replacement.display);
    }
    // tel anchors under this element
    if (n.querySelectorAll) {
    var anchors = n.querySelectorAll('a[href^="tel:"]');
    for (var k = 0; k < anchors.length; k++) {
    var a = anchors[k];
    var href = a.getAttribute("href") || "";
    var digits = normalizeDigits(href);
    if (digits === targetTelDigits) {
    a.setAttribute("href", "tel:" + replacement.tel);
    var t = (a.textContent || "").trim();
    if (TARGET_TEXTS.indexOf(t) > -1 || normalizeDigits(t) === targetTelDigits) {
    a.textContent = replacement.display;
    }
    }
    }
    }
    } catch (e) {}
    }
    }
    }
    });
    observer.observe(document.documentElement || document.body, {
    childList: true,
    subtree: true
    });
    }

    /**
    * Builds a case-insensitive lookup map for campaign numbers (cached)
    * @returns {Object} Lowercase campaign key to display number map
    */
    var campaignLookup = (function() {
    var lookup = {};
    for (var key in CAMPAIGN_NUMBERS) {
    if (CAMPAIGN_NUMBERS.hasOwnProperty(key)) {
    lookup[key.toLowerCase()] = CAMPAIGN_NUMBERS[key];
    }
    }
    return lookup;
    })();

    /**
    * Looks up a campaign number by name (case insensitive)
    * @param {string} campaign - Campaign name to look up
    * @returns {Object|null} Object with display and tel properties, or null if not found
    */
    function getCampaignNumber(campaign) {
    if (!campaign) return null;
    var display = campaignLookup[campaign.toLowerCase()];
    if (!display) return null;
    return {
    display: display,
    tel: normalizeDigits(display)
    };
    }

    /**
    * Main initialization function
    * Checks for se_campaign parameter, loads stored config, and performs replacements
    * Priority: stored config > new campaign parameter > no replacement
    */
    function init() {
    var campaign = getQueryParam("se_campaign");
    var fromStorage = loadNumberConfig();
    var chosen = null;

    // If we already have a stored campaign, use it (don't override)
    if (fromStorage && fromStorage.display && fromStorage.tel) {
    chosen = fromStorage;
    } else if (campaign) {
    // Only set new campaign if nothing is stored
    chosen = getCampaignNumber(campaign);
    if (chosen) {
    saveNumberConfig(chosen);
    }
    }

    if (!chosen) return; // nothing to do

    // Perform initial replacement and observe for future dynamic content
    if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
    replaceEverywhere(chosen);
    setupObserver(chosen);
    });
    } else {
    replaceEverywhere(chosen);
    setupObserver(chosen);
    }
    }

    init();
    })();