Merge pull request #3293 from Safelite/feature/CASH-2820

CASH-2820 | View More Shops
This commit is contained in:
scottkiener-at-safelite 2026-07-29 13:28:31 -04:00 committed by GitHub
commit 9ce6b3b412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 178 additions and 83 deletions

View file

@ -124,7 +124,7 @@ describe("scheduling.vue", () => {
});
const { wrapper } = setupMocks();
wrapper.vm.inShopProvidersAndTimeslots = [
wrapper.vm.inshopProvidersAndTimeSlots = [
{ provider: { providerNumber: "05018" }, timeSlots: { days: [] } },
{ provider: { providerNumber: "05019" }, timeSlots: { days: [] } },
];
@ -161,7 +161,7 @@ describe("scheduling.vue", () => {
});
const { wrapper } = setupMocks();
wrapper.vm.inShopProvidersAndTimeslots = [
wrapper.vm.inshopProvidersAndTimeSlots = [
{ provider: { providerNumber: "05018" }, timeSlots: { days: [] } },
{ provider: { providerNumber: "05019" }, timeSlots: { days: [] } },
];
@ -169,12 +169,12 @@ describe("scheduling.vue", () => {
await wrapper.vm.handleRequestMoreDates();
expect(wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days).toHaveLength(1);
expect(wrapper.vm.inshopProvidersAndTimeSlots[0].timeSlots.days).toHaveLength(1);
expect(
wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days[0].timeSlots[0].id
wrapper.vm.inshopProvidersAndTimeSlots[0].timeSlots.days[0].timeSlots[0].id
).toBe("slot-a");
expect(
wrapper.vm.inShopProvidersAndTimeslots[1].timeSlots.days[0].timeSlots[0].id
wrapper.vm.inshopProvidersAndTimeSlots[1].timeSlots.days[0].timeSlots[0].id
).toBe("slot-b");
wrapper.unmount();
});

View file

@ -1,6 +1,6 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<interceptOverlay v-if="isLoadingDates" />
<interceptOverlay v-if="isLoadingDates || isLoadingMoreShops" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<div class="container page-container-grouped-styles">
<div class="row">
@ -34,20 +34,39 @@
:isLoading="isLoadingDates"
@zip-code-clicked="onMobileZipCodeClicked" />
<inshopSchedulingCard
v-for="{ provider } in inShopProvidersAndTimeslots"
v-for="entry in inshopProvidersAndTimeSlots"
v-show="showInshopSchedulingCards"
:key="provider.providerNumber"
:key="entry.provider.providerNumber"
class="mt-4"
v-model="selectedScheduling"
:provider="provider"
:provider="entry.provider"
:timeSlots="
getInshopTimeSlotsForSelectedDate(provider.providerNumber)
getInshopTimeSlotsForSelectedDate(entry.provider.providerNumber)
"
:radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates"
@address-clicked="onInshopAddressClicked(provider)" />
:isLoading="isLoadingDates || Boolean(entry.isLoadingTimeSlots)"
@address-clicked="onInshopAddressClicked(entry.provider)" />
</div>
</Transition>
<div class="d-flex justify-content-center mt-4">
<textLink
v-if="hasMoreShopsAvailable"
id="viewMoreShopsLinkId"
linkType="text"
:text="viewMoreShopsText"
href="javascript:void(0)"
@click-event="onViewMoreShopsClick">
<template #after-text>
<span class="spacing-gap"></span>
<img
class="chevron-icon"
src="@/assets/img/icons/chevron-no-background.svg"
alt=""
aria-hidden="true" />
</template>
</textLink>
</div>
<waitlistQuestion
class="mt-5"
v-model="isWaitlistRequested"
@ -73,6 +92,7 @@ import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mo
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
import waitlistQuestion from "@/layouts/scheduling/waitlist-question/waitlist-question";
import textLink from "@/ux-components/text-link/text-link";
import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -84,7 +104,6 @@ import {
hasGlassPartsOrRepairInfo,
hasInsuranceInfo,
} from "@/helpers/page-prerequisites-helper.js";
import { debugLog } from "@/helpers/debug-log-helper";
/**
* Returns a YYYY-MM-DD date string offset by the given number of days from a base date.
@ -100,6 +119,9 @@ function toDateString(offsetDays, base = new Date()) {
const SCHEDULE_FETCH_DAYS = 15;
const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot";
const INITIAL_INSHOP_PROVIDER_COUNT = 3;
const MAX_INSHOP_PROVIDERS = 9;
const VIEW_MORE_SHOPS_BATCH_SIZE = 3;
/**
* Appends days from newSlots into entry.timeSlots, initializing it if absent.
@ -175,6 +197,58 @@ function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) {
});
}
/**
* Fetches inshop time slots (when providers are given) and mobile time slots (when requested)
* for a date range, settling both requests together. Either request is omitted entirely when
* it has no applicable providers/zip, rather than firing an empty/unnecessary call.
* @param {{
* startDate: string,
* endDate: string,
* providerNumbers: string[],
* zipCode?: string,
* includeMobile?: boolean,
* pageNameToLog: string,
* }} params
* @returns {Promise<{ inshopTimeSlots?: any, mobileTimeSlots?: any }>}
*/
function fetchTimeSlotsBatch({
startDate,
endDate,
providerNumbers,
zipCode,
includeMobile,
pageNameToLog,
}) {
return settleAllPromises([
...(providerNumbers.length
? [
{
resultKey: "inshopTimeSlots",
promise: fetchInshopTimeSlots({
startDate,
endDate,
providerNumbers,
pageNameToLog,
}),
},
]
: []),
...(includeMobile
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode,
pageNameToLog,
}),
},
]
: []),
]);
}
export default {
name: "scheduling",
async beforeRouteEnter(to, from, next) {
@ -200,12 +274,13 @@ export default {
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Get the first 3 providers from the shopProviderData
const providers = resultMap.providers?.shopProviders?.slice(0, 3) ?? [];
const allShopProviders = resultMap.providers?.shopProviders ?? [];
const providers = allShopProviders.slice(0, INITIAL_INSHOP_PROVIDER_COUNT);
const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null;
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.inShopProvidersAndTimeslots = providers.map((provider) => ({
vm.allShopProviders = allShopProviders;
vm.inshopProvidersAndTimeSlots = providers.map((provider) => ({
provider,
timeSlots: null,
}));
@ -215,38 +290,16 @@ export default {
const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const providerNumbers = providers.map((provider) => provider.providerNumber);
const timeSlotsPromiseResultMap = [
...(providerNumbers.length
? [
{
resultKey: "inshopTimeSlots",
promise: fetchInshopTimeSlots({
const timeSlotsResultMap = await fetchTimeSlotsBatch({
startDate,
endDate,
providerNumbers,
pageNameToLog: to.name,
}),
},
]
: []),
// Get the mobile time slots if a mobile provider number is available
...(mobileProviderNumber
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
includeMobile: Boolean(mobileProviderNumber),
pageNameToLog: to.name,
}),
},
]
: []),
];
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
});
assignInshopTimeSlotsFromV2Response(
vm.inShopProvidersAndTimeslots,
vm.inshopProvidersAndTimeSlots,
timeSlotsResultMap.inshopTimeSlots
);
if (vm.mobileProviderAndTimeSlot) {
@ -269,6 +322,9 @@ export default {
serviceLocationText() {
return this.getCmsContent("ServiceLocationText", "Text");
},
viewMoreShopsText() {
return this.getCmsContent("ViewMoreShopsWidget", "Text");
},
serviceZipCode() {
return store.getters.order.serviceLocation.zipCode;
},
@ -300,7 +356,7 @@ export default {
},
availableDates() {
if (!this.datesLoaded) return null;
const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) =>
const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) =>
(timeSlots?.days ?? []).map((d) => d.date)
);
const mobileDates = (this.mobileProviderAndTimeSlot?.timeSlots?.days ?? []).map(
@ -308,6 +364,10 @@ export default {
);
return [...new Set([...inshopDates, ...mobileDates])].sort();
},
hasMoreShopsAvailable() {
const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length);
return this.inshopProvidersAndTimeSlots.length < maxVisible;
},
},
data() {
return {
@ -316,11 +376,13 @@ export default {
datesLoaded: false,
datePickerStartDate: toDateString(0),
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
inShopProvidersAndTimeslots: [],
inshopProvidersAndTimeSlots: [],
allShopProviders: [],
mobileProviderAndTimeSlot: null,
mobilePremiumAppointmentFee: null,
selectedScheduling: null,
isWaitlistRequested: false,
isLoadingMoreShops: false,
};
},
methods: {
@ -328,7 +390,7 @@ export default {
if (!this.selectedDate) {
return [];
}
const providerEntry = this.inShopProvidersAndTimeslots.find(
const providerEntry = this.inshopProvidersAndTimeSlots.find(
({ provider }) => provider.providerNumber === providerNumber
);
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate);
@ -341,6 +403,45 @@ export default {
onMobileZipCodeClicked() {
// TODO: open service zip modal when zip edit is implemented for scheduling page
},
async onViewMoreShopsClick() {
if (this.isLoadingDates || this.isLoadingMoreShops) return;
const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length);
const remainingCapacity = maxVisible - this.inshopProvidersAndTimeSlots.length;
if (remainingCapacity <= 0) return;
const displayedProviderNumbers = new Set(
this.inshopProvidersAndTimeSlots.map(({ provider }) => provider.providerNumber)
);
const newProviders = this.allShopProviders
.filter((provider) => !displayedProviderNumbers.has(provider.providerNumber))
.slice(0, Math.min(VIEW_MORE_SHOPS_BATCH_SIZE, remainingCapacity));
if (!newProviders.length) return;
const newEntries = newProviders.map((provider) => ({
provider,
timeSlots: null,
isLoadingTimeSlots: true,
}));
this.inshopProvidersAndTimeSlots = [...this.inshopProvidersAndTimeSlots, ...newEntries];
this.isLoadingMoreShops = true;
try {
const resultMap = await fetchTimeSlotsBatch({
startDate: this.datePickerStartDate,
endDate: this.datePickerEndDate,
providerNumbers: newProviders.map((provider) => provider.providerNumber),
pageNameToLog: this.pageName,
});
assignInshopTimeSlotsFromV2Response(newEntries, resultMap.inshopTimeSlots);
} finally {
newEntries.forEach((entry) => {
entry.isLoadingTimeSlots = false;
});
this.isLoadingMoreShops = false;
}
},
arePagePrerequisitesValid() {
const order = store.getters.order;
const logQueue = [];
@ -350,7 +451,7 @@ export default {
const insuranceInfo = hasInsuranceInfo(order, logQueue);
const preReqResult = serviceZip && insuranceInfo && glassPartsOrRepair;
flushPagePrereqsLogs("scheduling.vue", preReqResult, logQueue);
return preReqResult;
},
backButtonAction() {
@ -377,43 +478,21 @@ export default {
const newStartDate = toDateString(1, this.datePickerEndDate);
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
const providerNumbers = this.inShopProvidersAndTimeslots.map(
const providerNumbers = this.inshopProvidersAndTimeSlots.map(
({ provider }) => provider.providerNumber
);
const promiseResultMap = [
...(providerNumbers.length
? [
{
resultKey: "inshopTimeSlots",
promise: fetchInshopTimeSlots({
const resultMap = await fetchTimeSlotsBatch({
startDate: newStartDate,
endDate: newEndDate,
providerNumbers,
pageNameToLog: this.pageName,
}),
},
]
: []),
...(this.mobileProviderAndTimeSlot
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate: newStartDate,
endDate: newEndDate,
zipCode: this.serviceZipCode,
includeMobile: Boolean(this.mobileProviderAndTimeSlot),
pageNameToLog: this.pageName,
}),
},
]
: []),
];
const resultMap = await settleAllPromises(promiseResultMap);
});
appendInshopTimeSlotsFromV2Response(
this.inShopProvidersAndTimeslots,
this.inshopProvidersAndTimeSlots,
resultMap.inshopTimeSlots
);
@ -449,6 +528,7 @@ export default {
inshopSchedulingCard,
interceptOverlay,
waitlistQuestion,
textLink,
},
};
</script>
@ -476,4 +556,19 @@ h5 {
.card-slide-leave-to {
opacity: 0;
}
.chevron-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
transform: rotate(180deg);
transition: transform 150ms linear;
vertical-align: baseline;
}
.spacing-gap {
margin-right: 6.5px;
}
:deep(.text-link) {
font-weight: 600;
}
</style>