diff --git a/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.spec.js b/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.spec.js new file mode 100644 index 000000000..f3129613b --- /dev/null +++ b/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.spec.js @@ -0,0 +1,145 @@ +import { shallowMount } from "@vue/test-utils"; +import schedulingZipSearch from "./scheduling-zip-search"; +import store from "@/store"; +import { errorMessages } from "@/constants/error-messages"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { + getBillToAccountNumber, + getZipCodeData, +} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; + +jest.mock("@/store", () => ({ + dispatch: jest.fn().mockResolvedValue(null), +})); + +jest.mock( + "@/layouts/service-location/helpers/service-location-helper/service-location-helper", + () => ({ + getZipCodeData: jest.fn().mockResolvedValue({ + state: "OH", + zipCodeCtu: "03357", + }), + getBillToAccountNumber: jest.fn().mockResolvedValue("87291"), + }) +); + +const MOCK_CMS_CONTENT = { + ServiceZipQuestionWidget: { + QuestionText: "Service ZIP code:", + }, +}; + +function mountComponent(props = {}, cmsContent = {}) { + const cmsContentByWidget = { + ...MOCK_CMS_CONTENT, + ...cmsContent, + ServiceZipQuestionWidget: { + ...MOCK_CMS_CONTENT.ServiceZipQuestionWidget, + ...cmsContent.ServiceZipQuestionWidget, + }, + }; + + const cmsMixin = { + methods: { + getCmsContent: jest.fn((widgetName, fieldName) => { + return cmsContentByWidget[widgetName]?.[fieldName] ?? ""; + }), + }, + }; + + const mountOptions = getMountOptions({ + route: { name: "scheduling" }, + mixins: [cmsMixin], + }); + + return shallowMount(schedulingZipSearch, { + props: { + modelValue: "", + pageNameToLog: "scheduling", + ...props, + }, + global: mountOptions.global, + }); +} + +describe("scheduling-zip-search.vue", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("prefills the zip input and CMS label from modelValue / ServiceZipQuestionWidget", () => { + const wrapper = mountComponent({ modelValue: "43235" }); + + expect(wrapper.find("#sz-service-zip").element.value).toBe("43235"); + expect(wrapper.vm.zipLabelText).toBe("Service ZIP code:"); + expect(wrapper.find("label").html()).toContain("Service ZIP code:"); + wrapper.unmount(); + }); + + test("shows required error when searching with a blank zip", async () => { + const wrapper = mountComponent({ modelValue: "" }); + + await wrapper.vm.onZipSearch({ preventDefault: jest.fn() }); + + expect(wrapper.vm.errorMessage).toBe(errorMessages.SERVICE_ZIP_REQUIRED); + expect(wrapper.find(".scheduling-zip-search__error").classes()).toContain("active"); + expect(getZipCodeData).not.toHaveBeenCalled(); + expect(wrapper.emitted("zip-searched")).toBeUndefined(); + wrapper.unmount(); + }); + + test("shows format error when zip is invalid", async () => { + const wrapper = mountComponent({ modelValue: "123" }); + + await wrapper.vm.onZipSearch({ preventDefault: jest.fn() }); + + expect(wrapper.vm.errorMessage).toBe(errorMessages.SERVICE_ZIP_FORMAT); + expect(getZipCodeData).not.toHaveBeenCalled(); + expect(wrapper.emitted("zip-searched")).toBeUndefined(); + wrapper.unmount(); + }); + + test("does not search when disabled", async () => { + const wrapper = mountComponent({ modelValue: "44101", disabled: true }); + + await wrapper.vm.onZipSearch({ preventDefault: jest.fn() }); + + expect(getZipCodeData).not.toHaveBeenCalled(); + expect(wrapper.emitted("zip-searched")).toBeUndefined(); + wrapper.unmount(); + }); + + test("saves zip info and emits billToAccountNumber when a valid zip is searched", async () => { + const wrapper = mountComponent({ modelValue: "44101" }); + + await wrapper.vm.onZipSearch({ preventDefault: jest.fn() }); + + expect(getZipCodeData).toHaveBeenCalledWith("44101", "scheduling"); + expect(getBillToAccountNumber).toHaveBeenCalledWith("03357", "scheduling"); + expect(store.dispatch).toHaveBeenCalledWith("saveServiceZipCodeInfo", { + zipCode: "44101", + state: "OH", + zipCodeCtu: "03357", + }); + expect(wrapper.emitted("zip-searched")).toEqual([ + [{ zipCode: "44101", billToAccountNumber: "87291" }], + ]); + wrapper.unmount(); + }); + + test("focusZipInput scrolls and focuses the zip input", () => { + const wrapper = mountComponent({ modelValue: "43235" }); + const zipInput = wrapper.find("#sz-service-zip").element; + zipInput.scrollIntoView = jest.fn(); + zipInput.focus = jest.fn(); + + wrapper.vm.focusZipInput(); + + expect(zipInput.scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "center", + }); + expect(zipInput.focus).toHaveBeenCalledWith({ preventScroll: true }); + wrapper.unmount(); + }); +}); diff --git a/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.vue b/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.vue new file mode 100644 index 000000000..01fa86259 --- /dev/null +++ b/src/layouts/scheduling/scheduling-zip-search/scheduling-zip-search.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index 92c869898..3df8ab9a1 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -9,7 +9,11 @@ jest.mock("@/store", () => ({ getters: { order: { serviceLocation: { zipCode: "43235", appointmentType: null }, - payment: { isInsurance: false, insuranceCoverage: { isVerified: false } }, + payment: { + isInsurance: false, + insuranceCoverage: { isVerified: false }, + billToAccountNumber: "12345", + }, referralNumber: "", damage: { isRepair: false }, lineItems: { glassParts: [] }, @@ -179,4 +183,70 @@ describe("scheduling.vue", () => { wrapper.unmount(); }); }); + + describe("service zip", () => { + test("prefills zipSearchCode and billToAccountNumber from store and renders zip search", () => { + const { wrapper } = setupMocks(); + + expect(wrapper.vm.zipSearchCode).toBe("43235"); + expect(wrapper.vm.billToAccountNumber).toBe("12345"); + expect(wrapper.find("scheduling-zip-search-stub").exists()).toBe(true); + wrapper.unmount(); + }); + + test("reloads providers and timeslots when zip-searched is emitted", async () => { + store.dispatch.mockClear(); + store.dispatch.mockImplementation((action) => { + if (action === "getProviders") { + return Promise.resolve({ + data: { + shopProviders: [{ providerNumber: "05018" }], + mobileProviderNumber: "12345", + }, + }); + } + return Promise.resolve(null); + }); + settleAllPromises.mockResolvedValueOnce({ + inshopTimeSlots: { + providerTimeSlots: [ + { providerNumber: "05018", days: [{ date: "2026-07-22", timeSlots: [] }] }, + ], + }, + mobileTimeSlots: { days: [{ date: "2026-07-22", timeSlots: [] }] }, + }); + + const { wrapper } = setupMocks(); + await wrapper.setData({ isLoadingDates: false }); + wrapper.vm.datePickerEndDate = "2026-08-15"; + wrapper.vm.selectedScheduling = { appointmentType: "Mobile" }; + const initialDatePickerKey = wrapper.vm.datePickerKey; + + await wrapper.vm.onZipSearched({ zipCode: "44101", billToAccountNumber: "87291" }); + + expect(store.dispatch).toHaveBeenCalledWith("getProviders", { + payload: { serviceZipCode: "44101" }, + pageNameToLog: undefined, + }); + expect(wrapper.vm.billToAccountNumber).toBe("87291"); + expect(settleAllPromises).toHaveBeenCalled(); + expect(wrapper.vm.datePickerKey).toBe(initialDatePickerKey + 1); + expect(wrapper.vm.selectedDate).toBeNull(); + expect(wrapper.vm.selectedScheduling).toBeNull(); + expect(wrapper.vm.isWaitlistRequested).toBe(false); + expect(wrapper.vm.inshopProvidersAndTimeSlots).toHaveLength(1); + expect(wrapper.vm.mobileProviderAndTimeSlot.providerNumber).toBe("12345"); + wrapper.unmount(); + }); + + test("anchors to zip search when mobile zip is clicked", () => { + const { wrapper } = setupMocks(); + wrapper.vm.$refs.schedulingZipSearch.focusZipInput = jest.fn(); + + wrapper.vm.onMobileZipCodeClicked(); + + expect(wrapper.vm.$refs.schedulingZipSearch.focusZipInput).toHaveBeenCalled(); + wrapper.unmount(); + }); + }); }); diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 53bd3a852..676ddae45 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -9,7 +9,14 @@ cmsWidgetName="FunnelSubHeaderWidget" :overrideHeaderSubText="estimatedTimeText" alignLeft /> + { vm.setCmsContent(resultMap.cmsContent); - vm.allShopProviders = allShopProviders; - vm.inshopProvidersAndTimeSlots = providers.map((provider) => ({ - provider, - timeSlots: null, - })); - vm.mobileProviderAndTimeSlot = mobileProviderNumber - ? { providerNumber: mobileProviderNumber, timeSlots: null } - : null; - const startDate = toDateString(0); - const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1); - const providerNumbers = providers.map((provider) => provider.providerNumber); - const timeSlotsResultMap = await fetchTimeSlotsBatch({ - startDate, - endDate, - providerNumbers, - zipCode: serviceZipCode, - includeMobile: Boolean(mobileProviderNumber), + await vm.loadSchedulingData(serviceZipCode, { + providersResult: resultMap.providers, pageNameToLog: to.name, }); - if (timeSlotsResultMap.inshopTimeSlots) { - vm.estimatedServiceMinutesMinimum = - timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMinimum; - vm.estimatedServiceMinutesMaximum = - timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMaximum; - } else if (timeSlotsResultMap.mobileTimeSlots) { - vm.estimatedServiceMinutesMinimum = - timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMinimum; - vm.estimatedServiceMinutesMaximum = - timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMaximum; - } - assignInshopTimeSlotsFromV2Response( - vm.inshopProvidersAndTimeSlots, - timeSlotsResultMap.inshopTimeSlots - ); - if (vm.mobileProviderAndTimeSlot) { - vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null; - } - vm.datesLoaded = true; vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null; vm.isLoadingDates = false; }); @@ -408,9 +380,77 @@ export default { selectedScheduling: null, isWaitlistRequested: false, isLoadingMoreShops: false, + zipSearchCode: store.getters.order.serviceLocation.zipCode ?? "", + datePickerKey: 0, + billToAccountNumber: store.getters.order.payment?.billToAccountNumber ?? null, }; }, methods: { + async loadSchedulingData( + serviceZipCode, + { providersResult = null, pageNameToLog = this.pageName } = {} + ) { + let providersData = providersResult; + if (!providersData) { + providersData = await store.dispatch("getProviders", { + payload: { serviceZipCode }, + pageNameToLog, + }); + } + if (providersData?.shopProviders === undefined) { + providersData = providersData?.data ?? {}; + } + + const allShopProviders = providersData.shopProviders ?? []; + const providers = allShopProviders.slice(0, INITIAL_INSHOP_PROVIDER_COUNT); + const mobileProviderNumber = providersData?.mobileProviderNumber ?? null; + + this.allShopProviders = allShopProviders; + this.inshopProvidersAndTimeSlots = providers.map((provider) => ({ + provider, + timeSlots: null, + })); + this.mobileProviderAndTimeSlot = mobileProviderNumber + ? { providerNumber: mobileProviderNumber, timeSlots: null } + : null; + + const startDate = toDateString(0); + const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1); + const providerNumbers = providers.map((provider) => provider.providerNumber); + const timeSlotsResultMap = await fetchTimeSlotsBatch({ + startDate, + endDate, + providerNumbers, + zipCode: serviceZipCode, + includeMobile: Boolean(mobileProviderNumber), + pageNameToLog, + }); + + if (timeSlotsResultMap.inshopTimeSlots) { + this.estimatedServiceMinutesMinimum = + timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMinimum; + this.estimatedServiceMinutesMaximum = + timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMaximum; + } else if (timeSlotsResultMap.mobileTimeSlots) { + this.estimatedServiceMinutesMinimum = + timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMinimum; + this.estimatedServiceMinutesMaximum = + timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMaximum; + } else { + this.estimatedServiceMinutesMinimum = null; + this.estimatedServiceMinutesMaximum = null; + } + + assignInshopTimeSlotsFromV2Response( + this.inshopProvidersAndTimeSlots, + timeSlotsResultMap.inshopTimeSlots + ); + if (this.mobileProviderAndTimeSlot) { + this.mobileProviderAndTimeSlot.timeSlots = + timeSlotsResultMap.mobileTimeSlots ?? null; + } + this.datesLoaded = true; + }, getInshopTimeSlotsForSelectedDate(providerNumber) { if (!this.selectedDate) { return []; @@ -426,7 +466,24 @@ export default { console.log("onInshopAddressClicked", provider?.providerNumber); }, onMobileZipCodeClicked() { - // TODO: open service zip modal when zip edit is implemented for scheduling page + this.$refs.schedulingZipSearch?.focusZipInput(); + }, + async onZipSearched({ zipCode, billToAccountNumber }) { + this.billToAccountNumber = billToAccountNumber; + this.isLoadingDates = true; + try { + this.selectedDate = null; + this.selectedScheduling = null; + this.isWaitlistRequested = false; + this.isLoadingMoreShops = false; + this.datesLoaded = false; + this.datePickerKey += 1; + this.datePickerStartDate = toDateString(0); + this.datePickerEndDate = toDateString(SCHEDULE_FETCH_DAYS - 1); + await this.loadSchedulingData(zipCode); + } finally { + this.isLoadingDates = false; + } }, async onViewMoreShopsClick() { if (this.isLoadingDates || this.isLoadingMoreShops) return; @@ -553,6 +610,7 @@ export default { inshopSchedulingCard, interceptOverlay, waitlistQuestion, + schedulingZipSearch, textLink, }, };