diff --git a/src/assets/img/party.svg b/src/assets/img/party.svg new file mode 100644 index 000000000..82f794a7b --- /dev/null +++ b/src/assets/img/party.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/img/van.svg b/src/assets/img/van.svg new file mode 100644 index 000000000..886cee73f --- /dev/null +++ b/src/assets/img/van.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js new file mode 100644 index 000000000..3a44b0e34 --- /dev/null +++ b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js @@ -0,0 +1,157 @@ +import { shallowMount } from "@vue/test-utils"; +import mobileSchedulingCard from "./mobile-scheduling-card"; +import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; + +const MOCK_TIME_SLOTS = [ + { + id: "3357I-03357-M-I*21346*AM", + startTime: "08:00", + endTime: "12:00", + offerPremium: true, + }, + { + id: "3357I-03357-M-I*21346*PM", + startTime: "12:00", + endTime: "17:00", + offerPremium: false, + }, +]; + +const MOCK_CMS_CONTENT = { + MobileCardWidget: { + HeaderText: "We'll come to you", + SubheaderText: "We'll come to you for free!", + BodyText: "Enter address on the next page", + FooterText: "First stop of the day", + FooterText2: "8:00 am - 12:00 pm arrival window", + }, +}; + +function mountComponent(props = {}, cmsContent = {}) { + const cmsContentByWidget = { + ...MOCK_CMS_CONTENT, + ...cmsContent, + MobileCardWidget: { + ...MOCK_CMS_CONTENT.MobileCardWidget, + ...cmsContent.MobileCardWidget, + }, + }; + + const cmsMixin = { + methods: { + getCmsContent: jest.fn((widgetName, fieldName) => { + return cmsContentByWidget[widgetName]?.[fieldName] ?? ""; + }), + }, + }; + + const mountOptions = getMountOptions({ + route: { name: "scheduling" }, + mixins: [cmsMixin], + }); + + return shallowMount(mobileSchedulingCard, { + props: { + zipCode: "43235", + timeSlots: MOCK_TIME_SLOTS, + premiumTimeSlotPrice: 29.99, + ...props, + }, + global: mountOptions.global, + }); +} + +describe("mobile-scheduling-card.vue", () => { + test("renders header, zip code, and time slot options", () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain("We'll come to you"); + expect(wrapper.text()).toContain("43235"); + expect(wrapper.text()).toContain("Enter address on the next page"); + expect(wrapper.text()).toContain("First stop of the day"); + expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window"); + expect(wrapper.text()).toContain("+$29.99"); + expect(wrapper.text()).toContain("3 appts"); + wrapper.unmount(); + }); + + test("shows greyed out 0 appts badge when there are no time slots", () => { + const wrapper = mountComponent({ timeSlots: [] }); + expect(wrapper.text()).toContain("0 appts"); + expect(wrapper.find(".mobile-scheduling-card__appt-badge--empty").exists()).toBe(true); + wrapper.unmount(); + }); + + test("shows free flag when showFreeFlag is true", () => { + const wrapper = mountComponent({ showFreeFlag: true }); + expect(wrapper.text()).toContain("We'll come to you for free!"); + wrapper.unmount(); + }); + + test("emits zip-code-clicked when zip link is clicked", async () => { + const wrapper = mountComponent(); + await wrapper.find(".mobile-scheduling-card__zip-link").trigger("click"); + expect(wrapper.emitted("zip-code-clicked")).toBeTruthy(); + wrapper.unmount(); + }); + + test("emits update:modelValue with premium selection", async () => { + const wrapper = mountComponent(); + const premiumInput = wrapper.find( + `input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]` + ); + await premiumInput.setValue(true); + expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ + selectedMobileTimeSlot: { + routeCodeId: MOCK_TIME_SLOTS[0].id, + isPremiumAppointment: true, + }, + }); + wrapper.unmount(); + }); + + test("shows premium sublabel only when the premium option is selected", async () => { + const wrapper = mountComponent(); + expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window"); + + const premiumInput = wrapper.find( + `input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]` + ); + await premiumInput.setValue(true); + await wrapper.setProps({ + modelValue: { + selectedMobileTimeSlot: { + routeCodeId: MOCK_TIME_SLOTS[0].id, + isPremiumAppointment: true, + }, + }, + }); + + expect(wrapper.text()).toContain("8:00 am - 12:00 pm arrival window"); + wrapper.unmount(); + }); + + test("emits update:modelValue with standard time slot selection", async () => { + const wrapper = mountComponent(); + const pmInput = wrapper.find(`input[value="${MOCK_TIME_SLOTS[1].id}"]`); + await pmInput.setValue(true); + expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ + selectedMobileTimeSlot: { + routeCodeId: MOCK_TIME_SLOTS[1].id, + isPremiumAppointment: false, + }, + }); + wrapper.unmount(); + }); + + test("collapses and expands body when header is clicked", async () => { + const wrapper = mountComponent(); + expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true); + await wrapper.find(".mobile-scheduling-card__header").trigger("click"); + expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(false); + expect(wrapper.find(".mobile-scheduling-card__location-bar").isVisible()).toBe(true); + await wrapper.find(".mobile-scheduling-card__header").trigger("click"); + expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue new file mode 100644 index 000000000..578faaa39 --- /dev/null +++ b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue @@ -0,0 +1,402 @@ + + + + + diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index f6c216a26..1c2a1fd49 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -19,6 +19,15 @@ :availableDates="availableDates" :isLoadingDates="isLoadingDates" @requestMoreDates="handleRequestMoreDates" /> + ({ resultKey: `inshopTimeSlots_${i}`, promise: store.dispatch("getShopTimeSlots", { @@ -123,12 +139,42 @@ export default { } : null; vm.datesLoaded = true; + vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null; }); }, + watch: { + selectedDate() { + this.selectedMobileScheduling = null; + }, + }, computed: { serviceLocationText() { return this.getCmsContent("ServiceLocationText", "Text"); }, + serviceZipCode() { + return store.getters.order.serviceLocation.zipCode; + }, + showMobileSchedulingCard() { + return Boolean(this.mobileProviderAndTimeSlot && this.selectedDate); + }, + mobileTimeSlotsForSelectedDate() { + if (!this.selectedDate) { + return []; + } + const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find( + (d) => d.date === this.selectedDate + ); + return day?.timeSlots ?? []; + }, + premiumTimeSlotPrice() { + if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) { + return null; + } + return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee); + }, + showMobileFreeFlag() { + return true; + }, availableDates() { if (!this.datesLoaded) return null; const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) => @@ -149,9 +195,14 @@ export default { datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1), inShopProvidersAndTimeslots: [], mobileProviderAndTimeSlot: null, + mobilePremiumAppointmentFee: null, + selectedMobileScheduling: null, }; }, methods: { + onMobileZipCodeClicked() { + // TODO: open service zip modal when zip edit is implemented for scheduling page + }, arePagePrerequisitesValid() { const order = store.getters.order; const logQueue = []; @@ -215,6 +266,7 @@ export default { navbar, Form, datePicker, + mobileSchedulingCard, loadingModal, }, };