diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index 58f22ebe2..262979ed4 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -32,6 +32,7 @@ const errorMessages = { DATE_REQUIRED: "Please select a date", PHONE_REQUIRED: "Please enter your phone number", PHONE_FORMAT: "Phone number must be 10 digits", + PHONE_EXTENSION_FORMAT: "The specified extension is invalid", SMS_CONSENT_REQUIRED_1: "Please select checkbox to receive text messages", SMS_CONSENT_REQUIRED_2: "Please select at least one consent option", YEAR_REQUIRED: "Please select your vehicle year", diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 8df02597c..81444f1e1 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -66,6 +66,9 @@ const experimentSettings = { // YMM BAILOUT_VIN_REQUIRED_VEHICLES: "BailoutVINRequiredVehicles", + + // Promo Banner + SHOW_PROMO_BANNER: "ShowQuotePageRegionalPromoBanner", }; const experimentTriggers = { diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index ae6ca6229..a94a4e079 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -114,11 +114,9 @@ - +
+ +
@@ -742,15 +740,6 @@ export default { diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 50512bcbf..c21b9164c 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -5,6 +5,14 @@
+
+
+ +
+
@@ -47,8 +55,7 @@ + cmsWidgetName="AfterpayBannerWidget" /> + class="quote-disclaimer text-left text-md-center" /> +
@@ -134,6 +146,7 @@ import textBlock from "@/digital-components/text-block/text-block"; import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal"; import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue"; import afterpayModalBanner from "@/layouts/quote/afterpay-modal-banner/afterpay-modal-banner"; +import promoBanner from "@/layouts/quote/promo-banner/promo-banner"; import recalDisclaimer from "@/layouts/quote/recal-disclaimer/recal-disclaimer.vue"; import saveProgressModalQuestion from "@/fmg-components/save-progress-modal-question/save-progress-modal-question"; import saveProgressPopupQuestion from "@/fmg-components/save-progress-popup-question/save-progress-popup-question"; @@ -684,6 +697,12 @@ export default { showAfterpayBanner() { return !this.isRecalibrationOnOrder || !this.shouldHideRecalibration; }, + showPromoBanner() { + return experimentMixin.methods.hasSettingEqualTo( + experimentSettings.SHOW_PROMO_BANNER, + "true" + ); + }, isRecalPriceRemove() { return ( experimentMixin.methods @@ -1041,6 +1060,7 @@ export default { contentGroupModal, loadingModal, afterpayModalBanner, + promoBanner, promoModalQuestion, recalDisclaimer, saveProgressModalQuestion, @@ -1166,4 +1186,7 @@ export default { :deep(.promo-modal-question a) { @include responsive-font-size-md(0.875rem, 1rem); } +.quote-disclaimer:last-child { + margin-bottom: 1.5rem; +} 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 c76a0985f..33b02e753 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -2,19 +2,34 @@ 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 }, - payment: { isInsurance: false, insuranceCoverage: { isVerified: false } }, + serviceLocation: { + zipCode: "43235", + zipCodeCtu: "1234", + appointmentType: null, + address: null, + address2: null, + city: null, + state: "OH", + isVehicleProtected: null, + }, + payment: { + isInsurance: false, + insuranceCoverage: { isVerified: false }, + billToAccountNumber: "12345", + }, referralNumber: "", damage: { isRepair: false }, lineItems: { glassParts: [] }, policy: { isItac: false, isNoComp: false }, }, + lineItems: { supportingItems: [] }, applicationUser: { experiments: [], }, @@ -44,12 +59,36 @@ jest.mock("@/helpers/page-prerequisites-helper.js", () => ({ hasInsuranceInfo: jest.fn(() => true), })); -function setupMocks() { +function setupMocks({ isAppleBrowser = false } = {}) { + const dispatchStoreAction = jest.fn().mockResolvedValue(null); const baseMixin = { methods: { getCmsContent: jest.fn(() => ""), setCmsContent: jest.fn(), getTotalLineItemPrice: jest.fn(() => 0), + isAppleBrowser: jest.fn(() => isAppleBrowser), + 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({ @@ -62,7 +101,7 @@ function setupMocks() { }); mountOptions.global.mixins = [baseMixin]; const wrapper = shallowMount(scheduling, mountOptions); - return { wrapper }; + return { wrapper, dispatchStoreAction }; } describe("scheduling.vue", () => { @@ -124,7 +163,7 @@ describe("scheduling.vue", () => { }); const { wrapper } = setupMocks(); - wrapper.vm.inShopProvidersAndTimeslots = [ + wrapper.vm.inshopProvidersAndTimeSlots = [ { provider: { providerNumber: "05018" }, timeSlots: { days: [] } }, { provider: { providerNumber: "05019" }, timeSlots: { days: [] } }, ]; @@ -161,7 +200,7 @@ describe("scheduling.vue", () => { }); const { wrapper } = setupMocks(); - wrapper.vm.inShopProvidersAndTimeslots = [ + wrapper.vm.inshopProvidersAndTimeSlots = [ { provider: { providerNumber: "05018" }, timeSlots: { days: [] } }, { provider: { providerNumber: "05019" }, timeSlots: { days: [] } }, ]; @@ -169,14 +208,244 @@ describe("scheduling.vue", () => { await wrapper.vm.handleRequestMoreDates(); - expect(wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days).toHaveLength(1); + expect(wrapper.vm.inshopProvidersAndTimeSlots[0].timeSlots.days).toHaveLength(1); expect( - wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days[0].timeSlots[0].id + wrapper.vm.inshopProvidersAndTimeSlots[0].timeSlots.days[0].timeSlots[0].id ).toBe("slot-a"); expect( - wrapper.vm.inShopProvidersAndTimeslots[1].timeSlots.days[0].timeSlots[0].id + wrapper.vm.inshopProvidersAndTimeSlots[1].timeSlots.days[0].timeSlots[0].id ).toBe("slot-b"); 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: "scheduling", + }); + 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(); + }); + }); + + 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(); + }); + }); + + describe("in-shop Maps link", () => { + const address = { + city: "Lewis Center", + state: "OH", + streetAddress: "1343 Cameron Ave", + zipCode: "43035", + }; + const query = encodeURIComponent( + "safelite,Safelite Autoglass, 1343 Cameron Ave, Lewis Center, OH 43035" + ); + + test("onInshopAddressClicked opens Maps in a new tab for Google and Apple", () => { + const openSpy = jest.spyOn(window, "open").mockImplementation(() => null); + + const { wrapper } = setupMocks(); + wrapper.vm.onInshopAddressClicked({ address }); + expect(openSpy).toHaveBeenCalledWith( + `https://www.google.com/maps/search/?api=1&query=${query}`, + "_blank", + "noopener,noreferrer" + ); + wrapper.unmount(); + + openSpy.mockClear(); + const { wrapper: appleWrapper } = setupMocks({ isAppleBrowser: true }); + appleWrapper.vm.onInshopAddressClicked({ address }); + expect(openSpy).toHaveBeenCalledWith( + `https://maps.apple.com/?q=${query}`, + "_blank", + "noopener,noreferrer" + ); + appleWrapper.unmount(); + openSpy.mockRestore(); + }); + }); }); diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 2b2a8e803..a3be13b82 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -1,17 +1,22 @@ @@ -64,6 +65,7 @@ export default {