diff --git a/src/constants/save-progress-cms-widgets.js b/src/constants/save-progress-cms-widgets.js new file mode 100644 index 000000000..d80960ace --- /dev/null +++ b/src/constants/save-progress-cms-widgets.js @@ -0,0 +1,10 @@ +const saveProgressCmsWidgets = { + PHONE_SPECIFIC: "SaveProgressPopupWidget_PhoneSpecific", + EMAIL_SPECIFIC: "SaveProgressPopupWidget_EmailSpecific", + MODAL_PHONE_QUESTION: "SaveProgressPhoneQuestionWidget", + MODAL_EMAIL_QUESTION: "SaveProgressEmailQuestionWidget", + POPUP_PHONE_QUESTION: "SaveProgressPopupPhoneQuestionWidget", + POPUP_EMAIL_QUESTION: "SaveProgressPopupEmailQuestionWidget", +}; + +export { saveProgressCmsWidgets }; diff --git a/src/fmg-components/save-progress-modal-question/save-progress-modal-question.spec.js b/src/fmg-components/save-progress-modal-question/save-progress-modal-question.spec.js index 15fb1481d..0f27858de 100644 --- a/src/fmg-components/save-progress-modal-question/save-progress-modal-question.spec.js +++ b/src/fmg-components/save-progress-modal-question/save-progress-modal-question.spec.js @@ -1,43 +1,261 @@ -import { mount, shallowMount } from "@vue/test-utils"; +import { mount } from "@vue/test-utils"; import saveProgressModalQuestion from "./save-progress-modal-question"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { storeActions } from "@/constants/store-actions"; +import { saveProgressCmsWidgets } from "@/constants/save-progress-cms-widgets"; +import { saveQuote } from "@/helpers/heritage-integration/order-helper.js"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { experimentSettings } from "@/constants/experiments"; + +jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({ + saveQuote: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper", () => ({ + ...jest.requireActual("@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper"), + getSaveProgressSmsConsentConfig: jest.fn().mockReturnValue({ + copy: { + transactional: "Sign me up for updates about my upcoming service.", + marketing: "Sign me up for promotional and product offers.", + }, + value: { transactional: false, marketing: false }, + }), +})); jest.mock("@/digital-components/modal/modal", () => ({ methods: { openModal: jest.fn(), + closeModal: jest.fn(), resetButtonStyle: jest.fn(), + resetForm: jest.fn(), + validate: jest.fn().mockResolvedValue({ valid: true }), }, })); -describe("save-progress-modal-question ", () => { - describe("when openModal is run ", () => { - test("the modal should open ", () => { - // Arrange +jest.mock("@/ux-components/alert/alert", () => ({ + name: "alert", + template: "
", +})); + +describe("save-progress-modal-question", () => { + beforeEach(() => { + jest.clearAllMocks(); + experimentMixin.methods.hasSettingEqualTo = jest.fn().mockReturnValue(false); + }); + + describe("when openModal is run", () => { + test("the modal should open", () => { const { wrapper } = setupMocks({ props: { - modelValue: "", modalWidgetName: "testModal", }, }); - // Act wrapper.vm.openModal(); - // Assert expect(wrapper.vm.modal).not.toBeNull(); }); }); + + describe("legacy email-only mode", () => { + test("should save email to the store", async () => { + const { wrapper, dispatchStoreAction } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + pageName: "part-questions", + }, + }); + + wrapper.vm.userInput = "test@example.com"; + await wrapper.vm.saveProgress(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_EMAIL, + "test@example.com", + false + ); + expect(saveQuote).toHaveBeenCalledWith({ pageNameToLog: "part-questions" }); + expect(wrapper.vm.isProgressSaved).toBe(true); + }); + + test("should use the legacy disclaimer widget", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + cmsContent: { + SaveProgressModalWidget: { FooterText2: "Legacy disclaimer" }, + }, + }); + + expect(wrapper.vm.modalDisclaimerText).toBe("Legacy disclaimer"); + }); + }); + + describe("consent management mode", () => { + beforeEach(() => { + experimentMixin.methods.hasSettingEqualTo = jest + .fn() + .mockImplementation( + (settingName, settingValue) => + settingName === experimentSettings.SHOW_CONSENT_MANAGEMENT && + settingValue === "true" + ); + }); + + test("should default to the phone tab when opened", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + }); + + wrapper.vm.openModal(); + + expect(wrapper.vm.isPhoneTabSelected).toBe(true); + }); + + test("should use popup-specific tab labels from CMS widgets", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + cmsContent: { + SaveProgressPopupWidget_PhoneSpecific: { HeadlineText: "PHONE" }, + SaveProgressPopupWidget_EmailSpecific: { HeadlineText: "EMAIL" }, + }, + }); + + expect(wrapper.vm.contactTabs).toEqual([ + { label: "PHONE", value: "PhoneAnswer" }, + { label: "EMAIL", value: "EmailAnswer" }, + ]); + }); + + test("should use the modal question CMS widgets for phone and email inputs", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + }); + + expect(wrapper.vm.phoneQuestionWidgetName).toBe( + saveProgressCmsWidgets.MODAL_PHONE_QUESTION + ); + expect(wrapper.vm.emailQuestionWidgetName).toBe( + saveProgressCmsWidgets.MODAL_EMAIL_QUESTION + ); + }); + + test("should use the email-specific popup disclaimer on the email tab", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + cmsContent: { + SaveProgressPopupWidget_EmailSpecific: { BodyText: "Email disclaimer" }, + SaveProgressPopupWidget_PhoneSpecific: { BodyText: "Phone disclaimer" }, + }, + }); + + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.modalDisclaimerText).toBe("Email disclaimer"); + }); + + test("should use the phone-specific popup disclaimer on the phone tab", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + cmsContent: { + SaveProgressPopupWidget_EmailSpecific: { BodyText: "Email disclaimer" }, + SaveProgressPopupWidget_PhoneSpecific: { BodyText: "Phone disclaimer" }, + }, + }); + + wrapper.vm.contactMethod = "PhoneAnswer"; + + expect(wrapper.vm.modalDisclaimerText).toBe("Phone disclaimer"); + }); + + test("should show consent errors when phone is valid but transactional consent is missing", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + }); + + wrapper.vm.contactMethod = "PhoneAnswer"; + wrapper.vm.userInput = "5551234567"; + wrapper.vm.smsConsent = { transactional: false, marketing: false }; + + await wrapper.vm.validatePhoneAndSave(); + + expect(wrapper.vm.showConsentErrors).toBe(true); + }); + + test("should save phone and sms consent when valid", async () => { + const { wrapper, dispatchStoreAction } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + pageName: "vehicle-parts", + }, + }); + + wrapper.vm.contactMethod = "PhoneAnswer"; + wrapper.vm.userInput = "555-123-4567"; + wrapper.vm.smsConsent = { transactional: true, marketing: true }; + + await wrapper.vm.saveProgress(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_PHONE_NUMBER, + "5551234567", + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_IS_SMS_OPT_IN, + true, + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_IS_SMS_MARKETING_OPT_IN, + true, + false + ); + expect(saveQuote).toHaveBeenCalledWith({ pageNameToLog: "vehicle-parts" }); + }); + + test("should reset sms consent when switching tabs", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressModalWidget", + }, + }); + + wrapper.vm.contactMethod = "PhoneAnswer"; + wrapper.vm.smsConsent = { transactional: true, marketing: true }; + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.smsConsent).toEqual({ transactional: false, marketing: false }); + }); + }); }); -function setupMocks({ options, props }) { +function setupMocks({ options, props, cmsContent = {} }) { const mountOptions = getMountOptions({ ...options, }); + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const mockBaseMixin = { methods: { - getCmsContent: jest.fn(), - dispatchStoreAction: jest.fn(), + getCmsContent: jest.fn((widgetName, fieldName) => cmsContent[widgetName]?.[fieldName]), + dispatchStoreAction, }, }; @@ -47,5 +265,5 @@ function setupMocks({ options, props }) { const wrapper = mount(saveProgressModalQuestion, mountOptions); - return { wrapper }; + return { wrapper, dispatchStoreAction }; } diff --git a/src/fmg-components/save-progress-modal-question/save-progress-modal-question.vue b/src/fmg-components/save-progress-modal-question/save-progress-modal-question.vue index 3bcc1a7a4..361a31869 100644 --- a/src/fmg-components/save-progress-modal-question/save-progress-modal-question.vue +++ b/src/fmg-components/save-progress-modal-question/save-progress-modal-question.vue @@ -17,13 +17,66 @@ :onModalClosedCallback="onModalClosed" :footerButtonText="modalButtonText" :isFooterButtonPrimary="true" + :isFooterButtonSuppressed="isPhoneTabSelected && !isProgressSaved" @footer-button-event="saveProgress"> + + :cmsWidgetName="emailQuestionWidgetName" + :isDisabled="isProgressSaved" /> @@ -37,11 +90,34 @@ + + diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index 92c869898..b76f2112d 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -2,19 +2,43 @@ 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, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; +import { partNumberStrings } from "@/constants/part-number-strings"; +import { experimentSettings } from "@/constants/experiments"; +import { GaActions, GaCategories, GaLabels } from "@/constants/analytics"; import store from "@/store"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { + getPricedMobileFeePart, + getPricedRecycleFeePart, +} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; 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 }, + isRecalAcknowledgedForScheduling: "", }, + lineItems: { supportingItems: [] }, applicationUser: { experiments: [], }, @@ -44,12 +68,52 @@ jest.mock("@/helpers/page-prerequisites-helper.js", () => ({ hasInsuranceInfo: jest.fn(() => true), })); -function setupMocks() { +jest.mock( + "@/layouts/service-location/helpers/service-location-helper/service-location-helper", + () => ({ + getPricedMobileFeePart: jest.fn().mockResolvedValue(null), + getPricedRecycleFeePart: jest.fn().mockResolvedValue(null), + getBillToAccountNumber: jest.fn().mockResolvedValue(null), + getZipCodeData: jest.fn().mockResolvedValue({}), + }) +); + +function setupMocks({ isAppleBrowser = false, cmsContent = {} } = {}) { + const dispatchStoreAction = jest.fn().mockResolvedValue(null); const baseMixin = { methods: { - getCmsContent: jest.fn(() => ""), + getCmsContent: jest.fn((widgetName, fieldName) => { + return cmsContent[widgetName]?.[fieldName] ?? ""; + }), 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", + SAVE_BILL_TO_ACCOUNT_NUMBER: "saveBillToAccountNumber", + SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable", + SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance", + }; + }, + navigationScenarios() { + return { + CLICKED_FORWARD: "clickedForward", + CLICKED_FORWARD_WITH_MOBILE_SERVICE: "clickedForwardWithMobileService", + CLICKED_BACK: "clickedBack", + ZIP_CODE_CHANGED_RECAL_ACK: "ZIP_CODE_CHANGED_RECAL_ACK", + }; + }, + pageName() { + return "scheduling"; + }, }, }; const mountOptions = getMountOptions({ @@ -62,10 +126,84 @@ function setupMocks() { }); mountOptions.global.mixins = [baseMixin]; const wrapper = shallowMount(scheduling, mountOptions); - return { wrapper }; + return { wrapper, dispatchStoreAction }; } describe("scheduling.vue", () => { + describe("estimatedTimeText", () => { + test("replaces {custom:DURATION} with bracketed duration range", async () => { + const { wrapper } = setupMocks({ + cmsContent: { + FunnelSubHeaderWidget: { + HeaderSubText: "Estimated service time: {custom:DURATION}", + }, + }, + }); + await wrapper.setData({ + estimatedServiceMinutesMinimum: 60, + estimatedServiceMinutesMaximum: 120, + }); + + expect(wrapper.vm.estimatedTimeText).toBe("Estimated service time: [1-2] hours"); + wrapper.unmount(); + }); + }); + + describe("section headers", () => { + test("renders mobile and inshop section headers from CMS", async () => { + const { wrapper } = setupMocks({ + cmsContent: { + MobileCardWidget: { + SubheaderText: "We'll come to you (for free!):", + }, + InshopCardWidget: { + HeaderText: "Visit us at a shop:", + }, + }, + }); + await wrapper.setData({ + isLoadingDates: false, + selectedDate: "2026-01-21", + mobileProviderAndTimeSlot: { providerNumber: "03357", timeSlots: { days: [] } }, + inshopProvidersAndTimeSlots: [ + { + provider: { providerNumber: "001820", address: { city: "Worthington" } }, + timeSlots: { days: [] }, + }, + ], + }); + + const headers = wrapper.findAll(".scheduling-section-header"); + expect(headers).toHaveLength(2); + expect(headers[0].text()).toContain("We'll come to you"); + expect(headers[0].find(".scheduling-section-header__icon").exists()).toBe(true); + expect(headers[0].find(".scheduling-section-header__free").text()).toBe("(for free!):"); + expect(headers[1].text()).toContain("Visit us at a shop:"); + expect(headers[1].find(".scheduling-section-header__icon").exists()).toBe(true); + expect(wrapper.find("mobile-scheduling-card-stub").exists()).toBe(true); + expect(wrapper.find("inshop-scheduling-card-stub").exists()).toBe(true); + wrapper.unmount(); + }); + + test("hides section headers when CMS content is empty", async () => { + const { wrapper } = setupMocks(); + await wrapper.setData({ + isLoadingDates: false, + selectedDate: "2026-01-21", + mobileProviderAndTimeSlot: { providerNumber: "03357", timeSlots: { days: [] } }, + inshopProvidersAndTimeSlots: [ + { + provider: { providerNumber: "001820", address: { city: "Worthington" } }, + timeSlots: { days: [] }, + }, + ], + }); + + expect(wrapper.find(".scheduling-section-header").exists()).toBe(false); + wrapper.unmount(); + }); + }); + describe("intercept overlay", () => { test("does not render interceptOverlay when isLoadingDates is false", async () => { const { wrapper } = setupMocks(); @@ -179,4 +317,609 @@ 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: "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(); + }); + + test.each([ + ["yes", "12345", GaLabels.YES], + ["no", null, GaLabels.NO], + ])( + "pushes mobile_available %s when mobileProviderNumber is %s", + async (_label, mobileProviderNumber, expectedGaLabel) => { + settleAllPromises.mockResolvedValueOnce({}); + const { wrapper } = setupMocks(); + wrapper.vm.pushEventToGA.mockClear(); + + await wrapper.vm.loadSchedulingData("43235", { + providersResult: { shopProviders: [], mobileProviderNumber }, + }); + + expect(wrapper.vm.pushEventToGA).toHaveBeenCalledWith( + GaCategories.APPOINTMENT, + GaActions.MOBILE_AVAILABLE, + expectedGaLabel, + true + ); + wrapper.unmount(); + } + ); + + test("navigates to service-zip when zip changes and recal acknowledgement applies", async () => { + store.getters.order.lineItems = { + glassParts: [ + { + partType: "WINDSHIELD", + requiresRecalibration: true, + canSafeliteRecalibrate: false, + }, + ], + }; + try { + const { wrapper } = setupMocks(); + await wrapper.setData({ + isRecalAcknowledgedForScheduling: "", + isLoadingDates: false, + }); + + await wrapper.vm.onZipSearched({ zipCode: "44101", billToAccountNumber: "87291" }); + + expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalledWith( + "ZIP_CODE_CHANGED_RECAL_ACK", + "scheduling" + ); + expect(wrapper.vm.isLoadingDates).toBe(false); + wrapper.unmount(); + } finally { + store.getters.order.lineItems = { glassParts: [] }; + } + }); + }); + + 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(); + }); + + test("updates recycle fee and adds mobile fee for mobile appointments", async () => { + const supportingItems = [ + { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 1, + sellingPrice: 1, + kitPrice: 1, + }, + ]; + store.getters.lineItems.supportingItems = supportingItems; + + 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: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-mobile", startTime: "08:00", endTime: "12:00" }, + ], + }, + ], + }, + }; + wrapper.vm.recycleFeePart = { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 5, + sellingPrice: 6, + kitPrice: 7, + }; + wrapper.vm.mobileFeePart = { + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + kitPrice: 0, + isInsurable: true, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(supportingItems[0]).toEqual( + expect.objectContaining({ + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 5, + sellingPrice: 6, + kitPrice: 7, + }) + ); + expect(supportingItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + isInsurable: true, + }), + ]) + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveSupportingItemsSuppressingStateResetting", + supportingItems, + false + ); + + store.getters.lineItems.supportingItems = []; + wrapper.unmount(); + }); + + test("removes mobile fee for inshop appointments and refreshes recycle fee", async () => { + const mobileFee = { + partType: "MOBILE FEE", + laborAmount: 10, + sellingPrice: 20, + kitPrice: 0, + }; + const supportingItems = [ + { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 1, + sellingPrice: 1, + kitPrice: 1, + }, + mobileFee, + { + partType: PREMIUM_FEE_PART_TYPE, + laborAmount: 2, + sellingPrice: 2, + kitPrice: 2, + }, + ]; + store.getters.lineItems.supportingItems = supportingItems; + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "05018", + routeCodeId: "route-inshop", + }; + wrapper.vm.inshopProvidersAndTimeSlots = [ + { + provider: { providerNumber: "05018", address: { zipCodeCtu: "9999" } }, + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-inshop", startTime: "09:00", endTime: "10:00" }, + ], + }, + ], + }, + }, + ]; + wrapper.vm.recycleFeePart = { + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 8, + sellingPrice: 9, + kitPrice: 10, + }; + wrapper.vm.mobileFeePart = mobileFee; + + await wrapper.vm.forwardButtonAction(); + + expect(supportingItems.find((item) => item.partType === "MOBILE FEE")).toBeUndefined(); + expect(supportingItems[0]).toEqual( + expect.objectContaining({ + partNumber: partNumberStrings.RECYCLE_FEE, + laborAmount: 8, + sellingPrice: 9, + kitPrice: 10, + }) + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveSupportingItemsSuppressingStateResetting", + supportingItems, + false + ); + + store.getters.lineItems.supportingItems = []; + wrapper.unmount(); + }); + + test("saves MSR applicable true and covered-by-insurance for mobile MSR fee", async () => { + const getSettingValueSpy = jest + .spyOn(experimentMixin.methods, "getSettingValue") + .mockImplementation((settingName) => { + if (settingName === experimentSettings.DISPLAY_MSR) return "true"; + if (settingName === experimentSettings.ENABLE_MSR_SPLIT_PAY) return "true"; + return null; + }); + + 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: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-mobile", startTime: "08:00", endTime: "12:00" }, + ], + }, + ], + }, + }; + wrapper.vm.mobileFeePart = { + partNumber: partNumberStrings.MOBILE_STATIC_RECAL_FEE, + partType: "MOBILE FEE", + laborAmount: 50, + sellingPrice: 0, + kitPrice: 0, + isInsurable: true, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith("saveIsMSRFeeApplicable", true); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveIsMSRFeeCoveredByInsurance", + true + ); + + getSettingValueSpy.mockRestore(); + wrapper.unmount(); + }); + + test("saves MSR applicable false for inshop appointments", async () => { + const getSettingValueSpy = jest + .spyOn(experimentMixin.methods, "getSettingValue") + .mockImplementation((settingName) => { + if (settingName === experimentSettings.DISPLAY_MSR) return "true"; + if (settingName === experimentSettings.ENABLE_MSR_SPLIT_PAY) return "true"; + return null; + }); + + const { wrapper, dispatchStoreAction } = setupMocks(); + wrapper.vm.selectedDate = "2026-07-22"; + wrapper.vm.selectedScheduling = { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "05018", + routeCodeId: "route-inshop", + }; + wrapper.vm.inshopProvidersAndTimeSlots = [ + { + provider: { providerNumber: "05018", address: { zipCodeCtu: "9999" } }, + timeSlots: { + days: [ + { + date: "2026-07-22", + timeSlots: [ + { id: "route-inshop", startTime: "09:00", endTime: "10:00" }, + ], + }, + ], + }, + }, + ]; + wrapper.vm.mobileFeePart = { + partNumber: partNumberStrings.MOBILE_STATIC_RECAL_FEE, + partType: "MOBILE FEE", + isInsurable: false, + }; + + await wrapper.vm.forwardButtonAction(); + + expect(dispatchStoreAction).toHaveBeenCalledWith("saveIsMSRFeeApplicable", false); + expect(dispatchStoreAction).toHaveBeenCalledWith( + "saveIsMSRFeeCoveredByInsurance", + false + ); + + getSettingValueSpy.mockRestore(); + wrapper.unmount(); + }); + }); + + describe("loadFeeParts", () => { + test("loads priced mobile and recycle fee parts for the zip", async () => { + getPricedMobileFeePart.mockResolvedValueOnce({ partType: "MOBILE FEE" }); + getPricedRecycleFeePart.mockResolvedValueOnce({ + partNumber: partNumberStrings.RECYCLE_FEE, + }); + + const { wrapper } = setupMocks(); + await wrapper.vm.loadFeeParts("44101"); + + expect(getPricedMobileFeePart).toHaveBeenCalledWith("44101", "scheduling"); + expect(getPricedRecycleFeePart).toHaveBeenCalledWith("44101", "scheduling"); + expect(wrapper.vm.mobileFeePart).toEqual({ partType: "MOBILE FEE" }); + expect(wrapper.vm.recycleFeePart).toEqual({ + partNumber: partNumberStrings.RECYCLE_FEE, + }); + wrapper.unmount(); + }); + }); + + describe("forwardButtonAction (recal acknowledgement)", () => { + test("opens recal modal and does not navigate when required", async () => { + store.getters.order.lineItems = { + glassParts: [ + { + partType: "WINDSHIELD", + requiresRecalibration: true, + canSafeliteRecalibrate: false, + }, + ], + }; + const { wrapper } = setupMocks(); + wrapper.vm.$refs.navbar.removeLoader = jest.fn(); + wrapper.vm.$refs.recalAckModal.openModal = jest.fn(); + await wrapper.setData({ isRecalAcknowledgedForScheduling: "" }); + + await wrapper.vm.forwardButtonAction(); + + expect(wrapper.vm.$refs.recalAckModal.openModal).toHaveBeenCalled(); + expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled(); + wrapper.unmount(); + store.getters.order.lineItems = { glassParts: [] }; + }); + + test("navigates forward after recal acknowledgement is saved", async () => { + const { wrapper } = setupMocks(); + await wrapper.setData({ isRecalAcknowledgedForScheduling: "Yes" }); + + await wrapper.vm.forwardButtonAction(); + + expect(store.dispatch).toHaveBeenCalledWith( + "saveIsRecalAcknowledgedForScheduling", + "Yes" + ); + expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled(); + wrapper.unmount(); + }); + + test("onRecalAcknowledged sets acknowledgement and continues forward", async () => { + const { wrapper } = setupMocks(); + wrapper.vm.forwardButtonAction = jest.fn(); + + wrapper.vm.onRecalAcknowledged("Yes"); + + expect(wrapper.vm.isRecalAcknowledgedForScheduling).toBe("Yes"); + expect(wrapper.vm.forwardButtonAction).toHaveBeenCalled(); + 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 7e6fab011..7a668a6fa 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -9,7 +9,14 @@ cmsWidgetName="FunnelSubHeaderWidget" :overrideHeaderSubText="estimatedTimeText" alignLeft /> +
+

+ + + {{ mobileSectionHeaderMainText }} + + {{ mobileSectionHeaderFreeText }} + + +

+

+ + {{ inshopSectionHeaderText }} +

@@ -96,13 +139,30 @@ import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mo import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card"; import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay"; import waitlistQuestion from "@/layouts/scheduling/waitlist-question/waitlist-question"; +import schedulingZipSearch from "@/layouts/scheduling/scheduling-zip-search/scheduling-zip-search"; +import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue"; import textLink from "@/ux-components/text-link/text-link"; import store from "@/store"; -import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; +import { partNumberStrings } from "@/constants/part-number-strings"; +import baseMixin from "@/mixins/base-mixin"; +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { experimentSettings } from "@/constants/experiments"; +import { storeActions } from "@/constants/store-actions"; +import { + AppointmentTypeStrings, + PREMIUM_FEE_PART_TYPE, + RECAL_ACK_YES, +} from "@/constants/schedule-constants"; +import { isDropOffRouteCode } from "@/layouts/schedule/helpers/schedule-helper"; +import { + getPricedMobileFeePart, + getPricedRecycleFeePart, +} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper"; +import { partTypeStrings } from "@/constants/part-type-strings"; import { flushPagePrereqsLogs, hasServiceZipInfo, @@ -115,6 +175,8 @@ import { getAvailableDatesFromProviderDays, } from "@/layouts/schedule/helpers/schedule-helper"; +const MOBILE_FEE_PART_TYPE = "MOBILE FEE"; + /** * Returns a YYYY-MM-DD date string offset by the given number of days from a base date. * @param {number} offsetDays - Number of days to offset (positive or negative). @@ -132,6 +194,7 @@ const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot"; const INITIAL_INSHOP_PROVIDER_COUNT = 3; const MAX_INSHOP_PROVIDERS = 9; const VIEW_MORE_SHOPS_BATCH_SIZE = 3; +const INSHOP_MAPS_PLACE_NAME = "safelite,Safelite Autoglass"; /** * Appends days from newSlots into entry.timeSlots, initializing it if absent. @@ -150,14 +213,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; }); } @@ -259,6 +331,24 @@ function fetchTimeSlotsBatch({ ]); } +/** + * Fetches serviceability details for a given zip code. + * @param {{ serviceZipCode: string, lineItems: any, pageNameToLog: string }} params + * @returns {Promise<{ isGlassServiceableInshop: boolean, isRecalibrationServiceableInshop: boolean, isGlassServiceableDropoff: boolean, isRecalibrationServiceableDropoff: boolean, isGlassServiceableMobile: boolean, isRecalibrationServiceableMobile: boolean }>} + */ +function getServiceabilityDetails(serviceZipCode, pageNameToLog) { + const lineItems = store.getters.lineItems; + return baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_SERVICEABILITY_DETAILS, + { + serviceZipCode: serviceZipCode, + lineItems: lineItems, + }, + pageNameToLog, + false + ); +} + export default { name: "scheduling", async beforeRouteEnter(to, from, next) { @@ -282,52 +372,25 @@ export default { pageNameToLog: to.name, }), }, + { + resultKey: "mobileFeePart", + promise: getPricedMobileFeePart(serviceZipCode, to.name), + }, + { + resultKey: "recycleFeePart", + promise: getPricedRecycleFeePart(serviceZipCode, to.name), + }, ]; const resultMap = await settleAllPromises(promiseResultMap); - const allShopProviders = resultMap.providers?.shopProviders ?? []; - const providers = allShopProviders.slice(0, INITIAL_INSHOP_PROVIDER_COUNT); - const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null; next(async (vm) => { 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.mobileFeePart = resultMap.mobileFeePart ?? null; + vm.recycleFeePart = resultMap.recycleFeePart ?? null; vm.isLoadingDates = false; }); }, @@ -340,8 +403,27 @@ export default { schedulingRadioGroupName() { return SCHEDULING_RADIO_GROUP_NAME; }, - serviceLocationText() { - return this.getCmsContent("ServiceLocationText", "Text"); + mobileSectionHeaderText() { + return this.getCmsContent("MobileCardWidget", "SubheaderText"); + }, + mobileSectionHeaderMainText() { + const text = this.mobileSectionHeaderText?.trim(); + if (!text) { + return ""; + } + const freeTextIndex = text.indexOf("("); + return freeTextIndex >= 0 ? text.slice(0, freeTextIndex).trimEnd() : text; + }, + mobileSectionHeaderFreeText() { + const text = this.mobileSectionHeaderText?.trim(); + if (!text) { + return ""; + } + const freeTextIndex = text.indexOf("("); + return freeTextIndex >= 0 ? text.slice(freeTextIndex) : ""; + }, + inshopSectionHeaderText() { + return this.getCmsContent("InshopCardWidget", "HeaderText"); }, viewMoreShopsText() { return this.getCmsContent("ViewMoreShopsWidget", "Text"); @@ -350,13 +432,16 @@ export default { if (!this.estimatedServiceMinutesMinimum || !this.estimatedServiceMinutesMaximum) { return " "; } - const durationText = getDisplayTextForDurationLength( + // "1 - 2 hours" → "[1-2] hours" + const formattedDuration = getDisplayTextForDurationLength( this.estimatedServiceMinutesMinimum, this.estimatedServiceMinutesMaximum - ); - return this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText").replace( + ) + .replace(" - ", "-") + .replace(/(\S+) (hours|minutes)/i, "[$1] $2"); + return (this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText") || "").replace( "{custom:DURATION}", - durationText + formattedDuration ); }, serviceZipCode() { @@ -394,9 +479,6 @@ export default { } return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee); }, - showMobileFreeFlag() { - return true; - }, availableDates() { if (!this.datesLoaded) return null; const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) => @@ -411,6 +493,45 @@ export default { const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length); return this.inshopProvidersAndTimeSlots.length < maxVisible; }, + isInsurance() { + return store.getters.order.payment?.isInsurance; + }, + isITAC() { + return store.getters.order.policy?.isItac; + }, + isNoComp() { + return store.getters.order.policy?.isNoComp; + }, + isCashItacNoComp() { + return !this.isInsurance || this.isITAC || this.isNoComp; + }, + displayMSR() { + return ( + experimentMixin.methods + .getSettingValue(experimentSettings.DISPLAY_MSR) + ?.toLowerCase() === "true" + ); + }, + enableMSRSplitPay() { + return ( + experimentMixin.methods + .getSettingValue(experimentSettings.ENABLE_MSR_SPLIT_PAY) + ?.toLowerCase() === "true" + ); + }, + isMSRFeePartNumber() { + return ( + this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_STATIC_RECAL_FEE || + this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_DUAL_RECAL_FEE + ); + }, + isMobileStaticRecalibrationApplicable() { + return ( + this.displayMSR && + this.isMSRFeePartNumber && + (this.enableMSRSplitPay || this.isCashItacNoComp || this.mobileFeePart?.isInsurable) + ); + }, }, data() { return { @@ -425,12 +546,119 @@ export default { allShopProviders: [], mobileProviderAndTimeSlot: null, mobilePremiumAppointmentFee: null, + mobileFeePart: null, + recycleFeePart: null, selectedScheduling: null, isWaitlistRequested: false, isLoadingMoreShops: false, + zipSearchCode: store.getters.order.serviceLocation.zipCode ?? "", + datePickerKey: 0, + billToAccountNumber: store.getters.order.payment?.billToAccountNumber ?? null, + isRecalAcknowledgedForScheduling: + store.getters.order.isRecalAcknowledgedForScheduling ?? "", + serviceabilityDetails: { + isGlassServiceableInshop: false, + isRecalibrationServiceableInshop: false, + isGlassServiceableDropoff: false, + isRecalibrationServiceableDropoff: false, + isGlassServiceableMobile: false, + isRecalibrationServiceableMobile: false, + }, }; }, methods: { + async loadFeeParts(serviceZipCode, pageNameToLog = this.pageName) { + const [mobileFeePart, recycleFeePart] = await Promise.all([ + getPricedMobileFeePart(serviceZipCode, pageNameToLog), + getPricedRecycleFeePart(serviceZipCode, pageNameToLog), + ]); + this.mobileFeePart = mobileFeePart ?? null; + this.recycleFeePart = recycleFeePart ?? null; + }, + 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; + + var gaLabel = this.GaLabels.NO; + if (this.mobileProviderAndTimeSlot) { + gaLabel = this.GaLabels.YES; + } + this.pushEventToGA( + this.GaCategories.APPOINTMENT, + this.GaActions.MOBILE_AVAILABLE, + gaLabel, + true + ); + + 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; + + const serviceabilityDetailsResultMap = await getServiceabilityDetails( + serviceZipCode, + pageNameToLog + ); + if (serviceabilityDetailsResultMap?.data) { + this.serviceabilityDetails = serviceabilityDetailsResultMap.data; + } + }, getInshopTimeSlotsForSelectedDate(providerNumber) { if (!this.selectedDate) { return []; @@ -441,6 +669,26 @@ export default { const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate); return day?.timeSlots ?? []; }, + buildInshopMapsQuery(address) { + const streetAddress = address?.streetAddress?.trim(); + if (!streetAddress) { + return null; + } + const street = [streetAddress, address.streetAddress2?.trim()] + .filter(Boolean) + .join(" "); + return `${INSHOP_MAPS_PLACE_NAME}, ${street}, ${address.city}, ${address.state} ${address.zipCode}`; + }, + buildInshopMapsUrl(address) { + const query = this.buildInshopMapsQuery(address); + if (!query) { + return null; + } + if (this.isAppleBrowser()) { + return `https://maps.apple.com/?q=${encodeURIComponent(query)}`; + } + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + }, getInshopAvailableDateChips(providerNumber) { if ( !this.selectedDate || @@ -460,11 +708,48 @@ export default { this.selectedDate = date; }, onInshopAddressClicked(provider) { - // TODO: open Google Maps when address link functionality is implemented - console.log("onInshopAddressClicked", provider?.providerNumber); + const url = this.buildInshopMapsUrl(provider?.address); + if (!url) { + return; + } + window.open(url, "_blank", "noopener,noreferrer"); }, onMobileZipCodeClicked() { - // TODO: open service zip modal when zip edit is implemented for scheduling page + this.$refs.schedulingZipSearch?.focusZipInput(); + }, + async onZipSearched({ zipCode, billToAccountNumber }) { + // When recal ack applies, changing zip must re-run parts selection via service-zip. + if (this.shouldShowRecalAckModal()) { + this.$router.navigateWithSaving( + this.navigationScenarios.ZIP_CODE_CHANGED_RECAL_ACK, + this.pageName + ); + return; + } + + const serviceabilityDetailsResultMap = await getServiceabilityDetails( + zipCode, + this.pageName + ); + if (serviceabilityDetailsResultMap?.data) { + this.serviceabilityDetails = serviceabilityDetailsResultMap.data; + } + + 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 Promise.all([this.loadSchedulingData(zipCode), this.loadFeeParts(zipCode)]); + } finally { + this.isLoadingDates = false; + } }, async onViewMoreShopsClick() { if (this.isLoadingDates || this.isLoadingMoreShops) return; @@ -566,9 +851,298 @@ 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, + }; + }, + updateAndSaveSupportingItems(appointmentType) { + let supportingItems = store.getters.lineItems?.supportingItems; + let shouldSaveSupportingItems = false; + + supportingItems = + !supportingItems && this.isMobileStaticRecalibrationApplicable + ? [] + : supportingItems; + + // for insurance orders, fees can get removed causing supportingitems to be null or empty + if (!supportingItems) { + return; + } + + // update recycle fee price + if (this.recycleFeePart) { + const recycleFeeIndex = supportingItems.findIndex( + (item) => item.partNumber == partNumberStrings.RECYCLE_FEE + ); + if (recycleFeeIndex >= 0) { + supportingItems[recycleFeeIndex].laborAmount = this.recycleFeePart.laborAmount; + supportingItems[recycleFeeIndex].sellingPrice = + this.recycleFeePart.sellingPrice; + supportingItems[recycleFeeIndex].kitPrice = this.recycleFeePart.kitPrice; + + shouldSaveSupportingItems = true; + } + } + + // if we have a mobile fee, then save/update supporting items + if (appointmentType === AppointmentTypeStrings.MOBILE) { + if (this.mobileFeePart) { + const mobileFeeIndex = supportingItems.findIndex( + (item) => item.partType == MOBILE_FEE_PART_TYPE + ); + if (mobileFeeIndex >= 0) { + supportingItems[mobileFeeIndex].laborAmount = + this.mobileFeePart.laborAmount; + supportingItems[mobileFeeIndex].sellingPrice = + this.mobileFeePart.sellingPrice; + supportingItems[mobileFeeIndex].kitPrice = this.mobileFeePart.kitPrice; + supportingItems[mobileFeeIndex].isInsurable = + this.mobileFeePart.isInsurable; + } else { + supportingItems.push(this.mobileFeePart); + } + shouldSaveSupportingItems = true; + } + } else { + const removeMobileFeeIndex = supportingItems.findIndex( + (item) => item.partType == MOBILE_FEE_PART_TYPE + ); + if (removeMobileFeeIndex >= 0) { + supportingItems.splice(removeMobileFeeIndex, 1); + shouldSaveSupportingItems = true; + } + } + + if (shouldSaveSupportingItems) { + this.dispatchStoreAction( + this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, + supportingItems, + false + ); + } + }, + updateAndSaveIsMSRFeeApplicable(appointmentType) { + const isMSRFeeApplicable = + this.isMobileStaticRecalibrationApplicable && + appointmentType === AppointmentTypeStrings.MOBILE; + this.dispatchStoreAction( + this.storeActions.SAVE_IS_MSR_FEE_APPLICABLE, + isMSRFeeApplicable + ); + }, + updateAndSaveIsMSRFeeCoveredByInsurance() { + const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable; + this.dispatchStoreAction( + this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE, + isMSRFeeCoveredByInsurance + ); + }, + 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() { + if (this.shouldShowRecalAckModal()) { + this.$refs.navbar.removeLoader(); + this.$refs.recalAckModal.openModal(); + return; + } + + const selectedScheduling = this.selectedScheduling; + const selectedDate = this.selectedDate; + + await store.dispatch( + storeActions.SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING, + this.isRecalAcknowledgedForScheduling + ); + + 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.updateAndSaveSupportingItems(appointmentType); + this.updateAndSaveIsMSRFeeApplicable(appointmentType); + this.updateAndSaveIsMSRFeeCoveredByInsurance(); + 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 @@ -580,6 +1154,23 @@ export default { ); } }, + shouldShowRecalAckModal() { + if (this.isRecalAcknowledgedForScheduling === RECAL_ACK_YES) { + return false; + } + + const glassParts = store.getters.order.lineItems?.glassParts ?? []; + return glassParts.some( + (part) => + part.partType === partTypeStrings.WINDSHIELD && + part.requiresRecalibration === true && + part.canSafeliteRecalibrate === false + ); + }, + onRecalAcknowledged(isAcknowledged) { + this.isRecalAcknowledgedForScheduling = isAcknowledged; + this.forwardButtonAction(); + }, }, components: { funnelHeader, @@ -591,17 +1182,37 @@ export default { inshopSchedulingCard, interceptOverlay, waitlistQuestion, + schedulingZipSearch, + recalAckModal, textLink, }, };