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 @@
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
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,
},
};