Merge branch 'develop' into nation/CASH-2844

This commit is contained in:
Carl Nation 2026-06-18 06:32:21 -04:00
commit 450eb1cc6c
14 changed files with 1089 additions and 51 deletions

3
.gitignore vendored
View file

@ -31,6 +31,9 @@ ortoni-report/
*.sln
*.sw?
# Cursor
.cursor/
# Misc
coverage/*
junit.xml

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00161 3.1177C7.76595 3.11605 7.53923 3.2078 7.37109 3.37286L0.264342 10.4688C0.0950866 10.638 -1.78339e-09 10.8674 0 11.1067C1.78339e-09 11.3459 0.0950866 11.5754 0.264342 11.7446C0.433597 11.9137 0.663157 12.0088 0.90252 12.0088C1.14188 12.0088 1.37144 11.9137 1.5407 11.7446L8.00161 5.27378L14.4702 11.7446C14.6394 11.9121 14.8683 12.0055 15.1065 12.0043C15.3447 12.0031 15.5726 11.9074 15.7402 11.7382C15.9077 11.569 16.0012 11.3402 16 11.1022C15.9988 10.8641 15.903 10.6363 15.7338 10.4688L8.63468 3.37796C8.46644 3.21088 8.23878 3.11729 8.00161 3.1177Z" fill="#0070D1"/>
</svg>

After

Width:  |  Height:  |  Size: 691 B

6
src/assets/img/shop.svg Normal file
View 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

View file

@ -7,4 +7,5 @@ export const headerKeys = {
EON: "X-Enterprise-Order-Number",
LOG_ENABLED: "log-enabled",
PAGE_NAME_TO_LOG: "X-Page-Name-To-Log",
DEVICE_ID: "X-Device-Id",
};

View file

@ -10,6 +10,7 @@ import { getSessionKeyValue } from "@/helpers/heritage-integration/cookie-helper
import { headerKeys } from "@/constants/header-keys";
import axiosResponseInterceptorMessages from "@/constants/axios-response-interceptor-messages.js";
import { endpoints } from "@/constants/endpoints";
import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper";
// Add a response interceptor for global axios error handing.
axios.interceptors.response.use(
@ -73,6 +74,7 @@ export default {
[headerKeys.TRANSACTION_ID]: crypto.randomUUID(),
[headerKeys.EON]: order?.eon,
[headerKeys.LOG_ENABLED]: store.getters.applicationUser?.loggingOption ?? false,
[headerKeys.DEVICE_ID]: getDeviceIdValue(),
};
axios({

View file

@ -8,13 +8,14 @@
<div class="container">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<div class="my-5" ref="unverified-block">
<div v-if="showUnverifiedContent" class="my-5" ref="unverified-block">
<funnelSubHeader cmsWidgetName="SubHeaderWidgetUnverified" alignLeft />
<customerInstructions
cmsWidgetName="InstructionsWidgetUnverified"
:cmsWidgetName="getUnverifiedInstructionsWidgetName"
:isRepair="isRepair"
v-on="{ textLinkClicked: openModalAction }" />
</div>
<div class="my-5" ref="verified-block">
<div v-if="showVerifiedContent" class="my-5" ref="verified-block">
<funnelSubHeader cmsWidgetName="SubHeaderWidgetVerified" alignLeft />
<priceDisplay
cmsWidgetName="PriceWidgetVerified"
@ -27,7 +28,7 @@
cmsWidgetName="AfterpayBreakoutWidget"
:totalAmount="cashPrice" />
</div>
<div class="my-5" ref="itac-block">
<div v-if="showItacContent" class="my-5" ref="itac-block">
<funnelSubHeader
class="mb-4"
cmsWidgetName="SubHeaderWidgetItac"
@ -39,7 +40,7 @@
<priceDisplay cmsWidgetName="PriceWidgetItac" :price="cashPrice" />
</div>
</div>
<div class="my-5" ref="nocomp-block">
<div v-if="showNoCompContent" class="my-5" ref="nocomp-block">
<funnelSubHeader
class="mb-4"
cmsWidgetName="SubHeaderWidgetNocomp"
@ -92,7 +93,11 @@ export default {
data() {
return {
isClaimAndCoverage: this.isClaimAndCoverageFromStore(),
isVerified: this.isVerifiedInsuranceFromStore(),
isItac: this.isItacFromStore(),
isNoComp: this.isNoCompFromStore(),
isRecalibrationOnOrder: this.isRecalibrationOnOrderFromStore(),
isRepair: this.isRepairFromStore(),
clientConfig: {},
};
},
@ -143,9 +148,21 @@ export default {
isClaimAndCoverageFromStore() {
return store.getters.order?.payment?.isClaimAndCoverage;
},
isVerifiedInsuranceFromStore() {
return store.getters.order?.payment?.insuranceCoverage.isVerified;
},
isItacFromStore() {
return store.getters.order?.policy?.isItac;
},
isNoCompFromStore() {
return store.getters.order?.policy?.isNoComp;
},
isRecalibrationOnOrderFromStore() {
return store.getters.isRecalibrationOnOrder;
},
isRepairFromStore() {
return store.getters.order?.damage?.isRepair;
},
backButtonAction() {
if (!this.isClaimAndCoverage) {
this.$router.navigateWithoutSaving(
@ -182,6 +199,27 @@ export default {
},
},
computed: {
showVerifiedContent() {
return this.isVerified && !this.isItac && !this.isNoComp;
},
showUnverifiedContent() {
return !this.isVerified;
},
showItacContent() {
return this.isItac;
},
showNoCompContent() {
return this.isNoComp;
},
getUnverifiedInstructionsWidgetName() {
if (!this.isVerified) {
if (this.isRepair) {
return "InstructionsWidgetUnverifiedRepair";
}
return "InstructionsWidgetUnverified";
}
return "";
},
clientLogoImageSrc() {
return this.clientConfig?.ClientLogoWidget?.Image || "";
},
@ -205,6 +243,9 @@ export default {
</script>
<style lang="scss">
.funnel-sub-header {
margin-bottom: 1.5rem;
}
.price-compare {
display: flex;

View file

@ -5,9 +5,9 @@
{{ headerText }}
</strong>
</div>
<div>
<div v-if="!this.isRepair">
<ol>
<li v-for="block in instructionBlocks" :key="block">
<li v-for="block in InstructionBlocks" :key="block">
<span v-for="token in block" :key="token">
<span v-if="doesCopyContainTextLink(token)" class="link">
<textLink
@ -24,6 +24,7 @@
</li>
</ol>
</div>
<div v-else v-html="RepairInstructionText"></div>
</div>
</template>
@ -45,6 +46,7 @@ export default {
},
props: {
cmsWidgetName: String,
isRepair: Boolean,
},
methods: {
doesCopyContainTextLink,
@ -57,7 +59,7 @@ export default {
headerText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
instructionBlocks() {
InstructionBlocks() {
const answers = this.getCmsContent(this.cmsWidgetName, "Answers");
if (!answers) {
@ -72,6 +74,9 @@ export default {
return instructionBlocksAsTokens;
},
RepairInstructionText() {
return this.getCmsContent(this.cmsWidgetName, "Text");
},
},
components: {
textLink,

View 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",
});
});
});

View file

@ -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();
}

View file

@ -0,0 +1,246 @@
import { shallowMount } from "@vue/test-utils";
import inshopSchedulingCard from "./inshop-scheduling-card";
import { RouteCodeFlags, AppointmentTypeStrings } 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 45 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: {
appointmentType: AppointmentTypeStrings.IN_SHOP,
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({
appointmentType: AppointmentTypeStrings.IN_SHOP,
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({
appointmentType: AppointmentTypeStrings.IN_SHOP,
providerNumber: "001820",
routeCodeId: MORNING_INSHOP_ROUTE_CODE,
});
wrapper.unmount();
});
test("highlights selected time slot", async () => {
const wrapper = mountComponent({
modelValue: {
appointmentType: AppointmentTypeStrings.IN_SHOP,
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("does not highlight selection from another provider", () => {
const wrapper = mountComponent({
modelValue: {
appointmentType: AppointmentTypeStrings.IN_SHOP,
providerNumber: "999999",
routeCodeId: MORNING_INSHOP_ROUTE_CODE,
},
});
expect(wrapper.find(".inshop-scheduling-card__slot--selected").exists()).toBe(false);
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();
});
});

View file

@ -0,0 +1,506 @@
<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-no-background.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, AppointmentTypeStrings } 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,
default: "schedulingTimeSlot",
},
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 * 10) / 10;
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() {
if (
this.modelValue?.appointmentType !== AppointmentTypeStrings.IN_SHOP ||
this.modelValue?.providerNumber !== this.provider.providerNumber
) {
return null;
}
return this.modelValue?.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", {
appointmentType: AppointmentTypeStrings.IN_SHOP,
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(180deg);
transition: transform 150ms linear;
&--expanded {
transform: rotate(0deg);
}
}
&__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>

View file

@ -1,6 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import mobileSchedulingCard from "./mobile-scheduling-card";
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
const MOCK_TIME_SLOTS = [
@ -54,6 +54,7 @@ function mountComponent(props = {}, cmsContent = {}) {
return shallowMount(mobileSchedulingCard, {
props: {
zipCode: "43235",
providerNumber: "03357",
timeSlots: MOCK_TIME_SLOTS,
premiumTimeSlotPrice: 29.99,
...props,
@ -102,10 +103,10 @@ describe("mobile-scheduling-card.vue", () => {
);
await premiumInput.setValue(true);
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
selectedMobileTimeSlot: {
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
},
appointmentType: AppointmentTypeStrings.MOBILE,
providerNumber: "03357",
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
});
wrapper.unmount();
});
@ -120,10 +121,10 @@ describe("mobile-scheduling-card.vue", () => {
await premiumInput.setValue(true);
await wrapper.setProps({
modelValue: {
selectedMobileTimeSlot: {
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
},
appointmentType: AppointmentTypeStrings.MOBILE,
providerNumber: "03357",
routeCodeId: MOCK_TIME_SLOTS[0].id,
isPremiumAppointment: true,
},
});
@ -136,11 +137,23 @@ describe("mobile-scheduling-card.vue", () => {
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,
appointmentType: AppointmentTypeStrings.MOBILE,
providerNumber: "03357",
routeCodeId: MOCK_TIME_SLOTS[1].id,
isPremiumAppointment: false,
});
wrapper.unmount();
});
test("does not highlight selection from in-shop appointment", async () => {
const wrapper = mountComponent({
modelValue: {
appointmentType: AppointmentTypeStrings.IN_SHOP,
providerNumber: "001820",
routeCodeId: MOCK_TIME_SLOTS[0].id,
},
});
expect(wrapper.find(".mobile-scheduling-card__slot--selected").exists()).toBe(false);
wrapper.unmount();
});

View file

@ -32,7 +32,7 @@
<img
class="mobile-scheduling-card__chevron"
:class="{ 'mobile-scheduling-card__chevron--expanded': isExpanded }"
src="@/assets/img/icons/chevron-right-blue.svg"
src="@/assets/img/icons/chevron-no-background.svg"
alt=""
aria-hidden="true" />
</button>
@ -59,7 +59,7 @@
<input
type="radio"
class="mobile-scheduling-card__slot-input"
name="mobileSchedulingTimeSlot"
:name="radioGroupName"
:value="slot.value"
:checked="isSlotSelected(slot)"
@change="onTimeSlotSelected(slot)" />
@ -84,7 +84,7 @@
</template>
<script>
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
export default {
@ -107,6 +107,14 @@ export default {
type: String,
required: true,
},
providerNumber: {
type: String,
required: true,
},
radioGroupName: {
type: String,
default: "schedulingTimeSlot",
},
showFreeFlag: {
type: Boolean,
default: false,
@ -144,7 +152,7 @@ export default {
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
},
hasPremiumTimeSlot() {
return this.timeSlots?.length > 0; //this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null;
return this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null;
},
displayTimeSlots() {
if (!this.timeSlots?.length) {
@ -179,13 +187,18 @@ export default {
return `+$${this.premiumTimeSlotPrice.toFixed(2)}`;
},
selectedRouteCodeValue() {
const selected = this.modelValue?.selectedMobileTimeSlot;
if (!selected?.routeCodeId) {
if (
this.modelValue?.appointmentType !== AppointmentTypeStrings.MOBILE ||
this.modelValue?.providerNumber !== this.providerNumber
) {
return null;
}
return selected.isPremiumAppointment
? this.addPremiumFlagToInput(selected.routeCodeId)
: selected.routeCodeId;
if (!this.modelValue?.routeCodeId) {
return null;
}
return this.modelValue.isPremiumAppointment
? this.addPremiumFlagToInput(this.modelValue.routeCodeId)
: this.modelValue.routeCodeId;
},
},
methods: {
@ -205,10 +218,10 @@ export default {
},
onTimeSlotSelected(slot) {
this.$emit("update:modelValue", {
selectedMobileTimeSlot: {
routeCodeId: slot.routeCodeId,
isPremiumAppointment: slot.isPremiumAppointment,
},
appointmentType: AppointmentTypeStrings.MOBILE,
providerNumber: this.providerNumber,
routeCodeId: slot.routeCodeId,
isPremiumAppointment: slot.isPremiumAppointment,
});
},
addPremiumFlagToInput(routeCode) {
@ -300,11 +313,11 @@ export default {
width: 16px;
height: 16px;
flex-shrink: 0;
transform: rotate(90deg);
transform: rotate(180deg);
transition: transform 150ms linear;
&--expanded {
transform: rotate(-90deg);
transform: rotate(0deg);
}
}

View file

@ -22,12 +22,24 @@
<mobileSchedulingCard
v-if="showMobileSchedulingCard"
class="mt-4"
v-model="selectedMobileScheduling"
v-model="selectedScheduling"
:providerNumber="mobileProviderAndTimeSlot.providerNumber"
:timeSlots="mobileTimeSlotsForSelectedDate"
:premiumTimeSlotPrice="premiumTimeSlotPrice"
:zipCode="serviceZipCode"
:showFreeFlag="showMobileFreeFlag"
:radioGroupName="schedulingRadioGroupName"
@zip-code-clicked="onMobileZipCodeClicked" />
<inshopSchedulingCard
v-for="{ provider } in inShopProvidersAndTimeslots"
v-show="selectedDate"
:key="provider.providerNumber"
class="mt-4"
v-model="selectedScheduling"
:provider="provider"
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)"
:radioGroupName="schedulingRadioGroupName"
@address-clicked="onInshopAddressClicked(provider)" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
@ -46,6 +58,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";
@ -66,12 +79,41 @@ import { debugLog } from "@/helpers/debug-log-helper";
* @param {Date} base - The starting date (defaults to today).
*/
function toDateString(offsetDays, base = new Date()) {
const d = new Date(base);
// Adding T00:00:00 ensures the date is treated as a local date in the browser's timezone.
const d = typeof base === "string" ? new Date(base + "T00:00:00") : new Date(base);
d.setDate(d.getDate() + offsetDays);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const SCHEDULE_FETCH_DAYS = 15;
const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot";
/**
* Returns a promise for inshop time slots for a single provider.
* @param {{ startDate: string, endDate: string, providerNumber: string, pageNameToLog: string }} params
*/
function fetchInshopTimeSlots({ startDate, endDate, providerNumber, pageNameToLog }) {
return store.dispatch("getShopTimeSlots", {
payload: {
startDate,
endDate,
shopAppointmentType: "InshopOrDropoff",
providerNumber,
},
pageNameToLog,
});
}
/**
* Returns a promise for mobile time slots.
* @param {{ startDate: string, endDate: string, zipCode: string, pageNameToLog: string }} params
*/
function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) {
return store.dispatch("getMobileTimeSlots", {
payload: { startDate, endDate, zipCode },
pageNameToLog,
});
}
export default {
name: "scheduling",
@ -102,13 +144,10 @@ export default {
},
...providers.map((provider, i) => ({
resultKey: `inshopTimeSlots_${i}`,
promise: store.dispatch("getShopTimeSlots", {
payload: {
startDate,
endDate,
shopAppointmentType: "InshopOrDropoff",
providerNumber: provider.providerNumber,
},
promise: fetchInshopTimeSlots({
startDate,
endDate,
providerNumber: provider.providerNumber,
pageNameToLog: to.name,
}),
})),
@ -117,8 +156,10 @@ export default {
? [
{
resultKey: "mobileTimeSlots",
promise: store.dispatch("getMobileTimeSlots", {
payload: { startDate, endDate, zipCode: serviceZipCode },
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
pageNameToLog: to.name,
}),
},
@ -144,10 +185,13 @@ export default {
},
watch: {
selectedDate() {
this.selectedMobileScheduling = null;
this.selectedScheduling = null;
},
},
computed: {
schedulingRadioGroupName() {
return SCHEDULING_RADIO_GROUP_NAME;
},
serviceLocationText() {
return this.getCmsContent("ServiceLocationText", "Text");
},
@ -196,10 +240,24 @@ export default {
inShopProvidersAndTimeslots: [],
mobileProviderAndTimeSlot: null,
mobilePremiumAppointmentFee: null,
selectedMobileScheduling: null,
selectedScheduling: null,
};
},
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
},
@ -238,10 +296,62 @@ export default {
this.isLoadingDates = true;
this.$refs.loadingModal.showModal();
// TODO: call getShopTimeSlots with the next date range and append results
// to this.availableDates and advance this.datePickerEndDate
console.log("handleRequestMoreDates");
const newStartDate = toDateString(1, this.datePickerEndDate);
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
const promiseResultMap = [
...this.inShopProvidersAndTimeslots.map(({ provider }, i) => ({
resultKey: `inshopTimeSlots_${i}`,
promise: fetchInshopTimeSlots({
startDate: newStartDate,
endDate: newEndDate,
providerNumber: provider.providerNumber,
pageNameToLog: this.pageName,
}),
})),
...(this.mobileProviderAndTimeSlot
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate: newStartDate,
endDate: newEndDate,
zipCode: this.serviceZipCode,
pageNameToLog: this.pageName,
}),
},
]
: []),
];
const resultMap = await settleAllPromises(promiseResultMap);
this.inShopProvidersAndTimeslots.forEach((entry, index) => {
const newSlots = resultMap[`inshopTimeSlots_${index}`];
if (!newSlots) return;
if (!entry.timeSlots) {
entry.timeSlots = newSlots;
} else {
entry.timeSlots.days = [
...(entry.timeSlots.days ?? []),
...(newSlots.days ?? []),
];
}
});
if (this.mobileProviderAndTimeSlot && resultMap.mobileTimeSlots) {
const newSlots = resultMap.mobileTimeSlots;
if (!this.mobileProviderAndTimeSlot.timeSlots) {
this.mobileProviderAndTimeSlot.timeSlots = newSlots;
} else {
this.mobileProviderAndTimeSlot.timeSlots.days = [
...(this.mobileProviderAndTimeSlot.timeSlots.days ?? []),
...(newSlots.days ?? []),
];
}
}
this.datePickerEndDate = newEndDate;
this.isLoadingDates = false;
this.$refs.loadingModal.hideModal();
},
@ -267,6 +377,7 @@ export default {
Form,
datePicker,
mobileSchedulingCard,
inshopSchedulingCard,
loadingModal,
},
};