UNPKG

@douglas.onsite.experimentation/douglas-ab-testing-toolkit

Version:

DOUGLAS A/B Testing Toolkit

1,130 lines (963 loc) 42.6 kB
/** * DOUGLAS A/B Testing Toolkit * * Feedback? * Max Vith <ma.vith@douglas.de> * Kristina Bekher <k.bekher@douglas.de> * */ /** * The logError function logs detailed information about an error to the console. * It includes the error message, stack trace, a prefix to identify the source, and optional context for additional debugging. * This function can also be extended to report errors to external monitoring tools like Grafana. * * @param {Error} error - The error object to log. * @param {string} prefix - A string used to identify the origin of the error (e.g., experiment ID or module). */ const logError = (error, prefix = '') => { window.console.error('>>> OE_ERROR', prefix, error); }; /** * Wraps an async function in try/catch, logs failures via {@link logError}, and avoids unhandled rejections by default. * * @param {Function} fn - The asynchronous function to run. * @param {string} [prefix=''] - Label for error logging (e.g. experiment id). * @param {{ rethrow?: boolean }} [options] - If {@code rethrow: true}, the error is thrown again after logging. * @returns {Function} Async wrapper. On error (without rethrow), resolves to {@code undefined}; otherwise returns the fn result. */ export const asyncSave = (fn, prefix = '', options = {}) => { const { rethrow = false } = options; return async function (...args) { try { return await fn(...args); } catch (error) { logError(error, prefix); if (rethrow) { throw error; } } }; }; /** * The qs function is a utility for selecting a single DOM element using a CSS selector. It simplifies element selection by * optionally allowing searches within a specific parent element. * * @param {string} selector - A CSS selector string used to identify the desired element. * @param {Element} parent - The element or document context within which the selector will be applied. * @returns Node - Returns the first element within the specified parent that matches the given selector. If no matching element * is found, it returns null. */ export const qs = (selector, parent) => { return (parent || document).querySelector(selector); }; /** * The qsa function is a utility for selecting multiple DOM elements using a CSS selector. It allows querying within a specific parent * element or the entire document. * * @param {string} selector - A CSS selector string used to identify the desired elements. * @param {Element | Document} [Optional] parent - The element or document context within which the selector will be applied. Defaults * to document if not provided. * @returns NodeList - Returns a NodeList containing all elements that match the given selector within the specified parent context. If * no elements match, an empty NodeList is returned. */ export const qsa = (selector, parent) => { return (parent || document).querySelectorAll(selector); }; /** * The elem function continuously checks for the presence of DOM elements matching a specified CSS selector or evaluates * a custom condition provided as a function. It executes a callback when the condition is met or times out after 10 seconds. * * @param {string | function} selector - A CSS selector string to query elements from the DOM. Alternatively, a function that * evaluates to true when the desired condition is met. * @param {function} callback - A function called when either: Matching elements are found, and the result is passed as an * argument or the timeout of 10 seconds is reached without finding any elements or satisfying the condition, in which case * false is passed to the callback. */ export const elem = (selector, callback) => { const endTime = Number(new Date()) + 10000; const selectorIsFunc = typeof selector === 'function'; if (!selectorIsFunc) { const tmp = selector; selector = () => qsa(tmp); } const iteration = () => { const result = selector(); if ((selectorIsFunc && result) || (!selectorIsFunc && result.length > 0)) { callback(result); } else if (Number(new Date()) < endTime) { setTimeout(iteration, 33); } else { callback(false); } }; iteration(); }; /** * elemSync is an asynchronous function that waits for DOM elements to be available by utilizing a provided elem function. * It returns a Promise that resolves with the elements matching the specified CSS selector. * * The function assumes that elem is a pre-existing function that accepts a CSS selector and a callback, providing * matched elements asynchronously. * * The code comment about resolving with false after 10 seconds. * * @param {string || function} selector - A CSS selector used to query the desired DOM elements. * @returns Promise - Returns a Promise that resolves to: The elements found by the elem function, based on the provided selector. */ export const elemSync = selector => { return new Promise((resolve) => { elem(selector, (response) => { resolve(response); }); }); }; /** * The addPrefix function is a utility that applies a CSS class to the root <html> element of a document. It is typically used to add * a global modifier or theme-related class for styling purposes. * * @param {string} selector - The name of the CSS class to be added to the <html> element. */ export const addPrefix = selector => addClass(document.documentElement, selector); /** * The removePrefix function removes a specified CSS class from the root <html> element of a document. It is typically used to reset or * revert global styles applied to the document. * * @param {string} selector - The name of the CSS class to be removed from the <html> element. */ export const removePrefix = selector => removeClass(document.documentElement, selector); /** * The hotjar function triggers a Hotjar event for tracking user interactions by sending a unique event key to the * Hotjar API. The function ensures that each event key is only triggered once per page load by maintaining a list of * already transmitted keys. * * @param {string} key - A unique identifier for the Hotjar event to be tracked. */ export const hotjar = key => { window.OE_HOTJAR = window.OE_HOTJAR || []; if (window.OE_HOTJAR.indexOf(key) === -1) { window.OE_HOTJAR.push(key); elem(() => { return window.hj; }, (hj) => { if (!hj) return; window.hj('event', key); }); } }; /** * The pushMetric function logs and optionally tracks unique conversion metrics during A/B testing experiments. It stores metric * IDs in a global array (window.OE_METRICS) and provides placeholders for integrating with third-party analytics tools. * * @param {string} key - The identifier for the metric or goal to be tracked. * @param {boolean} unique - If true, the function ensures the metric is only tracked once by checking if the id is already * present in window.OE_METRICS. Defaults to false. */ export const pushMetric = (id, unique = false) => { window.OE_METRICS = window.OE_METRICS || []; try { const metricTransmitted = window.OE_METRICS.indexOf(id) !== -1; if (unique && metricTransmitted) return; if (!metricTransmitted) window.OE_METRICS.push(id); Kameleoon.API.Goals.processConversion(id); console.log(">>> DOUGLAS Toolkit:", "Kameleoon API Goal", id); } catch (e) { console.log(">>> DOUGLAS Toolkit:", e); } }; /** * The pushData function sets a custom data value using Kameleoon’s JavaScript API. It takes a key and a value as parameters and updates the * associated custom data for the current user session. If the Kameleoon object or its API components are not available, the function gracefully * does nothing. * * @param {string} key - The name of the custom data to set. * @param {string | number} value - The value to associate with the custom data key. * @param {boolean} overwrite - The optional argument. When true, the new value overwrites existing custom data. When false or omitted: Lists/Count Lists append values (all values appear in reports); Single types overwrite (only the latest value appears); Page-scoped Custom Data (all types) append values. */ export const pushData = (key, value, overwrite = false) => { Kameleoon?.API?.Data?.setCustomData(key, value, overwrite); }; /** * The getWidth function calculates and returns the maximum width of the current document, accounting for different properties that * reflect the document's width under various scenarios. * * @returns number - The maximum width of the document in pixels. */ export const getWidth = () => { return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.documentElement.clientWidth); }; /** * The isMobile function detects if the current user is accessing the website from a mobile device by examining the user agent string * of the browser. * * @returns boolean - true if the user agent string contains "android" or "mobile", indicating a mobile device, false otherwise. */ export const isMobile = () => { const userAgent = navigator.userAgent.toLowerCase(); return userAgent.indexOf('android') !== -1 || userAgent.indexOf('mobile') !== -1; }; /** * The getCookie function retrieves the value of a specified cookie from the browser's document. * * @description https://www.w3schools.com/js/js_cookies.asp * @param {string} cookieName - The name of the cookie whose value you want to retrieve. * @returns string - The value of the specified cookie. Returns an empty string ("") if the cookie does not exist. */ export const getCookie = cname => { const name = cname + "="; const decodedCookie = decodeURIComponent(document.cookie); const ca = decodedCookie.split(';'); for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) == ' ') { c = c.substring(1); } if (c.indexOf(name) == 0) { return c.substring(name.length, c.length); } } return ""; }; /** * The setCookie function creates or updates a cookie with a specified name, value, and expiration date. It sets the cookie to be * accessible across the entire website by using a path of /. * * @param {string} cookieName - The name of the cookie to be set. * @param {string} cookieValue - The value to be assigned to the cookie. * @param {number} expiryDays - The number of days until the cookie expires. */ export const setCookie = (cname, cvalue, exdays) => { const d = new Date(); d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); let expires = "expires=" + d.toUTCString(); document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; console.log(">>> DOUGLAS Toolkit:", "Cookie created", cname, cvalue, expires); }; /** * The scrollTo function smoothly scrolls the page to a specified vertical position (pixel) over a given duration (duration). It * uses an easing function (easeInOutQuad) to create a smooth scrolling animation. * * @param {number} pixel - The target vertical scroll position (in pixels) to scroll to. This value can be any integer representing * the desired scroll position from the top of the page. * @param {number} duration - The duration (in milliseconds) over which the scroll animation should take place. This determines how * quickly the page will scroll to the specified position. */ export const scrollTo = (pixel, duration) => { const easeInOutQuad = (t, b, c, d) => { t /= d / 2; if (t < 1) { return (c / 2) * t * t + b; } t--; return (-c / 2) * (t * (t - 2) - 1) + b; }; const start = window.document.documentElement.scrollTop + window.document.body.scrollTop; const change = pixel - start; const increment = 20; let currentTime = 0; const animateScroll = function () { currentTime += increment; const val = easeInOutQuad(currentTime, start, change, duration); window.document.documentElement.scrollTop = val; window.document.body.scrollTop = val; if (currentTime < duration) { setTimeout(animateScroll, increment); } }; animateScroll(); }; /** * Ensures {@link window.OE_TOOLKIT} exists and {@link window.OE_TOOLKIT.exec} is a command queue array. * @returns {Object} window.OE_TOOLKIT */ const ensureToolkit = () => { if (!window.OE_TOOLKIT || typeof window.OE_TOOLKIT !== 'object') { window.OE_TOOLKIT = {}; } if (!Array.isArray(window.OE_TOOLKIT.exec)) { window.OE_TOOLKIT.exec = []; } return window.OE_TOOLKIT; }; /** * Runs every queued exec whose handler is already registered; removes those items from the queue. * Entries whose handler is not yet available stay queued (FIFO order preserved among remaining). */ const flushPendingExecs = () => { const tk = ensureToolkit(); const remaining = []; for (const item of tk.exec) { if (typeof tk[item.name] === 'function') { tk[item.name](item.params); console.log(">>> DOUGLAS Toolkit:", "Executed the function", item.name, item.params); } else { remaining.push(item); } } tk.exec = remaining; }; /** * Drops queued entries for a given name (e.g. after a direct exec so nothing stale remains). * @param {Object} tk * @param {string} name */ const removeQueuedByName = (tk, name) => { tk.exec = tk.exec.filter((item) => item.name !== name); }; /** * Registers a function on the global toolkit (typically from Global JS). After registration, any queued * {@link exec} calls whose handler now exists are flushed automatically. * * @param {string} name - Key under which {@code func} is stored on {@link window.OE_TOOLKIT}. * @param {function} func - Handler invoked later via {@link exec}. */ export const share = (name, func) => { const tk = ensureToolkit(); if (typeof func === 'function') { tk[name] = func; } console.log(">>> DOUGLAS Toolkit:", "Share function added", name); flushPendingExecs(); return tk; }; /** * Invokes a shared handler by name, or queues a call until {@link share} registers it. * Call {@code exec()} with no name to flush all runnable queued calls (same flush as after each {@link share}). * * @param {string} [name] - Handler name. If omitted, runs every queued item whose handler already exists. * @param {*} [params] - Argument passed to the handler. */ export const exec = (name, params) => { const tk = ensureToolkit(); if (!name) { flushPendingExecs(); return; } if (typeof tk[name] === 'function') { tk[name](params); removeQueuedByName(tk, name); return; } const existingIndex = tk.exec.findIndex((item) => item.name === name); if (existingIndex !== -1) { tk.exec[existingIndex].params = params; } else { tk.exec.push({ name, params }); } }; /** * addTask * * Add a task to the DOUGLAS Toolkit Observer * * @param {string} name The unique name of the task. This name will be used to identify the task in the toolkit observer. * @param {function} init The initialization callback. This function will be invoked when the task is triggered by the * mutation observer. * @param {function | null} target The function that returns a boolean indicating whether the `init` function should be * executed. If `null`, the `init` function will always be triggered. * @param {function | null} remove The function that is invoked when the `target` evaluates to `false`. This function is * used to remove or clean up the task if necessary. If `null`, no removal will occur. */ export const addTask = (name, init, target, remove) => { const toolkitObserver = window.OE_TOOLKIT?.observ; if (!toolkitObserver) { console.log(">>> DOUGLAS Toolkit:", "No observer active"); return; } // log only if (window.OE_TOOLKIT.observ.tasks[name]) console.log(">>> DOUGLAS Toolkit:", "Task already exist"); toolkitObserver.add(name, asyncSave(init, name), target, remove); console.log(">>> DOUGLAS Toolkit:", "Task added", name); }; /** * removeTask * * Remove a task to the DOUGLAS Toolkit Observer * * @param {string} name * @returns */ export const removeTask = (name) => { const toolkitObserver = window.OE_TOOLKIT?.observ; if (!toolkitObserver) { console.log(">>> DOUGLAS Toolkit:", "No observer active"); return; } // log only if (!window.OE_TOOLKIT.observ.tasks[name]) console.log(">>> DOUGLAS Toolkit:", "Task does not exist"); toolkitObserver.remove(name); console.log(">>> DOUGLAS Toolkit:", "Task removed", name); }; /** * updateConfig * * Update the config for DOUGLAS Toolkit Observer * * @param {*} config * @returns */ export const updateConfig = config => { const toolkitObserver = window.OE_TOOLKIT?.observ; if (!toolkitObserver) { console.log(">>> DOUGLAS Toolkit:", "No observer active"); return; } toolkitObserver.update(config); console.log(">>> DOUGLAS Toolkit:", "Observer was updated", config); }; /** * The pushHistory function updates the browser's history stack by adding a new entry with a modified URL path that includes * a hash fragment (#step). This function is useful for tracking navigation steps or states within a single-page application * (SPA) without reloading the page. * * @param {string} step - A string representing the step or identifier to be appended as a hash fragment to the current URL. * If step is falsy (e.g., null, undefined, or an empty string), the function exits without making changes. */ export const pushHistory = step => { if (!step) return; const path = `${location.pathname}#${step}`; window.history.pushState({ urlPath: path }, "", path); }; /** * The setPreload function preloads a list of image assets by creating new Image objects and setting their src attributes to the * provided image URLs. This technique ensures that images are downloaded and cached by the browser before they are needed, * improving the loading speed and user experience when these images are displayed. * * @param {array} images - An array of image URLs to be preloaded. */ export const setPreload = images => { if (!images?.forEach) return; images.forEach(image => { new Image().src = image; }); }; /** * The setStore function saves or updates an object in client-side storage (localStorage or sessionStorage). It merges the * new key-value pair with existing data stored under the specified name key, ensuring structured data persistence across * user sessions. * * @param {string} name - The name of the storage key used to save or retrieve the data. * @param {string} key - The property name in the object to store or update. * @param {any} value - The value associated with the specified key. * @param {string} storage - localStorage (default) | sessionStorage */ export const setStore = (name, key, value, storage = 'localStorage') => { const data = getStore(name, null, storage); let object = {}; if (data !== null) { object = { ...data, [key]: value }; } else { object = { [key]: value }; } window[storage].setItem(name, JSON.stringify(object)); }; /** * The getStore function retrieves data from a specified storage (localStorage or sessionStorage) and optionally returns a specific * property of the stored data object. The function handles JSON parsing and gracefully manages errors if the stored data is not in * a valid JSON format. * * @param {string} name - The key name under which the data is stored. * @param {string | null} key - The property to retrieve from the stored object. If omitted or null, the entire object is returned. * @param {string} storage - The storage type from which to retrieve the data. Options: "localStorage" (default) or "sessionStorage". * @returns any */ export const getStore = (name, key, storage = 'localStorage') => { const data = window[storage].getItem(name); if (data === null) return null; try { const json = JSON.parse(data); if (!key) return json; if (typeof json !== 'object' || json === null) return null; return Object.prototype.hasOwnProperty.call(json, key) ? json[key] : null; } catch (err) { return null; } }; /** * * setClickEvent * * @param {string || Element} selector css selector, node or node list * @param {string} prefix prefix of your experiment * @param {string || function} callback a key or a callback function * @param {string} [eventType="click"] - The event type to listen for (e.g., "click", "mouseover"). Defaults to "click". */ export const setClickEvent = (selector, prefix, callback, eventType = "click") => { const addClickListeners = (elements) => { elements.forEach(element => { if (!element || element.dataset[prefix]) return; element.dataset[prefix] = true; element.addEventListener(eventType, event => { asyncSave(typeof callback === 'function' ? () => callback(event) : () => pushMetric(callback), prefix)(); }); }); }; if (typeof selector === 'string') { elem(selector, elements => { if (!elements) return; addClickListeners(elements) }); } else { if (selector instanceof NodeList === false) selector = [selector]; addClickListeners(selector) } }; /** * * setAccessabilityEvents * * @param {string || Element} selector css selector, node or node list * @param {string} prefix prefix of your experiment * @param {function} callback a callback function */ export const setAccessabilityEvents = (selector, prefix, callback) => { const a11yPrefix = prefix + 'a11y'; const a11yCallback = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // prevent scroll for Space key asyncSave(() => callback(e), prefix)(); } } setClickEvent(selector, a11yPrefix, a11yCallback, 'keydown'); }; /** * The addClass function adds a specified CSS class to a given HTML element if the class is not already present. It ensures that the * operation only proceeds when the element and class name are valid. * * @param {HTMLElement} element - The target HTML element to which the class should be added. * @param {string} selector - The CSS class name to add to the element. * @returns {boolean} */ export const addClass = (el, selector) => { if (!el?.classList || !selector) return false; if (el.classList.contains(selector)) return false; el.classList.add(selector); return true; }; /** * The removeClass function removes a specified CSS class from an HTML element if the class is present. It ensures that the operation * only proceeds when valid input is provided. * * @param {HTMLElement} elem - The target HTML element from which the class should be removed. * @param {string} selector - The CSS class name to be removed from the element. * @returns {boolean} */ export const removeClass = (el, selector) => { if (!el?.classList || !selector) return false; if (!el.classList.contains(selector)) return false; el.classList.remove(selector); return true; }; /** * The hasClass function checks whether a specified CSS class is present on a given HTML element. It ensures that the input parameters * are valid before performing the check. * * @param {node} elem - The target HTML element to be checked for the specified class. * @param {string} selector - The CSS class name to check for. * @returns {boolean} - Returns true if the element has the class, otherwise false. */ export const hasClass = (el, selector) => { return el && el.classList && selector && el.classList.contains(selector); }; /** * The toggleClass function toggles a specified CSS class on a given HTML element. * It ensures that the input parameters are valid before performing the toggle. * * @param {HTMLElement} el - The target HTML element to toggle the class on. * @param {string} selector - The CSS class name to toggle. * @returns {boolean} - Returns true if the class is now present, false if removed or invalid input. */ export const toggleClass = (el, selector) => { return el && el.classList && selector && el.classList.toggle(selector); }; /** * The setMutationObserver function sets up a MutationObserver on a DOM element matching the given selector. The observer watches * for mutations (changes) to the element, and when such changes occur, it triggers the provided callback function. This function * returns a promise, resolving once the observer is successfully set, or rejecting if any errors occur. * * @param {string} selector - The CSS selector used to find the target DOM element to observe. * @param {string} attribute - A unique identifier used as a data attribute (data-[prefix]) to ensure that the observer is not * added multiple times to the same element. * @param {function} callback - The callback function to be executed when mutations are detected on the target element. * @param {object} [options={ attributes: true, childList: true, subtree: true }] - An optional object specifying which mutations * should be observed. * @returns {Promise<void>} - A promise that resolves when the MutationObserver is set up. */ export const setMutationObserver = (selector, prefix, callback, options = { attributes: true, childList: true, subtree: true } ) => { return new Promise((resolve, reject) => { elemSync(selector) .then((element) => { try { if (!element || element === false) { resolve(); return; } const target = element[0]; if (!target) { resolve(); return; } if (target.dataset[prefix]) { resolve(); return; } target.dataset[prefix] = true; new MutationObserver(asyncSave(callback, prefix)).observe(target, options); resolve(); } catch (error) { logError(error, prefix); reject(error); } }) .catch((error) => { logError(error, prefix); reject(error); }); }); }; /** * Returns whether {@code el} intersects the viewport (partially or fully). * * @param {HTMLElement} el - Element to test. * @returns {boolean} */ export const isElementInViewport = el => { if (!el || typeof el.getBoundingClientRect !== 'function') return false; const rect = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; const vw = window.innerWidth || document.documentElement.clientWidth; return rect.bottom > 0 && rect.right > 0 && rect.top < vh && rect.left < vw; }; /** * The inViewport function is used to monitor an HTML element's visibility in the viewport as the user scrolls. When the element enters * or exits the viewport, the provided callback function is invoked. This can be useful for triggering certain actions when an * element becomes visible, such as lazy loading content or triggering animations. * * @param {string | HTMLElement} selector - If a string is provided, it should be a CSS selector that will be used to find the element * within the document. If an HTMLElement is provided, the function directly uses it to check for visibility. * @param {string} prefix - A unique string used as a prefix to avoid duplicate event listeners. It is also used to mark elements with * a custom data attribute to track if the element has already been processed. * @param {function} callback - A function that will be invoked whenever the visibility status of the element changes (when it enters * or exits the viewport). It is passed the current visibility status (true if in the viewport, false if not). */ export const inViewport = (selector, prefix, callback) => { let status = false; const safeCallback = asyncSave(callback, prefix); const boundAttr = `data-oe-inviewport-${prefix}`; const initScrollEvent = elem => { if (!elem || elem.getAttribute(boundAttr)) return; elem.setAttribute(boundAttr, 'true'); window.addEventListener('scroll', () => { if (!isElementInViewport(elem) && status) { status = false; safeCallback(status); console.log(">>> DOUGLAS Toolkit:", "Element is not in your viewport!"); } else if (isElementInViewport(elem) && !status) { status = true; safeCallback(status); console.log(">>> DOUGLAS Toolkit:", "Element is in your viewport!"); } }); }; if (typeof selector === "string") { elem(selector, elem => { if (!elem) return; initScrollEvent(elem[0]); }); } else { const elem = selector; initScrollEvent(elem); } }; /** * The getPage function determines the current page type based on the URL pathname. It optionally checks if the user is * in the checkout process and categorizes the page accordingly. The function supports various page types, including * home, product, search, brand, login, cart, and confirmation pages. * * @param {boolean} inCheckout - If set to true, the function checks if the current page is part of the checkout process * and returns checkout-related page types. * @returns {undefined|string} - Returns a string indicating the page type, such as "home", "pdp", "checkout", "cart", etc. * Returns undefined if no matching page type is found. */ export const getPage = (inCheckout = false) => { const pathname = location.pathname; if (inCheckout) { if (pathname.indexOf('/checkout/') === -1) return 'no-checkout' if (pathname.indexOf('/checkout/') !== -1) return 'checkout'; } let currentPage; if (new RegExp('^\\/(de|pl|it|fr|nl|be|at|ch|es)$').test(pathname)) { currentPage = 'home'; } else if (pathname.indexOf('/c/') !== -1) { currentPage = 'pop'; } else if (pathname.indexOf('/p/') !== -1) { currentPage = 'pdp'; } else if (pathname.indexOf('/b/') !== -1) { currentPage = 'brand'; } else if (pathname.indexOf('/search') !== -1) { currentPage = 'search'; } else if (pathname.indexOf('/login') !== -1) { currentPage = 'login'; } else if (pathname.indexOf('/cart') !== -1) { currentPage = 'cart'; } else if (pathname.indexOf('/checkout/customer') !== -1) { currentPage = 'customer'; } else if (pathname.indexOf('/checkout/payment') !== -1) { currentPage = 'payment'; } else if (pathname.indexOf('/checkout/overview') !== -1) { currentPage = 'overview'; } else if (pathname.indexOf('/checkout/thankyou') !== -1) { currentPage = 'confirm'; } return currentPage; }; /** * The pageChange function monitors changes in the browser's URL without requiring a full page reload. It listens for changes * triggered by pushState, replaceState, and popstate events, ensuring that a callback function is executed whenever the * URL changes. This is particularly useful in Single Page Applications (SPAs) where navigation happens dynamically. * * @param {string} PREFIX - A unique prefix to prevent multiple initializations of the same function. * @param {Function} callback - A function that gets executed whenever the page's URL changes. * @returns */ export const pageChange = (PREFIX, callback) => { const windowPrefix = PREFIX + 'locationchange'; // check that we only initialize the event once if (window[windowPrefix]) return; window[windowPrefix] = true; window.addEventListener('locationchange', asyncSave(callback, PREFIX)); const oldPushState = history.pushState; history.pushState = function pushState() { const ret = oldPushState.apply(this, arguments); window.dispatchEvent(new Event('pushstate')); window.dispatchEvent(new Event('locationchange')); return ret; }; const oldReplaceState = history.replaceState; history.replaceState = function replaceState() { const ret = oldReplaceState.apply(this, arguments); window.dispatchEvent(new Event('replacestate')); window.dispatchEvent(new Event('locationchange')); return ret; }; window.addEventListener('popstate', () => { window.dispatchEvent(new Event('locationchange')); }); }; /** * The goTo function is a utility designed to navigate to a specified URL using the System.history.history.push method, if available. * It ensures that navigation attempts do not result in errors if System or its nested properties are undefined. * * @param {string} url - The URL to navigate to. */ export const goTo = url => { System?.history?.history?.push(url); }; /** * The getState function asynchronously retrieves the application state from a global store object located in * window.System. The function returns a promise that resolves to the state if it is successfully retrieved or rejects * in case of an error. * * @returns {Promise<undefined|object>} - Resolves to the application state retrieved from window.System.store.getState(). * If the store is not available, the promise rejects with an error. */ export const getState = () => { return new Promise((resolve, reject) => { elemSync(() => { const store = window.System?.store; if (typeof store !== "undefined") { return store.getState(); } }) .then((state) => { resolve(state); }) .catch((error) => { logError(error); reject(error); }); }); }; /** * The getProdId function extracts and returns a product ID from the current URL. It dynamically determines whether the * product ID is found in the search query string or the pathname, depending on the presence of a "variant" parameter. * * @returns {undefined|string} - The product ID, if found. */ export const getProdId = () => { const { pathname, search } = window.location; if (!pathname.includes('/p/')) return undefined; if (search.includes('variant')) { const params = new URLSearchParams(search); const variant = params.get('variant'); const match = variant?.match(/[mM]*\d+/); return match ? match[0] : undefined; } const segment = pathname.split('/p/')[1]; if (!segment) return undefined; const match = segment.split('/')[0].match(/[mM]*\d+/); return match ? match[0] : undefined; }; /** * The getProduct function asynchronously retrieves product information by dispatching a request * to the global System store using the product ID derived from the current page's URL or from a provided ID. * It returns a promise that resolves with the product data or rejects if the product is not found * or if an error occurs during the data retrieval process. * * The function uses caching to improve performance. If a product is already in the cache, it returns * the cached version instead of making a new API request. * * @param {string} [productId] - Optional. The product ID to fetch. If omitted, the function will use getProdId() to extract the ID from the current page URL. * @returns {Promise<object|undefined>} - Resolves with the product data retrieved from the API or cache. If the product is not found or an error occurs, the promise rejects. */ export const getProduct = (productId = '') => { return new Promise((resolve, reject) => { const prodId = productId && productId || getProdId(); if (!prodId) { const error = new Error("getProduct, product id not found"); logError(error); reject(error); return; } // Initialize cache if it doesn't exist const tk = ensureToolkit(); tk.CACHE = tk.CACHE || {}; // Check if product is in cache if (tk.CACHE[prodId]) { // TODO: test and remove log console.log(">>> DOUGLAS Toolkit:", "Product retrieved from cache", prodId); resolve(tk.CACHE[prodId]); return; } const store = window.System?.store; if (!store?.dispatch) { const error = new Error('getProduct, System.store.dispatch is not available'); logError(error); reject(error); return; } // If not in cache, make the API request store.dispatch({ type: 'GET_PRODUCT', payload: {}, url: `v2/products/${prodId}`, method: 'GET', target: 'api' }).then(response => { const product = response?.payload; if (typeof product !== "undefined" && product !== null) { // Store in cache tk.CACHE[prodId] = product; // TODO: test and remove log console.log(">>> DOUGLAS Toolkit:", "Product cached", prodId); resolve(product); return; } const error = new Error('getProduct, product not found in response'); logError(error, prodId); reject(error); }).catch((error) => { logError(error); reject(error); }); }); }; /** * The getProducts function retrieves product data from the store's cached search results. If the variations parameter is set to true, * it will fetch detailed product information for each product in the list by calling getProduct for each product code, and return an array * of detailed product objects. Otherwise, it returns the cached product list from the store. * * @param {boolean} [variations=false] - If true, fetches detailed product data for each product in the list. Defaults to false. * @returns {Promise<any[]>} - Resolves with an array of product objects. If variations is true, each object is a detailed product; otherwise, each object is from the cached search result. */ export const getProducts = (variations = false) => { return new Promise((resolve, reject) => { getState() .then((store) => { if (!store) { const error = new Error('getProducts, store is not available'); logError(error) reject(error); return; } const products = store?.search?.result?.products; if (products === undefined || products === null) { const error = new Error('getProducts, no products in store.search.result'); logError(error); reject(error); return; } if (variations) { Promise.all(products.map(product => getProduct(product.code) )).then(productsWithVariations => { resolve(productsWithVariations); }).catch(error => { logError(error); reject(error); }); } else { resolve(products); } }) .catch((error) => { logError(error); reject(error); }); }); }; /** * loadLibrary * * Dynamically loads an external JavaScript library from a given URL and ensures it is only loaded once. * If the script is already present in the document, it resolves immediately with the existing script element. * * @param {string} src - The URL of the CDN-hosted library to load. * @param {string} prefix - A unique attribute name to prevent multiple initializations of the same script. * @param {boolean} async - Define to load the script async or sync * @returns {Promise<HTMLScriptElement>} - Resolves with the script element once loaded, or rejects if the loading fails. */ export const loadLibrary = (src, prefix, async = true) => { return new Promise((resolve, reject) => { prefix = String(prefix).toLowerCase(); const dataAttr = `data-${prefix}`; const existing = qs(`script[${dataAttr}="true"]`); if (existing) { resolve(existing); return; } const script = document.createElement('script'); script.setAttribute(dataAttr, 'true'); script.src = src; script.async = async; script.onload = () => resolve(script); script.onerror = () => { const error = new Error(`loadLibrary, failed to load script: ${src}`); logError(error); reject(error); }; document.head.appendChild(script); }); }; /** * The addStyleToBody function injects a CSS string into the document head, using a unique prefix as a data attribute to prevent duplicate style insertions. * If a style element with the given prefix already exists, it does nothing. Otherwise, it creates and appends a new style element. * The purpose of this function is to prevent flickering when page url changes. * @param {string} css - The CSS string to be injected. * @param {string} prefix - The unique prefix used as a data attribute to identify the style element. */ export const addStyleToBody = (css, prefix) => { if (qs(`style[data-${prefix}="true"]`)) return; const style = document.createElement('style'); style.setAttribute(`data-${prefix}`, 'true'); style.appendChild(document.createTextNode(css)); document.head.appendChild(style); };