import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, Subject } from 'rxjs'; import { buffer, catchError } from 'rxjs/operators'; import { ConfigService } from '@arrow/bom/config'; import { ArrowService } from './arrow.service'; import { ListItemModel } from './list-item.model'; import { SBOSettings, SettingOption } from './settings.model'; import { AnalyticData } from './analytics/analytics-data'; @Injectable() export class AnalyticsService { // The source for the buffer used below impressionsSource: Subject = new Subject(); impressions$: Observable< ListItemModel > = this.impressionsSource.asObservable(); // The thing that gives the signal to the bufferedImpressions to emit its items recordImpressionsSource: Subject = new Subject(); recordImpressions$: Observable< void > = this.recordImpressionsSource.asObservable(); // Mirrors the original observable values but ONLY when given the signal bufferedImpressions$: Observable = this.impressions$.pipe( buffer(this.recordImpressions$) ); // Used to decide whether to start a new countdown or not isBufferEmpty: boolean = true; // Checks if the opening BOM is new isBomNew: boolean = false; // How long to hold the buffer before recording a GA event readonly groupImpressionTimeout: number = 250; private _arrowService: ArrowService; constructor( private http: HttpClient, private config: ConfigService, arrowService: ArrowService ) { this._arrowService = arrowService; this.bufferedImpressions$.subscribe((items: ListItemModel[]) => { this.isBufferEmpty = true; this.googleTrack(AnalyticsEvent.PRODUCT_LIST_IMPRESSION, items); }); } /** * track analytics actions * @param action * @param payload */ track(action: string, payload?: any): Observable { if (this.config.urls.analytics[action]) { const payloadData = payload ? '?j=' + encodeURIComponent(JSON.stringify(payload)) : ''; const url = this.config.apiUrl + this.config.urls.analytics[action] + payloadData; return this.http .post(url, null, { withCredentials: true }) .pipe(catchError(error => error)); } } /** * Send data to googleDataMapping to prepare it for the push to the arrow data layer for google analytics. * @param event Name of the tracking event. * @param data Raw data from the relevent data response. */ googleTrack(event: AnalyticsEvent, data: any): void { let mappedData: any; if (this.doSubmitMapping()) { mappedData = this.mapData(event, data); if (window['dataLayer']) window['dataLayer'].push(mappedData); } if (this.config.isLocal) console.log('*** ANALYTICS PUSH *** | mappedData: ', mappedData); } /** * Proxy event for "googleTrack" with typed data parameter * @param event * @param data, it also have optional custom metrics */ googleTrackEvent( event: AnalyticsEvent, data: { category: string; action: string; label: string | number; value?: string | number; bomAvailablePartPrice?: string | number; bomPotentialPartPrice?: string | number; bomMissedStock?: string | number; bomNumberNonpurchaseableItemsAttempted?: string | number; } ): void { this.googleTrack(event, data); } QtyTrackEvent( event: AnalyticsEvent, data: { category: string; action: string; label: string | number; value?: string | number; CM15?: number; CM10?: number; } ): void { this.googleTrack(event, data); } /** * track an especial event - inventory settings * @param oSet * @param nSet */ googleTrackInventorySettings(oSet: SBOSettings, nSet: SBOSettings): void { const changed = this._getForDiffs(oSet, nSet); changed.regionFilter.options .filter(o => o.isChanged) .forEach(o => { this.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Setting Change', label: `Ships from: ${o.name} - ${o.isActive}` }); }); changed.typeFilter.options .filter(o => o.isChanged) .forEach(o => { this.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Setting Change', label: `Packaging: ${o.name} - ${o.isActive}` }); }); const iPrefer = changed.cheapest.options.find( o => o.isChanged && o.isActive ); if (iPrefer) { this.googleTrackEvent(AnalyticsEvent.CLICK, { category: 'BOM', action: 'Setting Change', label: `I prefer: ${iPrefer.name}` }); } } /** * Checks whether analytics can and should be recorded **/ doSubmitMapping(): boolean { const isArrowCom = this.config.isArrowcom(); const isMyArrow = !isArrowCom; return ( (window['dataLayer'] && (isArrowCom || isMyArrow)) || this.config.isLocal ); } bufferTrackImpression(item: ListItemModel): void { // Adds the impression to the buffer this.impressionsSource.next(item); // If the buffer is currently empty start a countdown timer if (this.isBufferEmpty) { setTimeout(() => { // Tells subscriber that it's time to record this.recordImpressionsSource.next(); }, this.groupImpressionTimeout); } this.isBufferEmpty = false; } /** * Returns the "new settings" object with each option marked with whether it's been changed or not * @param olds Settings before user made changes * @param news Settings user changed and saved */ private _getForDiffs(oSet: SBOSettings, nSet: SBOSettings): SBOSettings { this._markDiffs(oSet.cheapest.options, nSet.cheapest.options); this._markDiffs(oSet.regionFilter.options, nSet.regionFilter.options); this._markDiffs(oSet.typeFilter.options, nSet.typeFilter.options); return nSet; } /** * @param oldOps * @param newOps */ private _markDiffs(oldOps: SettingOption[], newOps: SettingOption[]): void { oldOps.forEach(oo => { const newOp = newOps.find(no => no.id === oo.id); newOp.isChanged = oo.isActive !== newOp.isActive ? true : false; }); } /** * Chooses the right object model for the incoming tracking data. * @param event Tracking even name. * @param data Data sent from tracking call somewhere in the app. */ private mapData(event: AnalyticsEvent, data: any): any { let mappedData: any; switch (event) { case AnalyticsEvent.PRODUCT_LIST_IMPRESSION: mappedData = this.mapProductListImpression(event, data); break; case AnalyticsEvent.ADD_TO_CART_BOM_ALL: case AnalyticsEvent.ADD_TO_CART_BOM_SELECTED: case AnalyticsEvent.ADD_TO_CART_BOM: mappedData = this.mapAddItemToCart(event, data); break; case AnalyticsEvent.PRODUCT_LIST_CLICK: mappedData = this.mapProductListClick(event, data); break; case AnalyticsEvent.VIRTUAL_PAGE_VIEW: mappedData = this.mapVirtualPageView(event, data); break; case AnalyticsEvent.BOM_INFO: mappedData = this.mapBomInfo(event, data); break; case AnalyticsEvent.EVENT: mappedData = this.mapSimpleEvent(event, data); break; case AnalyticsEvent.CLICK: mappedData = this.mapEvent(event, data); break; default: mappedData = {}; console.error('Error while trying to map "%s" event data', event); // TODO: Better handle this error } return mappedData; } /** * Object model structure for 'productListImpression'. * All empty fields are there because we may have the data in the future. * @param event Tracking even name. * @param data Data sent from tracking call somewhere in the app. */ private mapProductListImpression(event: string, rows: any): any { return { event, ecommerce: { currencyCode: this.getCurrencyCode(), impressions: this.mapProductListImpressionsList(rows) } }; } /** * Object model structure for 'BomInfo' event * @param event * @param data */ private mapBomInfo(event: string, data: any): any { return { event, eventframework: { category: 'BOM', action: 'Bom Info', label: data.bomId }, bomInStockCount: data.bomInStockCount, bomOutOfStockCount: data.bomOutOfStockCount, bomStockErrorCount: data.bomStockErrorCount, bomPartCount: data.bomPartCount, bomAvailableValue: data.bomAvailableValue, bomPotentialValue: data.bomPotentialValue, bomMissedStockValue: data.bomMissedStockValue }; } private mapAddItemToCart(event: string, rows: any): any { // model uses arrow service so create one //this._arrowService = new ArrowService(); return { event, ecommerce: { add: { actionField: { list: 'bom_partslist', action: 'add' }, products: this.mapProductList(rows) } } }; } /** * Object model structure for 'productListClick'. * All empty fields are there because we may have the data in the future. * @param event Tracking even name. * @param row row data to send to GA. */ private mapProductListClick(event: string, row: any): any { // model uses arrow service so create one // this._arrowService = new ArrowService(); return { event, ecommerce: { currencyCode: this.getCurrencyCode(), click: { actionField: { list: 'bom_partlist', action: 'click' }, products: { id: row.partId || '', name: row.partName || '', price: row.item.getCurrentPrice() || '', brand: row.manufacturer || '', category: row.category || '', variant: row.description || '' } } } }; } /** * Maps tracking data from a product list. * @return Array of parts reduced to their tracking information */ private mapProductList(rows: Array): Array { const productDataArray: Array = []; rows.forEach((row: any) => { const reducedRowData: AnalyticData = { id: row.PartId || '', name: row.PartSeo || '', price: row.Price || '', brand: row.Manufacturer || '', category: row.Category || '', variant: row.Description || '', position: row.PositionInBom >= 0 ? row.PositionInBom : '', list: 'bom_partslist', dimension10: row.QuantityRequested, dimension12: row.SourcePartId || '', dimension13: row.ImageUrl || '', dimension14: row.QuantityInStock || '', dimension25: row.InStock, dimension26: '', dimension27: row.Manufacturer || '', dimension29: row.Packaging || '', dimension34: row.ShipIn === 0 || row.ShipIn > 0 ? row.ShipIn : '', dimension35: '', dimension36: '', dimension37: row.HasRohs, dimension38: '', dimension39: '', dimension41: '', dimension42: '', dimension43: row.SourcePartId || '', dimension78: row.HasTieredPricing }; productDataArray.push(reducedRowData); }); return productDataArray; } private mapProductListImpressionsList(rows: Array): Array { const productData: Array = []; rows.forEach((row: any) => { const reducedRowData: AnalyticData = { id: row.item.partId || '', name: row.item.partSeo || '', price: row.item.getCurrentPrice() || '', brand: row.item.manufacturer || '', category: row.item.category || '', variant: row.item.description || '', position: row.item.positionOnPage >= 0 ? row.item.positionOnPage : '', dimension10: row.item.quantity, dimension12: row.item.partId || '', dimension13: row.item.imageName || '', dimension25: row.item.stock ? true : false, dimension29: row.item.packaging || '', dimension44: '', dimension58: row.item.isObsolete ? true : false, dimension78: row.item.hasTieredPriceGroup ? true : false }; productData.push(reducedRowData); }); return productData; } /** * Maps tracking data from a product list. * @return Array of parts reduced to their tracking information */ private mapBuyProductList(items: Array, listName?: string): Array { const productDataArray: Array = []; items.forEach(item => { const sourceIdRegion: string = item.catalogSourcePartId && item.catalogSourceCode ? `${item.catalogSourcePartId} / ${item.catalogSourceCode}` : ''; const reduceItemData: AnalyticData = { id: item.catalogPartNo || '', name: item.mpn || '', price: item.unitPrice || '', brand: item.manufacturer || '', category: '', variant: item.partDescription || '', list: listName || '', quantity: item.quantity || '', dimension12: sourceIdRegion, dimension13: this.getImageName(item.catalogSmallImageURL) || '', dimension14: item.availableQuantity || '', dimension17: '', dimension25: item.availableQuantity ? true : false, dimension26: item.pedigree || '', dimension27: item.warranty || '', dimension29: '', dimension34: item.shippingLeadTime === 0 || item.shippingLeadTime > 0 ? item.shippingLeadTime : '', dimension35: item.countryOfOrigin || '', dimension36: item.eccn || '', dimension37: item.rohsCompliant || '', dimension38: item.sellerId || '', dimension39: item.sellerType || '', dimension41: '', dimension42: '', dimension43: '' }; productDataArray.push(reduceItemData); }); return productDataArray; } /** * Tracking data for virtual page views (navigating to different routes) */ private mapVirtualPageView(event: string, url: string): any { return { event, virtualPage: url }; } /** * Tracking data for different types of events */ private mapEvent(event: string, data: any): any { return { event, eventframework: { category: data.category, action: data.action, label: data.label || '', value: data.value || '', bomAvailablePartPrice: data.bomAvailablePartPrice || '', bomPotentialPartPrice: data.bomPotentialPartPrice || '', bomMissedStock: data.bomMissedStock || '', bomNumberNonpurchaseableItemsAttempted: data.bomNumberNonpurchaseableItemsAttempted || '', CM15: data.CM15 || '', CM10: data.CM10 || '' } }; } /** * Tracking data for simple events */ private mapSimpleEvent(event: string, data: any): any { return { event, eventframework: { category: data.category, action: data.action, label: data.label || '' } }; } /** * Set image name to the last item on the image url. * @param imageUrl Image Url * @returns String of string after the last / (forward slash) */ private getImageName(imageUrl: string): string { let urlSplit: Array; if (!!imageUrl) urlSplit = imageUrl.split('/'); return !!imageUrl ? urlSplit[urlSplit.length - 1] : undefined; } private getCurrencyCode(): string { return this.config.isArrowcom() && this._arrowService.uikit ? this._arrowService.uikit.config.locale.currencyIsoCode : this.config.userSelectedCurrency ? this.config.userSelectedCurrency : ''; } } export enum AnalyticsEvent{ EVENT = 'EVENT', PRODUCT_LIST_IMPRESSION = 'productListImpression', ADD_TO_CART_BOM_ALL = 'addToCartBomAll', ADD_TO_CART_BOM_SELECTED = 'addToCartBomSelected', ADD_TO_CART_BOM = 'addToCartBom', PRODUCT_LIST_CLICK = 'productListClick', VIRTUAL_PAGE_VIEW = 'virtualPageView', BOM_INFO = 'BomInfo', CLICK = 'Click' }