diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index e322bb48..fb14a057 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -182,6 +182,14 @@ const endpoints = Object.freeze({ url: `${ANALYTICS_BASE_URL}/initialize`, method: 'POST' }, + LogDigitalConsumer: { + url: `${ANALYTICS_BASE_URL}/digitalconsumer-log`, + method: "POST", + }, + LogIssSessionData: { + url: `${ANALYTICS_BASE_URL}/digitalconsumer-session-logging`, + method: "POST", + }, GetExperimentsByUser: { url: `${ANALYTICS_BASE_URL}/get-experiments`, method: 'GET' diff --git a/src/helpers/recal-helper.js b/src/helpers/recal-helper.js index 8d680881..e72c2d63 100644 --- a/src/helpers/recal-helper.js +++ b/src/helpers/recal-helper.js @@ -1,4 +1,5 @@ import partTypeStrings from '@/constants/part-type-strings'; +import { deepClone } from '@/helpers/object-helper'; const recalPartTypes = [partTypeStrings.RECALIBRATION, partTypeStrings.ADAS_RECALIBRATION]; @@ -23,3 +24,31 @@ export function getTopLevelGlassPartsWithRecal(glassParts) { return glassParts.filter((gp) => isRecalPartOrHasChildRecalPart(gp)); } + +export function getItemsWithoutRecalParts(lineItemsArray) { + if (!lineItemsArray || !Array.isArray(lineItemsArray)) return null; + const firstLevelFiltered = deepClone(lineItemsArray).filter((li) => !isRecalPart(li)); + + const childrenFiltered = firstLevelFiltered.map((li) => { + if (li.childParts && li.childParts.length > 0) { + li.childParts = getItemsWithoutRecalParts(li.childParts); + } + + return li; + }); + + return childrenFiltered; +} + +export function getRecalPartNumbers(glassPartsArray) { + if (glassPartsArray && glassPartsArray.length > 0) { + const topLevel = glassPartsArray.filter((gp) => isRecalPart(gp)).map((gp) => gp.partNumber); + + const children = glassPartsArray.map((gp) => getRecalPartNumbers(gp.childParts)).flat(); + + return [...topLevel, ...children]; + } else { + return []; + } +} + diff --git a/src/helpers/session-helper.js b/src/helpers/session-helper.js index a82e1618..47185a25 100644 --- a/src/helpers/session-helper.js +++ b/src/helpers/session-helper.js @@ -47,3 +47,48 @@ export function getDateForSavedSessionTimeout() { currentDate.setDate(currentDate.getDate() + applicationConfig.SAVED_SESSION_TIMEOUT_DAYS); return currentDate.toUTCString(); } + + +/* +Function to return the iOS version of the device. +*/ + +export function getiOSversion() { + if (/iP(hone|od|ad)/.test(navigator.platform)) { + // supports iOS 2.0 and later: + var v = navigator.appVersion.match(/OS (\d+)_(\d+)_?(\d+)?/); + return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || 0, 10)]; + } +} + +/* +Function to return true/false if the user agent is a mobile device or not. +*/ + +export function isMobileDevice() { + const userAgent = navigator.userAgent; + return ( + userAgent.includes("Android") || + userAgent.includes("Mobile") || + userAgent.includes("iPod") || + userAgent.includes("iPhone") || + userAgent.includes("IEMobile") || + userAgent.includes("BlackBerry") || + userAgent.includes("webOS") + ); +} + +/* +Function to return true/false if the user is using an apple browser on a device. +*/ + +export function isAppleBrowser() { + const userAgent = navigator.userAgent; + return ( + userAgent.includes("iPod") || + userAgent.includes("iPad") || + userAgent.includes("iPhone") || + userAgent.includes("Mac") + ); +} + diff --git a/src/helpers/useragent-helper.js b/src/helpers/useragent-helper.js new file mode 100644 index 00000000..d1db7efe --- /dev/null +++ b/src/helpers/useragent-helper.js @@ -0,0 +1,42 @@ +/* +Function to return the iOS version of the device. +*/ + +export function getiOSversion() { + if (/iP(hone|od|ad)/.test(navigator.platform)) { + // supports iOS 2.0 and later: + var v = navigator.appVersion.match(/OS (\d+)_(\d+)_?(\d+)?/); + return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || 0, 10)]; + } +} + +/* +Function to return true/false if the user agent is a mobile device or not. +*/ + +export function isMobileDevice() { + const userAgent = navigator.userAgent; + return ( + userAgent.includes("Android") || + userAgent.includes("Mobile") || + userAgent.includes("iPod") || + userAgent.includes("iPhone") || + userAgent.includes("IEMobile") || + userAgent.includes("BlackBerry") || + userAgent.includes("webOS") + ); +} + +/* +Function to return true/false if the user is using an apple browser on a device. +*/ + +export function isAppleBrowser() { + const userAgent = navigator.userAgent; + return ( + userAgent.includes("iPod") || + userAgent.includes("iPad") || + userAgent.includes("iPhone") || + userAgent.includes("Mac") + ); +} \ No newline at end of file diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 297bb8e2..b3ef198d 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -20,6 +20,11 @@ import { GaEvents, ValueToLogTypes } from '@/constants/analytics'; +import { getCartTotal, getSubtotal } from '@/helpers/cart-helper'; +import { getRecalPartNumbers } from "@/helpers/recal-helper"; +import coverageStatuses from '@/constants/coverage-statuses'; +import coverageType from '@/constants/coverage-type'; +import issPageValues from '@/router/router-constants/issPage-values'; import { useMainStore } from '@/store'; function pushToDataLayerIfDefined(data) { @@ -161,6 +166,184 @@ export default { return baseMethod.apply(object, arguments); }; }, + + logDigitalConsumer() { + const store = useMainStore(); + const currentPageName = this.getPageNameByQueryString(); + const universes = store.applicationUser.experiments; + const deviceId = getDeviceIdValue(); + const sid = getSessionIdValue(); + + const variationNames = universes + .filter((item) => item.universeName === experimentSettings.CONCEPT_FUNNEL) + .map((item) => item.variationName) + .filter(Boolean); // removes undefined/null + + const conceptVariation = variationNames.length > 0 ? variationNames[0] : ""; + + const isConceptExposed = universes.find( + (item) => item.universeName === experimentSettings.CONCEPT_FUNNEL + )?.isExposed; + + const submittedOrder = store.getSubmittedOrder(); + const hasSubmittedOrder = store.hasSubmittedOrder(); + + // TODO: Find equivalent ISS methods for these: + const hasSubmittedOrderAtConfirmationPage = + hasSubmittedOrder && currentPageName?.toLowerCase() === issPageValues.ORDER_CONFIRMATION; + + var payload = { + deviceId: deviceId, + sessionId: sid, + actionName: `Browser page:${currentPageName}`, + referralSequenceNumber: hasSubmittedOrderAtConfirmationPage + ? submittedOrder.referralSequenceNumber + : store.order.referralSequenceNumber, + referralNumber: hasSubmittedOrderAtConfirmationPage + ? submittedOrder.referralNumber + : store.order.referralNumber, + workOrderId: hasSubmittedOrderAtConfirmationPage + ? submittedOrder.workOrderId + : store.order.workOrderId, + workOrderNumber: hasSubmittedOrderAtConfirmationPage + ? submittedOrder.workOrderNumber + : store.order.workOrderNumber, + conceptVariation: conceptVariation, + isConceptExposed: isConceptExposed, + }; + + store.logDigitalConsumer(payload); + }, + + // This sends session data to the session logging endpoint on the analytics service. + // From there, it uses aws kinesis data stream, DigitalConsumer-Session-Data-Stream, + // and aws FireHose stream, DigitalConsumer-Session-Firehose, to push data to an + // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. + // This bucket data is then picked up by snowflake for analytics use. + pushIssSessionData() { + const store = useMainStore(); + const submittedOrder = store.getSubmittedOrder(); + const hasSubmittedOrder = store.hasSubmittedOrder(); + const order = hasSubmittedOrder ? submittedOrder : store.order; + const issConfig = store.issConfig; + const applicationUser = store.applicationUser; + + // NOTE: ISS does not support early bird times. + const isEarlyBird = false; /*order?.lineItems?.supportingItems?.find( + (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD + ); + */ + + // NOTE: No promo codes on ISS at the moment. + const promoCodes = ""; /*Array.isArray(order?.lineItems?.promos) + ? order?.lineItems?.promos + .map((item) => item.promoCode) + .filter((code) => code) + .join(", ") + : null; + */ + + const glassProducts = order?.damage?.glassToReplace?.map( + (part) => `${part.glassLocation}-${part.glassName}` + ); + + const format = (part) => `${part?.partNumber}-${part?.partType ? part.partType : ""}`; + const mapParts = (arr) => (arr ?? []).map(format); + + const glassParts = mapParts(order?.lineItems?.glassParts); + const supportingItems = mapParts(order?.lineItems?.supportingItems); + const vaps = mapParts(order?.lineItems?.vaps); + const childParts = [].concat( + ...(order?.lineItems?.glassParts ?? []).map((gp) => mapParts(gp?.childParts)) + ); + const allParts = [...glassParts, ...supportingItems, ...vaps, ...childParts]; + + if (applicationUser?.pageData?.quote?.servicePackageSelected) { + allParts.push(applicationUser.pageData.quote.servicePackageSelected); + } + + var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`; + var currentPageName = this.getPageNameByQueryString(); +/* + // add query strings to the page name for debugging. on the vehicle page, if from an external link, pull it from the stash + if (currentPageName === "vehicle") { + if (!window.location.search) { + if (store.getters.externalParameterState?.qsStash) { + currentPageName += `${store.getters.externalParameterState.qsStash}`; + } + } else { + currentPageName += `${window.location.search}`; + } + } else { + if (window.location.search) { + currentPageName += `${window.location.search}`; + } + } +*/ + var parentAccountNumber = (order?.parentAccountNumber ?? ( issConfig.parentAccountNumber ?? 0)).toString(); + + var sessionData = {}; + sessionData.currentPage = currentPageName; + sessionData.sid = getSessionIdValue(); + sessionData.deviceId = getDeviceIdValue(); + sessionData.issSessionId = applicationUser?.savedSessionId; + sessionData.skey = getSessionKeyValue().toString(); + sessionData.userId = getUserIdValue(); + sessionData.carId = order?.vehicle?.carId; + sessionData.vehicleYear = order?.vehicle?.year; + sessionData.vehicleMake = order?.vehicle?.make; + sessionData.vehicleModel = order?.vehicle?.model; + sessionData.vehicleStyle = order?.vehicle?.style; + sessionData.hasVin = order?.vehicle?.vin ? true : false; + sessionData.cashOrInsuranceAccountType = "Insurance"; + sessionData.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; + sessionData.productType = allParts; + sessionData.eon = order?.eon; + sessionData.referralNumber = order?.referralNumber; + sessionData.referralSequenceNumber = order?.referralSequenceNumber; + sessionData.referralDate = order?.referralDate; + sessionData.workOrderNumber = order?.workOrderNumber; + sessionData.workOrderId = order?.workOrderId; + + sessionData.isPia = store.isPayInAdvance; + sessionData.piaType = (store.isPayInAdvance ? order?.payment?.paymentMethod : null); + + sessionData.parentAccountNumber = parentAccountNumber; + sessionData.billToAccountNumber = issConfig.billToAccountNumber; + sessionData.settledTenderAmount = order?.payment?.nextGenSettledAmount; + sessionData.recalRequired = store.hasRecalibrationPart; + sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); + + sessionData.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode + ? order?.serviceLocation?.provider?.address?.zipCode + : order?.serviceLocation?.zipCode; + sessionData.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu + ? order?.serviceLocation?.provider?.address?.zipCodeCtu + : order?.serviceLocation?.zipCodeCtu; + sessionData.appointmentDate = appointment; + sessionData.serviceType = order?.serviceLocation?.appointmentType; + sessionData.promoCodes = promoCodes; + sessionData.hasTechnicianNotes = order?.contactInfo?.notesForTechnician ? true : false; + sessionData.paymentMethod = order?.payment?.paymentMethod; + sessionData.isTextingOptedIn = order?.contactInfo?.requestTextUpdates ? true : false; + sessionData.isEarlyBird = isEarlyBird; + + sessionData.insuranceCo = issConfig.clientName; + sessionData.deductible = store.currentDeductible ?? 0; + sessionData.isVerified = store.isVerified; + sessionData.coverageStatus = coverageStatuses.mapToApi(order?.insuranceCoverage.coverageStatus); + sessionData.coverageSubStatus = coverageType.mapToApi(order?.insuranceCoverage.coverageType); + + sessionData.isNoComp = store.isNoComp; + sessionData.isItac = store.isITAC; + sessionData.subTotalPrice = getSubtotal(order).toString(); + sessionData.totalPrice = getCartTotal(order).toString(); + sessionData.cashPriceSubTotal = getSubtotal(order).toString(); + + sessionData.userAgent = navigator.userAgent; + + store.logIssSessionData(sessionData); + }, async initSession() { regenerateDeviceId(); diff --git a/src/router/index.js b/src/router/index.js index c6a18493..eeca615e 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -178,6 +178,12 @@ router.afterEach(async (to, from) => { if (to.query.issPage !== issPageValues.ENTRY_PAGE) { document.title = routerTitles[to.query.issPage] || 'Safelite Solutions®'; + // digital consumer logging + analyticsMixin.methods.logDigitalConsumer(); + + // digital consumer ISS session logging to snowflake + analyticsMixin.methods.pushIssSessionData(); + // Push page view to GA analyticsMixin.methods.pushPageViewToGA(); diff --git a/src/store/index.js b/src/store/index.js index 4321a8f4..8a4f1b34 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -24,6 +24,7 @@ import { import { buildURLSearchParams, getPartNumbersListForQueryString } from '@/helpers/querystring-helper'; import { getTopLevelGlassPartsWithRecal } from '@/helpers/recal-helper'; import { getDateForSavedSessionTimeout } from '@/helpers/session-helper'; +import { isMobileDevice } from '@/helpers/useragent-helper'; import issPageValues from '@/router/router-constants/issPage-values'; import CoverageStatuses from '@/constants/coverage-statuses'; @@ -1396,6 +1397,7 @@ export const useMainStore = defineStore({ this.order.referralDate = response.referralDate; this.order.referralCorrelationId = response.referralCorrelationId; this.order.eon = response.eon; + this.order.workOrderId = response.workOrderId; this.order.workOrderNumber = response.workOrderNumber; this.order.settledTenderAmount = response.settledTenderAmount; this.order.lockToken = response.lockToken; @@ -2625,6 +2627,166 @@ export const useMainStore = defineStore({ updateTriggeredSiteEntry(wasSiteEntryTriggered) { this.applicationUser.triggeredSiteEntry = wasSiteEntryTriggered; }, + + // TODO: Shouldn't this be async? + logDigitalConsumer( + { + deviceId, + sessionId, + actionName, + referralSequenceNumber, + referralNumber, + workOrderId, + workOrderNumber, + conceptVariation, + isConceptExposed, + } + ) { + var payload = { + sessionId: sessionId, + deviceId: deviceId, + actionName: actionName ?? "", + referralSequenceNumber: referralSequenceNumber ?? "", + referralNumber: referralNumber ?? "", + applicationName: isMobileDevice() ? "ISS Mobile" : "ISS", + workOrderId: workOrderId ?? "", + workOrderNumber: workOrderNumber ?? "", + conceptVariation: conceptVariation, + isConceptExposed: isConceptExposed, + }; + + return globalMethods.callHttpClient({ + method: endpoints.LogDigitalConsumer.method, + endpoint: endpoints.LogDigitalConsumer.url, + payload, + logApiCall: false, + bailoutOnError: false + }).then( + (response) => response, + (error) => { + console.log(`Analytics Service Error: ${error.data}`); + } + ); + }, + + // TODO: Shouldn't this be async? + logIssSessionData( + { + currentPage, + sid, + deviceId, + issSessionId, + skey, + userId, + carId, + vehicleYear, + vehicleMake, + vehicleModel, + vehicleStyle, + hasVin, + cashOrInsuranceAccountType, + damageType, + productType, + eon, + referralNumber, + referralSequenceNumber, + referralDate, + workOrderNumber, + workOrderId, + isPia, + piaType, + parentAccountNumber, + billToAccountNumber, + settledTenderAmount, + recalRequired, + recalType, + serviceZipCode, + providerCtu, + appointmentDate, + serviceType, + promoCodes, + hasTechnicianNotes, + paymentMethod, + isTextingOptedIn, + isEarlyBird, + insuranceCo, + deductible, + isVerified, + coverageStatus, + coverageSubStatus, + isNoComp, + isItac, + subTotalPrice, + totalPrice, + userAgent, + cashPriceSubTotal, + } + ) { + var payload = { + currentPage: currentPage, + sid: sid, + deviceId: deviceId, + fmgSessionId: issSessionId, + skey: skey, + userId: userId, + carId: carId, + vehicleYear: vehicleYear, + vehicleMake: vehicleMake, + vehicleModel: vehicleModel, + vehicleStyle: vehicleStyle, + hasVin: hasVin, + cashOrInsuranceAccountType: cashOrInsuranceAccountType, + isVerified: isVerified, + coverageStatus: coverageStatus, + coverageSubStatus: coverageSubStatus, + damageType: damageType, + productType: productType, + eon: eon, + referralNumber: referralNumber, + referralSequenceNumber: referralSequenceNumber, + referralDate: referralDate, + workOrderNumber: workOrderNumber, + workOrderId: workOrderId, + isPia: isPia, + piaType: piaType, + parentAccountNumber: parentAccountNumber, + settledTenderAmount: settledTenderAmount, + recalRequired: recalRequired, + recalType: recalType, + serviceZipCode: serviceZipCode, + providerCtu: providerCtu, + appointmentDate: appointmentDate, + serviceType: serviceType, + promoCodes: promoCodes, + hasTechnicianNotes: hasTechnicianNotes, + paymentMethod: paymentMethod, + isTextingOptedIn: isTextingOptedIn, + isEarlyBird: isEarlyBird, + insuranceCo: insuranceCo, + deductible: deductible, + isNoComp: isNoComp, + isItac: isItac, + subTotalPrice: subTotalPrice, + totalPrice: totalPrice, + userAgent: userAgent, + cashPriceSubTotal: cashPriceSubTotal, + billToAccountNumber: billToAccountNumber, + }; + + return globalMethods.callHttpClient({ + method: endpoints.LogIssSessionData.method, + endpoint: endpoints.LogIssSessionData.url, + payload, + logApiCall: false, + bailoutOnError: false + }).then( + (response) => response, + (error) => { + console.log(`Analytics Service Error: ${error.data}`); + } + ); + }, + updateContactInfo(contactInfo) { this.order.customer.firstName = contactInfo?.firstName ?? this.order.customer.firstName;