Merge branch 'feature/CASH-2572' into feature/schedule-page-v2

This commit is contained in:
scottkiener-at-safelite 2026-04-30 12:08:41 -04:00
commit 5509b35a39
3 changed files with 542 additions and 0 deletions

View file

@ -1,5 +1,6 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<loadingModal notFullScreen ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<div class="container page-container-grouped-styles">
<div class="row">
@ -10,6 +11,14 @@
</span>
</h5>
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" alignLeft />
<datePicker
class="mt-5"
v-model="selectedDate"
:startDate="datePickerStartDate"
:endDate="datePickerEndDate"
:availableDates="availableDates"
:isLoadingDates="isLoadingDates"
@requestMoreDates="handleRequestMoreDates" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
@ -25,6 +34,8 @@
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import { Form } from "vee-validate";
import datePicker from "@/ux-components/date-picker/date-picker";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import store from "@/store";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
@ -38,6 +49,16 @@ import {
} from "@/helpers/page-prerequisites-helper.js";
import { debugLog } from "@/helpers/debug-log-helper";
// Mock scheduling helpers replace with real API data when integrating
// Offsets of available days within any 15-day block (gaps on days 2, 6, 7, 10, 13)
const MOCK_AVAILABLE_OFFSETS = [0, 1, 3, 4, 5, 8, 9, 11, 12, 14];
function toDateString(offsetDays, base = new Date()) {
const d = new Date(base);
d.setDate(d.getDate() + offsetDays);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export default {
name: "scheduling",
async beforeRouteEnter(to, from, next) {
@ -57,6 +78,15 @@ export default {
return this.getCmsContent("ServiceLocationText", "Text");
},
},
data() {
return {
selectedDate: null,
isLoadingDates: false,
datePickerStartDate: toDateString(0),
datePickerEndDate: toDateString(14),
availableDates: MOCK_AVAILABLE_OFFSETS.map((n) => toDateString(n)),
};
},
methods: {
arePagePrerequisitesValid() {
const order = store.getters.order;
@ -104,6 +134,22 @@ export default {
);
}
},
async handleRequestMoreDates() {
this.isLoadingDates = true;
this.$refs.loadingModal.showModal();
await new Promise((resolve) => setTimeout(resolve, 1000));
const currentEnd = new Date(
...this.datePickerEndDate.split("-").map((n, i) => (i === 1 ? Number(n) - 1 : Number(n)))
);
const newAvailable = MOCK_AVAILABLE_OFFSETS.map((n) => toDateString(n + 1, currentEnd));
this.availableDates = [...this.availableDates, ...newAvailable];
this.datePickerEndDate = toDateString(15, currentEnd);
this.isLoadingDates = false;
this.$refs.loadingModal.hideModal();
},
forwardButtonAction() {
const appointmentType = store.getters.order.serviceLocation.appointmentType;
if (appointmentType === AppointmentTypeStrings.MOBILE) {
@ -123,6 +169,8 @@ export default {
funnelHeader,
navbar,
Form,
datePicker,
loadingModal,
},
};
</script>

View file

@ -0,0 +1,224 @@
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, modelValue: null, ...props },
});
}
function mountMobile(props = {}) {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 375,
});
return shallowMount(datePicker, {
props: { availableDates: AVAILABLE_DATES, 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 2125
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();
});
test("forward navigation button is disabled when all dates fit in window", () => {
// Only 2 available dates → range is 2 days, window size 5 → no forward
const wrapper = mountDesktop({ availableDates: ["2026-01-21", "2026-01-22"] });
const forwardBtn = wrapper.findAll(".date-picker__nav-btn")[1];
expect(forwardBtn.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 2125 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 });
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 });
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 2125 = 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 });
await wrapper.vm.goForward();
await wrapper.vm.$nextTick();
const backBtn = wrapper.findAll(".date-picker__nav-btn")[0];
expect(backBtn.attributes("disabled")).toBeUndefined();
wrapper.unmount();
});
test("resets windowStart to 0 when availableDates prop changes", async () => {
const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"];
const wrapper = mountDesktop({ availableDates: manyDates });
await wrapper.vm.goForward();
expect(wrapper.vm.windowStart).toBeGreaterThan(0);
await wrapper.setProps({ availableDates: ["2026-02-01", "2026-02-02"] });
expect(wrapper.vm.windowStart).toBe(0);
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();
});
});
});

View file

@ -0,0 +1,270 @@
<template>
<div class="date-picker d-flex align-items-center">
<button
class="date-picker__nav-btn"
:disabled="!canGoBack"
aria-label="Previous dates"
@click="goBack">
</button>
<div class="date-picker__track d-flex flex-grow-1">
<button
v-for="date in visibleDates"
:key="date.value"
class="date-picker__day-card d-flex flex-column align-items-center justify-content-center"
:class="{
'date-picker__day-card--selected': isSelected(date.value),
'date-picker__day-card--disabled': !date.isAvailable,
}"
:disabled="!date.isAvailable"
:aria-pressed="isSelected(date.value)"
:aria-label="`${date.dayAbbr} ${date.monthAbbr} ${date.day}`"
@click="selectDate(date)">
<span class="date-picker__day-abbr">{{ date.dayAbbr }}</span>
<span class="date-picker__day-date">{{ date.monthAbbr }} {{ date.day }}</span>
</button>
</div>
<button
class="date-picker__nav-btn"
:disabled="!canGoForward"
aria-label="Next dates"
@click="goForward">
</button>
</div>
</template>
<script>
const DAY_ABBRS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const MONTH_ABBRS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MOBILE_WINDOW_SIZE = 3;
const DESKTOP_WINDOW_SIZE = 5;
const DESKTOP_BREAKPOINT_PX = 768;
function toLocalDateString(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function parseLocalDate(str) {
const [year, month, day] = str.split("-").map(Number);
return new Date(year, month - 1, day);
}
export default {
name: "datePicker",
props: {
availableDates: {
type: Array,
default: () => [],
},
startDate: {
type: String,
default: null,
},
endDate: {
type: String,
default: null,
},
modelValue: {
type: String,
default: null,
},
isLoadingDates: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue", "requestMoreDates"],
data() {
return {
windowStart: 0,
windowSize: DESKTOP_WINDOW_SIZE,
};
},
computed: {
allDates() {
const availableSet = new Set(this.availableDates);
const hasBounds = this.startDate && this.endDate;
const hasAvailable = this.availableDates.length > 0;
if (!hasBounds && !hasAvailable) return [];
let first, last;
if (hasBounds) {
first = parseLocalDate(this.startDate);
last = parseLocalDate(this.endDate);
} else {
const sorted = [...this.availableDates].sort();
first = parseLocalDate(sorted[0]);
last = parseLocalDate(sorted[sorted.length - 1]);
}
const dates = [];
const cursor = new Date(first);
while (cursor <= last) {
const value = toLocalDateString(cursor);
dates.push({
value,
isAvailable: availableSet.has(value),
dayAbbr: DAY_ABBRS[cursor.getDay()],
monthAbbr: MONTH_ABBRS[cursor.getMonth()],
day: cursor.getDate(),
});
cursor.setDate(cursor.getDate() + 1);
}
return dates;
},
visibleDates() {
return this.allDates.slice(this.windowStart, this.windowStart + this.windowSize);
},
canGoBack() {
return this.windowStart > 0;
},
canGoForward() {
return !this.isLoadingDates && this.allDates.length > 0;
},
},
watch: {
// Only reset position when startDate changes that signals a fresh range.
// Extending endDate/availableDates (loading more) keeps the current window.
startDate() {
this.windowStart = 0;
},
},
mounted() {
this.updateWindowSize();
window.addEventListener("resize", this.updateWindowSize);
if (this.modelValue && this.allDates.length) {
const idx = this.allDates.findIndex((d) => d.value === this.modelValue);
if (idx !== -1) {
this.windowStart = Math.floor(idx / this.windowSize) * this.windowSize;
}
}
},
beforeUnmount() {
window.removeEventListener("resize", this.updateWindowSize);
},
methods: {
updateWindowSize() {
this.windowSize =
window.innerWidth >= DESKTOP_BREAKPOINT_PX
? DESKTOP_WINDOW_SIZE
: MOBILE_WINDOW_SIZE;
},
goBack() {
this.windowStart = Math.max(0, this.windowStart - this.windowSize);
},
goForward() {
if (this.isLoadingDates) return;
const isAtEnd = this.windowStart + this.windowSize >= this.allDates.length;
if (isAtEnd) {
this.$emit("requestMoreDates");
} else {
const maxStart = Math.max(0, this.allDates.length - this.windowSize);
this.windowStart = Math.min(maxStart, this.windowStart + this.windowSize);
}
},
selectDate(date) {
if (!date.isAvailable) return;
this.$emit("update:modelValue", date.value);
},
isSelected(value) {
return this.modelValue === value;
},
},
};
</script>
<style lang="scss">
.date-picker {
gap: 0.25rem;
&__nav-btn {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
font-size: 2rem;
line-height: 1;
color: $blue;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50%;
transition: background 150ms ease;
&:hover:not(:disabled) {
background: $blue-100;
}
&:disabled {
color: $gray-300;
cursor: default;
}
}
&__track {
gap: 0.5rem;
}
&__day-card {
flex: 1;
min-width: 0;
border: 1px solid $gray-200;
border-radius: $border-radius-lg;
background: $white;
padding: 0.625rem 0.25rem;
cursor: pointer;
transition:
background 150ms ease,
border-color 150ms ease;
&:hover:not(:disabled):not(.date-picker__day-card--selected) {
border-color: $blue;
}
&--selected {
background: $blue;
border-color: $blue;
.date-picker__day-abbr,
.date-picker__day-date {
color: $white;
}
}
&--disabled {
background: $gray-100;
border-color: $gray-200;
cursor: default;
.date-picker__day-abbr,
.date-picker__day-date {
color: $gray-300;
}
}
}
&__day-abbr {
font-family: $font-family-sans-serif-semibold;
font-size: $font-size-12;
color: $gray-600;
text-transform: uppercase;
line-height: 1.2;
}
&__day-date {
font-family: $font-family-sans-serif-bold;
font-size: $font-size-14;
color: $gray-700;
line-height: 1.4;
white-space: nowrap;
}
}
</style>