diff --git a/src/constants/experiments.js b/src/constants/experiments.js index b702b11bb..18ca6387d 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -8,6 +8,7 @@ const experimentSettings = { DISPLAY_AVAILABILITY_INDICATORS: "DisplayAvailabilityIndicators", PIA_EXPERIENCE: "PIA Experience", SUBMIT_ORDER_ENABLE_PIA: "SubmitOrder_Enable_PIA", + IS_EMAIL_OPTIONAL: "isEmailOptional", }; const experimentTriggers = { diff --git a/src/digital-components/textbox-question/textbox-question.vue b/src/digital-components/textbox-question/textbox-question.vue index 11f232135..cc13231e1 100644 --- a/src/digital-components/textbox-question/textbox-question.vue +++ b/src/digital-components/textbox-question/textbox-question.vue @@ -121,6 +121,11 @@ export default { hideInput: Boolean, centerErrorMessage: Boolean, keyDownHandler: Function, + addOptionalText: { + type: Boolean, + default: false, + isRequired: false, + }, }, setup(props) { const uuid = uuidv4(); @@ -193,7 +198,9 @@ export default { }, computed: { questionText() { - return this.getCmsContent(this.cmsWidgetName, "QuestionText"); + return this.addOptionalText + ? this.getCmsContent(this.cmsWidgetName, "QuestionText") + " (optional)" + : this.getCmsContent(this.cmsWidgetName, "QuestionText"); }, value: { get: function () { diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index 2d268e81a..76847f3e9 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -13,78 +13,107 @@ {{ getFormattedAmount("", amountDue) }} -
-
- - - {{ getFormattedAmount("", packagePrice) }} - -
+
+
+
+ + + {{ getFormattedAmount("", packagePrice) }} + + + {{ getFormattedAmount("", packagePriceWithoutDiscount) }} + +
- -
- {{ cartItem.name }} - - - -
+ +
+ <{{ cartItem.name + }}> + + + +
- -
- {{ cartItem.name }} - - - - {{ recycleFeeCartItem.name }} - - {{ getFormattedAmount(cartItem.category, cartItem.subTotal) }} -
+ +
+ {{ cartItem.name }} + + + + {{ recycleFeeCartItem.name }} + + {{ getFormattedAmount(cartItem.category, cartItem.subTotal) }} +
- -
- {{ subtotalText }}{{ getFormattedAmount("", subTotal) }} -
-
- {{ salesTaxText }}{{ getFormattedAmount("", salesTax) }} -
-
- {{ amountPaidText }}{{ getFormattedAmount("", amountPaid) }} -
-
- {{ amountDueText }}{{ getFormattedAmount("", amountDue) }} + +
+ {{ subtotalText }}{{ getFormattedAmount("", subTotal) }} +
+
+ {{ salesTaxText }}{{ getFormattedAmount("", salesTax) }} +
+
+ {{ amountPaidText }}{{ getFormattedAmount("", amountPaid) }} +
+
+ {{ amountDueText }}{{ getFormattedAmount("", amountDue) }} +
+
+
+ Promo code <{{ promoCode }}> applied +
@@ -96,6 +125,7 @@ import textLink from "@/ux-components/text-link/text-link"; import textBlock from "@/digital-components/text-block/text-block"; import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal"; +import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question"; // Mixins import baseMixin from "@/mixins/base-mixin.js"; @@ -149,6 +179,17 @@ export default { return subTotal; }, + getPromoDiscounts() { + const promoItems = this.promoCartItems; + + let subTotal = 0; + + promoItems?.forEach((item) => { + subTotal += item.subTotal; + }); + + return subTotal; + }, getVapsCartItemsForSelectedPackage(packageName) { const packageContentTypes = getPackageContents( this.glassToReplace, @@ -167,6 +208,12 @@ export default { }); }); + if (this.promoCartItems) { + this.promoCartItems.forEach((promoCartItem) => { + vapsCartItemsForSelectedPackage.push(promoCartItem); + }); + } + return vapsCartItemsForSelectedPackage; }, getCmsContentForVapsType(vapsType) { @@ -192,6 +239,16 @@ export default { this.$emit("update:modelValue", this.lineItems); }, + getPromoCodeList() { + if (this.$refs["promoModalQuestion"]) { + return this.$refs["promoModalQuestion"].getPromoCodeList(); + } + return []; + }, + handleAddedPromo(lineItems) { + // NOTE: these lineItems are passed from promo-modal-question + this.$emit("update:modelValue", lineItems); + }, }, computed: { screenReaderTotalAmountDueText() { @@ -313,6 +370,12 @@ export default { return packagePrice; }, + packagePriceWithoutDiscount() { + let fullPrice = this.packagePrice; + let discount = this.getPromoDiscounts(); + fullPrice += Math.abs(discount); + return fullPrice; + }, isRepair() { if (!this.damage) { return; @@ -540,7 +603,6 @@ export default { return cartItem; }, - suppliesRepairCartItemName() { return this.getCmsContent("SuppliesRepairTextWidget", "Text"); }, @@ -578,7 +640,6 @@ export default { return cartItem; }, - mobileFeeCartItemName() { return this.getCmsContent("MobileServiceTextWidget", "Text"); }, @@ -734,7 +795,6 @@ export default { return promoCartItems; }, - removeLinkText() { return this.getCmsContent("RemoveCartItemTextWidget", "Text"); }, @@ -795,6 +855,7 @@ export default { textBlock, textLink, contentGroupModal, + promoModalQuestion, }, }; @@ -824,12 +885,14 @@ export default { .amount-due { color: $green; } - .price-table { + .cart-panel { max-height: 0; transition: all 350ms ease-in; overflow: hidden; visibility: hidden; color: $gray-600; + } + .price-table { div { display: flex; justify-content: space-between; @@ -846,6 +909,10 @@ export default { background-color: $gray-100; align-items: center; } + .struck-out-price { + text-decoration: line-through; + padding-left: 0.5rem; + } .packaged-cart-item { background-color: $gray-100; padding-left: 2.5rem; @@ -934,17 +1001,26 @@ export default { &.expanded:after { transform: rotate(180deg); } - &.expanded + .price-table { + &.expanded + .cart-panel { max-height: 650px; transition: all 150ms ease-in; overflow: hidden; - padding-top: 1rem; + padding: 1rem 0 0.5rem; visibility: visible; } a { text-decoration: none; } } + .applied-promo { + span { + color: $green-700; + font-weight: 500; + padding: 2px 8px; + background-color: $green-100; + border-radius: 12px; + } + } } .payment-method-question { .question-text { diff --git a/src/fmg-components/promo-modal-question/promo-modal-question.spec.js b/src/fmg-components/promo-modal-question/promo-modal-question.spec.js new file mode 100644 index 000000000..435a53277 --- /dev/null +++ b/src/fmg-components/promo-modal-question/promo-modal-question.spec.js @@ -0,0 +1,400 @@ +import { mount, shallowMount } from "@vue/test-utils"; +import promoModalQuestion from "./promo-modal-question"; +import { storeActions } from "@/constants/store-actions"; +import baseMixin from "@/mixins/base-mixin.js"; +import { + promoErrorCodes, + getPromoCodesFromPromoObjectsWithoutDuplicates, + getPromoCodeWithoutBundleIdentifier, +} from "@/helpers/promotions-helper"; +import { cartItemCategories } from "@/constants/cart-item-categories"; + +let mockReturnsForStoreActions = {}; + +jest.mock("@/mixins/base-mixin", () => ({ + ...jest.requireActual("@/mixins/base-mixin"), + methods: { + dispatchStoreActionWithLogging: jest.fn( + (action, { promoCode, addableVaps }, pageNameToLog, someBool) => { + return mockReturnsForStoreActions[action]; + } + ), + }, +})); + +afterEach(() => { + // reset store action returns + mockReturnsForStoreActions = {}; +}); + +jest.mock("@/digital-components/textbox-question/textbox-question", () => ({ + getCmsContent: jest.fn((widgetName, cmsFieldName) => { + return widgetName[cmsFieldName]; + }), +})); + +jest.mock("@/digital-components/modal/modal", () => ({ + methods: { + closeModal: jest.fn(), + resetButtonStyle: jest.fn(), + resetForm: jest.fn(), + }, +})); + +const modalWidgetName = "modalWidgetName"; + +const mockModalCmsContent = { + FooterText: "Sample modal footer text here.", +}; + +const mockMixin = { + methods: { + getCmsContent: jest.fn((widgetName, cmsFieldName) => { + if (widgetName === modalWidgetName) { + return mockModalCmsContent[cmsFieldName]; + } + + return null; + }), + }, +}; + +describe("promo-modal-question.vue", () => { + it("Should reset all alerts on onModalClosed", async () => { + // Arrange + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + + // Act + wrapper.vm.onModalClosed(); + + // Assert + expect(wrapper.vm.displayInvalidPromoAlert).toBe(false); + expect(wrapper.vm.displayStackingPromoAlert).toBe(false); + expect(wrapper.vm.displayInShopPromoAlert).toBe(false); + expect(wrapper.vm.displaySimilarPromoAlert).toBe(false); + }); + it("Should emit update:modelValue on Modal closed", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + + // Act + + await wrapper.vm.onModalClosed(); + + // Assert + expect(wrapper.emitted("update:modelValue")).toEqual([[lineItems]]); + }); + it("focuses on the input element when focusOnPromoInput is called", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const focusMock = jest.fn(); + const inputMock = { focus: focusMock }; + + // Mock document.getElementById to return the inputMock + jest.spyOn(document, "getElementById").mockReturnValue(inputMock); + + //Act + + wrapper.vm.focusOnPromoInput(); + await wrapper.vm.$nextTick(); + + //Assert + expect(focusMock).toHaveBeenCalled(); + }); + it("returns a validate response on applying promo", async () => { + // Arrange + const newPromo = "testPromo"; + const pageNameToLog = "testPage"; + const validateResponse = { orderPromos: [] }; + const addableVaps = []; + mockReturnsForStoreActions[storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA] = + validateResponse; + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + // Act + + wrapper.vm.getPromoCodeData(); + + const promoValidationResponse = await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA, + { + newPromo, + lineItems, + addableVaps, + }, + pageNameToLog, + false + ); + + // Assert + expect(promoValidationResponse).toEqual(validateResponse); + }); + test("If promocode is valid return taxed lineItems", async () => { + //Arrange + const taxedlineItems = {}; + + mockReturnsForStoreActions[storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA] = + taxedlineItems; + + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + let promoCode = "1wiper0"; + const pricedLineItemsToTax = []; + + pricedLineItemsToTax.push(promoCode); + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + + attachTo: document.body, + }); + wrapper.vm.addPromoCode(); + + const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + billToAccountNumber: "87291", + providerNumber: 2, + appointmentType: "IN_SHOP", + serviceLocationCity: "city", + serviceLocationState: "state", + serviceLocationZipCode: "12345", + pricedLineItems: pricedLineItemsToTax, + }, + + "payment-method", + + false + ); + + // Assert + + expect(taxedLineItems).toEqual(taxedlineItems); + }); + test("Get promoCode list will return the applied promos", async () => { + // Arrange + const promos = [ + { promoCode: "duplicatePromo" }, + { promoCode: "duplicatePromo" }, + { promoCode: "bundlePromo/400" }, + { promoCode: "bundlePromo/401" }, + ]; + const appliedPromos = ["duplicatePromo", "bundlePromo"]; + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + + // Act + wrapper.vm.getPromoCodeList(); + const promoCodeToDisplay = getPromoCodesFromPromoObjectsWithoutDuplicates(promos); + + // Assert + expect(promoCodeToDisplay).toEqual(appliedPromos); + }); + test("On clicking remove the promo gets removed from the lineItems", async () => { + //Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [{ promoCode: "duplicatePromo" }, { promoCode: "duplicatePromo" }], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const promo = ["bundlePromo"]; + //Act + wrapper.vm.removeItem(promo); + const existingPromo = (lineItems[cartItemCategories.PROMOS] = lineItems[ + cartItemCategories.PROMOS + ].filter( + (lineItemsToKeep) => + getPromoCodeWithoutBundleIdentifier(lineItemsToKeep.promoCode) != promo + )); + + //Assert + expect(existingPromo).toEqual(lineItems.promos); + }); + test("Getting stacking alert on stack promo error code", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const additionalInfo = ["testAdditionalInfo"]; + + const stackingErrorCode = promoErrorCodes.PROMO_STACKING_NOT_ALLOWED; + + // Act + wrapper.vm.getErrorMessage(stackingErrorCode, additionalInfo); + + // Assert + expect(wrapper.vm.displayStackingPromoAlert).toBe(true); + }); + test("Getting invalid promo alert on invalid promoCode", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const additionalInfo = ["testAdditionalInfo"]; + + const invalidErrorCode = promoErrorCodes.INVALID_PROMO_ON_ORDER; + + // Act + wrapper.vm.getErrorMessage(invalidErrorCode, additionalInfo); + + // Assert + expect(wrapper.vm.displayInvalidPromoAlert).toBe(true); + }); + test("Getting invalid promo alert on invalid promoCode", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const additionalInfo = ["APPOINTMENT_TYPE"]; + + const invalidErrorCode = promoErrorCodes.INVALID_PROMO_ON_ORDER; + + // Act + wrapper.vm.getErrorMessage(invalidErrorCode, additionalInfo); + + // Assert + expect(wrapper.vm.displayInShopPromoAlert).toBe(true); + }); + test("Get conflicting promoCodes on applying more than one promo", async () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [], + promos: [], + }; + + const wrapper = mount(promoModalQuestion, { + mixins: [mockMixin], + props: { + modelValue: lineItems, + modalWidgetName: modalWidgetName, + }, + attachTo: document.body, + }); + const additionalInfo = ["1wiper0", "Glass30"]; + const conflictingCodes = ["1wiper0", "Glass30"]; + + //Act + wrapper.vm.getConflictingPromoCode(additionalInfo); + + //Assert + expect(conflictingCodes).toEqual(additionalInfo); + }); +}); diff --git a/src/layouts/payment-method/promo-modal-question/promo-modal-question.vue b/src/fmg-components/promo-modal-question/promo-modal-question.vue similarity index 81% rename from src/layouts/payment-method/promo-modal-question/promo-modal-question.vue rename to src/fmg-components/promo-modal-question/promo-modal-question.vue index adb9bee3d..59c1523b2 100644 --- a/src/layouts/payment-method/promo-modal-question/promo-modal-question.vue +++ b/src/fmg-components/promo-modal-question/promo-modal-question.vue @@ -64,7 +64,7 @@ diff --git a/src/layouts/quote/service-package-question/service-package-question.spec.js b/src/layouts/quote/service-package-question/service-package-question.spec.js index c156ef34c..8163c6324 100644 --- a/src/layouts/quote/service-package-question/service-package-question.spec.js +++ b/src/layouts/quote/service-package-question/service-package-question.spec.js @@ -256,6 +256,57 @@ describe("service-package-question.vue", () => { // Assert expect(wrapper.vm.selectedPackageName).toBe("TierThree"); }); + it("should select default package if promos are added", async () => { + //Arrange + mockProps.activePromos != null; + const wrapper = setupMocks({ + mountOptionsMockData: { + store: { + getters: { + order: { + damage: { + isRepair: false, + glassToReplace: [{ glassLocation: "Windshield" }], + }, + }, + lineItems: { + vaps: [], + }, + hasAnyNonWindshieldGlassParts: false, + payment: { + isInsurance: false, + }, + }, + }, + }, + }); + + //Act + wrapper.setProps({ + activePromos: [ + { + discountedLineItemIds: [ + { + 0: "428ec73c-38e4-4e14-8703-a987b0391898", + 1: "79db94d7-163c-4408-a1ec-f83e58c11992", + }, + ], + partType: "PROMO_DISCOUNT", + promoCode: "1WIPER0", + partNumber: "WIPER DISCOUNT", + laborAmount: 0, + sellingPrice: -10, + kitPrice: 0, + salesTax: null, + }, + ], + }); + const selectDefaultPackageMock = jest.spyOn(wrapper.vm, "selectDefaultPackage"); + await nextTick(); + + //Assert + expect(selectDefaultPackageMock).toHaveBeenCalled(); + }); }); describe("service-package-question.vue, matching business rules for package display", () => { // mock scenarios in figma: 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 28727f155..6b84f3557 100644 --- a/src/layouts/quote/service-package-question/service-package-question.vue +++ b/src/layouts/quote/service-package-question/service-package-question.vue @@ -63,6 +63,9 @@ export default { this.selectDefaultPackage(); } }, + activePromos() { + this.selectDefaultPackage(); + }, selectedPackageName(newValue) { const VapsProductsInSelectedPackage = this.getVapsLineItemsForSelectedPackage(newValue); this.$emit("vapsItemsSelected", VapsProductsInSelectedPackage); diff --git a/src/layouts/vin-lookup/vin-lookup.spec.js b/src/layouts/vin-lookup/vin-lookup.spec.js index f25bc68db..1d7d3f493 100644 --- a/src/layouts/vin-lookup/vin-lookup.spec.js +++ b/src/layouts/vin-lookup/vin-lookup.spec.js @@ -3,6 +3,7 @@ import vinLookup from "./vin-lookup.vue"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { navigationScenarios } from "@/router/router-constants/navigation-scenarios.js"; import { settleAllPromises } from "@/helpers/layout-helper.js"; +import { experimentSettings } from "@/constants/experiments"; import store from "@/store"; @@ -378,5 +379,11 @@ function mockOutStubFunctions(wrapper) { const mockMixin = { methods: { getCmsContent: jest.fn(() => "placeholder CMS content"), + getSettingValue: jest.fn((settingName) => { + if (settingName === experimentSettings.IS_EMAIL_OPTIONAL) { + return "true"; + } + return "false"; + }), }, }; diff --git a/src/layouts/vin-lookup/vin-lookup.vue b/src/layouts/vin-lookup/vin-lookup.vue index c809bae70..ce8365a53 100644 --- a/src/layouts/vin-lookup/vin-lookup.vue +++ b/src/layouts/vin-lookup/vin-lookup.vue @@ -53,8 +53,9 @@ cmsWidgetName="EmailAddressQuestionWidget" v-model="emailAddress" customInputId="emailAddress" - isRequired - validationRules="email-address-required|email-address-format" /> + :isRequired="!IsEmailOptional" + :validationRules="EmailValidationRules" + :addOptionalText="IsEmailOptional" /> diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index d394190ff..cfa68c82c 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -120,77 +120,182 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, - pushSubmittedOrderToDataLayer() { - // check if submitted order exists; exit if not. - const hasSubmittedOrder = store.getters.hasSubmittedOrder; + pushOrderToDataLayer() { + // helper check for if an object is defined (but maybe falsey) + const isDefined = (x) => x !== null && x !== undefined; - if (!hasSubmittedOrder) { - return; + // Get correct order object + const hasSubmittedOrder = store.getters.hasSubmittedOrder; + const order = hasSubmittedOrder ? store.getters.submittedOrder : store.getters.order; + + // Begin assembling payload for data layer + const payload = {}; + + // Service Zip + if ( + order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE && + isDefined(order.serviceLocation.zipCode) + ) { + payload.serviceZipCode = order.serviceLocation.zipCode; + } else if ( + isDefined(order.serviceLocation.appointmentType) && + order.serviceLocation.appointmentType !== AppointmentTypeStrings.MOBILE && + isDefined(order.serviceLocation.provider.address.zipCode) + ) { + payload.serviceZipCode = order.serviceLocation.provider.address.zipCode; + } else { + payload.serviceZipCode = ""; } - const order = store.getters.submittedOrder; + // Damage Type + if (isDefined(order.damage.isRepair)) { + payload.damageType = order.damage.isRepair ? "repair" : "replace"; + } else { + payload.damageType = ""; + } - // assemble data for payload - // // reduce promocode array + // Account Type + if (isDefined(order.payment.isInsurance)) { + payload.accountType = order.payment.isInsurance ? "insurance" : "cash"; + } else { + payload.accountType = ""; + } + + // Promo Codes const promos = order.lineItems.promos ?? []; - const promoCodes = promos.map((promo) => promo.promoCode); - const promoString = - promoCodes.length === 0 ? "" : promoCodes.reduce((prev, next) => `${prev},${next}`); + if (promos.length === 0) { + payload.promoCodes = ""; + } else { + const promoCodes = promos.map((promo) => promo.promoCode); + const promoString = promoCodes.reduce((prev, next) => `${prev},${next}`); + payload.promoCodes = promoString; + } - // // reduce glass array - const glassToReplace = order.damage.glassToReplace ?? []; - const glassToReplaceNames = glassToReplace.map( - (glassPiece) => `${glassPiece.glassLocation}/${glassPiece.glassName}` - ); - const glassString = - glassToReplaceNames.length === 0 - ? "" - : glassToReplaceNames.reduce((prev, next) => `${prev},${next}`); + // Vehicle info + if (isDefined(order.vehicle.year)) { + // Ensure cast to string. + payload.vehicleYear = `${order.vehicle.year}`; + } else { + payload.vehicleYear = ""; + } - // // calculate subtotal - const lineItems = order.lineItems; + if (isDefined(order.vehicle.make)) { + payload.vehicleMake = order.vehicle.make; + } else { + payload.vehicleMake = ""; + } + + if (isDefined(order.vehicle.model)) { + payload.vehicleModel = order.vehicle.model; + } else { + payload.vehicleModel = ""; + } + + if (isDefined(order.vehicle.style)) { + payload.vehicleStyle = order.vehicle.style; + } else { + payload.vehicleStyle = ""; + } + + // Glass pieces + const glass = order.damage.glassToReplace ?? []; + if (glass.length === 0) { + payload.glassToReplace = ""; + } else { + const glassNames = glass.map((g) => `${g.glassLocation}/${g.glassName}`); + const glassString = glassNames.reduce((prev, next) => `${prev},${next}`); + + payload.glassToReplace = glassString; + } + + // Work Order Id + if (order.workOrderId) { + const parsedId = parseInt(order.workOrderId); + if (!isNaN(parsedId)) { + payload.workOrderId = parsedId; + } else { + payload.workOrderId = ""; + } + } else { + payload.workOrderId = ""; + } + + // Provider Ctu + if (isDefined(order.serviceLocation.zipCodeCtu)) { + const parsedCtu = parseInt(order.serviceLocation.zipCodeCtu); + if (!isNaN(parsedCtu)) { + payload.providerCtu = parseInt(order.serviceLocation.zipCodeCtu); + } else { + payload.providerCtu = ""; + } + } else { + payload.providerCtu = ""; + } + + // Work Order Number + if (order.workOrderNumber) { + payload.orderNumber = order.workOrderNumber; + } else { + payload.orderNumber = ""; + } + + // Pricing + // Only fire for completed orders? + + const lineItems = order.lineItems ?? {}; const combinedLineItems = [ ...(lineItems.glassParts ?? []), ...(lineItems.supportingItems ?? []), ...(lineItems.vaps ?? []), ...(lineItems.promos ?? []), ]; - const subtotal = baseMixin.methods - .getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, false) - .toFixed(2); - // // get correct zip code - const providerZip = order.serviceLocation.provider.address.zipCode; - const serviceZip = - order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE - ? order.serviceLocation.zipCode - : providerZip; + const isPricingAvailable = + combinedLineItems.length > 0 && + combinedLineItems.every( + (lineItem) => + isDefined(lineItem.kitPrice) && + isDefined(lineItem.laborAmount) && + isDefined(lineItem.sellingPrice) + ); + const isTaxAvailable = + isPricingAvailable && + combinedLineItems.every((lineItem) => isDefined(lineItem.salesTax)); - // // calculate total - const total = baseMixin.methods - .getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, true) - .toFixed(2); + if (isPricingAvailable) { + const subtotal = baseMixin.methods + .getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, false) + .toFixed(2); - const payload = { - serviceZipCode: serviceZip, - damageType: order.damage.isRepair ? "repair" : "replace", - accountType: order.payment.isInsurance ? "insurance" : "cash", - promoCodes: promoString, - vehicleYear: `${order.vehicle.year}`, - vehicleMake: order.vehicle.make, - vehicleModel: order.vehicle.model, - vehicleStyle: order.vehicle.style, - glassToReplace: glassString, - workOrderId: parseInt(order.workOrderId), - providerCtu: parseInt(order.serviceLocation.zipCodeCtu), - orderNumber: order.workOrderNumber, - priceTotal: parseFloat(total), - priceSubTotal: parseFloat(subtotal), - isRecalibrationOnOrder: store.getters.isRecalibrationOnSubmittedOrder, - appointmentType: order.serviceLocation.appointmentType, - }; + payload.priceSubTotal = parseFloat(subtotal); + } else { + payload.priceSubTotal = ""; + } + + if (isTaxAvailable) { + const total = baseMixin.methods + .getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, true) + .toFixed(2); + + payload.priceTotal = parseFloat(total); + } else { + payload.priceTotal = ""; + } + + // Recalibration + if (hasSubmittedOrder) { + payload.isRecalibrationOnOrder = store.getters.isRecalibrationOnSubmittedOrder; + } else { + payload.isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder; + } + + // Appointment Type + if (isDefined(order.serviceLocation.appointmentType)) { + payload.appointmentType = order.serviceLocation.appointmentType; + } else { + payload.appointmentType = ""; + } - // push to data layer. pushToDataLayerIfDefined(payload); }, diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js index ff6c30e7e..42aba5f31 100644 --- a/src/mixins/analytics-mixin.spec.js +++ b/src/mixins/analytics-mixin.spec.js @@ -38,27 +38,38 @@ const parts = { requiresRecalibration: true, salesTax: 63.86, sellingPrice: 791.46, + kitPrice: 0, + laborAmount: 0, }, frontWipers: { name: "front wipers", partNumber: "SBB16", description: "SAFELITE BEAM BLADE 16", partType: "FRONT WIPER", - price: 32.64, + sellingPrice: 32.64, + kitPrice: 0, + laborAmount: 0, + salesTax: 1, }, rearWipers: { name: "rear wipers", partNumber: "SBBR12A", description: "SAFELITE REAR BLADE 12A", partType: "REAR WIPER", - price: 24.48, + sellingPrice: 24.48, + kitPrice: 0, + laborAmount: 0, + salesTax: 2, }, rainDefense: { name: "rain defense", partNumber: "RAIN DEFENSE", description: null, partType: "RAIN DEFENSE", - price: 35.5, + sellingPrice: 35.5, + kitPrice: 0, + laborAmount: 0, + salesTax: 0, }, }; @@ -324,10 +335,10 @@ describe("analyticsMixin.js", () => { ]); }); - describe("pushSubmittedOrderToDataLayer", () => { + describe("pushOrderToDataLayer", () => { beforeEach(() => { - store.getters.hasSubmittedOrder = true; - store.getters.submittedOrder = { + store.getters.hasSubmittedOrder = false; + store.getters.order = { vehicle: { year: "2020", make: "acura", @@ -342,8 +353,8 @@ describe("analyticsMixin.js", () => { address2: "add2", city: "city", state: "state", - zipCode: "zip", - zipCodeCtu: "zipCtu", + zipCode: "11111", + zipCodeCtu: "11110", appointmentType: "IN_SHOP", isVehicleProtected: true, provider: { @@ -352,8 +363,8 @@ describe("analyticsMixin.js", () => { streetAddress: "add3", city: "city2", state: "state2", - zipCode: "zip2", - zipCodeCtu: "zipCtu2", + zipCode: "22222", + zipCodeCtu: "22220", }, }, techNotes: "", @@ -368,13 +379,28 @@ describe("analyticsMixin.js", () => { damage: { isRepair: false, numberOfChips: null, - glassToReplace: [{ glassName: "single", location: "windshield" }], + glassToReplace: [{ glassName: "single", glassLocation: "windshield" }], }, lineItems: { glassParts: [parts.windshield], supportingItems: [], vaps: [parts.frontWipers], - promos: [{ promoCode: "promoTEST" }, { promoCode: "promoTEST2" }], + promos: [ + { + promoCode: "promoTEST", + kitPrice: 0, + sellingPrice: 0, + laborAmount: 0, + salesTax: 0, + }, + { + promoCode: "promoTEST2", + kitPrice: 0, + sellingPrice: 0, + laborAmount: 0, + salesTax: 0, + }, + ], }, payment: { isInsurance: false, @@ -397,6 +423,8 @@ describe("analyticsMixin.js", () => { workOrderNumber: "01820-111111", workOrderId: "222222222222", }; + store.getters.isRecalibrationOnOrder = true; + store.getters.isRecalibrationOnSubmittedOrder = false; }); test("Pushes to data layer if nominal", () => { @@ -408,28 +436,203 @@ describe("analyticsMixin.js", () => { }; // Act - analyticsMixin.methods.pushSubmittedOrderToDataLayer(); + analyticsMixin.methods.pushOrderToDataLayer(); // Assert expect(mockDataLayerFn).toHaveBeenCalled(); }); - test("Does not push to data layer if no submitted order available.", () => { + test("Pushes populated data to data layer", () => { // Arrange - store.getters.hasSubmittedOrder = false; - store.getters.submittedOrder = undefined; + window.dataLayer = []; - const mockDataLayerFn = jest.fn(); + // Act + analyticsMixin.methods.pushOrderToDataLayer(); - window.dataLayer = { - push: mockDataLayerFn, + 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); + }); + + test("Pushes correct data when submitted order is present", () => { + // Arrange + window.dataLayer = []; + store.getters.hasSubmittedOrder = true; + store.getters.submittedOrder = store.getters.order; + store.getters.order = { + vehicle: { + year: null, + make: null, + model: null, + style: null, + carId: null, + category: null, + vin: 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: null, + numberOfChips: null, + glassToReplace: null, + }, + lineItems: { + glassParts: null, + supportingItems: null, + vaps: null, + promos: null, + }, + payment: { + isInsurance: null, + insuranceCoverage: { + isVerified: null, + coverageStatus: null, + coverageVerificationType: null, + }, + isPia: null, + piaType: null, + inactivePromos: null, + }, + schedule: { + date: null, + startTime: null, + endTime: null, + jobMinMinutes: null, + jobMaxMinutes: null, + }, + workOrderNumber: null, + workOrderId: null, }; // Act - analyticsMixin.methods.pushSubmittedOrderToDataLayer(); + analyticsMixin.methods.pushOrderToDataLayer(); + + const result = window.dataLayer[0]; // Assert - expect(mockDataLayerFn).not.toHaveBeenCalled(); + console.log(result); + const allFieldsPopulated = Object.keys(result).every( + (key) => result[key] === false || !!result[key] + ); + expect(allFieldsPopulated).toBe(true); + }); + + test("Pushes default values when data is missing", () => { + // Arrange + window.dataLayer = []; + store.getters.order = { + vehicle: { + year: null, + make: null, + model: null, + style: null, + carId: null, + category: null, + vin: 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: null, + numberOfChips: null, + glassToReplace: null, + }, + lineItems: { + glassParts: null, + supportingItems: null, + vaps: null, + promos: null, + }, + payment: { + isInsurance: null, + insuranceCoverage: { + isVerified: null, + coverageStatus: null, + coverageVerificationType: null, + }, + isPia: null, + piaType: null, + inactivePromos: null, + }, + schedule: { + date: null, + startTime: null, + endTime: null, + jobMinMinutes: null, + jobMaxMinutes: null, + }, + workOrderNumber: null, + workOrderId: null, + }; + + // Act + analyticsMixin.methods.pushOrderToDataLayer(); + + const result = window.dataLayer[0]; + + // Assert + console.log(result); + const allFieldsPopulatedOrDefault = Object.keys(result).every( + (key) => result[key] === false || !!result[key] || result[key] === "" + ); + expect(allFieldsPopulatedOrDefault).toBe(true); }); test("Glass and Promo strings correctly formatted", () => { @@ -437,7 +640,7 @@ describe("analyticsMixin.js", () => { window.dataLayer = []; // Act - analyticsMixin.methods.pushSubmittedOrderToDataLayer(); + analyticsMixin.methods.pushOrderToDataLayer(); // Assert const glassString = window.dataLayer[0].glassToReplace; diff --git a/src/mixins/vin-pages-mixin.js b/src/mixins/vin-pages-mixin.js index a0a435000..98eb7a75b 100644 --- a/src/mixins/vin-pages-mixin.js +++ b/src/mixins/vin-pages-mixin.js @@ -2,8 +2,20 @@ import { storeActions } from "@/constants/store-actions.js"; import store from "@/store"; import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin"; import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; +import { experimentSettings } from "@/constants/experiments"; export default { + computed: { + IsEmailOptional() { + const emailOptional = this.getSettingValue(experimentSettings.IS_EMAIL_OPTIONAL); + return emailOptional === "true"; + }, + EmailValidationRules() { + return this.IsEmailOptional + ? "email-address-format" + : "email-address-required|email-address-format"; + }, + }, methods: { async navigateForwardWithSingleCarMatch() { const pageName = this.$options?.name; diff --git a/src/router/index.js b/src/router/index.js index 8fc4d7960..2373b6e48 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -225,6 +225,9 @@ router.afterEach(async (to, from) => { // Push experiments to Data Layer analyticsMixin.methods.pushExperimentsToDataLayer(); + + // Push current order status to Data Layer + analyticsMixin.methods.pushOrderToDataLayer(); }); router.navigateWithoutSaving = ( diff --git a/src/store/index.js b/src/store/index.js index 3241695fb..e213e34aa 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2361,6 +2361,9 @@ export const actions = { ) { const order = context.getters.order; lineItemsToUse = lineItemsToUse ?? order.lineItems; + addGuidToLineItemsIfNotAlreadyThere(lineItemsToUse.vaps); + syncLineItemIds(addableVaps, lineItemsToUse.vaps); + // addableVaps still won't have IDs if they weren't already in lineItemsToUse addGuidToLineItemsIfNotAlreadyThere(addableVaps); const requestObject = { promoCode: promoCode, @@ -2879,12 +2882,18 @@ function syncLineItemIds(lineItemsWithoutIds, lineItemsWithIds) { if (!lineItemsWithIds || !lineItemsWithoutIds) { return; } + var clonedLineItemsWithIds = deepClone(lineItemsWithIds); lineItemsWithoutIds.forEach((noId) => { - lineItemsWithIds.forEach((withId) => { + var matchedLineItemIndex = -1; + clonedLineItemsWithIds.forEach((withId, index) => { if (noId.partNumber === withId.partNumber && noId.partType === withId.partType) { + matchedLineItemIndex = index; noId.id = withId.id; } }); + if (matchedLineItemIndex > -1) { + clonedLineItemsWithIds.splice(matchedLineItemIndex, 1); + } }); } diff --git a/src/store/store.spec.js b/src/store/store.spec.js index 17dc27ec2..bfa09f9a1 100644 --- a/src/store/store.spec.js +++ b/src/store/store.spec.js @@ -2933,7 +2933,7 @@ describe("Actions", () => { // Arrange const context = state; const promoCode = "testPromo"; - const lineItemsToUse = { vaps: [1], promos: [2] }; + const lineItemsToUse = { vaps: [{ partNumber: 1 }], promos: [2] }; const addableVaps = [{ partNumber: "addableVap" }]; context["getters"] = { @@ -3013,7 +3013,7 @@ describe("Actions", () => { }, referralSequenceNumber: "test", lineItems: { - vaps: [1], + vaps: [{ partNumber: 1 }], promos: [2], serverData: "test", }, @@ -3026,7 +3026,7 @@ describe("Actions", () => { crypto.randomUUID = jest.fn(() => "GUID"); - const expectedLineItemsOnOrder = [1, 2]; + const expectedLineItemsOnOrder = [{ partNumber: 1, id: "GUID" }, 2]; // Act actions.validateOrderPromoAndSaveServerData(context, { @@ -3073,7 +3073,7 @@ describe("Actions", () => { }, referralSequenceNumber: "test", lineItems: { - vaps: [1], + vaps: [{ partNumber: 1 }], promos: [2], serverData: "test", }, @@ -3131,7 +3131,7 @@ describe("Actions", () => { }, referralSequenceNumber: "test", lineItems: { - vaps: [1], + vaps: [{ partNumber: 1 }], promos: [2], serverData: "test", },