Add in-shop component (with incomplete functionality)
This commit is contained in:
parent
0970028fa3
commit
85b0b65ca2
6 changed files with 861 additions and 0 deletions
6
src/assets/img/shop.svg
Normal file
6
src/assets/img/shop.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<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="M19.348 12.5171H4.65205C3.64794 12.5171 2.87637 11.5714 3.01647 10.5128L3.33853 8.07548C3.45334 7.20759 4.15097 6.5625 4.97411 6.5625H19.0259C19.849 6.5625 20.5467 7.20759 20.6615 8.07548L20.9835 10.5128C21.1236 11.5714 20.3521 12.5171 19.348 12.5171Z" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.67969 12.5156V18.5656C4.67969 19.5318 5.46294 20.315 6.42814 20.315H17.5697C18.5359 20.315 19.3182 19.5318 19.3182 18.5656V12.5156" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.1249 20.3158V17.7549C14.1249 16.5815 13.1733 15.6289 11.9989 15.6289C10.8255 15.6289 9.87305 16.5815 9.87305 17.7549V20.3158" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.67969 6.55875V5.43301C4.67969 4.46684 5.46294 3.68359 6.42814 3.68359H17.5697C18.5359 3.68359 19.3182 4.46684 19.3182 5.43301V6.55875" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
44
src/layouts/schedule/helpers/schedule-helper.inshop.spec.js
Normal file
44
src/layouts/schedule/helpers/schedule-helper.inshop.spec.js
Normal file
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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() {
|
export function getTodayDate() {
|
||||||
return new Date();
|
return new Date();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
<template>
|
||||||
|
<div class="inshop-scheduling-card">
|
||||||
|
<div class="inshop-scheduling-card__panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inshop-scheduling-card__header"
|
||||||
|
:aria-expanded="isExpanded"
|
||||||
|
@click="toggleExpanded">
|
||||||
|
<img
|
||||||
|
class="inshop-scheduling-card__shop-icon"
|
||||||
|
src="@/assets/img/shop.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true" />
|
||||||
|
<span class="inshop-scheduling-card__header-title">{{ cityLabel }}</span>
|
||||||
|
<span class="inshop-scheduling-card__distance">{{ formattedDistance }}</span>
|
||||||
|
<span
|
||||||
|
class="inshop-scheduling-card__appt-badge"
|
||||||
|
:class="{
|
||||||
|
'inshop-scheduling-card__appt-badge--empty': appointmentCount === 0,
|
||||||
|
}">
|
||||||
|
{{ appointmentCountLabel }}
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
class="inshop-scheduling-card__chevron"
|
||||||
|
:class="{ 'inshop-scheduling-card__chevron--expanded': isExpanded }"
|
||||||
|
src="@/assets/img/icons/chevron-right-blue.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inshop-scheduling-card__address-link"
|
||||||
|
@click.stop="onAddressClick">
|
||||||
|
{{ 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 }}
|
||||||
|
</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 }}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { RouteCodeFlags } from "@/constants/schedule-constants";
|
||||||
|
import {
|
||||||
|
groupInshopTimeSlotsByTimeOfDay,
|
||||||
|
INSHOP_INITIAL_VISIBLE_TIME_SLOTS,
|
||||||
|
mapInshopTimeSlotToDisplaySlot,
|
||||||
|
} from "@/layouts/schedule/helpers/schedule-helper";
|
||||||
|
|
||||||
|
function toTitleCase(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DROPOFF_CMS_NAME_TO_ROUTE_FLAG = {
|
||||||
|
AllDayDropOff: RouteCodeFlags.ALL_DAY_DROP_OFF,
|
||||||
|
OvernightDropOff: RouteCodeFlags.OVERNIGHT_DROP_OFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "inshop-scheduling-card",
|
||||||
|
emits: ["update:modelValue", "address-clicked"],
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
timeSlots: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
radioGroupName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
cmsWidgetName: {
|
||||||
|
type: String,
|
||||||
|
default: "InshopCardWidget",
|
||||||
|
},
|
||||||
|
dropoffCmsWidgetName: {
|
||||||
|
type: String,
|
||||||
|
default: "DropoffQuestionWidget",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isExpanded: false,
|
||||||
|
expandedTimeSlotSections: {
|
||||||
|
morning: false,
|
||||||
|
afternoon: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
timeSlots() {
|
||||||
|
this.expandedTimeSlotSections = {
|
||||||
|
morning: false,
|
||||||
|
afternoon: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cityLabel() {
|
||||||
|
return toTitleCase(this.provider?.address?.city);
|
||||||
|
},
|
||||||
|
formattedDistance() {
|
||||||
|
const miles = this.provider?.distanceInMiles;
|
||||||
|
if (miles == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const rounded = Math.round(miles * 2) / 2;
|
||||||
|
return `${rounded} mi`;
|
||||||
|
},
|
||||||
|
formattedAddress() {
|
||||||
|
const { streetAddress, city, state, zipCode } = this.provider?.address ?? {};
|
||||||
|
if (!streetAddress) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${toTitleCase(streetAddress)}, ${toTitleCase(city)}, ${state} ${zipCode}`;
|
||||||
|
},
|
||||||
|
morningSectionLabel() {
|
||||||
|
return this.getCmsContent(this.cmsWidgetName, "BodyText");
|
||||||
|
},
|
||||||
|
afternoonSectionLabel() {
|
||||||
|
return this.getCmsContent(this.cmsWidgetName, "BodyText2");
|
||||||
|
},
|
||||||
|
viewMoreTimesText() {
|
||||||
|
return this.getCmsContent(this.cmsWidgetName, "FooterText");
|
||||||
|
},
|
||||||
|
dropOffQuestionLabel() {
|
||||||
|
return this.getCmsContent(this.dropoffCmsWidgetName, "QuestionText");
|
||||||
|
},
|
||||||
|
dropOffAnswers() {
|
||||||
|
return this.getCmsContent(this.dropoffCmsWidgetName, "Answers") ?? [];
|
||||||
|
},
|
||||||
|
dropOffSection() {
|
||||||
|
if (!this.dropOffQuestionLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const slots = this.dropOffAnswers
|
||||||
|
.map((answer) => this.buildDropOffSlotFromAnswer(answer))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!slots.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: this.dropOffQuestionLabel,
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
timeSlotSections() {
|
||||||
|
const { morning, afternoon } = groupInshopTimeSlotsByTimeOfDay(this.timeSlots);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "morning",
|
||||||
|
label: this.morningSectionLabel,
|
||||||
|
slots: morning.map(mapInshopTimeSlotToDisplaySlot),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "afternoon",
|
||||||
|
label: this.afternoonSectionLabel,
|
||||||
|
slots: afternoon.map(mapInshopTimeSlotToDisplaySlot),
|
||||||
|
},
|
||||||
|
].filter((section) => section.label && section.slots.length);
|
||||||
|
},
|
||||||
|
displayTimeSlotSections() {
|
||||||
|
return this.timeSlotSections.map((section) => {
|
||||||
|
const isExpanded = this.expandedTimeSlotSections[section.key];
|
||||||
|
const hasMoreThanInitialVisible =
|
||||||
|
section.slots.length > INSHOP_INITIAL_VISIBLE_TIME_SLOTS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
visibleSlots: isExpanded
|
||||||
|
? section.slots
|
||||||
|
: section.slots.slice(0, INSHOP_INITIAL_VISIBLE_TIME_SLOTS),
|
||||||
|
showViewMoreLink: hasMoreThanInitialVisible && !isExpanded,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
appointmentCount() {
|
||||||
|
const dropOffCount = this.dropOffSection?.slots.length ?? 0;
|
||||||
|
const timeSlotCount = this.timeSlotSections.reduce(
|
||||||
|
(count, section) => count + section.slots.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return dropOffCount + timeSlotCount;
|
||||||
|
},
|
||||||
|
appointmentCountLabel() {
|
||||||
|
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
|
||||||
|
},
|
||||||
|
selectedRouteCodeValue() {
|
||||||
|
return this.modelValue?.selectedInshopTimeSlot?.routeCodeId ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleExpanded() {
|
||||||
|
this.isExpanded = !this.isExpanded;
|
||||||
|
},
|
||||||
|
onAddressClick() {
|
||||||
|
this.$emit("address-clicked");
|
||||||
|
},
|
||||||
|
onViewMoreTimesClick(sectionKey) {
|
||||||
|
this.expandedTimeSlotSections = {
|
||||||
|
...this.expandedTimeSlotSections,
|
||||||
|
[sectionKey]: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
findDropOffTimeSlotForCmsName(cmsName) {
|
||||||
|
const routeFlag = DROPOFF_CMS_NAME_TO_ROUTE_FLAG[cmsName];
|
||||||
|
if (!routeFlag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.timeSlots.find((timeSlot) => timeSlot.id?.includes(routeFlag)) ?? null;
|
||||||
|
},
|
||||||
|
buildDropOffSlotFromAnswer(answer) {
|
||||||
|
const timeSlot = this.findDropOffTimeSlotForCmsName(answer.Name);
|
||||||
|
if (!timeSlot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: timeSlot.id,
|
||||||
|
label: answer.Text,
|
||||||
|
subText: answer.SubText || null,
|
||||||
|
routeCodeId: timeSlot.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isSlotSelected(slot) {
|
||||||
|
return this.selectedRouteCodeValue === slot.routeCodeId;
|
||||||
|
},
|
||||||
|
onTimeSlotSelected(slot) {
|
||||||
|
this.$emit("update:modelValue", {
|
||||||
|
selectedInshopTimeSlot: {
|
||||||
|
providerNumber: this.provider.providerNumber,
|
||||||
|
routeCodeId: slot.routeCodeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.inshop-scheduling-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&__panel {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid $gray-1000;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__shop-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: $black;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distance {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: $gray-600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appt-badge {
|
||||||
|
background: $blue-150;
|
||||||
|
color: $blue;
|
||||||
|
border-radius: 72px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--empty {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chevron {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transition: transform 150ms linear;
|
||||||
|
|
||||||
|
&--expanded {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__address-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: $blue;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-label {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time-slots {
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&--grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid $gray-1000;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
background: $white;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border: 2px solid $blue;
|
||||||
|
background: $blue-150;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slot-input {
|
||||||
|
margin-top: 4px;
|
||||||
|
accent-color: $blue;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slot-label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slot-label-primary,
|
||||||
|
&__slot-label-secondary {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: $gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slot--selected &__slot-label-primary {
|
||||||
|
color: $black;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-more-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: $blue;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -28,6 +28,16 @@
|
||||||
:zipCode="serviceZipCode"
|
:zipCode="serviceZipCode"
|
||||||
:showFreeFlag="showMobileFreeFlag"
|
:showFreeFlag="showMobileFreeFlag"
|
||||||
@zip-code-clicked="onMobileZipCodeClicked" />
|
@zip-code-clicked="onMobileZipCodeClicked" />
|
||||||
|
<inshopSchedulingCard
|
||||||
|
v-for="{ provider } in inShopProvidersAndTimeslots"
|
||||||
|
v-show="selectedDate"
|
||||||
|
:key="provider.providerNumber"
|
||||||
|
class="mt-4"
|
||||||
|
v-model="selectedInshopSchedulingByProvider[provider.providerNumber]"
|
||||||
|
:provider="provider"
|
||||||
|
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)"
|
||||||
|
:radioGroupName="`inshopSchedulingTimeSlot-${provider.providerNumber}`"
|
||||||
|
@address-clicked="onInshopAddressClicked(provider)" />
|
||||||
<navbar
|
<navbar
|
||||||
cmsWidgetName="FunnelFooterWidget"
|
cmsWidgetName="FunnelFooterWidget"
|
||||||
ref="navbar"
|
ref="navbar"
|
||||||
|
|
@ -46,6 +56,7 @@ import navbar from "@/fmg-components/nav-bar/nav-bar";
|
||||||
import { Form } from "vee-validate";
|
import { Form } from "vee-validate";
|
||||||
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
||||||
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
||||||
|
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
|
||||||
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
||||||
|
|
@ -145,6 +156,7 @@ export default {
|
||||||
watch: {
|
watch: {
|
||||||
selectedDate() {
|
selectedDate() {
|
||||||
this.selectedMobileScheduling = null;
|
this.selectedMobileScheduling = null;
|
||||||
|
this.selectedInshopSchedulingByProvider = {};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -197,9 +209,24 @@ export default {
|
||||||
mobileProviderAndTimeSlot: null,
|
mobileProviderAndTimeSlot: null,
|
||||||
mobilePremiumAppointmentFee: null,
|
mobilePremiumAppointmentFee: null,
|
||||||
selectedMobileScheduling: null,
|
selectedMobileScheduling: null,
|
||||||
|
selectedInshopSchedulingByProvider: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getInshopTimeSlotsForSelectedDate(providerNumber) {
|
||||||
|
if (!this.selectedDate) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const providerEntry = this.inShopProvidersAndTimeslots.find(
|
||||||
|
({ provider }) => 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() {
|
onMobileZipCodeClicked() {
|
||||||
// TODO: open service zip modal when zip edit is implemented for scheduling page
|
// TODO: open service zip modal when zip edit is implemented for scheduling page
|
||||||
},
|
},
|
||||||
|
|
@ -267,6 +294,7 @@ export default {
|
||||||
Form,
|
Form,
|
||||||
datePicker,
|
datePicker,
|
||||||
mobileSchedulingCard,
|
mobileSchedulingCard,
|
||||||
|
inshopSchedulingCard,
|
||||||
loadingModal,
|
loadingModal,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue