Add mobile scheduling component

This commit is contained in:
Chloe Herd 2026-06-08 13:40:14 -04:00
parent 60c3b1e398
commit 23d3ccddcc
5 changed files with 628 additions and 1 deletions

9
src/assets/img/party.svg Normal file
View file

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.08048 11.2565C7.10347 12.7378 6.74105 14.0529 7.27617 14.5523C8.01886 15.2464 10.2121 14.1048 12.1742 12.0032C14.1363 9.90159 15.1238 7.63545 14.3803 6.94141C13.7861 6.38602 12.2617 7.00628 10.671 8.35383" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5095 7.10645L17.4713 16.4087C17.61 16.8441 17.2135 17.2608 16.7724 17.1449L7.55371 14.719" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.5 5.67676H2.51946M4.85242 14.3255H4.87187" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.08657 4.91572C8.08657 5.46787 7.63902 5.91543 7.08687 5.91543C6.53472 5.91543 6.08716 5.46787 6.08716 4.91572C6.08716 4.36357 6.53472 3.91602 7.08687 3.91602C7.63902 3.91602 8.08657 4.36357 8.08657 4.91572Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.38436 11.5723C5.38436 11.5723 3.81872 11.6031 2.62036 13.1793" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.4553 10.5741C10.4553 10.5741 7.71892 7.75006 3.5271 8.94357" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.4734 2.83496C11.4734 2.83496 9.8891 5.00465 11.6437 7.61298" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src/assets/img/van.svg Normal file
View file

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.80005 18.4746C7.90462 18.4746 8.80005 17.5792 8.80005 16.4746C8.80005 15.37 7.90462 14.4746 6.80005 14.4746C5.69548 14.4746 4.80005 15.37 4.80005 16.4746C4.80005 17.5792 5.69548 18.4746 6.80005 18.4746Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.6001 18.4746C18.7047 18.4746 19.6001 17.5792 19.6001 16.4746C19.6001 15.37 18.7047 14.4746 17.6001 14.4746C16.4955 14.4746 15.6001 15.37 15.6001 16.4746C15.6001 17.5792 16.4955 18.4746 17.6001 18.4746Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 5.52539V10.0254H18.1" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.8 16.1754H3C2.45 16.1754 2 15.7254 2 15.1754V6.52539C2 5.97539 2.45 5.52539 3 5.52539H14.92C15.28 5.52539 15.61 5.71539 15.79 6.02539L18.1 10.0254L20.72 11.0354C21.49 11.3354 22 12.0754 22 12.9054V15.1854C22 15.7354 21.55 16.1854 21 16.1854H19.6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.6 16.1748H8.80005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,157 @@
import { shallowMount } from "@vue/test-utils";
import mobileSchedulingCard from "./mobile-scheduling-card";
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
const MOCK_TIME_SLOTS = [
{
id: "3357I-03357-M-I*21346*AM",
startTime: "08:00",
endTime: "12:00",
offerPremium: true,
},
{
id: "3357I-03357-M-I*21346*PM",
startTime: "12:00",
endTime: "17:00",
offerPremium: false,
},
];
const MOCK_CMS_CONTENT = {
MobileCardWidget: {
HeaderText: "We'll come to you",
SubheaderText: "We'll come to you for free!",
BodyText: "Enter address on the next page",
FooterText: "First stop of the day",
FooterText2: "8:00 am - 12:00 pm arrival window",
},
};
function mountComponent(props = {}, cmsContent = {}) {
const cmsContentByWidget = {
...MOCK_CMS_CONTENT,
...cmsContent,
MobileCardWidget: {
...MOCK_CMS_CONTENT.MobileCardWidget,
...cmsContent.MobileCardWidget,
},
};
const cmsMixin = {
methods: {
getCmsContent: jest.fn((widgetName, fieldName) => {
return cmsContentByWidget[widgetName]?.[fieldName] ?? "";
}),
},
};
const mountOptions = getMountOptions({
route: { name: "scheduling" },
mixins: [cmsMixin],
});
return shallowMount(mobileSchedulingCard, {
props: {
zipCode: "43235",
timeSlots: MOCK_TIME_SLOTS,
premiumTimeSlotPrice: 29.99,
...props,
},
global: mountOptions.global,
});
}
describe("mobile-scheduling-card.vue", () => {
test("renders header, zip code, and time slot options", () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain("We'll come to you");
expect(wrapper.text()).toContain("43235");
expect(wrapper.text()).toContain("Enter address on the next page");
expect(wrapper.text()).toContain("First stop of the day");
expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window");
expect(wrapper.text()).toContain("+$29.99");
expect(wrapper.text()).toContain("3 appts");
wrapper.unmount();
});
test("shows greyed out 0 appts badge when there are no time slots", () => {
const wrapper = mountComponent({ timeSlots: [] });
expect(wrapper.text()).toContain("0 appts");
expect(wrapper.find(".mobile-scheduling-card__appt-badge--empty").exists()).toBe(true);
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!");
wrapper.unmount();
});
test("emits zip-code-clicked when zip link is clicked", async () => {
const wrapper = mountComponent();
await wrapper.find(".mobile-scheduling-card__zip-link").trigger("click");
expect(wrapper.emitted("zip-code-clicked")).toBeTruthy();
wrapper.unmount();
});
test("emits update:modelValue with premium selection", async () => {
const wrapper = mountComponent();
const premiumInput = wrapper.find(
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]`
);
await premiumInput.setValue(true);
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
selectedMobileTimeSlot: {
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
},
});
wrapper.unmount();
});
test("shows premium sublabel only when the premium option is selected", async () => {
const wrapper = mountComponent();
expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window");
const premiumInput = wrapper.find(
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]`
);
await premiumInput.setValue(true);
await wrapper.setProps({
modelValue: {
selectedMobileTimeSlot: {
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
},
},
});
expect(wrapper.text()).toContain("8:00 am - 12:00 pm arrival window");
wrapper.unmount();
});
test("emits update:modelValue with standard time slot selection", async () => {
const wrapper = mountComponent();
const pmInput = wrapper.find(`input[value="${MOCK_TIME_SLOTS[1].id}"]`);
await pmInput.setValue(true);
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
selectedMobileTimeSlot: {
routeCodeId: MOCK_TIME_SLOTS[1].id,
isPremiumAppointment: false,
},
});
wrapper.unmount();
});
test("collapses and expands body when header is clicked", async () => {
const wrapper = mountComponent();
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
await wrapper.find(".mobile-scheduling-card__header").trigger("click");
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(false);
expect(wrapper.find(".mobile-scheduling-card__location-bar").isVisible()).toBe(true);
await wrapper.find(".mobile-scheduling-card__header").trigger("click");
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
wrapper.unmount();
});
});

View file

@ -0,0 +1,402 @@
<template>
<div class="mobile-scheduling-card">
<div v-if="showFreeFlag" class="mobile-scheduling-card__free-flag">
<img
class="mobile-scheduling-card__free-flag-icon"
src="@/assets/img/party.svg"
alt=""
aria-hidden="true" />
<span class="mobile-scheduling-card__free-flag-text">{{ freeFlagText }}</span>
</div>
<div
class="mobile-scheduling-card__panel"
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
<button
type="button"
class="mobile-scheduling-card__header"
:aria-expanded="isExpanded"
@click="toggleExpanded">
<img
class="mobile-scheduling-card__van-icon"
src="@/assets/img/van.svg"
alt=""
aria-hidden="true" />
<span class="mobile-scheduling-card__header-title">{{ headerTitle }}</span>
<span
class="mobile-scheduling-card__appt-badge"
:class="{
'mobile-scheduling-card__appt-badge--empty': appointmentCount === 0,
}">
{{ appointmentCountLabel }}
</span>
<img
class="mobile-scheduling-card__chevron"
:class="{ 'mobile-scheduling-card__chevron--expanded': isExpanded }"
src="@/assets/img/icons/chevron-right-blue.svg"
alt=""
aria-hidden="true" />
</button>
<div class="mobile-scheduling-card__location-bar">
<button
type="button"
class="mobile-scheduling-card__zip-link"
@click.stop="onZipCodeClick">
{{ zipCode }}
</button>
<span class="mobile-scheduling-card__location-divider" aria-hidden="true">|</span>
<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">
<legend class="sr-only">{{ headerTitle }}</legend>
<label
v-for="slot in displayTimeSlots"
:key="slot.value"
class="mobile-scheduling-card__slot"
:class="{
'mobile-scheduling-card__slot--selected': isSlotSelected(slot),
}">
<input
type="radio"
class="mobile-scheduling-card__slot-input"
name="mobileSchedulingTimeSlot"
:value="slot.value"
:checked="isSlotSelected(slot)"
@change="onTimeSlotSelected(slot)" />
<span class="mobile-scheduling-card__slot-label">
<span class="mobile-scheduling-card__slot-label-primary">
{{ slot.label }}
</span>
<span
v-if="slot.subLabel && isSlotSelected(slot)"
class="mobile-scheduling-card__slot-label-secondary">
{{ slot.subLabel }}
</span>
</span>
<span v-if="slot.priceLabel" class="mobile-scheduling-card__slot-price">
{{ slot.priceLabel }}
</span>
</label>
</fieldset>
</div>
</div>
</div>
</template>
<script>
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
export default {
name: "mobile-scheduling-card",
emits: ["update:modelValue", "zip-code-clicked"],
props: {
modelValue: {
type: Object,
default: null,
},
timeSlots: {
type: Array,
default: () => [],
},
premiumTimeSlotPrice: {
type: Number,
default: null,
},
zipCode: {
type: String,
required: true,
},
showFreeFlag: {
type: Boolean,
default: false,
},
cmsWidgetName: {
type: String,
default: "MobileCardWidget",
},
},
data() {
return {
isExpanded: true,
};
},
computed: {
headerTitle() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
freeFlagText() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
addressHintText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText");
},
premiumTimeSlotLabel() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
premiumTimeSlotSubLabel() {
return this.getCmsContent(this.cmsWidgetName, "FooterText2");
},
appointmentCount() {
return this.displayTimeSlots.length;
},
appointmentCountLabel() {
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
},
hasPremiumTimeSlot() {
return this.timeSlots?.length > 0; //this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null;
},
displayTimeSlots() {
if (!this.timeSlots?.length) {
return [];
}
const slots = this.timeSlots.map((timeSlot) => ({
value: timeSlot.id,
label: this.formatArrivalWindow(timeSlot.startTime, timeSlot.endTime),
priceLabel: null,
isPremiumAppointment: false,
routeCodeId: timeSlot.id,
}));
if (this.hasPremiumTimeSlot) {
slots.unshift({
value: this.addPremiumFlagToInput(this.timeSlots[0].id),
label: this.premiumTimeSlotLabel,
subLabel: this.premiumTimeSlotSubLabel || null,
priceLabel: this.formattedPremiumPrice,
isPremiumAppointment: true,
routeCodeId: this.timeSlots[0].id,
});
}
return slots;
},
formattedPremiumPrice() {
if (this.premiumTimeSlotPrice == null) {
return null;
}
return `+$${this.premiumTimeSlotPrice.toFixed(2)}`;
},
selectedRouteCodeValue() {
const selected = this.modelValue?.selectedMobileTimeSlot;
if (!selected?.routeCodeId) {
return null;
}
return selected.isPremiumAppointment
? this.addPremiumFlagToInput(selected.routeCodeId)
: selected.routeCodeId;
},
},
methods: {
toggleExpanded() {
this.isExpanded = !this.isExpanded;
},
onZipCodeClick() {
this.$emit("zip-code-clicked");
},
formatArrivalWindow(startTime, endTime) {
return `${militaryToTwelveHourTime(startTime)} - ${militaryToTwelveHourTime(
endTime
)} arrival window`;
},
isSlotSelected(slot) {
return this.selectedRouteCodeValue === slot.value;
},
onTimeSlotSelected(slot) {
this.$emit("update:modelValue", {
selectedMobileTimeSlot: {
routeCodeId: slot.routeCodeId,
isPremiumAppointment: slot.isPremiumAppointment,
},
});
},
addPremiumFlagToInput(routeCode) {
return routeCode + PREMIUM_TIME_SLOT_ID_FLAG;
},
},
};
</script>
<style lang="scss" scoped>
.mobile-scheduling-card {
display: flex;
flex-direction: column;
align-items: flex-start;
&__free-flag {
display: inline-flex;
align-items: center;
gap: 2px;
background: $green;
color: $green-600;
border-radius: 8px 8px 0 0;
padding: 2px 8px 2px 4px;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25rem;
}
&__free-flag-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
&__panel {
width: 100%;
border: 1px solid $gray-1000;
border-radius: 16px;
padding: 16px;
background: $white;
&--with-free-flag {
border-radius: 0 16px 16px 16px;
}
}
&__header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
border: 0;
background: transparent;
padding: 0;
text-align: left;
}
&__van-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
&__header-title {
flex: 1;
font-size: 1rem;
font-weight: 600;
line-height: 1.5rem;
color: $black;
}
&__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);
}
}
&__body {
margin-top: 8px;
}
&__location-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
line-height: 1.5rem;
margin-top: 4px;
}
&__zip-link {
border: 0;
background: transparent;
padding: 0;
color: $blue;
font-weight: 600;
text-decoration: underline;
cursor: pointer;
}
&__location-divider,
&__address-hint {
color: $gray-600;
}
&__time-slots {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
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;
}
}
&__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;
}
&__slot-price {
background: $green;
color: $green-600;
border-radius: 72px;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25rem;
white-space: nowrap;
flex-shrink: 0;
}
}
</style>

View file

@ -19,6 +19,15 @@
:availableDates="availableDates"
:isLoadingDates="isLoadingDates"
@requestMoreDates="handleRequestMoreDates" />
<mobileSchedulingCard
v-if="showMobileSchedulingCard"
class="mt-4"
v-model="selectedMobileScheduling"
:timeSlots="mobileTimeSlotsForSelectedDate"
:premiumTimeSlotPrice="premiumTimeSlotPrice"
:zipCode="serviceZipCode"
:showFreeFlag="showMobileFreeFlag"
@zip-code-clicked="onMobileZipCodeClicked" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
@ -36,11 +45,12 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
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 loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import {
flushPagePrereqsLogs,
@ -84,6 +94,12 @@ export default {
resultKey: "cmsContent",
promise: fetchCmsContentForPage(to.name),
},
{
resultKey: "mobilePremiumFee",
promise: store.dispatch("getMobilePremiumFee", {
pageNameToLog: to.name,
}),
},
...providers.map((provider, i) => ({
resultKey: `inshopTimeSlots_${i}`,
promise: store.dispatch("getShopTimeSlots", {
@ -123,12 +139,42 @@ export default {
}
: null;
vm.datesLoaded = true;
vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null;
});
},
watch: {
selectedDate() {
this.selectedMobileScheduling = null;
},
},
computed: {
serviceLocationText() {
return this.getCmsContent("ServiceLocationText", "Text");
},
serviceZipCode() {
return store.getters.order.serviceLocation.zipCode;
},
showMobileSchedulingCard() {
return Boolean(this.mobileProviderAndTimeSlot && this.selectedDate);
},
mobileTimeSlotsForSelectedDate() {
if (!this.selectedDate) {
return [];
}
const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find(
(d) => d.date === this.selectedDate
);
return day?.timeSlots ?? [];
},
premiumTimeSlotPrice() {
if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) {
return null;
}
return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee);
},
showMobileFreeFlag() {
return true;
},
availableDates() {
if (!this.datesLoaded) return null;
const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) =>
@ -149,9 +195,14 @@ export default {
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
inShopProvidersAndTimeslots: [],
mobileProviderAndTimeSlot: null,
mobilePremiumAppointmentFee: null,
selectedMobileScheduling: null,
};
},
methods: {
onMobileZipCodeClicked() {
// TODO: open service zip modal when zip edit is implemented for scheduling page
},
arePagePrerequisitesValid() {
const order = store.getters.order;
const logQueue = [];
@ -215,6 +266,7 @@ export default {
navbar,
Form,
datePicker,
mobileSchedulingCard,
loadingModal,
},
};