This is an automated email from the ASF dual-hosted git repository. jkevan pushed a commit to branch UNOMI-610-new-tracker in repository https://gitbox.apache.org/repos/asf/unomi-tracker.git
commit 22d125111f84f09e7d072c38e4cc3deabd45f895 Author: Kevan <[email protected]> AuthorDate: Mon Sep 5 18:42:59 2022 +0200 UNOMI-610: base tracker first draft --- package.json | 3 +- src/index.js | 6 +- src/tracker/tracker.js | 1109 ++++++++++++++++++++++++++++++++++++++++++++++++ test/spec.js | 6 +- yarn.lock | 5 + 5 files changed, 1124 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 46c352a..dbb8c6b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "rollup": "^2.79.0" }, "dependencies": { - "@babel/runtime": "^7.18.9" + "@babel/runtime": "^7.18.9", + "es6-crawler-detect": "^3.3.0" }, "license": "Apache-2.0" } diff --git a/src/index.js b/src/index.js index 5a7f0b3..6bbd7cf 100644 --- a/src/index.js +++ b/src/index.js @@ -15,4 +15,8 @@ * limitations under the License. */ -export const hello = () => "Hello world!"; \ No newline at end of file +import {newTracker} from "./tracker/tracker"; + +export const useTracker = () => { + return newTracker(); +}; \ No newline at end of file diff --git a/src/tracker/tracker.js b/src/tracker/tracker.js new file mode 100644 index 0000000..62a148e --- /dev/null +++ b/src/tracker/tracker.js @@ -0,0 +1,1109 @@ +import { Crawler } from 'es6-crawler-detect'; + +export const newTracker = () => { + const wem = { + enableWem: () => { + wem._enableWem(true); + }, + + disableWem: () => { + wem._enableWem(false); + }, + /** + * This function initialize the context in the page it is called internally and should not be called twice in the same page + * + * @param {object} digitalData config of the tracker + */ + init: function (digitalData) { + // added for external tracker + // store digitalData in tracker instance instead of window. + wem.digitalData = digitalData; + // new conf: + wem.trackerProfileIdCookieName = wem.digitalData.wemInitConfig.trackerProfileIdCookieName ? wem.digitalData.wemInitConfig.trackerProfileIdCookieName : "wem-profile-id"; + wem.trackerSessionIdCookieName = wem.digitalData.wemInitConfig.trackerSessionIdCookieName ? wem.digitalData.wemInitConfig.trackerSessionIdCookieName : "wem-session-id"; + wem.activateWem = wem.digitalData.wemInitConfig.activateWem; + + const { contextServerUrl, isPreview, timeoutInMilliseconds, contextServerCookieName } = wem.digitalData.wemInitConfig; + wem.contextServerCookieName = contextServerCookieName; + wem.contextServerUrl = contextServerUrl; + wem.timeoutInMilliseconds = timeoutInMilliseconds; + wem.formNamesToWatch = []; + wem.eventsPrevented = []; + wem.sessionID = wem.getCookie(wem.trackerSessionIdCookieName); + wem.fallback = false; + if (wem.sessionID === null) { + console.warn('[WEM] sessionID is null !'); + } else if (!wem.sessionID || wem.sessionID === '') { + console.warn('[WEM] empty sessionID, setting to null !'); + wem.sessionID = null; + } + + if (isPreview) { + // do not execute fallback for preview! + return; + } + + let cookieDisabled = !navigator.cookieEnabled; + let noSessionID = !wem.sessionID || wem.sessionID === ''; + let crawlerDetected = navigator.userAgent; + if (crawlerDetected) { + const browserDetector = new Crawler(); + crawlerDetected = browserDetector.isCrawler(navigator.userAgent); + } + if (cookieDisabled || noSessionID || crawlerDetected) { + document.addEventListener('DOMContentLoaded', function () { + wem._executeFallback('navigator cookie disabled: ' + cookieDisabled + ', no sessionID: ' + noSessionID + ', web crawler detected: ' + crawlerDetected); + }); + return; + } + + wem._registerCallback(function () { + if (wem.cxs.profileId) { + wem.setCookie(wem.trackerProfileIdCookieName, wem.cxs.profileId); + } + if (!wem.cxs.profileId) { + wem.removeCookie(wem.trackerProfileIdCookieName); + } + // process tracked events + var videoNamesToWatch = []; + var clickToWatch = []; + + if (wem.cxs.trackedConditions && wem.cxs.trackedConditions.length > 0) { + for (var i = 0; i < wem.cxs.trackedConditions.length; i++) { + switch (wem.cxs.trackedConditions[i].type) { + case 'formEventCondition': + if (wem.cxs.trackedConditions[i].parameterValues && wem.cxs.trackedConditions[i].parameterValues.formId) { + wem.formNamesToWatch.push(wem.cxs.trackedConditions[i].parameterValues.formId); + } + break; + case 'videoViewEventCondition': + if (wem.cxs.trackedConditions[i].parameterValues && wem.cxs.trackedConditions[i].parameterValues.videoId) { + videoNamesToWatch.push(wem.cxs.trackedConditions[i].parameterValues.videoId); + } + break; + case 'clickOnLinkEventCondition': + if (wem.cxs.trackedConditions[i].parameterValues && wem.cxs.trackedConditions[i].parameterValues.itemId) { + clickToWatch.push(wem.cxs.trackedConditions[i].parameterValues.itemId); + } + break; + } + } + } + + var forms = document.querySelectorAll('form'); + for (var formIndex = 0; formIndex < forms.length; formIndex++) { + var form = forms.item(formIndex); + var formName = form.getAttribute('name') ? form.getAttribute('name') : form.getAttribute('id'); + // test attribute data-form-id to not add a listener on FF form + if (formName && wem.formNamesToWatch.indexOf(formName) > -1 && form.getAttribute('data-form-id') == null) { + // add submit listener on form that we need to watch only + console.info('[WEM] watching form ' + formName); + form.addEventListener('submit', wem._formSubmitEventListener, true); + } + } + + for (var videoIndex = 0; videoIndex < videoNamesToWatch.length; videoIndex++) { + var videoName = videoNamesToWatch[videoIndex]; + var video = document.getElementById(videoName) || document.getElementById(wem._resolveId(videoName)); + + if (video) { + video.addEventListener('play', wem.sendVideoEvent); + video.addEventListener('ended', wem.sendVideoEvent); + console.info('[WEM] watching video ' + videoName); + } else { + console.warn('[WEM] unable to watch video ' + videoName + ', video not found in the page'); + } + } + + for (var clickIndex = 0; clickIndex < clickToWatch.length; clickIndex++) { + var clickIdName = clickToWatch[clickIndex]; + var click = (document.getElementById(clickIdName) || document.getElementById(wem._resolveId(clickIdName))) + ? (document.getElementById(clickIdName) || document.getElementById(wem._resolveId(clickIdName))) + : document.getElementsByName(clickIdName)[0]; + if (click) { + click.addEventListener('click', wem.sendClickEvent); + console.info('[WEM] watching click ' + clickIdName); + } else { + console.warn('[WEM] unable to watch click ' + clickIdName + ', element not found in the page'); + } + } + }); + + // Load the context once document is ready + document.addEventListener('DOMContentLoaded', function () { + wem.DOMLoaded = true; + + // complete already registered events + wem._checkUncompleteRegisteredEvents(); + + // Dispatch javascript events for the experience (perso/opti displayed from SSR, based on unomi events) + wem._dispatchJSExperienceDisplayedEvents(); + + // Some event may not need to be send to unomi, check for them and filter them out. + wem._filterUnomiEvents(); + + // Add referrer info into digitalData.page object. + wem._processReferrer(); + + // Build view event + const viewEvent = wem.buildEvent('view', wem.buildTargetPage(), wem.buildSource(wem.digitalData.site.siteInfo.siteID, 'site')); + viewEvent.flattenedProperties = {}; + + // Add URLParameters + if (location.search) { + viewEvent.flattenedProperties['URLParameters'] = wem.convertUrlParametersToObj(location.search); + } + // Add interests + if (wem.digitalData.interests) { + viewEvent.flattenedProperties['interests'] = wem.digitalData.interests; + } + + // Register the page view event, it's unshift because it should be the first event, this is just for logical purpose. (page view comes before perso displayed event for example) + wem._registerEvent(viewEvent, true); + + if (wem.activateWem) { + wem.loadContext(); + } else { + wem._executeFallback('wem is not activated on current page'); + } + }); + }, + + convertUrlParametersToObj: function (searchString) { + if (!searchString) { + return null; + } + + return searchString + .replace(/^\?/, '') // Only trim off a single leading interrobang. + .split('&') + .reduce((result, next) => { + if (next === '') { + return result; + } + let pair = next.split('='); + let key = decodeURIComponent(pair[0]); + let value = typeof pair[1] !== 'undefined' && decodeURIComponent(pair[1]) || undefined; + if (Object.prototype.hasOwnProperty.call(result, key)) { // Check to see if this property has been met before. + if (Array.isArray(result[key])) { // Is it already an array? + result[key].push(value); + } else { // Make it an array. + result[key] = [result[key], value]; + } + } else { // First time seen, just add it. + result[key] = value; + } + + return result; + }, {} + ); + }, + + /** + * This function will register a personalization + * + * @param {object} personalization + * @param {object} variants + * @param {boolean} [ajax] Deprecated: Ajax rendering is not supported anymore + * @param {function} [resultCallback] + */ + registerPersonalizationObject: function (personalization, variants, ajax, resultCallback) { + var target = personalization.id; + wem._registerPersonalizationCallback(personalization, function (result) { + var successfulFilters = []; + for (var i = 0; i < result.length; i++) { + successfulFilters.push(variants[result[i]]); + } + + var selectedFilter = null; + if (successfulFilters.length > 0) { + selectedFilter = successfulFilters[0]; + var minPos = successfulFilters[0].position; + if (minPos >= 0) { + for (var j = 1; j < successfulFilters.length; j++) { + if (successfulFilters[j].position < minPos) { + selectedFilter = successfulFilters[j]; + } + } + } + } + + if (resultCallback) { + // execute callback + resultCallback(successfulFilters, selectedFilter); + } else { + if (selectedFilter) { + var targetFilters = document.getElementById(target).children; + for (var fIndex in targetFilters) { + var filter = targetFilters.item(fIndex); + if (filter) { + filter.style.display = (filter.id === selectedFilter.content) ? '' : 'none'; + } + } + + // we now add control group information to event if the user is in the control group. + if (wem._isInControlGroup(target)) { + console.info('[WEM] Profile is in control group for target: ' + target + ', adding to personalization event...'); + selectedFilter.event.target.properties.inControlGroup = true; + if (selectedFilter.event.target.properties.variants) { + selectedFilter.event.target.properties.variants.forEach(variant => variant.inControlGroup = true); + } + } + + // send event to unomi + wem.collectEvent(wem._completeEvent(selectedFilter.event), function () { + console.info('[WEM] Personalization event successfully collected.'); + }, function () { + console.error('[WEM] Could not send personalization event.'); + }); + + //Trigger variant display event for personalization + wem._dispatchJSExperienceDisplayedEvent(selectedFilter.event); + } else { + var elements = document.getElementById(target).children; + for (var eIndex in elements) { + var el = elements.item(eIndex); + el.style.display = 'none'; + } + } + } + }); + }, + + /** + * This function will register an optimization test or A/B test + * + * @param {string} optimizationTestNodeId + * @param {string} goalId + * @param {string} containerId + * @param {object} variants + * @param {boolean} [ajax] Deprecated: Ajax rendering is not supported anymore + * @param {object} [variantsTraffic] + */ + registerOptimizationTest: function (optimizationTestNodeId, goalId, containerId, variants, ajax, variantsTraffic) { + + // check persona panel forced variant + var selectedVariantId = wem.getUrlParameter('wemSelectedVariantId-' + optimizationTestNodeId); + + // check already resolved variant stored in local + if (selectedVariantId === null) { + if (wem.storageAvailable('sessionStorage')) { + selectedVariantId = sessionStorage.getItem(optimizationTestNodeId); + } else { + selectedVariantId = wem.getCookie('selectedVariantId'); + if (selectedVariantId != null && selectedVariantId === '') { + selectedVariantId = null; + } + } + } + + // select random variant and call unomi + if (!(selectedVariantId && variants[selectedVariantId])) { + var keys = Object.keys(variants); + if (variantsTraffic) { + var rand = 100 * Math.random() << 0; + for (var nodeIdentifier in variantsTraffic) { + if ((rand -= variantsTraffic[nodeIdentifier]) < 0 && selectedVariantId == null) { + selectedVariantId = nodeIdentifier; + } + } + } else { + selectedVariantId = keys[keys.length * Math.random() << 0]; + } + if (wem.storageAvailable('sessionStorage')) { + sessionStorage.setItem(optimizationTestNodeId, selectedVariantId); + } else { + wem.setCookie('selectedVariantId', selectedVariantId, 1); + } + + // spread event to unomi + wem._registerEvent(wem._completeEvent(variants[selectedVariantId].event)); + } + + //Trigger variant display event for optimization + // (Wrapped in DOMContentLoaded because opti are resulted synchronously at page load, so we dispatch the JS even after page load, to be sure that listeners are ready) + window.addEventListener('DOMContentLoaded', () => { + wem._dispatchJSExperienceDisplayedEvent(variants[selectedVariantId].event); + }); + if (selectedVariantId) { + // update persona panel selected variant + if (window.optimizedContentAreas && window.optimizedContentAreas[optimizationTestNodeId]) { + window.optimizedContentAreas[optimizationTestNodeId].selectedVariant = selectedVariantId; + } + + // display the good variant + document.getElementById(variants[selectedVariantId].content).style.display = ''; + } + }, + + /** + * This function is used to load the current context in the page + * + * @param {boolean} [skipEvents=false] Should we send the events + * @param {boolean} [invalidate=false] Should we invalidate the current context + */ + loadContext: function (skipEvents, invalidate) { + if (wem.contextLoaded) { + console.log('Context already requested by', wem.contextLoaded); + return; + } + var jsonData = { + requiredProfileProperties: wem.digitalData.wemInitConfig.requiredProfileProperties, + requiredSessionProperties: wem.digitalData.wemInitConfig.requiredSessionProperties, + requireSegments: wem.digitalData.wemInitConfig.requireSegments, + requireScores: wem.digitalData.wemInitConfig.requireScores, + source: wem.buildSourcePage() + }; + if (!skipEvents) { + jsonData.events = wem.digitalData.events; + } + if (wem.digitalData.personalizationCallback) { + jsonData.personalizations = wem.digitalData.personalizationCallback.map(function (x) { + return x.personalization; + }); + } + + jsonData.sessionId = wem.sessionID; + + var contextUrl = wem.contextServerUrl + '/context.json'; + if (invalidate) { + contextUrl += '?invalidateSession=true&invalidateProfile=true'; + } + wem.ajax({ + url: contextUrl, + type: 'POST', + async: true, + contentType: 'text/plain;charset=UTF-8', // Use text/plain to avoid CORS preflight + jsonData: jsonData, + dataType: 'application/json', + invalidate: invalidate, + success: wem._onSuccess, + error: function () { + wem._executeFallback('error during context loading'); + } + }); + wem.contextLoaded = Error().stack; + console.info('[WEM] context loading...'); + }, + + /** + * This function will send an event to Apache Unomi + * @param {object} event The event object to send, you can build it using wem.buildEvent(eventType, target, source) + * @param {function} successCallback will be executed in case of success + * @param {function} errorCallback will be executed in case of error + */ + collectEvent: function (event, successCallback, errorCallback) { + wem.collectEvents({ events: [event] }, successCallback, errorCallback); + }, + + /** + * This function will send the events to Apache Unomi + * + * @param {object} events Javascript object { events: [event1, event2] } + * @param {function} successCallback will be executed in case of success + * @param {function} errorCallback will be executed in case of error + */ + collectEvents: function (events, successCallback, errorCallback) { + if (wem.fallback) { + // in case of fallback we dont want to collect any events + return; + } + + events.sessionId = wem.sessionID ? wem.sessionID : ''; + + var data = JSON.stringify(events); + wem.ajax({ + url: wem.contextServerUrl + '/eventcollector', + type: 'POST', + async: true, + contentType: 'text/plain;charset=UTF-8', // Use text/plain to avoid CORS preflight + data: data, + dataType: 'application/json', + success: successCallback, + error: errorCallback + }); + }, + + /** + * This function will build an event of type click and send it to Apache Unomi + * + * @param {object} event javascript + * @param {function} [successCallback] will be executed if case of success + * @param {function} [errorCallback] will be executed if case of error + */ + sendClickEvent: function (event, successCallback, errorCallback) { + if (event.target.id || event.target.name) { + console.info('[WEM] Send click event'); + var targetId = event.target.id ? event.target.id : event.target.name; + var clickEvent = wem.buildEvent('click', + wem.buildTarget(targetId, event.target.localName), + wem.buildSourcePage()); + + var eventIndex = wem.eventsPrevented.indexOf(targetId); + if (eventIndex !== -1) { + wem.eventsPrevented.splice(eventIndex, 0); + } else { + wem.eventsPrevented.push(targetId); + + event.preventDefault(); + + var target = event.target; + + wem.collectEvent(clickEvent, function (xhr) { + console.info('[WEM] Click event successfully collected.'); + if (successCallback) { + successCallback(xhr); + } else { + target.click(); + } + }, function (xhr) { + console.error('[WEM] Could not send click event.'); + if (errorCallback) { + errorCallback(xhr); + } else { + target.click(); + } + }); + } + } + }, + + /** + * This function will build an event of type video and send it to Apache Unomi + * + * @param {object} event javascript + * @param {function} [successCallback] will be executed if case of success + * @param {function} [errorCallback] will be executed if case of error + */ + sendVideoEvent: function (event, successCallback, errorCallback) { + console.info('[WEM] catching video event'); + var videoEvent = wem.buildEvent('video', wem.buildTarget(event.target.id, 'video', { action: event.type }), wem.buildSourcePage()); + + wem.collectEvent(videoEvent, function (xhr) { + console.info('[WEM] Video event successfully collected.'); + if (successCallback) { + successCallback(xhr); + } + }, function (xhr) { + console.error('[WEM] Could not send video event.'); + if (errorCallback) { + errorCallback(xhr); + } + }); + }, + + /** + * This function return the basic structure for an event, it must be adapted to your need + * + * @param {string} eventType The name of your event + * @param {object} [target] The target object for your event can be build with wem.buildTarget(targetId, targetType, targetProperties) + * @param {object} [source] The source object for your event can be build with wem.buildSource(sourceId, sourceType, sourceProperties) + * @returns {{eventType: *, scope}} + */ + buildEvent: function (eventType, target, source) { + var event = { + eventType: eventType, + scope: wem.digitalData.scope + }; + + if (target) { + event.target = target; + } + + if (source) { + event.source = source; + } + + return event; + }, + + /** + * This function return an event of type form + * + * @param {string} formName The HTML name of id of the form to use in the target of the event + * @returns {*|{eventType: *, scope, source: {scope, itemId: string, itemType: string, properties: {}}, target: {scope, itemId: string, itemType: string, properties: {}}}} + */ + buildFormEvent: function (formName) { + return wem.buildEvent('form', wem.buildTarget(formName, 'form'), wem.buildSourcePage()); + }, + + /** + * This function return the source object for a source of type page + * + * @returns {*|{scope, itemId: *, itemType: *}} + */ + buildTargetPage: function () { + return wem.buildTarget(wem.digitalData.page.pageInfo.pageID, 'page', wem.digitalData.page); + }, + + /** + * This function return the source object for a source of type page + * + * @returns {*|{scope, itemId: *, itemType: *}} + */ + buildSourcePage: function () { + return wem.buildSource(wem.digitalData.page.pageInfo.pageID, 'page', wem.digitalData.page); + }, + + /** + * This function return the basic structure for the target of your event + * + * @param {string} targetId The ID of the target + * @param {string} targetType The type of the target + * @param {object} [targetProperties] The optional properties of the target + * @returns {{scope, itemId: *, itemType: *}} + */ + buildTarget: function (targetId, targetType, targetProperties) { + return wem._buildObject(targetId, targetType, targetProperties); + }, + + /** + * This function return the basic structure for the source of your event + * + * @param {string} sourceId The ID of the source + * @param {string} sourceType The type of the source + * @param {object} [sourceProperties] The optional properties of the source + * @returns {{scope, itemId: *, itemType: *}} + */ + buildSource: function (sourceId, sourceType, sourceProperties) { + return wem._buildObject(sourceId, sourceType, sourceProperties); + }, + + /*************************************/ + /* Utility functions under this line */ + /*************************************/ + + /** + * This is an utility function to set a cookie + * + * @param {string} cookieName name of the cookie + * @param {string} cookieValue value of the cookie + * @param {number} [expireDays] number of days to set the expire date + */ + setCookie: function (cookieName, cookieValue, expireDays) { + var expires = ''; + if (expireDays) { + var d = new Date(); + d.setTime(d.getTime() + (expireDays * 24 * 60 * 60 * 1000)); + expires = '; expires=' + d.toUTCString(); + } + document.cookie = cookieName + '=' + cookieValue + expires + '; path=/; SameSite=Strict'; + }, + + /** + * This is an utility function to get a cookie + * + * @param {string} cookieName name of the cookie to get + * @returns {*} the value of the first cookie with the corresponding name or null if not found + */ + getCookie: function (cookieName) { + var name = cookieName + '='; + var ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return null; + }, + + /** + * This is an utility function to remove a cookie + * + * @param {string} cookieName the name of the cookie to rename + */ + removeCookie: function (cookieName) { + 'use strict'; + wem.setCookie(cookieName, '', -1); + }, + + /** + * This is an utility function to execute AJAX call + * + * @param {object} options + */ + ajax: function (options) { + var xhr = new XMLHttpRequest(); + if ('withCredentials' in xhr) { + xhr.open(options.type, options.url, options.async); + xhr.withCredentials = true; + } else if (typeof XDomainRequest != 'undefined') { + /* global XDomainRequest */ + xhr = new XDomainRequest(); + xhr.open(options.type, options.url); + } + + if (options.contentType) { + xhr.setRequestHeader('Content-Type', options.contentType); + } + if (options.dataType) { + xhr.setRequestHeader('Accept', options.dataType); + } + + if (options.responseType) { + xhr.responseType = options.responseType; + } + + var requestExecuted = false; + if (wem.timeoutInMilliseconds !== -1) { + setTimeout(function () { + if (!requestExecuted) { + console.error('[WEM] XML request timeout, url: ' + options.url); + requestExecuted = true; + if (options.error) { + options.error(xhr); + } + } + }, wem.timeoutInMilliseconds); + } + + xhr.onreadystatechange = function () { + if (!requestExecuted) { + if (xhr.readyState === 4) { + if (xhr.status === 200 || xhr.status === 204 || xhr.status === 304) { + if (xhr.responseText != null) { + requestExecuted = true; + if (options.success) { + options.success(xhr); + } + } + } else { + requestExecuted = true; + if (options.error) { + options.error(xhr); + } + console.error('[WEM] XML request error: ' + xhr.statusText + ' (' + xhr.status + ')'); + } + } + } + }; + + if (options.jsonData) { + xhr.send(JSON.stringify(options.jsonData)); + } else if (options.data) { + xhr.send(options.data); + } else { + xhr.send(); + } + }, + + /** + * This is an utility function to check if the local storage is available or not + * @param type + * @returns {boolean} + */ + storageAvailable: function (type) { + try { + var storage = window[type], + x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + return false; + } + }, + + dispatchJSEvent: function (name, canBubble, cancelable, detail) { + var event = document.createEvent('CustomEvent'); + event.initCustomEvent(name, canBubble, cancelable, detail); + document.dispatchEvent(event); + }, + + /** + * This is an utility function to get current url parameter value + * @param name, the name of the parameter + * @returns {string} + */ + getUrlParameter: function (name) { + name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]'); + var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); + var results = regex.exec(window.location.search); + return results === null ? null : decodeURIComponent(results[1].replace(/\+/g, ' ')); + }, + + /*************************************/ + /* Private functions under this line */ + /*************************************/ + _checkUncompleteRegisteredEvents: function () { + if (wem.digitalData && wem.digitalData.events) { + for (const event of wem.digitalData.events) { + wem._completeEvent(event); + } + } + }, + + _dispatchJSExperienceDisplayedEvents: () => { + if (wem.digitalData && wem.digitalData.events) { + for (const event of wem.digitalData.events) { + if (event.eventType === 'optimizationTestEvent' || event.eventType === 'personalizationEvent') { + wem._dispatchJSExperienceDisplayedEvent(event); + } + } + } + }, + + _dispatchJSExperienceDisplayedEvent: experienceUnomiEvent => { + if (!wem.fallback && + experienceUnomiEvent && + experienceUnomiEvent.target && + experienceUnomiEvent.target.properties && + experienceUnomiEvent.target.properties.variants && + experienceUnomiEvent.target.properties.variants.length > 0) { + + let typeMapper = { + optimizationTestEvent: 'optimization', + personalizationEvent: 'personalization' + }; + for (const variant of experienceUnomiEvent.target.properties.variants) { + let jsEventDetail = { + id: variant.id, + name: variant.systemName, + displayableName: variant.displayableName, + path: variant.path, + type: typeMapper[experienceUnomiEvent.eventType], + variantType: experienceUnomiEvent.target.properties.type, + tags: variant.tags, + nodeType: variant.nodeType, + wrapper: { + id: experienceUnomiEvent.target.itemId, + name: experienceUnomiEvent.target.properties.systemName, + displayableName: experienceUnomiEvent.target.properties.displayableName, + path: experienceUnomiEvent.target.properties.path, + tags: experienceUnomiEvent.target.properties.tags, + nodeType: experienceUnomiEvent.target.properties.nodeType + } + }; + + wem.dispatchJSEvent('displayWemVariant', false, false, jsEventDetail); + } + } + }, + + _filterUnomiEvents: () => { + if (wem.digitalData && wem.digitalData.events) { + wem.digitalData.events = wem.digitalData.events + .filter(event => !event.properties || !event.properties.doNotSendToUnomi) + .map(event => { + if (event.properties) { + delete event.properties.doNotSendToUnomi; + } + return event; + }); + } + }, + + _completeEvent: function (event) { + if (!event.source) { + event.source = wem.buildSourcePage(); + } + if (!event.scope) { + event.scope = wem.digitalData.scope; + } + if (event.target && !event.target.scope) { + event.target.scope = wem.digitalData.scope; + } + return event; + }, + + _registerEvent: function (event, unshift) { + if (wem.digitalData) { + if (wem.cxs) { + console.error('[WEM] already loaded, too late...'); + return; + } + } else { + wem.digitalData = {}; + } + + wem.digitalData.events = wem.digitalData.events || []; + if (unshift) { + wem.digitalData.events.unshift(event); + } else { + wem.digitalData.events.push(event); + } + }, + + _registerCallback: function (onLoadCallback) { + if (wem.digitalData) { + if (wem.cxs) { + console.info('[WEM] digitalData object loaded, calling on load callback immediately and registering update callback...'); + if (onLoadCallback) { + onLoadCallback(wem.digitalData); + } + } else { + console.info('[WEM] digitalData object present but not loaded, registering load callback...'); + if (onLoadCallback) { + wem.digitalData.loadCallbacks = wem.digitalData.loadCallbacks || []; + wem.digitalData.loadCallbacks.push(onLoadCallback); + } + } + } else { + console.info('[WEM] No digital data object found, creating and registering update callback...'); + wem.digitalData = {}; + if (onLoadCallback) { + wem.digitalData.loadCallbacks = []; + wem.digitalData.loadCallbacks.push(onLoadCallback); + } + } + }, + + _registerPersonalizationCallback: function (personalization, callback) { + if (wem.digitalData) { + if (wem.cxs) { + console.error('[WEM] already loaded, too late...'); + } else { + console.info('[WEM] digitalData object present but not loaded, registering sort callback...'); + wem.digitalData.personalizationCallback = wem.digitalData.personalizationCallback || []; + wem.digitalData.personalizationCallback.push({ personalization: personalization, callback: callback }); + } + } else { + wem.digitalData = {}; + wem.digitalData.personalizationCallback = wem.digitalData.personalizationCallback || []; + wem.digitalData.personalizationCallback.push({ personalization: personalization, callback: callback }); + } + }, + + _buildObject: function (itemId, itemType, properties) { + var object = { + scope: wem.digitalData.scope, + itemId: itemId, + itemType: itemType + }; + + if (properties) { + object.properties = properties; + } + + return object; + }, + + _onSuccess: function (xhr) { + wem.cxs = JSON.parse(xhr.responseText); + + if (wem.digitalData.loadCallbacks && wem.digitalData.loadCallbacks.length > 0) { + console.info('[WEM] Found context server load callbacks, calling now...'); + if (wem.digitalData.loadCallbacks) { + for (var i = 0; i < wem.digitalData.loadCallbacks.length; i++) { + wem.digitalData.loadCallbacks[i](wem.digitalData); + } + } + if (wem.digitalData.personalizationCallback) { + for (var j = 0; j < wem.digitalData.personalizationCallback.length; j++) { + wem.digitalData.personalizationCallback[j].callback(wem.cxs.personalizations[wem.digitalData.personalizationCallback[j].personalization.id]); + } + } + } + // Put a marker to be able to know when wem is full loaded, context is loaded, and callbacks have been executed. + window.wemLoaded = true; + }, + + _executeFallback: function (logMessage) { + console.warn('[WEM] execute fallback' + (logMessage ? (': ' + logMessage) : '')); + wem.fallback = true; + wem.cxs = {}; + for (var index in wem.digitalData.loadCallbacks) { + wem.digitalData.loadCallbacks[index](); + } + if (wem.digitalData.personalizationCallback) { + for (var i = 0; i < wem.digitalData.personalizationCallback.length; i++) { + wem.digitalData.personalizationCallback[i].callback([wem.digitalData.personalizationCallback[i].personalization.strategyOptions.fallback]); + } + } + }, + + _processReferrer: function () { + var referrerURL = wem.digitalData.page.pageInfo.referringURL || document.referrer; + var sameDomainReferrer = false; + if (referrerURL) { + // parse referrer URL + var referrer = new URL(referrerURL); + // Set sameDomainReferrer property + sameDomainReferrer = referrer.host === window.location.host; + + // only process referrer if it's not coming from the same site as the current page + if (!sameDomainReferrer) { + // get search element if it exists and extract search query if available + var search = referrer.search; + var query = undefined; + if (search && search != '') { + // parse parameters + var queryParams = [], param; + var queryParamPairs = search.slice(1).split('&'); + for (var i = 0; i < queryParamPairs.length; i++) { + param = queryParamPairs[i].split('='); + queryParams.push(param[0]); + queryParams[param[0]] = param[1]; + } + + // try to extract query: q is Google-like (most search engines), p is Yahoo + query = queryParams.q || queryParams.p; + query = decodeURIComponent(query).replace(/\+/g, ' '); + } + + // register referrer event + // Create deep copy of wem.digitalData.page and add data to pageInfo sub object + if (wem.digitalData && wem.digitalData.page && wem.digitalData.page.pageInfo) { + wem.digitalData.page.pageInfo.referrerHost = referrer.host; + wem.digitalData.page.pageInfo.referrerQuery = query; + } + } + } + wem.digitalData.page.pageInfo.sameDomainReferrer = sameDomainReferrer; + }, + + _formSubmitEventListener: function (event) { + console.info('[WEM] Registering form event callback'); + var form = event.target; + var formName = form.getAttribute('name') ? form.getAttribute('name') : form.getAttribute('id'); + if (formName && wem.formNamesToWatch.indexOf(formName) > -1) { + console.info('[WEM] catching form ' + formName); + + var eventCopy = document.createEvent('Event'); + // Define that the event name is 'build'. + eventCopy.initEvent('submit', event.bubbles, event.cancelable); + + event.stopImmediatePropagation(); + event.preventDefault(); + + var formEvent = wem.buildFormEvent(formName); + // merge form properties with event properties + formEvent.flattenedProperties = { + fields: wem._extractFormData(form) + }; + + wem.collectEvent(formEvent, + function () { + form.removeEventListener('submit', wem._formSubmitEventListener, true); + form.dispatchEvent(eventCopy); + if (!eventCopy.defaultPrevented && !eventCopy.cancelBubble) { + form.submit(); + } + form.addEventListener('submit', wem._formSubmitEventListener, true); + }, + function (xhr) { + console.error('[WEM] Error while collecting form event: ' + xhr.status + ' ' + xhr.statusText); + xhr.abort(); + form.removeEventListener('submit', wem._formSubmitEventListener, true); + form.dispatchEvent(eventCopy); + if (!eventCopy.defaultPrevented && !eventCopy.cancelBubble) { + form.submit(); + } + form.addEventListener('submit', wem._formSubmitEventListener, true); + } + ); + } + }, + + _extractFormData: function (form) { + var params = {}; + for (var i = 0; i < form.elements.length; i++) { + var e = form.elements[i]; + // ignore empty and undefined key (e.name) + if (e.name) { + switch (e.nodeName) { + case 'TEXTAREA': + case 'INPUT': + switch (e.type) { + case 'checkbox': + var checkboxes = document.querySelectorAll('input[name="' + e.name + '"]'); + if (checkboxes.length > 1) { + if (!params[e.name]) { + params[e.name] = []; + } + if (e.checked) { + params[e.name].push(e.value); + } + + } + break; + case 'radio': + if (e.checked) { + params[e.name] = e.value; + } + break; + default: + if (!e.value || e.value == '') { + // ignore element if no value is provided + break; + } + params[e.name] = e.value; + } + break; + case 'SELECT': + if (e.options && e.options[e.selectedIndex]) { + if (e.multiple) { + params[e.name] = []; + for (var j = 0; j < e.options.length; j++) { + if (e.options[j].selected) { + params[e.name].push(e.options[j].value); + } + } + } else { + params[e.name] = e.options[e.selectedIndex].value; + } + } + break; + } + } + } + return params; + }, + + _resolveId: function (id) { + if (wem.digitalData.sourceLocalIdentifierMap){ + var source = Object.keys(wem.digitalData.sourceLocalIdentifierMap).filter(function (source) { + return id.indexOf(source) > 0; + }); + return source ? id.replace(source, wem.digitalData.sourceLocalIdentifierMap[source]) : id; + } + return id; + }, + + _enableWem: (enable, callback) => { + // display fallback if wem is not enable + wem.fallback = !enable; + // remove cookies, reset cxs + if (!enable) { + wem.cxs = {}; + document.cookie = wem.trackerProfileIdCookieName + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = wem.contextServerCookieName + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + delete wem.contextLoaded; + } else { + if (wem.DOMLoaded) { + wem.loadContext(); + } else { + // As Dom loaded listener not triggered, enable global value. + wem.activateWem = true; + } + } + + if (callback) { + callback(enable) + } + console.log(`Wem ${enable ? 'enabled' : 'disabled'}`); + }, + + _isInControlGroup: function (id) { + if (wem.cxs.profileProperties && wem.cxs.profileProperties.unomiControlGroups) { + let controlGroup = wem.cxs.profileProperties.unomiControlGroups.find(controlGroup => controlGroup.id === id); + if (controlGroup) { + return true; + } + } + if (wem.cxs.sessionProperties && wem.cxs.sessionProperties.unomiControlGroups) { + let controlGroup = wem.cxs.sessionProperties.unomiControlGroups.find(controlGroup => controlGroup.id === id); + if (controlGroup) { + return true; + } + } + return false; + } + } + + return wem; +}; \ No newline at end of file diff --git a/test/spec.js b/test/spec.js index 938ea50..760fc99 100644 --- a/test/spec.js +++ b/test/spec.js @@ -15,9 +15,9 @@ * limitations under the License. */ -const tracker = require('..'); const assert = require('assert') +const unomi = require('..'); +const tracker = unomi.useTracker(); -assert.strictEqual(tracker.hello(), 'Hello world!'); - +assert(tracker !== null) console.log(`Tests passed`); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index acaa886..595aa58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1330,6 +1330,11 @@ electron-to-chromium@^1.4.202: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.239.tgz#5b04acb39c16b897a508980d1be95ba5f0201771" integrity sha512-XbhfzxPIFzMjJm17T7yUGZEyYh5XuUjrA/FQ7JUy2bEd4qQ7MvFTaKpZ6zXZog1cfVttESo2Lx0ctnf7eQOaAQ== +es6-crawler-detect@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/es6-crawler-detect/-/es6-crawler-detect-3.3.0.tgz#3a05cd3f2739099145bf40b012a6ad472cbbfb49" + integrity sha512-ptGU13H76+HNr5n0kvi5aO+RuqRHaIET/60Srv4+BgVWsuVVf3x9seDhz/IEcmuQMXJvrU2g+DbrKVrliUkTJQ== + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
