Merge branch 'develop' into feature/SSR-113

This commit is contained in:
Kulbhushan Kaushik 2022-11-14 08:05:22 -05:00
commit 2c215e23a9
27 changed files with 16582 additions and 106 deletions

15261
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="">
<html lang="en-US">
<head>
<script>
window.dataLayer = [{}];

View file

@ -13,4 +13,4 @@
@import "@/styles/common-typography-styles.scss";
@import "@/styles/common-error-styles.scss";
@import "@/styles/common-animations.scss";
</style>
</style>

View file

@ -0,0 +1,36 @@
const analyticsPageEvents = {
ENTRY: "ENTRY",
EVENT: "EVENT",
};
// GA Constants
const GaEvents = {
GENERIC_EVENT: "event",
PAGE_VIEW_EVENT: "logPageview",
};
const GaCategories = {
API_RESPONSE: "Api_Response",
EVOX: "Evox",
};
const GaActions = {
RESULT: "Result",
CLICKED: "Clicked",
VIF: "vif",
SUBMITTED: "Submitted",
};
const GaLabels = {
SUCCESS: "Success",
ERROR: "Error",
LICENSE_PLATE_LOOKUP: "License_Plate_Look_Up",
VIN_LOOKUP: "Vin_Look_Up",
ADDRESS_LOOKUP: "Address_Look_up",
};
const ValueToLogTypes = {
LAST_5: "last_5",
};
export { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents, ValueToLogTypes };

View file

@ -1,6 +1,9 @@
const applicationConfig = {
CURRENT_ENVIRONMENT: process.env.VUE_APP_CURRENT_ENVIRONMENT, // "Localhost", "Dev", "QA", and "Prod"
CONSUMER_CF_DISTRO: process.env.VUE_APP_CONSUMER_CF_DISTRO,
ANALYTICS_SESSION_TIMEOUT_MINUTES: 30,
SAVED_SESSION_TIMEOUT_DAYS: 45,
COOKIE_PATH: "/",
APPLICATION_NAME: "SelfService",
SITE_ENTRY_TRIGGER_VALUE: "SelfService",
APPLICATION_ABBREVIATION: "iss",

View file

@ -0,0 +1,12 @@
import { applicationConfig } from "@/constants/application-config.js";
const cookieNames = {
ISS_SESSION_INFO: `ISSSessionInfo-${applicationConfig.CURRENT_ENVIRONMENT}`,
// Existing Safelite.com cookies
DXDEV: "dxdev",
SESSION_ID: "sid",
SESSION_KEY: "skey",
};
export { cookieNames };

View file

@ -31,6 +31,30 @@ const endpoints = {
url: "/parts/api/v1/parts/damage-options",
method: "GET",
},
LogExperimentExposureIfAssigned: {
url: "/experiments/api/v1/experiments/log-exposure",
method: "POST",
},
LogPageView: {
url: "/analytics/api/v1/analytics/log-page-view",
method: "POST",
},
LogCustomEvent: {
url: "/analytics/api/v1/analytics/log-custom-event",
method: "POST",
},
InitializeSession: {
url: "/analytics/api/v1/analytics/initialize",
method: "POST",
},
GetExperimentsByUser: {
url: "/analytics/api/v1/analytics/get-experiments",
method: "GET",
},
RunExperimentsForTrigger: {
url: "/experiments/api/v1/experiments/run",
method: "POST",
},
};
export { endpoints };

View file

@ -0,0 +1,14 @@
const experimentUniverses = {
ISS_FUNNEL: "ISSFunnel",
};
const experimentSettings = {
GOOGLE_CUSTOM_DIMENSION_INDEX: "Google Custom Dimension Index",
};
const experimentTriggers = {
SITE_ENTRY: "SiteEntry",
PAGE_ENTRY: "PageEntry",
};
export { experimentUniverses, experimentSettings, experimentTriggers };

View file

@ -0,0 +1,3 @@
export const headerKeys = {
EXPERIMENT: "X-Experiment-Data",
};

View file

@ -1,9 +0,0 @@
const storeActions = {
// Content Actions
GET_ROUTE_INFO_ACTION: "getRouteInfo",
GET_HOMEPAGE_NAME: "getHomepageName",
GET_PAGE_DATA: "getPageData",
};
export { storeActions };

View file

@ -1,14 +1,39 @@
import axios from "axios";
import analyticsMixIn from "@/mixins/analytics-mixin.js";
import { useMainStore } from "@/store";
import { applicationConfig } from "@/constants/application-config.js";
import { GaCategories, GaActions, GaLabels } from "@/constants/analytics";
import { headerKeys } from "@/constants/header-keys";
export default {
callHttpClient({ method, endpoint, payload}) {
callHttpClient({ method, endpoint, payload, logApiCall = true}) {
return new Promise((resolve, reject) => {
const store = useMainStore();
const cfDistroUrl = applicationConfig.CONSUMER_CF_DISTRO;
const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "SelfService" });
const headers = {
[headerKeys.EXPERIMENT]: JSON.stringify(store.experimentSettings),
};
axios({ method: method, url: cfDistroUrl + endpoint, data: payloadAndAnalyticsData, crossDomain: true, responseType: {}})
axios({
method: method,
url: cfDistroUrl + endpoint,
data: payloadAndAnalyticsData,
crossDomain: true,
responseType: {},
headers: headers,
})
.then((response) => {
if (logApiCall) {
analyticsMixIn.methods.pushEventToGA(
GaCategories.API_RESPONSE,
GaActions.RESULT,
`${GaLabels.SUCCESS}_${endpoint}`,
true
);
}
return resolve(response);
},
error => {
@ -22,7 +47,12 @@ export default {
//used for mocked services
async mockCallHttpClient(method, endpoint) {
return new Promise((resolve, reject) => {
axios({ method: method, url: endpoint, crossDomain: true, responseType: {}})
axios({
method: method,
url: endpoint,
crossDomain: true,
responseType: {}
})
.then((response) => {
return resolve(response);
},

View file

@ -1,8 +1,11 @@
import axios from 'axios';
import globalMethods from "@/global-methods";
import analyticsMixIn from "@/mixins/analytics-mixin";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
//Mock external dependencies
jest.mock("axios");
jest.mock("@/mixins/analytics-mixin");
it("Global Methods - Call Http Client - Should Resolve Promise", () => {
//Arrange
@ -25,6 +28,7 @@ it("Global Methods - Call Http Client - Should Resolve Promise", () => {
endpoint: endpoint,
isError: true,
});
analyticsMixIn.methods.pushEventToGA = jest.fn();
//Act
globalMethods.callHttpClient(httpArgs).catch((err) => {
@ -42,6 +46,8 @@ it("Global Methods - Call Http Client - Should Resolve Promise", () => {
}) {
//Clear node module
axios.mockClear();
const mountOptions = getMountOptions();
// Success Response
const response = {

View file

@ -0,0 +1,205 @@
import { cookieNames } from "@/constants/cookie-names";
import { applicationConfig } from "@/constants/application-config";
import { useMainStore } from "@/store";
/*
Will update the cookie if present, or create a new one if not.
*/
export function updateOrCreateISSCookie() {
const store = useMainStore();
// Set up cookie with all the props.
setISSCookieProperties({
LastTouched: new Date().toUTCString(),
SavedSessionTimeoutDate: store.applicationUser.savedSessionTimeout,
ShouldResetState: false,
ReferralNumber: store.order.referralNumber,
ReferralDate: store.order.referralDate,
ReferralCorrelationId: store.order.referralCorrelationId,
ReferralParentAccountNumber: store.order.accountNumber,
});
}
/*
Gets the current instance of the ISS cookie.
Returns null if cookie isn't valid JSON.
*/
export function getISSCookie() {
const cookieJson = document.cookie
?.split("; ")
?.find((row) => row.startsWith(`${cookieNames.ISS_SESSION_INFO}=`))
?.split("=")[1];
try {
return JSON.parse(cookieJson);
} catch (error) {
return null;
}
}
/*
Removes cookie from browser.
*/
export function deleteISSCookie() {
createOrUpdateCookie(cookieNames.ISS_SESSION_INFO, undefined, { maxAge: 0 });
}
/*
Gets cookie domain value. Localhost will be empty "".
*/
export function getCookieDomainValue() {
return isLocalhost() ? "" : `domain=${getDomainWithoutSubdomain()};`;
}
/*
Gets value of dxdev cookie, and then extracts "did" value from it.
Returns empty string if cookie not found or "did" string not present.
*/
export function getDeviceIdValue() {
// Sometimes these cookie contains more than the device ID.
const cookieValue = getCookieValueByName(cookieNames.DXDEV);
const cookieValuesSplit = cookieValue.split("=");
// If this is the only value, just use that.
if (cookieValuesSplit.length === 2 && cookieValuesSplit[0] === "did") {
return cookieValuesSplit[1];
}
const cookieValueMatch = cookieValue.match("^did=[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}");
if (cookieValueMatch) {
return cookieValueMatch[0].split("=")[1];
}
return "00000000-0000-0000-0000-000000000000";
}
/*
Gets value of skey cookie, returns 0 if not found.
*/
export function getSessionKeyValue() {
const cookieValue = getCookieValueByName(cookieNames.SESSION_KEY);
if (cookieValue) {
return cookieValue;
}
return 0;
}
/*
Gets value of skey cookie, returns 0 if not found.
*/
export function getSessionIdValue() {
const cookieValue = getCookieValueByName(cookieNames.SESSION_ID);
if (cookieValue) {
return cookieValue;
}
return "00000000-0000-0000-0000-000000000000";
}
/*
Updates session ID cookie with new expiration date
*/
export function updateSessionIdCookie() {
createOrUpdateCookie(cookieNames.SESSION_ID, getSessionIdValue(), { maxAge: 60 * 30 });
}
export function setCookieProperties(
properties,
{ useDefaultISSCookieAttributes = true, maxAge, isSecure }
) {
if (typeof properties == "object") {
Object.keys(properties).forEach((key) => {
createOrUpdateCookie(key, properties[key], {
useDefaultISSCookieAttributes,
maxAge,
isSecure,
});
});
}
}
/*
===========================
= PRIVATE FUNCTIONS =
===========================
*/
/*
Used to set properties on the ISS cookie.
Takes an object with properties to set. Will overwrite existing properties.
*/
function setISSCookieProperties(properties) {
if (typeof properties == "object") {
let cookie = getISSCookie();
if (cookie !== null) {
Object.keys(properties).forEach((key) => {
cookie[key] = properties[key];
});
}
const cookieValueJson = JSON.stringify(cookie ?? {});
createOrUpdateCookie(cookieNames.ISS_SESSION_INFO, cookieValueJson, {});
}
}
/*
Used to create a cookie.
`useDefaultISSCookieAttributes` will set the path and domain to our defaults
*/
function createOrUpdateCookie(
key,
value = "",
{ useDefaultISSCookieAttributes = true, maxAge, isSecure = true }
) {
let cookieToAdd = `${key}=${value}; `;
if (useDefaultISSCookieAttributes) {
cookieToAdd += `path=${applicationConfig.COOKIE_PATH}; ${getCookieDomainValue()} `;
}
if (isSecure && !isLocalhost()) {
cookieToAdd += `secure; `;
}
if (!isNaN(maxAge)) {
cookieToAdd += `max-age=${maxAge};`;
}
document.cookie = cookieToAdd;
}
/*
Gets current domain without the subdomain for cookie.
*/
function getDomainWithoutSubdomain() {
let url = location.hostname;
if (isLocalhost()) {
return "localhost";
}
const urlParts = url.split(".");
return `.${urlParts
.slice(0)
.slice(-(urlParts.length === 4 ? 3 : 2))
.join(".")}`;
}
/*
Gets cookie value by name, returns empty string if not found.
*/
function getCookieValueByName(name) {
const value = "; " + document.cookie;
const parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function isLocalhost() {
return location.hostname.includes("localhost");
}

View file

@ -0,0 +1,48 @@
import { applicationConfig } from "@/constants/application-config";
import { getISSCookie } from "@/helpers/cookie-helper.js";
/*
Method to determine if our analytics session has timed out or not.
Amount used for timeout is configurable in application-config.js
*/
export function isAnalyticsSessionStillActive() {
if (getISSCookie() !== null) {
const lastTouchedValue = getISSCookie().LastTouched;
const timeoutAmount = applicationConfig.ANALYTICS_SESSION_TIMEOUT_MINUTES;
const isMoreThanHalfHourAgo =
(new Date() - new Date(lastTouchedValue)) / 60000 > timeoutAmount;
if (isMoreThanHalfHourAgo) {
return false;
}
return true;
}
}
/*
Method to determine if the users 'saved' session is still active.
When user state is created, there is a date that is saved into state
this method checks against that date.
Note: That time for saved session timeout is configurable in application-config.js
*/
export function isSavedSessionStillActive() {
if (getISSCookie() !== null) {
const savedSessionTimeStamp = new Date(getISSCookie().SavedSessionTimeoutDate);
const isSavedSessionTimedOut = new Date(new Date().toUTCString()) > savedSessionTimeStamp;
return !isSavedSessionTimedOut;
}
}
/*
Function to calculate the date for the saved session timeout.
*/
export function getDateForSavedSessionTimeout() {
const currentDate = new Date(new Date().toUTCString());
currentDate.setDate(currentDate.getDate() + applicationConfig.SAVED_SESSION_TIMEOUT_DAYS);
return currentDate.toUTCString();
}

View file

@ -0,0 +1,89 @@
import * as cookieHelper from "@/helpers/cookie-helper";
import {
isAnalyticsSessionStillActive,
isSavedSessionStillActive,
getDateForSavedSessionTimeout,
} from "@/helpers/session-helper";
import { applicationConfig } from "@/constants/application-config";
describe("isAnalyticsSessionStillActive", () => {
test("isAnalyticsSessionStillActive, should return true", () => {
// Arrange
const mockDate = new Date(new Date().toUTCString());
mockDate.setDate(mockDate.getDate() + 1);
cookieHelper.getISSCookie = jest
.spyOn(cookieHelper, "getISSCookie")
.mockReturnValue({ LastTouched: mockDate });
// Act
const result = isAnalyticsSessionStillActive();
// Assert
expect(result).toBe(true);
});
test("isAnalyticsSessionStillActive, should return false", () => {
// Arrange
const mockDate = new Date(new Date().toUTCString());
mockDate.setDate(mockDate.getDate() - 1);
cookieHelper.getISSCookie = jest
.spyOn(cookieHelper, "getISSCookie")
.mockReturnValue({ LastTouched: mockDate });
// Act
const result = isAnalyticsSessionStillActive();
// Assert
expect(result).toBe(false);
});
});
describe("isSavedSessionStillActive", () => {
test("isSavedSessionStillActive, should return true", () => {
// Arrange
const mockDate = new Date(new Date().toUTCString());
mockDate.setDate(mockDate.getDate() + 1);
cookieHelper.getISSCookie = jest
.spyOn(cookieHelper, "getISSCookie")
.mockReturnValue({ SavedSessionTimeoutDate: mockDate });
// Act
const result = isSavedSessionStillActive();
// Assert
expect(result).toBe(true);
});
test("isSavedSessionStillActive, should return false", () => {
// Arrange
const mockDate = new Date(new Date().toUTCString());
mockDate.setDate(mockDate.getDate() - 1);
cookieHelper.getISSCookie = jest
.spyOn(cookieHelper, "getISSCookie")
.mockReturnValue({ SavedSessionTimeoutDate: mockDate });
// Act
const result = isSavedSessionStillActive();
// Assert
expect(result).toBe(false);
});
});
describe("getDateForSavedSessionTimeout", () => {
test("getDateForSavedSessionTimeout, should equal application config setting", () => {
// Arrange
const currentDate = new Date(new Date().toUTCString());
currentDate.setDate(currentDate.getDate() + applicationConfig.SAVED_SESSION_TIMEOUT_DAYS);
// Act
const result = getDateForSavedSessionTimeout();
// Assert
expect(result).toEqual(currentDate.toUTCString());
});
});

View file

@ -1,8 +1,22 @@
import { storeActions } from "@/constants/store-actions";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios.js";
import { vehicleCategories } from "@/constants/vehicle-categories.js";
import { issPageValues } from "@/router/router-constants/issPage-values";
import { cookieNames } from "@/constants/cookie-names";
import { Form } from "vee-validate";
import baseMixin from "@/mixins/base-mixin";
import {
getCookieDomainValue,
setCookieProperties,
} from "@/helpers/cookie-helper";
import {
analyticsPageEvents,
GaCategories,
GaActions,
GaLabels,
GaEvents,
ValueToLogTypes,
} from "@/constants/analytics";
import { routerParams } from "@/router/router-constants/router-params";
import { queryStrings } from "@/constants/query-strings";
import { useMainStore } from "@/store";
import { mapStores } from "pinia";
@ -20,27 +34,142 @@ const mockMixin = {
}
};
// Common methods
export function getMountOptions(mockData) {
// Define our mocks to attached to the 'global' object for Vue/Jest.
const mocks = {};
// Mock const files
mocks.storeActions = storeActions;
mocks.navigationScenarios = navigationScenarios;
mocks.vehicleCategories = vehicleCategories;
mocks.issPageValues = issPageValues;
mocks.queryStrings = queryStrings;
mocks.$router = mockData?.router;
const global = {
mocks: mocks,
mixins: [mockMixin],
plugins: [pinia],
stubs: { Form }
};
return { global };
// Define our mocks to attached to the 'global' object for Vue/Jest.
const mocks = {};
// Mock const files
mocks.navigationScenarios = navigationScenarios;
mocks.vehicleCategories = vehicleCategories;
mocks.issPageValues = issPageValues;
mocks.queryStrings = queryStrings;
mocks.$router = mockData?.router;
const global = {
mocks: mocks,
mixins: [mockMixin],
plugins: [pinia],
stubs: { Form }
};
return { global };
}
// Heritage integration common methods
export const cookies = {
[cookieNames.ISS_SESSION_INFO]: `{"ReferralNumber":"1566818","ReferralDate":"2022-03-15T10:56:24.597","ReferralCorrelationId":"404d2b04-f86e-45c3-b373-127b6217b060","ShouldResetState":false}`,
UNIQUE_SESSION_ID: "33756020-b58e-4ec7-b8b8-3f1576719c40",
anotherCookie: "{}",
someOtherCookie: "{}",
dxdev: "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe",
sid: "cba0c3d1-3c1b-4305-bb56-31aa50f58e27",
skey: "12345",
};
export function setupCookies({ ISSCookieValue = "", includeHeritageCookie = true }) {
Object.keys(cookies).forEach((key) => {
const cookieValue =
key == cookieNames.ISS_SESSION_INFO ? ISSCookieValue : cookies[key];
if (includeHeritageCookie || key != cookieNames.ISS_SESSION_INFO)
setCookieProperties({ [key]: cookieValue }, { isSecure: false });
});
}
// Removes test cookies for testing cookie-helper and order-helper
export function removeAllTestCookies() {
Object.keys(cookies).forEach((key) => {
document.cookie = `${key}=;Max-Age=0;`;
document.cookie = `${key}=;Max-Age=0;path=/;${getCookieDomainValue()}`;
});
}
export function setupMocksForJsFiles() {
return { baseMixin };
}
// Private methods
/*
// Common methods
export function getMountOptions(mockData) {
// Define our mocks to attached to the 'global' object for Vue/Jest.
const mocks = {};
//this is mocking if you use the mixin directly(baseMixin.methods.dispatchStoreAction) vs this.dispatchStoreAction
setupBaseMixinDispatchStoreAction(mockData);
mocks.pushEventToGA = jest.fn();
mocks.pushPageViewToGA = jest.fn();
mocks.logEvent = jest.fn();
mocks.pushExperimentsToDataLayer = jest.fn();
mocks.prependActionToMethod = jest.fn();
mocks.dispatchStoreAction = jest.fn();
mocks.dispatchStoreAction.mockImplementation((actionName) => {
let actionFilterResult = mockData.actionList?.filter((x) => x.actionName == actionName);
if (actionFilterResult?.length === 1) {
return Promise.resolve({
data: actionFilterResult[0].data,
});
}
});
// Mock const files
mocks.storeActions = storeActions;
mocks.storeMutations = storeMutations;
mocks.navigationScenarios = navigationScenarios;
mocks.vehicleCategories = vehicleCategories;
mocks.issPageValues = issPageValues;
mocks.analyticsPageEvents = analyticsPageEvents;
mocks.GaCategories = GaCategories;
mocks.GaActions = GaActions;
mocks.GaLabels = GaLabels;
mocks.GaEvents = GaEvents;
mocks.ValueToLogTypes = ValueToLogTypes;
mocks.queryStrings = queryStrings;
mocks.routerParams = routerParams;
// Mock $store and $router when accessing this.$store/$router
mocks.$store = mockData.store;
mocks.$router = mockData.router;
mocks.$route = mockData.route;
mocks.$loadScript = mockData.loadScript;
const global = {
mocks: mocks,
mixins: mockData.mixins,
plugins: [pinia],
stubs: { Form }
};
return { global };
}
export function getMockOrderInfo(
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
accountNumber = "0",
savedSessionId,
crmCustomerId
) {
return {
referralNumber: mockReferralNumber,
referralCorrelationId: mockCorrelationId,
referralDate: mockReferralDate,
accountNumber: accountNumber,
savedSessionId: savedSessionId,
crmCustomerId: crmCustomerId,
};
}
*/

View file

@ -30,7 +30,6 @@ import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
export default {
name: "vehicle-style",
data() {

View file

@ -8,6 +8,10 @@ import baseMixin from "@/mixins/base-mixin.js";
import { createPinia } from 'pinia';
import Maska from "maska";
import analyticsMixin from "@/mixins/analytics-mixin.js";
import experimentMixin from "@/mixins/experiment-mixin.js";
// Vue App Setup
const vueApp = createApp(App);
// Pinia
@ -16,14 +20,12 @@ vueApp.use(pinia);
pinia.use(piniaPluginPersistedstate);
useMainStore().populateInitialState();
// Additional Vue items to setup
vueApp.mixin(baseMixin);
vueApp.use(Maska);
vueApp.use(router);
vueApp.use(Maska);
vueApp.mixin(baseMixin);
vueApp.mixin(analyticsMixin);
vueApp.mixin(experimentMixin);
vueApp.mount("#app");

View file

@ -0,0 +1,205 @@
import {
setCookieProperties,
getDeviceIdValue,
getSessionIdValue,
getSessionKeyValue,
} from "@/helpers/cookie-helper";
import { queryStrings } from "@/constants/query-strings";
import { experimentSettings } from "@/constants/experiments";
import {
analyticsPageEvents,
GaCategories,
GaActions,
GaLabels,
GaEvents,
ValueToLogTypes,
} from "@/constants/analytics";
import { cookieNames } from "@/constants/cookie-names";
import { useMainStore } from "@/store";
export default {
methods: {
logPageView(pageEvent) {
const currentPageName = getPageNameByQueryString();
var payload = {
userId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: currentPageName,
sessionId: getSessionIdValue(),
action: "",
event: pageEvent,
shouldUseSessionId: false,
experimentsForUser: useMainStore().applicationUser.experiments,
};
useMainStore().logPageView(payload);
},
logCustomEvent(category, action, label, value) {
const currentPageName = getPageNameByQueryString();
var payload = {
userId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: currentPageName,
sessionId: getSessionIdValue(),
category: category,
action: action,
label: label,
value: value,
shouldUseSessionId: false,
experimentsForUser: useMainStore().applicationUser.experiments,
};
useMainStore().logCustomEvent(payload);
},
pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) {
const currentPageName = getPageNameByQueryString();
const labelToLog = getValueToLog(label, valueToLogType);
const eventToBePushed = {
event: GaEvents.GENERIC_EVENT,
category: category,
action: action,
label: labelToLog,
value: undefined,
path: `/iss/?${queryStrings.ISS_PAGE}=${currentPageName}`,
};
pushToDataLayerIfDefined(eventToBePushed);
if (pushToLogApp) {
this.logCustomEvent(category, action, labelToLog, undefined);
}
},
pushPageViewToGA() {
const currentPageName = getPageNameByQueryString();
const pageViewEvent = {
event: GaEvents.PAGE_VIEW_EVENT,
pagePath: `/iss/?${queryStrings.ISS_PAGE}=${currentPageName}`,
pageTitle: currentPageName,
};
pushToDataLayerIfDefined(pageViewEvent);
this.logPageView(analyticsPageEvents.ENTRY);
},
pushExperimentsToDataLayer() {
const experiments = useMainStore().applicationUser.experiments;
experiments?.forEach((exp) => {
// Set Google Dimension Index based on experiment settings.
let googleDimensionIndex = 99;
if (exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX] !== undefined) {
googleDimensionIndex =
exp.settings[experimentSettings.GOOGLE_CUSTOM_DIMENSION_INDEX];
}
// Create object with dimension index and value.
const experimentWithDimension = {
[`experimentId_${googleDimensionIndex}`]: exp.universeId,
[`variationId_${googleDimensionIndex}`]: exp.variationId,
[`experimentName_${googleDimensionIndex}`]: exp.universeName,
[`variationName_${googleDimensionIndex}`]: exp.variationName,
[`customDimension_${googleDimensionIndex}`]: `${exp.universeId}_${exp.variationId}_${exp.universeName}_${exp.variationName}`,
};
// Push to the data layer with the Google Custom Dimension Index.
pushToDataLayerIfDefined(experimentWithDimension);
});
},
prependActionToMethod(object, method, actionToPrepend) {
const baseMethodName = method.name.startsWith("bound ")
? method.name.substring(6)
: method.name;
const baseMethod = object[baseMethodName];
object[baseMethodName] = function () {
actionToPrepend.apply(this, arguments);
return baseMethod.apply(object, arguments);
};
},
async initSession() {
const sid = getSessionIdValue();
const skey = getSessionKeyValue();
var payload = {
userId: getDeviceIdValue(),
sessionId: sid,
userAgent: navigator.userAgent,
referrer: document.referrer,
};
const response = await useMainStore().initializeSession(payload);
if (response.data) {
if (response.data.sessionKey && skey === 0) {
setCookieProperties(
{ [cookieNames.SESSION_KEY]: response.data.sessionKey },
{
useDefaultFunnelCookieAttributes: false,
}
);
}
if (response.data.sessionId && sid === "00000000-0000-0000-0000-000000000000") {
setCookieProperties(
{ [cookieNames.SESSION_ID]: response.data.sessionId },
{
maxAge: 60 * 30, // 30 minutes
}
);
}
}
},
noSession() {
return (
getSessionKeyValue() === 0 ||
getSessionIdValue() === "00000000-0000-0000-0000-000000000000"
);
},
},
computed: {
analyticsPageEvents() {
return analyticsPageEvents;
},
GaCategories() {
return GaCategories;
},
GaActions() {
return GaActions;
},
GaLabels() {
return GaLabels;
},
ValueToLogTypes() {
return ValueToLogTypes;
},
},
};
function pushToDataLayerIfDefined(data) {
if (window.dataLayer !== undefined) {
window.dataLayer.push(data);
}
}
function getPageNameByQueryString() {
const params = new URLSearchParams(location.search);
if (params.has(queryStrings.ISS_PAGE)) {
return params.get(queryStrings.ISS_PAGE);
} else {
return "";
}
}
function getValueToLog(value, valueToLogType) {
if (valueToLogType != null && valueToLogType === ValueToLogTypes.LAST_5) {
return value.slice(-5);
}
return value;
}

View file

@ -0,0 +1,280 @@
import analyticsMixin from "@/mixins/analytics-mixin";
import { setupMocksForJsFiles, setupCookies } from "@/helpers/unit-test-helper.js";
import {
analyticsPageEvents,
GaCategories,
GaActions,
GaLabels,
GaEvents,
ValueToLogTypes,
} from "@/constants/analytics";
import { useMainStore } from "@/store";
describe("analyticsMixin.js", () => {
test("logPageView: calls dispatch with type and payload", () => {
const type = "";
const payload = {};
const mockData = {
};
const mocks = setupMocksForJsFiles(mockData);
const testCookieValue = {
sid: "10000000-0000-0000-0000-000000000001",
};
setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) });
useMainStore().logPageView(payload);
expect(useMainStore().logPageView).toBeCalled();
});
test("logCustomEvent: calls dispatch with type and payload", () => {
const mockData = {};
const mocks = setupMocksForJsFiles(mockData);
useMainStore().logCustomEvent("someCat", "someAction", "someLabel", "someVal");
expect(useMainStore().logCustomEvent).toBeCalled();
});
test("pushEventToGA, should call dataLayer push and logCustomEvent too", () => {
// Arrange
window.dataLayer = [];
const mockData = {};
const mocks = setupMocksForJsFiles(mockData);
var mockDataLayer = [];
mockDataLayer.push({
event: "event",
category: "category",
action: "action",
label: "label",
value: undefined,
path: "/iss/?issPage=",
});
// Act
analyticsMixin.methods.pushEventToGA("category", "action", "label", true);
// Assert
expect(mockDataLayer).toEqual(expect.arrayContaining(window.dataLayer));
});
test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 only logs last 5 of label", () => {
// Arrange
window.dataLayer = [];
var expectedDataLayer = [];
expectedDataLayer.push({
event: "event",
category: "category",
action: "action",
label: "33333",
value: undefined,
path: "/iss/?issPage=",
});
// Act
analyticsMixin.methods.pushEventToGA(
"category",
"action",
"1111122222333333",
false,
ValueToLogTypes.LAST_5
);
// Assert
expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer));
});
test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 logs only the last 3 characters for a 3 character string", () => {
// Arrange
window.dataLayer = [];
var expectedDataLayer = [];
expectedDataLayer.push({
event: "event",
category: "category",
action: "action",
label: "111",
value: undefined,
path: "/iss/?issPage=",
});
// Act
analyticsMixin.methods.pushEventToGA(
"category",
"action",
"111",
false,
ValueToLogTypes.LAST_5
);
// Assert
expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer));
});
/*
test("Experiments, should push to dataLayer with default Google Custom Dimension Index", () => {
// Arrange
window.dataLayer = [];
const mockExperimentData = [
{
settings: {},
variationName: "test",
universeName: "testUniverse",
},
];
// Mock store
jest.mock(
"@/store",
() => {
return {};
},
{ virtual: true }
);
useMainStore.getters = {
applicationUserObj: {
experiments: [
{
settings: {},
variationName: "test",
universeName: "testUniverse",
},
],
},
};
// Act
analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData);
// Assert
expect(window.dataLayer).toEqual([
{
experimentId_99: undefined,
variationId_99: undefined,
experimentName_99: "testUniverse",
variationName_99: "test",
customDimension_99: "undefined_undefined_testUniverse_test",
},
]);
});
test("Experiments, should push to dataLayer with custom Google Custom Dimension Index", () => {
// Arrange
window.dataLayer = [];
const mockExperimentData = [
{
settings: { "Google Custom Dimension Index": "5" },
variationName: "test",
universeName: "testUniverse",
},
];
// Mock store
jest.mock(
"@/store",
() => {
return {};
},
{ virtual: true }
);
useMainStore.getters = {
applicationUserObj: {
experiments: [
{
settings: { "Google Custom Dimension Index": "5" },
variationName: "test",
universeName: "testUniverse",
},
],
},
};
// Act
analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData);
// Assert
expect(window.dataLayer).toEqual([
{
experimentId_5: undefined,
variationId_5: undefined,
experimentName_5: "testUniverse",
variationName_5: "test",
customDimension_5: "undefined_undefined_testUniverse_test",
},
]);
});
*/
test("Obj is not null after action prepended", () => {
//Arrange
const obj = { baseMethodName: "testMethodName", data: "testData" };
const method = { name: "testMethodName", data: "testData" };
const action = "testAction";
//Act
analyticsMixin.methods.prependActionToMethod(obj, method, action);
//Assert
expect(obj != null);
});
test("Obj method name does not include bound", () => {
//Arrange
const obj = { baseMethodName: "testMethodName", data: "testData" };
const method = { name: "testMethodName", data: "testData" };
const action = "testAction";
//Act
analyticsMixin.methods.prependActionToMethod(obj, method, action);
//Assert
expect(method.name.startsWith("bound ")).toBe(false);
});
test("Prepended action does not include bound", () => {
//Arrange
const obj = { baseMethodName: "testMethodName", data: "testData" };
const method = { name: "testMethodName", data: "testData" };
const action = "testAction";
//Act
analyticsMixin.methods.prependActionToMethod(obj, method, action);
//Assert
expect(action.startsWith("bound ")).toBe(false);
});
test("analyticsPageEvents returns constants analyticsPageEvents", () => {
//Act
const analyticsPE = analyticsMixin.computed.analyticsPageEvents();
//Assert
expect(analyticsPE).toEqual(analyticsPageEvents);
});
test("GaActions returns constants GaActions", () => {
//Act
const gaActions = analyticsMixin.computed.GaActions();
//Assert
expect(gaActions).toEqual(GaActions);
});
test("GaCategories returns constants GaCategories", () => {
//Act
const gaCategories = analyticsMixin.computed.GaCategories();
//Assert
expect(gaCategories).toEqual(GaCategories);
});
test("GaLabels returns constants GaLabels", () => {
//Act
const gaLabels = analyticsMixin.computed.GaLabels();
//Assert
expect(gaLabels).toEqual(GaLabels);
});
});

View file

@ -1,10 +1,11 @@
import { storeActions } from "@/constants/store-actions.js";
import { mapStores } from "pinia";
import { useMainStore } from "@/store";
;
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios";
import { vehicleCategories } from "@/constants/vehicle-categories.js";
import { queryStrings } from "@/constants/query-strings";
import { dynamicStrings } from "@/constants/dynamic-strings";
import { useMainStore } from "@/store";
import { mapStores } from "pinia";
export default {
data() {
@ -25,11 +26,9 @@ export default {
},
},
computed: {
// store will be accessible globally as its id + 'Store'
// store will be accessible globally as its id + 'Store'
...mapStores(useMainStore),
storeActions() {
return storeActions;
},
navigationScenarios() {
return navigationScenarios;
},
@ -46,13 +45,4 @@ export default {
return "widget-name-" + this.cmsWidgetName;
},
},
};
function encodeUriData(payload) {
if (payload && Object.keys(payload).length > 0) {
// Loop through the payload and encode the values
Object.keys(payload).forEach((key) => {
payload[key] = encodeURIComponent(payload[key]);
});
}
}
};

View file

@ -0,0 +1,17 @@
import { useMainStore } from "@/store";
export default {
methods: {
hasSettingEqualTo(settingName, settingValue) {
return useMainStore.experimentSettings[settingName] == settingValue;
},
hasSetting(settingName) {
return Object.hasOwn( useMainStore.experimentSettings, settingName);
},
getSettingValue(settingName) {
return this.hasSetting(settingName)
? useMainStore.experimentSettings[settingName]
: null;
},
},
};

View file

@ -6,13 +6,14 @@ import { useMainStore } from '@/store';
import eventBus from "@/helpers/event-bus/event-bus";
import { globalEvents, globalEventTypes } from "@/constants/events";
import analyticsMixin from "@/mixins/analytics-mixin";
const routes = [
{
path: '/',
name: 'root',
async beforeEnter(to, from, next) {
try {
try {
to.query.issPage = !to.query.issPage ? issPageValues.VEHICLE_YEAR : to.query.issPage;
if (router.hasRoute(to.query.issPage)) {
@ -52,10 +53,23 @@ const router = createRouter({
routes,
});
router.afterEach((to, from) => {
const store = useMainStore();
// Update lastPageVisited in the store
store.updateLastPageVisited(to.name);
// Push page view to GA
analyticsMixin.methods.pushPageViewToGA();
// Push experiments to Data Layer
analyticsMixin.methods.pushExperimentsToDataLayer();
});
// Get route information by page name.
// This will reach out to the Cms and there is a 1:1 relationship between page names and route names.
async function GetRouteInfoFromPageName(pageName) {
const response = await useMainStore().getRouteInfo(pageName);
const jsonFromResponse = JSON.parse(response.data.Result);
@ -69,7 +83,7 @@ async function GetRouteInfoFromPageName(pageName) {
component: lazyLoadComponent(jsonFromResponse[key].LayoutName),
});
});
return routeData;
};
@ -124,6 +138,7 @@ function navigateToUrl(url, optionalQuery = {}) {
function getNavigationMap (scenario, currentRoute) {
const issPageValue = currentRoute.query.issPage;
try {
const matchedQueryValue = routingTable(useMainStore())
.filter(
(item) =>

View file

@ -4,5 +4,18 @@ export const issPageValues = {
VEHICLE_YEAR: "vehicle-year",
VEHICLE_MODEL: "vehicle-model",
VEHICLE_STYLE: "vehicle-style",
VEHICLE_DAMAGE: "vehicle-damage",
ADDRESS_LOOKUP: "address-lookup",
VIN_LOOKUP: "vin-lookup",
VEHICLE_PARTS: "vehicle-parts",
PART_QUESTIONS: "part-questions",
MOLDING_QUESTIONS: "molding-questions",
CAPABILITY_QUESTIONS: "capability-questions",
LICENSE_PLATE_LOOKUP: "license-plate-lookup",
REVEAL: "reveal",
ESTIMATE: "estimate",
ADDRESS_VEHICLES: "address-vehicles",
QUOTE: "quote",
HERITAGE: "heritage",
};

View file

@ -0,0 +1,5 @@
const routerParams = {
DISPLAY_VEHICLE_CHANGE_ALERT: "displayVehicleChangeAlert",
};
export { routerParams };

View file

@ -1,5 +1,6 @@
import { defineStore } from "pinia";
import { endpoints } from "@/constants/endpoints.js";
import { getDateForSavedSessionTimeout } from "@/helpers/session-helper";
import globalMethods from "@/global-methods";
import { applicationConfig } from "@/constants/application-config";
@ -42,11 +43,17 @@ const getDefaultState = () => {
accountNumber: 0,
},
applicationUser: {
crmCustomerId: null,
lastPageVisited: null,
experiments: [],
triggeredSiteEntry: false,
eventBus: []
eventBus: [],
pageData: {},
savedSessionTimeout: getDateForSavedSessionTimeout(),
saveSessionPromise: null,
savedSessionId: null,
crmCustomerId: null,
lastPageVisited: null,
triggeredSiteEntry: false,
},
};
};
@ -68,6 +75,70 @@ export const useMainStore = defineStore({
return matchedEvent?.eventValue;
},
eventBus: (state) => state.applicationUser.eventBus,
applicationUserObj: (state) => state.applicationUser,
pageData: (state) => (page) => {
return state.applicationUser.pageData[page];
},
experimentOrder: (state) => {
return {
funnelVehicleYear: state.order.vehicle.year,
funnelVehicleMake: state.order.vehicle.make,
funnelVehicleModel: state.order.vehicle.model,
funnelVehicleStyle: state.order.vehicle.style,
funnelIsRepair: state.order.damage.isRepair,
funnelNumberOfChips: state.order.damage.numberOfChips,
funnelCarId: state.order.vehicle.carId,
funnelServiceCity: state.order.serviceLocation.city,
funnelServiceState: state.order.serviceLocation.state,
funnelServiceZipCode: state.order.serviceLocation.zipCode,
funnelParentAccountNumber: state.order.accountNumber,
funnelIsCoverageVerified: state.order.payment.insuranceCoverage.isVerified,
funnelHasRecalibrationPart: getHasRecalibrationPart(state),
funnelSelectedMultiGlass: state.order.damage.glassToReplace?.length > 1,
funnelSelectedWindshieldGlass: getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.damage.glassToReplace,
"glassLocation"
).includes(damageLocationsSelected.WINDSHIELD),
funnelSelectedBackGlass: getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.damage.glassToReplace,
"glassLocation"
).includes(damageLocationsSelected.REAR),
funnelSelectedDriverSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.damage.glassToReplace,
"glassLocation"
).includes(damageLocationsSelected.DRIVER),
funnelSelectedPassengerSideGlass: getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.damage.glassToReplace,
"glassLocation"
).includes(damageLocationsSelected.PASSENGER),
funnelOrderPartNumbers: [
...getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.lineItems.glassParts,
"partNumber"
),
...getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.lineItems.otherParts,
"partNumber"
),
],
funnelOrderPartTypes: [
...getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.lineItems.glassParts,
"recalibrationType"
),
...getNonFalseValuesOfPropertyInArrayOfObjects(
state.order.lineItems.otherParts,
"recalibrationType"
),
],
};
},
experimentSettings: (state) =>
state.applicationUser.experiments
.map((x) => x.settings)
.reduce((r, c) => Object.assign(r, c), {}) ?? {}
},
actions:
{
@ -87,12 +158,13 @@ export const useMainStore = defineStore({
endpoint: endpoints.GetHomepageInfo.url(applicationConfig.APPLICATION_ABBREVIATION),
});
},
getPageData(pageName) {
return globalMethods.callHttpClient({
method: endpoints.GetPageData.method,
endpoint: endpoints.GetPageData.url(applicationConfig.APPLICATION_ABBREVIATION, pageName),
payload: {},
});
});
},
// Vehicle API Actions
@ -186,7 +258,108 @@ export const useMainStore = defineStore({
if(!localStorage.getItem(storeId)) {
this.$state = state;
}
},
// Analytics Actions
logExperimentExposure({ userId, sessionKey, pageName, experiment }) {
return globalMethods.callHttpClient({
method: endpoints.LogExperimentExposureIfAssigned.method,
endpoint: endpoints.LogExperimentExposureIfAssigned.url,
payload: {
experimentForLogging: {
userId: userId,
experimentUniverseId: experiment.universeId,
experimentUniverseName: experiment.universeName,
experimentTestId: experiment.testId,
experimentTestName: experiment.testName,
experimentVariationId: experiment.variationId,
experimentVariationName: experiment.variationName,
enabled: experiment.isActive,
isExposed: experiment.isExposed,
userPartitionNumber: experiment.userPartitionNumber,
assignmentId: experiment.assignmentId,
sessionKey: sessionKey,
pageName: pageName,
},
},
});
},
logPageView({ userId, sessionKey, pageName, sessionId, action, event, shouldUseSessionId, experimentsForUser })
{
var payload = {
userId: userId,
sessionKey: sessionKey,
sessionId: sessionId,
pageName: pageName,
applicationName: applicationConfig.APPLICATION_NAME,
action: action,
event: event,
shouldUseSessionId: shouldUseSessionId,
experimentsForUser: experimentsForUser,
}
return globalMethods.callHttpClient({
method: endpoints.LogPageView.method,
endpoint: endpoints.LogPageView.url,
payload: payload,
logApiCall: false,
});
},
logCustomEvent({ userId, sessionKey, pageName, sessionId, category, action, label, value, shouldUseSessionId, experimentsForUser})
{
var payload = {
userId: userId,
sessionKey: sessionKey,
sessionId: sessionId,
pageName: pageName,
applicationName: applicationConfig.APPLICATION_NAME,
category: category,
action: action,
label: label,
value: value,
shouldUseSessionId: shouldUseSessionId,
experimentsForUser: experimentsForUser,
};
return globalMethods.callHttpClient({
method: endpoints.LogCustomEvent.method,
endpoint: endpoints.LogCustomEvent.url,
payload: payload,
logApiCall: false,
});
},
initializeSession({ userId, sessionId, userAgent, referrer }) {
var payload = {
applicationName: applicationConfig.APPLICATION_NAME,
userId: userId,
deviceId: userId,
sessionId: sessionId,
userAgent: userAgent,
operatorId: "WEB",
userName: "SafeliteISS",
referrer: referrer,
};
return globalMethods.callHttpClient({
method: endpoints.InitializeSession.method,
endpoint: endpoints.InitializeSession.url,
payload: payload,
logApiCall: false,
});
},
updateLastPageVisited(lastPageVisited) {
this.applicationUser.lastPageVisited = lastPageVisited;
},
GetExperimentsByUser(userId) {
return globalMethods.callHttpClient({
method: endpoints.GetExperimentsByUser.method,
endpoint: `${endpoints.GetExperimentsByUser.url}/${userId}`,
payload: {},
});
},
},
persist: true
});

View file

@ -21,7 +21,7 @@ describe("Store", () => {
it("Should Store Vehicle Year", () => {
let testYear = "2001";
store.updateVehicleYear(testYear);
store.order.vehicle.year = testYear;
expect(store.order.vehicle.year).toEqual(testYear);
});