diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index 3df8ab9a1..2f700ae2c 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -2,13 +2,23 @@ import { shallowMount } from "@vue/test-utils"; import scheduling from "./scheduling"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; import { settleAllPromises } from "@/helpers/layout-helper"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import store from "@/store"; jest.mock("@/store", () => ({ dispatch: jest.fn().mockResolvedValue(null), getters: { order: { - serviceLocation: { zipCode: "43235", appointmentType: null }, + serviceLocation: { + zipCode: "43235", + zipCodeCtu: "1234", + appointmentType: null, + address: null, + address2: null, + city: null, + state: "OH", + isVehicleProtected: null, + }, payment: { isInsurance: false, insuranceCoverage: { isVerified: false }, @@ -19,6 +29,7 @@ jest.mock("@/store", () => ({ lineItems: { glassParts: [] }, policy: { isItac: false, isNoComp: false }, }, + lineItems: { supportingItems: [] }, applicationUser: { experiments: [], }, @@ -49,11 +60,34 @@ jest.mock("@/helpers/page-prerequisites-helper.js", () => ({ })); function setupMocks() { + const dispatchStoreAction = jest.fn().mockResolvedValue(null); const baseMixin = { methods: { getCmsContent: jest.fn(() => ""), setCmsContent: jest.fn(), getTotalLineItemPrice: jest.fn(() => 0), + dispatchStoreAction, + }, + computed: { + storeActions() { + return { + SAVE_SERVICE_LOCATION: "saveServiceLocation", + SAVE_SCHEDULE: "saveSchedule", + SAVE_WAITLIST_REQUESTED: "saveWaitListRequested", + SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING: + "saveSupportingItemsSuppressingStateResetting", + }; + }, + navigationScenarios() { + return { + CLICKED_FORWARD: "clickedForward", + CLICKED_FORWARD_WITH_MOBILE_SERVICE: "clickedForwardWithMobileService", + CLICKED_BACK: "clickedBack", + }; + }, + pageName() { + return "scheduling"; + }, }, }; const mountOptions = getMountOptions({ @@ -66,7 +100,7 @@ function setupMocks() { }); mountOptions.global.mixins = [baseMixin]; const wrapper = shallowMount(scheduling, mountOptions); - return { wrapper }; + return { wrapper, dispatchStoreAction }; } describe("scheduling.vue", () => { @@ -249,4 +283,132 @@ describe("scheduling.vue", () => { wrapper.unmount(); }); }); + + describe("forwardButtonAction", () => { + test("saves service location, schedule, waitlist and navigates for inshop selection", async () => { + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.isWaitlistRequested = true; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "05018", + routeCodeId: "route-inshop", + }; + wrapper.vm.inshopProvidersAndTimeSlots = [ + { + provider: { + providerNumber: "05018", + address: { + streetAddress: "123 Main", + city: "Columbus", + state: "OH", + zipCode: "43235", + zipCodeCtu: "9999", + }, + }, + timeSlots: { + estimatedServiceMinutesMinimum: 60, + estimatedServiceMinutesMaximum: 120, + days: [ + { + date: "2026-07-22", + timeSlots: [ + { + id: "route-inshop", + startTime: "09:00", + endTime: "10:00", + }, + ], + }, + ], + }, + }, + ]; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveServiceLocation", + expect.objectContaining({ + appointmentType: AppointmentTypeStrings.IN_SHOP, + zipCodeCtu: "9999", + provider: expect.objectContaining({ providerNumber: "05018" }), + }), + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveSchedule", + expect.objectContaining({ + date: "2026-07-22", + routeCode: "route-inshop", + startTime: "09:00", + endTime: "10:00", + jobMinMinutes: "60", + jobMaxMinutes: "120", + }), + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith("saveWaitListRequested", true, false); + expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalledWith( + "clickedForward", + "scheduling" + ); + wrapper.unmount(); + }); + + test("navigates with mobile scenario and clears provider address for mobile selection", async () => { + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.MOBILE, + providerNumber: "MOBILE1", + routeCodeId: "route-mobile", + isPremiumAppointment: false, + }; + wrapper.vm.mobileProviderAndTimeSlot = { + providerNumber: "MOBILE1", + timeSlots: { + estimatedServiceMinutesMinimum: 90, + estimatedServiceMinutesMaximum: 150, + days: [ + { + date: "2026-07-22", + timeSlots: [ + { + id: "route-mobile", + startTime: "08:00", + endTime: "12:00", + }, + ], + }, + ], + }, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveServiceLocation", + expect.objectContaining({ + appointmentType: AppointmentTypeStrings.MOBILE, + provider: { + providerNumber: "MOBILE1", + address: { + streetAddress: null, + city: null, + state: null, + zipCode: null, + zipCodeCtu: null, + }, + }, + }), + false + ); + expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalledWith( + "clickedForwardWithMobileService", + "scheduling" + ); + wrapper.unmount(); + }); + }); }); diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 676ddae45..e430330e4 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -102,6 +102,7 @@ import textLink from "@/ux-components/text-link/text-link"; import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; +import { isDropOffRouteCode } from "@/layouts/schedule/helpers/schedule-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; @@ -148,14 +149,23 @@ function appendDays(entry, newSlots) { /** * Assigns time slots from a v2 multi-provider response onto in-shop provider entries. * @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries - * @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[], provisionalTriggers?: string[] }> } | null | undefined} multiProviderResponse + * @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[], provisionalTriggers?: string[] }>, estimatedServiceMinutesMinimum?: number, estimatedServiceMinutesMaximum?: number } | null | undefined} multiProviderResponse */ function assignInshopTimeSlotsFromV2Response(entries, multiProviderResponse) { const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? []; entries.forEach((entry) => { - entry.timeSlots = + const matchedSlots = providerTimeSlots.find((pts) => pts.providerNumber === entry.provider.providerNumber) ?? null; + if (matchedSlots) { + matchedSlots.estimatedServiceMinutesMinimum = + matchedSlots.estimatedServiceMinutesMinimum ?? + multiProviderResponse?.estimatedServiceMinutesMinimum; + matchedSlots.estimatedServiceMinutesMaximum = + matchedSlots.estimatedServiceMinutesMaximum ?? + multiProviderResponse?.estimatedServiceMinutesMaximum; + } + entry.timeSlots = matchedSlots; }); } @@ -585,9 +595,202 @@ export default { this.datePickerEndDate = newEndDate; this.isLoadingDates = false; }, - forwardButtonAction() { - const appointmentType = store.getters.order.serviceLocation.appointmentType; // TODO: remove this once we have a proper appointment type + getInShopOrDropOffApptType(selectedScheduling = this.selectedScheduling) { + if (selectedScheduling?.appointmentType === AppointmentTypeStrings.MOBILE) { + return AppointmentTypeStrings.MOBILE; + } + return isDropOffRouteCode(selectedScheduling?.routeCodeId) + ? AppointmentTypeStrings.DROP_OFF + : AppointmentTypeStrings.IN_SHOP; + }, + getSelectedProvider(appointmentType, selectedScheduling = this.selectedScheduling) { if (appointmentType === AppointmentTypeStrings.MOBILE) { + return { + providerNumber: selectedScheduling?.providerNumber, + address: { + streetAddress: null, + city: null, + state: null, + zipCode: null, + zipCodeCtu: null, + }, + }; + } + return ( + this.inshopProvidersAndTimeSlots.find( + ({ provider }) => provider.providerNumber === selectedScheduling?.providerNumber + )?.provider ?? null + ); + }, + getSelectedTimeSlot( + appointmentType, + selectedScheduling = this.selectedScheduling, + selectedDate = this.selectedDate + ) { + const routeCodeId = selectedScheduling?.routeCodeId; + if (!routeCodeId || !selectedDate) { + return null; + } + + let timeSlots; + if (appointmentType === AppointmentTypeStrings.MOBILE) { + const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find( + (d) => d.date === selectedDate + ); + timeSlots = day?.timeSlots ?? []; + } else { + const providerEntry = this.inshopProvidersAndTimeSlots.find( + ({ provider }) => provider.providerNumber === selectedScheduling.providerNumber + ); + const day = providerEntry?.timeSlots?.days?.find((d) => d.date === selectedDate); + timeSlots = day?.timeSlots ?? []; + } + + return timeSlots.find((timeSlot) => timeSlot.id === routeCodeId) ?? null; + }, + getEstimatedServiceMinutes(appointmentType, selectedScheduling = this.selectedScheduling) { + if (appointmentType === AppointmentTypeStrings.MOBILE) { + return { + minimum: + this.mobileProviderAndTimeSlot?.timeSlots?.estimatedServiceMinutesMinimum, + maximum: + this.mobileProviderAndTimeSlot?.timeSlots?.estimatedServiceMinutesMaximum, + }; + } + + const providerEntry = this.inshopProvidersAndTimeSlots.find( + ({ provider }) => provider.providerNumber === selectedScheduling?.providerNumber + ); + return { + minimum: providerEntry?.timeSlots?.estimatedServiceMinutesMinimum, + maximum: providerEntry?.timeSlots?.estimatedServiceMinutesMaximum, + }; + }, + updateSupportingItems(selectedScheduling = this.selectedScheduling) { + const supportingItems = store.getters.lineItems?.supportingItems; + if (!supportingItems) { + return; + } + + const isPremiumMobile = + selectedScheduling?.appointmentType === AppointmentTypeStrings.MOBILE && + selectedScheduling?.isPremiumAppointment; + + if (isPremiumMobile && this.mobilePremiumAppointmentFee) { + const premiumFeeIndex = supportingItems.findIndex( + (item) => item.partType == PREMIUM_FEE_PART_TYPE + ); + + if (premiumFeeIndex > -1) { + supportingItems[premiumFeeIndex].laborAmount = + this.mobilePremiumAppointmentFee.laborAmount; + supportingItems[premiumFeeIndex].sellingPrice = + this.mobilePremiumAppointmentFee.sellingPrice; + supportingItems[premiumFeeIndex].kitPrice = + this.mobilePremiumAppointmentFee.kitPrice; + } else { + supportingItems.push(this.mobilePremiumAppointmentFee); + } + } else { + const removePremiumFeeIndex = supportingItems.findIndex( + (item) => item.partType == PREMIUM_FEE_PART_TYPE + ); + if (removePremiumFeeIndex >= 0) { + supportingItems.splice(removePremiumFeeIndex, 1); + } + } + + this.dispatchStoreAction( + this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, + supportingItems, + false + ); + }, + async forwardButtonAction() { + const selectedScheduling = this.selectedScheduling; + const selectedDate = this.selectedDate; + + const appointmentType = this.getInShopOrDropOffApptType(selectedScheduling); + const selectedProvider = this.getSelectedProvider(appointmentType, selectedScheduling); + const serviceLocation = store.getters.order.serviceLocation; + const isMobile = appointmentType === AppointmentTypeStrings.MOBILE; + + let zipCodeCtu = serviceLocation.zipCodeCtu; + if ( + !isMobile && + selectedProvider?.address?.zipCodeCtu && + zipCodeCtu !== selectedProvider.address.zipCodeCtu + ) { + zipCodeCtu = selectedProvider.address.zipCodeCtu; + } + + await this.dispatchStoreAction( + this.storeActions.SAVE_SERVICE_LOCATION, + { + address: isMobile ? serviceLocation.address : "", + address2: isMobile ? serviceLocation.address2 : "", + city: isMobile ? serviceLocation.city : "", + state: serviceLocation.state, + zipCode: serviceLocation.zipCode, + zipCodeCtu: zipCodeCtu, + appointmentType, + isVehicleProtected: isMobile ? serviceLocation.isVehicleProtected : null, + provider: { + providerNumber: selectedProvider?.providerNumber, + address: { + streetAddress: selectedProvider?.address?.streetAddress ?? null, + city: selectedProvider?.address?.city ?? null, + state: selectedProvider?.address?.state ?? null, + zipCode: selectedProvider?.address?.zipCode ?? null, + zipCodeCtu: selectedProvider?.address?.zipCodeCtu ?? null, + }, + }, + }, + false + ); + + if (this.billToAccountNumber) { + this.dispatchStoreAction( + this.storeActions.SAVE_BILL_TO_ACCOUNT_NUMBER, + this.billToAccountNumber, + false + ); + } + + this.updateSupportingItems(selectedScheduling); + + const selectedTimeSlot = this.getSelectedTimeSlot( + appointmentType, + selectedScheduling, + selectedDate + ); + const estimatedServiceMinutes = this.getEstimatedServiceMinutes( + appointmentType, + selectedScheduling + ); + + this.dispatchStoreAction( + this.storeActions.SAVE_SCHEDULE, + { + date: selectedDate, + routeCode: selectedScheduling?.routeCodeId ?? null, + startTime: selectedTimeSlot?.startTime ?? null, + endTime: selectedTimeSlot?.endTime ?? null, + jobMinMinutes: estimatedServiceMinutes.minimum?.toString() ?? null, + jobMaxMinutes: estimatedServiceMinutes.maximum?.toString() ?? null, + }, + false + ); + + if (this.isWaitlistRequested !== null && this.isWaitlistRequested !== undefined) { + this.dispatchStoreAction( + this.storeActions.SAVE_WAITLIST_REQUESTED, + this.isWaitlistRequested, + false + ); + } + + if (isMobile) { this.$router.navigateWithSaving( this.navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE, this.pageName