CASH-2862 - Scheduling - Adjust grouping
This commit is contained in:
parent
b81972f401
commit
58da49e8f3
6 changed files with 183 additions and 109 deletions
|
|
@ -40,6 +40,7 @@ const MOCK_PROVIDER = {
|
|||
|
||||
const MOCK_CMS_CONTENT = {
|
||||
InshopCardWidget: {
|
||||
HeaderText: "Visit us at a shop:",
|
||||
BodyText: "Morning:",
|
||||
BodyText2: "Afternoon:",
|
||||
FooterText: "View more times",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,6 @@
|
|||
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
|
||||
|
|
@ -356,12 +351,6 @@ export default {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
&__shop-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__header-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ const MOCK_TIME_SLOTS = [
|
|||
|
||||
const MOCK_CMS_CONTENT = {
|
||||
MobileCardWidget: {
|
||||
HeaderText: "We'll come to you",
|
||||
SubheaderText: "We'll come to you for free!",
|
||||
HeaderText: "We'll come to you (for free!):",
|
||||
SubheaderText: "At your location",
|
||||
BodyText: "Enter address on the next page",
|
||||
FooterText: "First stop of the day",
|
||||
FooterText2: "8:00 am - 12:00 pm arrival window",
|
||||
FooterText2: "8:00 am - 12:00 pm arrival",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -66,13 +66,16 @@ function mountComponent(props = {}, cmsContent = {}) {
|
|||
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("At your location");
|
||||
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("8:00 am - 12:00 pm arrival");
|
||||
expect(wrapper.text()).toContain("12:00 pm - 5:00 pm arrival");
|
||||
expect(wrapper.text()).toContain("+$29.99");
|
||||
expect(wrapper.text()).toContain("3 appts");
|
||||
expect(wrapper.find(".mobile-scheduling-card__free-flag").exists()).toBe(false);
|
||||
expect(wrapper.find(".mobile-scheduling-card__van-icon").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -83,12 +86,6 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
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");
|
||||
|
|
@ -113,7 +110,7 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
|
||||
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");
|
||||
expect(wrapper.find(".mobile-scheduling-card__slot-label-secondary").exists()).toBe(false);
|
||||
|
||||
const premiumInput = wrapper.find(
|
||||
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]`
|
||||
|
|
@ -128,7 +125,9 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("8:00 am - 12:00 pm arrival window");
|
||||
const subLabel = wrapper.find(".mobile-scheduling-card__slot-label-secondary");
|
||||
expect(subLabel.exists()).toBe(true);
|
||||
expect(subLabel.text()).toBe("8:00 am - 12:00 pm arrival");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -183,12 +182,6 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("hides the free flag when isLoading is true even if showFreeFlag is true", () => {
|
||||
const wrapper = mountComponent({ isLoading: true, showFreeFlag: true });
|
||||
expect(wrapper.find(".mobile-scheduling-card__free-flag").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("defaults isLoading to false", () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.props("isLoading")).toBe(false);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,14 @@
|
|||
<template>
|
||||
<div class="mobile-scheduling-card">
|
||||
<div v-if="showFreeFlag && !isLoading" 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 v-if="isLoading" class="mobile-scheduling-card__panel">
|
||||
<schedulingCardLoader />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mobile-scheduling-card__panel"
|
||||
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
|
||||
<div v-else class="mobile-scheduling-card__panel">
|
||||
<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"
|
||||
|
|
@ -120,10 +104,6 @@ export default {
|
|||
type: String,
|
||||
default: "schedulingTimeSlot",
|
||||
},
|
||||
showFreeFlag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
cmsWidgetName: {
|
||||
type: String,
|
||||
default: "MobileCardWidget",
|
||||
|
|
@ -142,9 +122,6 @@ export default {
|
|||
headerTitle() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
|
||||
},
|
||||
freeFlagText() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
|
||||
},
|
||||
addressHintText() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "BodyText");
|
||||
},
|
||||
|
|
@ -218,9 +195,9 @@ export default {
|
|||
this.$emit("zip-code-clicked");
|
||||
},
|
||||
formatArrivalWindow(startTime, endTime) {
|
||||
return `${militaryToTwelveHourTime(startTime)} - ${militaryToTwelveHourTime(
|
||||
endTime
|
||||
)} arrival window`;
|
||||
const start = militaryToTwelveHourTime(startTime)?.toLowerCase();
|
||||
const end = militaryToTwelveHourTime(endTime)?.toLowerCase();
|
||||
return `${start} - ${end} arrival`;
|
||||
},
|
||||
isSlotSelected(slot) {
|
||||
return this.selectedRouteCodeValue === slot.value;
|
||||
|
|
@ -249,35 +226,12 @@ export default {
|
|||
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 {
|
||||
|
|
@ -291,12 +245,6 @@ export default {
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
&__van-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__header-title {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
|
|
@ -413,12 +361,12 @@ export default {
|
|||
}
|
||||
|
||||
&__slot-price {
|
||||
background: $green;
|
||||
color: $green-600;
|
||||
background: $green-100;
|
||||
color: $green-700;
|
||||
border-radius: 72px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -78,11 +78,13 @@ jest.mock(
|
|||
})
|
||||
);
|
||||
|
||||
function setupMocks({ isAppleBrowser = false } = {}) {
|
||||
function setupMocks({ isAppleBrowser = false, cmsContent = {} } = {}) {
|
||||
const dispatchStoreAction = jest.fn().mockResolvedValue(null);
|
||||
const baseMixin = {
|
||||
methods: {
|
||||
getCmsContent: jest.fn(() => ""),
|
||||
getCmsContent: jest.fn((widgetName, fieldName) => {
|
||||
return cmsContent[widgetName]?.[fieldName] ?? "";
|
||||
}),
|
||||
setCmsContent: jest.fn(),
|
||||
getTotalLineItemPrice: jest.fn(() => 0),
|
||||
isAppleBrowser: jest.fn(() => isAppleBrowser),
|
||||
|
|
@ -128,6 +130,80 @@ function setupMocks({ isAppleBrowser = false } = {}) {
|
|||
}
|
||||
|
||||
describe("scheduling.vue", () => {
|
||||
describe("estimatedTimeText", () => {
|
||||
test("replaces {custom:DURATION} with bracketed duration range", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
cmsContent: {
|
||||
FunnelSubHeaderWidget: {
|
||||
HeaderSubText: "Estimated service time: {custom:DURATION}",
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.setData({
|
||||
estimatedServiceMinutesMinimum: 60,
|
||||
estimatedServiceMinutesMaximum: 120,
|
||||
});
|
||||
|
||||
expect(wrapper.vm.estimatedTimeText).toBe("Estimated service time: [1-2] hours");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("section headers", () => {
|
||||
test("renders mobile and inshop section headers from CMS", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
cmsContent: {
|
||||
MobileCardWidget: {
|
||||
HeaderText: "We'll come to you (for free!):",
|
||||
},
|
||||
InshopCardWidget: {
|
||||
HeaderText: "Visit us at a shop:",
|
||||
},
|
||||
},
|
||||
});
|
||||
await wrapper.setData({
|
||||
isLoadingDates: false,
|
||||
selectedDate: "2026-01-21",
|
||||
mobileProviderAndTimeSlot: { providerNumber: "03357", timeSlots: { days: [] } },
|
||||
inshopProvidersAndTimeSlots: [
|
||||
{
|
||||
provider: { providerNumber: "001820", address: { city: "Worthington" } },
|
||||
timeSlots: { days: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const headers = wrapper.findAll(".scheduling-section-header");
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0].text()).toContain("We'll come to you");
|
||||
expect(headers[0].find(".scheduling-section-header__icon").exists()).toBe(true);
|
||||
expect(headers[0].find(".scheduling-section-header__free").text()).toBe("(for free!):");
|
||||
expect(headers[1].text()).toContain("Visit us at a shop:");
|
||||
expect(headers[1].find(".scheduling-section-header__icon").exists()).toBe(true);
|
||||
expect(wrapper.find("mobile-scheduling-card-stub").exists()).toBe(true);
|
||||
expect(wrapper.find("inshop-scheduling-card-stub").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("hides section headers when CMS content is empty", async () => {
|
||||
const { wrapper } = setupMocks();
|
||||
await wrapper.setData({
|
||||
isLoadingDates: false,
|
||||
selectedDate: "2026-01-21",
|
||||
mobileProviderAndTimeSlot: { providerNumber: "03357", timeSlots: { days: [] } },
|
||||
inshopProvidersAndTimeSlots: [
|
||||
{
|
||||
provider: { providerNumber: "001820", address: { city: "Worthington" } },
|
||||
timeSlots: { days: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wrapper.find(".scheduling-section-header").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("intercept overlay", () => {
|
||||
test("does not render interceptOverlay when isLoadingDates is false", async () => {
|
||||
const { wrapper } = setupMocks();
|
||||
|
|
|
|||
|
|
@ -26,23 +26,53 @@
|
|||
@requestMoreDates="handleRequestMoreDates" />
|
||||
<Transition name="card-slide" mode="out-in">
|
||||
<div :key="selectedDate">
|
||||
<p
|
||||
v-if="showMobileSchedulingCard && mobileSectionHeaderMainText"
|
||||
class="scheduling-section-header scheduling-section-header--mobile mt-5">
|
||||
<img
|
||||
class="scheduling-section-header__icon"
|
||||
src="@/assets/img/van.svg"
|
||||
alt=""
|
||||
aria-hidden="true" />
|
||||
<span class="scheduling-section-header__text">
|
||||
{{ mobileSectionHeaderMainText }}
|
||||
<span
|
||||
v-if="mobileSectionHeaderFreeText"
|
||||
class="scheduling-section-header__free">
|
||||
{{ mobileSectionHeaderFreeText }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<mobileSchedulingCard
|
||||
v-if="showMobileSchedulingCard"
|
||||
class="mt-4"
|
||||
class="mt-2"
|
||||
v-model="selectedScheduling"
|
||||
:providerNumber="mobileProviderAndTimeSlot.providerNumber"
|
||||
:timeSlots="mobileTimeSlotsForSelectedDate"
|
||||
:premiumTimeSlotPrice="premiumTimeSlotPrice"
|
||||
:zipCode="serviceZipCode"
|
||||
:showFreeFlag="showMobileFreeFlag"
|
||||
:radioGroupName="schedulingRadioGroupName"
|
||||
:isLoading="isLoadingDates"
|
||||
@zip-code-clicked="onMobileZipCodeClicked" />
|
||||
<p
|
||||
v-if="
|
||||
showInshopSchedulingCards &&
|
||||
inshopSectionHeaderText &&
|
||||
inshopProvidersAndTimeSlots.length
|
||||
"
|
||||
class="scheduling-section-header scheduling-section-header--inshop mt-5">
|
||||
<img
|
||||
class="scheduling-section-header__icon"
|
||||
src="@/assets/img/shop.svg"
|
||||
alt=""
|
||||
aria-hidden="true" />
|
||||
<span>{{ inshopSectionHeaderText }}</span>
|
||||
</p>
|
||||
<inshopSchedulingCard
|
||||
v-for="entry in inshopProvidersAndTimeSlots"
|
||||
v-for="(entry, index) in inshopProvidersAndTimeSlots"
|
||||
v-show="showInshopSchedulingCards"
|
||||
:key="entry.provider.providerNumber"
|
||||
class="mt-4"
|
||||
:class="index === 0 ? 'mt-2' : 'mt-4'"
|
||||
v-model="selectedScheduling"
|
||||
:provider="entry.provider"
|
||||
:timeSlots="
|
||||
|
|
@ -362,8 +392,27 @@ export default {
|
|||
schedulingRadioGroupName() {
|
||||
return SCHEDULING_RADIO_GROUP_NAME;
|
||||
},
|
||||
serviceLocationText() {
|
||||
return this.getCmsContent("ServiceLocationText", "Text");
|
||||
mobileSectionHeaderText() {
|
||||
return this.getCmsContent("MobileCardWidget", "SubheaderText");
|
||||
},
|
||||
mobileSectionHeaderMainText() {
|
||||
const text = this.mobileSectionHeaderText?.trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
const freeTextIndex = text.indexOf("(");
|
||||
return freeTextIndex >= 0 ? text.slice(0, freeTextIndex).trimEnd() : text;
|
||||
},
|
||||
mobileSectionHeaderFreeText() {
|
||||
const text = this.mobileSectionHeaderText?.trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
const freeTextIndex = text.indexOf("(");
|
||||
return freeTextIndex >= 0 ? text.slice(freeTextIndex) : "";
|
||||
},
|
||||
inshopSectionHeaderText() {
|
||||
return this.getCmsContent("InshopCardWidget", "HeaderText");
|
||||
},
|
||||
viewMoreShopsText() {
|
||||
return this.getCmsContent("ViewMoreShopsWidget", "Text");
|
||||
|
|
@ -372,13 +421,16 @@ export default {
|
|||
if (!this.estimatedServiceMinutesMinimum || !this.estimatedServiceMinutesMaximum) {
|
||||
return " ";
|
||||
}
|
||||
const durationText = getDisplayTextForDurationLength(
|
||||
// "1 - 2 hours" → "[1-2] hours"
|
||||
const formattedDuration = getDisplayTextForDurationLength(
|
||||
this.estimatedServiceMinutesMinimum,
|
||||
this.estimatedServiceMinutesMaximum
|
||||
);
|
||||
return this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText").replace(
|
||||
)
|
||||
.replace(" - ", "-")
|
||||
.replace(/(\S+) (hours|minutes)/i, "[$1] $2");
|
||||
return (this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText") || "").replace(
|
||||
"{custom:DURATION}",
|
||||
durationText
|
||||
formattedDuration
|
||||
);
|
||||
},
|
||||
serviceZipCode() {
|
||||
|
|
@ -407,9 +459,6 @@ export default {
|
|||
}
|
||||
return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee);
|
||||
},
|
||||
showMobileFreeFlag() {
|
||||
return true;
|
||||
},
|
||||
availableDates() {
|
||||
if (!this.datesLoaded) return null;
|
||||
const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) =>
|
||||
|
|
@ -1102,12 +1151,30 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.dark-header {
|
||||
.scheduling-section-header {
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
color: $black;
|
||||
}
|
||||
h5 {
|
||||
line-height: 32px;
|
||||
font-size: 1.25rem;
|
||||
|
||||
&--mobile,
|
||||
&--inshop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__free {
|
||||
font-weight: 400;
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.card-slide-enter-active {
|
||||
|
|
|
|||
Loading…
Reference in a new issue