diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7b96087d9..2760d6a1a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -79,6 +79,9 @@ stages: __VUE_APP_CONSUMER_API_GATEWAY__: $(__VUE_APP_CONSUMER_API_GATEWAY__) __VUE_APP_GOOGLE_PLACES_API_KEY__: $(__VUE_APP_GOOGLE_PLACES_API_KEY__) __VUE_APP_HERITAGE_FUNNEL__: $(__VUE_APP_HERITAGE_FUNNEL__) + indexDeployVariables: + __VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__) + __VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__) cfDistributionId: $(cfDistributionId) diff --git a/jest.config.js b/jest.config.js index c1edf3074..7f7516934 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,30 +11,28 @@ module.exports = { "!src/constants/*.js", "!src/router/**/*.js", "!src/helpers/unit-test-helper.js", + "!src/helpers/damage-helper.js", "!src/layouts/component-test/component-test.vue", "!src/layouts/form-test/form-test.vue", - "!src/layouts/license-plate-lookup/license-plate-lookup.vue", "!src/layouts/vin-lookup/vin-lookup.vue", + "!src/layouts/license-plate-lookup/license-plate-lookup.vue", "!src/layouts/vehicle-damage/windshield-damage-type-question/windshield-damage-type-question.vue", "!src/layouts/vehicle-damage/windshield-options/windshield-options.vue", - "!src/layouts/address-poc/address-poc.vue", - "!src/layouts/nested-radio-poc/nested-radio.vue", - "!src/layouts/button-question-examples/**/*.vue", "!src/layouts/part-questions/**/*.vue", "!src/layouts/reveal/**/*.vue", + "!src/layouts/estimate/**/*.vue", // REMOVE THESE AFTER WRITING UNIT TESTS "!src/layouts/address-lookup/address-lookup.vue", "!src/layouts/address-lookup/customer-questions/customer-questions.vue", "!src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue", "!src/common-components/dropdown-question/dropdown-question.vue", - "!src/common-components/textbox-question/textbox-question.vue", - "!src/helpers/validation-rules.js", + "!src/common-components/textbox-question/textbox-question.vue", // END ], //! means exclude from coverage. testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"], coverageThreshold: { global: { - statements: 87, + statements: 85, // Got the go ahead from Mark to temporarily lower this. Taking out initialize component made the year,make,model and style coverage drop a bit. Once unit tests for license plate lookup, vin lookup and address lookup are in the coverage should go back up to 90 }, }, diff --git a/public/index.html b/public/index.html index b926ca739..b8a80e384 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,10 @@ + + @@ -11,9 +15,17 @@ <%= htmlWebpackPlugin.options.title %> + + + + +
diff --git a/src/common-components/button-question/button-question.spec.js b/src/common-components/button-question/button-question.spec.js index d36b73012..b70304035 100644 --- a/src/common-components/button-question/button-question.spec.js +++ b/src/common-components/button-question/button-question.spec.js @@ -1,6 +1,8 @@ import { shallowMount } from "@vue/test-utils"; import buttonQuestion from "@/common-components/button-question/button-question"; -import { nextTick } from "vue"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; + +jest.mock("@/store", () => { return {}; }, { virtual: true }); describe("buttonQuestion.vue", () => { it("Should show overflow classes on fieldset if isOverflowScrollable is true", () => { @@ -45,15 +47,74 @@ describe("buttonQuestion.vue", () => { }); }); +describe("buttonQuestion.vue", () => { + it("Fieldset classes should contain ui-radio if button type is radio", () => { + // Act + const wrapper = shallowMount(buttonQuestion, { + propsData: { + buttonType: "radio", + } + }); + // Assert + const Div = wrapper.find('fieldset div'); + expect(Div.classes()).toContain("ui-radio"); + }); +}); + + +// testing a computed property +describe("buttonQuestion.vue", () => { + it("getColLength should return '12' if prop isWide is set to true", () => { + // Act + const localThis = { isWide: true } + + expect(buttonQuestion.computed.getColLength.call(localThis)).toBe("12"); + }); +}); + +describe("buttonQuestion.vue", () => { + it("getColLength should return '' if prop isWide is set to false", () => { + // Act + const localThis = { + isWide: false, + answers: ['a', 'b'] + } + + expect(buttonQuestion.computed.getColLength.call(localThis)).toBe(""); + }); +}); + +describe("buttonQuestion.vue", () => { + it("Should return answer.Text if prop useTextForValue is true", async () => { + // Act + const localThis = { useTextForValue: true }; + const answer = { 'Name': 'testName', 'Text': 'testText' }; + + // Assert + expect(buttonQuestion.methods.getValues.call(localThis, answer)).toBe('testText'); + }); +}); + +describe("buttonQuestion.vue", () => { + it("Should return answer.Name if prop useTextForValue is false and answer.Name exists", async () => { + // Act + const localThis = { useTextForValue: false }; + const answer = { 'Name': 'testName', 'Text': 'testText' }; + + // Assert + expect(buttonQuestion.methods.getValues.call(localThis, answer)).toBe('testName'); + }); +}); + describe("buttonQuestion.vue", () => { it("Should trigger event modelValue change to new value on when radio button selected", async () => { // Act - const wrapper = shallowMount(buttonQuestion); + const wrapper = shallowMount(buttonQuestion, setupMocks({})); await wrapper.setProps({ answers: ["2022", "2021", "2020"], isMultiSelect: false }); - const val = {checkValue: true, value: "2021", } + const val = { checkValue: true, value: "2021", } wrapper.vm.handleCheckedChanged(val); // Assert expect(wrapper.emitted()["update:modelValue"][0]).toEqual([["2021"]]); @@ -63,16 +124,76 @@ describe("buttonQuestion.vue", () => { describe("buttonQuestion.vue", () => { it("Should add values to array on checkbox click", () => { // Act - const wrapper = shallowMount(buttonQuestion, { + const wrapper = shallowMount(buttonQuestion, setupMocks({ propsData: { modelValue: ["2022", "2021", "2020"], isMultiSelect: true, } - }); - const val = {checkValue: true, value: "2019", } + })); + const val = { checkValue: true, value: "2019", } wrapper.vm.handleCheckedChanged(val); // Assert expect(wrapper.emitted()["update:modelValue"][0]).toEqual([["2022", "2021", "2020", "2019"]]); }); }); +describe("buttonQuestion.vue", () => { + it("Should add a value to this.selectedValues if prop isMultiSelect is true, checkValue is true and this.selectedValues already exists", () => { + // Act + const wrapper = shallowMount(buttonQuestion, setupMocks({ + propsData: { + isMultiSelect: true, + modelValue: ['a', 'b'] + } + })); + const val = { checkValue: true, value: "2021", } + wrapper.vm.handleCheckedChanged(val); + + // Assert + expect(wrapper.vm.selectedValues).toEqual(["a", "b", "2021"]); + }); +}); + +describe("buttonQuestion.vue", () => { + it("Should remove a value to this.selectedValues if prop isMultiSelect is true, checkValue is false and this.selectedValues already exists", () => { + // Act + const wrapper = shallowMount(buttonQuestion, setupMocks({ + propsData: { + isMultiSelect: true, + modelValue: ['a', 'b'] + } + })); + + const val = { checkValue: false, value: "a", } + wrapper.vm.handleCheckedChanged(val); + + // Assert + expect(wrapper.vm.selectedValues).toEqual(["b"]); + }); +}); + + +describe("buttonQuestion.vue", () => { + it("Should do nothing to this.selectedValues if this.selectedValues is not an array", () => { + // Act + const wrapper = shallowMount(buttonQuestion, setupMocks({ + propsData: { + isMultiSelect: true, + modelValue: 'a', + } + })); + const val = { checkValue: true, value: "c", } + wrapper.vm.handleCheckedChanged(val); + + // Assert + expect(wrapper.vm.selectedValues).toEqual("a"); + }); +}); + +function setupMocks(mountOptionsMockData = {}) { + const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } }; + const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData)); + const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions); + + return allMountOptions; +} diff --git a/src/common-components/button-question/button-question.vue b/src/common-components/button-question/button-question.vue index 91c83fc2a..7fba59c51 100644 --- a/src/common-components/button-question/button-question.vue +++ b/src/common-components/button-question/button-question.vue @@ -5,7 +5,7 @@ {{ questionText }}
-
+
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }} @@ -35,6 +35,7 @@ :selectedValues="selectedValues" data-test="button" :validationRules="validationRules" + :class="[suppressError ? 'alertError' : '']" />
@@ -51,6 +52,7 @@ import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-bu import listCard from "@/ux-components/list-card/list-card"; import { ErrorMessage } from 'vee-validate'; import radio from "@/ux-components/radio/radio"; +import { queryStrings } from "@/constants/query-strings"; export default { name: "buttonQuestion", @@ -132,6 +134,9 @@ export default { return answer.Name ? answer.Name : answer; }, handleCheckedChanged(val) { + + this.pushEventToGA(this.$route.query[queryStrings.FMG_PAGE], this.GaActions.CLICKED, val.value, true); + if(this.isMultiSelect && this.selectedValues) { // Add or remove item to array of data to emit const newSelectedValues = this.selectedValues; @@ -157,11 +162,11 @@ export default { diff --git a/src/constants/analytics.js b/src/constants/analytics.js new file mode 100644 index 000000000..f6f6ff82e --- /dev/null +++ b/src/constants/analytics.js @@ -0,0 +1,32 @@ +const analyticsPageEvents = { + ENTRY: "ENTRY", + EVENT: "EVENT" +}; + +// GA Constants +const GaEvents = { + GENERIC_EVENT: 'ga_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', +}; + + +export { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents}; diff --git a/src/constants/dynamic-strings.js b/src/constants/dynamic-strings.js new file mode 100644 index 000000000..1c3b8ade7 --- /dev/null +++ b/src/constants/dynamic-strings.js @@ -0,0 +1,7 @@ +const dynamicStrings = { + GLOBAL_STATE: "globalState", + CUSTOM: "custom", + ROUTER_LINK: "routerLink" + }; + + export { dynamicStrings }; \ No newline at end of file diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 2325c29cd..ca1555e7b 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -47,6 +47,10 @@ const endpoints = { url: "/vehicle/api/v1/vehicle/lookup-vin-by-plate", method: "POST", }, + LookupVinByAddress: { + url: "/vehicle/api/v1/vehicle/lookup-vin-by-address", + method: "POST", + }, GetPartsOrQuestions: { url: "/parts/api/v1/parts/parts-or-questions", method: "POST", @@ -66,6 +70,14 @@ const endpoints = { LogExperimentExposureIfAssigned:{ url: "/analytics/api/v1/analytics/log-experiment-exposure", method: "POST", + }, + LogActivity:{ + url: "/analytics/api/v1/analytics/activity", + method: "POST", + }, + GetExperimentsByUserForGa: { + url: "/analytics/api/v1/analytics/get-experiments-for-GA", + method: "GET", } }; diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index 311e747b0..17012ab68 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -11,11 +11,16 @@ const errorMessages = { CITY_REQUIRED: "Please enter your city", STATE_REQUIRED: "Please enter your state", ZIP_REQUIRED: "Please enter your ZIP", + ZIP_FORMAT: "Please enter a valid ZIP", LICENSE_PLATE_REQUIRED: "Please enter your license plate number", FIRST_NAME_REQUIRED: "Please enter your first name", LAST_NAME_REQUIRED: "Please enter your last name", EMAIL_ADDRESS_REQUIRED: "Please enter your email address", EMAIL_ADDRESS_FORMAT: "Please enter a valid email address", + SERVICE_ZIP_REQUIRED: "Please enter your Service ZIP", + SERVICE_ZIP_FORMAT: "Please enter a valid Service ZIP", + VIN_REQUIRED: "Please enter your VIN", + VIN_FORMAT: "Please enter a valid VIN", }; - -export { errorMessages }; \ No newline at end of file + +export { errorMessages }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 13a51aa7f..cd2325a24 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -1,6 +1,10 @@ const experimentUniverses = { CONCEPT_FUNNEL: 'ConceptFunnel' }; + +const experimentSettings = { + GOOGLE_CUSTOM_DIMENSION_INDEX: 'Google Custom Dimension Index' +} -export { experimentUniverses }; +export { experimentUniverses, experimentSettings}; \ No newline at end of file diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 9e87416d4..74dac5043 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -12,12 +12,15 @@ const storeActions = { LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms", LOOKUP_VEHICLE_BY_VIN: "lookupVehicleByVin", LOOKUP_VIN_BY_PLATE: "lookupVinByPlate", + LOOKUP_VIN_BY_ADDRESS: "lookupVinByAddress", GET_PARTS_OR_QUESTIONS: "getPartsOrQuestions", SAVE_ORDER: "saveOrder", LOAD_ORDER: "loadOrder", SET_REFERRAL_INFORMATION: "setReferralInformation", VALIDATE_ZIP: "validateZip", LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure", + LOG_ACTIVITY: "logActivity", + GET_EXPERIMENTS_BY_USER_FOR_GA: "getExperimentsByUserForGa", // DEPENDENCY MUTATIONS RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleAndDependencies", diff --git a/src/global-methods.js b/src/global-methods.js index c0823f29a..e7996c4fa 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -1,39 +1,34 @@ import axios from "axios"; +import analyticsMixIn from "@/mixins/analytics-mixin.js"; + import { applicationConfig } from "@/constants/application-config.js"; -import httpStatusCodes from "http-status-codes"; +import { GaCategories, GaActions, GaLabels } from "@/constants/analytics"; export default { - callHttpClient({ method, endpoint, payload }) { + callHttpClient({ method, endpoint, payload, logApiCall = true }) { return new Promise((resolve, reject) => { const apiGatewayUrl = applicationConfig.CONSUMER_APIGATEWAY_URL; + const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "FixMyGlass" }); - const payloadAndAnalyticsData = Object.assign({}, payload, { - AppName: "FixMyGlass", - }); + axios({ method: method, url: apiGatewayUrl + endpoint, data: payloadAndAnalyticsData, crossDomain: true, responseType: {} }) + .then((response) => { - axios({ - method: method, - url: apiGatewayUrl + endpoint, - data: payloadAndAnalyticsData, - crossDomain: true, - responseType: {}, - }).then( - (response) => { - if (response.status == httpStatusCodes.OK) { - if(response.data == undefined) { - reject(response); - }else{ - resolve(response); - } - } else { - reject(response); + if (logApiCall) { + analyticsMixIn.methods.pushEventToGA(GaCategories.API_RESPONSE, GaActions.RESULT, `${GaLabels.SUCCESS}_${endpoint}`, true); } + + return resolve(response); }, - (error) => { - console.error(error); - return reject(error.response); - } - ); + error => { + console.error(error); + + if (logApiCall) { + analyticsMixIn.methods.pushEventToGA(GaCategories.API_RESPONSE, GaActions.RESULT, `${GaLabels.ERROR}_${endpoint}`, true); + } + + return reject(error.response); + } + ); }); }, @@ -48,11 +43,7 @@ export default { responseType: {}, }).then( (response) => { - if (response.status == httpStatusCodes.OK) { - resolve(response); - } else { - reject(response); - } + resolve(response); }, (error) => { return reject(error.response); diff --git a/src/global-methods.spec.js b/src/global-methods.spec.js index 59fa15b77..5dcba0ccd 100644 --- a/src/global-methods.spec.js +++ b/src/global-methods.spec.js @@ -1,13 +1,16 @@ import globalMethods from "@/global-methods"; import axios from "axios"; +import analyticsMixIn from "@/mixins/analytics-mixin"; //Mock external dependencies jest.mock("axios"); +jest.mock("@/mixins/analytics-mixin"); it("Global Methods - Call Http Client - Should Resolve Promise", () => { //Arrange const endpoint = "https://mock.safelite.com"; const httpArgs = setupMocksForHttpClient({ endpoint: endpoint }); + analyticsMixIn.methods.pushEventToGA = jest.fn(); //Act globalMethods.callHttpClient(httpArgs).then((response) => { @@ -25,6 +28,7 @@ it("Global Methods - Call Http Client - Should Reject Promise", () => { endpoint: endpoint, isError: true, }); + analyticsMixIn.methods.pushEventToGA = jest.fn(); //Act globalMethods.callHttpClient(httpArgs).catch((err) => { @@ -72,5 +76,6 @@ function setupMocksForHttpClient({ return { endpoint: endpoint, + logApiCall: true }; } diff --git a/src/helpers/cms-content-helper.js b/src/helpers/cms-content-helper.js index 3bd5319b5..642f0d014 100644 --- a/src/helpers/cms-content-helper.js +++ b/src/helpers/cms-content-helper.js @@ -1,5 +1,6 @@ import { storeActions } from "@/constants/store-actions.js"; import store from "@/store"; +import { dynamicStrings } from "../constants/dynamic-strings"; export function fetchCmsContentForPage(fmgPage) { return store @@ -40,12 +41,15 @@ export function fetchCmsContentForPage(fmgPage) { function mapStringToState(str) { // Pull all matches out of the string. const regexExp = new RegExp("{(.*?):(.*?)}", "g"); - const matches = [...str.matchAll(regexExp)]; + const regexMatches = [...str.matchAll(regexExp)]; + const globalStateMatches = regexMatches.filter(match => { + return match[1] === dynamicStrings.GLOBAL_STATE; + }) // Our final string value that will be built from the matches. let stringBuilder = ""; - for (const match of matches) { + for (const match of globalStateMatches) { // Reset store state for each match. let storeState = store.state; @@ -60,7 +64,7 @@ function mapStringToState(str) { const stringWithReplacement = str.replace(match[0], storeState); // If we still have values we need to substitute, call this function again. - if (stringWithReplacement.includes("{globalState:")) { + if (stringWithReplacement.includes(dynamicStrings.GLOBAL_STATE)) { return mapStringToState(stringWithReplacement); } @@ -96,7 +100,7 @@ function findAndReplaceGlobalStateValues(widgetModel, widgetName) { function processWidgetItemForReplacement(widgetModel, key) { // If we have a string, and it needs to be replaced. if (typeof widgetModel[key] === "string") { - if (widgetModel[key].includes("{globalState:")) { + if (widgetModel[key].includes(dynamicStrings.GLOBAL_STATE)) { widgetModel[key] = mapStringToState(widgetModel[key]); } return widgetModel[key]; @@ -116,4 +120,4 @@ function processWidgetItemForReplacement(widgetModel, key) { // If we have something else like a number, boolean, etc. just return it return widgetModel[key]; -} +} \ No newline at end of file diff --git a/src/helpers/damage-helper.js b/src/helpers/damage-helper.js new file mode 100644 index 000000000..cf363e4d9 --- /dev/null +++ b/src/helpers/damage-helper.js @@ -0,0 +1,31 @@ +import store from "@/store"; +import baseMixin from "@/mixins/base-mixin.js"; +import { storeActions } from "@/constants/store-actions"; + +export function getDamageString() { + return store.getters.damage.glassToReplace.length > 1 ? "match" : store.getters.damage.glassToReplace[0].location; +} + +export async function isGlassAvailableForCarId(carId){ + const newGlassOptions = await baseMixin.methods.dispatchStoreAction( + storeActions.GET_DAMAGE_OPTIONS, + { carId: carId } + ); + + const currentGlassOptions = store.getters.damage.glassToReplace; + + const optionsMap = { + Windshield: "windshieldOptions", + Driver: "driverSideOptions", + Passenger: "passengerSideOptions", + Rear: "backGlassOptions" + } + + for(const option of currentGlassOptions){ + if(!newGlassOptions.data[optionsMap[option.location]].availableReplacementOptions.includes(option.name)){ + return false; + } + } + + return true; + } \ No newline at end of file diff --git a/src/helpers/damage-helper.spec.js b/src/helpers/damage-helper.spec.js new file mode 100644 index 000000000..96cbdd0d4 --- /dev/null +++ b/src/helpers/damage-helper.spec.js @@ -0,0 +1,42 @@ +import {getDamageString, isGlassAvailableForCarId} from "./damage-helper"; +//import baseMixin from "@/mixins/base-mixin.js"; + +jest.mock("@/store", () => ({ + getters: {damage: { + glassToReplace: [{location: "Windshield", name: "windshield"}] + } + } + })); + + describe("damage-helper.js", () => { + it("Should return damage getter info", () => { + const damage = getDamageString(); + expect(damage).toEqual("Windshield") + }); + }); + + // describe("damage-helper.js", () => { + // it("Should return false if no mismatches between each array", async () => { + // const updatedOptions = { + // data: { + // windshieldOptions: {availableReplacementOptions: ["windshield"]} + // } + // } + // baseMixin.methods.dispatchStoreAction = jest.fn().mockImplementation(()=> { + // return updatedOptions; + // }); + // const misMatch = await isGlassAvailableForCarId(); + // expect(misMatch).toEqual(false); + // }); + // }); + + // describe("damage-helper.js", () => { + // it("Should return true if there are any mismatches between arrays", () => { + // const newOptions = { + // windshieldOptions: {availableReplacementOptions: ["window"]} + // } + // const currentOptions = [{location: "Windshield", name: "windshield"}]; + // const misMatch = compareGlassOptions(newOptions, currentOptions); + // expect(misMatch).toEqual(true); + // }); + // }); \ No newline at end of file diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js index 52cb17642..e69c21419 100644 --- a/src/helpers/heritage-integration/cookie-helper.js +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -18,6 +18,7 @@ export function updateOrCreateFunnelCookie() { ReferralNumber: store.getters.order.referralNumber, ReferralDate: store.getters.order.referralDate, ReferralCorrelationId: store.getters.order.referralCorrelationId, + ReferralParentAccountNumber: store.getters.order.accountNumber, }); } @@ -66,25 +67,12 @@ export function getDeviceIdValue(){ return cookieValuesSplit[1]; } - const cookieValueMatch = cookieValue.match("(?<=did=).*?(?=&)"); + const cookieValueMatch = cookieValue.match("^did=[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}"); if(cookieValueMatch){ - return cookieValueMatch[0]; + return cookieValueMatch[0].split('=')[1]; } - return ''; -} - -/* - Gets value of sid cookie, returns empty string if not found. -*/ -export function getSessionIdValue(){ - const cookieValue = getCookieValueByName(cookieNames.SESSION_ID); - - if(cookieValue){ - return cookieValue; - } - - return ''; + return '00000000-0000-0000-0000-000000000000'; } /* @@ -100,6 +88,19 @@ export function getSessionKeyValue(){ 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'; +} + /* =========================== = PRIVATE FUNCTIONS = diff --git a/src/helpers/heritage-integration/cookie-helper.spec.js b/src/helpers/heritage-integration/cookie-helper.spec.js index 9d14389e2..03fca51b5 100644 --- a/src/helpers/heritage-integration/cookie-helper.spec.js +++ b/src/helpers/heritage-integration/cookie-helper.spec.js @@ -1,4 +1,4 @@ -import {getFunnelCookie} from "@/helpers/heritage-integration/cookie-helper.js"; +import {getFunnelCookie, getDeviceIdValue, getSessionKeyValue, getSessionIdValue} from "@/helpers/heritage-integration/cookie-helper.js"; import { removeAllTestCookies, setupCookies } from "@/helpers/unit-test-helper"; describe("cookies", () => { @@ -100,5 +100,45 @@ describe("cookies", () => { expect(actualCookieValue).toBeNull(); }); }) + + describe("getDeviceIdValue", () => { + test("getDeviceIdValue, should return GUID", () => { + // Arrange + setupCookies({}); + + // Act + const result = getDeviceIdValue(); + + //Assert + expect(result).toBe('21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe'); + + }); + + test("getSessionKeyValue, should return session key int", () => { + // Arrange + setupCookies({}); + + // Act + const result = getSessionKeyValue(); + + //Assert + expect(result).toBe('12345'); + + }); + }); + + describe("getSessionIdValue", () => { + test("getSessionIdValue, should return GUID", () => { + // Arrange + setupCookies({}); + + // Act + const result = getSessionIdValue(); + + //Assert + expect(result).toBe('cba0c3d1-3c1b-4305-bb56-31aa50f58e27'); + + }); + }); }) \ No newline at end of file diff --git a/src/helpers/heritage-integration/navigation-helper.js b/src/helpers/heritage-integration/navigation-helper.js index 06eff0042..989dec817 100644 --- a/src/helpers/heritage-integration/navigation-helper.js +++ b/src/helpers/heritage-integration/navigation-helper.js @@ -2,55 +2,38 @@ import { queryStrings } from "@/constants/query-strings"; import { externalUrls } from "@/router/router-constants/externalUrl-values"; import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js"; import { saveOrder } from "@/helpers/heritage-integration/order-helper.js"; +import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; import store from "@/store"; import router from "@/router"; /* If the user has visited the funnel before this method will determine the bets place to drop them so they don't start at the beginning again. This method will return 'heritage' if - the user has an existing order and they come back in from the Safelite.com CTA. + the user has an existing order and they come back in from the Safelite.com CTA. */ export async function getPageToRouteExistingOrderTo(toRoute = {}, existingHeritageOrder = false) { // If the user is coming in via the Safelite.Com CTA if (toRoute.query[queryStrings.START_TYPE] === 'fmg') { - // If they have an existing order, return 'heritage' for the page name. if (existingHeritageOrder) { return 'heritage'; } + + return await getLatestPageForRedirection(); } - - // If this is a non-CTA navigation, determine where to send the user based on page prerequisites. - // This also works if a user has a 'fmg' start_type query string but no current order. - // That shouldn't happen, but it's possible. - const vehicleMakeComponent = (await lazyLoadComponent('vehicle-make')()).default; - const vehicleModelComponent = (await lazyLoadComponent('vehicle-model')()).default; - const vehicleStyleComponent = (await lazyLoadComponent('vehicle-style')()).default; - const vehicleDamageComponent = (await lazyLoadComponent('vehicle-damage')()).default; - - - if (!vehicleMakeComponent.methods.arePagePrerequisitesValid()) { - return "vehicle-year"; - } else if (!vehicleModelComponent.methods.arePagePrerequisitesValid()) { - return "vehicle-make"; - } else if (!vehicleStyleComponent.methods.arePagePrerequisitesValid()) { - return "vehicle-model"; - } else if (!vehicleDamageComponent.methods.arePagePrerequisitesValid()) { - return "vehicle-style"; - } else if (store.getters.damage.isRepair == null || !store.getters.vehicle.carId) { - return 'vehicle-damage' - } else { - if (store.getters.vehicle.vin) { - return 'vehicle-damage'; - //return "vin-lookup"; (uncomment) - } else { - return 'vehicle-damage'; - //return "estimate" (uncomment) - } + // If navigating to a specific page, and that page is not part of the vin pages. + // Return that page, so that it can navigate like normal. + if (toRoute.query[queryStrings.FMG_PAGE] !== undefined && !isVinRelatedPage(toRoute)) { + return overrideYmmsDirectionIfNeeded(toRoute); } + // If this is not a direct link to a page using fmgPage, not from Safelite.com CTA or this is a vin related page. + // Get the latest page for redirection. + const latestPageRoute = await getLatestPageForRedirection(); + + return latestPageRoute; } /* @@ -68,4 +51,93 @@ export async function navigateToHeritageFunnel() { src: "concept-funnel", } ); -} \ No newline at end of file +} + +export async function navigateAfterSaveToHeritageFunnel(currentRoute) { + const currentComponent = currentRoute.matched[0].components; + currentComponent.default.methods.resetDependentState(); + // Create the order (or save existing order) when navigating to Heritage Funnel. + await saveOrder(); + + router.navigateToExternalUrl( + externalUrls.HERITAGE_FUNNEL, + { + corid: store.getters.order.referralCorrelationId, + src: "concept-funnel" + } + ); +} + +/* + Logic for getting the last "valid" page a user visited. +*/ +async function getLatestPageForRedirection() { + // If this is a non-CTA navigation, determine where to send the user based on page prerequisites. + // This also works if a user has a 'fmg' start_type query string but no current order. + // That shouldn't happen, but it's possible. + const vehicleMakeComponent = (await lazyLoadComponent('vehicle-make')()).default; + const vehicleModelComponent = (await lazyLoadComponent('vehicle-model')()).default; + const vehicleStyleComponent = (await lazyLoadComponent('vehicle-style')()).default; + const vehicleDamageComponent = (await lazyLoadComponent('vehicle-damage')()).default; + + + if (!vehicleMakeComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_YEAR; + } else if (!vehicleModelComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_MAKE; + } else if (!vehicleStyleComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_MODEL; + } else if (!vehicleDamageComponent.methods.arePagePrerequisitesValid()) { + return fmgPageValues.VEHICLE_STYLE; + } else if (store.getters.damage.isRepair == null || !store.getters.vehicle.carId) { + return fmgPageValues.VEHICLE_DAMAGE; + } else { + if (store.getters.vehicle.vin) { + return fmgPageValues.LICENSE_PLATE_LOOKUP; + } else { + return fmgPageValues.ESTIMATE; + } + } +} + +/* + Overrides functionality to go to the YMMS pages in certain cases. + If this is not one of the cases, it returns the 'to' fmgPage value. +*/ + +/* istanbul ignore next */ +function overrideYmmsDirectionIfNeeded(toRoute) { + const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE]; + + if (store.getters.payment.insuranceCoverage.isVerified) { + switch (fmgPageValue) { + case fmgPageValues.VEHICLE_YEAR: + case fmgPageValues.VEHICLE_MAKE: + case fmgPageValues.VEHICLE_MODEL: + case fmgPageValues.VEHICLE_STYLE: + { + return fmgPageValues.VEHICLE_DAMAGE; + } + default: { + return fmgPageValue; + } + } + } else { + return fmgPageValue; + } +} + +/* + Determine if the page is a vin related page. +*/ + +/* istanbul ignore next */ +function isVinRelatedPage(toRoute) { + const fmgPageValue = toRoute.query[queryStrings.FMG_PAGE]; + + return fmgPageValue === fmgPageValues.VIN_LOOKUP || + fmgPageValue === fmgPageValues.LICENSE_PLATE_LOOKUP || + fmgPageValue === fmgPageValues.ADDRESS_LOOKUP || + fmgPageValue === fmgPageValues.ADDRESS_VEHICLES || + fmgPageValue === fmgPageValues.ESTIMATE; +} diff --git a/src/helpers/heritage-integration/navigation-helper.spec.js b/src/helpers/heritage-integration/navigation-helper.spec.js index be1136e63..17561ba52 100644 --- a/src/helpers/heritage-integration/navigation-helper.spec.js +++ b/src/helpers/heritage-integration/navigation-helper.spec.js @@ -175,7 +175,7 @@ describe("getPageToRouteExistingOrderTo", () => { expect(result).toBe('vehicle-damage'); }); - test("getPageToRouteExistingOrderTo, should return vin-lookup", async () => { + test("getPageToRouteExistingOrderTo, should return license-plate-lookup", async () => { // Arrange const toRoute = { query: {} @@ -228,8 +228,7 @@ describe("getPageToRouteExistingOrderTo", () => { const result = await getPageToRouteExistingOrderTo(toRoute, false); //Assert - //expect(result).toBe('vin-lookup'); - expect(result).toBe('vehicle-damage'); + expect(result).toBe('license-plate-lookup'); }); test("getPageToRouteExistingOrderTo, should return estimate", async () => { @@ -284,8 +283,7 @@ describe("getPageToRouteExistingOrderTo", () => { const result = await getPageToRouteExistingOrderTo(toRoute, false); //Assert - //expect(result).toBe('estimate'); - expect(result).toBe('vehicle-damage'); + expect(result).toBe('estimate'); }); test("getPageToRouteExistingOrderTo, existing order, should return heritage", async () => { diff --git a/src/helpers/heritage-integration/order-helper.js b/src/helpers/heritage-integration/order-helper.js index 1ce2ac2cc..b18b64a85 100644 --- a/src/helpers/heritage-integration/order-helper.js +++ b/src/helpers/heritage-integration/order-helper.js @@ -18,14 +18,14 @@ export async function loadOrderIfPresent() { // Reset state if cookie says to. if (funnelCookie.ShouldResetState) { - baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.RESET_STATE); + baseMixin.methods.dispatchStoreAction(storeActions.RESET_STATE); deleteFunnelCookie(); return null; } // Load referral if there is a cookie, and it doesn't indicate it needs a state reset. - return (await loadOrder(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId)).data; + return (await loadOrder(funnelCookie.ReferralNumber, funnelCookie.ReferralDate, funnelCookie.ReferralCorrelationId, funnelCookie.ReferralParentAccountNumber)).data; } /* @@ -34,13 +34,14 @@ export async function loadOrderIfPresent() { update the cookie. */ export async function saveOrder() { - const savedOrderInfo = await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.SAVE_ORDER); + const savedOrderInfo = await baseMixin.methods.dispatchStoreAction(storeActions.SAVE_ORDER); // Save the referral information back from the store. - await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.SET_REFERRAL_INFORMATION, { + await baseMixin.methods.dispatchStoreAction(storeActions.SET_REFERRAL_INFORMATION, { referralNumber: savedOrderInfo.data.referralNumber, referralCorrelationId: savedOrderInfo.data.referralCorrelationId, referralDate: savedOrderInfo.data.referralDate, + accountNumber: savedOrderInfo.data.accountNumber }, false); // Update the cookie with the referral information when saved. @@ -54,12 +55,13 @@ export async function saveOrder() { Calls API to load order given the referral number, referralDate, and referralCorrelationId and returns the response. */ -async function loadOrder(referralNumber, referralDate, referralCorrelationId) { - const response = await baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.LOAD_ORDER, +async function loadOrder(referralNumber, referralDate, referralCorrelationId, accountNumber) { + const response = await baseMixin.methods.dispatchStoreAction(storeActions.LOAD_ORDER, { referralNumber: referralNumber.toString(), referralDate: referralDate, - referralCorrelationId: referralCorrelationId + referralCorrelationId: referralCorrelationId, + accountNumber: accountNumber?.toString() }, false); return response; diff --git a/src/helpers/heritage-integration/order-helper.spec.js b/src/helpers/heritage-integration/order-helper.spec.js index cbe54ba25..803c94b6b 100644 --- a/src/helpers/heritage-integration/order-helper.spec.js +++ b/src/helpers/heritage-integration/order-helper.spec.js @@ -48,7 +48,7 @@ describe("loadOrderIfPresent", () => { // Assert expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); - expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.RESET_STATE); + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.RESET_STATE); }); test("Funnel cookie is null => store is unchanged", () => { @@ -68,7 +68,7 @@ describe("loadOrderIfPresent", () => { // Assert expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); - expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).not.toHaveBeenCalledWith(storeActions.RESET_STATE); + expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith(storeActions.RESET_STATE); }); test("Funnel cookie valid, should call loadOrder", async () => { @@ -90,7 +90,7 @@ describe("loadOrderIfPresent", () => { // Assert expect(cookieHelper.getFunnelCookie).toHaveBeenCalled(); - expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).not.toHaveBeenCalledWith(storeActions.LOAD_ORDER); + expect(mocks.baseMixin.methods.dispatchStoreAction).not.toHaveBeenCalledWith(storeActions.LOAD_ORDER); expect(result.ReferralNumber).toBe(123456); expect(result.vehicle.year).toBe(2010); }); @@ -127,8 +127,8 @@ describe("saveOrder", () => { await saveOrder(); // Assert - expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.SAVE_ORDER); - expect(mocks.baseMixin.methods.dispatchNonBlockingStoreAction).toHaveBeenCalledWith(storeActions.SET_REFERRAL_INFORMATION, { + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.SAVE_ORDER); + expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(storeActions.SET_REFERRAL_INFORMATION, { referralNumber: mockReferralNumber, referralDate: mockReferralDate, referralCorrelationId: mockCorrelationId diff --git a/src/helpers/unit-test-helper.js b/src/helpers/unit-test-helper.js index 4b9aeaccd..770dd893e 100644 --- a/src/helpers/unit-test-helper.js +++ b/src/helpers/unit-test-helper.js @@ -7,17 +7,25 @@ import { cookieNames } from "@/constants/cookie-names"; import { Form } from "vee-validate"; import baseMixin from "@/mixins/base-mixin"; import { getCookieDomainValue } from "@/helpers/heritage-integration/cookie-helper"; +import { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents } from "@/constants/analytics"; +import { queryStrings } from "@/constants/query-strings"; +import { routerParams } from "@/router/router-constants/router-params"; // 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.dispatchNonBlockingStoreAction) vs this.dispatchNonBlockingStoreAction - setupBaseMixinDispatchNonBlockingStoreAction(mockData); + //this is mocking if you use the mixin directly(baseMixin.methods.dispatchStoreAction) vs this.dispatchStoreAction + setupBaseMixinDispatchStoreAction(mockData); - mocks.dispatchNonBlockingStoreAction = jest.fn(); - mocks.dispatchNonBlockingStoreAction.mockImplementation((actionName) => { + mocks.pushEventToGA = jest.fn(); + mocks.pushPageViewToGA = jest.fn(); + mocks.logEvent = jest.fn(); + mocks.pushExperimentsToDataLayer = jest.fn(); + + mocks.dispatchStoreAction = jest.fn(); + mocks.dispatchStoreAction.mockImplementation((actionName) => { let actionFilterResult = mockData.actionList.filter( (x) => x.actionName == actionName ); @@ -35,6 +43,13 @@ export function getMountOptions(mockData) { mocks.navigationScenarios = navigationScenarios; mocks.vehicleCategories = vehicleCategories; mocks.fmgPageValues = fmgPageValues; + mocks.analyticsPageEvents = analyticsPageEvents; + mocks.GaCategories = GaCategories; + mocks.GaActions = GaActions; + mocks.GaLabels = GaLabels; + mocks.GaEvents = GaEvents; + mocks.queryStrings = queryStrings; + mocks.routerParams = routerParams; // Mock $store and $router when accessing this.$store/$router mocks.$store = mockData.store; @@ -50,7 +65,7 @@ export function getMountOptions(mockData) { } export function setupMocksForJsFiles(mockData = {}) { - setupBaseMixinDispatchNonBlockingStoreAction(mockData); + setupBaseMixinDispatchStoreAction(mockData); return { baseMixin }; } @@ -60,7 +75,10 @@ export const cookies = { [cookieNames.FUNNEL_SESSION_INFO]: `{"ReferralNumber":"1566818","ReferralDate":"2022-03-15T10:56:24.597","ReferralCorrelationId":"404d2b04-f86e-45c3-b373-127b6217b060","ShouldResetState":false,"DidHeritageFunnelUpdateLast":true}`, "UNIQUE_SESSION_ID": "33756020-b58e-4ec7-b8b8-3f1576719c40", "anotherCookie": "{}", - "someOtherCookie": "{}" + "someOtherCookie": "{}", + "dxdev": "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe", + "sid": "cba0c3d1-3c1b-4305-bb56-31aa50f58e27", + "skey": "12345" }; export function removeAllTestCookies() { @@ -87,10 +105,10 @@ export function setupCookies({ funnelCookieValue = "", includeHeritageCookie = t } // Private methods -function setupBaseMixinDispatchNonBlockingStoreAction(mockData) { +function setupBaseMixinDispatchStoreAction(mockData) { if (mockData.actionList !== undefined) { - baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn(); - baseMixin.methods.dispatchNonBlockingStoreAction.mockImplementation((actionName) => { + baseMixin.methods.dispatchStoreAction = jest.fn(); + baseMixin.methods.dispatchStoreAction.mockImplementation((actionName) => { let actionFilterResult = mockData.actionList.filter( (x) => x.actionName == actionName ); diff --git a/src/helpers/validation-rules.spec.js b/src/helpers/validation-rules.spec.js index 323e05ff1..7e0c6304e 100644 --- a/src/helpers/validation-rules.spec.js +++ b/src/helpers/validation-rules.spec.js @@ -1,4 +1,5 @@ import { required } from "@/helpers/validation-rules"; +import { regex } from "@/helpers/validation-rules"; describe("validation-rules.vue", () => { test("required rules should return error if value missing", () => { @@ -15,15 +16,57 @@ describe("validation-rules.vue", () => { }); describe("validation-rules.vue", () => { - test("required rules should return true if value present", () => { - - //Arrange - const testFn = required("an error"); - - //Act - const testResponse = testFn('some value'); - - //Assert - expect(testResponse).toBe(true); - }); - }); \ No newline at end of file + test("required rules should return true if value present", () => { + + //Arrange + const testFn = required("an error"); + + //Act + const testResponse = testFn('some value'); + + //Assert + expect(testResponse).toBe(true); + }); +}); + +describe("validation-rules.vue", () => { + test("regex rules should return true if value is not present", () => { + + //Arrange + const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex + + //Act + const testResponse = testFn(); + + //Assert + expect(testResponse).toBe(true); + }); +}); + +describe("validation-rules.vue", () => { + test("regex rules should return false if value is present but does not match regular expression", () => { + + //Arrange + const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex + + //Act + const testResponse = testFn('4321'); // needs to be 5 numbers + + //Assert + expect(testResponse).toBe("an error"); + }); +}); + +describe("validation-rules.vue", () => { + test("regex rules should return true if value is present and does match regular expression", () => { + + //Arrange + const testFn = regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, "an error"); // Using Zip regex + + //Act + const testResponse = testFn('43213'); // needs to be 5 numbers + + //Assert + expect(testResponse).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/layouts/address-lookup/address-lookup.spec.js1 b/src/layouts/address-lookup/address-lookup.spec.js1 index e69de29bb..43f3649b0 100644 --- a/src/layouts/address-lookup/address-lookup.spec.js1 +++ b/src/layouts/address-lookup/address-lookup.spec.js1 @@ -0,0 +1,244 @@ +// Components +import addressLookup from "@/layouts/address-lookup/address-lookup.vue"; +import funnelHeader from "@/common-components/funnel-header/funnel-header"; +import funnelFooter from "@/common-components/funnel-footer/funnel-footer"; +import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner"; +import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header"; +import customerQuestions from "@/layouts/address-lookup/customer-questions/customer-questions"; +import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions"; + +// Supporting Files +import { settleAllPromises } from "@/helpers/layout-helper.js"; +import baseMixin from "@/mixins/base-mixin"; +import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; +import { mount, flushPromises } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { maska } from 'maska'; +import { nextTick } from "vue"; +import { storeActions } from "@/constants/store-actions"; +import { storeMutations } from "@/constants/store-mutations"; +import store from "@/store"; +import { validate } from "vee-validate"; + +// Mock our module for promises. +jest.mock("@/helpers/layout-helper.js", () => ({ + settleAllPromises: jest.fn(), +})); + +// Mock fetchCmsContentForPage +jest.mock("@/helpers/cms-content-helper", () => ({ + fetchCmsContentForPage: jest.fn(), +})); + +describe("address-lookup.vue", () => { + test("Page header is initialized with api data", async (done) => { + //Arrange + const pageHeaderWidgetHeaderText = "Select Damage"; + const { wrapper, apiPromise } = setupMocks({ + pageHeaderWidgetHeaderText: pageHeaderWidgetHeaderText, + }); + + //Act + addressLookup.beforeRouteEnter.call( + wrapper.vm, + { query: { fmgPage: "address-lookup" } }, + undefined, + (c) => c(wrapper.vm) + ); + + //Assert + apiPromise.finally(() => { + expect(funnelSubHeader.methods.initializeComponent).toHaveBeenCalledWith( + pageHeaderWidgetHeaderText + ); + done(); + }); + }); + + test("Customer Questions component is initialized with api data", async (done) => { + //Arrange + const StreetAddressQuestionWidget = { QuestionText: "test" }; + const CityQuestionWidget = { QuestionText: "test" }; + const StateQuestionWidget = { QuestionText: "test" }; + const ZipQuestionWidget = { QuestionText: "test" }; + const FirstNameQuestionWidget = { QuestionText: "test" }; + const LastNameQuestionWidget = { QuestionText: "test" }; + const EmailAddressQuestionWidget = { QuestionText: "test" }; + const AlertVerificationWarningWidget = { HeaderText: "test", BodyText: "test" }; + const AlertNoMatchWarningWidget = { HeaderText: "test", BodyText: "test" }; + + const widgets = [ + StreetAddressQuestionWidget, + CityQuestionWidget, + StateQuestionWidget, + ZipQuestionWidget, + AlertVerificationWarningWidget, + AlertNoMatchWarningWidget, + FirstNameQuestionWidget, + LastNameQuestionWidget, + EmailAddressQuestionWidget, + ]; + + const { wrapper, apiPromise } = setupMocks({ + cmsContent: widgets, + }); + + //Act + addressLookup.beforeRouteEnter.call( + wrapper.vm, + { query: { fmgPage: "address-lookup" } }, + undefined, + (c) => c(wrapper.vm) + ); + + //Assert + apiPromise.finally(() => { + expect(customerQuestions.methods.initializeComponent).toHaveBeenCalledWith( + widgets + ); + done(); + }); + }); + + }); + + + + function setupMocks({ + pageHeaderWidgetHeaderText = {}, + mountOptionsMockData = { + router: { + navigate: jest.fn(), + }, + store: { + getters: { + vehicle: {}, + }, + }, + }, + }) { + //Mock api responses + baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn(); + const apiResponses = { + cmsContent: { + FunnelSubHeaderWidget: pageHeaderWidgetHeaderText, + VehicleBannerWidget: { + GenericVehicleImage: + "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3", + }, + FunnelHeaderWidget: { + LogoImage: + "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3", + }, + StreetAddressQuestionWidget: { + QuestionText: + "test" + }, + CityQuestionWidget: { + QuestionText: + "test" + }, + StateQuestionWidget: { + QuestionText: + "test" + }, + ZipQuestionWidget: { + QuestionText: + "test" + }, + FirstNameQuestionWidget: { + QuestionText: + "test" + }, + LastNameQuestionWidget: { + QuestionText: + "test" + }, + EmailAddressQuestionWidget: { + QuestionText: + "test" + }, + AlertVerificationWarningWidget: { + HeaderText: + "test", + BodyText: + "test", + }, + AlertNoMatchWarningWidget: { + HeaderText: + "test", + BodyText: + "test", + }, + + }, + }; + + const apiPromise = Promise.resolve(apiResponses); + + settleAllPromises.mockImplementation(() => apiPromise); + fetchCmsContentForPage.mockImplementation(() => Promise.resolve()); + + //Mock damage initialize methods + funnelHeader.methods = { + initializeComponent: jest.fn(), + }; + + vehicleBanner.methods = { + initializeComponent: jest.fn(), + }; + + funnelSubHeader.methods = { + initializeComponent: jest.fn(), + }; + + funnelFooter.methods = { + initializeComponent: jest.fn(), + }; + + customerQuestions.methods = { + initializeComponent: jest.fn(), + }; + + addressQuestions.methods = { + initializeComponent: jest.fn(), + setupAddressLookup: jest.fn(), + }; + + const mountOptions = getMountOptions(mountOptionsMockData); + mountOptions['attachTo'] = document.body; // append wrapper to document.body to test DOM methods + + mountOptions.global.directives = { + maska: maska + }; + + const wrapper = mount(addressLookup, mountOptions); + + const funnelHeaderWrapper = wrapper.findComponent({ name: "funnelHeader" }); + funnelHeaderWrapper.vm.initializeComponent = + funnelHeader.methods.initializeComponent; + + const vehicleBannerWrapper = wrapper.findComponent({ name: "vehicleBanner" }); + vehicleBannerWrapper.vm.initializeComponent = + vehicleBanner.methods.initializeComponent; + + const funnelSubHeaderWrapper = wrapper.findComponent({ name: "funnelSubHeader" }); + funnelSubHeaderWrapper.vm.initializeComponent = + funnelSubHeader.methods.initializeComponent; + + const funnelFooterWrapper = wrapper.findComponent({ name: "funnelFooter" }); + funnelFooterWrapper.vm.initializeComponent = + funnelFooter.methods.initializeComponent; + + const customerQuestionsWrapper = wrapper.findComponent({ name: "customerQuestions" }); + customerQuestionsWrapper.vm.initializeComponent = + customerQuestions.methods.initializeComponent; + + const addressQuestionsWrapper = wrapper.findComponent({ name: "addressQuestions" }); + addressQuestionsWrapper.vm.initializeComponent = + addressQuestions.methods.initializeComponent; + addressQuestionsWrapper.vm.setupAddressLookup = + addressQuestions.methods.setupAddressLookup; + + return { wrapper, apiPromise }; + } \ No newline at end of file diff --git a/src/layouts/address-lookup/address-lookup.vue b/src/layouts/address-lookup/address-lookup.vue index 2086cd032..c18ee545e 100644 --- a/src/layouts/address-lookup/address-lookup.vue +++ b/src/layouts/address-lookup/address-lookup.vue @@ -5,15 +5,54 @@ ref="theForm" v-slot="{ meta }" autocomplete="off" > -
- +
+ - + + + + + + +
+
+
+ +
+
+
+
+
@@ -26,14 +65,27 @@ import funnelFooter from "@/common-components/funnel-footer/funnel-footer"; import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner"; import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header"; import customerQuestions from "@/layouts/address-lookup/customer-questions/customer-questions"; +import alert from "@/ux-components/alert/alert"; +import textboxQuestion from "@/common-components/textbox-question/textbox-question"; + import { Form } from "vee-validate"; +import { defineRule } from "vee-validate"; +import { required } from "@/helpers/validation-rules"; +import { regex } from "@/helpers/validation-rules"; +import { errorMessages } from "@/constants/error-messages"; // Supporting files import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; -import { storeActions } from "@/constants/store-actions"; import store from "@/store"; +import { storeActions } from "@/constants/store-actions"; +import { storeMutations } from "@/constants/store-mutations"; +import baseMixin from "@/mixins/base-mixin"; +import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; +import { getDamageString, isGlassAvailableForCarId } from "@/helpers/damage-helper"; +defineRule("service-zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); +defineRule("service-zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT)); export default { name: "address-lookup", @@ -53,46 +105,29 @@ export default { // Call the "next" function to complete the transition to this page. next((vm) => { - vm.$refs.funnelHeader.initializeComponent( - resultMap.cmsContent.FunnelHeaderWidget - ); - vm.$refs.vehicleBanner.initializeComponent( - resultMap.cmsContent.VehicleBannerWidget - ); - vm.$refs.funnelSubHeader.initializeComponent( - resultMap.cmsContent.FunnelSubHeaderWidget - ); - vm.$refs.funnelFooter.initializeComponent( - resultMap.cmsContent.FunnelFooterWidget - ); - vm.$refs.customerQuestions.initializeComponent([ - resultMap.cmsContent.StreetAddressQuestionWidget, - resultMap.cmsContent.CityQuestionWidget, - resultMap.cmsContent.StateQuestionWidget, - resultMap.cmsContent.ZipQuestionWidget, - resultMap.cmsContent.AlertVerificationWarningWidget, - resultMap.cmsContent.AlertNoMatchWarningWidget, - resultMap.cmsContent.FirstNameQuestionWidget, - resultMap.cmsContent.LastNameQuestionWidget, - resultMap.cmsContent.EmailAddressQuestionWidget, - - ] - ); + vm.setCmsContent(resultMap.cmsContent); }); }, data() { return { customerQuestions: { addressQuestions: { - streetAddress: "", - city: "", - state: "", - zip: "", + streetAddress: this.getRegistrationAddressFromStore(), + city: this.getRegistrationCityFromStore(), + state: this.getRegistrationStateFromStore(), + zip: this.getRegistrationZipFromStore(), }, - firstName: "", - lastName: "", - emailAddress: "", - } + firstName: this.getRegistrationFirstNameFromStore(), + lastName: this.getRegistrationLastNameFromStore(), + emailAddress: this.getEmailFromStore(), + }, + serviceZip: this.getServiceZipFromStore(), + displayNonServiceableZipAlert: false, + displayVinNotFoundAlert: false, + displayMatchedDifferentVehicleAlert: false, + displayVinLookupByHomeAddressNotAllowedAlert: false, + previousCarIdFound: "", + customAlertData: {}, } }, methods: { @@ -100,9 +135,220 @@ export default { return store.getters.vehicle.carId !== null; }, resetDependentState() { - // Invokes + store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, null); + store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES); + }, + backButtonAction() { + // route to move backwards + this.$router.navigate( + this.navigationScenarios.CLICKED_BACK, + this.$route + ); + }, + getRegistrationAddressFromStore() { + return store.getters.vehicle.registration.address; + }, + getRegistrationCityFromStore() { + return store.getters.vehicle.registration.city; + }, + getRegistrationStateFromStore() { + return store.getters.vehicle.registration.state; + }, + getRegistrationZipFromStore() { + return store.getters.vehicle.registration.zipCode; + }, + getRegistrationFirstNameFromStore() { + return store.getters.vehicle.registration.firstName; + }, + getRegistrationLastNameFromStore() { + return store.getters.vehicle.registration.lastName; + }, + getEmailFromStore() { + return store.getters.order.customer.emailAddress; + }, + getServiceZipFromStore() { + return store.getters.order.serviceLocation.zip; + }, + async forwardButtonAction() { + this.resetWarningsAndErrors(); + + // Lookup VIN(s) with the provided address + const vinLookup = await this.lookupVin( + this.customerQuestions.lastName, + this.customerQuestions.addressQuestions.streetAddress, + this.customerQuestions.addressQuestions.zip, + this.customerQuestions.addressQuestions.state + ); + + if (!vinLookup.data.isStatePermissible) { + // State Restrictions forbid lookup by address + this.displayVinLookupByHomeAddressNotAllowedAlert = true; + this.$refs.funnelFooter.removeLoader(); + return; + } + + // Validate if the original or service zip provided is serviceable + const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.customerQuestions.addressQuestions.zip); + if (!zipValidation.data.isServiceable) { + this.displayNonServiceableZipAlert = true; + this.showServiceZipField = true; + this.$refs.funnelFooter.removeLoader(); + } + + const carEntered = store.getters.vehicle; + const carsFound = vinLookup.data.vinVehicles; + if (carsFound.length == 0) { + // No VINs found + this.displayVinNotFoundAlert = true; + this.$refs.funnelFooter.removeLoader(); + return; + } else if (carsFound.length == 1) { + var carFound = carsFound[0].vehicle; + + if (carEntered.carId == carFound.carId || carFound.carId == this.previousCarIdFound) { + // update data + this.updateVehicleInfo(carFound.vin, carFound); + this.updateCustomerInfo(); + + // navigate forward + this.navigateForward(carEntered, carsFound); + } else { + // Display Alert + this.customAlertData.vehicleInfo = carFound; + this.displayMatchedDifferentVehicleAlert = true; + + // Update button "Continue with..." + this.$refs.funnelFooter.updateButtonText(`Continue with ${carFound.year} ${carFound.make} ${carFound.model}`); + this.$refs.funnelFooter.removeLoader(); + } + + this.previousCarIdFound = carFound.carId; + + } else if (vinLookup.data.vinVehicles.length > 1) { + this.updateCustomerInfo(); + + // navigate forward + this.navigateForward(carEntered, carsFound); + } + }, + resetWarningsAndErrors() { + this.displayVinNotFoundAlert = false; + this.displayNonServiceableZipAlert = false; + this.displayMatchedDifferentVehicleAlert = false; + this.displayVinLookupByHomeAddressNotAllowedAlert = false; + }, + async navigateForward(carEntered, carsFound) { + if (carsFound.length == 1) { + // get the damage options for the car that was found + const carFound = carsFound[0].vehicle; + const glassOptions = await baseMixin.methods.dispatchNonBlockingStoreAction( + storeActions.GET_DAMAGE_OPTIONS, + { carId: carFound.carId } + ); + + // if the car entered is the same as the car found OR the glass options for the found car match the users damage selections + if (carEntered.carId == carFound.carId || isGlassAvailableForCarId(carFound.carId)) { + navigateAfterSaveToHeritageFunnel(this.$route); + } else { + // if not then navigate to the "vehicle-damage" page + this.$router.navigateAfterSave(this.navigationScenarios.CLICKED_FORWARD, this.$route, {}, { displayVehicleChangeAlert: true }, {}); + } + } else if (carsFound.length > 1) { + // if multiple cars were found + if (carsFound.find(car => car.carId === carEntered.carId)) { + // and one of them matches the car id entered + navigateAfterSaveToHeritageFunnel(this.$route); + } else { + // and there is no match, navigate to "address-vehicle" page + this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_VEHICLES, this.$route, {}, {}, carsFound); + } + } + + }, + validateZip(zip) { + return baseMixin.methods.dispatchNonBlockingStoreAction( + storeActions.VALIDATE_ZIP, + { zip }); + }, + lookupVin(lastName, streetAddress, zip, state) { + return baseMixin.methods.dispatchNonBlockingStoreAction( + storeActions.LOOKUP_VIN_BY_ADDRESS, + { + licenseLastName: lastName, + licenseStreetAddress: streetAddress, + licenseZip: zip, + licenseState: state + }, false + ); + }, + updateVehicleInfo(vin, vehicleInfo) { + store.commit(storeMutations.UPDATE_VEHICLE_VIN, vin); + store.commit(storeMutations.UPDATE_YEAR, vehicleInfo.year); + store.commit(storeMutations.UPDATE_MAKE, vehicleInfo.make); + store.commit(storeMutations.UPDATE_MODEL, vehicleInfo.model); + store.commit(storeMutations.UPDATE_STYLE, vehicleInfo.style); + store.commit(storeMutations.UPDATE_CAR_ID, vehicleInfo.carId); + store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, vehicleInfo.category); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, vehicleInfo.imageUrl); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, vehicleInfo.imageVifNumber); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, vehicleInfo.imageColor); + }, + updateCustomerInfo() { + store.commit(storeMutations.UPDATE_REGISTRATION_ADDRESS, this.customerQuestions.addressQuestions.streetAddress); + store.commit(storeMutations.UPDATE_REGISTRATION_CITY, this.customerQuestions.addressQuestions.city); + store.commit(storeMutations.UPDATE_REGISTRATION_STATE, this.customerQuestions.addressQuestions.state); + store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, this.customerQuestions.addressQuestions.zipCode); + store.commit(storeMutations.UPDATE_REGISTRATION_FIRST_NAME, this.customerQuestions.firstName); + store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, this.customerQuestions.lastName); + store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, this.serviceZip); + store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.customerQuestions.email); + }, + + }, + computed: { + AlertNonServiceableZipHeader(){ + let zip = this.serviceZip ? this.serviceZip : this.customerQuestions.addressQuestions.zip; + let text = this.getCmsContent("AlertNonServiceableZipWidget", "HeadlineText").replaceAll("{custom:serviceZip}", zip); + return text; + }, + AlertNonServiceableZipBody(){ + return this.getCmsContent("AlertNonServiceableZipWidget", "BodyText"); + }, + AlertMatchedDifferentVehicleHeader(){ + let text = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "HeadlineText").replaceAll("{custom:glassText}", getDamageString()); + return text; + }, + AlertMatchedDifferentVehicleBody(){ + let content = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "BodyText"); + content = content.replaceAll("{custom:glassText}", getDamageString()); + let vinYmmFound = `${this.customAlertData?.vehicleInfo?.year} ${this.customAlertData?.vehicleInfo?.make} ${this.customAlertData?.vehicleInfo?.model}`; + let vinYmmExpected = `${store.getters.vehicle.year} ${store.getters.vehicle.make} ${store.getters.vehicle.model}`; + + content = content.replaceAll("{custom:vinYmmFound}", vinYmmFound); + content = content.replaceAll("{custom:vinYmmExpected}", vinYmmExpected); + + return content; + }, + }, + watch: { + customerQuestions: { + handler(newValue) { + // if they modify one of the lookup fields (address, city, state, zip, or lastName), then modify the button text back to “Get my personalized quote” + this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText")); + this.showServiceZipField = false; + this.resetWarningsAndErrors(); + }, + deep: true + }, + serviceZip: { + handler(newValue) { + // if they modify the service zip, then hide the error message” + this.displayNonServiceableZipAlert = false; + }, + } + }, components: { funnelHeader, @@ -110,6 +356,8 @@ export default { vehicleBanner, funnelSubHeader, customerQuestions, + textboxQuestion, + alert, Form }, }; diff --git a/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue b/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue index 0954caf34..4a772e468 100644 --- a/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue +++ b/src/layouts/address-lookup/customer-questions/address-questions/address-questions.vue @@ -1,36 +1,34 @@ @@ -41,17 +39,17 @@ import textboxQuestion from "@/common-components/textbox-question/textbox-questi import dropdownQuestion from "@/common-components/dropdown-question/dropdown-question"; import alert from "@/ux-components/alert/alert"; import { applicationConfig } from "@/constants/application-config.js"; -import { computed } from 'vue'; import { defineRule } from "vee-validate"; import { required } from "@/helpers/validation-rules"; +import { regex } from "@/helpers/validation-rules"; import { errorMessages } from "@/constants/error-messages"; -//import store from "@/store"; // DEFINE VALIDATION RULES defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQUIRED)); defineRule("city-required", required(errorMessages.CITY_REQUIRED)); defineRule("state-required", required(errorMessages.STATE_REQUIRED)); defineRule("zip-required", required(errorMessages.ZIP_REQUIRED)); +defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT)); export default ({ name: "address-questions", @@ -67,19 +65,7 @@ export default ({ }), }, validationRules: String, - }, - setup(props, { emit }) { - // Please do not modify, this "computed" is used to track and report - // this object's property changes to the parent component - const addressModel = computed({ // Use computed to wrap the object - get: () => props.modelValue, - set: (value) => emit('update:modelValue', value), - }); - - return { - addressModel, - }; - }, + }, data() { return { showAddressFields: false, @@ -148,128 +134,130 @@ export default ({ 'WY': 'Wyoming', } } - } + }, + addressModel: { + get: function() { + return this.modelValue; + }, + set: function(newValue) { + this.$emit("update:modelValue", newValue); + } + }, }, methods: { - initializeComponent(cmsContent) { - this.$refs.autocomplete.initializeComponent(cmsContent[0].QuestionText); - this.$refs.city.initializeComponent(cmsContent[1].QuestionText); - this.$refs.state.initializeComponent(cmsContent[2].QuestionText); - this.$refs.zip.initializeComponent(cmsContent[3].QuestionText); + setupAddressLookup() { + const addressField1 = document.getElementById("autocomplete"); + const self = this; - // assign alert texts to this component - this.alertHeadlineVerificationWarning = cmsContent[4].HeadlineText; - this.alertCopyVerificationWarning = cmsContent[4].BodyText; + const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY; - this.alertHeadlineNoMatchWarning = cmsContent[5].HeadlineText; - this.alertCopyNoMatchWarning = cmsContent[5].BodyText; + this.$loadScript(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`) + .then(() => { + // Script is loaded, initialize the autocomplete textbox + const autocomplete = new window.google.maps.places.Autocomplete( + addressField1, + { + componentRestrictions: { country: ["us"] }, + fields: ["address_components"], + types: ["geocode"], + } + ); + + // Standard place_changed event handling + autocomplete.addListener('place_changed', fillInAddress); + + addressField1.onblur = function() { + const hover = document.querySelector(".pac-container .pac-item:hover"); + // if an item has been clicked, do nothing, otherwise get first solution and use Geocoder to get the place + if (hover === null) { + const item = document.querySelector(".pac-container .pac-item"); + if (item != null) { + const firstResult = item.textContent; + const geocoder = new window.google.maps.Geocoder(); + geocoder.geocode({ + address: firstResult + }, function (results, status) { + if (status === window.google.maps.GeocoderStatus.OK) { + fillInAddress(results[0]); + self.displayVerificationWarning = true; + self.displayNoMatchWarning = false; + } + }); + } + else { + self.addressModel.city = ""; + self.addressModel.state = ""; + self.addressModel.zip = ""; + self.showAddressFields = true; + self.displayVerificationWarning = false; + self.displayNoMatchWarning = true; + } + } + }; + + function fillInAddress(place) { + if (!place) { + place = autocomplete.getPlace(); + } + + if (place && place.address_components) { + self.addressModel.streetAddress= ""; + self.showAddressFields = true; + + for (const component of place.address_components) { + const componentType = component.types[0]; + + switch (componentType) { + case "street_number": { + self.addressModel.streetAddress = component.long_name; + break; + } + case "route": { + self.addressModel.streetAddress += ' ' + component.short_name; + break; + } + case "locality": { + self.addressModel.city = component.long_name; + break; + } + case "administrative_area_level_1": { + self.addressModel.state = component.short_name; + break; + } + case "postal_code": { + self.addressModel.zip = component.long_name; + break; + } + + } + } + + self.displayVerificationWarning = false; + self.displayNoMatchWarning = false; + } + else { + self.displayVerificationWarning = true; + self.displayNoMatchWarning = false; + } + } + }) + .catch(() => { + // Failed to fetch script + console.log("Unable to load Google Places API script"); + }); } }, mounted() { - - const addressField1 = document.getElementById("autocomplete"); - const self = this; - - const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY; - - this.$loadScript(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`) - .then(() => { - // Script is loaded, initialize the autocomplete textbox - const autocomplete = new window.google.maps.places.Autocomplete( - addressField1, - { - componentRestrictions: { country: ["us"] }, - fields: ["address_components"], - types: ["address"], - } - ); - - // Standard place_changed event handling - autocomplete.addListener('place_changed', fillInAddress); - - addressField1.onblur = function() { - const hover = document.querySelector(".pac-container .pac-item:hover"); - - // if an item has been clicked, do nothing, otherwise get first solution and use Geocoder to get the place - if (hover === null) { - const item = document.querySelector(".pac-container .pac-item"); - if (item != null) { - const firstResult = item.textContent; - const geocoder = new window.google.maps.Geocoder(); - geocoder.geocode({ - address: firstResult - }, function (results, status) { - if (status === window.google.maps.GeocoderStatus.OK) { - fillInAddress(results[0]); - self.displayVerificationWarning = true; - self.displayNoMatchWarning = false; - } - }); - } - else { - self.addressModel.city = ""; - self.addressModel.state = ""; - self.addressModel.zip = ""; - self.showAddressFields = true; - self.displayVerificationWarning = false; - self.displayNoMatchWarning = true; - } - - - } - }; - - function fillInAddress(place) { - if (!place) { - place = autocomplete.getPlace(); - } - - if (place && place.address_components) { - self.addressModel.streetAddress= ""; - self.showAddressFields = true; - - for (const component of place.address_components) { - const componentType = component.types[0]; - - switch (componentType) { - case "street_number": { - self.addressModel.streetAddress = component.long_name; - break; - } - case "route": { - self.addressModel.streetAddress += ' ' + component.short_name; - break; - } - case "locality": { - self.addressModel.city = component.long_name; - break; - } - case "administrative_area_level_1": { - self.addressModel.state = component.short_name; - break; - } - case "postal_code": { - self.addressModel.zip = component.long_name; - break; - } - - } - } - - self.displayVerificationWarning = false; - self.displayNoMatchWarning = false; - } - else { - self.displayVerificationWarning = true; - self.displayNoMatchWarning = false; - } - } - }) - .catch(() => { - // Failed to fetch script - console.log("Unable to load Google Places API script"); - }); + this.setupAddressLookup(); }, + watch: { + addressModel: { + handler(newValue){ + this.displayNoMatchWarning = false; + }, + deep: true + } + }, components: { textboxQuestion, dropdownQuestion, diff --git a/src/layouts/address-lookup/customer-questions/customer-questions.vue b/src/layouts/address-lookup/customer-questions/customer-questions.vue index 7fa324f91..e1c505c9e 100644 --- a/src/layouts/address-lookup/customer-questions/customer-questions.vue +++ b/src/layouts/address-lookup/customer-questions/customer-questions.vue @@ -1,18 +1,18 @@ @@ -20,12 +20,10 @@ diff --git a/src/layouts/form-test/form-test.vue b/src/layouts/form-test/form-test.vue index 8e9aefe22..8489f9558 100644 --- a/src/layouts/form-test/form-test.vue +++ b/src/layouts/form-test/form-test.vue @@ -44,7 +44,7 @@ /> @@ -95,7 +95,7 @@ export default { // Call APIs const cmsContentPromise = fetchCmsContentForPage('vehicle-damage'); const damageOptionsPromise = - baseMixin.methods.dispatchNonBlockingStoreAction( + baseMixin.methods.dispatchStoreAction( storeActions.GET_DAMAGE_OPTIONS, { carId: store.getters.vehicle.carId } ); diff --git a/src/layouts/license-plate-lookup/license-plate-lookup.spec.js b/src/layouts/license-plate-lookup/license-plate-lookup.spec.js new file mode 100644 index 000000000..8253a97b5 --- /dev/null +++ b/src/layouts/license-plate-lookup/license-plate-lookup.spec.js @@ -0,0 +1,147 @@ +// Components +import vehicleDamage from "@/layouts/license-plate-lookup/license-plate-lookup.vue"; + +// Supporting Files +import { settleAllPromises } from "@/helpers/layout-helper.js"; +import baseMixin from "@/mixins/base-mixin"; +import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; +import { shallowMount, flushPromises } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { nextTick } from "vue"; +import { storeActions } from "@/constants/store-actions"; +import { storeMutations } from "@/constants/store-mutations"; +import store from "@/store"; +import { validate } from "vee-validate"; + +// Mock our module for promises. +jest.mock("@/helpers/layout-helper.js", () => ({ + settleAllPromises: jest.fn(), +})); + +// Mock fetchCmsContentForPage +jest.mock("@/helpers/cms-content-helper", () => ({ + fetchCmsContentForPage: jest.fn(), +})); + +// Mock Store +jest.mock("@/store", () => ({ + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + order: { + customer: { + emailAddress: "test@test.com" + }, + serviceLocation: { + zip: "43443" + } + }, + vehicle: { + carId: "C00000000", + image: "test.jpg", + payment: { + insuranceCoverage: { + isVerified: false + } + }, + registration: { + licensePlate: "HWV4445", + zipCode: "43224" + } + }, + eventBusItem: jest.fn(), + damage: { + glassToReplace: [] + }, + }, +})); + +describe("license-plate-lookup.vue", () => { + test("CarId set, arePagePrerequisitesValid should be true ", async () => { + //Arrange + const { wrapper } = setupMocks({}); + + //Act + vehicleDamage.beforeRouteEnter.call( + wrapper.vm, + { query: { fmgPage: "license-plate-lookup" } }, + undefined, + (c) => c(wrapper.vm) + ); + + let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + await nextTick(); + + //Assert + expect(arePagePrerequisitesValid).toBe(true); + }); +}); + +describe("license-plate-lookup.vue", () => { + test("BackButtonAction triggers a router.navigate change", async () => { + + //Arrange + const { wrapper } = setupMocks({}); + + //Act + vehicleDamage.beforeRouteEnter.call( + wrapper.vm, + { query: { fmgPage: "license-plate-lookup" } }, + undefined, + (c) => c(wrapper.vm) + ); + + wrapper.vm.backButtonAction(); + + //Assert + expect(wrapper.vm.$router.navigate).toHaveBeenCalled(); + + }); +}); + + +function setupMocks({ + pageHeaderWidgetHeaderText = {}, + mountOptionsMockData = { + router: { + navigate: jest.fn(), + }, + store: { + getters: { + vehicle: {}, + payment: { insuranceCoverage: { isVerified: false } }, + }, + }, + }, +}) { + //Mock api responses + baseMixin.methods.dispatchStoreAction = jest.fn(); + const apiResponses = { + cmsContent: { + FunnelSubHeaderWidget: pageHeaderWidgetHeaderText, + VehicleBannerWidget: { + GenericVehicleImage: + "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3", + }, + FunnelHeaderWidget: { + LogoImage: + "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3", + }, + }, + }; + + const apiPromise = Promise.resolve(apiResponses); + + settleAllPromises.mockImplementation(() => apiPromise); + fetchCmsContentForPage.mockImplementation(() => Promise.resolve()); + + + const mountOptions = getMountOptions(mountOptionsMockData); + mountOptions['attachTo'] = document.body; // append wrapper to document.body to test DOM methods + + const wrapper = shallowMount(vehicleDamage, mountOptions); + + wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent; + + return { wrapper, apiPromise }; +} \ No newline at end of file diff --git a/src/layouts/license-plate-lookup/license-plate-lookup.vue b/src/layouts/license-plate-lookup/license-plate-lookup.vue index a6d181ddd..ecee25f79 100644 --- a/src/layouts/license-plate-lookup/license-plate-lookup.vue +++ b/src/layouts/license-plate-lookup/license-plate-lookup.vue @@ -11,46 +11,48 @@
- +
- +
- +
+
+
+ +
+
-
-
- -
-
@@ -75,8 +77,10 @@ import baseMixin from "@/mixins/base-mixin.js"; import { storeActions } from "@/constants/store-actions"; import { storeMutations } from "@/constants/store-mutations"; import { errorMessages } from "@/constants/error-messages"; +import { getDamageString, isGlassAvailableForCarId } from "@/helpers/damage-helper"; import { required, regex } from "@/helpers/validation-rules"; import { Form, defineRule } from "vee-validate"; +import { navigateAfterSaveToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; // DEFINE VALIDATION RULES defineRule("license-plate-required", required(errorMessages.LICENSE_PLATE_REQUIRED)); @@ -111,15 +115,38 @@ export default { }, data() { return { - newServiceZipRequired: false, - vinNotValid: false, - vinDoesNotMatchCarId: false, - licensePlate: '', - zip: '', - email: '', - serviceZip: '', + isRegistrationZipServicable: true, + isVinValid: true, + isCarIdDifferent: false, + licensePlate: this.getLicensePlateFromStore(), + registrationZip: this.getRegistrationZipFromStore(), + email: this.getEmailFromStore(), + serviceZip: this.getServiceZipFromStore(), + previouslyEnteredCarId: '', + customAlertData: {}, + isSelectedGlassAvailableForVehicle: true, }; }, + computed: { + MatchedDifferentVehicleAlertHeader(){ + let text = this.getCmsContent("MatchedDifferentVehicleAlertWidget", "HeadlineText").replaceAll("{custom:damage}", getDamageString()); + + return text; + }, + MatchedDifferentVehicleAlertBody(){ + let text = this.getCmsContent("MatchedDifferentVehicleAlertWidget", "BodyText").replaceAll("{custom:damage}", getDamageString()).replaceAll("{custom:plateLookupYear}", this.customAlertData?.vehicleInfo?.year).replaceAll("{custom:plateLookupMake}", this.customAlertData?.vehicleInfo?.make).replaceAll("{custom:plateLookupModel}", this.customAlertData?.vehicleInfo?.model); + + return text; + }, + NoServiceZipHeader(){ + let text = this.getCmsContent("NoServiceZipWidget", "HeadlineText").replaceAll("{custom:zip}", this.registrationZip); + + return text; + }, + NoServiceZipBody(){ + return this.getCmsContent("NoServiceZipWidget", "BodyText"); + }, + }, methods: { arePagePrerequisitesValid() { return store.getters.vehicle.carId !== null; @@ -131,84 +158,120 @@ export default { store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, null); store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES); }, + getLicensePlateFromStore(){ + return store.getters.vehicle.registration.licensePlate + }, + getRegistrationZipFromStore(){ + return store.getters.vehicle.registration.zipCode + }, + getEmailFromStore(){ + return store.getters.order.customer.emailAddress + }, + getServiceZipFromStore(){ + return store.getters.order.serviceLocation.zip + }, backButtonAction() { this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route); }, async forwardButtonAction() { - const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.zip); + + this.pushEventToGA(this.$route.query[this.queryStrings.FMG_PAGE], this.GaActions.SUBMITTED, this.GaLabels.LICENSE_PLATE_LOOKUP , true); + + const zipValidation = this.serviceZip ? await this.validateZip(this.serviceZip) : await this.validateZip(this.registrationZip); if (!zipValidation.data.isServiceable) { this.$refs.funnelFooter.removeLoader(); - this.newServiceZipRequired = true; - return; - } - const vinLookup = await this.lookupVin(this.licensePlate, zipValidation.data.state).catch(() => { - this.$refs.funnelFooter.removeLoader(); - this.vinNotValid = true; - return; - }); - if (vinLookup.data.vehicle.carId !== store.getters.vehicle.carId) { - this.$refs.funnelFooter.updateButtonText(`Continue with ${vinLookup.data.vin} ${vinLookup.data.vehicle.year} ${vinLookup.data.vehicle.make} ${vinLookup.data.vehicle.model}`); - this.$refs.funnelFooter.removeLoader(); - this.vinDoesNotMatchCarId = true; + this.isVinValid = true; + this.isRegistrationZipServicable = false; + this.isCarIdDifferent = false; return; } - const partsData = await baseMixin.methods.dispatchNonBlockingStoreAction( + const vinLookup = await this.lookupVin(this.licensePlate, zipValidation.data.state).catch(() => { + this.$refs.funnelFooter.removeLoader(); + this.isVinValid = false; + this.isCarIdDifferent = false; + return; + }); + + this.isCarIdDifferent = vinLookup.data.vehicle.carId !== store.getters.vehicle.carId; + + if (this.isCarIdDifferent && (vinLookup.data.vehicle.carId !== this.previouslyEnteredCarId)) { + this.previouslyEnteredCarId = vinLookup.data.vehicle.carId; + this.customAlertData.vehicleInfo = vinLookup.data.vehicle; + this.$refs.funnelFooter.updateButtonText(`Continue with ${vinLookup.data.vehicle.year} ${vinLookup.data.vehicle.make} ${vinLookup.data.vehicle.model}`); + this.isVinValid = true; + this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(vinLookup.data.vehicle.carId); + this.$refs.funnelFooter.removeLoader(); + return; + } + + this.updateCustomerInfo(vinLookup.data.vin, vinLookup.data.vehicle, zipValidation.data.state); + + const partsData = await baseMixin.methods.dispatchStoreAction( this.storeActions.GET_PARTS_OR_QUESTIONS, { - carId: store.getters.vehicle.carId, - glassArray: store.getters.damage.glassToReplace, - zipCode: this.zip, - vin: vinLookup.vin + carId: vinLookup.data.vehicle.carId, + glassArray: this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle ? [] : store.getters.damage.glassToReplace, + zipCode: this.serviceZip ? this.serviceZip : this.registrationZip, + vin: vinLookup.data.vin }, false ); this.navigateForward(partsData); }, navigateForward(partsData){ - if(partsData.data.partsOrQuestions[0].partQuestions && partsData.data.partsOrQuestions[0].partQuestions.length > 0){ - this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_PARTS_QUESTION, this.$route, {}, {}, partsData.data); + if(this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle){ + this.$router.navigateAfterSave(this.navigationScenarios.CLICKED_FORWARD, this.$route, {}, { displayVehicleChangeAlert: true }, partsData.data); return; - } else if((!partsData.data.partsOrQuestions[0].partQuestions || partsData.data.partsOrQuestions[0].partQuestions.length < 1) && partsData.data.partsOrQuestions[0].parts.length > 1) { - this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_PARTS, this.$route, {}, {}, partsData.data); - return; } else { - this.$router.navigate(this.navigationScenarios.CONTINUING_WITH_SINGLE_PART, this.$route); + navigateAfterSaveToHeritageFunnel(this.$route); + return; } }, validateZip(zip) { - return baseMixin.methods.dispatchNonBlockingStoreAction( + return baseMixin.methods.dispatchStoreAction( storeActions.VALIDATE_ZIP, { zip } ); }, lookupVin(plate, state) { - return baseMixin.methods.dispatchNonBlockingStoreAction( + return baseMixin.methods.dispatchStoreAction( storeActions.LOOKUP_VIN_BY_PLATE, { licensePlate: plate, licenseState: state } ); }, - updateStore() { - // if(vehicleDamage){ - // store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES); - // } - store.commit(storeMutations.UPDATE_VEHICLE_VIN, null); - store.commit(storeMutations.UPDATE_YEAR, null); - store.commit(storeMutations.UPDATE_MAKE, null); - store.commit(storeMutations.UPDATE_MODEL, null); - store.commit(storeMutations.UPDATE_STYLE, null); - store.commit(storeMutations.UPDATE_CAR_ID, null); - store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, null); - store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, null); - store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, null); - store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, null); - store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, null); - store.commit(storeMutations.UPDATE_REGISTRATION_STATE, null); - store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, null); - store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, null); - store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, null); + updateCustomerInfo(vin, vehicleInfo, registrationState) { + if(this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle){ + store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES); + } + store.commit(storeMutations.UPDATE_VEHICLE_VIN, vin); + store.commit(storeMutations.UPDATE_YEAR, vehicleInfo.year); + store.commit(storeMutations.UPDATE_MAKE, vehicleInfo.make); + store.commit(storeMutations.UPDATE_MODEL, vehicleInfo.model); + store.commit(storeMutations.UPDATE_STYLE, vehicleInfo.style); + store.commit(storeMutations.UPDATE_CAR_ID, vehicleInfo.carId); + store.commit(storeMutations.UPDATE_VEHICLE_CATEGORY, vehicleInfo.category); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, vehicleInfo.imageUrl); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, vehicleInfo.imageVifNumber); + store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, vehicleInfo.imageColor); + store.commit(storeMutations.UPDATE_REGISTRATION_LICENSE_PLATE, this.licensePlate); + store.commit(storeMutations.UPDATE_REGISTRATION_STATE, registrationState); + store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, this.registrationZip); + store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP, this.serviceZip); + store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.email); }, }, + watch: { + licensePlate() { + this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText")); + }, + registrationZip(){ + this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText")); + }, + serviceZip(){ + this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText")); + } + }, components: { Form, funnelHeader, diff --git a/src/layouts/part-questions/part-questions.vue b/src/layouts/part-questions/part-questions.vue index c1b9142bd..0818bb46f 100644 --- a/src/layouts/part-questions/part-questions.vue +++ b/src/layouts/part-questions/part-questions.vue @@ -1,10 +1,12 @@ @@ -49,18 +51,7 @@ export default { // Call the "next" function to complete the transition to this page. next((vm) => { - vm.$refs.funnelHeader.initializeComponent( - resultMap.cmsContent.FunnelHeaderWidget - ); - vm.$refs.vehicleBanner.initializeComponent( - resultMap.cmsContent.VehicleBannerWidget - ); - vm.$refs.funnelSubHeader.initializeComponent( - resultMap.cmsContent.FunnelSubHeaderWidget - ); - vm.$refs.funnelFooter.initializeComponent( - resultMap.cmsContent.FunnelFooterWidget - ); + vm.setCmsContent(resultMap.cmsContent); }); }, methods: { diff --git a/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue b/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue index 690e7df78..16d67b3ee 100644 --- a/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue +++ b/src/layouts/vehicle-damage/damage-location-question/damage-location-question.vue @@ -6,6 +6,7 @@ :answers="answersToDisplay" :groupName="groupName" buttonType="listCard" + isRequired v-model="selectedValues" validationRules="damage-location-required" /> @@ -30,11 +31,7 @@ export default ({ } }, props: { - isMultiSelect: Boolean, modelValue: Array, - isAvailable: Boolean, - filterByVehicleCategory: Boolean, - name: String, groupName: String, cmsWidgetName: String, }, diff --git a/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue b/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue index e2a6bb426..53ef1e2fa 100644 --- a/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue +++ b/src/layouts/vehicle-damage/replace-options-question/replace-options-question.vue @@ -11,6 +11,7 @@ v-model="selectedValues" :validationRules="validationRules" :suppressError="suppressError" + :isRequired=isRequired />
@@ -36,6 +37,7 @@ export default ({ validationRules: String, suppressError: Boolean, cmsWidgetName: String, + isRequired: Boolean, }, methods: { initializeComponent(replaceOptions){ diff --git a/src/layouts/vehicle-damage/side-door-options/side-door-options.vue b/src/layouts/vehicle-damage/side-door-options/side-door-options.vue index a79cdf49f..b4df1121e 100644 --- a/src/layouts/vehicle-damage/side-door-options/side-door-options.vue +++ b/src/layouts/vehicle-damage/side-door-options/side-door-options.vue @@ -10,6 +10,7 @@ buttonType="listCard" v-model="selectedDoorSidesValues" validationRules="damage-side-required" + isRequired /> @@ -22,6 +23,7 @@ filterByVehicleCategory v-model="selectedDriverSideReplaceOptionsValues" validationRules="driver-side-options-required" + isRequired /> diff --git a/src/layouts/vehicle-damage/vehicle-damage.spec.js b/src/layouts/vehicle-damage/vehicle-damage.spec.js index fe3a4aa51..9a9f7e6d7 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.spec.js +++ b/src/layouts/vehicle-damage/vehicle-damage.spec.js @@ -17,6 +17,7 @@ import { storeMutations } from "@/constants/store-mutations"; import store from "@/store"; import { validate } from "vee-validate"; import { damageLocationsSelected } from "@/constants/damage-locations-selected.js"; +import { routerParams } from "@/router/router-constants/router-params"; // Mock our module for promises. jest.mock("@/helpers/layout-helper.js", () => ({ @@ -36,6 +37,11 @@ jest.mock("@/store", () => ({ vehicle: { carId: "C00000000", image: "test.jpg", + payment: { + insuranceCoverage: { + isVerified: false + } + } }, eventBusItem: jest.fn(), damage: { @@ -134,6 +140,7 @@ describe("vehicle-damage.vue", () => { store: { getters: { vehicle: {}, + payment: { insuranceCoverage: { isVerified: false } }, }, }, }, @@ -201,6 +208,7 @@ describe("vehicle-damage.vue", () => { store: { getters: { vehicle: {}, + payment: { insuranceCoverage: { isVerified: false } }, }, }, }, @@ -291,6 +299,7 @@ describe("vehicle-damage.vue", () => { store: { getters: { vehicle: {}, + payment: { insuranceCoverage: { isVerified: false } }, }, }, }, @@ -336,6 +345,7 @@ describe("vehicle-damage.vue", () => { store: { getters: { vehicle: {}, + payment: { insuranceCoverage: { isVerified: false } }, }, }, }, @@ -694,6 +704,57 @@ describe("vehicle-damage.vue", () => { }); }); +describe("vehicle-damage.vue", () => { + test("when displayVehicleChangeAlert router params is true, the alert: 'vehicleChangeAlert' should be visible", () => { + // Arrange & Act + const { wrapper } = setupMocks({ + mountOptionsMockData: { + route: { + params: { + [routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: true + } + } + } + }); + // Assert + expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(true); + }) +}); + +describe("vehicle-damage.vue", () => { + test("when displayVehicleChangeAlert router params is false, the alert: 'vehicleChangeAlert' should not be visible", () => { + // Arrange & Act + const { wrapper } = setupMocks({ + mountOptionsMockData: { + route: { + params: { + [routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: false + } + } + } + }); + // Assert + expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(false); + }) +}); + +describe("vehicle-damage.vue", () => { + test("when displayVehicleChangeAlert router params is undefined, the alert: 'vehicleChangeAlert' should not be visible", () => { + // Arrange & Act + const { wrapper } = setupMocks({ + mountOptionsMockData: { + route: { + params: { + [routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: undefined + } + } + } + }); + // Assert + expect(wrapper.findComponent({ref: 'vehicleChangeAlert'}).isVisible()).toBe(false); + }) +}); + // THE FOLLOWING TEST IS NOT NECESSARILY REQUIRED FOR COVERAGE // BUT KEEP FOR AN EXAMPLE OF A VALIDATION TEST // @@ -725,31 +786,41 @@ describe("vehicle-damage.vue", () => { }); -function setupMocks({ - pageHeaderWidgetHeaderText = {}, - mountOptionsMockData = { +function setupMocks({pageHeaderWidgetHeaderText, mountOptionsMockData}) { + var pageHeaderWidgetHeaderTextDefault = {}; + var mountOptionsMockDataDefault = { router: { navigate: jest.fn(), }, + route: { + params: { + [routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: false + } + }, store: { getters: { vehicle: {}, + payment: { + insuranceCoverage: { + isVerified: false + } + }, }, }, - }, -}) { + }; + // Combine parameters with default values + pageHeaderWidgetHeaderText = Object.assign(pageHeaderWidgetHeaderTextDefault, pageHeaderWidgetHeaderText); + mountOptionsMockData = Object.assign(mountOptionsMockDataDefault, mountOptionsMockData); //Mock api responses - baseMixin.methods.dispatchNonBlockingStoreAction = jest.fn(); + baseMixin.methods.dispatchStoreAction = jest.fn(); const apiResponses = { cmsContent: { FunnelSubHeaderWidget: pageHeaderWidgetHeaderText, VehicleBannerWidget: { - GenericVehicleImage: - "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3", + GenericVehicleImage: "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/blurred-image.jpg?sfvrsn=a6ce3034_3", }, FunnelHeaderWidget: { - LogoImage: - "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3", + LogoImage: "https://digitalconsumercms-dev.safelite.com/images/default-source/default-album/safelite-logo.svg?sfvrsn=45e7ed06_3", }, }, damageOptions: { diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index be071a76a..862abaced 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -9,48 +9,57 @@ - - - - - - + + + + + + + @@ -77,6 +86,7 @@ import { errorMessages } from "@/constants/error-messages"; import { damageLocationsCms } from "@/constants/damage-locations-cms.js"; import { damageLocationsSelected } from "@/constants/damage-locations-selected.js"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; +import { queryStrings } from "@/constants/query-strings"; import store from "@/store"; import baseMixin from "@/mixins/base-mixin"; @@ -90,7 +100,7 @@ export default { // Call APIs const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage); const damageOptionsPromise = - baseMixin.methods.dispatchNonBlockingStoreAction( + baseMixin.methods.dispatchStoreAction( storeActions.GET_DAMAGE_OPTIONS, { carId: store.getters.vehicle.carId } ); @@ -138,6 +148,12 @@ export default { selectedRearReplaceOptions: this.getRearReplaceOptionsFromStore(), } }, + mounted(){ + if(this.$store.getters.vehicle.imageVifNumber){ + this.pushEventToGA(this.GaCategories.EVOX, `${this.GaActions.VIF}_${this.$store.getters.vehicle.imageVifNumber}`, + this.$store.getters.vehicle.carId, true); + } + }, methods: { arePagePrerequisitesValid() { if(store.getters.vehicle.carId){ @@ -145,9 +161,11 @@ export default { } return false; }, + resetDependentState() { store.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES); }, + backButtonAction() { // route to move backwards this.$router.navigate( @@ -155,19 +173,20 @@ export default { this.$route ); }, + getDamageLocationsFromStore() { var glassSelections = []; - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD }) || + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD }) || store.getters.damage.isRepair) { glassSelections.push(damageLocationsSelected.WINDSHIELD); } - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.DRIVER || + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.DRIVER || glass.location === damageLocationsSelected.PASSENGER })) { glassSelections.push(damageLocationsSelected.SIDEDOOR); } - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.REAR })) { + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.REAR })) { glassSelections.push(damageLocationsSelected.REARWINDOW); } @@ -180,19 +199,19 @@ export default { if (store.getters.damage.isRepair === undefined) return windshieldOptions; if (!store.getters.damage.isRepair) { - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && glass.name === damageLocationsSelected.SINGLE })) { windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE); windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.SINGLE); } - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && glass.name === damageLocationsSelected.DRIVER })) { windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE); windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.DRIVER); } - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.WINDSHIELD && glass.name === damageLocationsSelected.PASSENGER })) { windShieldOptions.selectedWindshieldDamageType.push(damageLocationsSelected.REPLACE); windShieldOptions.selectedWindshieldReplaceOptions.push(damageLocationsSelected.PASSENGER); @@ -210,11 +229,11 @@ export default { getDoorSidesFromStore() { var doorSides = []; - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.DRIVER })){ + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.DRIVER })){ doorSides.push(damageLocationsSelected.DRIVERSIDE); } - if (store.getters.damage.glassToReplace.some(glass => { return glass.location === damageLocationsSelected.PASSENGER })){ + if (store.getters.damage.glassToReplace?.some(glass => { return glass.location === damageLocationsSelected.PASSENGER })){ doorSides.push(damageLocationsSelected.PASSENGERSIDE); } @@ -224,7 +243,7 @@ export default { getDriverSideReplaceOptionsFromStore() { var driverSideReplaceOptions = []; - store.getters.damage.glassToReplace.forEach(glass => { + store.getters.damage.glassToReplace?.forEach(glass => { if (glass.location === damageLocationsSelected.DRIVER){ driverSideReplaceOptions.push(glass.name); } @@ -236,7 +255,7 @@ export default { getPassengerSideReplaceOptionsFromStore() { var passengerSideReplaceOptions = []; - store.getters.damage.glassToReplace.forEach(glass => { + store.getters.damage.glassToReplace?.forEach(glass => { if (glass.location === damageLocationsSelected.PASSENGER){ passengerSideReplaceOptions.push(glass.name); } @@ -248,7 +267,7 @@ export default { getRearReplaceOptionsFromStore(){ var rearReplaceOptions = []; - store.getters.damage.glassToReplace.forEach(glass => { + store.getters.damage.glassToReplace?.forEach(glass => { if (glass.location === damageLocationsSelected.REAR){ rearReplaceOptions.push(glass.name); } @@ -268,20 +287,33 @@ export default { store.commit(this.storeMutations.UPDATE_GLASS_TO_REPLACE, this.selectedGlassToReplace()); - const partsData = await baseMixin.methods.dispatchNonBlockingStoreAction(this.storeActions.GET_PARTS_OR_QUESTIONS, + const partsData = await baseMixin.methods.dispatchStoreAction(this.storeActions.GET_PARTS_OR_QUESTIONS, { carId: store.getters.vehicle.carId, glassArray: this.selectedGlassToReplace()}, false); this.navigateForward(partsData); }, navigateForward(partsData){ - // CSR-98 TEMP - const vehicleYearsToShowHeritageFunnel = [ 2001, 2002, 2010 ]; + + // Temporary easter egg to navigate to heritage funnel. + const vehicleYearsToShowHeritageFunnel = [ 2001, 2002, 2010, 2016 ]; if (vehicleYearsToShowHeritageFunnel.includes(store.getters.vehicle.year)) { navigateToHeritageFunnel(); return; } + // Temporary easter egg to navigate to address-lookup. + if (store.getters.vehicle.year === 2014) { + this.$router.navigateAfterSave(this.navigationScenarios.TEMPORARY_TO_ADDRESS_LOOKUP, this.$route, {}, {}, partsData.data); + return; + } + + // If vin already exists, navigate directly to vin-lookup + if(this.$store.getters.vehicle.vin){ + this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD_WITH_VIN, this.$route); + return; + } + //found problem questions if (partsData.data.partsOrQuestions.some(pq => pq.partQuestions != null && pq.partQuestions.length > 0)){ this.$router.navigateAfterSave(this.navigationScenarios.SELECTED_DAMAGE_WITH_PART_QUESTIONS, this.$route, {}, {}, partsData.data); @@ -382,7 +414,7 @@ export default { return this.isWindshieldDamageLocation && this.selectedDamageLocations.length > 1 && this.isWindshieldRepair; }, hasSplitSingleConflict() { - if (!this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false; + if (!this.selectedDamageLocations || !this.selectedDamageLocations.includes("Windshield") || !this.selectedWindshieldOptions.selectedWindshieldDamageType || !this.selectedWindshieldOptions.selectedWindshieldDamageType.includes("Replace") || !this.selectedWindshieldOptions.selectedWindshieldReplaceOptions) return false; return this.selectedWindshieldOptions.selectedWindshieldReplaceOptions.some(selectedSingleWindshield => { @@ -398,6 +430,12 @@ export default { }) ); }, + shouldDisplayVehicleChangeAlert() { + return this.$route.params[this.routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]; + }, + shouldHideBackButton(){ + return this.$store.getters.payment.insuranceCoverage.isVerified; + } }, components: { diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue b/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue index fc29d1c34..25d273eca 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-chip-count-question/windshield-chip-count-question.vue @@ -9,6 +9,7 @@ useTextForValue v-model="selectedChipCountValues" :validationRules="validationRules" + isRequired /> diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue b/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue index 3c377ec88..455c27361 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-damage-type-question/windshield-damage-type-question.vue @@ -9,6 +9,7 @@ v-model="selectedValues" :suppressError="suppressError" :validationRules="validationRules" + isRequired /> diff --git a/src/layouts/vehicle-damage/windshield-options/windshield-options.vue b/src/layouts/vehicle-damage/windshield-options/windshield-options.vue index df2560e17..1e9cfbc0a 100644 --- a/src/layouts/vehicle-damage/windshield-options/windshield-options.vue +++ b/src/layouts/vehicle-damage/windshield-options/windshield-options.vue @@ -27,6 +27,7 @@ v-model="selectedWindshieldReplaceOptionsValues" validationRules="windshield-replace-options-required|prevent-split-and-single-together" :suppressError="hasSplitSingleConflict" + isRequired /> diff --git a/src/layouts/vehicle-style/style-question/style-question.vue b/src/layouts/vehicle-style/style-question/style-question.vue index 0d3dd54ac..45c0f7dea 100644 --- a/src/layouts/vehicle-style/style-question/style-question.vue +++ b/src/layouts/vehicle-style/style-question/style-question.vue @@ -5,7 +5,7 @@ selectingInitiatesLoad :questionText="questionText" :answers="styles" - groupName="Choose Vehicle Style" + groupName="ChooseVehicleStyle" textPosition="text-start" v-model="selectedValueAsArray" isRequired=true @@ -50,7 +50,7 @@ export default { }, methods: { loadInitialData() { - return baseMixin.methods.dispatchNonBlockingStoreAction( + return baseMixin.methods.dispatchStoreAction( storeActions.GET_VEHICLE_STYLES, { year: store.getters.vehicle.year, diff --git a/src/layouts/vehicle-style/vehicle-style.spec.js b/src/layouts/vehicle-style/vehicle-style.spec.js index 852bf248d..3d62e8f15 100644 --- a/src/layouts/vehicle-style/vehicle-style.spec.js +++ b/src/layouts/vehicle-style/vehicle-style.spec.js @@ -87,7 +87,7 @@ describe("vehicle-style.vue", () => { }); describe("vehicle-style.vue", () => { - test("selectVehicle triggers a dispatchNonBlockingStoreAction commit", async (done) => { + test("selectVehicle triggers a dispatchStoreAction commit", async (done) => { //Arrange const { wrapper, apiPromise } = setupMocks({ pageHeaderWidgetHeaderText: "Select a style to get started", @@ -119,7 +119,7 @@ describe("vehicle-style.vue", () => { //Assert apiPromise.finally(() => { - expect(wrapper.vm.dispatchNonBlockingStoreAction).toHaveBeenCalled(); + expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalled(); done(); }); }); diff --git a/src/layouts/vehicle-style/vehicle-style.vue b/src/layouts/vehicle-style/vehicle-style.vue index e98cc400d..8bb542154 100644 --- a/src/layouts/vehicle-style/vehicle-style.vue +++ b/src/layouts/vehicle-style/vehicle-style.vue @@ -76,7 +76,7 @@ export default { ); }, setVehicle() { - return this.dispatchNonBlockingStoreAction( + return this.dispatchStoreAction( this.storeActions.SET_VEHICLE, { year: this.$store.getters.vehicle.year, diff --git a/src/layouts/vehicle-year/vehicle-year.vue b/src/layouts/vehicle-year/vehicle-year.vue index 85faa0d61..fec51eafd 100644 --- a/src/layouts/vehicle-year/vehicle-year.vue +++ b/src/layouts/vehicle-year/vehicle-year.vue @@ -44,14 +44,11 @@ export default { const yearQuestionInitialDataPromise = yearQuestion.methods.loadInitialData(); // Log experiment exposure - const logExperimentExposurePromise = baseMixin.methods.dispatchNonBlockingStoreAction(storeActions.LOG_EXPERIMENT_EXPOSURE, + const logExperimentExposurePromise = baseMixin.methods.dispatchStoreAction(storeActions.LOG_EXPERIMENT_EXPOSURE, { userId: getDeviceIdValue(), sessionKey: getSessionKeyValue(), - sessionId: getSessionIdValue(), pageName: to.query.fmgPage, - serverName: 'ConceptFunnel', - pageEvent: { action: '', event: 'ENTRY' }, universeName: experimentUniverses.CONCEPT_FUNNEL }, false); diff --git a/src/layouts/vehicle-year/year-question/year-question.vue b/src/layouts/vehicle-year/year-question/year-question.vue index 8a53e83db..a5b263fe8 100644 --- a/src/layouts/vehicle-year/year-question/year-question.vue +++ b/src/layouts/vehicle-year/year-question/year-question.vue @@ -5,7 +5,7 @@ selectingInitiatesLoad :questionText="questionText" :answers="years" - groupName="Choose Vehicle Year" + groupName="ChooseVehicleYear" textPosition="text-start" v-model="selectedValueAsArray" isRequired=true @@ -48,7 +48,7 @@ export default { }, methods: { loadInitialData() { - return baseMixin.methods.dispatchNonBlockingStoreAction( + return baseMixin.methods.dispatchStoreAction( storeActions.GET_VEHICLE_YEARS, {} ); diff --git a/src/layouts/vin-lookup/vin-lookup.vue b/src/layouts/vin-lookup/vin-lookup.vue index fc9058abf..4064da145 100644 --- a/src/layouts/vin-lookup/vin-lookup.vue +++ b/src/layouts/vin-lookup/vin-lookup.vue @@ -1,41 +1,274 @@ - - diff --git a/src/main.js b/src/main.js index fd53bde6d..6a81170db 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import App from "./App.vue"; import router from "./router"; import store from "@/store"; import baseMixin from "@/mixins/base-mixin.js"; +import analyticsMixin from "@/mixins/analytics-mixin.js"; import "../node_modules/bootstrap/dist/js/bootstrap.js"; // Vue App Setup @@ -15,5 +16,6 @@ vueApp.use(store); vueApp.use(LoadScript); vueApp.use(Maska); vueApp.mixin(baseMixin); +vueApp.mixin(analyticsMixin); vueApp.mount("#app"); diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js new file mode 100644 index 000000000..c2c86c2dd --- /dev/null +++ b/src/mixins/analytics-mixin.js @@ -0,0 +1,116 @@ +import { storeActions } from "@/constants/store-actions"; +import { getDeviceIdValue, getSessionIdValue, getSessionKeyValue } from "@/helpers/heritage-integration/cookie-helper"; +import { queryStrings } from "@/constants/query-strings"; +import { experimentSettings } from "@/constants/experiments"; +import { analyticsPageEvents, GaCategories, GaActions, GaLabels, GaEvents } from "@/constants/analytics"; + +import baseMixin from "@/mixins/base-mixin"; + +// We will need current page name for multiple methods, define it once to re-use. +const currentPageName = getPageNameByQueryString(); + +export default { + methods: { + logEvent(pageEvent, category, action, label, value) { + var payload = { + userId: getDeviceIdValue(), + sessionKey: getSessionKeyValue(), + pageName: currentPageName, + sessionId: getSessionIdValue(), + shouldUseSessionId: true, + }; + + if (pageEvent) { + payload.pageEvent = { action: '', event: pageEvent }; + } + + if (category) { + payload.customEvent = { category: category, action: action, label: label, value: value }; + } + + baseMixin.methods.dispatchStoreAction(storeActions.LOG_ACTIVITY, payload, false); + }, + + pushEventToGA(category, action, label, pushToLogApp = false) { + const eventToBePushed = { + 'event': GaEvents.GENERIC_EVENT, + 'category': category, + 'action': action, + 'label': label, + 'value': undefined, + 'path': `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}` + } + + pushToDataLayerIfDefined(eventToBePushed); + + if (pushToLogApp) { + this.logEvent(undefined, category, action, label, undefined); + } + + }, + + pushPageViewToGA() { + const pageViewEvent = { + 'event': GaEvents.PAGE_VIEW_EVENT, + 'pagePath': `/fmg/?${queryStrings.FMG_PAGE}=${currentPageName}`, + 'pageTitle': currentPageName + }; + + pushToDataLayerIfDefined(pageViewEvent); + + this.logEvent(currentPageName, analyticsPageEvents.ENTRY); + }, + + pushExperimentsToDataLayer(experiments) { + experiments?.data?.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 = {}; + + Object.keys(exp).forEach(key => { + experimentWithDimension[`${key}_${googleDimensionIndex}`] = exp[key]; + }); + + // Push to the data layer with the Google Custom Dimension Index. + pushToDataLayerIfDefined(experimentWithDimension); + }); + } + }, + computed: { + analyticsPageEvents() { + return analyticsPageEvents; + }, + GaCategories() { + return GaCategories; + }, + GaActions() { + return GaActions; + }, + GaLabels() { + return GaLabels; + } + } +}; + +function pushToDataLayerIfDefined(data) { + if (window.dataLayer !== undefined) { + window.dataLayer.push(data); + } +} + +function getPageNameByQueryString() { + const params = new URLSearchParams(location.search); + + if (params.has(queryStrings.FMG_PAGE)) { + return params.get(queryStrings.FMG_PAGE); + } else { + return ''; + } +} \ No newline at end of file diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js new file mode 100644 index 000000000..b4ec2a5b5 --- /dev/null +++ b/src/mixins/analytics-mixin.spec.js @@ -0,0 +1,83 @@ +import analyticsMixin from "@/mixins/analytics-mixin"; +import { setupMocksForJsFiles } from "@/helpers/unit-test-helper.js"; +import { storeActions } from "@/constants/store-actions"; + +describe("analyticsMixin.js", () => { + test("logEvent: calls dispatch with type and payload", () => { + const type = ""; + const payload = {}; + + const mockData = { + actionList: [{ + actionName: storeActions.LOG_ACTIVITY + }], + } + const mocks = setupMocksForJsFiles(mockData); + + analyticsMixin.methods.logEvent(type, payload); + + expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); + }); + + test("pushEventToGA, should call logEvent too", () => { + // Arrange + const mockData = { + actionList: [{ + actionName: storeActions.LOG_ACTIVITY + }], + } + const mocks = setupMocksForJsFiles(mockData); + + // Act + analyticsMixin.methods.pushEventToGA('category', 'action', 'label', true); + + // Assert + expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); + + }); + + test("Experiments, should push to dataLayer with default Google Custom Dimension Index", () => { + // Arrange + window.dataLayer = []; + + const mockExperimentData = { + data: [ + { + settings: {}, + variationName: 'test', + universeName: 'testUniverse' + } + ] + } + + // Act + analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); + + // Assert + expect(window.dataLayer).toEqual([ { settings_99: {}, variationName_99: 'test', universeName_99: 'testUniverse' } ]); + + }); + + test("Experiments, should push to dataLayer with custom Google Custom Dimension Index", () => { + // Arrange + window.dataLayer = []; + + const mockExperimentData = { + data: [ + { + settings: { "Google Custom Dimension Index": "5"}, + variationName: 'test', + universeName: 'testUniverse' + } + ] + } + + // Act + analyticsMixin.methods.pushExperimentsToDataLayer(mockExperimentData); + + // Assert + expect(window.dataLayer).toEqual([ { settings_5: {"Google Custom Dimension Index": "5"}, variationName_5: 'test', universeName_5: 'testUniverse' } ]); + + }); + +}); \ No newline at end of file diff --git a/src/mixins/base-mixin.js b/src/mixins/base-mixin.js index b5a954c43..d8df53281 100644 --- a/src/mixins/base-mixin.js +++ b/src/mixins/base-mixin.js @@ -3,6 +3,8 @@ import { storeActions } from "@/constants/store-actions.js"; import { storeMutations } from "@/constants/store-mutations.js"; import { navigationScenarios } from "@/router/router-constants/navigation-scenarios"; import { vehicleCategories } from "@/constants/vehicle-categories.js"; +import { routerParams } from "@/router/router-constants/router-params"; +import { queryStrings } from "@/constants/query-strings"; export default { data() { @@ -11,13 +13,13 @@ export default { }; }, methods: { - setCmsContent(cmsContent){ + setCmsContent(cmsContent) { this.$root.cmsContentByWidget = cmsContent; }, - getCmsContent(widgetName, fieldName){ + getCmsContent(widgetName, fieldName) { return this.$root.cmsContentByWidget?.[widgetName]?.[fieldName] ? this.$root.cmsContentByWidget[widgetName][fieldName] : ''; }, - dispatchNonBlockingStoreAction(type, payload, encodePayload = true) { + dispatchStoreAction(type, payload, encodePayload = true) { // Encode the payload if required if (encodePayload) { encodeUriData(payload); @@ -25,10 +27,10 @@ export default { return store.dispatch(type, payload); }, - savePageDataToStore(page, data){ + savePageDataToStore(page, data) { store.commit(storeMutations.UPDATE_PAGE_DATA, { page: page, data: data }); }, - onSubmit() {}, // DO NOT REMOVE; needed to prevent default form submit behavior + onSubmit() { }, // DO NOT REMOVE; needed to prevent default form submit behavior onInvalidSubmit({ values, errors, results }) { // identify the first error field and put focus on it // get error names array @@ -54,6 +56,12 @@ export default { vehicleCategories() { return vehicleCategories; }, + routerParams() { + return routerParams; + }, + queryStrings(){ + return queryStrings; + } }, }; diff --git a/src/mixins/base-mixin.spec.js b/src/mixins/base-mixin.spec.js index 48af9a7c0..f343336c3 100644 --- a/src/mixins/base-mixin.spec.js +++ b/src/mixins/base-mixin.spec.js @@ -11,7 +11,7 @@ describe("baseMixin.js", () => { const type = ""; const payload = {}; - mixIn.methods.dispatchNonBlockingStoreAction(type, payload); + mixIn.methods.dispatchStoreAction(type, payload); expect(store.dispatch).toBeCalledWith(type, payload); }); @@ -21,7 +21,7 @@ describe("baseMixin.js", () => { const type = ""; const payload = { make: "Alfa Romeo/Chrysler" }; - mixIn.methods.dispatchNonBlockingStoreAction(type, payload, true); + mixIn.methods.dispatchStoreAction(type, payload, true); expect(store.dispatch).toBeCalledWith(type, payload); }); diff --git a/src/router/index.js b/src/router/index.js index 778c55cfc..9ffd28522 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -4,21 +4,25 @@ import { storeActions } from "@/constants/store-actions"; import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js"; import { routingTable } from "@/router/router-constants/routing-table.js"; import { globalEvents, globalEventTypes } from "@/constants/events"; +import { queryStrings } from "@/constants/query-strings"; +import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper"; // Heritage integration import { isSavedSessionStillActive } from "@/helpers/heritage-integration/session-helper"; -import { updateOrCreateFunnelCookie,getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import { updateOrCreateFunnelCookie, getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { loadOrderIfPresent, saveOrder } from "@/helpers/heritage-integration/order-helper"; -import { getPageToRouteExistingOrderTo, navigateToHeritageFunnel} from "@/helpers/heritage-integration/navigation-helper"; +import { getPageToRouteExistingOrderTo, navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import baseMixin from "@/mixins/base-mixin"; import eventBus from "@/helpers/event-bus/event-bus"; import store from "@/store"; +import analyticsMixin from "@/mixins/analytics-mixin"; // Components import ComponentTest from "@/layouts/component-test/component-test.vue"; import FormTest from "@/layouts/form-test/form-test.vue"; + const routes = [ { path: "/component-test", // This is a temporary route for testing. @@ -35,84 +39,78 @@ const routes = [ name: "root", async beforeEnter(to, from, next) { // If we have no query string, or we don't have the FmgPage query string. - if (to.query.fmgPage === undefined) { - await GoToFunnelStartOn404(next); - } else { - try { - - // If the saved session has timed out, clear the session, execute 404 logic. - if (getFunnelCookie() !== null && !isSavedSessionStillActive()) { - await GoToFunnelStartOn404(next); - } - - - - // On entering the funnel "fresh", read cookie information, decide what to do next. - if (from.redirectedFrom === undefined) { - const loadOrderResponse = await loadOrderIfPresent(); - const pageToRedirectTo = await getPageToRouteExistingOrderTo(to, loadOrderResponse); - - // If getPageToRouteExistingOrderTo determines that the return user needs to - // go back to heritage funnel, send them there and stop our current navigation. - if (pageToRedirectTo === 'heritage') { - await navigateToHeritageFunnel(); - return next(false); - } - - // Assign our fmgPage so it will load normally like the other pages. - to.query.fmgPage = pageToRedirectTo; - } - - // Process funnel cookie. - updateOrCreateFunnelCookie(); - - // If we already have our route, go to it. - if (router.hasRoute(to.query.fmgPage)) { - // Since our route is already in scope, we can grab the component from it and call the arePagePrerequisitesValid function. - let component = router.getRoutes().filter((x) => x.name === to.query.fmgPage)[0].components; - - // If the component hasn't been loaded fully, load it before we check prerequisites. - if (component.default.methods === undefined) { - component = await component.default(); - } - - if (!arePagePrerequisitesValid(component)) { - await GoToFunnelStartOn404(next); - } - - return next({ name: to.query.fmgPage, query: to.query, params: to.params }); - } - - // Get route info for the given url. Names will have a 1:1 relationship with names in the Cms. - const routeData = await GetRouteInfoFromPageName(to.query.fmgPage); - - // Add our dynamic route. - router.addRoute({ - path: routeData[0].path, // Always the same path, because we control it with query strings. - name: routeData[0].name, - component: routeData[0].component, - }); - - // Call the next components arePagePrerequisitesValid method before load. - // If it returns false, use the 404 logic. - const nextComponent = await router.getRoutes().filter((x) => x.name === routeData[0].name)[0].components.default(); - - if (!arePagePrerequisitesValid(nextComponent)) { - await GoToFunnelStartOn404(next); - } - - // Assign current query string parameters, as well as our fmgPage one. - next({ - name: routeData[0].name, - query: Object.assign(to.query, { fmgPage: routeData[0].name }), - params: to.params - }); - } catch (error) { - console.log(error); - - // If we don't have a route, go to our 404 page. + try { + // If the saved session has timed out, clear the session, execute 404 logic. + if (getFunnelCookie() !== null && !isSavedSessionStillActive()) { await GoToFunnelStartOn404(next); } + + // On entering the funnel "fresh", read cookie information, decide what to do next. + if (from.redirectedFrom === undefined) { + const loadOrderResponse = await loadOrderIfPresent(); + const pageToRedirectTo = await getPageToRouteExistingOrderTo(to, loadOrderResponse); + + // If getPageToRouteExistingOrderTo determines that the return user needs to + // go back to heritage funnel, send them there and stop our current navigation. + if (pageToRedirectTo === 'heritage') { + await navigateToHeritageFunnel(); + return next(false); + } + + + // Assign our fmgPage so it will load normally like the other pages. + to.query.fmgPage = pageToRedirectTo; + } + + // Process funnel cookie. + updateOrCreateFunnelCookie(); + + // If we already have our route, go to it. + if (router.hasRoute(to.query.fmgPage)) { + // Since our route is already in scope, we can grab the component from it and call the arePagePrerequisitesValid function. + let component = router.getRoutes().filter((x) => x.name === to.query.fmgPage)[0].components; + + // If the component hasn't been loaded fully, load it before we check prerequisites. + if (component.default.methods === undefined) { + component = await component.default(); + } + + if (!arePagePrerequisitesValid(component)) { + await GoToFunnelStartOn404(next); + } + + return next({ name: to.query.fmgPage, query: to.query, params: to.params }); + } + + // Get route info for the given url. Names will have a 1:1 relationship with names in the Cms. + const routeData = await GetRouteInfoFromPageName(to.query.fmgPage); + + // Add our dynamic route. + router.addRoute({ + path: routeData[0].path, // Always the same path, because we control it with query strings. + name: routeData[0].name, + component: routeData[0].component, + }); + + // Call the next components arePagePrerequisitesValid method before load. + // If it returns false, use the 404 logic. + const nextComponent = await router.getRoutes().filter((x) => x.name === routeData[0].name)[0].components.default(); + + if (!arePagePrerequisitesValid(nextComponent)) { + await GoToFunnelStartOn404(next); + } + + // Assign current query string parameters, as well as our fmgPage one. + next({ + name: routeData[0].name, + query: Object.assign(to.query, { fmgPage: routeData[0].name }), + params: to.params + }); + } catch (error) { + console.log(error); + + // If we don't have a route, go to our 404 page. + await GoToFunnelStartOn404(next); } }, }, @@ -125,6 +123,13 @@ const router = createRouter({ //---------------------------------------------------------- Router Functions ---------------------------------------------------------- +router.afterEach(async (to, from) => { + // Push page view to GA + analyticsMixin.methods.pushPageViewToGA(to.query[queryStrings.FMG_PAGE]); + const assignedExperiments = await baseMixin.methods.dispatchStoreAction(storeActions.GET_EXPERIMENTS_BY_USER_FOR_GA, { userId: getDeviceIdValue() }); + analyticsMixin.methods.pushExperimentsToDataLayer(assignedExperiments); +}); + router.navigate = (scenario, currentRoute, optionalQuery = {}, optionalParams = {}, optionalPageData = {}) => { navigate(scenario, currentRoute, false, optionalQuery, optionalParams, optionalPageData); } @@ -205,6 +210,13 @@ function navigateToUrl(url, optionalQuery = {}) { externalUrl.searchParams.append(queryKey, optionalQuery[queryKey]); } + ///////////////////////////////////////////////////// + // TEMP CODE FOR TESTING WITH SPECIFIC EXPERIMENTS // + ///////////////////////////////////////////////////// + if (externalUrl.search.indexOf("corid=") != -1) + externalUrl.search = externalUrl.search + '&experiments=CollectEmailOnQuote=CollectEmailOnQuote_V1=YesCollectEmail_TEST1=true,RemoveServiceAreaPage=ServAreaRemoval_V7=ServAreaNoRemove_V7_TEST=true,VINeducationV2=VINeducation_V2=NoShowVINmodalV2_CONTROL=true,ServicePackages=ServicePackages_V1=NoShowPackages_CONTROL=true,PhotoUploadRedesign=PhotoUploadRedesign_V1=CurrentPhotoUpload_CONTROL=true,ScheduleDetailsServiceType=ScheduleBeforeServiceType_V1=ServTypeThenSched_CONTROL=true'; + ///////////// END TEMP CODE ///////////////////////// + window.location.assign(externalUrl); } diff --git a/src/router/router-constants/fmgPage-values.js b/src/router/router-constants/fmgPage-values.js index f3c811de3..9abb45cd2 100644 --- a/src/router/router-constants/fmgPage-values.js +++ b/src/router/router-constants/fmgPage-values.js @@ -11,6 +11,7 @@ const fmgPageValues = { LICENSE_PLATE_LOOKUP: "license-plate-lookup", REVEAL: "reveal", ESTIMATE: "estimate", + ADDRESS_VEHICLES: "address-vehicles", }; export { fmgPageValues }; diff --git a/src/router/router-constants/navigation-scenarios.js b/src/router/router-constants/navigation-scenarios.js index da170da53..5380a3fa7 100644 --- a/src/router/router-constants/navigation-scenarios.js +++ b/src/router/router-constants/navigation-scenarios.js @@ -4,14 +4,18 @@ const navigationScenarios = { SELECTED_MAKE: "SELECTED_MAKE", SELECTED_STYLE: "SELECTED_STYLE", CLICKED_BACK: "CLICKED_BACK", + CLICKED_BACK_WITH_VIN: "CLICKED_BACK_WITH_VIN", CLICKED_FORWARD: "CLICKED_FORWARD", + CLICKED_FORWARD_WITH_VIN: "CLICKED_FORWARD_WITH_VIN", SELECTED_PARTS: "SELECTED_PARTS", SELECTED_DAMAGE_WITH_SINGLE_PART: "SELECTED_DAMAGE_WITH_SINGLE_PART", SELECTED_DAMAGE_WITH_MULTIPLE_PARTS: "SELECTED_DAMAGE_WITH_MULTIPLE_PARTS", SELECTED_DAMAGE_WITH_PART_QUESTIONS: "SELECTED_DAMAGE_WITH_PART_QUESTIONS", CONTINUING_WITH_PARTS_QUESTION: "CONTINUING_WITH_PARTS_QUESTION", CONTINUING_WITH_MULTIPLE_PARTS: "CONTINUING_WITH_MULTIPLE_PARTS", - CONTINUING_WITH_SINGLE_PART: "CONTINUING_WITH_SINGLE_PART" + CONTINUING_WITH_SINGLE_PART: "CONTINUING_WITH_SINGLE_PART", + CONTINUING_WITH_MULTIPLE_VEHICLES: "CONTINUING_WITH_MULTIPLE_VEHICLES", + TEMPORARY_TO_ADDRESS_LOOKUP: "TEMPORARY_TO_ADDRESS_LOOKUP", }; export { navigationScenarios }; diff --git a/src/router/router-constants/router-params.js b/src/router/router-constants/router-params.js new file mode 100644 index 000000000..80ddb779d --- /dev/null +++ b/src/router/router-constants/router-params.js @@ -0,0 +1,5 @@ +const routerParams = { + DISPLAY_VEHICLE_CHANGE_ALERT: "displayVehicleChangeAlert" + }; + + export { routerParams }; \ No newline at end of file diff --git a/src/router/router-constants/routing-table.js b/src/router/router-constants/routing-table.js index 99583b975..9f412288a 100644 --- a/src/router/router-constants/routing-table.js +++ b/src/router/router-constants/routing-table.js @@ -1,6 +1,5 @@ import { fmgPageValues } from "@/router/router-constants/fmgPage-values"; import { navigationScenarios } from "@/router/router-constants/navigation-scenarios"; -import { applicationConfig } from "@/constants/application-config"; const routingTable = [ { @@ -58,9 +57,13 @@ const routingTable = [ scenario: navigationScenarios.CLICKED_BACK, destinationFmgPageValue: fmgPageValues.VEHICLE_STYLE, }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_VIN, + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP, + }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_SINGLE_PART, - destinationFmgPageValue: fmgPageValues.REVEAL + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP, }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_MULTIPLE_PARTS, @@ -68,7 +71,11 @@ const routingTable = [ }, { scenario: navigationScenarios.SELECTED_DAMAGE_WITH_PART_QUESTIONS, - destinationFmgPageValue: fmgPageValues.PART_QUESTIONS, + destinationFmgPageValue: fmgPageValues.LICENSE_PLATE_LOOKUP,//This might be temporary + }, + { + scenario: navigationScenarios.TEMPORARY_TO_ADDRESS_LOOKUP, + destinationFmgPageValue: fmgPageValues.ADDRESS_LOOKUP, //This will be temporary }, ], }, @@ -107,6 +114,10 @@ const routingTable = [ maps: [ { scenario: navigationScenarios.CLICKED_BACK, + destinationFmgPageValue: fmgPageValues.REVEAL, + }, + { + scenario: navigationScenarios.CLICKED_BACK_WITH_VIN, destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE, }, { @@ -134,8 +145,29 @@ const routingTable = [ scenario: navigationScenarios.CONTINUING_WITH_SINGLE_PART, destinationFmgPageValue: fmgPageValues.REVEAL, }, + { + scenario: navigationScenarios.CLICKED_FORWARD, + destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE, + }, ], }, + { + fmgPageValue: fmgPageValues.ADDRESS_LOOKUP, + maps: [ + { + scenario: navigationScenarios.CLICKED_BACK, + destinationFmgPageValue: fmgPageValues.ESTIMATE, + }, + { + scenario: navigationScenarios.CONTINUING_WITH_MULTIPLE_VEHICLES, + destinationFmgPageValue: fmgPageValues.ADDRESS_VEHICLES, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD, + destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE, + }, + ], + }, ]; export { routingTable }; diff --git a/src/store/index.js b/src/store/index.js index 0fc7dec76..60c2f6435 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -45,10 +45,16 @@ const getDefaultState = () => { glassParts: null, otherParts: null }, + payment:{ + isInsurance: null, + insuranceCoverage: { + isVerified: null + } + }, referralNumber: null, referralDate: null, referralCorrelationId: null, - parentAccountNumber: null, + accountNumber: 0, }, applicationUser: { eventBus: [], @@ -118,7 +124,40 @@ export const mutations = { state.order.referralDate = referralDate; }, updateParentAcctNumber(state, parentAcctNumber) { - state.order.parentAccountNumber = parentAcctNumber; + state.order.accountNumber = parentAcctNumber; + }, + updateIsInsurance(state, isInsurance) { + state.order.payment.isInsurance = isInsurance; + }, + updateInsuranceVerifiedStatus(state, isVerified) { + state.order.payment.insuranceCoverage.isVerified = isVerified; + }, + updateRegistrationLicensePlate(state, licensePlate){ + state.order.vehicle.registration.licensePlate = licensePlate; + }, + updateRegistrationState(state, registrationState){ + state.order.vehicle.registration.state = registrationState; + }, + updateRegistrationZipCode(state, registrationZipCode){ + state.order.vehicle.registration.zipCode = registrationZipCode; + }, + updateRegistrationAddress(state, registrationAddress){ + state.order.vehicle.registration.address = registrationAddress; + }, + updateServiceLocationZip(state, serviceLocationZip){ + state.order.serviceLocation.zip = serviceLocationZip; + }, + updateRegistrationCity(state, registrationCity){ + state.order.vehicle.registration.city = registrationCity; + }, + updateRegistrationFirstName(state, firstName){ + state.order.vehicle.registration.firstName = firstName; + }, + updateRegistrationLastName(state, lastName){ + state.order.vehicle.registration.lastName = lastName; + }, + updateCustomerEmailAddress(state, customerEmailAddress){ + state.order.customer.emailAddress = customerEmailAddress; }, @@ -176,23 +215,30 @@ export const mutations = { state.order.referralNumber = orderInformation.referralNumber; state.order.referralDate = orderInformation.referralDate; state.order.referralCorrelationId = orderInformation.referralCorrelationId; + state.order.vehicle = Object.assign(state.order.vehicle, { year: orderInformation.vehicle?.year, make: orderInformation.vehicle?.make, model: orderInformation.vehicle?.model, style: orderInformation.vehicle?.style, + vin: orderInformation.vehicle?.vin, carId: orderInformation.vehicle?.carId, category: orderInformation.vehicle?.category, imageUrl: orderInformation.vehicle?.imageUrl, imageVifNumber: orderInformation.vehicle?.imageVifNumber, - imageColor: orderInformation.vehicle?.imageVifColor + imageColor: orderInformation.vehicle?.imageVifColor, }); + state.order.damage.glassToReplace = orderInformation.glassToReplace; state.order.damage.isRepair = orderInformation.isRepair; state.order.damage.numberOfChips = orderInformation.numberOfChips; + state.order.lineItems.glassParts = orderInformation.parts; - state.order.parentAccountNumber = orderInformation.parentAccountNumber; - state.order.serviceLocation.zipCode = orderInformation.zipCode; // TODO CSR-416 Make sure this is correct + state.order.accountNumber = orderInformation.accountNumber; + state.order.serviceLocation.zipCode = orderInformation.zipCode; + + state.order.payment.isInsurance = orderInformation.IsInsuranceOrder; + state.order.payment.insuranceCoverage.isVerified = orderInformation?.insuranceInfo.coverageVerified; } } @@ -210,11 +256,10 @@ export const getters = { eventBus: (state) => state.applicationUser.eventBus, damage: (state) => state.order.damage, lineItems: (state) => state.order.lineItems, - pageData: (state) => (page) => { - return state.applicationUser.pageData[page]; - }, + pageData: (state) => (page) => { return state.applicationUser.pageData[page]; }, applicationUser: (state) => state.applicationUser, order: (state) => state.order, + payment: (state) => state.order.payment, } // Export Actions @@ -253,6 +298,18 @@ export const actions = { }, }); }, + lookupVinByAddress(context, { licenseLastName, licenseStreetAddress, licenseZip, licenseState }) { + return globalMethods.callHttpClient({ + method: endpoints.LookupVinByAddress.method, + endpoint: endpoints.LookupVinByAddress.url, + payload: { + licenseLastName: licenseLastName, + licenseStreetAddress: licenseStreetAddress, + licenseZip: licenseZip, + licenseState: licenseState + }, + }); + }, getVehicleMakes(context, { year }) { return globalMethods.callHttpClient({ method: endpoints.GetVehicleMakes.method, @@ -363,22 +420,54 @@ export const actions = { context.commit(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, referralCorrelationId); }, - logExperimentExposure(context, { userId, sessionKey, sessionId, pageName, serverName, pageEvent, universeName }) { + logExperimentExposure(context, { userId, sessionKey, pageName, universeName }) { return globalMethods.callHttpClient({ method: endpoints.LogExperimentExposureIfAssigned.method, endpoint: endpoints.LogExperimentExposureIfAssigned.url, payload: { userId: userId, sessionKey: sessionKey, - sessionId: sessionId, pageName: pageName, - serverName: serverName, - pageEvent: pageEvent, universeName: universeName } }); }, + logActivity(context, { userId, sessionKey, pageName, sessionId, pageEvent, customEvent, shouldUseSessionId }) { + var payload = { + userId: userId, + sessionKey: sessionKey, + sessionId: sessionId, + pageName: pageName, + applicationName: 'SafeliteDotCom', + shouldUseSessionId: shouldUseSessionId + }; + + if (typeof pageEvent !== 'undefined') { + payload.pageEvent = { action: pageEvent.action, event: pageEvent.event}; + } + + if (typeof customEvent !== 'undefined') { + payload.customEvents = [{category: customEvent.category, action: customEvent.action, label: customEvent.label, value: customEvent.value}]; + } + + return globalMethods.callHttpClient({ + method: endpoints.LogActivity.method, + endpoint: endpoints.LogActivity.url, + payload: payload, + logApiCall: false + }); + }, + + getExperimentsByUserForGa(context, { userId }){ + return globalMethods.callHttpClient({ + method: endpoints.GetExperimentsByUserForGa.method, + endpoint: `${endpoints.GetExperimentsByUserForGa.url}/${userId}`, + payload: {} + }); + }, + + // Parts API Actions getPartsOrQuestions(context, { carId, glassArray, zipCode, vin = '' }) { return globalMethods.callHttpClient({ @@ -408,24 +497,27 @@ export const actions = { make: vehicle.make, model: vehicle.model, style: vehicle.style, + vin: vehicle.vin }, numberOfChips: damage.numberOfChips, zipCode: 43215, // TODO CSR-416, should not be hardcoded (state.order.serviceLocation.zipCode) glassToReplace: damage.glassToReplace, referralNumber: context.state.order.referralNumber, - referralDate: context.state.order.referralDate + referralDate: context.state.order.referralDate, + accountNumber: context.state.order.accountNumber }, }); }, - loadOrder(context, { referralNumber, referralDate, referralCorrelationId }) { + loadOrder(context, { referralNumber, referralDate, referralCorrelationId, accountNumber}) { return globalMethods.callHttpClient({ method: endpoints.LoadOrder.method, endpoint: endpoints.LoadOrder.url, payload: { referralNumber: referralNumber, referralDate: referralDate, - referralCorrelationId: referralCorrelationId + referralCorrelationId: referralCorrelationId, + accountNumber: accountNumber }, }).then((response) => { context.commit(storeMutations.RESET_STATE); diff --git a/src/store/store.spec.js b/src/store/store.spec.js index 08db50bd0..e27569a6f 100644 --- a/src/store/store.spec.js +++ b/src/store/store.spec.js @@ -221,7 +221,8 @@ describe("Mutations", () => { isRepair: false, numberOfChips: 0, parts: [], - parentAccountNumber: "123456789", + accountNumber: "123456789", + insuranceInfo: {} }); // Assert @@ -232,6 +233,17 @@ describe("Mutations", () => { expect(storeState.order.vehicle.model).toEqual("ILX"); }); + it("updateInsuranceVerifiedStatus, should set isVerified flag", () => { + // Arrange + const storeState = state; + + // Act + mutations.updateInsuranceVerifiedStatus(storeState, true); + + // Assert + expect(storeState.order.payment.insuranceCoverage.isVerified).toEqual(true); + }); + }); describe("Actions", () => { @@ -605,6 +617,32 @@ describe("Actions", () => { expect(commit).toBeCalledWith(storeMutations.UPDATE_REFERRAL_CORRELATION_ID, "xxx-xxx-xxx"); }); + it("logActivity action, should return nothing", async () => { + + // Arrange + const context = state; + var pageEvent = { + action: "", + event: "ENTRY", + } + + var customEvent = [{ + category: "tstCat", + action: "click", + label: "damage", + value: "psych" + }]; + + // Act + globalMethods.callHttpClient.mockImplementation(() => { + return Promise.resolve({ }); + }); + + // Assert + const response = await actions.logActivity(context, { userId: "userId", sessionKey: "sessionKey", pageName: "pageName", sessionId: "sessionId", pageEvent: pageEvent, customEvent: customEvent, shouldUseSessionId: true }); + expect(response).toEqual({}); + }); + }); describe("Getters", () => { @@ -690,4 +728,15 @@ describe("Getters", () => { }); + it("Payment getter, should return payment data", () => { + // Arrange + const storeState = state; + + //Act + mutations.updateInsuranceVerifiedStatus(storeState, true ); + + //Assert + expect(getters.payment(storeState).insuranceCoverage.isVerified).toEqual(true); + }); + }); \ No newline at end of file diff --git a/src/styles/common-error-styles.scss b/src/styles/common-error-styles.scss index 530f9c392..67bd9473b 100644 --- a/src/styles/common-error-styles.scss +++ b/src/styles/common-error-styles.scss @@ -1,82 +1,126 @@ -.has-error { - &.list-button, - &.list-card { - border: 1px solid $red; - color: $red; - input[type=checkbox]:focus + label, - input[type=radio]:focus + label { - box-shadow: 0 0 0 2.5px $red; - } - &:hover { - box-shadow: 0px 0px 0px 4px $red-200; - border-radius: 10px !important; - } - } - &.list-button-horizontal { - color: $red; - label { +html { + .has-error { + &.list-button, + &.list-card { border: 1px solid $red; + color: $red; + input[type=checkbox]:focus + label, + input[type=radio]:focus + label { + box-shadow: 0 0 0 2.5px $red; + } + input[type=checkbox]:checked + label { + box-shadow: 0 0 0 1px $red; + } &:hover { box-shadow: 0px 0px 0px 4px $red-200; + border-radius: 10px; } } - input[type=checkbox]:focus + label, - input[type=radio]:focus + label { - box-shadow: 0 0 1px $red !important; - } - } - &.ui-radio, - &.ui-checkbox { - input[type=checkbox], - input[type=radio], - input[type=radio]+label:before, - input[type=checkbox]+label:before { - border: 1px solid $red; - } - input[type=checkbox]:checked + label:before { - border: 1px solid $blue; - } - } - &.textbox-question, - &.dropdown-question { - p { + &.list-button-horizontal { color: $red; - } - input, - select { - border: 1px solid $red; - &:focus { - border: 1px solid transparent; + label { + border: 1px solid $red; + &:hover { + box-shadow: 0px 0px 0px 4px $red-200; + } + } + input[type=checkbox]:focus + label, + input[type=radio]:focus + label { + box-shadow: 0 0 1px $red; } } - select { - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.89' xml:space='preserve'%3e%3cpath d='M8 8.89c-.24 0-.46-.09-.63-.26L.26 1.53a.901.901 0 0 1 0-1.27C.43.1.66 0 .9 0s.47.1.64.26L8 6.74 14.47.27c.17-.17.4-.27.64-.27s.47.1.63.27c.17.17.26.4.26.64s-.1.47-.27.63l-7.1 7.09a.86.86 0 0 1-.63.26z' fill='%23d4281c'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - background-size: 16px 12px; + &.ui-radio, + &.ui-checkbox { + input[type=checkbox], + input[type=radio], + input[type=radio]+label:before, + input[type=checkbox]+label:before { + border: 1px solid $red; + } + input[type=checkbox]:checked + label:before { + border: 1px solid $blue; + } + } + &.textbox-question, + &.dropdown-question { + p { + color: $red; + } + input, + select { + border: 1px solid $red; + &:focus { + border: 1px solid transparent; + } + } + select { + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.89' xml:space='preserve'%3e%3cpath d='M8 8.89c-.24 0-.46-.09-.63-.26L.26 1.53a.901.901 0 0 1 0-1.27C.43.1.66 0 .9 0s.47.1.64.26L8 6.74 14.47.27c.17-.17.4-.27.64-.27s.47.1.63.27c.17.17.26.4.26.64s-.1.47-.27.63l-7.1 7.09a.86.86 0 0 1-.63.26z' fill='%23d4281c'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; + } + } + } + //Restore to default style if alert box is present + .alertError { + .has-error { + &.list-button, + &.list-card { + border: 1px solid $gray-500; + input:not(:focus) { + + label { + box-shadow: 0 0 0 1px $gray-500; + border-radius: .5rem; + } + } + input:checked:focus { + + label { + box-shadow: 0 0 0 2.5px $blue; + border-radius: .5rem; + } + } + input:checked:not(:focus) { + + label { + box-shadow: 0 0 0 1px $blue; + border-radius: .5rem; + } + } + input:focus { + border: 1px solid $gray-500; + + label { + box-shadow: 0 0 0 2.5px transparent; + } + } + &:hover { + box-shadow: 0 0 0 4px $blue-300; + + label { + box-shadow: 0 0 0 2.5px transparent; + border: 1px solid $blue; + } + } + } + } + } + + .form-test-error { + color: $red; + font-size: .875rem; + font-weight: 500; + } + + .form-test-invalid { + &.btn.btn-primary { + color: $gray; + background: $gray-200; + cursor: pointer; + pointer-events: all; + } + &.btn.btn-primary:hover, + &.btn.btn-primary:focus, + &.btn.btn-primary:focus-visible { + color: $gray !important; + background: $gray-200; + box-shadow: none; } } } - -.form-test-error { - color: $red; - font-size: .875rem; - font-weight: 500; -} - -.form-test-invalid { - &.btn.btn-primary { - color: $gray; - background: $gray-200; - cursor: pointer; - pointer-events: all; - } - &.btn.btn-primary:hover, - &.btn.btn-primary:focus, - &.btn.btn-primary:focus-visible { - color: $gray !important; - background: $gray-200 !important; - box-shadow: none !important; - } - -} diff --git a/src/ux-components/alert/alert.spec.js b/src/ux-components/alert/alert.spec.js index 8072f8d73..17ed6988a 100644 --- a/src/ux-components/alert/alert.spec.js +++ b/src/ux-components/alert/alert.spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from "@vue/test-utils"; +import { shallowMount } from "@vue/test-utils"; import alert from "./alert"; describe("alert.vue", () => { @@ -9,6 +9,13 @@ describe("alert.vue", () => { propsData: { isDismissible: true }, + computed: { + splitAlertCopyForLink: { + get() { + return "TEST"; + }, + } + }, mixins: [mockMixin] }); @@ -24,6 +31,13 @@ describe("alert.vue", () => { propsData: { alertClass: 'warning' }, + computed: { + splitAlertCopyForLink: { + get() { + return "TEST"; + }, + } + }, mixins: [mockMixin] }); diff --git a/src/ux-components/alert/alert.vue b/src/ux-components/alert/alert.vue index e8f8848a7..7cd719ba7 100644 --- a/src/ux-components/alert/alert.vue +++ b/src/ux-components/alert/alert.vue @@ -5,7 +5,15 @@ :class="[isDismissible ? 'alert-dismissible' : '', this.alertClass]" >

{{ alertHeadline }}

-

+

+ +

+