diff --git a/src/constants/analytics.js b/src/constants/analytics.js index c3416edea..f6534cda4 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -12,6 +12,8 @@ const GaEvents = { const GaCategories = { API_RESPONSE: "Api_Response", EVOX: "Evox", + APPOINTMENT: "Appointment", + SERVICE_LOCATION: "service-location", }; const GaActions = { @@ -21,6 +23,10 @@ const GaActions = { SUBMITTED: "Submitted", DISPLAYED: "Displayed", CASH_QUOTE_DISPLAYED: "Cash quote displayed", + SERVICE_PACKAGE_PRICE: "Service package price", + MOBILE_AVAILABLE: "mobile_available", + SHOPS_FIRST_DISPLAYED: "shops_first_displayed", + MORE_LOCATIONS_CLICKED: "more_locations_clicked", }; const GaLabels = { @@ -29,6 +35,8 @@ const GaLabels = { LICENSE_PLATE_LOOKUP: "License_Plate_Look_Up", VIN_LOOKUP: "Vin_Look_Up", ADDRESS_LOOKUP: "Address_Look_up", + YES: "yes", + NO: "no", }; const ValueToLogTypes = { diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 10b8b3483..16e0803e6 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -176,6 +176,10 @@ const endpoints = { url: "/account/api/v1/account/insurance-companies", method: "GET", }, + GetBillToAccountNumber: { + url: "/account/api/v1/account/bill-to-account-number", + method: "GET", + }, }; export { endpoints }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 52017ce47..eeabeb7d6 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -13,6 +13,8 @@ const experimentSettings = { IS_EMAIL_OPTIONAL: "isEmailOptional", SERVICE_PACKAGE_DISCOUNT: "OfferServicePackageDiscount", RECAL_PRICE_REMOVE: "RecalPriceRemove", + INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold", + INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold", }; const experimentTriggers = { diff --git a/src/constants/header-keys.js b/src/constants/header-keys.js index 2cfbba367..2f7598244 100644 --- a/src/constants/header-keys.js +++ b/src/constants/header-keys.js @@ -1,3 +1,8 @@ export const headerKeys = { EXPERIMENT: "X-Experiment-Data", + APPLICATION_NAME: "X-Application-Name", + REFERRAL_SEQUENCE_NUMBER: "X-Referral-Sequence-Number", + SESSION_SEQUENCE_NUMBER: "X-Session-Sequence-Number", + TRANSACTION_ID: "X-Transaction-Id", + EON: "X-Enterprise-Order-Number", }; diff --git a/src/constants/query-strings.js b/src/constants/query-strings.js index 9fb379836..3cdfd655f 100644 --- a/src/constants/query-strings.js +++ b/src/constants/query-strings.js @@ -44,6 +44,7 @@ const queryStrings = { CJUNCTION: "cjunction", ORGANIC: "organic", ORGANIC_SOCIAL: "organic_social", + EXPERIMENTS: "experiments", }; export { queryStrings }; diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index f5c7ea047..185d3d369 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -53,6 +53,7 @@ const storeActions = { VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA: "validateOrderPromoAndSaveServerData", REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA: "revalidateOrderPromosAndSaveServerData", GET_INSURANCE_COMPANY_LIST: "getInsuranceCompanyList", + GET_BILL_TO_ACCOUNT_NUMBER: "getBillToAccountNumber", GET_RECAL_PARTS_AND_SAVE_TO_LINE_ITEMS: "getRecalPartsAndSaveToLineItems", // DEPENDENCY MUTATIONS diff --git a/src/constants/store-mutations.js b/src/constants/store-mutations.js index 494be36e3..02b05bd28 100644 --- a/src/constants/store-mutations.js +++ b/src/constants/store-mutations.js @@ -103,6 +103,7 @@ const storeMutations = { UPDATE_EXTERNAL_PARAMETER_IS_INSURANCE: "updateExternalParameterIsInsurance", UPDATE_EXTERNAL_PARAMETER_VIN_SELECTION: "updateExternalParameterVinSelection", UPDATE_EXTERNAL_PARAMETER_SERVICE_PACKAGE: "updateExternalParameterServicePackage", + UPDATE_EXTERNAL_PARAMETER_SOURCE: "updateExternalParameterSource", //RESET EXTERNAL_PARAMETER MUTATIONS RESET_EXTERNAL_PARAMETER_VEHICLE_STATE: "resetExternalParameterVehicleState", @@ -110,6 +111,7 @@ const storeMutations = { RESET_EXTERNAL_PARAMETER_ESTIMATE_STATE: "resetExternalParameterEstimateState", RESET_EXTERNAL_PARAMETER_SERVICEZIP_STATE: "resetExternalParameterServiceZipState", RESET_EXTERNAL_PARAMETER_QUOTE_STATE: "resetExternalParameterQuoteState", + RESET_EXTERNAL_PARAMETER_SOURCE_STATE: "resetExternalParameterSourceState", RESET_IS_EXTERNAL_PARAMETER: "resetIsExternalParameter", //Affiliate Cookies diff --git a/src/fmg-components/funnel-sub-header/funnel-sub-header.vue b/src/fmg-components/funnel-sub-header/funnel-sub-header.vue index fa8aea13f..54c8aa34d 100644 --- a/src/fmg-components/funnel-sub-header/funnel-sub-header.vue +++ b/src/fmg-components/funnel-sub-header/funnel-sub-header.vue @@ -53,7 +53,7 @@ export default { return this.leftAlignHeader ? "justify-content-start" : "justify-content-center"; }, subText() { - return this.getCmsContent(this.cmsWidgetName, "HeaderSubText"); + return this.getCmsContent(this.cmsWidgetName, "SubheaderText"); }, backButtonAccessibleText() { return this.getCmsContent(this.cmsWidgetName, "BackButtonAccessibleText"); diff --git a/src/global-methods.js b/src/global-methods.js index c286ede9b..6afeef53d 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -1,5 +1,6 @@ import axios from "axios"; import analyticsMixIn from "@/mixins/analytics-mixin.js"; +import baseMixin from "@/mixins/base-mixin.js"; import router from "@/router"; import store from "@/store"; @@ -57,9 +58,17 @@ export default { }) { return new Promise((resolve, reject) => { const cfDistroUrl = applicationConfig.CONSUMER_CF_DISTRO; + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const order = hasSubmittedOrder ? submittedOrder : store.getters.order; const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "FixMyGlass" }); const headers = { [headerKeys.EXPERIMENT]: JSON.stringify(store.getters.experimentSettings), + [headerKeys.APPLICATION_NAME]: applicationConfig.APPLICATION_NAME, + [headerKeys.SESSION_SEQUENCE_NUMBER]: store.getters.applicationUser?.savedSessionId, + [headerKeys.REFERRAL_SEQUENCE_NUMBER]: order?.referralSequenceNumber, + [headerKeys.TRANSACTION_ID]: crypto.randomUUID(), + [headerKeys.EON]: order?.eon, }; axios({ diff --git a/src/global-methods.spec.js b/src/global-methods.spec.js index 93aafea81..e6c0e4858 100644 --- a/src/global-methods.spec.js +++ b/src/global-methods.spec.js @@ -13,6 +13,7 @@ global.$logger = { logError: jest.fn(), logCritical: jest.fn(), }; +global.crypto = { randomUUID: jest.fn() }; it("Global Methods - Call Http Client - Should Resolve Promise", () => { //Arrange diff --git a/src/helpers/heritage-integration/order-helper.js b/src/helpers/heritage-integration/order-helper.js index 07c469ae8..555c41325 100644 --- a/src/helpers/heritage-integration/order-helper.js +++ b/src/helpers/heritage-integration/order-helper.js @@ -57,7 +57,7 @@ export async function saveSession({ pageNameToLog, shouldAwaitSaveSessionQueue = false, submitAfterSave = false, - createDeleteStatusWorkOrderForPia = false, + createUnscheduledStatusWorkOrderForPIA = false, }) { var saveSessionPromise; if (store.getters.applicationUser.saveSessionPromise) { @@ -67,7 +67,7 @@ export async function saveSession({ return saveSessionHelper( pageNameToLog, submitAfterSave, - createDeleteStatusWorkOrderForPia + createUnscheduledStatusWorkOrderForPIA ); }); } else { @@ -75,7 +75,7 @@ export async function saveSession({ saveSessionPromise = saveSessionHelper( pageNameToLog, submitAfterSave, - createDeleteStatusWorkOrderForPia + createUnscheduledStatusWorkOrderForPIA ); } store.commit(storeMutations.UPDATE_SAVE_SESSION_PROMISE, saveSessionPromise); @@ -88,13 +88,13 @@ export async function saveSession({ export async function submitWorkOrder({ pageNameToLog, submitAfterSave = false, - createDeleteStatusWorkOrderForPia = false, + createUnscheduledStatusWorkOrderForPIA = false, }) { await saveSession({ pageNameToLog: pageNameToLog, shouldAwaitSaveSessionQueue: true, submitAfterSave: submitAfterSave, - createDeleteStatusWorkOrderForPia: createDeleteStatusWorkOrderForPia, + createUnscheduledStatusWorkOrderForPIA: createUnscheduledStatusWorkOrderForPIA, }); } @@ -139,13 +139,13 @@ async function loadSession( async function saveSessionHelper( pageNameToLog, submitAfterSave = false, - createDeleteStatusWorkOrderForPia = false + createUnscheduledStatusWorkOrderForPIA = false ) { const savedSessionInfo = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.SAVE_SESSION, { submitAfterSave: submitAfterSave, - createDeleteStatusWorkOrderForPia: createDeleteStatusWorkOrderForPia, + createUnscheduledStatusWorkOrderForPIA: createUnscheduledStatusWorkOrderForPIA, }, pageNameToLog ); diff --git a/src/helpers/heritage-integration/order-helper.spec.js b/src/helpers/heritage-integration/order-helper.spec.js index daf9af6bb..ac8d08e9f 100644 --- a/src/helpers/heritage-integration/order-helper.spec.js +++ b/src/helpers/heritage-integration/order-helper.spec.js @@ -179,7 +179,7 @@ describe("saveSession", () => { // Assert expect(mocks.baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledWith( storeActions.SAVE_SESSION, - { createDeleteStatusWorkOrderForPia: false, submitAfterSave: false }, + { createUnscheduledStatusWorkOrderForPIA: false, submitAfterSave: false }, "test" ); expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith( @@ -287,7 +287,7 @@ describe("submitWorkOrder", () => { // Assert expect(mocks.baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledWith( storeActions.SAVE_SESSION, - { createDeleteStatusWorkOrderForPia: false, submitAfterSave: true }, + { createUnscheduledStatusWorkOrderForPIA: false, submitAfterSave: true }, "test" ); expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith( diff --git a/src/layouts/confirmation/confirmation.vue b/src/layouts/confirmation/confirmation.vue index acb97cba2..72ee038db 100644 --- a/src/layouts/confirmation/confirmation.vue +++ b/src/layouts/confirmation/confirmation.vue @@ -93,7 +93,6 @@ import textBlock from "@/digital-components/text-block/text-block"; //Supporting files import baseMixin from "@/mixins/base-mixin.js"; -import analyticsMixin from "@/mixins/analytics-mixin"; import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; @@ -107,7 +106,8 @@ import { get12HourTimeFormat, get12HourTimeMobileFormat } from "@/helpers/date-h import { coverageStatus } from "@/constants/insurance"; import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper"; import { experimentSettings } from "@/constants/experiments"; -import experimentMixin from "@/mixins/experiment-mixin.js"; +import { partTypeStrings } from "@/constants/part-type-strings"; +import { containsLineItemWithPartType } from "@/helpers/service-package-helper"; export default { name: "confirmation", @@ -169,8 +169,17 @@ export default { }; }, computed: { + isRecalibrationOnOrder() { + return containsLineItemWithPartType( + partTypeStrings.RECALIBRATION, + this?.lineItems?.supportingItems + ); + }, shouldHideRecalibration() { - return this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE); + return ( + this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() === + "true" && this.isRecalibrationOnOrder + ); }, ShowCart() { if (this.isPia && this.submittedOrder?.settledTenderAmount == 0) { diff --git a/src/layouts/estimate/estimate.spec.js b/src/layouts/estimate/estimate.spec.js index 87235d15a..74ca7d0bf 100644 --- a/src/layouts/estimate/estimate.spec.js +++ b/src/layouts/estimate/estimate.spec.js @@ -24,6 +24,7 @@ jest.mock("@/helpers/cms-content-helper", () => ({ fetchCmsContentForPage: jest.fn(), })); +global.crypto = { randomUUID: jest.fn() }; describe("estimate.vue", () => { test("Selected vin option is emitted upon selection.", async () => { //Arrange diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 2c458e124..688d98b8a 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -552,23 +552,21 @@ export default { }, async setupPia() { this.$refs.loadingModal.showModal(); - - // if we don't have a work order in delete substatus, set the flag and submit. // we need a work order number for pia so we can pass it to safeliteHop. - if (!store.getters.order.workOrderNumber) { - try { - await submitWorkOrder({ - pageNameToLog: "payment-method", - submitAfterSave: false, - createDeleteStatusWorkOrderForPia: true, - }); - } catch (error) { - console.log("error: response from pia submit work order:" + error.message); - this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE); - this.$route.query[queryStrings.DISPLAY_PIA_ALERT] = true; - return; - } + // this will create one in delete substatus. + try { + await submitWorkOrder({ + pageNameToLog: "payment-method", + submitAfterSave: false, + createUnscheduledStatusWorkOrderForPIA: true, + }); + } catch (error) { + console.log("error: response from pia submit work order:" + error.message); + this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE); + this.$route.query[queryStrings.DISPLAY_PIA_ALERT] = true; + return; } + this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE); this.$router.navigateWithoutSaving( this.navigationScenarios.CLICKED_PAY_NOW, @@ -596,7 +594,10 @@ export default { return containsRecalParts(order.lineItems); }, shouldHideRecalibration() { - return this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE); + return ( + this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() === + "true" && this.isRecalibrationOnOrder + ); }, showApplePay() { return baseMixin.methods.showApplePay(); diff --git a/src/layouts/quote/quote.spec.js b/src/layouts/quote/quote.spec.js index ca2bfd78e..d9c1ffabf 100644 --- a/src/layouts/quote/quote.spec.js +++ b/src/layouts/quote/quote.spec.js @@ -7,6 +7,7 @@ import store from "@/store"; import * as navigateToHeritage from "@/helpers/heritage-integration/navigation-helper"; import { nextTick } from "vue"; import { experimentSettings } from "@/constants/experiments"; +import experimentMixin from "@/mixins/experiment-mixin.js"; import baseMixin from "@/mixins/base-mixin.js"; import { containsLineItemWithPartType } from "../../helpers/service-package-helper"; @@ -169,12 +170,12 @@ describe("quote.vue", () => { damage: { isRepair: true, }, - referralNumber: "1234567", - }, - payment: { - insuranceCoverage: { - isVerified: false, + payment: { + insuranceCoverage: { + isVerified: false, + }, }, + referralNumber: "1234567", }, }; const { wrapper } = setupMocks({}); @@ -198,10 +199,10 @@ describe("quote.vue", () => { lineItems: { glassParts: ["item", "item2"], }, - }, - payment: { - insuranceCoverage: { - isVerified: false, + payment: { + insuranceCoverage: { + isVerified: false, + }, }, }, }; @@ -391,12 +392,11 @@ describe("quote.vue", () => { }, }; const { wrapper } = setupMocks({}); - wrapper.vm.$route = { query: { isInsurance: "false" } }; //Act await quote.beforeRouteEnter.call( wrapper.vm, - { query: { fmgPage: "quote" } }, + { query: { fmgPage: "quote", isInsurance: false } }, undefined, (c) => c(wrapper.vm) ); @@ -747,6 +747,14 @@ function setupMocks({ customMountOptions }) { ...customMountOptions, }); + mountOptions.mixins = [ + { + methods: { + getSettingValue: jest.fn(), + }, + }, + ]; + baseMixin.methods.ResetExternalParamsAndHideModal = jest.fn(); baseMixin.methods.isFormValid = jest.fn().mockReturnValue(true); mountOptions.global.mocks["$store"] = store; diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index c6176ff9d..c8e2dda02 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -113,8 +113,7 @@ import { storeActions } from "@/constants/store-actions"; import { deepClone } from "@/helpers/object-helper"; import store from "@/store"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; -import { experimentUniverses, experimentSettings } from "@/constants/experiments"; -import { getSessionKeyValue, getUserIdValue } from "@/helpers/heritage-integration/cookie-helper"; +import { experimentSettings } from "@/constants/experiments"; import { required } from "@/helpers/validation-rules"; import { errorMessages } from "@/constants/error-messages"; import { Form, defineRule } from "vee-validate"; @@ -129,13 +128,18 @@ import { queryStrings } from "@/constants/query-strings"; import { getQuerystringParameter } from "@/helpers/querystring-helper"; import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question"; import { partTypeStrings } from "@/constants/part-type-strings"; -import { containsLineItemWithPartType } from "@/helpers/service-package-helper"; +import { + containsLineItemWithPartType, + findLineItemsWithPartType, +} from "@/helpers/service-package-helper"; import { nextTick } from "vue"; import { packageNames } from "@/constants/package-names"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { containsRecalParts } from "@/helpers/recal-helper"; defineRule("option-required", required(errorMessages.OPTION_REQUIRED)); +const INSURANCE_TAB_TO_DISPLAY_THRESHOLD_DEFAULT = 300; + export default { name: "quote", async beforeRouteEnter(to, from, next) { @@ -208,7 +212,10 @@ export default { ...servicePackageDiscountPart, ]; - // When calling pricing from the quote page, always use the cash parent account + // When calling pricing from the quote page, always use the cash parent account but save the existing one in case it's unverified insurance navigating backwards + var holdParentAccountNumber = store.getters.order.payment?.parentAccountNumber; + var holdBillToAccountNumber = store.getters.order.payment?.billToAccountNumber; + baseMixin.methods.dispatchStoreAction( storeActions.SAVE_PARENT_ACCOUNT_NUMBER, applicationConfig.CASH_PARENT_ACCOUNT_NUMBER, @@ -260,7 +267,8 @@ export default { vm.addableVaps = addableVaps; vm.lineItems = lineItems; vm.availableLineItems = pricingResults; - vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems); + vm.holdParentAccountNumber = holdParentAccountNumber; + vm.holdBillToAccountNumber = holdBillToAccountNumber; if (revalidatePromoResponse) { const revalidateAlerts = buildToastMessagesFromRevalidateOrValidatePromoResponse( @@ -281,21 +289,68 @@ export default { ); } + // This is a safeguard to prevent verified insurance from seeing quote page again. const payment = store.getters.payment; - const policy = store.getters.policy; - if (payment?.isInsurance) { - const vehicleChangedDuringPolicyLookupInHeritage = - payment.insuranceCoverage?.coverageStatus && policy?.policyNumber !== ""; - - // This is a safeguard to prevent insurance from seeing quote page again. - // Usually this is caught in vehicle-questions-mixin with a referral length 6 check - if (vehicleChangedDuringPolicyLookupInHeritage) { - navigateToHeritageFunnel({ shouldSaveSession: true, pageNameToLog: "quote" }); - } + if (payment?.isInsurance && payment?.insuranceCoverage?.isVerified) { + navigateToHeritageFunnel({ shouldSaveSession: true, pageNameToLog: "quote" }); } - if (store.getters.externalParameterState?.isExternalParameter) { + const getIsInsuranceSelectedValue = (availableLineItems, insuranceThreshold) => { + const serviceLocationState = store.getters.order.serviceLocation.state; + // Override if coming back from QuoteDetails. Remove override after Quote release + const isInsuranceOverrideValue = to.query?.isInsurance; + const defaultIsInsuranceSelectedValue = store.getters.order.payment.isInsurance; + const hideRecalCost = + experimentMixin.methods + .getSettingValue(experimentSettings.RECAL_PRICE_REMOVE) + ?.toLowerCase() === "true"; + + //Default to Insurance Tab if user Service zip is from certain States + if ( + serviceLocationState != null && + payWithInsuranceStates.find((item) => item === serviceLocationState) + ) + return true; + else if (isInsuranceOverrideValue != null) { + return isInsuranceOverrideValue == "true"; + } else if (defaultIsInsuranceSelectedValue != null) { + return defaultIsInsuranceSelectedValue; + } else { + if (availableLineItems) { + const lineItemsForCalculatingPrice = hideRecalCost + ? baseMixin.methods.filterOutFees( + baseMixin.methods.filterOutRecalibration(availableLineItems) // Strip out recal before filtering out fees + ) + : baseMixin.methods.filterOutFees(availableLineItems); + + return ( + baseMixin.methods.getTierOnePackagePrice(lineItemsForCalculatingPrice) > + insuranceThreshold + ); // compare base price vs arbitrary threshold (representing insurance price) + } else { + return null; + } + } + }; + let thresholdToUse = INSURANCE_TAB_TO_DISPLAY_THRESHOLD_DEFAULT; + let internalThreshold = experimentMixin.methods.getSettingValue( + experimentSettings.INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL + ); + + if (!store.getters.externalParameterState?.isExternalParameter) { + // there are no external parameters + + if (internalThreshold) thresholdToUse = internalThreshold; + vm.isInsuranceSelected = getIsInsuranceSelectedValue( + vm.availableLineItems, + thresholdToUse + ); + baseMixin.methods.ResetExternalParamsAndHideModal(); + } else { + // there ARE external parameters + if (store.getters.externalParameterQuote.isInsurance == true) { + // did user intentionally select insurance? vm.isInsuranceSelected = true; vm.servicePackage = store.getters.externalParameterQuote.servicePackage; await nextTick(); @@ -306,10 +361,26 @@ export default { baseMixin.methods.ResetExternalParamsAndHideModal(); } } else { + // did user come from external source (LeadGen)? + let externalSource = store.getters.externalParameterSource + ? store.getters.externalParameterSource + : null; + + // Business logic to determine what "external" source is + if (externalSource?.includes("LeadGen")) { + const externalThreshold = experimentMixin.methods.getSettingValue( + experimentSettings.INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL + ); + if (externalThreshold) thresholdToUse = externalThreshold; + } else { + if (internalThreshold) thresholdToUse = internalThreshold; + } + vm.isInsuranceSelected = getIsInsuranceSelectedValue( + vm.availableLineItems, + thresholdToUse + ); baseMixin.methods.ResetExternalParamsAndHideModal(); } - } else { - baseMixin.methods.ResetExternalParamsAndHideModal(); } }); }, @@ -320,6 +391,8 @@ export default { lineItems: [], addableVaps: [], servicePackage: null, + holdParentAccountNumber: null, + holdBillToAccountNumber: null, }; }, computed: { @@ -329,11 +402,23 @@ export default { isRecalibrationOnOrder() { return containsRecalParts(this.lineItems); }, - shouldHideRecalibration() { - const recalSettingValue = experimentMixin.methods.getSettingValue( - experimentSettings.RECAL_PRICE_REMOVE + isServicePackageDiscountOnOrder() { + return containsLineItemWithPartType( + partTypeStrings.SERVICE_PACKAGE_DISCOUNT, + this.availableLineItems + ); + }, + servicePackageDiscountParts() { + return findLineItemsWithPartType( + partTypeStrings.SERVICE_PACKAGE_DISCOUNT, + this.availableLineItems + ); + }, + shouldHideRecalibration() { + return ( + this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() === + "true" && this.isRecalibrationOnOrder ); - return recalSettingValue; }, showAfterpayBanner() { return ( @@ -350,39 +435,46 @@ export default { this.$refs[modalName].openModal(); }, arePagePrerequisitesValid() { + const payment = store.getters.order.payment; + const log = getQuerystringParameter(queryStrings.LOG); + if (eval(log)) { + console.log("quote.vue pagePrereqs:"); + console.log( + new Date() + + " store.getters.order.serviceLocation.zipCode: " + + store.getters.order.serviceLocation?.zipCode + ); + console.log( + new Date() + + " store.getters.order.serviceLocation.zipCodeCtu: " + + store.getters.order.serviceLocation?.zipCodeCtu + ); + console.log( + new Date() + + " store.getters.order.damage.isRepair: " + + store.getters.order.damage?.isRepair + ); + console.log( + new Date() + + " store.getters.order.lineItems?.glassParts: " + + JSON.stringify(store.getters.order.lineItems?.glassParts) + ); + console.log( + new Date() + + " store.getters.order.payment.insuranceCoverage?.isVerified: " + + payment?.insuranceCoverage?.isVerified + ); + } return ( store.getters.order.serviceLocation.zipCode && store.getters.order.serviceLocation.zipCodeCtu && (store.getters.order.damage.isRepair || (store.getters.order.lineItems?.glassParts != null && store.getters.order.lineItems.glassParts.length > 0)) && - store.getters.order.referralNumber?.length !== 6 + (payment.insuranceCoverage?.isVerified == null || + payment.insuranceCoverage?.isVerified === false) ); }, - getDefaultIsInsuranceSelectedValue(availableLineItems) { - const serviceLocationState = store.getters.order.serviceLocation.state; - // Override if coming back from QuoteDetails. Remove override after Quote release - const isInsuranceOverrideValue = this.$route.query?.isInsurance; - - const defaultIsInsuranceSelectedValue = this.$store.getters.order.payment.isInsurance; - //Default to Insurance Tab if user Service zip is from certain States - if ( - serviceLocationState != null && - payWithInsuranceStates.find((item) => item === serviceLocationState) - ) - return true; - else if (isInsuranceOverrideValue != null) { - return isInsuranceOverrideValue == "true"; - } else if (defaultIsInsuranceSelectedValue != null) { - return defaultIsInsuranceSelectedValue; - } else { - return availableLineItems - ? baseMixin.methods.getTierOnePackagePrice( - baseMixin.methods.filterOutFees(availableLineItems) - ) > 300 - : null; - } - }, vapsItemsSelectedAction(vapsItemsSelected) { this.lineItems.vaps = vapsItemsSelected; }, @@ -410,6 +502,17 @@ export default { applicationConfig.CASH_DEFAULT_BILL_TO_ACCOUNT_NUMBER, false ); + } else { + baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_PARENT_ACCOUNT_NUMBER, + this.holdParentAccountNumber, + false + ); + baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_BILL_TO_ACCOUNT_NUMBER, + this.holdBillToAccountNumber, + false + ); } if ( @@ -473,6 +576,46 @@ export default { ); } }); + this.$nextTick(() => { + const availablePackageNames = this.$refs.servicePackage.servicePackageAnswers; + var eventLabel = ""; + availablePackageNames?.forEach((tier) => { + if (tier) { + let lineItemsToPrice = this.availableLineItems; + if (this.isRecalibrationOnOrder && this.shouldHideRecalibration) { + lineItemsToPrice = lineItemsToPrice?.filter((item) => { + return item.partType != partTypeStrings.RECALIBRATION; + }); + } + if (this.isServicePackageDiscountOnOrder) { + lineItemsToPrice = lineItemsToPrice?.filter((item) => { + return ( + item.partType != this.servicePackageDiscountParts[0].partType + ); + }); + } + let price = 0; + if (tier.value == "TierTwo" && this.isServicePackageDiscountOnOrder) { + lineItemsToPrice.push(...this.servicePackageDiscountParts); + } + price += baseMixin.methods.getTierOnePackagePrice( + baseMixin.methods.filterOutFees(lineItemsToPrice) + ); + price += this.$refs.servicePackage.getVapsPrice(tier.value); + const formattedPrice = parseFloat(price).toFixed(2); + eventLabel += tier.value + "_" + formattedPrice; + } + eventLabel += ","; + }); + eventLabel = eventLabel.slice(0, -1); + + this.pushEventToGA( + "quote", + this.GaActions.SERVICE_PACKAGE_PRICE, + eventLabel, + true + ); + }); }, }, mounted() { diff --git a/src/layouts/quote/service-package-question/service-package-question.vue b/src/layouts/quote/service-package-question/service-package-question.vue index 6dfe4fed6..afd592e72 100644 --- a/src/layouts/quote/service-package-question/service-package-question.vue +++ b/src/layouts/quote/service-package-question/service-package-question.vue @@ -15,7 +15,6 @@ import buttonQuestion from "@/digital-components/button-question/button-question"; import baseMixin from "@/mixins/base-mixin.js"; import { processIfStatements } from "@/helpers/cms-content-helper"; -import { damageLocationsSelected as glassLocations } from "@/constants/damage-locations-selected"; import servicePackageRadio from "./service-package-radio/service-package-radio"; import { partTypeStrings } from "@/constants/part-type-strings"; import { packageNames } from "@/constants/package-names"; @@ -238,7 +237,7 @@ export default { return (this.isInsuranceSelected ? "As little as $" : "$") + formattedPriceFloat; }, getPackagePrice(packageName, { discountedPrice = false, servicePackageDiscount = false }) { - var lineItemsToPrice = [...this.nullSafeAvailableLineItems]; + let lineItemsToPrice = [...this.nullSafeAvailableLineItems]; if (this.isRecalibrationOnOrder && this.shouldHideRecalibration) { lineItemsToPrice = getItemsWithoutRecalParts(lineItemsToPrice); diff --git a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js index e92349ddf..1e7b6bdb3 100644 --- a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js +++ b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js @@ -3,7 +3,7 @@ import baseMixin from "@/mixins/base-mixin.js"; export async function getPricedMobileFeePart(serviceZipCode, pageNameToLog) { if (!serviceZipCode) { - return Promise.resolve(null); + return null; } const zipCodeData = await baseMixin.methods.getZipCodeData(serviceZipCode, pageNameToLog); @@ -36,12 +36,12 @@ export async function getPricedMobileFeePart(serviceZipCode, pageNameToLog) { false ); - return Promise.resolve(pricingResults[0]); + return pricingResults[0]; } export async function getServiceabilityDetails(serviceZipCode, lineItems, pageNameToLog) { // Get the Mobile Fee Part - const serviceabilityDetails = await baseMixin.methods.dispatchStoreActionWithLogging( + return await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.GET_SERVICEABILITY_DETAILS, { serviceZipCode: serviceZipCode, @@ -50,20 +50,26 @@ export async function getServiceabilityDetails(serviceZipCode, lineItems, pageNa pageNameToLog, false ); +} - return Promise.resolve(serviceabilityDetails); +export async function getBillToAccountNumber(providerNumber) { + // Get the BillTo Account Number + return await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_BILL_TO_ACCOUNT_NUMBER, + { providerNumber: providerNumber }, + "service-location", + false + ); } export async function getShopProviderData(serviceZipCode) { - const shopProviderData = await baseMixin.methods.dispatchStoreActionWithLogging( + return await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.GET_PROVIDERS, { serviceZipCode: serviceZipCode, }, "service-location" ); - - return Promise.resolve(shopProviderData); } export async function getAvailabilityRating( @@ -92,5 +98,5 @@ export async function getAvailabilityRating( const shopStatus = isGoodAvailability ? "high" : "low"; - return Promise.resolve(shopStatus); + return shopStatus; } diff --git a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js index 56833dbaa..863a04c82 100644 --- a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js +++ b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.spec.js @@ -2,6 +2,7 @@ import mobileLocationModalQuestions from "./mobile-location-modal-questions"; import { mount, shallowMount } from "@vue/test-utils"; import { storeActions } from "@/constants/store-actions"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { getBillToAccountNumber } from "../helpers/service-location-helper/service-location-helper"; const linkWidgetName = "linkWidgetName"; const modalWidgetName = "modalWidgetName"; @@ -125,6 +126,9 @@ jest.mock( getServiceabilityDetails: jest.fn((mockServiceZipCode) => { return mockGetServiceabilityDetails(mockServiceZipCode); }), + getBillToAccountNumber: jest.fn(() => { + return "87291"; + }), }) ); 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 4bdc00cb3..89e095279 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 @@ -83,6 +83,7 @@ import { deepClone } from "@/helpers/object-helper"; import { getPricedMobileFeePart, getServiceabilityDetails, + getBillToAccountNumber, } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; // Validation @@ -91,7 +92,12 @@ import { v4 as uuidv4 } from "uuid"; export default { name: "mobile-location-modal-questions", - emits: ["update:modelValue", "updated-mobile-fee-part", "updated-contains-military-base"], + emits: [ + "update:modelValue", + "updated-mobile-fee-part", + "updated-contains-military-base", + "updated-bill-to-account-number", + ], data() { return { internalModel: deepClone(this.modelValue), @@ -266,11 +272,16 @@ export default { "service-location" ); + 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 the page level model this.$emit("update:modelValue", this.internalModel); diff --git a/src/layouts/service-location/service-location.vue b/src/layouts/service-location/service-location.vue index f9a2ec240..1922d098a 100644 --- a/src/layouts/service-location/service-location.vue +++ b/src/layouts/service-location/service-location.vue @@ -19,6 +19,7 @@ @updated-mobile-fee-part="setMobileFeePart" @updated-serviceability="setServiceabilityDetails" @updated-contains-military-base="setContainsMilitaryBase" + @updated-bill-to-account-number="setBillToAccountNumber" linkWidgetName="ServiceZipLinkWidget" modalWidgetName="ServiceZipModalWidget" /> @@ -191,6 +192,7 @@ export default { mobileFeePart: null, zipContainsMilitaryBase: false, zipCodeCtu: null, + billToAccountNumber: null, shopProviderData: null, navigatingForward: false, }; @@ -431,12 +433,27 @@ export default { if (shopProviderData) { this.shopProviderData = shopProviderData; } + + var gaLabel = this.GaLabels.NO; + if (this.isServiceableMobile) { + gaLabel = this.GaLabels.YES; + } + + this.pushEventToGA( + this.GaCategories.APPOINTMENT, + this.GaActions.MOBILE_AVAILABLE, + gaLabel, + true + ); }, setContainsMilitaryBase(val) { if (this.zipContainsMilitaryBase !== val) { this.zipContainsMilitaryBase = val; } }, + setBillToAccountNumber(val) { + this.billToAccountNumber = val; + }, setCtuForMobile(val) { this.zipCodeCtu = val; }, @@ -498,7 +515,8 @@ export default { this.recalibrationInformationModal.openModal(); }, backButtonAction() { - if (store.getters.order.payment.isInsurance) { + const payment = store.getters.order.payment; + if (payment?.isInsurance && payment?.insuranceCoverage?.isVerified) { navigateToHeritageFunnel({ shouldSaveSession: false }); } else { this.$router.navigateWithoutSaving( @@ -602,6 +620,14 @@ export default { false ); + if (this.billToAccountNumber) { + this.dispatchStoreAction( + this.storeActions.SAVE_BILL_TO_ACCOUNT_NUMBER, + this.billToAccountNumber, + false + ); + } + this.updateAndSaveSupportingItems(); this.$router.navigateWithSaving(this.navigationScenarios.CLICKED_FORWARD, this.$route); }, diff --git a/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.spec.js b/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.spec.js index 7c09836fe..ed8a811ed 100644 --- a/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.spec.js +++ b/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.spec.js @@ -1,5 +1,6 @@ import { mount, shallowMount } from "@vue/test-utils"; import serviceZipModalQuestion from "./service-zip-modal-question"; +import { getBillToAccountNumber } from "../helpers/service-location-helper/service-location-helper"; jest.mock("@/digital-components/textbox-question/textbox-question", () => ({ getCmsContent: jest.fn((widgetName, cmsFieldName) => { @@ -51,6 +52,9 @@ jest.mock( getServiceabilityDetails: jest.fn((mockServiceZipCode) => { return mockGetServiceabilityDetails(mockServiceZipCode); }), + getBillToAccountNumber: jest.fn((mockServiceZipCodeCtu) => { + return "87291"; + }), }) ); diff --git a/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue b/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue index 841dfe153..c0231ea80 100644 --- a/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue +++ b/src/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue @@ -46,11 +46,17 @@ import alert from "@/ux-components/alert/alert"; import { getPricedMobileFeePart, getServiceabilityDetails, + getBillToAccountNumber, } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; export default { name: "service-zip-modal-question", - emits: ["update:modelValue", "updated-mobile-fee-part", "updated-contains-military-base"], + emits: [ + "update:modelValue", + "updated-mobile-fee-part", + "updated-contains-military-base", + "updated-bill-to-account-number", + ], data() { return { internalModel: this.copyModel(this.modelValue), @@ -165,10 +171,15 @@ export default { "service-location" ); + 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-bill-to-account-number", billToAccountNumber); // update the page level model this.$emit("update:modelValue", this.internalModel); diff --git a/src/layouts/service-location/shop-question/shop-question.vue b/src/layouts/service-location/shop-question/shop-question.vue index 1b4d7dea8..d77a7eb04 100644 --- a/src/layouts/service-location/shop-question/shop-question.vue +++ b/src/layouts/service-location/shop-question/shop-question.vue @@ -52,6 +52,7 @@ import { nextTick } from "vue"; import { getAvailabilityRating } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; import { Provider } from "@/layouts/service-location/classes/provider"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; defineRule("option-required", required(errorMessages.OPTION_REQUIRED)); @@ -125,6 +126,8 @@ export default { }, methods: { async getNextShopsFromList(numberToGet = 3) { + const logFirstShopsDisplayed = this.shopIndex == 0; + const shopIterator = (array, n) => { const l = array.length; return () => { @@ -160,6 +163,27 @@ export default { }; }); + var gaAction = this.GaActions.MORE_LOCATIONS_CLICKED; + if (logFirstShopsDisplayed) { + gaAction = this.GaActions.SHOPS_FIRST_DISPLAYED; + } + if (this.selectedAppointmentType === AppointmentTypeStrings.IN_SHOP) { + var shops = mappedData.map((shop) => { + if (shop.value.length > 5 && shop.value.startsWith("00")) { + return shop.value.substring(1); + } else { + return shop.value; + } + }); + + this.pushEventToGA( + this.GaCategories.SERVICE_LOCATION, + gaAction, + shops.join("/"), + true + ); + } + if (this.answers.length === 0) { this.answers = mappedData; } else { diff --git a/src/layouts/service-zip/service-zip.vue b/src/layouts/service-zip/service-zip.vue index 34efac1a1..a60eb4aff 100644 --- a/src/layouts/service-zip/service-zip.vue +++ b/src/layouts/service-zip/service-zip.vue @@ -213,7 +213,7 @@ export default { const supportingItemsPromise = await this.dispatchStoreActionWithLogging( storeActions.GET_SUPPORTING_ITEMS, null, - "estimate" + "service-zip" ); const promiseResultMap = [ @@ -232,7 +232,7 @@ export default { ); // call saveSession here - navigateWithSaving saves too late in the flow - await saveSession({ pageNameToLog: "estimate" }); + await saveSession({ pageNameToLog: "service-zip" }); return this.$router.navigateWithSaving( this.navigationScenarios.CLICKED_FORWARD_WITH_NO_QUESTIONS, this.$route diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 416113d2e..b66ad59dc 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -211,6 +211,13 @@ export default { payload.glassToReplace = glassString; } + //EON + if (order.eon) { + payload.eon = order.eon; + } else { + payload.eon = ""; + } + // Work Order Id if (order.workOrderId) { const parsedId = parseInt(order.workOrderId); @@ -384,6 +391,257 @@ export default { }); }, + pushProductArrayToDataLayer() { + // Get correct order object + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + + if (hasSubmittedOrder) { + var lineItems = submittedOrder.lineItems ? submittedOrder.lineItems : {}; + + // combine all line items + var combinedLineItems = [ + ...(lineItems.glassParts ?? []), + ...(lineItems.supportingItems ?? []), + ...(lineItems.vaps ?? []), + ...(lineItems.promos ?? []), + ]; + + //Get child parts + combinedLineItems = flattenArray(combinedLineItems); + + var products = []; + var discount = 0; + var coupon = ""; + var subTotal = 0; + for (var i = 0; i < combinedLineItems.length; i++) { + let part = combinedLineItems[i]; + let productSku = part.partNumber; + let productType = part.partType; + let promoCode = part.promoCode; + let productPrice = baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts( + [part], + false + ); + + if (productSku == "DISCOUNT" || productType == "SERVICE PACKAGE DISCOUNT") { + if (coupon) { + coupon += ","; + } + if (productSku == "DISCOUNT") { + coupon += promoCode; + } else { + coupon += productSku; + } + + discount += productPrice * -1; + } else { + products.push({ + productType: productType, + productSku: productSku, + productPrice: productPrice.toFixed(2), + productQuantity: "1", + }); + subTotal += productPrice; + } + } + pushToDataLayerIfDefined({ + productArray: { + coupon: coupon, + discount: discount.toFixed(2), + subTotal: subTotal.toFixed(2), + products: products, + }, + }); + } + }, + + pushECommerceCartToDataLayer() { + const isDefined = (x) => x !== null && x !== undefined; + // Get correct order object + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + if (hasSubmittedOrder) { + var lineItems = submittedOrder.lineItems ? submittedOrder.lineItems : {}; + var supportingItems = + submittedOrder.lineItems && submittedOrder.lineItems.supportingItems + ? submittedOrder.lineItems.supportingItems + : []; + var insurance = submittedOrder.payment && submittedOrder.payment.isInsurance; + var itac = submittedOrder.policy && submittedOrder.policy.isItac; + var nocomp = submittedOrder.policy && submittedOrder.policy.isNoComp; + + var recalLineItem = supportingItems.filter(function (lineItem) { + return lineItem.partType.indexOf("RECALIBRATION") !== -1; + }); + var disposalFee = supportingItems.filter(function (lineItem) { + return lineItem.partType.indexOf("DISPOSAL FEE") !== -1; + }); + var mobileFee = supportingItems.filter(function (lineItem) { + return lineItem.partType.indexOf("MOBILE FEE") !== -1; + }); + var repairFee = supportingItems.filter(function (lineItem) { + return lineItem.partType.indexOf("REPAIR FEE") !== -1; + }); + + var vaps = + submittedOrder.lineItems && submittedOrder.lineItems.vaps + ? submittedOrder.lineItems.vaps + : []; + var wipers = vaps.filter(function (lineItem) { + return ( + lineItem.partType.indexOf("FRONT WIPER") !== -1 || + lineItem.partType.indexOf("REAR WIPER") !== -1 + ); + }); + var rainDefense = vaps.filter(function (lineItem) { + return lineItem.partType.indexOf("RAIN DEFENSE") !== -1; + }); + + // Combine line items + var combinedLineItems = []; + if (lineItems.glassParts) { + combinedLineItems = combinedLineItems.concat(lineItems.glassParts); + } + if (recalLineItem) { + combinedLineItems = combinedLineItems.concat(recalLineItem); + } + if (disposalFee) { + combinedLineItems = combinedLineItems.concat(disposalFee); + } + if (mobileFee && !(insurance && !itac && !nocomp)) { + combinedLineItems = combinedLineItems.concat(mobileFee); + } + if (repairFee) { + combinedLineItems = combinedLineItems.concat(repairFee); + } + if (wipers) { + combinedLineItems = combinedLineItems.concat(wipers); + } + if (rainDefense) { + combinedLineItems = combinedLineItems.concat(rainDefense); + } + + //Remove child Parts if any + combinedLineItems.forEach((lineItem) => { + lineItem.childParts = []; + }); + + var isPricingAvailable = + combinedLineItems.length > 0 && + combinedLineItems.every(function (lineItem) { + return ( + isDefined(lineItem.kitPrice) && + isDefined(lineItem.laborAmount) && + isDefined(lineItem.sellingPrice) + ); + }); + let subtotal = 0; + if (isPricingAvailable) { + subtotal = baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts( + combinedLineItems, + false + ); + } + pushToDataLayerIfDefined({ + eCommerceCart: { products: combinedLineItems, subTotal: parseInt(subtotal) }, + }); + } + }, + + pushCommissionJunctionGtmDataToDataLayer() { + // helper check for if an object is defined (but maybe falsey) + const isDefined = (x) => x !== null && x !== undefined; + let repairReplace = ""; + let coupons = ""; + let refSequenceNum = ""; + let accountType = ""; + let amount = 0; + let cjEvent = ""; + if (store.getters.applicationUser.affiliateCookies?.length > 0) { + const affiliateCookies = store.getters.applicationUser.affiliateCookies; + var cookieArray = []; + affiliateCookies.forEach((item) => { + let cookieObject = convertCookieStringToObject(item.CookieValue); + cookieArray.push(cookieObject); + }); + const sortedCookies = cookieArray.sort( + (a, b) => + new Date(b.timestamp.replace("/", "T")) - + new Date(a.timestamp.replace("/", "T")) + ); + const filteredCookie = sortedCookies?.find( + (x) => x.tagEvent?.length > 0 && x.batchEvent?.length > 0 + ); + if (isDefined(filteredCookie)) { + cjEvent = filteredCookie.tagEvent; + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + + if (hasSubmittedOrder) { + repairReplace = submittedOrder.damage.isRepair ? "Repair" : "Replace"; + const promos = submittedOrder.lineItems.promos ?? []; + if (promos.length === 0) { + coupons = ""; + } else { + const promoCodes = promos.map((promo) => promo.promoCode); + const promoString = promoCodes.reduce( + (prev, next) => `${prev}_${next}` + ); + coupons = promoString; + } + const serviceZipPackage = submittedOrder.lineItems?.supportingItems?.find( + (x) => x.partType == "SERVICE PACKAGE DISCOUNT" + ); + if (isDefined(serviceZipPackage)) { + if (coupons) { + coupons += ","; + } + coupons += serviceZipPackage.partNumber; + } + refSequenceNum = submittedOrder.referralSequenceNumber; + + if (isDefined(submittedOrder.payment.isInsurance)) { + accountType = submittedOrder.payment.isInsurance ? "insurance" : "cash"; + } else { + accountType = ""; + } + const lineItems = submittedOrder.lineItems ?? {}; + const combinedLineItems = [ + ...(lineItems.glassParts ?? []), + ...(lineItems.supportingItems ?? []), + ...(lineItems.vaps ?? []), + ...(lineItems.promos ?? []), + ]; + + const isPricingAvailable = + combinedLineItems.length > 0 && + combinedLineItems.every( + (lineItem) => + isDefined(lineItem.kitPrice) && + isDefined(lineItem.laborAmount) && + isDefined(lineItem.sellingPrice) + ); + if (accountType == "cash" && isPricingAvailable) { + amount = baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts( + combinedLineItems, + false + ); + } + } + } + } + pushToDataLayerIfDefined({ + commissionJunctionGtmData: { + cj_commission_junction_event: cjEvent, + cj_referral_sequence_number: refSequenceNum, + cj_amount: amount.toFixed(2), + cj_repair_replace: repairReplace, + cj_coupon: coupons, + }, + }); + }, + prependActionToMethod(object, method, actionToPrepend) { const baseMethodName = method.name.startsWith("bound ") ? method.name.substring(6) @@ -496,3 +754,24 @@ function getValueToLog(value, valueToLogType) { } return value; } + +function flattenArray(arr) { + let result = []; + arr.forEach((item) => { + result.push(item); + if (item.childParts) { + result = result.concat(item.childParts); + delete item.childParts; // Remove childParts after flattening + } + }); + return result; +} + +function convertCookieStringToObject(cookieValue) { + const cookieObject = cookieValue.split("&").reduce((acc, pair) => { + const [key, value] = pair.split("="); + acc[key] = value; + return acc; + }, {}); + return cookieObject; +} diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js index 87d85e952..a4b4672bb 100644 --- a/src/mixins/analytics-mixin.spec.js +++ b/src/mixins/analytics-mixin.spec.js @@ -853,4 +853,198 @@ describe("analyticsMixin.js", () => { expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); }); }); + describe("push product array and ecommerce cart to data layer", () => { + const mocks = setupMocksForJsFiles(); + it("should push product array to data layer correctly", () => { + // Mock the methods + mocks.baseMixin.methods.hasSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.getSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts = jest.fn(); + mocks.baseMixin.methods.hasSubmittedOrder.mockReturnValue(true); + mocks.baseMixin.methods.getSubmittedOrder.mockReturnValue({ + lineItems: { + glassParts: [{ partNumber: "GP1", partType: "glass" }], + supportingItems: [{ partNumber: "SI1", partType: "support" }], + vaps: [{ partNumber: "VAP1", partType: "vap" }], + promos: [{ partNumber: "DISCOUNT", partType: "promo", promoCode: "PROMO4" }], + }, + }); + mocks.baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts.mockReturnValue(100); + + // Call the function + analyticsMixin.methods.pushProductArrayToDataLayer(); + + // Assertions + expect(mocks.baseMixin.methods.hasSubmittedOrder).toHaveBeenCalled(); + expect(mocks.baseMixin.methods.getSubmittedOrder).toHaveBeenCalled(); + expect( + mocks.baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts + ).toHaveBeenCalled(); + }); + it("should push eCommerce cart to data layer correctly", () => { + // Mock the methods + mocks.baseMixin.methods.hasSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.getSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.hasSubmittedOrder.mockReturnValue(true); + mocks.baseMixin.methods.getSubmittedOrder.mockReturnValue({ + lineItems: { + supportingItems: [ + { partType: "RECALIBRATION", partNumber: "R1" }, + { partType: "DISPOSAL FEE", partNumber: "D1" }, + { partType: "MOBILE FEE", partNumber: "M1" }, + { partType: "REPAIR FEE", partNumber: "RF1" }, + ], + vaps: [{ partType: "FRONT WIPER", partNumber: "FW1" }], + }, + payment: { isInsurance: true }, + policy: { isItac: true, isNoComp: false }, + }); + + // Call the function + analyticsMixin.methods.pushECommerceCartToDataLayer(); + + // Assertions + expect(mocks.baseMixin.methods.hasSubmittedOrder).toHaveBeenCalled(); + expect(mocks.baseMixin.methods.getSubmittedOrder).toHaveBeenCalled(); + expect( + mocks.baseMixin.methods.getTotalPriceOfAllLineItemsAndChildParts + ).toHaveBeenCalled(); + }); + }); + + describe("pushCommissionJunctionGtmDataToDataLayer", () => { + const mocks = setupMocksForJsFiles(); + it("should handle empty affiliateCookies", () => { + store.getters = { + applicationUser: { + affiliateCookies: [], + }, + }; + + mocks.baseMixin.methods.hasSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.hasSubmittedOrder.mockReturnValue(false); + const result = analyticsMixin.methods.pushCommissionJunctionGtmDataToDataLayer(); + expect(result).toBeUndefined(); + }); + + test("Pushes populated data to data layer", () => { + mocks.baseMixin.methods.hasSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.hasSubmittedOrder.mockReturnValue(true); + mocks.baseMixin.methods.getSubmittedOrder = jest.fn(); + mocks.baseMixin.methods.getSubmittedOrder.mockReturnValue({ + vehicle: { + year: "2020", + make: "acura", + model: "mdx", + style: "4-door sedan", + carId: "dummyCarId", + category: "dummyCategory", + vin: "dummyVin", + }, + serviceLocation: { + address: "add1", + address2: "add2", + city: "city", + state: "state", + zipCode: "11111", + zipCodeCtu: "11110", + appointmentType: "IN_SHOP", + isVehicleProtected: true, + provider: { + providerNumber: 2, + address: { + streetAddress: "add3", + city: "city2", + state: "state2", + zipCode: "22222", + zipCodeCtu: "22220", + }, + }, + techNotes: "", + }, + customer: { + firstName: "first", + lastName: "last", + emailAddress: "builddigitaltest@safelite.com", + phoneNumber: "555-555-5555", + isSmsOptIn: false, + }, + damage: { + isRepair: false, + numberOfChips: null, + glassToReplace: [{ glassName: "single", glassLocation: "windshield" }], + }, + lineItems: { + glassParts: [parts.windshield], + supportingItems: [], + vaps: [parts.frontWipers], + promos: [ + { + promoCode: "promoTEST", + kitPrice: 0, + sellingPrice: 0, + laborAmount: 0, + salesTax: 0, + }, + { + promoCode: "promoTEST2", + kitPrice: 0, + sellingPrice: 0, + laborAmount: 0, + salesTax: 0, + }, + ], + }, + payment: { + isInsurance: false, + insuranceCoverage: { + isVerified: null, + coverageStatus: null, + coverageVerificationType: null, + }, + isPia: true, + piaType: "Afterpay", + inactivePromos: [], + }, + schedule: { + date: "date", + startTime: "start", + endTime: "end", + jobMinMinutes: "30", + jobMaxMinutes: "45", + }, + workOrderNumber: "01820-111111", + workOrderId: "222222222222", + referralSequenceNumber: "1111111", + }); + store.getters = { + applicationUser: { + affiliateCookies: [ + { + CookieName: "Cookie1", + CookieValue: "timestamp=2023-09-18/12:00:00&tagEvent=event1", + }, + { + CookieName: "Cookie2", + CookieValue: "timestamp=2023-09-19/12:00:00&tagEvent=event2", + }, + ], + }, + }; + // Arrange + window.dataLayer = []; + + // Act + analyticsMixin.methods.pushCommissionJunctionGtmDataToDataLayer(); + + const result = window.dataLayer[0]; + + // Assert + console.log(result); + const allFieldsPopulated = Object.keys(result).every( + (key) => result[key] === false || !!result[key] + ); + expect(allFieldsPopulated).toBe(true); + }); + }); }); diff --git a/src/router/index.js b/src/router/index.js index 4549f9e7c..4c57921b7 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -145,6 +145,12 @@ const routes = [ await runExperiments(to.query.fmgPage); + //Affiliate Cookies + const affiliateCookies = getAffiliateCookies(); + if (affiliateCookies != null && affiliateCookies.length > 0) { + store.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies); + } + setupAdvertiserTracking(); // Process funnel cookie. updateOrCreateFunnelCookie(); @@ -307,6 +313,17 @@ router.afterEach(async (to, from) => { // Push current order status to Data Layer analyticsMixin.methods.pushOrderToDataLayer(); + + if (to.query.fmgPage == fmgPageValues.CONFIRMATION) { + //Push Product Array to Data Layer + analyticsMixin.methods.pushProductArrayToDataLayer(); + + //Push Ecommerce Cart to Data Layer + analyticsMixin.methods.pushECommerceCartToDataLayer(); + + //Push Commission Junction Gtm Data To Data Layer + analyticsMixin.methods.pushCommissionJunctionGtmDataToDataLayer(); + } }); router.navigateWithoutSaving = ( @@ -615,6 +632,7 @@ function updateExternalParameterState() { const externalParameterVinSelection = getQuerystringParameter(queryStrings.VIN_SELECTION); const externalParameterServicePackage = getQuerystringParameter(queryStrings.SERVICE_PACKAGE); const externalParameterNumberOfChips = getQuerystringParameter(queryStrings.NUMBER_OF_CHIPS); + const externalParameterSource = getQuerystringParameter(queryStrings.EXPERIMENTS); if ( externalParameterYear && externalParameterMake && @@ -664,6 +682,9 @@ function updateExternalParameterState() { externalParameterVinSelection ); } + if (externalParameterSource) { + store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_SOURCE, externalParameterSource); + } } export default router; diff --git a/src/store/index.js b/src/store/index.js index f6751bcef..b5bd94f5e 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -110,6 +110,7 @@ const getDefaultState = () => { insuranceCoverage: { isVerified: null, coverageStatus: null, + coverageSubStatus: null, coverageType: null, coverageVerificationType: null, }, @@ -473,6 +474,10 @@ export const mutations = { externalParameterState.quote.servicePackage = servicePackage; saveExternalParameterState(externalParameterState); }, + updateExternalParameterSource(state, source) { + externalParameterState.source = source; + saveExternalParameterState(externalParameterState); + }, //RESET ExternalParameter MUTATIONS resetExternalParameterVehicleState(state) { externalParameterState.vehicle.year = null; @@ -503,6 +508,10 @@ export const mutations = { externalParameterState.quote.servicePackage = null; saveExternalParameterState(externalParameterState); }, + resetExternalParameterSourceState(state) { + externalParameterState.source = null; + saveExternalParameterState(externalParameterState); + }, resetIsExternalParameter(state) { externalParameterState.isExternalParameter = null; saveExternalParameterState(externalParameterState); @@ -683,6 +692,8 @@ export const mutations = { state.order.payment.insuranceCoverage.coverageStatus = coverageStatusValue( sessionInformation?.order.insuranceCoverage.coverageStatus ); + state.order.payment.insuranceCoverage.coverageSubStatus = + sessionInformation?.order.insuranceCoverage.coverageSubStatus; state.order.payment.insuranceCoverage.coverageType = coverageTypeValue( sessionInformation?.order.insuranceCoverage.coverageType ); @@ -905,6 +916,7 @@ export const getters = { externalParameterQuote: (state) => externalParameterState?.quote, externalParameterServiceZip: (state) => externalParameterState?.serviceZip, externalParameterEstimate: (state) => externalParameterState?.estimate, + externalParameterSource: (state) => externalParameterState?.source, }; function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) { @@ -1810,8 +1822,8 @@ export const actions = { const lineItems = context.state.order.lineItems; const submitAfterSave = payload?.submitAfterSave === "true"; - const createDeleteStatusWorkOrderForPia = - payload?.createDeleteStatusWorkOrderForPia === "true"; + const createUnscheduledStatusWorkOrderForPIA = + payload?.createUnscheduledStatusWorkOrderForPIA === "true"; // create a new array to avoid mutating state const newGlassToReplace = convertGlassPieceNamingForApi(damage.glassToReplace); @@ -1882,6 +1894,7 @@ export const actions = { }, isInsurance: order.payment.isInsurance, parentAccountNumber: order.payment.parentAccountNumber, + billToAccountNumber: order.payment.billToAccountNumber, inactivePromos: order.payment.inactivePromos, isCreditCard: order.payment.piaType == paymentMethods.CREDIT_CARD ? true : false, @@ -1945,7 +1958,7 @@ export const actions = { referralNumber: order.referralNumber?.toString(), referralSequenceNumber: order.referralSequenceNumber, eon: order.eon, - createDeleteStatusWorkOrderForPia: createDeleteStatusWorkOrderForPia, + createUnscheduledStatusWorkOrderForPIA: createUnscheduledStatusWorkOrderForPIA, lockToken: order.lockToken, isRecalAckOptIn: order.isRecalAckOptIn, }, @@ -2671,6 +2684,10 @@ export const actions = { return revalidateResponse?.data; }, + /////////////////////////// + // Account Service Calls // + /////////////////////////// + // List all insurance companies async getInsuranceCompanyList(context, { pageNameToLog }) { const insuranceCompanyList = await globalMethods.callHttpClient({ @@ -2693,6 +2710,40 @@ export const actions = { return 0; }); }, + // Get BillToAccountNumber + async getBillToAccountNumber( + context, + { + payload: { + parentAccountNumber = context.getters.order.payment.parentAccountNumber, + providerNumber = context.getters.order.serviceLocation.zipCodeCtu, + isItac = context.getters.order.policy.isItac, + }, + pageNameToLog, + } + ) { + // Cash orders default to 87291 - update if/when more detailed + // billToAccountNumbers are needed for cash + if (parentAccountNumber == applicationConfig.CASH_PARENT_ACCOUNT_NUMBER) { + return applicationConfig.CASH_DEFAULT_BILL_TO_ACCOUNT_NUMBER; + } + const queryString = + `ParentAccountNumber=${parentAccountNumber}` + + `&ProviderNumber=${providerNumber}` + + `&IsItac=${isItac}`; + + const billToAccountNumberResponse = await globalMethods.callHttpClient({ + method: endpoints.GetBillToAccountNumber.method, + endpoint: `${endpoints.GetBillToAccountNumber.url}?${queryString}`, + logApiCall: true, + pageNameToLog: pageNameToLog, + }); + + return billToAccountNumberResponse.data.toString(); + }, + ////////////////////////////////// + // END OF Account Service Calls // + ////////////////////////////////// // Misc order actions saveSchedule(context, scheduleInfo) { @@ -2835,6 +2886,7 @@ export const actions = { context.commit(storeMutations.RESET_EXTERNAL_PARAMETER_ESTIMATE_STATE); context.commit(storeMutations.RESET_EXTERNAL_PARAMETER_SERVICEZIP_STATE); context.commit(storeMutations.RESET_EXTERNAL_PARAMETER_QUOTE_STATE); + context.commit(storeMutations.RESET_EXTERNAL_PARAMETER_SOURCE_STATE); context.commit(storeMutations.RESET_IS_EXTERNAL_PARAMETER); } }, @@ -3378,6 +3430,7 @@ function createExternalParameterDefaultState() { isInsurance: null, servicePackage: null, }, + source: null, }; // set to session storage saveExternalParameterState(externalParameterDefaultState);