From 0970028fa3339dc83b21617a3ed3865e0e1ce1a5 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 9 Jun 2026 09:51:31 -0400 Subject: [PATCH 1/3] Remove dummy logic for premium timeslot --- .../mobile-scheduling-card/mobile-scheduling-card.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue index 578faaa39..780e18957 100644 --- a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue +++ b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue @@ -144,7 +144,7 @@ export default { return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`; }, hasPremiumTimeSlot() { - return this.timeSlots?.length > 0; //this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null; + return this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null; }, displayTimeSlots() { if (!this.timeSlots?.length) { From 85b0b65ca26341956055ec4c1db25bf1f97243f7 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 9 Jun 2026 14:24:06 -0400 Subject: [PATCH 2/3] Add in-shop component (with incomplete functionality) --- src/assets/img/shop.svg | 6 + .../helpers/schedule-helper.inshop.spec.js | 44 ++ .../schedule/helpers/schedule-helper.js | 44 ++ .../inshop-scheduling-card.spec.js | 238 +++++++++ .../inshop-scheduling-card.vue | 501 ++++++++++++++++++ src/layouts/scheduling/scheduling.vue | 28 + 6 files changed, 861 insertions(+) create mode 100644 src/assets/img/shop.svg create mode 100644 src/layouts/schedule/helpers/schedule-helper.inshop.spec.js create mode 100644 src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js create mode 100644 src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue diff --git a/src/assets/img/shop.svg b/src/assets/img/shop.svg new file mode 100644 index 000000000..34dfea893 --- /dev/null +++ b/src/assets/img/shop.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/layouts/schedule/helpers/schedule-helper.inshop.spec.js b/src/layouts/schedule/helpers/schedule-helper.inshop.spec.js new file mode 100644 index 000000000..7b4b82aa3 --- /dev/null +++ b/src/layouts/schedule/helpers/schedule-helper.inshop.spec.js @@ -0,0 +1,44 @@ +import { RouteCodeFlags } from "@/constants/schedule-constants"; +import { + getInshopAppointmentTimeSlots, + groupInshopTimeSlotsByTimeOfDay, + mapInshopTimeSlotToDisplaySlot, +} from "./schedule-helper"; + +describe("schedule-helper inshop time slots", () => { + const dropOffSlot = { + id: `route-${RouteCodeFlags.ALL_DAY_DROP_OFF}`, + startTime: "08:00", + endTime: "17:00", + }; + const morningSlot = { id: "route-morning", startTime: "09:30", endTime: "10:00" }; + const afternoonSlot = { id: "route-afternoon", startTime: "13:00", endTime: "13:30" }; + + test("getInshopAppointmentTimeSlots excludes drop off route codes", () => { + const result = getInshopAppointmentTimeSlots([dropOffSlot, morningSlot, afternoonSlot]); + expect(result).toEqual([morningSlot, afternoonSlot]); + }); + + test("groupInshopTimeSlotsByTimeOfDay splits and sorts slots by start time", () => { + const lateMorning = { id: "route-late-morning", startTime: "11:30", endTime: "12:00" }; + const earlyMorning = { id: "route-early-morning", startTime: "08:00", endTime: "08:30" }; + + const result = groupInshopTimeSlotsByTimeOfDay([ + afternoonSlot, + dropOffSlot, + lateMorning, + earlyMorning, + ]); + + expect(result.morning).toEqual([earlyMorning, lateMorning]); + expect(result.afternoon).toEqual([afternoonSlot]); + }); + + test("mapInshopTimeSlotToDisplaySlot formats label and route code", () => { + expect(mapInshopTimeSlotToDisplaySlot(morningSlot)).toEqual({ + value: "route-morning", + label: "9:30 AM", + routeCodeId: "route-morning", + }); + }); +}); diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 799d4b92f..0841322dc 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -72,6 +72,50 @@ export function isDropOffRouteCode(routeCode) { ); } +export const INSHOP_INITIAL_VISIBLE_TIME_SLOTS = 6; + +export function getInshopAppointmentTimeSlots(timeSlots = []) { + return (timeSlots ?? []).filter((timeSlot) => !isDropOffRouteCode(timeSlot.id)); +} + +export function isMorningInshopTimeSlot(timeSlot) { + const hours = parseInt(timeSlot?.startTime?.split(":")[0], 10); + if (Number.isNaN(hours)) { + return false; + } + return hours < 12; +} + +function sortTimeSlotsByStartTime(timeSlots) { + return [...timeSlots].sort((a, b) => { + if (a.startTime < b.startTime) return -1; + if (a.startTime > b.startTime) return 1; + return 0; + }); +} + +export function groupInshopTimeSlotsByTimeOfDay(timeSlots = []) { + const inshopSlots = getInshopAppointmentTimeSlots(timeSlots); + const morning = sortTimeSlotsByStartTime(inshopSlots.filter(isMorningInshopTimeSlot)); + const afternoon = sortTimeSlotsByStartTime( + inshopSlots.filter((timeSlot) => !isMorningInshopTimeSlot(timeSlot)) + ); + + return { morning, afternoon }; +} + +export function formatInshopTimeSlotLabel(timeSlot) { + return militaryToTwelveHourTime(timeSlot.startTime); +} + +export function mapInshopTimeSlotToDisplaySlot(timeSlot) { + return { + value: timeSlot.id, + label: formatInshopTimeSlotLabel(timeSlot), + routeCodeId: timeSlot.id, + }; +} + export function getTodayDate() { return new Date(); } diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js new file mode 100644 index 000000000..a3c2f8115 --- /dev/null +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js @@ -0,0 +1,238 @@ +import { shallowMount } from "@vue/test-utils"; +import inshopSchedulingCard from "./inshop-scheduling-card"; +import { RouteCodeFlags } from "@/constants/schedule-constants"; +import { getMountOptions } from "@/helpers/unit-test-helper.js"; + +const ALL_DAY_DROPOFF_ROUTE_CODE = `03357-001820-I*21346*${RouteCodeFlags.ALL_DAY_DROP_OFF}`; +const OVERNIGHT_DROPOFF_ROUTE_CODE = `03357-001820-I*21346*${RouteCodeFlags.OVERNIGHT_DROP_OFF}`; +const MORNING_INSHOP_ROUTE_CODE = "03357-001820-I*21346*0800"; +const AFTERNOON_INSHOP_ROUTE_CODE = "03357-001820-I*21346*1300"; + +const MOCK_TIME_SLOTS = [ + { id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" }, + { id: OVERNIGHT_DROPOFF_ROUTE_CODE, startTime: "17:00", endTime: "08:00" }, + { id: MORNING_INSHOP_ROUTE_CODE, startTime: "08:00", endTime: "08:30" }, + { id: "03357-001820-I*21346*0830", startTime: "08:30", endTime: "09:00" }, + { id: "03357-001820-I*21346*0900", startTime: "09:00", endTime: "09:30" }, + { id: AFTERNOON_INSHOP_ROUTE_CODE, startTime: "13:00", endTime: "13:30" }, + { id: "03357-001820-I*21346*1400", startTime: "14:00", endTime: "14:30" }, +]; + +const MANY_MORNING_TIME_SLOTS = [ + { id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" }, + ...["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00"].map((startTime, i) => ({ + id: `03357-001820-I*21346*M${i}`, + startTime, + endTime: startTime, + })), +]; + +const MOCK_PROVIDER = { + providerNumber: "001820", + distanceInMiles: 4.5, + address: { + city: "WORTHINGTON", + state: "OH", + streetAddress: "760 DEARBORN PARK LN", + zipCode: "43085", + }, +}; + +const MOCK_CMS_CONTENT = { + InshopCardWidget: { + BodyText: "Morning:", + BodyText2: "Afternoon:", + FooterText: "View more times", + }, + DropoffQuestionWidget: { + QuestionText: "Drop off your vehicle", + Answers: [ + { + Name: "AllDayDropOff", + Text: "Drop off before 9:00 am", + SubText: + "Drop off your vehicle before 9:00 AM and pick it up between 4–5 PM. Park in any open spot and visit the shop's front desk to drop off your key.", + }, + { + Name: "OvernightDropOff", + Text: "Overnight drop off", + SubText: + "Drop off your vehicle before closing and pick it up the next business day.", + }, + ], + }, +}; + +function mountComponent(props = {}, cmsContent = {}) { + const cmsContentByWidget = { + ...MOCK_CMS_CONTENT, + ...cmsContent, + InshopCardWidget: { + ...MOCK_CMS_CONTENT.InshopCardWidget, + ...cmsContent.InshopCardWidget, + }, + DropoffQuestionWidget: { + ...MOCK_CMS_CONTENT.DropoffQuestionWidget, + ...cmsContent.DropoffQuestionWidget, + }, + }; + + const cmsMixin = { + methods: { + getCmsContent: jest.fn((widgetName, fieldName) => { + return cmsContentByWidget[widgetName]?.[fieldName] ?? ""; + }), + }, + }; + + const mountOptions = getMountOptions({ + route: { name: "scheduling" }, + mixins: [cmsMixin], + }); + + return shallowMount(inshopSchedulingCard, { + props: { + provider: MOCK_PROVIDER, + timeSlots: MOCK_TIME_SLOTS, + radioGroupName: "inshopSchedulingTimeSlot-001820", + ...props, + }, + global: mountOptions.global, + }); +} + +describe("inshop-scheduling-card.vue", () => { + test("renders city, distance, address, and grouped appointment sections", () => { + const wrapper = mountComponent(); + expect(wrapper.text()).toContain("Worthington"); + expect(wrapper.text()).toContain("4.5 mi"); + expect(wrapper.text()).toContain("760 Dearborn Park Ln, Worthington, OH 43085"); + expect(wrapper.text()).toContain("Drop off your vehicle"); + expect(wrapper.text()).toContain("Morning:"); + expect(wrapper.text()).toContain("Afternoon:"); + expect(wrapper.text()).toContain("8:00 AM"); + expect(wrapper.text()).toContain("1:00 PM"); + expect(wrapper.text()).toContain("7 appts"); + wrapper.unmount(); + }); + + test("renders only drop off options that match available time slots", () => { + const wrapper = mountComponent({ + timeSlots: [{ id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" }], + }); + expect(wrapper.text()).toContain("Drop off before 9:00 am"); + expect(wrapper.text()).not.toContain("Overnight drop off"); + expect(wrapper.text()).not.toContain("Morning:"); + expect(wrapper.text()).toContain("1 appt"); + wrapper.unmount(); + }); + + test("hides drop off section when no matching time slots are available", () => { + const wrapper = mountComponent({ timeSlots: [] }); + expect(wrapper.text()).not.toContain("Drop off your vehicle"); + expect(wrapper.text()).toContain("0 appts"); + wrapper.unmount(); + }); + + test("shows drop off SubText only when the option is selected", async () => { + const wrapper = mountComponent(); + expect(wrapper.text()).not.toContain("Park in any open spot"); + + const dropOffInput = wrapper.find(`input[value="${ALL_DAY_DROPOFF_ROUTE_CODE}"]`); + await dropOffInput.setValue(true); + await wrapper.setProps({ + modelValue: { + selectedInshopTimeSlot: { + providerNumber: "001820", + routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, + }, + }, + }); + + expect(wrapper.text()).toContain("Park in any open spot"); + wrapper.unmount(); + }); + + test("emits update:modelValue with route code when drop off is selected", async () => { + const wrapper = mountComponent(); + const dropOffInput = wrapper.find(`input[value="${ALL_DAY_DROPOFF_ROUTE_CODE}"]`); + await dropOffInput.setValue(true); + expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ + selectedInshopTimeSlot: { + providerNumber: "001820", + routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, + }, + }); + wrapper.unmount(); + }); + + test("shows view more times link only when a section has more than six slots", () => { + const wrapper = mountComponent({ timeSlots: MANY_MORNING_TIME_SLOTS }); + const viewMoreLinks = wrapper.findAll(".inshop-scheduling-card__view-more-link"); + expect(viewMoreLinks.length).toBe(1); + expect(viewMoreLinks[0].text()).toBe("View more times"); + wrapper.unmount(); + }); + + test("expands section when view more times is clicked", async () => { + const wrapper = mountComponent({ timeSlots: MANY_MORNING_TIME_SLOTS }); + expect(wrapper.findAll(".inshop-scheduling-card__slot-input").length).toBe(7); + + await wrapper.find(".inshop-scheduling-card__view-more-link").trigger("click"); + expect(wrapper.findAll(".inshop-scheduling-card__slot-input").length).toBe(8); + expect(wrapper.find(".inshop-scheduling-card__view-more-link").exists()).toBe(false); + wrapper.unmount(); + }); + + test("does not show view more times when six or fewer inshop slots are available", () => { + const wrapper = mountComponent(); + expect(wrapper.find(".inshop-scheduling-card__view-more-link").exists()).toBe(false); + wrapper.unmount(); + }); + + test("emits address-clicked when address link is clicked", async () => { + const wrapper = mountComponent(); + await wrapper.find(".inshop-scheduling-card__address-link").trigger("click"); + expect(wrapper.emitted("address-clicked")).toBeTruthy(); + wrapper.unmount(); + }); + + test("emits update:modelValue with route code when an inshop time slot is selected", async () => { + const wrapper = mountComponent(); + const morningInput = wrapper.find(`input[value="${MORNING_INSHOP_ROUTE_CODE}"]`); + await morningInput.setValue(true); + expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ + selectedInshopTimeSlot: { + providerNumber: "001820", + routeCodeId: MORNING_INSHOP_ROUTE_CODE, + }, + }); + wrapper.unmount(); + }); + + test("highlights selected time slot", async () => { + const wrapper = mountComponent({ + modelValue: { + selectedInshopTimeSlot: { + providerNumber: "001820", + routeCodeId: MORNING_INSHOP_ROUTE_CODE, + }, + }, + }); + const selectedSlot = wrapper.find(".inshop-scheduling-card__slot--selected"); + expect(selectedSlot.exists()).toBe(true); + expect(selectedSlot.text()).toContain("8:00 AM"); + wrapper.unmount(); + }); + + test("collapses and expands body when header is clicked", async () => { + const wrapper = mountComponent(); + expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false); + await wrapper.find(".inshop-scheduling-card__header").trigger("click"); + expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(true); + expect(wrapper.find(".inshop-scheduling-card__address-link").isVisible()).toBe(true); + await wrapper.find(".inshop-scheduling-card__header").trigger("click"); + expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false); + wrapper.unmount(); + }); +}); diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue new file mode 100644 index 000000000..df34adcfc --- /dev/null +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 1c2a1fd49..9c3e1adad 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -28,6 +28,16 @@ :zipCode="serviceZipCode" :showFreeFlag="showMobileFreeFlag" @zip-code-clicked="onMobileZipCodeClicked" /> + provider.providerNumber === providerNumber + ); + const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate); + return day?.timeSlots ?? []; + }, + onInshopAddressClicked(provider) { + // TODO: open Google Maps when address link functionality is implemented + console.log("onInshopAddressClicked", provider?.providerNumber); + }, onMobileZipCodeClicked() { // TODO: open service zip modal when zip edit is implemented for scheduling page }, @@ -267,6 +294,7 @@ export default { Form, datePicker, mobileSchedulingCard, + inshopSchedulingCard, loadingModal, }, }; From 725b504d67b716ce8d6dad001cafe2c3eca994b7 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Thu, 11 Jun 2026 13:56:12 -0400 Subject: [PATCH 3/3] Visual tweaks + combine selection between components --- .../img/icons/chevron-no-background.svg | 3 ++ .../inshop-scheduling-card.spec.js | 42 +++++++++++-------- .../inshop-scheduling-card.vue | 27 +++++++----- .../mobile-scheduling-card.spec.js | 37 ++++++++++------ .../mobile-scheduling-card.vue | 41 +++++++++++------- src/layouts/scheduling/scheduling.vue | 18 ++++---- 6 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 src/assets/img/icons/chevron-no-background.svg diff --git a/src/assets/img/icons/chevron-no-background.svg b/src/assets/img/icons/chevron-no-background.svg new file mode 100644 index 000000000..a5c669c0f --- /dev/null +++ b/src/assets/img/icons/chevron-no-background.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js index a3c2f8115..bfbade779 100644 --- a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js @@ -1,6 +1,6 @@ import { shallowMount } from "@vue/test-utils"; import inshopSchedulingCard from "./inshop-scheduling-card"; -import { RouteCodeFlags } from "@/constants/schedule-constants"; +import { RouteCodeFlags, AppointmentTypeStrings } from "@/constants/schedule-constants"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; const ALL_DAY_DROPOFF_ROUTE_CODE = `03357-001820-I*21346*${RouteCodeFlags.ALL_DAY_DROP_OFF}`; @@ -142,10 +142,9 @@ describe("inshop-scheduling-card.vue", () => { await dropOffInput.setValue(true); await wrapper.setProps({ modelValue: { - selectedInshopTimeSlot: { - providerNumber: "001820", - routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, - }, + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "001820", + routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, }, }); @@ -158,10 +157,9 @@ describe("inshop-scheduling-card.vue", () => { const dropOffInput = wrapper.find(`input[value="${ALL_DAY_DROPOFF_ROUTE_CODE}"]`); await dropOffInput.setValue(true); expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ - selectedInshopTimeSlot: { - providerNumber: "001820", - routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, - }, + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "001820", + routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE, }); wrapper.unmount(); }); @@ -202,10 +200,9 @@ describe("inshop-scheduling-card.vue", () => { const morningInput = wrapper.find(`input[value="${MORNING_INSHOP_ROUTE_CODE}"]`); await morningInput.setValue(true); expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({ - selectedInshopTimeSlot: { - providerNumber: "001820", - routeCodeId: MORNING_INSHOP_ROUTE_CODE, - }, + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "001820", + routeCodeId: MORNING_INSHOP_ROUTE_CODE, }); wrapper.unmount(); }); @@ -213,10 +210,9 @@ describe("inshop-scheduling-card.vue", () => { test("highlights selected time slot", async () => { const wrapper = mountComponent({ modelValue: { - selectedInshopTimeSlot: { - providerNumber: "001820", - routeCodeId: MORNING_INSHOP_ROUTE_CODE, - }, + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "001820", + routeCodeId: MORNING_INSHOP_ROUTE_CODE, }, }); const selectedSlot = wrapper.find(".inshop-scheduling-card__slot--selected"); @@ -225,6 +221,18 @@ describe("inshop-scheduling-card.vue", () => { wrapper.unmount(); }); + test("does not highlight selection from another provider", () => { + const wrapper = mountComponent({ + modelValue: { + appointmentType: AppointmentTypeStrings.IN_SHOP, + providerNumber: "999999", + routeCodeId: MORNING_INSHOP_ROUTE_CODE, + }, + }); + expect(wrapper.find(".inshop-scheduling-card__slot--selected").exists()).toBe(false); + wrapper.unmount(); + }); + test("collapses and expands body when header is clicked", async () => { const wrapper = mountComponent(); expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false); diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue index df34adcfc..23638d9e0 100644 --- a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue @@ -23,7 +23,7 @@ @@ -107,7 +107,7 @@