Merge pull request #3196 from Safelite/feature/CASH-2572
CASH-2571 & CASH-2572 | Scheduling page work
This commit is contained in:
commit
bda7d7ed83
12 changed files with 804 additions and 1 deletions
3
src/assets/img/icons/chevron-right-blue.svg
Normal file
3
src/assets/img/icons/chevron-right-blue.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.24658e-06 12C-6.55781e-06 14.3734 0.703782 16.6935 2.02236 18.6668C3.34094 20.6402 5.21508 22.1783 7.40779 23.0866C9.60051 23.9948 12.0133 24.2324 14.3411 23.7694C16.6688 23.3064 18.807 22.1635 20.4853 20.4853C22.1635 18.8071 23.3064 16.6689 23.7694 14.3411C24.2324 12.0133 23.9948 9.60051 23.0865 7.4078C22.1783 5.21509 20.6402 3.34094 18.6668 2.02237C16.6934 0.703788 14.3734 -2.13306e-07 12 -5.24537e-07C8.8174 -9.41884e-07 5.76515 1.26428 3.51472 3.51472C1.26428 5.76515 -5.82924e-06 8.8174 -6.24658e-06 12ZM11.28 5.8872L16.8 11.4072C16.9568 11.5646 17.0449 11.7778 17.0449 12C17.0449 12.2222 16.9568 12.4354 16.8 12.5928L11.28 18.1128C11.1228 18.2719 10.9088 18.3621 10.6851 18.3634C10.4614 18.3648 10.2463 18.2772 10.0872 18.12C9.92806 17.9628 9.83791 17.7488 9.83656 17.5251C9.83521 17.3014 9.92277 17.0863 10.08 16.9272L15.012 12L10.08 7.0728C9.92278 6.91367 9.83521 6.6986 9.83656 6.47491C9.83791 6.25122 9.92807 6.03722 10.0872 5.88C10.2463 5.72278 10.4614 5.63521 10.6851 5.63656C10.9088 5.63791 11.1228 5.72807 11.28 5.8872Z" fill="#0070D1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/img/icons/chevron-right-grey.svg
Normal file
3
src/assets/img/icons/chevron-right-grey.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.24658e-06 12C-6.55781e-06 14.3734 0.703782 16.6935 2.02236 18.6668C3.34094 20.6402 5.21508 22.1783 7.40779 23.0866C9.60051 23.9948 12.0133 24.2324 14.3411 23.7694C16.6688 23.3064 18.807 22.1635 20.4853 20.4853C22.1635 18.8071 23.3064 16.6689 23.7694 14.3411C24.2324 12.0133 23.9948 9.60051 23.0865 7.4078C22.1783 5.21509 20.6402 3.34094 18.6668 2.02237C16.6934 0.703788 14.3734 -2.13306e-07 12 -5.24537e-07C8.8174 -9.41884e-07 5.76515 1.26428 3.51472 3.51472C1.26428 5.76515 -5.82924e-06 8.8174 -6.24658e-06 12ZM11.28 5.8872L16.8 11.4072C16.9568 11.5646 17.0449 11.7778 17.0449 12C17.0449 12.2222 16.9568 12.4354 16.8 12.5928L11.28 18.1128C11.1228 18.2719 10.9088 18.3621 10.6851 18.3634C10.4614 18.3648 10.2463 18.2772 10.0872 18.12C9.92806 17.9628 9.83791 17.7488 9.83656 17.5251C9.83521 17.3014 9.92277 17.0863 10.08 16.9272L15.012 12L10.08 7.0728C9.92278 6.91367 9.83521 6.6986 9.83656 6.47491C9.83791 6.25122 9.92807 6.03722 10.0872 5.88C10.2463 5.72278 10.4614 5.63521 10.6851 5.63656C10.9088 5.63791 11.1228 5.72807 11.28 5.8872Z" fill="#B3B4B5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
5
src/constants/breakpoints.js
Normal file
5
src/constants/breakpoints.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const Breakpoints = {
|
||||
MD: 768, // This value is to match the md breakpoint in src/styles/ux-variables.scss
|
||||
};
|
||||
|
||||
export { Breakpoints };
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ export const pageProgressMapper = {
|
|||
percent: 76,
|
||||
step: 3,
|
||||
},
|
||||
scheduling: {
|
||||
percent: 76,
|
||||
step: 3,
|
||||
},
|
||||
"mobile-details": {
|
||||
percent: 76,
|
||||
step: 3,
|
||||
|
|
|
|||
225
src/layouts/scheduling/date-picker/date-picker.spec.js
Normal file
225
src/layouts/scheduling/date-picker/date-picker.spec.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
295
src/layouts/scheduling/date-picker/date-picker.vue
Normal file
295
src/layouts/scheduling/date-picker/date-picker.vue
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
<template>
|
||||
<div class="date-picker d-flex align-items-center">
|
||||
<button
|
||||
class="date-picker__nav-btn"
|
||||
:disabled="!canGoBack"
|
||||
aria-label="Previous dates"
|
||||
@click="goBack">
|
||||
<img
|
||||
v-if="canGoBack"
|
||||
src="@/assets/img/icons/chevron-right-blue.svg"
|
||||
class="date-picker__nav-icon date-picker__nav-icon--flipped"
|
||||
alt="" />
|
||||
<img
|
||||
v-else
|
||||
src="@/assets/img/icons/chevron-right-grey.svg"
|
||||
class="date-picker__nav-icon date-picker__nav-icon--flipped"
|
||||
alt="" />
|
||||
</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">
|
||||
<img
|
||||
v-if="canGoForward"
|
||||
src="@/assets/img/icons/chevron-right-blue.svg"
|
||||
class="date-picker__nav-icon"
|
||||
alt="" />
|
||||
<img
|
||||
v-else
|
||||
src="@/assets/img/icons/chevron-right-grey.svg"
|
||||
class="date-picker__nav-icon"
|
||||
alt="" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Breakpoints } from "@/constants/breakpoints";
|
||||
|
||||
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 = Breakpoints.MD;
|
||||
|
||||
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:
|
||||
window.innerWidth >= DESKTOP_BREAKPOINT_PX
|
||||
? DESKTOP_WINDOW_SIZE
|
||||
: MOBILE_WINDOW_SIZE,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allDates() {
|
||||
const availableSet = new Set(this.availableDates);
|
||||
|
||||
if (!this.availableDates.length || !this.startDate || !this.endDate) return [];
|
||||
|
||||
const first = parseLocalDate(this.startDate);
|
||||
const last = parseLocalDate(this.endDate);
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateWindowSize();
|
||||
window.addEventListener("resize", this.updateWindowSize);
|
||||
if (this.modelValue && this.allDates.length) {
|
||||
const index = this.allDates.findIndex((d) => d.value === this.modelValue);
|
||||
if (index !== -1) {
|
||||
this.windowStart = Math.floor(index / 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;
|
||||
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 {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
&__track {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__day-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid $blue;
|
||||
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) {
|
||||
background: $blue-100;
|
||||
}
|
||||
|
||||
&--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: $blue;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__day-date {
|
||||
font-family: $font-family-sans-serif-bold;
|
||||
font-size: $font-size-14;
|
||||
color: $blue;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__nav-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&--flipped {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
223
src/layouts/scheduling/scheduling.vue
Normal file
223
src/layouts/scheduling/scheduling.vue
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<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">
|
||||
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
|
||||
<h5 class="fw-normal mb-0 dark-header" :class="headerColor">
|
||||
<span>
|
||||
{{ serviceLocationText }}
|
||||
</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"
|
||||
@back-clicked="backButtonAction"
|
||||
@ForwardClicked="forwardButtonAction" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
||||
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
||||
import navbar from "@/fmg-components/nav-bar/nav-bar";
|
||||
import { Form } from "vee-validate";
|
||||
import datePicker from "@/layouts/scheduling/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";
|
||||
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
|
||||
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
||||
import {
|
||||
flushPagePrereqsLogs,
|
||||
hasServiceZipInfo,
|
||||
hasGlassPartsOrRepairInfo,
|
||||
hasInsuranceInfo,
|
||||
} from "@/helpers/page-prerequisites-helper.js";
|
||||
import { debugLog } from "@/helpers/debug-log-helper";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @param {Date} base - The starting date (defaults to today).
|
||||
*/
|
||||
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")}`;
|
||||
}
|
||||
|
||||
const SCHEDULE_FETCH_DAYS = 15;
|
||||
|
||||
export default {
|
||||
name: "scheduling",
|
||||
async beforeRouteEnter(to, from, next) {
|
||||
const serviceZipCode = store.getters.order.serviceLocation.zipCode;
|
||||
|
||||
const shopProviderData = await store.dispatch("getProviders", {
|
||||
payload: { serviceZipCode },
|
||||
pageNameToLog: to.name,
|
||||
});
|
||||
// Get the first 3 providers from the shopProviderData
|
||||
const providers = shopProviderData?.data?.shopProviders?.slice(0, 3) ?? [];
|
||||
const mobileProviderNumber = shopProviderData?.data?.mobileProviderNumber ?? null;
|
||||
|
||||
const startDate = toDateString(0);
|
||||
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
|
||||
|
||||
const promiseResultMap = [
|
||||
{
|
||||
resultKey: "cmsContent",
|
||||
promise: fetchCmsContentForPage(to.name),
|
||||
},
|
||||
...providers.map((provider, i) => ({
|
||||
resultKey: `inshopTimeSlots_${i}`,
|
||||
promise: store.dispatch("getShopTimeSlots", {
|
||||
payload: {
|
||||
startDate,
|
||||
endDate,
|
||||
shopAppointmentType: "InshopOrDropoff",
|
||||
providerNumber: provider.providerNumber,
|
||||
},
|
||||
pageNameToLog: to.name,
|
||||
}),
|
||||
})),
|
||||
// Get the mobile time slots if a mobile provider number is available
|
||||
...(mobileProviderNumber
|
||||
? [
|
||||
{
|
||||
resultKey: "mobileTimeSlots",
|
||||
promise: store.dispatch("getMobileTimeSlots", {
|
||||
payload: { startDate, endDate, zipCode: serviceZipCode },
|
||||
pageNameToLog: to.name,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const resultMap = await settleAllPromises(promiseResultMap);
|
||||
next((vm) => {
|
||||
vm.setCmsContent(resultMap.cmsContent);
|
||||
vm.inShopProvidersAndTimeslots = providers.map((provider, i) => ({
|
||||
provider,
|
||||
timeSlots: resultMap[`inshopTimeSlots_${i}`] ?? null,
|
||||
}));
|
||||
vm.mobileProviderAndTimeSlot = mobileProviderNumber
|
||||
? {
|
||||
providerNumber: mobileProviderNumber,
|
||||
timeSlots: resultMap.mobileTimeSlots ?? null,
|
||||
}
|
||||
: null;
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
serviceLocationText() {
|
||||
return this.getCmsContent("ServiceLocationText", "Text");
|
||||
},
|
||||
availableDates() {
|
||||
const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) =>
|
||||
(timeSlots?.days ?? []).map((d) => d.date)
|
||||
);
|
||||
const mobileDates = (this.mobileProviderAndTimeSlot?.timeSlots?.days ?? []).map(
|
||||
(d) => d.date
|
||||
);
|
||||
return [...new Set([...inshopDates, ...mobileDates])].sort();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedDate: null,
|
||||
isLoadingDates: false,
|
||||
datePickerStartDate: toDateString(0),
|
||||
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
|
||||
inShopProvidersAndTimeslots: [],
|
||||
mobileProviderAndTimeSlot: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
arePagePrerequisitesValid() {
|
||||
const order = store.getters.order;
|
||||
const logQueue = [];
|
||||
|
||||
const serviceZip = hasServiceZipInfo(order, logQueue);
|
||||
const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
|
||||
const insuranceInfo = hasInsuranceInfo(order, logQueue);
|
||||
|
||||
const preReqResult = serviceZip && insuranceInfo && glassPartsOrRepair;
|
||||
|
||||
return preReqResult;
|
||||
},
|
||||
backButtonAction() {
|
||||
const payment = store.getters.order.payment;
|
||||
if (
|
||||
payment?.isInsurance &&
|
||||
(payment?.insuranceCoverage?.isVerified ||
|
||||
store.getters.order.referralNumber.length === 6)
|
||||
) {
|
||||
navigateToHeritageFunnel({
|
||||
shouldSaveSession: false,
|
||||
pageNameToLog: this.pageName,
|
||||
navType: "back",
|
||||
});
|
||||
} else {
|
||||
this.$router.navigateWithoutSaving(
|
||||
this.navigationScenarios.CLICKED_BACK,
|
||||
this.pageName
|
||||
);
|
||||
}
|
||||
},
|
||||
async handleRequestMoreDates() {
|
||||
this.isLoadingDates = true;
|
||||
this.$refs.loadingModal.showModal();
|
||||
|
||||
// TODO: call getShopTimeSlots with the next date range and append results
|
||||
// to this.availableDates and advance this.datePickerEndDate
|
||||
console.log("handleRequestMoreDates");
|
||||
|
||||
this.isLoadingDates = false;
|
||||
this.$refs.loadingModal.hideModal();
|
||||
},
|
||||
forwardButtonAction() {
|
||||
const appointmentType = store.getters.order.serviceLocation.appointmentType; // TODO: remove this once we have a proper appointment type
|
||||
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
||||
this.$router.navigateWithSaving(
|
||||
this.navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
|
||||
this.pageName
|
||||
);
|
||||
} else {
|
||||
this.$router.navigateWithSaving(
|
||||
this.navigationScenarios.CLICKED_FORWARD,
|
||||
this.pageName
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
funnelHeader,
|
||||
funnelSubHeader,
|
||||
navbar,
|
||||
Form,
|
||||
datePicker,
|
||||
loadingModal,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.dark-header {
|
||||
color: $black;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -109,6 +109,10 @@ export const routeData = {
|
|||
name: "schedule",
|
||||
path: "/schedule",
|
||||
},
|
||||
SCHEDULING: {
|
||||
name: "scheduling",
|
||||
path: "/scheduling",
|
||||
},
|
||||
MOBILE_DETAILS: {
|
||||
name: "mobile-details",
|
||||
path: "/mobile-details",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
17
src/router/methods/route-logic/schedule.js
Normal file
17
src/router/methods/route-logic/schedule.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue