diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 847b14477..1861ac2c1 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -52,7 +52,7 @@ const storeActions = { RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise", GET_SIGNATURE: "getSignature", VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA: "validateOrderPromoAndSaveServerData", - REVALIDATE_ORDER_PROMOS_AND_UPDATE_STORE: "revalidateOrderPromosAndUpdateStore", + REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA: "revalidateOrderPromosAndSaveServerData", // DEPENDENCY MUTATIONS RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies", @@ -87,10 +87,14 @@ const storeActions = { "saveSupportingItemsSuppressingStateResetting", SAVE_VAPS: "saveVaps", SAVE_PROMOS: "savePromos", + SAVE_INACTIVE_PROMOS: "saveInactivePromos", SAVE_CUSTOMER_DETAILS: "saveCustomerDetails", SAVE_SERVICE_LOCATION_TECH_NOTES: "saveServiceLocationTechNotes", SAVE_CCTOKEN: "saveCCToken", SAVE_PAYPAL_TOKEN: "savePaypalToken", + + CREATE_SUBMITTED_ORDER: "createSubmittedOrder", + RESET_SUBMITTED_ORDER: "resetSubmittedOrder", }; export { storeActions }; diff --git a/src/fmg-components/cart/cart.spec.js b/src/fmg-components/cart/cart.spec.js new file mode 100644 index 000000000..bfe1dbb55 --- /dev/null +++ b/src/fmg-components/cart/cart.spec.js @@ -0,0 +1,369 @@ +// Components +import cart from "@/fmg-components/cart/cart.vue"; + +// Supporting Files +import { shallowMount } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; + +describe("cart.vue", () => { + describe("cartItem computeds", () => { + test("if there are one or more front wipers on the order, a front wiper cart item should be added to the cart", () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [ + { + cartItemType: "FRONT WIPERS", + description: 'WIPER BLADE STANDARD 18"', + kitPrice: 0, + laborAmount: 0, + partNumber: "WB18", + partType: "FRONT WIPER", + salesTax: 2.44, + sellingPrice: 32.49, + }, + { + cartItemType: "FRONT WIPERS", + description: 'WIPER BLADE STANDARD 26"', + kitPrice: 0, + laborAmount: 0, + partNumber: "WB26", + partType: "FRONT WIPER", + salesTax: 2.44, + sellingPrice: 32.49, + }, + ], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.frontWipersCartItem).not.toBeNull(); + expect(wrapper.vm.frontWipersCartItem.subTotal).toEqual(64.98); + expect(wrapper.vm.frontWipersCartItem.salesTax).toEqual(4.88); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.frontWipersCartItem + ) >= 0; + expect(found).toBe(true); + }); + + test("if there are one or more rear wipers on the order, a rear wiper cart item should be added to the cart", () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [ + { + cartItemType: "REAR WIPERS", + description: 'REAR WIPER BLADE STANDARD 18"', + kitPrice: 0, + laborAmount: 0, + partNumber: "EWB18", + partType: "REAR WIPER", + salesTax: 2.44, + sellingPrice: 32.49, + }, + { + cartItemType: "REAR WIPERS", + description: 'REAR WIPER BLADE STANDARD 26"', + kitPrice: 0, + laborAmount: 0, + partNumber: "RWB26", + partType: "REAR WIPER", + salesTax: 2.44, + sellingPrice: 32.49, + }, + ], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.rearWipersCartItem).not.toBeNull(); + expect(wrapper.vm.rearWipersCartItem.subTotal).toEqual(64.98); + expect(wrapper.vm.rearWipersCartItem.salesTax).toEqual(4.88); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.rearWipersCartItem + ) >= 0; + expect(found).toBe(true); + }); + + test("if there is Rain Defense on the order, a rain defense cart item should be added to the cart", () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [], + vaps: [ + { + cartItemType: "RAIN DEFENSE", + description: null, + kitPrice: 0, + laborAmount: 0, + listPrice: "44.99", + partNumber: "RAIN DEFENSE", + partType: "RAIN DEFENSE", + salesTax: 3.37, + sellingPrice: 44.99, + }, + ], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.rainDefenseCartItem).not.toBeNull(); + expect(wrapper.vm.rainDefenseCartItem.subTotal).toEqual(44.99); + expect(wrapper.vm.rainDefenseCartItem.salesTax).toEqual(3.37); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.rainDefenseCartItem + ) >= 0; + expect(found).toBe(true); + }); + + // test("if there are glass parts on the order, a glass part cart item should be added to the cart but should not be displayed", () => { + // // Arrange + // const windshieldWithChildParts = { + // canSafeliteRecalibrate: true, + // childParts: [ + // { + // kitPrice: 0, + // laborAmount: 23.55, + // partNumber: "GGG FW4896", + // salesTax: 1.77, + // sellingPrice: 0, + // }, + // ], + // color: "Green Tint", + // description: + // "solar, soundproofing, lane keep assist, lane departure warning system, w/adaptive cruise control", + // id: "db22fd44-10dd-456f-979b-ff88cf68cca6", + // kitPrice: 0, + // laborAmount: 60, + // partNumber: "FW04896GTYN", + // partType: "WINDSHIELD", + // recalibrationType: "STATIC", + // requiresCapabilityQuestions: false, + // requiresRecalibration: true, + // salesTax: 63.86, + // sellingPrice: 791.46, + // }; + + // const lineItems = { + // glassParts: [windshieldWithChildParts], + // supportingItems: [], + // vaps: [], + // promos: [], + // }; + + // const availableLineItems = []; + + // // Act + // const { wrapper } = setupMocks({ + // props: { + // modelValue: lineItems, + // availableLineItems: availableLineItems, + // }, + // }); + + // // Assert + // expect(wrapper.vm.glassPartCartItems[0]).not.toBeNull(); + // expect(wrapper.vm.glassPartCartItems[0].subTotal).toEqual(875.01); + // expect(wrapper.vm.glassPartCartItems[0].salesTax).toEqual(65.63); + // expect(wrapper.vm.glassPartCartItems[0].isDisplayed).toEqual(false); + + // const found = + // wrapper.vm.cartItems.findIndex( + // (cartItem) => cartItem == wrapper.vm.glassPartsCartItem + // ) >= 0; + + // expect(found).toBe(true); + // }); + + // Recycle Fee Cart Item + test("if there is recycling fee on the order, a recycle fee cart item should be added to the cart", () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [ + { + description: null, + id: "9ff98501-03a4-432b-884e-e4e28f884a2f", + kitPrice: 0, + laborAmount: 34.98, + partNumber: "RECYCLE FEE", + partType: "REPLACE FEE", + salesTax: 2.62, + sellingPrice: 0, + }, + ], + vaps: [], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.recycleFeeCartItem).not.toBeNull(); + expect(wrapper.vm.recycleFeeCartItem.subTotal).toEqual(34.98); + expect(wrapper.vm.recycleFeeCartItem.salesTax).toEqual(2.62); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.recycleFeeCartItem + ) >= 0; + expect(found).toBe(true); + }); + + // Mobile Fee Cart Item + test("if there is mobile fee on the order, a mobile fee cart item should be added to the cart", () => { + // Arrange + const lineItems = { + glassParts: [], + supportingItems: [ + { + description: null, + id: "9ff98501-03a4-432b-884e-e4e28f884a2f", + kitPrice: 0, + laborAmount: 34.98, + partNumber: "MOBILE FEE", + partType: "REPLACE FEE", + salesTax: 2.62, + sellingPrice: 0, + }, + ], + vaps: [], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.mobileFeeCartItem).not.toBeNull(); + expect(wrapper.vm.mobileFeeCartItem.subTotal).toEqual(34.98); + expect(wrapper.vm.mobileFeeCartItem.salesTax).toEqual(2.62); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.mobileFeeCartItem + ) >= 0; + expect(found).toBe(true); + }); + + // Other Supporting Items Cart Item + test("if there are other supporting items on the order, an other supporting items cart item should be added to the cart but should not be displayed", () => { + // Arrange + const recalibrationLineItem = { + cartItemType: "SUPPORTING ITEMS", + description: null, + id: "8ab6ef9b-8620-430a-8a92-29f7d4bb8b00", + kitPrice: 0, + laborAmount: 450, + partNumber: "RECAL STATIC", + partType: "RECALIBRATION", + salesTax: 33.75, + sellingPrice: 0, + }; + + const lineItems = { + glassParts: [], + supportingItems: [recalibrationLineItem], + vaps: [], + promos: [], + }; + + const availableLineItems = []; + + // Act + const { wrapper } = setupMocks({ + props: { + modelValue: lineItems, + availableLineItems: availableLineItems, + }, + }); + + // Assert + expect(wrapper.vm.otherSupportingItemsCartItem).not.toBeNull(); + expect(wrapper.vm.otherSupportingItemsCartItem.subTotal).toEqual(450); + expect(wrapper.vm.otherSupportingItemsCartItem.salesTax).toEqual(33.75); + expect(wrapper.vm.otherSupportingItemsCartItem.isDisplayed).toEqual(false); + + const found = + wrapper.vm.cartItems.findIndex( + (cartItem) => cartItem == wrapper.vm.otherSupportingItemsCartItem + ) >= 0; + expect(found).toBe(true); + }); + }); +}); + +function setupMocks({ options, props }) { + const mountOptions = getMountOptions({ + ...options, + }); + + const mockBaseMixin = { + methods: { + getTierOnePackagePrice: jest.fn(), + filterOutFees: jest.fn(), + getCmsContent: jest.fn(), + }, + }; + + if (props) mountOptions.propsData = props; + + mountOptions.global.mixins = [mockBaseMixin]; + + const wrapper = shallowMount(cart, mountOptions); + + return { wrapper }; +} diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index 440823f67..ce43971d8 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -73,13 +73,18 @@ + + diff --git a/src/layouts/payment/payment-switch/payment-switch.vue b/src/layouts/payment/payment-switch/payment-switch.vue new file mode 100644 index 000000000..5b72ffc39 --- /dev/null +++ b/src/layouts/payment/payment-switch/payment-switch.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index fc43af443..a8780cbc5 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -21,39 +21,10 @@ scrolling="no"> -
{{ switchPaymentText }}
-
- -
-
or
-
- -
-
or
-
- -
+ diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 507532f4e..d956632ef 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -83,7 +83,7 @@ import { payWithInsuranceStates } from "@/constants/pay-with-insurance-states"; import { revalidatePromosAndValidateNewPromo } from "@/helpers/promotions-helper"; import { queryStrings } from "@/constants/query-strings"; import { getQuerystringParameter } from "@/helpers/querystring-helper"; -import { setTransitionHooks } from "vue"; + defineRule("option-required", required(errorMessages.OPTION_REQUIRED)); export default { @@ -168,7 +168,7 @@ export default { vm.supportingItems = resultMap.supportingItems; vm.availableLineItems = pricingResults; vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems); - vm.newValidatedPromos = validatePromoResponse?.data.orderPromos; + vm.newValidatedPromos = validatePromoResponse?.orderPromos; }); }, data() { diff --git a/src/layouts/service-location/shop-question/shop-question.spec.js b/src/layouts/service-location/shop-question/shop-question.spec.js index b93728110..7118ab985 100644 --- a/src/layouts/service-location/shop-question/shop-question.spec.js +++ b/src/layouts/service-location/shop-question/shop-question.spec.js @@ -452,7 +452,7 @@ describe("shop-question.vue", () => { // Assert expect(wrapper.vm.answers.length).toBe(6); - console.log(wrapper.vm.answers); + expect(wrapper.vm.answers).toEqual([ { buttonBodyCopy: "4403 Executive Pkwy, Westerville, OH 43081", diff --git a/src/layouts/vehicle/vehicle.vue b/src/layouts/vehicle/vehicle.vue index 1754a3a91..200ade920 100644 --- a/src/layouts/vehicle/vehicle.vue +++ b/src/layouts/vehicle/vehicle.vue @@ -334,6 +334,7 @@ export default { }, async forwardButtonAction() { + await this.dispatchStoreAction(storeActions.RESET_SUBMITTED_ORDER); this.dispatchStoreAction( storeActions.SAVE_VEHICLE, { diff --git a/src/router/index.js b/src/router/index.js index 0a1bef3a3..cec3ff193 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -55,8 +55,17 @@ const routes = [ await GoToFunnelStartOn404(next); } + // Intercept all navigation if a submitted order exists in storage + if (store.getters.hasSubmittedOrder) { + if (to.query.fmgPage !== "vehicle") { + to.query.fmgPage = "confirmation"; + } + } // On entering the funnel "fresh", read cookie information, decide what to do next. payment pages used to return from safelitehop so exclude here - if (from.redirectedFrom === undefined && !to.query.fmgPage.startsWith("payment")) { + else if ( + from.redirectedFrom === undefined && + !to.query.fmgPage.startsWith("payment") + ) { // clear the saveSessionPromise - if it exists in the vuex store but a new instance was created // the saveSessionPromise will no longer point to a valid promise baseMixin.methods.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE); diff --git a/src/store/index.js b/src/store/index.js index 3b606b2f7..e2915a1af 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -691,6 +691,8 @@ export const getters = { .filter((x) => !!x.isActive) .map((x) => x.settings) .reduce((r, c) => Object.assign(r, c), {}) ?? {}, + submittedOrder: (state) => JSON.parse(window.sessionStorage.getItem("submittedOrder")), + hasSubmittedOrder: (state) => window.sessionStorage.getItem("submittedOrder") !== null, }; function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) { @@ -2105,6 +2107,9 @@ export const actions = { savePromos(context, promos) { context.commit(storeMutations.UPDATE_PROMOS, promos); }, + saveInactivePromos(context, inactivePromos) { + context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, inactivePromos); + }, // Price order actions async priceOrderItemsAndSaveServerData( @@ -2238,11 +2243,15 @@ export const actions = { return pricedAvailableLineItems; }, + // The parameter is an array of ALL available front wipers and rain defense + // for the vehicle. There is no need to confirm an addableVap is not already on the order + // is an optional parameter if the lineItems in the store may not be up to date async validateOrderPromoAndSaveServerData( context, - { payload: { promoCode, addableVaps }, pageNameToLog } + { payload: { promoCode, lineItemsToUse, addableVaps }, pageNameToLog } ) { const order = context.getters.order; + lineItemsToUse = lineItemsToUse ?? order.lineItems; addGuidToLineItemsIfNotAlreadyThere(addableVaps); const requestObject = { promoCode: promoCode, @@ -2254,7 +2263,7 @@ export const actions = { eon: order.eon, isRepair: order.damage.isRepair, glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace), - lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems), + lineItemsOnOrder: getArrayOfAllLineItems(lineItemsToUse), parentAccountNumber: order.payment.parentAccountNumber, referralSequenceNumber: order.referralSequenceNumber, serviceState: order.serviceLocation.state, @@ -2287,13 +2296,22 @@ export const actions = { ); } - return validateResponse; + return validateResponse?.data; }, - async revalidateOrderPromosAndUpdateStore(context, { pageNameToLog }) { + // All payload parameters are optional - will use store data if not provided + async revalidateOrderPromosAndSaveServerData( + context, + { payload: { activePromosToUse, inactivePromosToUse, lineItemsToUse }, pageNameToLog } + ) { const order = context.getters.order; + activePromosToUse = activePromosToUse ?? order.lineItems.promos; + inactivePromosToUse = inactivePromosToUse ?? order.payment.inactivePromos; + lineItemsToUse = lineItemsToUse ? deepClone(lineItemsToUse) : deepClone(order.lineItems); + lineItemsToUse.promos = activePromosToUse; + let requestObject = { - inactivePromos: order.payment.inactivePromos, + inactivePromos: inactivePromosToUse, order: { appointmentType: order.serviceLocation.appointmentType, carId: order.vehicle.carId, @@ -2301,7 +2319,7 @@ export const actions = { eon: order.eon, isRepair: order.damage.isRepair, glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace), - lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems), + lineItemsOnOrder: getArrayOfAllLineItems(lineItemsToUse), parentAccountNumber: order.payment.parentAccountNumber, referralSequenceNumber: order.referralSequenceNumber, serviceState: order.serviceLocation.state, @@ -2322,16 +2340,12 @@ export const actions = { pageNameToLog: pageNameToLog, }); - const revalidationErrorPromoCodes = revalidateResponse.data.errors.map((x) => x.promoCode); - - context.commit(storeMutations.UPDATE_PROMOS, revalidateResponse.data.promoLineItems); - context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, revalidationErrorPromoCodes); context.commit( storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, revalidateResponse.data.lineItemsServerData ); - return revalidateResponse; + return revalidateResponse?.data; }, // Misc order actions @@ -2441,6 +2455,26 @@ export const actions = { return false; }, + + createSubmittedOrder(context) { + if (context.getters.hasSubmittedOrder) { + return; + } + + // create a submitted order object from vuex. + const submittedOrder = context.state.order; + + // set to local storage + window.sessionStorage.setItem("submittedOrder", JSON.stringify(submittedOrder)); + + // clear vuex + context.commit(storeMutations.RESET_STATE); + }, + + resetSubmittedOrder(context) { + // clear from local storage + window.sessionStorage.removeItem("submittedOrder"); + }, }; export default createStore({ @@ -2576,15 +2610,6 @@ function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) { return pricedLineItems; } -export class LineItemUtilities { - constructor() { - this.getArrayOfAllLineItems = getArrayOfAllLineItems; - this.getFlattenedArrayOfLineItemsWithChildParts = - getFlattenedArrayOfLineItemsWithChildParts; - this.addGuidToLineItemsIfNotAlreadyThere = addGuidToLineItemsIfNotAlreadyThere; - } -} - export function mapTaxedAvailableLineItemsToStoreFormat(availableLineItems, storeLineItems) { // clone the lineItems array because what we're passing in is referencing the store directly const lineItems = deepClone(storeLineItems); @@ -2592,12 +2617,69 @@ export function mapTaxedAvailableLineItemsToStoreFormat(availableLineItems, stor for (let [category, lineItemsInCategory] of Object.entries(lineItems)) { lineItemsInCategory = lineItemsInCategory ?? []; - for (let lineItemIndex = 0; lineItemIndex < lineItemsInCategory.length; lineItemIndex++) { - const availableLineItem = availableLineItems.find( - (ali) => ali.partNumber == lineItemsInCategory[lineItemIndex].partNumber + if (category == "supportingItems") { + // if the category is supporting items we need to filter out the items that aren't repair chips + const nonRepairChipSupportItemsLineItems = lineItemsInCategory.filter( + (lineItem) => lineItem.partNumber != "WSREPAIR" ); - if (availableLineItem) { - lineItemsInCategory[lineItemIndex].salesTax = availableLineItem.salesTax; + + /* + Because repair chips all have the same part number but different prices based on the quantity, + we have to sort the store line items and available line items by descending labor amount in order to + map the tax correctly to each repair chip + */ + // get the supporting items that ARE repair chips and sort them by descending labor amount + let repairChipLineItems = lineItemsInCategory.filter( + (lineItem) => lineItem.partNumber == "WSREPAIR" + ); + repairChipLineItems = repairChipLineItems.sort( + (a, b) => parseFloat(b.laborAmount) - parseFloat(a.laborAmount) + ); + + // get the supporting items from the available line items (taxed) that ARE repair chips and sort them by descending labor amount + let availableRepairChipLineItems = availableLineItems.filter( + (lineItem) => lineItem.partNumber == "WSREPAIR" + ); + availableRepairChipLineItems = availableRepairChipLineItems.sort( + (a, b) => parseFloat(b.laborAmount) - parseFloat(a.laborAmount) + ); + + // go through each one of those mapping the taxes to the correct chip + for (let i = 0; i < availableRepairChipLineItems.length; i++) { + repairChipLineItems[i].salesTax = availableRepairChipLineItems[i].salesTax; + } + + // then we map the non-repair chip items based on part number + for ( + let lineItemIndex = 0; + lineItemIndex < nonRepairChipSupportItemsLineItems.length; + lineItemIndex++ + ) { + const availableLineItem = availableLineItems.find( + (ali) => + ali.partNumber == + nonRepairChipSupportItemsLineItems[lineItemIndex].partNumber + ); + if (availableLineItem) { + nonRepairChipSupportItemsLineItems[lineItemIndex].salesTax = + availableLineItem.salesTax; + } + } + + // finally we splice the two arrays back into one + lineItemsInCategory = nonRepairChipSupportItemsLineItems.concat(repairChipLineItems); + } else { + for ( + let lineItemIndex = 0; + lineItemIndex < lineItemsInCategory.length; + lineItemIndex++ + ) { + const availableLineItem = availableLineItems.find( + (ali) => ali.partNumber == lineItemsInCategory[lineItemIndex].partNumber + ); + if (availableLineItem) { + lineItemsInCategory[lineItemIndex].salesTax = availableLineItem.salesTax; + } } } }