CASH-2862 - Scheduling - Adjust grouping

This commit is contained in:
Minojhini Valaiyapathi 2026-08-04 09:22:56 -04:00
parent b81972f401
commit 58da49e8f3
6 changed files with 183 additions and 109 deletions

View file

@ -40,6 +40,7 @@ const MOCK_PROVIDER = {
const MOCK_CMS_CONTENT = { const MOCK_CMS_CONTENT = {
InshopCardWidget: { InshopCardWidget: {
HeaderText: "Visit us at a shop:",
BodyText: "Morning:", BodyText: "Morning:",
BodyText2: "Afternoon:", BodyText2: "Afternoon:",
FooterText: "View more times", FooterText: "View more times",

View file

@ -9,11 +9,6 @@
class="inshop-scheduling-card__header" class="inshop-scheduling-card__header"
:aria-expanded="isExpanded" :aria-expanded="isExpanded"
@click="toggleExpanded"> @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__header-title">{{ cityLabel }}</span>
<span class="inshop-scheduling-card__distance">{{ formattedDistance }}</span> <span class="inshop-scheduling-card__distance">{{ formattedDistance }}</span>
<span <span
@ -356,12 +351,6 @@ export default {
text-align: left; text-align: left;
} }
&__shop-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
&__header-title { &__header-title {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;

View file

@ -20,11 +20,11 @@ const MOCK_TIME_SLOTS = [
const MOCK_CMS_CONTENT = { const MOCK_CMS_CONTENT = {
MobileCardWidget: { MobileCardWidget: {
HeaderText: "We'll come to you", HeaderText: "We'll come to you (for free!):",
SubheaderText: "We'll come to you for free!", SubheaderText: "At your location",
BodyText: "Enter address on the next page", BodyText: "Enter address on the next page",
FooterText: "First stop of the day", 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", () => { describe("mobile-scheduling-card.vue", () => {
test("renders header, zip code, and time slot options", () => { test("renders header, zip code, and time slot options", () => {
const wrapper = mountComponent(); 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("43235");
expect(wrapper.text()).toContain("Enter address on the next page"); expect(wrapper.text()).toContain("Enter address on the next page");
expect(wrapper.text()).toContain("First stop of the day"); 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("+$29.99");
expect(wrapper.text()).toContain("3 appts"); 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(); wrapper.unmount();
}); });
@ -83,12 +86,6 @@ describe("mobile-scheduling-card.vue", () => {
wrapper.unmount(); 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 () => { test("emits zip-code-clicked when zip link is clicked", async () => {
const wrapper = mountComponent(); const wrapper = mountComponent();
await wrapper.find(".mobile-scheduling-card__zip-link").trigger("click"); 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 () => { test("shows premium sublabel only when the premium option is selected", async () => {
const wrapper = mountComponent(); 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( const premiumInput = wrapper.find(
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]` `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(); wrapper.unmount();
}); });
@ -183,12 +182,6 @@ describe("mobile-scheduling-card.vue", () => {
wrapper.unmount(); 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", () => { test("defaults isLoading to false", () => {
const wrapper = mountComponent(); const wrapper = mountComponent();
expect(wrapper.props("isLoading")).toBe(false); expect(wrapper.props("isLoading")).toBe(false);

View file

@ -1,30 +1,14 @@
<template> <template>
<div class="mobile-scheduling-card"> <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"> <div v-if="isLoading" class="mobile-scheduling-card__panel">
<schedulingCardLoader /> <schedulingCardLoader />
</div> </div>
<div <div v-else class="mobile-scheduling-card__panel">
v-else
class="mobile-scheduling-card__panel"
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
<button <button
type="button" type="button"
class="mobile-scheduling-card__header" class="mobile-scheduling-card__header"
:aria-expanded="isExpanded" :aria-expanded="isExpanded"
@click="toggleExpanded"> @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__header-title">{{ headerTitle }}</span>
<span <span
class="mobile-scheduling-card__appt-badge" class="mobile-scheduling-card__appt-badge"
@ -120,10 +104,6 @@ export default {
type: String, type: String,
default: "schedulingTimeSlot", default: "schedulingTimeSlot",
}, },
showFreeFlag: {
type: Boolean,
default: false,
},
cmsWidgetName: { cmsWidgetName: {
type: String, type: String,
default: "MobileCardWidget", default: "MobileCardWidget",
@ -142,9 +122,6 @@ export default {
headerTitle() { headerTitle() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText"); return this.getCmsContent(this.cmsWidgetName, "HeaderText");
}, },
freeFlagText() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
addressHintText() { addressHintText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText"); return this.getCmsContent(this.cmsWidgetName, "BodyText");
}, },
@ -218,9 +195,9 @@ export default {
this.$emit("zip-code-clicked"); this.$emit("zip-code-clicked");
}, },
formatArrivalWindow(startTime, endTime) { formatArrivalWindow(startTime, endTime) {
return `${militaryToTwelveHourTime(startTime)} - ${militaryToTwelveHourTime( const start = militaryToTwelveHourTime(startTime)?.toLowerCase();
endTime const end = militaryToTwelveHourTime(endTime)?.toLowerCase();
)} arrival window`; return `${start} - ${end} arrival`;
}, },
isSlotSelected(slot) { isSlotSelected(slot) {
return this.selectedRouteCodeValue === slot.value; return this.selectedRouteCodeValue === slot.value;
@ -249,35 +226,12 @@ export default {
flex-direction: column; flex-direction: column;
align-items: flex-start; 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 { &__panel {
width: 100%; width: 100%;
border: 1px solid $gray-1000; border: 1px solid $gray-1000;
border-radius: 16px; border-radius: 16px;
padding: 16px; padding: 16px;
background: $white; background: $white;
&--with-free-flag {
border-radius: 0 16px 16px 16px;
}
} }
&__header { &__header {
@ -291,12 +245,6 @@ export default {
text-align: left; text-align: left;
} }
&__van-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
&__header-title { &__header-title {
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
@ -413,12 +361,12 @@ export default {
} }
&__slot-price { &__slot-price {
background: $green; background: $green-100;
color: $green-600; color: $green-700;
border-radius: 72px; border-radius: 72px;
padding: 2px 8px; padding: 2px 8px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 500;
line-height: 1.25rem; line-height: 1.25rem;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;

View file

@ -78,11 +78,13 @@ jest.mock(
}) })
); );
function setupMocks({ isAppleBrowser = false } = {}) { function setupMocks({ isAppleBrowser = false, cmsContent = {} } = {}) {
const dispatchStoreAction = jest.fn().mockResolvedValue(null); const dispatchStoreAction = jest.fn().mockResolvedValue(null);
const baseMixin = { const baseMixin = {
methods: { methods: {
getCmsContent: jest.fn(() => ""), getCmsContent: jest.fn((widgetName, fieldName) => {
return cmsContent[widgetName]?.[fieldName] ?? "";
}),
setCmsContent: jest.fn(), setCmsContent: jest.fn(),
getTotalLineItemPrice: jest.fn(() => 0), getTotalLineItemPrice: jest.fn(() => 0),
isAppleBrowser: jest.fn(() => isAppleBrowser), isAppleBrowser: jest.fn(() => isAppleBrowser),
@ -128,6 +130,80 @@ function setupMocks({ isAppleBrowser = false } = {}) {
} }
describe("scheduling.vue", () => { 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", () => { describe("intercept overlay", () => {
test("does not render interceptOverlay when isLoadingDates is false", async () => { test("does not render interceptOverlay when isLoadingDates is false", async () => {
const { wrapper } = setupMocks(); const { wrapper } = setupMocks();

View file

@ -26,23 +26,53 @@
@requestMoreDates="handleRequestMoreDates" /> @requestMoreDates="handleRequestMoreDates" />
<Transition name="card-slide" mode="out-in"> <Transition name="card-slide" mode="out-in">
<div :key="selectedDate"> <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 <mobileSchedulingCard
v-if="showMobileSchedulingCard" v-if="showMobileSchedulingCard"
class="mt-4" class="mt-2"
v-model="selectedScheduling" v-model="selectedScheduling"
:providerNumber="mobileProviderAndTimeSlot.providerNumber" :providerNumber="mobileProviderAndTimeSlot.providerNumber"
:timeSlots="mobileTimeSlotsForSelectedDate" :timeSlots="mobileTimeSlotsForSelectedDate"
:premiumTimeSlotPrice="premiumTimeSlotPrice" :premiumTimeSlotPrice="premiumTimeSlotPrice"
:zipCode="serviceZipCode" :zipCode="serviceZipCode"
:showFreeFlag="showMobileFreeFlag"
:radioGroupName="schedulingRadioGroupName" :radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates" :isLoading="isLoadingDates"
@zip-code-clicked="onMobileZipCodeClicked" /> @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 <inshopSchedulingCard
v-for="entry in inshopProvidersAndTimeSlots" v-for="(entry, index) in inshopProvidersAndTimeSlots"
v-show="showInshopSchedulingCards" v-show="showInshopSchedulingCards"
:key="entry.provider.providerNumber" :key="entry.provider.providerNumber"
class="mt-4" :class="index === 0 ? 'mt-2' : 'mt-4'"
v-model="selectedScheduling" v-model="selectedScheduling"
:provider="entry.provider" :provider="entry.provider"
:timeSlots=" :timeSlots="
@ -362,8 +392,27 @@ export default {
schedulingRadioGroupName() { schedulingRadioGroupName() {
return SCHEDULING_RADIO_GROUP_NAME; return SCHEDULING_RADIO_GROUP_NAME;
}, },
serviceLocationText() { mobileSectionHeaderText() {
return this.getCmsContent("ServiceLocationText", "Text"); 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() { viewMoreShopsText() {
return this.getCmsContent("ViewMoreShopsWidget", "Text"); return this.getCmsContent("ViewMoreShopsWidget", "Text");
@ -372,13 +421,16 @@ export default {
if (!this.estimatedServiceMinutesMinimum || !this.estimatedServiceMinutesMaximum) { if (!this.estimatedServiceMinutesMinimum || !this.estimatedServiceMinutesMaximum) {
return " "; return " ";
} }
const durationText = getDisplayTextForDurationLength( // "1 - 2 hours" "[1-2] hours"
const formattedDuration = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum, this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum this.estimatedServiceMinutesMaximum
); )
return this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText").replace( .replace(" - ", "-")
.replace(/(\S+) (hours|minutes)/i, "[$1] $2");
return (this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText") || "").replace(
"{custom:DURATION}", "{custom:DURATION}",
durationText formattedDuration
); );
}, },
serviceZipCode() { serviceZipCode() {
@ -407,9 +459,6 @@ export default {
} }
return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee); return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee);
}, },
showMobileFreeFlag() {
return true;
},
availableDates() { availableDates() {
if (!this.datesLoaded) return null; if (!this.datesLoaded) return null;
const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) => const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) =>
@ -1102,12 +1151,30 @@ export default {
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.dark-header { .scheduling-section-header {
margin-bottom: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.5rem;
color: $black; color: $black;
}
h5 { &--mobile,
line-height: 32px; &--inshop {
font-size: 1.25rem; 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 { .card-slide-enter-active {