blob: 3207722845d7cd75a7f7ed37e44dc9e589b912a4 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Crawler } from 'es6-crawler-detect';
export const newTracker = () => {
const wem = {
/**
* This function initialize the tracker
*
* @param {object} digitalData config of the tracker
* @returns {undefined}
*/
initTracker: function (digitalData) {
wem.digitalData = digitalData;
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.browserGeneratedSessionSuffix = wem.digitalData.wemInitConfig.browserGeneratedSessionSuffix ? wem.digitalData.wemInitConfig.browserGeneratedSessionSuffix : '';
wem.disableTrackedConditionsListeners = wem.digitalData.wemInitConfig.disableTrackedConditionsListeners;
wem.activateWem = wem.digitalData.wemInitConfig.activateWem;
const { contextServerUrl, 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;
}
},
/**
* This function start the tracker by loading the context in the page
* Note: that the tracker will start once the current DOM is complete loaded, using listener on current document: DOMContentLoaded
*
* @param {object[]} digitalDataOverrides optional, list of digitalData extensions, they will be merged with original digitalData before context loading
* @returns {undefined}
*/
startTracker: function (digitalDataOverrides = undefined) {
// Check before start
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;
}
// Register base context callback
wem._registerCallback(function () {
if (wem.cxs.profileId) {
wem.setCookie(wem.trackerProfileIdCookieName, wem.cxs.profileId);
}
if (!wem.cxs.profileId) {
wem.removeCookie(wem.trackerProfileIdCookieName);
}
if (!wem.disableTrackedConditionsListeners) {
wem._registerListenersForTrackedConditions();
}
}, 'Default tracker', 0);
// Load the context once document is ready
document.addEventListener('DOMContentLoaded', function () {
wem.DOMLoaded = true;
// enrich digital data considering extensions
wem._handleDigitalDataOverrides(digitalDataOverrides);
// 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');
}
});
},
/**
* get the current loaded context from Unomi, will be accessible only after loadContext() have been performed
* @returns {object} loaded context
*/
getLoadedContext: function () {
return wem.cxs;
},
/**
* In case Unomi contains rules related to HTML forms in the current page.
* The logic is simple, in case a rule exists in Unomi targeting a form event within the current webpage path
* - then this form will be identified as form to be watched.
* You can reuse this function to get the list of concerned forms in order to attach listeners automatically for those form for example
* (not that current tracker is doing that by default, check function: _registerListenersForTrackedConditions())
* @returns {string[]} form names/ids in current web page
*/
getFormNamesToWatch: function () {
return wem.formNamesToWatch;
},
/**
* Get current session id
* @returns {null|string} get current session id
*/
getSessionId: function () {
return wem.sessionID;
},
/**
* This function will register a personalization
*
* @param {object} personalization the personalization object
* @param {object} variants the variants
* @param {boolean} [ajax] Deprecated: Ajax rendering is not supported anymore
* @param {function} [resultCallback] the callback to be executed after personalization resolved
* @returns {undefined}
*/
registerPersonalizationObject: function (personalization, variants, ajax, resultCallback) {
var target = personalization.id;
wem._registerPersonalizationCallback(personalization, function (result, additionalResultInfos) {
var selectedFilter = null;
var successfulFilters = [];
var inControlGroup = additionalResultInfos && additionalResultInfos.inControlGroup;
// In case of control group Unomi is not resolving any strategy or fallback for us. So we have to do the fallback here.
if (inControlGroup && personalization.strategyOptions && personalization.strategyOptions.fallback) {
selectedFilter = variants[personalization.strategyOptions.fallback];
successfulFilters.push(selectedFilter);
} else {
for (var i = 0; i < result.length; i++) {
successfulFilters.push(variants[result[i]]);
}
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 (inControlGroup) {
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 the optimization test node id
* @param {string} goalId the associated goal Id
* @param {string} containerId the HTML container Id
* @param {object} variants the variants
* @param {boolean} [ajax] Deprecated: Ajax rendering is not supported anymore
* @param {object} [variantsTraffic] the associated traffic allocation
* @return {undefined}
*/
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));
}
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
* @param {boolean} [forceReload=false] This function contains an internal check to avoid loading of the context multiple times.
* But in some rare cases, it could be useful to force the reloading of the context and bypass the check.
* @return {undefined}
*/
loadContext: function (skipEvents = false, invalidate = false, forceReload = false) {
if (wem.contextLoaded && !forceReload) {
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 optional, will be executed in case of success
* @param {function} errorCallback optional, will be executed in case of error
* @return {undefined}
*/
collectEvent: function (event, successCallback = undefined, errorCallback = undefined) {
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 optional, will be executed in case of success
* @param {function} errorCallback optional, will be executed in case of error
* @return {undefined}
*/
collectEvents: function (events, successCallback = undefined, errorCallback = undefined) {
if (wem.fallback) {
// in case of fallback we don't 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] optional, will be executed if case of success
* @param {function} [errorCallback] optional, will be executed if case of error
* @return {undefined}
*/
sendClickEvent: function (event, successCallback = undefined, errorCallback = undefined) {
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] optional, will be executed if case of success
* @param {function} [errorCallback] optional, will be executed if case of error
* @return {undefined}
*/
sendVideoEvent: function (event, successCallback = undefined, errorCallback = undefined) {
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 will invalidate the Apache Unomi session and profile,
* by removing the associated cookies, set the loaded context to undefined
* and set the session id cookie with a newly generated ID
* @return {undefined}
*/
invalidateSessionAndProfile: function () {
'use strict';
wem.sessionID = wem.generateGuid() + wem.browserGeneratedSessionSuffix;
wem.setCookie(wem.trackerSessionIdCookieName, wem.sessionID, 1);
wem.removeCookie(wem.contextServerCookieName);
wem.removeCookie(wem.trackerProfileIdCookieName);
wem.cxs = undefined;
},
/**
* 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 {object} the event
*/
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
* @param {HTMLFormElement} form optional HTML form element, if provided will be used to extract the form fields and populate the form event
* @returns {object} the form event
*/
buildFormEvent: function (formName, form = undefined) {
const formEvent = wem.buildEvent('form', wem.buildTarget(formName, 'form'), wem.buildSourcePage());
formEvent.flattenedProperties = {
fields: form ? wem._extractFormData(form) : {}
};
return formEvent;
},
/**
* This function return the source object for a source of type page
*
* @returns {object} the target page
*/
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 {object} the source page
*/
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 {object} the target
*/
buildTarget: function (targetId, targetType, targetProperties = undefined) {
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 {object} the source
*/
buildSource: function (sourceId, sourceType, sourceProperties = undefined) {
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
* @return {undefined}
*/
setCookie: function (cookieName, cookieValue, expireDays) {
let expires = '';
if (expireDays) {
var d = new Date();
d.setTime(d.getTime() + (expireDays * 24 * 60 * 60 * 1000));
expires = '; expires=' + d.toUTCString();
}
let secure = location.protocol === 'https:' ? '; secure' : '';
document.cookie = cookieName + '=' + cookieValue + expires + '; path=/; SameSite=Strict' + secure;
},
/**
* This is an utility function to get a cookie
*
* @param {string} cookieName name of the cookie to get
* @returns {string} 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
* @return {undefined}
*/
removeCookie: function (cookieName) {
'use strict';
wem.setCookie(cookieName, '', -1);
},
/**
* This is an utility function to execute AJAX call
*
* @param {object} options options of the request
* @return {undefined}
*/
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 generate a new UUID
*
* @returns {string} the newly generated UUID
*/
generateGuid: function () {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
s4() + '-' + s4() + s4() + s4();
},
/**
* This is an utility function to check if the local storage is available or not
* @param {string} type the type of storage to test
* @returns {boolean} true in case storage is available, false otherwise
*/
storageAvailable: function (type) {
try {
var storage = window[type],
x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch (e) {
return false;
}
},
/**
* Dispatch a JavaScript event in current HTML document
*
* @param {string} name the name of the event
* @param {boolean} canBubble does the event can bubble ?
* @param {boolean} cancelable is the event cancelable ?
* @param {*} detail event details
* @return {undefined}
*/
dispatchJSEvent: function (name, canBubble, cancelable, detail) {
var event = document.createEvent('CustomEvent');
event.initCustomEvent(name, canBubble, cancelable, detail);
document.dispatchEvent(event);
},
/**
* Fill the wem.digitalData.displayedVariants with the javascript event passed as parameter
* @param {object} jsEvent javascript event
* @private
* @return {undefined}
*/
_fillDisplayedVariants: (jsEvent) => {
if (!wem.digitalData.displayedVariants) {
wem.digitalData.displayedVariants = [];
}
wem.digitalData.displayedVariants.push(jsEvent);
},
/**
* This is an utility function to get current url parameter value
*
* @param {string} name, the name of the parameter
* @returns {string} the value of the parameter
*/
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, ' '));
},
/**
* convert the passed query string into JS object.
* @param {string} searchString The URL query string
* @returns {object} converted URL params
*/
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;
}, {}
);
},
/*************************************/
/* Private functions under this line */
/*************************************/
/**
* Used to override the default digitalData values,
* the result will impact directly the current instance wem.digitalData
*
* @param {object[]} digitalDataOverrides list of overrides
* @private
* @return {undefined}
*/
_handleDigitalDataOverrides: function (digitalDataOverrides) {
if (digitalDataOverrides && digitalDataOverrides.length > 0) {
for (const digitalDataOverride of digitalDataOverrides) {
wem.digitalData = wem._deepMergeObjects(digitalDataOverride, wem.digitalData);
}
}
},
/**
* Check for tracked conditions in the current loaded context, and attach listeners for the known tracked condition types:
* - formEventCondition
* - videoViewEventCondition
* - clickOnLinkEventCondition
*
* @private
* @return {undefined}
*/
_registerListenersForTrackedConditions: function () {
console.info('[WEM] Check for tracked conditions and attach related HTML listeners');
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');
}
}
},
/**
* Check for currently registered events in wem.digitalData.events that would be incomplete:
* - autocomplete the event with the current digitalData page infos for the source
* @private
* @return {undefined}
*/
_checkUncompleteRegisteredEvents: function () {
if (wem.digitalData && wem.digitalData.events) {
for (const event of wem.digitalData.events) {
wem._completeEvent(event);
}
}
},
/**
* dispatch JavaScript event in current HTML document for perso and opti events contains in digitalData.events
* @private
* @return {undefined}
*/
_dispatchJSExperienceDisplayedEvents: () => {
if (wem.digitalData && wem.digitalData.events) {
for (const event of wem.digitalData.events) {
if (event.eventType === 'optimizationTestEvent' || event.eventType === 'personalizationEvent') {
wem._dispatchJSExperienceDisplayedEvent(event);
}
}
}
},
/**
* build and dispatch JavaScript event in current HTML document for the given Unomi event (perso/opti)
* @private
* @param {object} experienceUnomiEvent perso/opti Unomi event
* @return {undefined}
*/
_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
}
};
if (experienceUnomiEvent.eventType === 'personalizationEvent') {
jsEventDetail.wrapper.inControlGroup = experienceUnomiEvent.target.properties.inControlGroup;
}
wem._fillDisplayedVariants(jsEventDetail);
wem.dispatchJSEvent('displayWemVariant', false, false, jsEventDetail);
}
}
},
/**
* Filter events in digitalData.events that would have the property: event.properties.doNotSendToUnomi
* The effect is directly stored in a new version of wem.digitalData.events
* @private
* @return {undefined}
*/
_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;
});
}
},
/**
* Check if event is incomplete and complete what is missing:
* - source: if missing, use the current source page
* - scope: if missing, use the current scope
* @param {object} event, the event to be checked
* @private
* @return {object} the complete 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;
},
/**
* Register an event in the wem.digitalData.events.
* Registered event, will be sent automatically during the context loading process.
*
* Beware this function is useless in case the context is already loaded.
* in case the context is already loaded (check: wem.getLoadedContext()), then you should use: wem.collectEvent(s) functions
*
* @private
* @param {object} event the Unomi event to be registered
* @param {boolean} unshift optional, if true, the event will be added at the beginning of the list otherwise at the end of the list. (default: false)
* @return {undefined}
*/
_registerEvent: function (event, unshift = false) {
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);
}
},
/**
* This function allow for registering callback that will be executed once the context is loaded.
* @param {function} onLoadCallback the callback to be executed
* @param {string} name optional name for the call, used mostly for logging the execution
* @param {number} priority optional priority to execute the callbacks in a specific order
* (default: 5, to leave room for the tracker default callback(s))
* @private
* @return {undefined}
*/
_registerCallback: function (onLoadCallback, name = undefined, priority = 5) {
if (wem.digitalData) {
if (wem.cxs) {
console.info('[WEM] Trying to register context load callback, but context already loaded, executing now...');
if (onLoadCallback) {
console.info('[WEM] executing context load callback: ' + (name ? name : 'Callback without name'));
onLoadCallback(wem.digitalData);
}
} else {
console.info('[WEM] registering context load callback: ' + (name ? name : 'Callback without name'));
if (onLoadCallback) {
wem.digitalData.loadCallbacks = wem.digitalData.loadCallbacks || [];
wem.digitalData.loadCallbacks.push({
priority,
name,
execute: onLoadCallback
});
}
}
} else {
console.info('[WEM] Trying to register context load callback, but no digitalData conf found, creating it and registering the callback: ' + (name ? name : 'Callback without name'));
wem.digitalData = {};
if (onLoadCallback) {
wem.digitalData.loadCallbacks = [];
wem.digitalData.loadCallbacks.push({
priority,
name,
execute: onLoadCallback
});
}
}
},
/**
* Internal function for personalization specific callbacks (used for HTML dom manipulation once we get the context loaded)
* @param {object} personalization the personalization
* @param {function} callback the callback
* @private
* @return {undefined}
*/
_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 });
}
},
/**
* Build a simple Unomi object
* @param {string} itemId the itemId of the object
* @param {string} itemType the itemType of the object
* @param {object} properties optional properties for the object
* @private
* @return {object} the built Unomi JSON object
*/
_buildObject: function (itemId, itemType, properties = undefined) {
var object = {
scope: wem.digitalData.scope,
itemId: itemId,
itemType: itemType
};
if (properties) {
object.properties = properties;
}
return object;
},
/**
* Main callback used once the Ajax context request succeed
* @param {XMLHttpRequest} xhr the request
* @private
* @return {undefined}
*/
_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...');
wem._executeLoadCallbacks(wem.digitalData);
if (wem.digitalData.personalizationCallback) {
for (var j = 0; j < wem.digitalData.personalizationCallback.length; j++) {
if (wem.cxs.personalizationResults) {
// Since Unomi 2.1.0 personalization results are available with more infos
var personalizationResult = wem.cxs.personalizationResults[wem.digitalData.personalizationCallback[j].personalization.id];
wem.digitalData.personalizationCallback[j].callback(personalizationResult.contentIds, personalizationResult.additionalResultInfos);
} else {
// probably a version older than Unomi 2.1.0, fallback to old personalization results
wem.digitalData.personalizationCallback[j].callback(wem.cxs.personalizations[wem.digitalData.personalizationCallback[j].personalization.id]);
}
}
}
}
},
/**
* Main callback used once the Ajax context request failed
* @param {string} logMessage the log message, to identify the place of failure
* @private
* @return {undefined}
*/
_executeFallback: function (logMessage) {
console.warn('[WEM] execute fallback' + (logMessage ? (': ' + logMessage) : '') + ', load fallback callbacks, calling now...');
wem.fallback = true;
wem.cxs = {};
wem._executeLoadCallbacks(undefined);
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]);
}
}
},
/**
* Executed the registered context loaded callbacks
* @param {*} callbackParam param of the callbacks
* @private
* @return {undefined}
*/
_executeLoadCallbacks: function (callbackParam) {
if (wem.digitalData.loadCallbacks && wem.digitalData.loadCallbacks.length > 0) {
wem.digitalData.loadCallbacks
.sort((a, b) => a.priority - b.priority)
.forEach(loadCallback => {
console.info('[WEM] executing context load callback: ' + (loadCallback.name ? loadCallback.name : 'callback without name'));
loadCallback.execute(callbackParam);
});
}
},
/**
* Parse current HTML document referrer information to enrich the digitalData page infos
* @private
* @return {undefined}
*/
_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;
},
/**
* Listener callback that can be attached to a specific HTML form,
* this listener will automatically send the form event to Unomi, by parsing the HTML form data.
* (NOTE: the form listener only work for know forms to be watch due to tracked conditions)
*
* @param {object} event the original HTML form submition event for the watch form.
* @private
* @return {undefined}
*/
_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();
wem.collectEvent(wem.buildFormEvent(formName, form),
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);
}
);
}
},
/**
* Utility function to extract data from an HTML form.
*
* @param {HTMLFormElement} form the HTML form element
* @private
* @return {object} the form data as JSON
*/
_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;
},
/**
* Internal function used for mapping ids when current HTML document ids doesn't match ids stored in Unomi backend
* @param {string} id the id to resolve
* @return {string} the resolved id or the original id if not match found
* @private
*/
_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;
},
/**
* Enable or disable tracking in current page
* @param {boolean} enable true will enable the tracking feature, otherwise they will be disabled
* @param {function} callback an optional callback that can be used to perform additional logic based on enabling/disabling results
* @private
* @return {undefined}
*/
_enableWem: (enable, callback = undefined) => {
// display fallback if wem is not enable
wem.fallback = !enable;
// remove cookies, reset cxs
if (!enable) {
wem.cxs = {};
wem.removeCookie(wem.contextServerCookieName);
wem.removeCookie(wem.trackerProfileIdCookieName);
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] successfully ${enable ? 'enabled' : 'disabled'} tracking in current page`);
},
/**
* Utility function used to merge two JSON object together (arrays are concat for example)
* @param {object} source the source object for merge
* @param {object} target the target object for merge
* @private
* @return {object} the merged results
*/
_deepMergeObjects: function (source, target) {
if (!wem._isObject(target) || !wem._isObject(source)) {
return source;
}
Object.keys(source).forEach(key => {
const targetValue = target[key];
const sourceValue = source[key];
// concat arrays || merge objects || add new props
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
target[key] = targetValue.concat(sourceValue);
} else if (wem._isObject(targetValue) && wem._isObject(sourceValue)) {
target[key] = wem._deepMergeObjects(sourceValue, Object.assign({}, targetValue));
} else {
target[key] = sourceValue;
}
});
return target;
},
/**
* Utility function used to check if the given variable is a JavaScript object.
* @param {*} obj the variable to check
* @private
* @return {boolean} true if the variable is an object, false otherwise
*/
_isObject: function (obj) {
return obj && typeof obj === 'object';
}
};
return wem;
};