/** * Helper functions * @author [Vivek Sudarsan] * @version 0.1.0 */ // Import third party libraries import Cookies from 'js-cookie'; // Import constants import { FORM_NAMES, EXCLUDED_FORM_TYPES, EXCLUDED_FORM_NAMES, documentElementTypes, } from './constants'; import { glob } from '..'; // Type definitions type BrowserTarget = 'es2020' | 'legacy'; /** * Add a third party script to the page and wait * for it to finish loading */ export const loadScript = (scriptBody: any) => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.innerHTML = scriptBody; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); resolve(scriptBody); }); }; /** * Get the users IP address * @returns string */ export const getIpAddress = async () => { const inEu = Intl.DateTimeFormat() .resolvedOptions() .timeZone.startsWith('Europe') ? true : false; let ipAddress = null; // only store the ip address if they're not in the EU if (!inEu) { let response = await fetch('https://api.ipify.org/'); ipAddress = await response.text(); } return ipAddress; }; /** * Get all of the url parameters * @returns object */ export const getUrlParams = () => { return JSON.stringify( Object.fromEntries(new URLSearchParams(location.search)) ); }; /** * Get document referrer if it exists * @returns string */ export const getReferrer = () => { return window.document.referrer; }; /** * Detect url changes and trigger callback */ export const detectUrlChange = (callback: any) => { let url = location.href; document.body.addEventListener( 'click', () => { requestAnimationFrame(() => { if (url === location.href) return; avinaLog('URL changed, reinitializing...'); url = location.href; callback(); }); }, true ); }; /** * Hash a string with a salt and return the hash * as a Base64 encoded string * @param value * @returns */ export const hashWithSalt = async (value: string) => { const salt = glob.workspaceHashSalt; // add the salt to the value (with null check) const valueWithSalt = (value.trim() || '') + salt; // convert the string to a byte array (Uint8Array) using UTF-8 encoding const encoder = new TextEncoder(); const data = encoder.encode(valueWithSalt); // hash the byte array using SHA-256 const hashBuffer = await crypto.subtle.digest('SHA-256', data); // convert the hash to a Base64 encoded string const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashBase64 = btoa(hashArray.map(b => String.fromCharCode(b)).join('')); return hashBase64; }; /** * Determine if the browser supports ES2020 features * @returns 'legacy' | 'es2020' */ export const detectBrowser = (agent: string): BrowserTarget => { var options: [RegExp, RegExp, number][] = [ [/Edg\//, /Edg\/(\d+)/, 80], [/OPR\//, /OPR\/(\d+)/, 67], [/Chrome\//, /Chrome\/(\d+)/, 80], [/Safari\//, /Version\/(\d+)/, 14], [/Firefox\//, /Firefox\/(\d+)/, 74], ]; for (var i = 0; i < options.length; i++) { var option = options[i]; var browserRegExp = option[0]; var versionRegExp = option[1]; var minVersion = option[2]; if (!agent.match(browserRegExp)) { continue; } var versionMatch = agent.match(new RegExp(versionRegExp)); if (versionMatch) { var version = parseInt(versionMatch[1], 10); if (version >= minVersion) { return 'es2020'; } } break; } return 'legacy'; }; export const checkCookie = (cookieName: string) => { let counter = 0; return new Promise(function(resolve, reject) { const interval = setInterval(function() { if (Cookies.get(cookieName) !== null || counter == 15) { clearInterval(interval); resolve(Cookies.get(cookieName)); } counter += 1; }, 1000); }); }; /** * Add mesh visitor id to any form field that has mesh-visitor-id * field name (these are hidden fields typically) * @returns */ export const addMeshVisitorIdToForms = () => { try { const visitorId = glob.visitorId; if (!visitorId) { avinaLog(`[Error] No visitor id when setting form input...`); return; } // get all inputs on page with mesh-visitor-id name // check every second for 5 seconds because some forms // are lazy loaded or take time to render let counter = 0; const intervalId = setInterval(() => { counter++; // check for both mesh-visitor-id and mesh_visitor_id const meshNames = ['mesh-visitor-id', 'mesh_visitor_id']; // loop through the mesh names and check if the input exists meshNames.forEach(name => { const inputs = document.querySelectorAll(`input[name="${name}"]`); // if the input exists, set the value and clear the interval if (inputs.length > 0 || counter >= 5) { clearInterval(intervalId); inputs.forEach((input: any) => { input.value = glob.visitorId; }); } }); }, 1000); } catch (err) { avinaLog(`[Error] ${err}`); } }; /** * Set up event handler for form tracking * across all marketing forms */ export const initFormHandlingOnBody = (callback: any) => { if (glob.sdkAttributes.appMode) return; // if form handling is already set, return if (glob.settings.formHandlingSet) return; // Function to handle input event const handleInput = (event: any) => { try { const input = event.target; // Check if the target is an input element and not excluded if ( input.tagName === 'INPUT' && !EXCLUDED_FORM_TYPES.includes(input.type) ) { // capture the input name, placeholder, and value const { name, placeholder, type, value } = input; // skip inputs with excluded names, even partial matches // if (EXCLUDED_FORM_NAMES.some(term => name.includes(term))) return; // exit if the value is empty if (!value || value == '') return; try { avinaLog(`Mapping field: ${name} ${placeholder}`); const formData = () => { // get matches for both the input name and placeholder let nameMappedData = mapFormData({ [name]: value }); let placeholderMappedData = mapFormData({ [placeholder]: value }); let typeMappedData = mapFormData({ [type]: value }); // we prefer the name match because it's often more accurate // but if no name matches, use the placeholder match if it exists if (nameMappedData.foundMapMatch) return nameMappedData.mappedData; if (placeholderMappedData.foundMapMatch) return placeholderMappedData.mappedData; if (typeMappedData.foundMapMatch) return typeMappedData.mappedData; // otherwise, just return the name match return nameMappedData.mappedData; }; // execute the callback callback(formData()); } catch (err) { avinaLog(`[Error] ${err}`); } } } catch (err) { avinaLog(`[Error] ${err}`); } }; // Debounce the input event handler const [debouncedHandleInput, debounceRef] = debounce(handleInput, 250); // Execute the callback immediately if timeout exists const executeCallbackImmediately = (event: any) => { if (debounceRef.timeout) { // only execute if the timer is still active // meaning the user switched inputs in less than 1000ms clearTimeout(debounceRef.timeout); handleInput(event); } }; try { // Attach the event listener to a parent element that exists in the DOM document.body.addEventListener('input', debouncedHandleInput); // Additional listener for blur event to prevent missed // callback if user switches before timeout document.body.addEventListener('blur', executeCallbackImmediately); // Set the form handling flag glob.settings.formHandlingSet = true; avinaLog('Form handling set...'); } catch (err) { avinaLog(`[Error] Form handling not set... ${err}`); } }; const mapFormData = (data: any) => { // loop through the data and check if it matches for (const [key, value] of Object.entries(data)) { // check if the key matches any of the form names const match = Object.keys(FORM_NAMES).find( term => term === key || FORM_NAMES[term].some((alias: any) => new RegExp(alias, 'i').test(key)) ); // if there's a match, return the mapped data if (match) { // special handling for a name so we split it if (match === 'name' && typeof value === 'string') { const [firstName, lastName] = value.split(' '); // return the split name return { mappedData: { firstName, lastName, }, foundMapMatch: true, }; } // otherwise, return the mapped data as is return { mappedData: { [match]: value, }, foundMapMatch: true, }; } } // no matches found so return the original data return { mappedData: data, foundMapMatch: false, }; }; /** * Debounce a function * @param func * @param wait * @returns */ export const debounce = (func: any, wait: number) => { let timeout: any; const reference: any = { timeout: undefined }; const debouncedFunction = (...args: any) => { const context = this; clearTimeout(timeout); timeout = setTimeout(() => { func.apply(context, args); reference.timeout = null; }, wait); reference.timeout = timeout; }; return [debouncedFunction, reference]; }; /** * Send a message to all embedded frames * @param message */ export const postMessageToAllFrames = (message: any) => { setTimeout(() => { const frames = Array.from(document.getElementsByTagName('iframe')); frames.forEach((frame: any) => { frame.contentWindow.postMessage( { sender: 'mesh', payload: message }, '*' ); }); }, 1000); }; /** * Handle messages from parent to embedded frames * @param event * @returns */ export const handleFrameMessage = (event: any) => { if (glob.sdkAttributes.appMode) return; if (event.data.sender === 'mesh') { console.log('[Mesh] Received message from parent: ', event.data.payload); const { payload } = event.data; Object.keys(payload).forEach(key => { glob[key] = payload[key]; }); } }; /** * Hide any embedded frames that we need to mutate * @returns */ export const hideFramesToMutate = () => { // embedded urls to search for // const embeddedUrls = ['calendly']; if (glob.sdkAttributes.appMode) return; // gets all iframes on the page const frames = Array.from(document.getElementsByTagName('iframe')); let match = null; // loop through all of the embedded frames on // the page and check if they are calendly frames.forEach((frame: any) => { const src = frame.src; // TODO: make this more generalized if (src.includes('calendly')) match = frame; }); return match; }; /** * Get the percentage of the page that has been scrolled * @returns */ export const getScrollPercentage = () => { var scrollTop = window.scrollY || document.documentElement.scrollTop; var docHeight = document.documentElement.scrollHeight; var winHeight = window.innerHeight; var scrollPercent = (scrollTop / (docHeight - winHeight)) * 100; return Math.min(100, parseFloat(scrollPercent.toFixed(2))); }; /** * Write a log message to the console * and add it to the window object * @param message */ export const avinaLog = (message: string) => { // add to window object for debugging const meshLogs = window.meshLogs || []; meshLogs.push(message); window.meshLogs = meshLogs; // log to console console.debug(`[Avina] ${message}`); }; /** * Get the browser name, version, and platform * to include in debug payload * @returns */ export const getBrowserDetails = () => { try { // Initialize an object to store the browser info let browserInfo = { browserName: '', version: '', platform: '', }; // Use navigator.userAgent to detect the browser name and version let userAgent = navigator.userAgent; // Browser name and version detection logic if (userAgent.match(/firefox|fxios/i)) { browserInfo.browserName = 'Firefox'; const match = userAgent.match(/firefox\/([\d.]+)/i); browserInfo.version = match ? match[1] : 'Unknown'; } else if (userAgent.match(/chrome|chromium|crios/i)) { browserInfo.browserName = 'Chrome'; const match = userAgent.match(/chrome\/([\d.]+)/i); browserInfo.version = match ? match[1] : 'Unknown'; } else if (userAgent.match(/safari/i)) { browserInfo.browserName = 'Safari'; const match = userAgent.match(/version\/([\d.]+)/i); browserInfo.version = match ? match[1] : 'Unknown'; } else if (userAgent.match(/msie|trident/i)) { browserInfo.browserName = 'Internet Explorer'; const match = userAgent.match(/(?:msie |rv:)([\d.]+)/i); browserInfo.version = match ? match[1] : 'Unknown'; } else if (userAgent.match(/edg/i)) { browserInfo.browserName = 'Edge'; const match = userAgent.match(/edg\/([\d.]+)/i); browserInfo.version = match ? match[1] : 'Unknown'; } else { browserInfo.browserName = 'Unknown'; browserInfo.version = 'Unknown'; } // Platform detection (Mobile or Desktop) if (/Mobi|Android/i.test(userAgent)) { browserInfo.platform = 'Mobile'; } else { browserInfo.platform = 'Desktop'; } return browserInfo; } catch (e) { // Error handling avinaLog(`Unable to get browser details...`); return { browserName: '', version: '', platform: '', }; } }; export const tryParseJSON = (jsonString: string) => { try { const o = JSON.parse(jsonString); if (o && typeof o === 'object') { return o; } } catch (e) {} return jsonString; }; /** * Inject a script into a document's */ export const injectScript = (scriptContent: string): void => { const scriptElement = document.createElement('script'); scriptElement.type = 'text/javascript'; scriptElement.innerHTML = scriptContent; document.head.appendChild(scriptElement); }; /** * Poll an `async` function for its `result` until `done` or timeout * @param func - an `async` function that returns `{ done: boolean, result?: any }` * @param intervalMs - a polling interval, in milliseconds * @param timeoutMs - the total timeout duration, in milliseconds * @returns the `result` from `func` when `done`, or `null` on timeout */ export const poll = async ( func: () => Promise<{ done: boolean; result?: T }>, intervalMs: number, timeoutMs: number ): Promise<{ success: boolean; result?: T; elapsedMs: number; attempts: number; }> => { const maxAttempts = Math.ceil(timeoutMs / intervalMs); const startTime = Date.now(); for (let i = 0; i < maxAttempts; i++) { // If we have remaining attampts, try executing the given function try { const { done, result } = await func(); // If the function is `done`, notify the caller if (done) { return { success: true, result, elapsedMs: Date.now() - startTime, attempts: i + 1, }; } } catch (e) { avinaLog(`Polling error: ${e}`); } // Wait for the given interval to pass, and then try again await new Promise(r => setTimeout(r, intervalMs)); } // If we've exhausted all attempts, notify the caller return { success: false, elapsedMs: Date.now() - startTime, attempts: maxAttempts, }; };