Merge pull request #3230 from Safelite/rlsmerge/2026.06.18-to-develop

Rlsmerge/2026.06.18 to develop
This commit is contained in:
CarlNation 2026-06-16 07:52:48 -04:00 committed by GitHub
commit 8a0abf5292
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 937 additions and 30 deletions

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

@ -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";
@ -72,6 +85,7 @@ function toDateString(offsetDays, base = new Date()) {
}
const SCHEDULE_FETCH_DAYS = 15;
const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot";
export default {
name: "scheduling",
@ -144,10 +158,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 +213,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
},
@ -267,6 +298,7 @@ export default {
Form,
datePicker,
mobileSchedulingCard,
inshopSchedulingCard,
loadingModal,
},
};