Draft implementation.
This commit is contained in:
parent
9ce6b3b412
commit
beb3350957
11 changed files with 561 additions and 71 deletions
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
getAvailableAppointmentDatesToShow,
|
||||
getAvailableDatesFromProviderDays,
|
||||
} from "./schedule-helper";
|
||||
|
||||
describe("getAvailableDatesFromProviderDays", () => {
|
||||
test("returns sorted dates that have at least one time slot", () => {
|
||||
const days = [
|
||||
{ date: "2026-06-16", timeSlots: [{ id: "a" }] },
|
||||
{ date: "2026-06-11", timeSlots: [] },
|
||||
{ date: "2026-06-14", timeSlots: [{ id: "b" }, { id: "c" }] },
|
||||
];
|
||||
|
||||
expect(getAvailableDatesFromProviderDays(days)).toEqual(["2026-06-14", "2026-06-16"]);
|
||||
});
|
||||
|
||||
test("returns an empty array when days is null or undefined", () => {
|
||||
expect(getAvailableDatesFromProviderDays(null)).toEqual([]);
|
||||
expect(getAvailableDatesFromProviderDays(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableAppointmentDatesToShow", () => {
|
||||
const selectedDate = "2026-06-15";
|
||||
|
||||
test("AC1: happy path", () => {
|
||||
const available = [
|
||||
"2026-06-11",
|
||||
"2026-06-12",
|
||||
"2026-06-13",
|
||||
"2026-06-14",
|
||||
"2026-06-16",
|
||||
"2026-06-17",
|
||||
"2026-06-18",
|
||||
"2026-06-19",
|
||||
"2026-06-20",
|
||||
];
|
||||
|
||||
expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([
|
||||
"2026-06-11",
|
||||
"2026-06-14",
|
||||
"2026-06-16",
|
||||
]);
|
||||
});
|
||||
|
||||
test("AC2: no previous dates available", () => {
|
||||
const available = ["2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"];
|
||||
|
||||
expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([
|
||||
"2026-06-16",
|
||||
"2026-06-17",
|
||||
"2026-06-18",
|
||||
]);
|
||||
});
|
||||
|
||||
test("AC3: no next dates available", () => {
|
||||
const available = [
|
||||
"2026-06-09",
|
||||
"2026-06-10",
|
||||
"2026-06-11",
|
||||
"2026-06-12",
|
||||
"2026-06-13",
|
||||
"2026-06-14",
|
||||
];
|
||||
|
||||
expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([
|
||||
"2026-06-09",
|
||||
"2026-06-10",
|
||||
"2026-06-14",
|
||||
]);
|
||||
});
|
||||
|
||||
test("AC4: only one previous date available and it matches earliest", () => {
|
||||
const available = [
|
||||
"2026-06-11",
|
||||
"2026-06-16",
|
||||
"2026-06-17",
|
||||
"2026-06-18",
|
||||
"2026-06-19",
|
||||
"2026-06-20",
|
||||
];
|
||||
|
||||
expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([
|
||||
"2026-06-11",
|
||||
"2026-06-16",
|
||||
"2026-06-17",
|
||||
]);
|
||||
});
|
||||
|
||||
test("AC5: no dates available", () => {
|
||||
expect(getAvailableAppointmentDatesToShow([], selectedDate)).toEqual([]);
|
||||
expect(getAvailableAppointmentDatesToShow([""], selectedDate)).toEqual([]);
|
||||
expect(getAvailableAppointmentDatesToShow([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
test("AC6: only one date available", () => {
|
||||
expect(getAvailableAppointmentDatesToShow(["2026-06-13"], selectedDate)).toEqual([
|
||||
"2026-06-13",
|
||||
]);
|
||||
});
|
||||
|
||||
test("AC7: only two dates available", () => {
|
||||
expect(
|
||||
getAvailableAppointmentDatesToShow(["2026-06-11", "2026-06-12"], selectedDate)
|
||||
).toEqual(["2026-06-11", "2026-06-12"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -257,3 +257,104 @@ export function getInitialViewWeeks(todayString, initialViewRowsToShow, preSelec
|
|||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export const SCHEDULING_DAY_ABBRS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
|
||||
export const SCHEDULING_MONTH_ABBRS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
const MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW = 3;
|
||||
|
||||
/**
|
||||
* Returns sorted YYYY-MM-DD strings for days that have at least one time slot.
|
||||
* @param {Array<{ date: string, timeSlots?: Array<unknown> }> | null | undefined} days
|
||||
*/
|
||||
export function getAvailableDatesFromProviderDays(days) {
|
||||
return (days ?? [])
|
||||
.filter((day) => day.timeSlots?.length > 0)
|
||||
.map((day) => day.date)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects up to three available appointment dates to display when the selected day has no slots.
|
||||
* Priority order: earliest, previous closest to selected, next closest to selected, then chronological.
|
||||
* @param {string[]} availableDates - Sorted or unsorted YYYY-MM-DD strings with availability.
|
||||
* @param {string} selectedDate - Currently selected YYYY-MM-DD date from the date picker.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAvailableAppointmentDatesToShow(availableDates, selectedDate) {
|
||||
const sorted = [...new Set((availableDates ?? []).filter(Boolean))].sort();
|
||||
if (sorted.length === 0 || !selectedDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const used = new Set();
|
||||
|
||||
const add = (date) => {
|
||||
if (date && sorted.includes(date) && !used.has(date)) {
|
||||
used.add(date);
|
||||
result.push(date);
|
||||
}
|
||||
};
|
||||
|
||||
const earliest = sorted[0];
|
||||
const previousClosest = sorted.filter((date) => date < selectedDate).at(-1) ?? null;
|
||||
const nextClosest = sorted.find((date) => date > selectedDate) ?? null;
|
||||
|
||||
const priorityCandidates = [earliest];
|
||||
|
||||
if (previousClosest && previousClosest !== earliest) {
|
||||
priorityCandidates.push(previousClosest);
|
||||
} else if (nextClosest && nextClosest !== earliest) {
|
||||
priorityCandidates.push(nextClosest);
|
||||
}
|
||||
|
||||
if (nextClosest) {
|
||||
priorityCandidates.push(nextClosest);
|
||||
}
|
||||
|
||||
for (const date of priorityCandidates) {
|
||||
if (result.length >= MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW) {
|
||||
break;
|
||||
}
|
||||
add(date);
|
||||
}
|
||||
|
||||
for (const date of sorted) {
|
||||
if (result.length >= MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW) {
|
||||
break;
|
||||
}
|
||||
add(date);
|
||||
}
|
||||
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateString - YYYY-MM-DD
|
||||
*/
|
||||
export function formatSchedulingDateChip(dateString) {
|
||||
const date = convertDateStringToDate(dateString);
|
||||
const dayAbbr = SCHEDULING_DAY_ABBRS[date.getDay()];
|
||||
const monthAbbr = SCHEDULING_MONTH_ABBRS[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const dayLabel = `${dayAbbr.charAt(0) + dayAbbr.slice(1).toLowerCase()}.`;
|
||||
|
||||
return {
|
||||
value: dateString,
|
||||
label: `${dayLabel} ${monthAbbr} ${day}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { shallowMount } from "@vue/test-utils";
|
||||
import availableAppointmentDateChips from "./available-appointment-date-chips.vue";
|
||||
|
||||
describe("available-appointment-date-chips.vue", () => {
|
||||
test("renders a button for each date", () => {
|
||||
const wrapper = shallowMount(availableAppointmentDateChips, {
|
||||
props: {
|
||||
dates: ["2026-06-11", "2026-06-16"],
|
||||
label: "Available appointments:",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Available appointments:");
|
||||
expect(wrapper.findAll(".available-appointment-date-chips__day-card").length).toBe(2);
|
||||
expect(wrapper.text()).toContain("Thu. Jun 11");
|
||||
expect(wrapper.text()).toContain("Tue. Jun 16");
|
||||
expect(wrapper.findAll(".available-appointment-date-chips__day-label").length).toBe(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits date-selected when a chip is clicked", async () => {
|
||||
const wrapper = shallowMount(availableAppointmentDateChips, {
|
||||
props: {
|
||||
dates: ["2026-06-11"],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".available-appointment-date-chips__day-card").trigger("click");
|
||||
expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="available-appointment-date-chips">
|
||||
<p v-if="label" class="available-appointment-date-chips__label">{{ label }}</p>
|
||||
<div class="available-appointment-date-chips__track">
|
||||
<button
|
||||
v-for="date in formattedDates"
|
||||
:key="date.value"
|
||||
type="button"
|
||||
class="available-appointment-date-chips__day-card d-flex align-items-center justify-content-center"
|
||||
:aria-label="date.label"
|
||||
@click="$emit('date-selected', date.value)">
|
||||
<span class="available-appointment-date-chips__day-label">{{ date.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatSchedulingDateChip } from "@/layouts/schedule/helpers/schedule-helper";
|
||||
|
||||
export default {
|
||||
name: "availableAppointmentDateChips",
|
||||
emits: ["date-selected"],
|
||||
props: {
|
||||
dates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
formattedDates() {
|
||||
return this.dates.map(formatSchedulingDateChip);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.available-appointment-date-chips {
|
||||
&__label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
&__track {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__day-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid $blue;
|
||||
border-radius: $border-radius-lg;
|
||||
background: $white;
|
||||
padding: 0.375rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: $blue-100;
|
||||
}
|
||||
}
|
||||
|
||||
&__day-label {
|
||||
font-family: $font-family-sans-serif-bold;
|
||||
font-size: $font-size-14;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: 0.03em;
|
||||
color: $blue;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -154,6 +154,30 @@ describe("date-picker.vue", () => {
|
|||
expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("scrolls visible window and updates selection when modelValue changes externally", async () => {
|
||||
const wrapper = mountDesktop({
|
||||
availableDates: [
|
||||
"2026-01-21",
|
||||
"2026-01-22",
|
||||
"2026-01-23",
|
||||
"2026-01-25",
|
||||
"2026-01-28",
|
||||
"2026-01-30",
|
||||
],
|
||||
startDate: "2026-01-21",
|
||||
endDate: "2026-01-30",
|
||||
modelValue: "2026-01-21",
|
||||
});
|
||||
|
||||
expect(wrapper.vm.windowStart).toBe(0);
|
||||
|
||||
await wrapper.setProps({ modelValue: "2026-01-28" });
|
||||
|
||||
expect(wrapper.vm.windowStart).toBe(5);
|
||||
expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,10 @@ export default {
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue, oldValue) {
|
||||
if (newValue === oldValue) return;
|
||||
this.syncWindowToSelectedDate();
|
||||
},
|
||||
allDates(newDates) {
|
||||
if (!newDates.length) return;
|
||||
|
||||
|
|
@ -182,7 +186,9 @@ export default {
|
|||
if (this.pendingAutoSelect) {
|
||||
this.pendingAutoSelect = false;
|
||||
this.advanceWindowAndAutoSelect();
|
||||
return;
|
||||
}
|
||||
this.syncWindowToSelectedDate();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -192,8 +198,7 @@ export default {
|
|||
// If a model value is set, we need to set the window start to the index of the model value.
|
||||
// Otherwise, we need to initialize the window.
|
||||
if (this.modelValue) {
|
||||
const index = this.allDates.findIndex((d) => d.value === this.modelValue);
|
||||
if (index !== -1) this.windowStart = this.getWindowStartFromIndex(index);
|
||||
this.syncWindowToSelectedDate();
|
||||
} else {
|
||||
this.initialize();
|
||||
}
|
||||
|
|
@ -214,6 +219,14 @@ export default {
|
|||
getWindowStartFromIndex(index) {
|
||||
return Math.floor(index / this.windowSize) * this.windowSize;
|
||||
},
|
||||
syncWindowToSelectedDate() {
|
||||
if (!this.modelValue || !this.allDates.length) return;
|
||||
|
||||
const index = this.allDates.findIndex((date) => date.value === this.modelValue);
|
||||
if (index !== -1) {
|
||||
this.windowStart = this.getWindowStartFromIndex(index);
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
this.windowStart = Math.max(0, this.windowStart - this.windowSize);
|
||||
this.autoSelectFirstAvailable();
|
||||
|
|
|
|||
|
|
@ -134,6 +134,31 @@ describe("inshop-scheduling-card.vue", () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("shows available date chips when selected day has no slots and chips are provided", async () => {
|
||||
const wrapper = mountComponent({
|
||||
timeSlots: [],
|
||||
availableDateChips: ["2026-06-11", "2026-06-14", "2026-06-16"],
|
||||
});
|
||||
await wrapper.find(".inshop-scheduling-card__header").trigger("click");
|
||||
expect(wrapper.text()).toContain("Available appointments:");
|
||||
expect(wrapper.find("available-appointment-date-chips-stub").exists()).toBe(true);
|
||||
expect(wrapper.find(".inshop-scheduling-card__time-slots").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits date-selected when an available date chip is selected", async () => {
|
||||
const wrapper = mountComponent({
|
||||
timeSlots: [],
|
||||
availableDateChips: ["2026-06-11"],
|
||||
});
|
||||
await wrapper.find(".inshop-scheduling-card__header").trigger("click");
|
||||
await wrapper
|
||||
.find("available-appointment-date-chips-stub")
|
||||
.vm.$emit("date-selected", "2026-06-11");
|
||||
expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]);
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -37,73 +37,82 @@
|
|||
{{ formattedAddress }}
|
||||
</button>
|
||||
<div v-show="isExpanded" class="inshop-scheduling-card__body">
|
||||
<div v-if="dropOffSection" class="inshop-scheduling-card__section">
|
||||
<p class="inshop-scheduling-card__section-label">{{ dropOffSection.label }}</p>
|
||||
<fieldset class="inshop-scheduling-card__time-slots">
|
||||
<legend class="sr-only">{{ dropOffSection.label }}</legend>
|
||||
<label
|
||||
v-for="slot in dropOffSection.slots"
|
||||
:key="slot.value"
|
||||
class="inshop-scheduling-card__slot inshop-scheduling-card__slot--full-width"
|
||||
:class="{
|
||||
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
|
||||
}">
|
||||
<input
|
||||
type="radio"
|
||||
class="inshop-scheduling-card__slot-input"
|
||||
:name="radioGroupName"
|
||||
:value="slot.value"
|
||||
:checked="isSlotSelected(slot)"
|
||||
@change="onTimeSlotSelected(slot)" />
|
||||
<span class="inshop-scheduling-card__slot-label">
|
||||
<span class="inshop-scheduling-card__slot-label-primary">
|
||||
{{ slot.label }}
|
||||
<availableAppointmentDateChips
|
||||
v-if="showAvailableDateChips"
|
||||
:dates="availableDateChips"
|
||||
:label="availableDatesLabel"
|
||||
@date-selected="$emit('date-selected', $event)" />
|
||||
<template v-else>
|
||||
<div v-if="dropOffSection" class="inshop-scheduling-card__section">
|
||||
<p class="inshop-scheduling-card__section-label">
|
||||
{{ dropOffSection.label }}
|
||||
</p>
|
||||
<fieldset class="inshop-scheduling-card__time-slots">
|
||||
<legend class="sr-only">{{ dropOffSection.label }}</legend>
|
||||
<label
|
||||
v-for="slot in dropOffSection.slots"
|
||||
:key="slot.value"
|
||||
class="inshop-scheduling-card__slot inshop-scheduling-card__slot--full-width"
|
||||
:class="{
|
||||
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
|
||||
}">
|
||||
<input
|
||||
type="radio"
|
||||
class="inshop-scheduling-card__slot-input"
|
||||
:name="radioGroupName"
|
||||
:value="slot.value"
|
||||
:checked="isSlotSelected(slot)"
|
||||
@change="onTimeSlotSelected(slot)" />
|
||||
<span class="inshop-scheduling-card__slot-label">
|
||||
<span class="inshop-scheduling-card__slot-label-primary">
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="slot.subText && isSlotSelected(slot)"
|
||||
class="inshop-scheduling-card__slot-label-secondary"
|
||||
v-html="slot.subText" />
|
||||
</span>
|
||||
<span
|
||||
v-if="slot.subText && isSlotSelected(slot)"
|
||||
class="inshop-scheduling-card__slot-label-secondary"
|
||||
v-html="slot.subText" />
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
v-for="section in displayTimeSlotSections"
|
||||
:key="section.key"
|
||||
class="inshop-scheduling-card__section">
|
||||
<p class="inshop-scheduling-card__section-label">{{ section.label }}</p>
|
||||
<fieldset
|
||||
class="inshop-scheduling-card__time-slots inshop-scheduling-card__time-slots--grid">
|
||||
<legend class="sr-only">{{ section.label }}</legend>
|
||||
<label
|
||||
v-for="slot in section.visibleSlots"
|
||||
:key="slot.value"
|
||||
class="inshop-scheduling-card__slot"
|
||||
:class="{
|
||||
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
|
||||
}">
|
||||
<input
|
||||
type="radio"
|
||||
class="inshop-scheduling-card__slot-input"
|
||||
:name="radioGroupName"
|
||||
:value="slot.value"
|
||||
:checked="isSlotSelected(slot)"
|
||||
@change="onTimeSlotSelected(slot)" />
|
||||
<span class="inshop-scheduling-card__slot-label">
|
||||
<span class="inshop-scheduling-card__slot-label-primary">
|
||||
{{ slot.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
v-for="section in displayTimeSlotSections"
|
||||
:key="section.key"
|
||||
class="inshop-scheduling-card__section">
|
||||
<p class="inshop-scheduling-card__section-label">{{ section.label }}</p>
|
||||
<fieldset
|
||||
class="inshop-scheduling-card__time-slots inshop-scheduling-card__time-slots--grid">
|
||||
<legend class="sr-only">{{ section.label }}</legend>
|
||||
<label
|
||||
v-for="slot in section.visibleSlots"
|
||||
:key="slot.value"
|
||||
class="inshop-scheduling-card__slot"
|
||||
:class="{
|
||||
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
|
||||
}">
|
||||
<input
|
||||
type="radio"
|
||||
class="inshop-scheduling-card__slot-input"
|
||||
:name="radioGroupName"
|
||||
:value="slot.value"
|
||||
:checked="isSlotSelected(slot)"
|
||||
@change="onTimeSlotSelected(slot)" />
|
||||
<span class="inshop-scheduling-card__slot-label">
|
||||
<span class="inshop-scheduling-card__slot-label-primary">
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button
|
||||
v-if="section.showViewMoreLink"
|
||||
type="button"
|
||||
class="inshop-scheduling-card__view-more-link"
|
||||
@click="onViewMoreTimesClick(section.key)">
|
||||
{{ viewMoreTimesText }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button
|
||||
v-if="section.showViewMoreLink"
|
||||
type="button"
|
||||
class="inshop-scheduling-card__view-more-link"
|
||||
@click="onViewMoreTimesClick(section.key)">
|
||||
{{ viewMoreTimesText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -117,6 +126,7 @@ import {
|
|||
mapInshopTimeSlotToDisplaySlot,
|
||||
} from "@/layouts/schedule/helpers/schedule-helper";
|
||||
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader";
|
||||
import availableAppointmentDateChips from "@/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue";
|
||||
|
||||
function toTitleCase(str) {
|
||||
if (!str) return "";
|
||||
|
|
@ -133,7 +143,7 @@ const DROPOFF_CMS_NAME_TO_ROUTE_FLAG = {
|
|||
|
||||
export default {
|
||||
name: "inshop-scheduling-card",
|
||||
emits: ["update:modelValue", "address-clicked"],
|
||||
emits: ["update:modelValue", "address-clicked", "date-selected"],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
|
|
@ -163,6 +173,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
availableDateChips: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -272,6 +286,15 @@ export default {
|
|||
appointmentCountLabel() {
|
||||
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
|
||||
},
|
||||
showAvailableDateChips() {
|
||||
return this.availableDateChips.length > 0 && this.appointmentCount === 0;
|
||||
},
|
||||
availableDatesLabel() {
|
||||
return (
|
||||
this.getCmsContent(this.cmsWidgetName, "AvailableDatesLabel") ||
|
||||
"Available appointments:"
|
||||
);
|
||||
},
|
||||
selectedRouteCodeValue() {
|
||||
if (
|
||||
this.modelValue?.appointmentType !== AppointmentTypeStrings.IN_SHOP ||
|
||||
|
|
@ -327,6 +350,7 @@ export default {
|
|||
},
|
||||
components: {
|
||||
schedulingCardLoader,
|
||||
availableAppointmentDateChips,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,29 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("shows available date chips when selected day has no slots and chips are provided", () => {
|
||||
const wrapper = mountComponent({
|
||||
timeSlots: [],
|
||||
availableDateChips: ["2026-06-11", "2026-06-14", "2026-06-16"],
|
||||
});
|
||||
expect(wrapper.text()).toContain("Available appointments:");
|
||||
expect(wrapper.find("available-appointment-date-chips-stub").exists()).toBe(true);
|
||||
expect(wrapper.find(".mobile-scheduling-card__time-slots").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits date-selected when an available date chip is selected", async () => {
|
||||
const wrapper = mountComponent({
|
||||
timeSlots: [],
|
||||
availableDateChips: ["2026-06-11"],
|
||||
});
|
||||
await wrapper
|
||||
.find("available-appointment-date-chips-stub")
|
||||
.vm.$emit("date-selected", "2026-06-11");
|
||||
expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]);
|
||||
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!");
|
||||
|
|
|
|||
|
|
@ -51,7 +51,12 @@
|
|||
<span class="mobile-scheduling-card__address-hint">{{ addressHintText }}</span>
|
||||
</div>
|
||||
<div v-show="isExpanded" class="mobile-scheduling-card__body">
|
||||
<fieldset class="mobile-scheduling-card__time-slots">
|
||||
<availableAppointmentDateChips
|
||||
v-if="showAvailableDateChips"
|
||||
:dates="availableDateChips"
|
||||
:label="availableDatesLabel"
|
||||
@date-selected="$emit('date-selected', $event)" />
|
||||
<fieldset v-else class="mobile-scheduling-card__time-slots">
|
||||
<legend class="sr-only">{{ headerTitle }}</legend>
|
||||
<label
|
||||
v-for="slot in displayTimeSlots"
|
||||
|
|
@ -91,10 +96,11 @@
|
|||
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
|
||||
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
|
||||
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue";
|
||||
import availableAppointmentDateChips from "@/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue";
|
||||
|
||||
export default {
|
||||
name: "mobile-scheduling-card",
|
||||
emits: ["update:modelValue", "zip-code-clicked"],
|
||||
emits: ["update:modelValue", "zip-code-clicked", "date-selected"],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
|
|
@ -132,6 +138,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
availableDateChips: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -160,6 +170,15 @@ export default {
|
|||
appointmentCountLabel() {
|
||||
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
|
||||
},
|
||||
showAvailableDateChips() {
|
||||
return this.availableDateChips.length > 0 && this.displayTimeSlots.length === 0;
|
||||
},
|
||||
availableDatesLabel() {
|
||||
return (
|
||||
this.getCmsContent(this.cmsWidgetName, "AvailableDatesLabel") ||
|
||||
"Available appointments:"
|
||||
);
|
||||
},
|
||||
hasPremiumTimeSlot() {
|
||||
return this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null;
|
||||
},
|
||||
|
|
@ -239,6 +258,7 @@ export default {
|
|||
},
|
||||
components: {
|
||||
schedulingCardLoader,
|
||||
availableAppointmentDateChips,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@
|
|||
v-model="selectedScheduling"
|
||||
:providerNumber="mobileProviderAndTimeSlot.providerNumber"
|
||||
:timeSlots="mobileTimeSlotsForSelectedDate"
|
||||
:availableDateChips="mobileAvailableDateChips"
|
||||
:premiumTimeSlotPrice="premiumTimeSlotPrice"
|
||||
:zipCode="serviceZipCode"
|
||||
:showFreeFlag="showMobileFreeFlag"
|
||||
:radioGroupName="schedulingRadioGroupName"
|
||||
:isLoading="isLoadingDates"
|
||||
@zip-code-clicked="onMobileZipCodeClicked" />
|
||||
@zip-code-clicked="onMobileZipCodeClicked"
|
||||
@date-selected="onSchedulingDateSelected" />
|
||||
<inshopSchedulingCard
|
||||
v-for="entry in inshopProvidersAndTimeSlots"
|
||||
v-show="showInshopSchedulingCards"
|
||||
|
|
@ -43,9 +45,13 @@
|
|||
:timeSlots="
|
||||
getInshopTimeSlotsForSelectedDate(entry.provider.providerNumber)
|
||||
"
|
||||
:availableDateChips="
|
||||
getInshopAvailableDateChips(provider.providerNumber)
|
||||
"
|
||||
:radioGroupName="schedulingRadioGroupName"
|
||||
:isLoading="isLoadingDates || Boolean(entry.isLoadingTimeSlots)"
|
||||
@address-clicked="onInshopAddressClicked(entry.provider)" />
|
||||
@date-selected="onSchedulingDateSelected" />
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="d-flex justify-content-center mt-4">
|
||||
|
|
@ -104,6 +110,11 @@ import {
|
|||
hasGlassPartsOrRepairInfo,
|
||||
hasInsuranceInfo,
|
||||
} from "@/helpers/page-prerequisites-helper.js";
|
||||
import { debugLog } from "@/helpers/debug-log-helper";
|
||||
import {
|
||||
getAvailableAppointmentDatesToShow,
|
||||
getAvailableDatesFromProviderDays,
|
||||
} from "@/layouts/schedule/helpers/schedule-helper";
|
||||
|
||||
/**
|
||||
* Returns a YYYY-MM-DD date string offset by the given number of days from a base date.
|
||||
|
|
@ -345,6 +356,15 @@ export default {
|
|||
);
|
||||
return day?.timeSlots ?? [];
|
||||
},
|
||||
mobileAvailableDateChips() {
|
||||
if (!this.selectedDate || this.mobileTimeSlotsForSelectedDate.length > 0) {
|
||||
return [];
|
||||
}
|
||||
const availableDates = getAvailableDatesFromProviderDays(
|
||||
this.mobileProviderAndTimeSlot?.timeSlots?.days
|
||||
);
|
||||
return getAvailableAppointmentDatesToShow(availableDates, this.selectedDate);
|
||||
},
|
||||
premiumTimeSlotPrice() {
|
||||
if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) {
|
||||
return null;
|
||||
|
|
@ -396,6 +416,24 @@ export default {
|
|||
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate);
|
||||
return day?.timeSlots ?? [];
|
||||
},
|
||||
getInshopAvailableDateChips(providerNumber) {
|
||||
if (
|
||||
!this.selectedDate ||
|
||||
this.getInshopTimeSlotsForSelectedDate(providerNumber).length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const providerEntry = this.inShopProvidersAndTimeslots.find(
|
||||
({ provider }) => provider.providerNumber === providerNumber
|
||||
);
|
||||
const availableDates = getAvailableDatesFromProviderDays(
|
||||
providerEntry?.timeSlots?.days
|
||||
);
|
||||
return getAvailableAppointmentDatesToShow(availableDates, this.selectedDate);
|
||||
},
|
||||
onSchedulingDateSelected(date) {
|
||||
this.selectedDate = date;
|
||||
},
|
||||
onInshopAddressClicked(provider) {
|
||||
// TODO: open Google Maps when address link functionality is implemented
|
||||
console.log("onInshopAddressClicked", provider?.providerNumber);
|
||||
|
|
|
|||
Loading…
Reference in a new issue