diff --git a/src/assets/img/icons/chevron-right-blue.svg b/src/assets/img/icons/chevron-right-blue.svg new file mode 100644 index 000000000..d7f2b9308 --- /dev/null +++ b/src/assets/img/icons/chevron-right-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/icons/chevron-right-grey.svg b/src/assets/img/icons/chevron-right-grey.svg new file mode 100644 index 000000000..f77c94b2d --- /dev/null +++ b/src/assets/img/icons/chevron-right-grey.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/constants/breakpoints.js b/src/constants/breakpoints.js new file mode 100644 index 000000000..f69aaff40 --- /dev/null +++ b/src/constants/breakpoints.js @@ -0,0 +1,5 @@ +const Breakpoints = { + MD: 768, // This value is to match the md breakpoint in src/styles/ux-variables.scss +}; + +export { Breakpoints }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 26c7b07ad..b470cc425 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -47,6 +47,7 @@ const experimentSettings = { USE_ADYEN_PAYMENT: "UseAdyenPayment", SHOW_UNVERIFIED_DEDUCT_ENTRY: "ShowUnverifiedDeducEntry", SERVICE_PACKAGE_ORDER: "ServicePackageOrder", + USE_SCHEDULING_PAGE: "UseSchedulingPage", // Claim and Coverage for 2.0 ENABLE_NON_CLAIM_AND_COVERAGE_FLOW: "EnableNonC&CFlow", diff --git a/src/constants/progress-bar-mapper.js b/src/constants/progress-bar-mapper.js index 3293c067c..214219aa6 100644 --- a/src/constants/progress-bar-mapper.js +++ b/src/constants/progress-bar-mapper.js @@ -104,6 +104,10 @@ export const pageProgressMapper = { percent: 76, step: 3, }, + scheduling: { + percent: 76, + step: 3, + }, "mobile-details": { percent: 76, step: 3, diff --git a/src/layouts/scheduling/date-picker/date-picker.spec.js b/src/layouts/scheduling/date-picker/date-picker.spec.js new file mode 100644 index 000000000..f64d27a12 --- /dev/null +++ b/src/layouts/scheduling/date-picker/date-picker.spec.js @@ -0,0 +1,225 @@ +import { shallowMount } from "@vue/test-utils"; +import datePicker from "./date-picker"; + +const AVAILABLE_DATES = ["2026-01-21", "2026-01-22", "2026-01-23", "2026-01-25"]; + +function mountDesktop(props = {}) { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + return shallowMount(datePicker, { + props: { + availableDates: AVAILABLE_DATES, + startDate: "2026-01-21", + endDate: "2026-01-25", + modelValue: null, + ...props, + }, + }); +} + +function mountMobile(props = {}) { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 375, + }); + return shallowMount(datePicker, { + props: { + availableDates: AVAILABLE_DATES, + startDate: "2026-01-21", + endDate: "2026-01-25", + modelValue: null, + ...props, + }, + }); +} + +describe("date-picker.vue", () => { + afterEach(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + }); + + describe("rendering", () => { + test("renders 5 date cards on desktop", () => { + const wrapper = mountDesktop(); + expect(wrapper.findAll(".date-picker__day-card").length).toBe(5); + wrapper.unmount(); + }); + + test("renders 3 date cards on mobile", () => { + const wrapper = mountMobile(); + expect(wrapper.findAll(".date-picker__day-card").length).toBe(3); + wrapper.unmount(); + }); + + test("renders disabled cards for dates not in availableDates", () => { + const wrapper = mountDesktop(); + // Jan 24 is not in AVAILABLE_DATES but falls in the range Jan 21–25 + const disabledCards = wrapper.findAll(".date-picker__day-card--disabled"); + expect(disabledCards.length).toBeGreaterThan(0); + wrapper.unmount(); + }); + + test("back navigation button is disabled at the start", () => { + const wrapper = mountDesktop(); + const backBtn = wrapper.findAll(".date-picker__nav-btn")[0]; + expect(backBtn.attributes("disabled")).toBeDefined(); + wrapper.unmount(); + }); + }); + + describe("allDates computation", () => { + test("builds a contiguous date range between first and last available date", () => { + const wrapper = mountDesktop(); + const allValues = wrapper.vm.allDates.map((d) => d.value); + // Range Jan 21–25 should include all 5 dates + expect(allValues).toEqual([ + "2026-01-21", + "2026-01-22", + "2026-01-23", + "2026-01-24", + "2026-01-25", + ]); + wrapper.unmount(); + }); + + test("marks dates not in availableDates as unavailable", () => { + const wrapper = mountDesktop(); + const jan24 = wrapper.vm.allDates.find((d) => d.value === "2026-01-24"); + expect(jan24.isAvailable).toBe(false); + wrapper.unmount(); + }); + + test("marks dates in availableDates as available", () => { + const wrapper = mountDesktop(); + const jan21 = wrapper.vm.allDates.find((d) => d.value === "2026-01-21"); + expect(jan21.isAvailable).toBe(true); + wrapper.unmount(); + }); + + test("attaches correct day abbreviation to each date", () => { + const wrapper = mountDesktop(); + const jan21 = wrapper.vm.allDates.find((d) => d.value === "2026-01-21"); + // 2026-01-21 is a Wednesday + expect(jan21.dayAbbr).toBe("WED"); + wrapper.unmount(); + }); + + test("returns empty array when availableDates is empty", () => { + const wrapper = mountDesktop({ availableDates: [] }); + expect(wrapper.vm.allDates).toEqual([]); + wrapper.unmount(); + }); + }); + + describe("date selection", () => { + test("emits update:modelValue with the date string when an available card is clicked", async () => { + const wrapper = mountDesktop(); + const availableCard = wrapper.find( + ".date-picker__day-card:not(.date-picker__day-card--disabled)" + ); + await availableCard.trigger("click"); + expect(wrapper.emitted("update:modelValue")).toBeTruthy(); + expect(wrapper.emitted("update:modelValue")[0][0]).toBe("2026-01-21"); + wrapper.unmount(); + }); + + test("does not emit when a disabled card is clicked", async () => { + const wrapper = mountDesktop(); + const disabledCard = wrapper.find(".date-picker__day-card--disabled"); + await disabledCard.trigger("click"); + expect(wrapper.emitted("update:modelValue")).toBeFalsy(); + wrapper.unmount(); + }); + + test("applies the selected class to the card matching modelValue", async () => { + const wrapper = mountDesktop({ modelValue: "2026-01-21" }); + const selectedCard = wrapper.find(".date-picker__day-card--selected"); + expect(selectedCard.exists()).toBeTruthy(); + wrapper.unmount(); + }); + + test("no card has the selected class when modelValue is null", () => { + const wrapper = mountDesktop({ modelValue: null }); + expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(false); + wrapper.unmount(); + }); + }); + + describe("navigation", () => { + test("goForward advances windowStart by the window size", async () => { + // Use a longer range so forward is possible on desktop (window 5) + const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"]; + const wrapper = mountDesktop({ + availableDates: manyDates, + startDate: "2026-01-01", + endDate: "2026-01-10", + }); + expect(wrapper.vm.canGoForward).toBe(true); + await wrapper.vm.goForward(); + expect(wrapper.vm.windowStart).toBe(5); + wrapper.unmount(); + }); + + test("goBack decrements windowStart by the window size", async () => { + const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"]; + const wrapper = mountDesktop({ + availableDates: manyDates, + startDate: "2026-01-01", + endDate: "2026-01-10", + }); + await wrapper.vm.goForward(); + await wrapper.vm.goBack(); + expect(wrapper.vm.windowStart).toBe(0); + wrapper.unmount(); + }); + + test("goBack does not go below 0", () => { + const wrapper = mountDesktop(); + wrapper.vm.goBack(); + expect(wrapper.vm.windowStart).toBe(0); + wrapper.unmount(); + }); + + test("goForward does not exceed the last window position", async () => { + // Range Jan 21–25 = 5 dates, window 5 on desktop → already at max + const wrapper = mountDesktop(); + await wrapper.vm.goForward(); + // windowStart should not push visible window past the array length + expect(wrapper.vm.windowStart + wrapper.vm.windowSize).toBeLessThanOrEqual( + wrapper.vm.allDates.length + wrapper.vm.windowSize + ); + wrapper.unmount(); + }); + + test("back button becomes enabled after navigating forward", async () => { + const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"]; + const wrapper = mountDesktop({ + availableDates: manyDates, + startDate: "2026-01-01", + endDate: "2026-01-10", + }); + await wrapper.vm.goForward(); + await wrapper.vm.$nextTick(); + const backBtn = wrapper.findAll(".date-picker__nav-btn")[0]; + expect(backBtn.attributes("disabled")).toBeUndefined(); + wrapper.unmount(); + }); + }); + + describe("initial window positioning", () => { + test("scrolls to the window containing the pre-selected date on mount", () => { + // Jan 25 is the 5th date (index 4); with window size 5 on desktop it's still in window 0 + const wrapper = mountDesktop({ modelValue: "2026-01-25" }); + expect(wrapper.vm.windowStart).toBe(0); + wrapper.unmount(); + }); + }); +}); diff --git a/src/layouts/scheduling/date-picker/date-picker.vue b/src/layouts/scheduling/date-picker/date-picker.vue new file mode 100644 index 000000000..6b3fb7638 --- /dev/null +++ b/src/layouts/scheduling/date-picker/date-picker.vue @@ -0,0 +1,295 @@ + + + + + + + + + {{ date.dayAbbr }} + {{ date.monthAbbr }} {{ date.day }} + + + + + + + + + + + + diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue new file mode 100644 index 000000000..da6dcf61b --- /dev/null +++ b/src/layouts/scheduling/scheduling.vue @@ -0,0 +1,223 @@ + + + + + + + + + + {{ serviceLocationText }} + + + + + + + + + + + + + diff --git a/src/router/constants/routes.js b/src/router/constants/routes.js index 479d0899d..f83f07f86 100644 --- a/src/router/constants/routes.js +++ b/src/router/constants/routes.js @@ -109,6 +109,10 @@ export const routeData = { name: "schedule", path: "/schedule", }, + SCHEDULING: { + name: "scheduling", + path: "/scheduling", + }, MOBILE_DETAILS: { name: "mobile-details", path: "/mobile-details", diff --git a/src/router/constants/routing-table.js b/src/router/constants/routing-table.js index fcd259790..ef597a06a 100644 --- a/src/router/constants/routing-table.js +++ b/src/router/constants/routing-table.js @@ -655,6 +655,27 @@ const routingTable = function () { }, ], }, + { + pageName: routeData.SCHEDULING.name, + maps: [ + { + scenario: navigationScenarios.CLICKED_BACK, + destinationPageData: routeData.QUOTE, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD, + destinationPageData: routeData.CUSTOMER_DETAILS, + }, + { + scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE, + destinationPageData: routeData.MOBILE_DETAILS, + }, + { + scenario: navigationScenarios.ZIP_CODE_CHANGED_RECAL_ACK, + destinationPageData: routeData.SERVICE_ZIP, + }, + ], + }, { pageName: routeData.MOBILE_DETAILS.name, maps: [ diff --git a/src/router/methods/route-logic/schedule.js b/src/router/methods/route-logic/schedule.js new file mode 100644 index 000000000..1ac83312f --- /dev/null +++ b/src/router/methods/route-logic/schedule.js @@ -0,0 +1,17 @@ +import experimentMixin from "@/mixins/experiment-mixin.js"; +import { experimentSettings } from "@/constants/experiments"; +import { routeData } from "@/router/constants/routes"; + +export async function scheduleBeforeEnter(to, from) { + const isSchedulingEnabled = experimentMixin.methods.hasSettingEqualTo( + experimentSettings.USE_SCHEDULING_PAGE, + "true" + ); + + if (isSchedulingEnabled) { + return { + name: routeData.SCHEDULING.name, + replace: true, + }; + } +} diff --git a/src/router/methods/routes.js b/src/router/methods/routes.js index 34056ba76..9555a583d 100644 --- a/src/router/methods/routes.js +++ b/src/router/methods/routes.js @@ -14,6 +14,7 @@ import { paymentMethodBeforeEnter } from "@/router/methods/route-logic/payment-m import { paymentBeforeEnter } from "@/router/methods/route-logic/payment"; import { serviceLocationBeforeEnter } from "@/router/methods/route-logic/service-location"; import { adyenReturnBeforeEnter } from "@/router/methods/route-logic/adyen-return"; +import { scheduleBeforeEnter } from "@/router/methods/route-logic/schedule"; export const routes = [ // Non-virtual pages. @@ -32,7 +33,8 @@ export const routes = [ createRoute(routeData.QUOTE, quoteBeforeEnter), createRoute(routeData.INSURANCE_COMPANY, insuranceCompanyBeforeEnter), createRoute(routeData.SERVICE_LOCATION, serviceLocationBeforeEnter), - createRoute(routeData.SCHEDULE), + createRoute(routeData.SCHEDULE, scheduleBeforeEnter), + createRoute(routeData.SCHEDULING), createRoute(routeData.CUSTOMER_DETAILS), createRoute(routeData.PAYMENT_METHOD, paymentMethodBeforeEnter), createRoute(routeData.PAYMENT, paymentBeforeEnter),