@shopgate/tracking-core
Version:
Tracking core library for the Shopgate Connect PWA.
440 lines (405 loc) • 14.1 kB
JavaScript
import "core-js/modules/es.string.replace.js";
import { hex2bin, SGLink } from "./helper";
import sgTrackingUrlMapper from "./urlMapping";
import { customEvents } from "./events";
/**
* Gets the value at path of object. If the resolved value is undefined,
* the defaultValue is used in its place.
*
* @param {Object} object The object to query
* @param {string} path The path of the property to get
* @param {*} [defaultValue] The value returned for undefined resolved values
* @returns {*} Returns the resolved value
*/
function get(object, path, defaultValue) {
// Initialize the parameters
const data = object || {};
const dataPath = path || '';
const defaultReturnValue = defaultValue;
// Get the segments of the path
const pathSegments = dataPath.split('.');
if (!dataPath || !pathSegments.length) {
// No path or path segments where determinable - return the default value
return defaultReturnValue;
}
/**
* Recursive callable function to traverse through a complex object
*
* @param {Object} currentData The current data that shall be investigated
* @param {number} currentPathSegmentIndex The current index within the path segment list
* @returns {*} The value at the end of the path or the default one
*/
function checkPathSegment(currentData, currentPathSegmentIndex) {
// Get the current segment within the path
const currentPathSegment = pathSegments[currentPathSegmentIndex];
const nextPathSegmentIndex = currentPathSegmentIndex + 1;
/**
* Prepare the default value as return value for the case that no matching property was
* found for the current path segment. In that case the path must be wrong.
*/
let result = defaultReturnValue;
if (currentData && currentData.hasOwnProperty(currentPathSegment)) {
if (typeof currentData[currentPathSegment] !== 'object' || pathSegments.length === nextPathSegmentIndex) {
// A final value was found
result = currentData[currentPathSegment];
} else {
// The value at the current step within the path is another object. Traverse through it
result = checkPathSegment(currentData[currentPathSegment], nextPathSegmentIndex);
}
}
return result;
}
// Start traversing the path within the data object
return checkPathSegment(data, 0);
}
/**
* Converts a numeric value into a suitable one for the unified tracking data
*
* @param {*} numericValue The value that shall be converted
* @returns {number|undefined} The converted value
* @private
*/
function getUnifiedNumber(numericValue) {
// Convert the value
const convertedValue = parseFloat(numericValue);
// Check if the converted value is numeric. If it's not, return "undefined" instead of it
// eslint-disable-next-line no-restricted-globals
return !isNaN(convertedValue) ? convertedValue : undefined;
}
/**
* Converts a value to a string. It returns an empty string for null or undefined.
* @param {*} value The value.
* @return {string}
*/
function getStringValue(value) {
return value || value === 0 ? `${value}` : '';
}
/**
* Returns the productNumber or uid from a product
*
* @param {Object} product Object with product data
* @returns {string} productNumber or uid of the product
* @private
*/
function getProductIdentifier(product) {
return get(product, 'productNumber') || get(product, 'uid');
}
/**
* Removes shop names out of the page title
* @param {string} title The page title
* @param {string} shopName The shop name
* @returns {string} The sanitized page title
*/
function sanitizeTitle(title, shopName) {
// Take care that the parameters don't contain leading or trailing spaces
let trimmedTitle = title.trim();
const trimmedShopName = shopName.trim();
if (!trimmedShopName) {
/**
* If no shop name is available, it doesn't make sense to replace it.
* So we return the the trimmed title directly.
*/
return trimmedTitle;
}
/**
* Setup the RegExp. It matches leading and trailing occurrences
* of known patterns for generically added shop names within page title
*/
const shopNameRegExp = new RegExp(`((^${trimmedShopName}:)|(- ${trimmedShopName}$))+`, 'ig');
if (trimmedTitle === trimmedShopName) {
// Clear the page title if it only contains the shop name
trimmedTitle = '';
}
// Remove the shop name from the page title
return trimmedTitle.replace(shopNameRegExp, '').trim();
}
/**
* Convert sgData product to unified item
*
* @param {Object} product Item from sgData
* @returns {Object} Data for the unified item
*/
function formatSgDataProduct(product) {
return {
id: getStringValue(getProductIdentifier(product)),
type: 'product',
name: get(product, 'name'),
priceNet: getUnifiedNumber(get(product, 'amount.net')),
priceGross: getUnifiedNumber(get(product, 'amount.gross')),
quantity: getUnifiedNumber(get(product, 'quantity')),
currency: get(product, 'amount.currency'),
brand: get(product, 'manufacturer')
};
}
/**
* Convert sgData products to unified items
*
* @param {Array} products Items from sgData
* @returns {UnifiedPurchaseItem[] | UnifiedAddToCartItem[]} Data for the unified items
*/
function formatSgDataProducts(products) {
// TODO: Error handling for malformed data
if (!products || !Array.isArray(products)) {
return [];
}
return products.map(formatSgDataProduct);
}
/**
* Convert products from the favouriteListItemAdded event to unified items
*
* @param {Array} products Items from sgData
* @returns {UnifiedAddToWishlistItem[]} Data for the unified items
*/
function formatFavouriteListItems(products) {
if (!products || !Array.isArray(products)) {
return [];
}
return products.map(product => ({
id: getStringValue(get(product, 'product_number_public') || get(product, 'product_number') || get(product, 'uid')),
type: 'product',
name: get(product, 'name'),
priceNet: getUnifiedNumber(get(product, 'unit_amount_net')) / 100,
priceGross: getUnifiedNumber(get(product, 'unit_amount_with_tax')) / 100,
currency: get(product, 'currency_id')
}));
}
/**
* Storage for helper functions that transforms raw data
* into the unified format for the tracking plugins
*/
const dataFormatHelpers = {};
/**
* Converter for the custom events
*
* @param {Object} data Raw data from the core
* @returns {UnifiedCustomEvent} Data for the unified custom event
* @private
*/
const formatEventData = data => ({
eventCategory: '',
eventAction: '',
eventLabel: null,
eventValue: null,
nonInteraction: false,
...data
});
// Assign the format helper to all custom events
customEvents.forEach(event => {
dataFormatHelpers[event] = formatEventData;
});
/**
* Converter for the purchase event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedPurchase} Data for the unified purchase event
*/
dataFormatHelpers.purchase = rawData => ({
id: getStringValue(get(rawData, 'order.number')),
type: 'product',
affiliation: get(rawData, 'shop.name', ''),
revenueGross: getUnifiedNumber(get(rawData, 'order.amount.gross')),
revenueNet: getUnifiedNumber(get(rawData, 'order.amount.net')),
tax: getUnifiedNumber(get(rawData, 'order.amount.tax')),
shippingGross: getUnifiedNumber(get(rawData, 'order.shipping.amount.gross')),
shippingNet: getUnifiedNumber(get(rawData, 'order.shipping.amount.net')),
currency: get(rawData, 'order.amount.currency'),
items: formatSgDataProducts(get(rawData, 'order.products'))
});
/**
* Converter for the pageview event
*
* @param {Object} rawData Raw data from the core
* @returns {{page: {merchantUrl: string, shopgateUrl: string}}} Formatted data
*/
dataFormatHelpers.pageview = rawData => {
const mappedUrls = sgTrackingUrlMapper(get(rawData, 'page.link'), rawData);
return {
page: {
merchantUrl: mappedUrls.public,
shopgateUrl: mappedUrls.private
}
};
};
/**
* Converter for the viewContent event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedPageview} Data for the unified page view event
*/
dataFormatHelpers.viewContent = rawData => {
let link = get(rawData, 'page.link');
if (link.indexOf('sg_app_resources') !== -1) {
/**
* Check if the link is formatted in the app style
* and reformat it do make it parsable with SGLink
* Note: this will be removed when CON-410 is done
*/
link = `http://dummy.com${link.replace(/(.*sg_app_resources\/\d*)/i, '')}`;
}
link = new SGLink(link);
/**
* In splittedPath we have the action as the first array entry
* to get the id/action params we remove the action from the array
*/
const splittedPath = [].concat(link.splittedPath);
splittedPath.shift();
/**
* All pages have action as type and a parsed page title without shop name as name
* fb content ID = 'id / name'
*/
let id = splittedPath.join('/');
let type = link.action || 'index';
let name = sanitizeTitle(get(rawData, 'page.title', ''), get(rawData, 'shop.name', '')) || get(rawData, 'page.name');
/**
* Category, product and product related pages should have productnumber/categorynumber as id
* they should have product name / category name as name
*/
if (rawData.hasOwnProperty('product') && type === 'item') {
type = 'product';
id = getProductIdentifier(rawData.product);
} else if (rawData.hasOwnProperty('category') && type === 'category') {
id = get(rawData, 'category.uid');
if (splittedPath.indexOf('all') !== -1) {
id += '/all';
}
} else if (rawData.hasOwnProperty('search') && type === 'search') {
id = get(rawData, 'search.query');
name = name.substring(0, name.indexOf(':')).trim();
} else if (['product_info', 'reviews', 'add_review'].indexOf(type) !== -1) {
id = hex2bin(link.splittedPath[1]) || '';
} else if (type === 'payment_success') {
type = 'checkout_success';
}
return {
id,
type,
name
};
};
/**
* Converter for the addToCart event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedAddToCart} data for the addToCart event
*/
dataFormatHelpers.addToCart = rawData => ({
type: 'product',
items: formatSgDataProducts(get(rawData, 'products'))
});
/**
* Converter for the variantSelected event
*
* @param {Object} rawData {variant:{}, baseProduct:{}} Raw data from the core
* @returns {Object} data for the addToCart event
*/
dataFormatHelpers.variantSelected = rawData => ({
variant: formatSgDataProduct(rawData.variant),
baseProduct: formatSgDataProduct(rawData.baseProduct)
});
/**
* Converter for the addToWishlist event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedAddToWishlist} data for the addToWishlist event
*/
dataFormatHelpers.addToWishlist = rawData => ({
type: 'product',
items: formatFavouriteListItems(get(rawData, 'favouriteListProducts'))
});
/**
* Converter for the initiatedCheckout event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedInitiatedCheckout} data for the addToCart event
*/
dataFormatHelpers.initiatedCheckout = rawData => {
const checkoutType = get(rawData, 'checkoutType', 'default');
// Get amount, depending if pp express on cart or item page was clicked
const amount = get(rawData, 'product.amount') || get(rawData, 'cart.amount');
return {
type: checkoutType,
valueNet: getUnifiedNumber(get(amount, 'net')),
valueGross: getUnifiedNumber(get(amount, 'gross')),
// PP express on item page sends the quantity directly
numItems: getUnifiedNumber(get(rawData, 'quantity') || get(rawData, 'cart.productsCount')),
currency: get(amount, 'currency'),
paymentInfoAvailable: checkoutType !== 'default'
};
};
/**
* Converter for the completedRegistration event
* @param {Object} rawData rawData Raw data from the core
* @returns {UnifiedCompletedRegistration} Information about the registration type
*/
dataFormatHelpers.completedRegistration = rawData => ({
registrationMethod: get(rawData, 'registrationType')
});
/**
* Converter for the search event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedSearched} data for the search event
*/
dataFormatHelpers.search = rawData => {
const hits = get(rawData, 'search.resultCount');
return {
type: 'product',
query: get(rawData, 'search.query'),
hits: getUnifiedNumber(hits),
success: !!hits
};
};
/**
* Return the url from the rawData
*
* @param {Object} rawData Raw data from the core
* @returns {Object} data for the setCampaignWithUrl event
*/
dataFormatHelpers.setCampaignWithUrl = rawData => ({
url: rawData.url
});
/**
* Converter for the addedPaymentInfo event
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedPaymentInfo} Data for the AddedPaymentInfo event
*/
dataFormatHelpers.addedPaymentInfo = rawData => ({
success: get(rawData, 'paymentMethodAdded.success'),
name: get(rawData, 'paymentMethodAdded.name')
});
/**
* Converter for the selectedPaymentInfo event. It's compatible to the addedPaymentInfo event, but
* other than this, it's also triggered when a payment method was selected which doesn't have
* configurable entities, like "credit card".
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedPaymentInfo} Data for the SelectedPaymentInfo event
*/
dataFormatHelpers.selectedPaymentInfo = rawData => ({
success: get(rawData, 'paymentMethodSelected.success'),
name: get(rawData, 'paymentMethodSelected.name')
});
/**
* Converter for the logItemView event.
*
* @param {Object} rawData Raw data from the core
* @returns {UnifiedItemView} Data for the logItemView event
*/
dataFormatHelpers.itemView = rawData => {
let product;
if (rawData.product) {
({
product
} = rawData);
} else if (rawData.variant) {
product = rawData.variant;
}
product = formatSgDataProduct(product);
delete product.quantity;
return {
...product,
type: ''
};
};
export default dataFormatHelpers;