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() {
|
||||
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"
|
||||
:showFreeFlag="showMobileFreeFlag"
|
||||
@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
|
||||
cmsWidgetName="FunnelFooterWidget"
|
||||
ref="navbar"
|
||||
|
|
@ -46,6 +56,7 @@ import navbar from "@/fmg-components/nav-bar/nav-bar";
|
|||
import { Form } from "vee-validate";
|
||||
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
||||
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 store from "@/store";
|
||||
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
||||
|
|
@ -145,6 +156,7 @@ export default {
|
|||
watch: {
|
||||
selectedDate() {
|
||||
this.selectedMobileScheduling = null;
|
||||
this.selectedInshopSchedulingByProvider = {};
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -197,9 +209,24 @@ export default {
|
|||
mobileProviderAndTimeSlot: null,
|
||||
mobilePremiumAppointmentFee: null,
|
||||
selectedMobileScheduling: null,
|
||||
selectedInshopSchedulingByProvider: {},
|
||||
};
|
||||
},
|
||||
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() {
|
||||
// TODO: open service zip modal when zip edit is implemented for scheduling page
|
||||
},
|
||||
|
|
@ -267,6 +294,7 @@ export default {
|
|||
Form,
|
||||
datePicker,
|
||||
mobileSchedulingCard,
|
||||
inshopSchedulingCard,
|
||||
loadingModal,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue