diff --git a/jest.config.js b/jest.config.js index 052e1c47b..ce4aa3ad6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,7 +27,6 @@ module.exports = { "!src/layouts/insurance/*.vue", // Temp test exclusion while in development "!src/layouts/insurance-company/*.vue", // Temp test exclusion while in development "!src/layouts/insurance-company/insurance-company-question/*.vue", // Temp test exclusion while in development - "!src/layouts/return-user/*.vue", // Temp test exclusion while in development // END ], // ! means exclude from coverage. testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"], diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index c972d8118..29d36d091 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -125,7 +125,7 @@ const endpoints = { method: "POST", }, SaveQuote: { - url: "/order/api/v1/order/initiate-saved-progress-email", + url: "/order/api/v1/order/save-progress", method: "POST", }, GetSignature: { @@ -168,6 +168,10 @@ const endpoints = { url: "/analytics/api/v1/analytics/initialize", method: "POST", }, + LogPartQuestions: { + url: "/analytics/api/v1/analytics/log-part-questions", + method: "POST", + }, GetExperimentsByUser: { url: "/analytics/api/v1/analytics/get-experiments", method: "GET", diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 83555fc04..92d27a4a1 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -1,6 +1,7 @@ const experimentUniverses = { CONCEPT_FUNNEL: "ConceptFunnel", RECAL_PRICE_REMOVAL: "NextGen_RecalPriceRemoval", + MSR: "MSR", }; const experimentSettings = { @@ -16,6 +17,7 @@ const experimentSettings = { RECAL_PRICE_REMOVE: "RecalPriceRemove", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold", + DISPLAY_MSR: "DisplayMSR", }; const experimentTriggers = { diff --git a/src/constants/header-keys.js b/src/constants/header-keys.js index 2f7598244..d9b3578c4 100644 --- a/src/constants/header-keys.js +++ b/src/constants/header-keys.js @@ -5,4 +5,5 @@ export const headerKeys = { SESSION_SEQUENCE_NUMBER: "X-Session-Sequence-Number", TRANSACTION_ID: "X-Transaction-Id", EON: "X-Enterprise-Order-Number", + LOG_ENABLED: "log-enabled" }; diff --git a/src/constants/part-number-strings.js b/src/constants/part-number-strings.js new file mode 100644 index 000000000..c32864a87 --- /dev/null +++ b/src/constants/part-number-strings.js @@ -0,0 +1,7 @@ +const partNumberStrings = { + // Recalibration + MOBILE_STATIC_RECAL_FEE: "RECAL MOBILE", + MOBILE_DUAL_RECAL_FEE: "RECAL MOBILEDUAL", +}; + +export { partNumberStrings }; diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 45a18bace..aee8d705f 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -42,10 +42,6 @@ const storeActions = { VALIDATE_ZIP: "validateZip", PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "priceOrderItemsAndSaveServerData", TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "taxOrderItemsAndSaveServerData", - LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure", - LOG_PAGE_VIEW: "logPageView", - LOG_CUSTOM_EVENT: "logCustomEvent", - INITIALIZE_SESSION: "initializeSession", GET_EXPERIMENTS_BY_USER: "GetExperimentsByUser", RUN_EXPERIMENTS_FOR_TRIGGER: "runExperimentsForTrigger", CLEAR_VIN: "clearVin", @@ -57,6 +53,13 @@ const storeActions = { GET_BILL_TO_ACCOUNT_NUMBER: "getBillToAccountNumber", GET_RECAL_PARTS_AND_SAVE_TO_LINE_ITEMS: "getRecalPartsAndSaveToLineItems", + // Analytics + LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure", + LOG_PAGE_VIEW: "logPageView", + LOG_CUSTOM_EVENT: "logCustomEvent", + INITIALIZE_SESSION: "initializeSession", + LOG_PART_QUESTIONS: "logPartQuestions", + // DEPENDENCY MUTATIONS RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleStateAndDependencies", RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies", diff --git a/src/digital-components/modal/modal.vue b/src/digital-components/modal/modal.vue index 58260e831..72ed56035 100644 --- a/src/digital-components/modal/modal.vue +++ b/src/digital-components/modal/modal.vue @@ -29,9 +29,10 @@ @@ -205,35 +207,63 @@ export default { left: 0; } } - .modal-footer { - order: 3; - padding: 0; - } - .modal-disclaimer { - order: 4; + .modal.modal-component .modal-dialog .modal-content { + p.modal-body { + padding: 0; + } + .modal-body { + display: flex; + flex-direction: column; + padding: 0 1.5rem; + + .textbox-question { + padding: 0; + margin: 0 0 1.5rem 0; + + label { + text-align: left; + font-weight: 900; + } + } + } + .modal-body-inner { + text-align: center; + margin-bottom: 1.5rem; + } + .modal-footer { + padding: 0 1.5rem; + margin: 0 0 1.5rem 0; + + button.btn { + order: 1; + margin-bottom: 1.5rem; + } + button.btn.skip-button { + padding: 0; + order: 2; + font-size: 1rem; + } + .modal-disclaimer { + font-size: 0.75rem; + text-align: left; + order: 3; + margin: 0; + + a { + text-decoration: none; + } + } + } } &.progress-saved { - .modal-footer, - .save-progress-popup-question { + .modal-footer-button, + .modal-disclaimer { display: none; } + .skip-button { + margin-bottom: 0; + } } } -.applied-promo { - font-size: 0.875rem; - font-weight: 500; - line-height: 24px; - color: $green; - - .promo-code { - text-transform: uppercase; - } -} -.alert { - color: $red; - font-size: 0.875rem; - font-weight: 500; - line-height: 24px; -} diff --git a/src/global-methods.js b/src/global-methods.js index 77255e99d..ba6cec1ec 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -70,6 +70,7 @@ export default { [headerKeys.REFERRAL_SEQUENCE_NUMBER]: order?.referralSequenceNumber, [headerKeys.TRANSACTION_ID]: crypto.randomUUID(), [headerKeys.EON]: order?.eon, + [headerKeys.LOG_ENABLED]: store.getters.applicationUser?.loggingOption ?? false, }; axios({ diff --git a/src/helpers/boolean-helper.js b/src/helpers/boolean-helper.js new file mode 100644 index 000000000..71016ef06 --- /dev/null +++ b/src/helpers/boolean-helper.js @@ -0,0 +1,19 @@ +export function getBoolFromString(str) { + // Catch edge cases + if (str === null || str === undefined) { + return false; + } + + if (typeof str === "boolean") { + return str; + } + + if (typeof str !== "string") { + return false; + } + + // string logic + const toLower = str?.toLowerCase(); + + return toLower === "true"; +} diff --git a/src/helpers/boolean-helper.spec.js b/src/helpers/boolean-helper.spec.js new file mode 100644 index 000000000..189bd2a78 --- /dev/null +++ b/src/helpers/boolean-helper.spec.js @@ -0,0 +1,146 @@ +import { getBoolFromString } from "./boolean-helper"; + +describe("getBoolFromString", () => { + describe("Happy paths", () => { + test('"true" -> true', () => { + const input = "true"; + + const output = getBoolFromString(input); + + expect(output).toBe(true); + }); + + test('"false" -> false', () => { + const input = "false"; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test('"other string" -> false', () => { + const input = "some random value"; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + }); + + describe("Capitalization", () => { + test('"TRUE" -> true', () => { + const input = "TRUE"; + + const output = getBoolFromString(input); + + expect(output).toBe(true); + }); + + test('"True" -> true', () => { + const input = "True"; + + const output = getBoolFromString(input); + + expect(output).toBe(true); + }); + + test('"FALSE" -> false', () => { + const input = "FALSE"; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test('"False" -> false', () => { + const input = "False"; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + }); + + describe("Graceful type errors", () => { + describe("Boolean", () => { + test("true -> true", () => { + const input = true; + + const output = getBoolFromString(input); + + expect(output).toBe(true); + }); + + test("false -> false", () => { + const input = false; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + }); + + describe("Empty", () => { + test("undefined -> false", () => { + const input = undefined; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test("null -> false", () => { + const input = null; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test("{} -> false", () => { + const input = {}; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + }); + + describe("Other types", () => { + test("1 -> false", () => { + const input = 1; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test("Object -> false", () => { + const input = { + test: true, + true: true, + }; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test("function -> false", () => { + const input = () => true; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + + test("array -> false", () => { + const input = [true]; + + const output = getBoolFromString(input); + + expect(output).toBe(false); + }); + }); + }); +}); diff --git a/src/helpers/querystring-helper.js b/src/helpers/querystring-helper.js index 38583b1ae..e6c488b1d 100644 --- a/src/helpers/querystring-helper.js +++ b/src/helpers/querystring-helper.js @@ -8,3 +8,17 @@ export function getQuerystringParameter(key) { return lowerCaseParams.get(key) ? lowerCaseParams.get(key) : null; } + +// if you add the fmgPage to the querystringobject before calling, then pass true for skipFmgPageName +export function buildQuerystringObject(qso, skipFmgPageName = false) { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + + for (const [name, value] of urlParams) { + if (name.toLowerCase() === "fmgpage" && skipFmgPageName) { + continue; + } + qso[name] = value; + } + return qso; +} diff --git a/src/layouts/capability-questions/capability-questions.vue b/src/layouts/capability-questions/capability-questions.vue index 57567e128..1896513a1 100644 --- a/src/layouts/capability-questions/capability-questions.vue +++ b/src/layouts/capability-questions/capability-questions.vue @@ -64,9 +64,7 @@ export default { // Call the "next" function to complete the transition to this page. next(async (vm) => { vm.setCmsContent(resultMap.cmsContent); - // turned off Save Your Progress for 1/23/25 release - // to restore, uncomment line below - // vm.showSaveProgressModal = !(emailFromStore?.length > 0); + vm.showSaveProgressModal = !(emailFromStore?.length > 0); if (store.getters.externalParameterState?.isExternalParameter) { if (store.getters.externalParameterServiceZip.zipCode) { await baseMixin.methods.dispatchStoreAction( diff --git a/src/layouts/confirmation/confirmation.vue b/src/layouts/confirmation/confirmation.vue index d206a4bef..59a8b1a8b 100644 --- a/src/layouts/confirmation/confirmation.vue +++ b/src/layouts/confirmation/confirmation.vue @@ -120,6 +120,43 @@ export default { async beforeRouteEnter(to, from, next) { const hasRecalPriceRemoveExperiment = await store.getters.shouldHideRecalibration; + // send part question data to SPS API for logging + const orderFromStore = await deepClone(store.getters.order); + const ctu = orderFromStore.serviceLocation?.zipCodeCtu + + const partsOrQuestions = orderFromStore.damage?.partQuestionAnswers; + + partsOrQuestions.forEach(pqa => { + const payloadPartQuestions = []; + pqa.answeredQuestions.forEach(answer => { + payloadPartQuestions.push({ + questionSeq: answer?.questionNum, + questionText: answer?.questionText, + answerText: answer?.selectedAnswerText, + basePart: pqa?.result, + }); + }); + + const payload = { + eon: orderFromStore.eon, + ctu: ctu, + workOrderId: orderFromStore.workOrderId, + workOrderNumber: orderFromStore.workOrderNumber, + carId: orderFromStore.vehicle.carId, + glassLocation: pqa?.glassLocation, + partQuestions: payloadPartQuestions, + }; + + baseMixin.methods.dispatchStoreAction( + storeActions.LOG_PART_QUESTIONS, + payload, + false + ); + + }); + + + // Create order await baseMixin.methods.dispatchStoreAction(storeActions.CREATE_SUBMITTED_ORDER); // This nulls the Store order // Call APIs diff --git a/src/layouts/molding-questions/molding-questions.vue b/src/layouts/molding-questions/molding-questions.vue index 4da3588d7..1bcc3d7a8 100644 --- a/src/layouts/molding-questions/molding-questions.vue +++ b/src/layouts/molding-questions/molding-questions.vue @@ -64,9 +64,7 @@ export default { // Call the "next" function to complete the transition to this page. next(async (vm) => { vm.setCmsContent(resultMap.cmsContent); - // turned off Save Your Progress for 1/23/25 release - // to restore, uncomment line below - // vm.showSaveProgressModal = !(emailFromStore?.length > 0); + vm.showSaveProgressModal = !(emailFromStore?.length > 0); if (store.getters.externalParameterState?.isExternalParameter) { if (store.getters.externalParameterServiceZip.zipCode) { await baseMixin.methods.dispatchStoreAction( diff --git a/src/layouts/part-questions/part-questions.vue b/src/layouts/part-questions/part-questions.vue index 37f005d48..f905e86c7 100644 --- a/src/layouts/part-questions/part-questions.vue +++ b/src/layouts/part-questions/part-questions.vue @@ -64,9 +64,7 @@ export default { // Call the "next" function to complete the transition to this page. next(async (vm) => { vm.setCmsContent(resultMap.cmsContent); - // turned off Save Your Progress for 1/23/25 release - // to restore, uncomment line below - // vm.showSaveProgressModal = !(emailFromStore?.length > 0); + vm.showSaveProgressModal = !(emailFromStore?.length > 0); if (store.getters.externalParameterState?.isExternalParameter) { if (store.getters.externalParameterServiceZip.zipCode) { const serviceState = store.getters.order.serviceLocation.state; diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 5c6025b84..698c8bb09 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -156,6 +156,7 @@ import { mapTaxedLineItemsToStoreFormat } from "../../store"; import { coverageStatus } from "@/constants/insurance"; import { containsLineItemWithPartType } from "@/helpers/service-package-helper"; import { containsRecalParts } from "@/helpers/recal-helper"; +import { getBoolFromString } from "@/helpers/boolean-helper"; defineRule("payment-method-required", required(errorMessages.OPTION_REQUIRED)); defineRule("recal-ack-required", required(errorMessages.RECAL_ACK_REQUIRED)); @@ -429,7 +430,7 @@ export default { // prettier-ignore { const log = getQuerystringParameter(queryStrings.LOG); - const logAsBool = (log?.toLowerCase() === "true"); + const logAsBool = getBoolFromString(log); if (logAsBool || !preReqResult) { console.log("------------- payment-method.vue pagePrereqs start -----------------"); console.log(new Date() + " serviceLocationReqs::isMobile: " + isMobile); @@ -786,7 +787,7 @@ export default { shouldDisplayPiaAlert() { return ( this.$route.query[queryStrings.DISPLAY_PIA_ALERT] || - eval(window.history.state.displayPiaAlert) + getBoolFromString(window.history.state.displayPiaAlert) ); }, // Necessary to make the watcher of lineItems work diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index ce5e6bcbe..5ffb04faa 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -234,6 +234,7 @@ import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js" import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.js"; import { routerParams } from "@/router/router-constants/router-params"; import { coverageStatus } from "@/constants/insurance"; +import { getBoolFromString } from "@/helpers/boolean-helper.js"; export default { name: "payment", @@ -800,7 +801,7 @@ export default { shouldDisplayPiaAlert(payMethod) { return ( this.$route.query[queryStrings.DISPLAY_PIA_ALERT] === payMethod || - eval(window.history.state.displayPiaAlert) + getBoolFromString(window.history.state.displayPiaAlert) ); }, }, diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index f3590cbc3..4428f6e40 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -159,6 +159,7 @@ import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigat import { containsRecalParts } from "@/helpers/recal-helper"; import { externalParameterStatus } from "@/constants/external-parameters"; import { saveQuote } from "@/helpers/heritage-integration/order-helper.js"; +import { getBoolFromString } from "@/helpers/boolean-helper"; defineRule("option-required", required(errorMessages.OPTION_REQUIRED)); @@ -293,12 +294,8 @@ export default { // Call the "next" function to complete the transition to this page. next(async (vm) => { vm.setCmsContent(resultMap.cmsContent); - - // turned off Save Your Progress for 1/23/25 release - // to restore, uncomment the 2 lines below - // vm.showSaveProgressPopup = showSaveProgressPopup; - // vm.showSaveProgressModal = showSaveProgressModal; - + vm.showSaveProgressPopup = showSaveProgressPopup; + vm.showSaveProgressModal = showSaveProgressModal; vm.addableVaps = addableVaps; vm.lineItems = lineItems; vm.availableLineItems = pricingResults; @@ -407,7 +404,7 @@ export default { baseMixin.methods.ResetExternalParamsAndHideModal(); } else { // user came from an external source, however, the externalParms may have been reset on service-zip, property-questions, etc... - if (eval(store.getters.externalParameterQuote?.isInsurance)) { + if (getBoolFromString(store.getters.externalParameterQuote?.isInsurance)) { // did user intentionally select insurance? vm.isInsuranceSelected = true; vm.servicePackage = store.getters.externalParameterQuote.servicePackage; diff --git a/src/layouts/return-user/return-user.spec.js b/src/layouts/return-user/return-user.spec.js new file mode 100644 index 000000000..dc631ce62 --- /dev/null +++ b/src/layouts/return-user/return-user.spec.js @@ -0,0 +1,312 @@ +// Components +import returnUser from "@/layouts/return-user/return-user.vue"; + +// Supporting Files +import funnelHeader from "@/fmg-components/funnel-header/funnel-header"; +import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header"; +import { shallowMount } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper"; +import baseMixin from "@/mixins/base-mixin.js"; +import { dispatchStoreAction } from "@/mixins/base-mixin.js"; +import store from "@/store"; +import router from "@/router"; +import navbar from "@/fmg-components/nav-bar/nav-bar"; +import { Form } from "vee-validate"; +import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; +import { navigationScenarios } from "@/router/router-constants/navigation-scenarios"; +import { settleAllPromises } from "@/helpers/layout-helper"; +import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; + +import { experimentSettings } from "../../constants/experiments"; +import { storeActions } from "@/constants/store-actions"; +import buttonMain from "@/ux-components/button-main/button-main"; + +// Constants + +// Setup global mocks +let mockStoreActionData = {}; + +let mockStoreData = {}; +let mockExperimentSettings = { + experiments: [ + { + universeName: "ConceptFunnel", + settings: { + SuppressVinCapture: false, + }, + }, + ], +}; + +function resetMockStoreData() { + mockStoreData = { + vehicle: { + year: "2000", + make: "TestMake", + model: "TestModel", + style: "TestStyle", + carId: "TestID", + vin: null, + registration: { + licensePlate: null, + }, + }, + serviceLocation: { + address: null, + address2: null, + city: null, + state: null, + zipCode: null, + zipCodeCtu: null, + appointmentType: null, + isVehicleProtected: null, + provider: { + providerNumber: null, + address: { + streetAddress: null, + city: null, + state: null, + zipCode: null, + zipCodeCtu: null, + }, + }, + techNotes: null, + }, + customer: { + firstName: null, + lastName: null, + emailAddress: null, + phoneNumber: null, + isSmsOptIn: null, + }, + damage: { + isRepair: false, + numberOfChips: null, + glassToReplace: [{ glassLocation: "Windshield", glassName: "windshield" }], + partQuestionAnswers: null, + moldingQuestionAnswers: null, + capabilityQuestionAnswers: null, + dateOfLoss: null, + damageCause: null, + }, + lineItems: { + glassParts: [ + { + canSafeliteRecalibrate: true, + childParts: [ + { + kitPrice: 0, + laborAmount: 23.55, + partNumber: "GGG FW4896", + salesTax: 1.77, + sellingPrice: 0, + }, + ], + color: "Green Tint", + description: + "solar, soundproofing, lane keep assist, lane departure warning system, w/adaptive cruise control", + id: "db22fd44-10dd-456f-979b-ff88cf68cca6", + kitPrice: 0, + laborAmount: 60, + partNumber: "FW04896GTYN", + partType: "WINDSHIELD", + recalibrationType: "STATIC", + requiresCapabilityQuestions: false, + requiresRecalibration: true, + salesTax: 63.86, + sellingPrice: 791.46, + }, + ], + supportingItems: null, + vaps: null, + serverData: null, + promos: null, + }, + payment: { + isInsurance: null, + insuranceCoverage: { + isVerified: null, + coverageStatus: null, + coverageType: null, + coverageVerificationType: null, + }, + parentAccountNumber: 0, + billToAccountNumber: null, + isPia: null, + piaType: null, + inactivePromos: null, + paypalToken: null, + nextGenSettledAmount: 0, + ccToken: { + subscriptionId: null, + expMonth: null, + expYear: null, + cardType: null, + billToPostalCode: null, + billToFirstName: null, + billToLastName: null, + referenceNumber: null, + authCode: null, + transactionId: null, + transReferenceNumber: null, + lastFour: null, + }, + }, + policy: { + currentDeductible: 0, + policyNumber: null, + isItac: false, + additionalAuthFlag: null, + isNoComp: false, + insuranceCompanyName: null, + }, + schedule: { + date: null, + startTime: null, + endTime: null, + routeCode: null, + jobMaxMinutes: null, + jobMinMinutes: null, + }, + externalParameterServiceZip: { + zipCode: null, + emailAddress: null, + }, + externalParameterState: { isExternalParameter: false }, + }; +} + +function applyMockStoreDataToGetters() { + store.getters = { + vehicle: mockStoreData.vehicle, + }; + store.state.order = mockStoreData; + store.state.applicationUser.experiments = mockExperimentSettings; +} + +async function mockDispatchStoreAction(actionName) { + return mockStoreActionData[actionName]; +} + +jest.mock("@/mixins/base-mixin.js", () => ({ + methods: { + dispatchStoreAction: jest.fn(), + dispatchStoreActionWithLogging: jest.fn().mockImplementation(mockDispatchStoreAction), + }, +})); + +jest.mock("@/helpers/cms-content-helper", () => ({ + fetchCmsContentForPage: () => Promise.resolve("content"), +})); + +jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({ + saveSession: jest.fn(), +})); + +jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({ + deleteFunnelCookie: jest.fn(), + getFunnelCookie: jest.fn(), +})); + +router.navigateWithoutSaving = jest.fn(); +router.navigateWithSaving = jest.fn(); + +// Tests +describe("return-user.vue", () => { + beforeEach(() => { + resetMockStoreData(); + jest.clearAllMocks(); + }); + + describe("Test prerequisites are valid and child components are rendered", () => { + test("expect pagePrerequisites are valid to be called", () => { + // Arrange + const wrapper = setupMocks({}); + + applyMockStoreDataToGetters(); + + // Act + const pagePrerequisitesSpy = jest.spyOn(wrapper.vm, "arePagePrerequisitesValid"); + + wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(pagePrerequisitesSpy).toBeCalled(); + expect(getFunnelCookie).toHaveBeenCalled(); + }); + + test("renders child components", () => { + const wrapper = setupMocks({}); + + expect(wrapper.findComponent(funnelHeader).exists()).toBe(true); + expect(wrapper.findComponent(funnelSubHeader).exists()).toBe(true); + expect(wrapper.findComponent(navbar).exists()).toBe(true); + }); + }); + + describe("Navigation", () => { + test("Check forwardButtonAction is working", async () => { + // Arrange + const wrapper = setupMocks({}); + // Act + + await wrapper.vm.forwardButtonAction(); + + // Assert + expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith( + navigationScenarios.CLICKED_FORWARD, + wrapper.vm.$route + ); + }); + test("expect functions in startOver to be called", async () => { + //Arrange + const wrapper = setupMocks({}); + + // Act + const dispatchStoreActionSpy = jest.spyOn(wrapper.vm, "dispatchStoreAction"); + + await wrapper.vm.$nextTick(); + await wrapper.vm.startOver(); + + // Assert + expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith( + navigationScenarios.CLICKED_FORWARD, + wrapper.vm.$route + ); + + expect(dispatchStoreActionSpy).toHaveBeenCalledWith(storeActions.RESET_STATE); + expect(deleteFunnelCookie).toHaveBeenCalled(); + }); + }); +}); + +function setupMocks({ customMountOptions }) { + const route = { query: { fmgPage: "return-user" }, params: {} }; + + baseMixin.methods.ResetExternalParamsAndHideModal = jest.fn(); + + const mountOptions = getMountOptions({ + ...customMountOptions, + route: route, + }); + + mountOptions.global.mocks["$store"] = store; + mountOptions.global.mocks["$router"] = router; + baseMixin.methods.isFormValid = jest.fn().mockReturnValue(true); + mountOptions["attachTo"] = document.body; + + const wrapper = shallowMount(returnUser, mountOptions, { + stubs: { + Form, + funnelHeader, + funnelSubHeader, + navbar, + loadingModal: true, + buttonMain, + }, + }); + + return wrapper; +} diff --git a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue index 84087cb6e..d88bd53e2 100644 --- a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue +++ b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue @@ -241,57 +241,46 @@ export default { this.displayMismatchStateAndZipAlert = false; }, async setMobileLocation() { - if ( - this.internalModel.addressQuestions.zipCode !== - this.modelValue.addressQuestions.zipCode - ) { - this.resetAlerts(); - // Validate the Zip Code - const zipCodeData = await this.getZipCodeData( - this.internalModel.addressQuestions.zipCode, + this.resetAlerts(); + // Validate the Zip Code + const zipCodeData = await this.getZipCodeData( + this.internalModel.addressQuestions.zipCode, + "service-location" + ); + + if (!zipCodeData.isValid) { + this.displayInvalidZipAlert = true; + this.resetModalButtonStyle(); + } else if (zipCodeData.state != this.internalModel.addressQuestions.state) { + this.displayMismatchStateAndZipAlert = true; + this.resetModalButtonStyle(); + } else { + // retrieve mobile fee part + const serviceZipCode = this.internalModel.addressQuestions.zipCode; + const mobileFeePart = await getPricedMobileFeePart( + serviceZipCode, "service-location" ); - if (!zipCodeData.isValid) { - this.displayInvalidZipAlert = true; - this.resetModalButtonStyle(); - } else if (zipCodeData.state != this.internalModel.addressQuestions.state) { - this.displayMismatchStateAndZipAlert = true; - this.resetModalButtonStyle(); - } else { - // retrieve mobile fee part - const serviceZipCode = this.internalModel.addressQuestions.zipCode; - const mobileFeePart = await getPricedMobileFeePart( - serviceZipCode, - "service-location" - ); + // retrieve serviceability details + const serviceabilityDetails = await getServiceabilityDetails( + serviceZipCode, + null, + "service-location" + ); - // retrieve serviceability details - const serviceabilityDetails = await getServiceabilityDetails( - serviceZipCode, - null, - "service-location" - ); + const billToAccountNumber = await getBillToAccountNumber( + this.internalModel.zipCodeCtu + ); - const billToAccountNumber = await getBillToAccountNumber( - this.internalModel.zipCodeCtu - ); + // update content related to service zip code + this.$emit("updated-mobile-fee-part", mobileFeePart); + this.$emit("updated-serviceability", serviceabilityDetails.data); + this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase); + this.$emit("updated-mobile-ctu", zipCodeData.zipCodeCtu); + this.$emit("updated-bill-to-account-number", billToAccountNumber); - // update content related to service zip code - this.$emit("updated-mobile-fee-part", mobileFeePart); - this.$emit("updated-serviceability", serviceabilityDetails.data); - this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase); - this.$emit("updated-mobile-ctu", zipCodeData.zipCodeCtu); - this.$emit("updated-bill-to-account-number", billToAccountNumber); - - // update the page level model - this.$emit("update:modelValue", this.internalModel); - - //Page advance to Schedule page - this.$emit("mobileLocationSelected"); - } - } else { - // Update the page level model + // update the page level model this.$emit("update:modelValue", this.internalModel); //Page advance to Schedule page diff --git a/src/layouts/service-location/service-location.spec.js b/src/layouts/service-location/service-location.spec.js index 6333082ec..54f28a89c 100644 --- a/src/layouts/service-location/service-location.spec.js +++ b/src/layouts/service-location/service-location.spec.js @@ -7,6 +7,7 @@ import { getMountOptions } from "@/helpers/unit-test-helper"; import store from "@/store"; import baseMixin from "@/mixins/base-mixin"; +import { experimentSettings } from "@/constants/experiments"; // Define Mocks jest.mock("@/helpers/cms-content-helper", () => ({ @@ -199,6 +200,9 @@ beforeEach(() => { zipCode: "43054", }, }, + experimentSettings: { + settingName: experimentSettings.DISPLAY_MSR, + }, }; }); diff --git a/src/layouts/service-location/service-location.vue b/src/layouts/service-location/service-location.vue index 71bfa4b01..9addea705 100644 --- a/src/layouts/service-location/service-location.vue +++ b/src/layouts/service-location/service-location.vue @@ -139,6 +139,8 @@ import contentGroupModal from "@/fmg-components/content-group-modal/content-grou // Supporting files import baseMixin from "@/mixins/base-mixin.js"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { experimentSettings } from "@/constants/experiments"; import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; @@ -151,6 +153,7 @@ import { import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { applicationConfig } from "@/constants/application-config"; import { Provider } from "@/layouts/service-location/classes/provider"; +import { partNumberStrings } from "@/constants/part-number-strings"; import store from "@/store"; @@ -159,7 +162,6 @@ import { defineRule } from "vee-validate"; import { errorMessages } from "@/constants/error-messages"; const MOBILE_FEE_PART_TYPE = "MOBILE FEE"; -const MOBILE_STATIC_RECAL_FEE_PART_NUMBER = "RECAL MOBILE"; // DEFINE VALIDATION RULES defineRule("mobile-location-required", (value) => { @@ -319,11 +321,19 @@ export default { }, isMobileStaticRecalibrationApplicable() { return ( + this.displayMSR && this.isVehicleMobileStaticRecalibrationApplicable && - this.mobileFeePart?.partNumber == MOBILE_STATIC_RECAL_FEE_PART_NUMBER && + this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE && (this.isInsurance ? this.mobileFeePart?.isInsurable : true) ); }, + displayMSR() { + return ( + experimentMixin.methods + .getSettingValue(experimentSettings.DISPLAY_MSR) + ?.toLowerCase() === "true" + ); + }, mobileFeeApplies() { if ( this.mobileFeePart?.laborAmount > 0 || diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index 7cce1c120..6e9c4b198 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -102,6 +102,7 @@ import { routerParams } from "@/router/router-constants/router-params"; import store from "@/store"; import baseMixin from "@/mixins/base-mixin"; import { nextTick } from "vue"; +import { getBoolFromString } from "@/helpers/boolean-helper"; // DEFINE VALIDATION RULES defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED)); @@ -622,7 +623,7 @@ export default { ); }, shouldDisplayVehicleChangeAlert() { - return eval(window.history.state.displayVehicleChangeAlert); + return getBoolFromString(window.history.state.displayVehicleChangeAlert); }, shouldHideBackButton() { return this.$store.getters.requiresVerifiedRedirecting; diff --git a/src/layouts/vehicle-parts/vehicle-parts.vue b/src/layouts/vehicle-parts/vehicle-parts.vue index 43f66c376..4b68007f9 100644 --- a/src/layouts/vehicle-parts/vehicle-parts.vue +++ b/src/layouts/vehicle-parts/vehicle-parts.vue @@ -94,10 +94,8 @@ export default { // Call the "next" function to complete the transition to this page. next((vm) => { vm.setCmsContent(resultMap.cmsContent); - // turned off Save Your Progress for 1/23/25 release - // to restore, uncomment line below - // vm.showSaveProgressModal = !(emailFromStore?.length > 0); - + vm.showSaveProgressModal = !(emailFromStore?.length > 0); + // Glass Part Question dynamic component Object.keys(vm.$refs) .filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined) diff --git a/src/layouts/vehicle/vehicle.vue b/src/layouts/vehicle/vehicle.vue index d23588d37..79fad6313 100644 --- a/src/layouts/vehicle/vehicle.vue +++ b/src/layouts/vehicle/vehicle.vue @@ -80,7 +80,11 @@ import vehicleQuestion from "@/layouts/vehicle/vehicle-question/vehicle-question // Supporting files import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { experimentUniverses } from "@/constants/experiments"; -import { getSessionKeyValue, getUserIdValue } from "@/helpers/heritage-integration/cookie-helper"; +import { + getSessionKeyValue, + getUserIdValue, + getDeviceIdValue, +} from "@/helpers/heritage-integration/cookie-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; import { Form, defineRule } from "vee-validate"; import { required } from "@/helpers/validation-rules"; @@ -186,11 +190,12 @@ export default { storeActions.LOG_EXPERIMENT_EXPOSURE, { userId: getUserIdValue(), + deviceId: getDeviceIdValue(), sessionKey: getSessionKeyValue(), - pageName: to.query.fmgPage, + pageName: "vehicle-logExposure", //to.query.fmgPage, experiment: experimentForLogging, }, - "vehicle", + "vehicle-logExposure", false ); } diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 6f7f67c6f..9bb8f0cda 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -712,24 +712,19 @@ export default { return !areAllSessionCookiesSet(); }, - async validateSession() { - // The noSession function checks the cookies related to analytics logging(sid). it is not the funnel info cookie. - // The sid cookie for analytics will expire every 30 minutes and get recreated in initSession. If this happens - // and we also have the funnel cookie present, that indicates they have an existing session that is now expired - // so route them to the return user page. + // sessionExpired is true when one of the analytics cookies(sid, dxdev) has expired but we still have the vehicle year in vuex + sessionExpired() { const fromHeritage = getQuerystringParameter(queryStrings.FROM_HERITAGE) === "true"; - const funnelCookieLastTouched = getFunnelCookie()?.LastTouched; - if ( - this.noSession() && - !fromHeritage && - funnelCookieLastTouched !== null && - funnelCookieLastTouched !== undefined - ) { - await this.initSession(); - router.push({ - path: "/", - query: { fmgPage: fmgPageValues.RETURN_USER }, - }); + if (this.noSession() && !fromHeritage && store.getters.order.vehicle?.year > 0) { + return true; + } else { + return false; + } + }, + + async validateSession() { + // do not init session if it is expired. let the click event on nav-bar handle session expired logic + if (this.sessionExpired()) { return; } diff --git a/src/router/index.js b/src/router/index.js index d956af773..918688a3c 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -6,7 +6,7 @@ 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 { getQuerystringParameter } from "@/helpers/querystring-helper"; +import { getQuerystringParameter, buildQuerystringObject } from "@/helpers/querystring-helper"; import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper"; import { showFmgLoadingModal } from "@/helpers/loading-modal-helper"; import { fmgPageValues, funnelStartPageName } from "@/router/router-constants/fmgPage-values"; @@ -38,6 +38,7 @@ import { applicationConfig } from "../constants/application-config"; import { shouldStripPromoQueryString } from "@/helpers/promotions-helper"; import bailout from "@/layouts/bailout/bailout"; import { nextTick } from "vue"; +import { getBoolFromString } from "@/helpers/boolean-helper"; const routes = [ { @@ -58,6 +59,11 @@ const routes = [ await analyticsMixin.methods.validateSession(); + // after session is validated, remove the fromHeritage querystring if it exists so session expiration works + if (to.query) { + delete to.query[queryStrings.FROM_HERITAGE]; + } + if (getFunnelCookie()?.SuppressConceptFunnel) { log(" --go to heritage suppressConceptFunnel: "); await navigateToHeritageFunnel({ shouldSaveSession: false }); @@ -95,27 +101,24 @@ const routes = [ } // On entering the funnel "fresh", read cookie information, decide what to do next. else if (from.redirectedFrom === undefined || fromReturnUser) { - // if entering the funnel from the content site, check and see if there is already a funnel cookie. + // if entering the funnel from the content site, check and see if there is already a vehicle year in vuex. // if so, send them to return-user page. - const funnelCookieLastTouched = getFunnelCookie()?.LastTouched; if ( fromContentSite && - funnelCookieLastTouched !== null && - funnelCookieLastTouched !== undefined && - !toReturnUserPage + !toReturnUserPage && + store.getters.order.vehicle?.year > 0 ) { log(" --from content site navigate to return user"); var qso = { fmgPage: fmgPageValues.RETURN_USER, }; - const lg = getQuerystringParameter(queryStrings.LOG); - if (lg) { - qso[queryStrings.LOG] = true; - } + + const newQueryString = buildQuerystringObject(qso, true); + log(" -- returnUser add querystring: " + JSON.stringify(newQueryString)); router.push({ path: "/", - query: Object.assign({}, qso), + query: Object.assign({}, newQueryString), }); return; } @@ -173,8 +176,8 @@ const routes = [ // clear part related state because heritage selected a new vehicle if ( to.query.fmgPage === fmgPageValues.VEHICLE && - eval(getFunnelCookie()?.HasDelayedClaimRegistration && - !fromReturnUser) + getBoolFromString(getFunnelCookie()?.HasDelayedClaimRegistration) && + !fromReturnUser ) { store.commit(storeMutations.RESET_GLASS_PARTS_STATE); } @@ -182,7 +185,7 @@ const routes = [ // if coming from the return user page, clear the destination page so implicit navigation runs log(" --to.query ", JSON.stringify(to.query)); if (fromReturnUser && to.query) { - log( " --clear to.query"); + log(" --clear to.query"); delete to.query[queryStrings.FMG_PAGE]; //to.query[queryStrings.FMG_PAGE] = ""; @@ -422,7 +425,7 @@ router.afterEach(async (to, from) => { store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name); // If saving on navigation is requested, check for saved SessionId or EmailAddress to determine if saving is appropriate - if (eval(window.history.state.isSavingNavigation)) { + if (getBoolFromString(window.history.state.isSavingNavigation)) { if ( store.getters.applicationUser.savedSessionId || store.getters.order.customer?.emailAddress @@ -566,7 +569,7 @@ async function navigate( const pageError = getQuerystringParameter(queryStrings.PAGE_ERROR); const logQs = getQuerystringParameter(queryStrings.LOG); baseMixin.methods.dispatchStoreAction(storeActions.SAVE_LOGGING_OPTION, logQs, false); - + log("------------- router index.js navigate start -----------------"); log(" --scenario: ", scenario); log(" --isSavingNavigation: ", isSavingNavigation); @@ -631,7 +634,7 @@ function getNavigationMap(scenario, currentRoute) { function log(message, data) { const log = getQuerystringParameter(queryStrings.LOG); baseMixin.methods.dispatchStoreAction(storeActions.SAVE_LOGGING_OPTION, log, false); - + data = data ?? ""; const outData = typeof data === "object" ? JSON.stringify(data) : data; @@ -794,7 +797,7 @@ async function runExperiments(nextPage) { await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.RUN_EXPERIMENTS_FOR_TRIGGER, { - userId: getDeviceIdValue(), + deviceId: getDeviceIdValue(), triggerEvent: experimentTriggers.SITE_ENTRY, triggerValue: applicationConfig.SITE_ENTRY_TRIGGER_VALUE, }, @@ -805,9 +808,9 @@ async function runExperiments(nextPage) { await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.RUN_EXPERIMENTS_FOR_TRIGGER, { - userId: getDeviceIdValue(), + deviceId: getDeviceIdValue(), triggerEvent: experimentTriggers.PAGE_ENTRY, - triggerValue: nextPage, + triggerValue: nextPage == "vehicle" ? "vehicle-pageEntry" : nextPage, }, nextPage ); diff --git a/src/store/index.js b/src/store/index.js index 5a674b317..77a1f1e95 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1252,7 +1252,7 @@ export const actions = { // Analytics Actions logExperimentExposure( context, - { payload: { userId, sessionKey, pageName, experiment }, pageNameToLog } + { payload: { userId, deviceId, sessionKey, pageName, experiment }, pageNameToLog } ) { return globalMethods.callHttpClient({ method: endpoints.LogExperimentExposureIfAssigned.method, @@ -1260,6 +1260,7 @@ export const actions = { payload: { experimentForLogging: { userId: userId, + deviceId: deviceId, experimentUniverseId: experiment.universeId, experimentUniverseName: experiment.universeName, experimentTestId: experiment.testId, @@ -1445,6 +1446,39 @@ export const actions = { }); }, + logPartQuestions( + context, + { + eon, + ctu, + workOrderId, + workOrderNumber, + carId, + glassLocation, + partQuestions, + userAgent, + } + ) { + var payload = { + applicationName: applicationConfig.APPLICATION_NAME, + eon: eon, + ctu: ctu, + workOrderId: workOrderId, + workOrderNumber: workOrderNumber, + carId: carId, + glassLocation: glassLocation, + partQuestions: partQuestions, + userAgent: navigator.userAgent + }; + + return globalMethods + .callHttpClient({ + method: endpoints.LogPartQuestions.method, + endpoint: endpoints.LogPartQuestions.url, + payload: payload, + }); + }, + // Misc Actions setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) { context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber); @@ -1463,7 +1497,7 @@ export const actions = { async runExperimentsForTrigger( context, - { payload: { userId, triggerEvent, triggerValue }, pageNameToLog } + { payload: { deviceId, triggerEvent, triggerValue }, pageNameToLog } ) { if (triggerEvent == experimentTriggers.SITE_ENTRY) { context.commit(storeMutations.UPDATE_TRIGGERED_SITE_ENTRY, true); @@ -1471,7 +1505,7 @@ export const actions = { var payload = { applicationName: applicationConfig.APPLICATION_NAME, - userId: userId, + deviceId: deviceId, triggerEvent: triggerEvent, triggerValue: triggerValue, experimentOrder: context.getters.experimentOrder, @@ -1638,10 +1672,10 @@ export const actions = { var endPoint = `${endpoints.GetMobileFeePart.url}/?serviceType=${serviceType}&facilityType=${facilityType}&parentAccountNumber=${parentAccountNumber}&billToAccountNumber=${billToAccountNumber}&providerNumber=${providerNumber}&isItacOptimized=${isItacOptimized}&zipCode=${zipCode}`; if (isMobileStaticRecalibrationApplicable) { - const staticRecalPartNumber = getStaticRecalPartNumber(order.lineItems?.glassParts[0]); + const recalPartNumber = getRecalPartNumber(order.lineItems?.glassParts[0]); const carId = order.vehicle?.carId; - if (staticRecalPartNumber && carId) { - endPoint = `${endPoint}&partNumbers=${staticRecalPartNumber}&carId=${carId}`; + if (recalPartNumber && carId) { + endPoint = `${endPoint}&partNumbers=${recalPartNumber}&carId=${carId}`; } } @@ -3685,9 +3719,11 @@ function saveExternalParameterState(externalParameterState) { window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState)); } -//This function checks if static recalibration is available for the vehicle and returns the part number for it. -function getStaticRecalPartNumber(glassPartsArray) { - const recalPart = glassPartsArray.childParts.find((item) => item.partNumber === "RECAL STATIC"); +//This function finds the recalibration part and returns the part number for it. +function getRecalPartNumber(glassPartsArray) { + const recalPart = glassPartsArray.childParts.find( + (item) => item.partType === partTypeStrings.ADAS_RECALIBRATION + ); if (recalPart) { return recalPart.partNumber; } else {