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

View file

@ -1,6 +1,6 @@
<template> <template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm"> <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<interceptOverlay v-if="isLoadingDates" /> <interceptOverlay v-if="isLoadingDates || isLoadingMoreShops" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" /> <funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<div class="container page-container-grouped-styles"> <div class="container page-container-grouped-styles">
<div class="row"> <div class="row">
@ -34,20 +34,39 @@
:isLoading="isLoadingDates" :isLoading="isLoadingDates"
@zip-code-clicked="onMobileZipCodeClicked" /> @zip-code-clicked="onMobileZipCodeClicked" />
<inshopSchedulingCard <inshopSchedulingCard
v-for="{ provider } in inShopProvidersAndTimeslots" v-for="entry in inshopProvidersAndTimeSlots"
v-show="showInshopSchedulingCards" v-show="showInshopSchedulingCards"
:key="provider.providerNumber" :key="entry.provider.providerNumber"
class="mt-4" class="mt-4"
v-model="selectedScheduling" v-model="selectedScheduling"
:provider="provider" :provider="entry.provider"
:timeSlots=" :timeSlots="
getInshopTimeSlotsForSelectedDate(provider.providerNumber) getInshopTimeSlotsForSelectedDate(entry.provider.providerNumber)
" "
:radioGroupName="schedulingRadioGroupName" :radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates" :isLoading="isLoadingDates || Boolean(entry.isLoadingTimeSlots)"
@address-clicked="onInshopAddressClicked(provider)" /> @address-clicked="onInshopAddressClicked(entry.provider)" />
</div> </div>
</Transition> </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 <waitlistQuestion
class="mt-5" class="mt-5"
v-model="isWaitlistRequested" 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 inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay"; import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
import waitlistQuestion from "@/layouts/scheduling/waitlist-question/waitlist-question"; import waitlistQuestion from "@/layouts/scheduling/waitlist-question/waitlist-question";
import textLink from "@/ux-components/text-link/text-link";
import store from "@/store"; import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -84,7 +104,6 @@ import {
hasGlassPartsOrRepairInfo, hasGlassPartsOrRepairInfo,
hasInsuranceInfo, hasInsuranceInfo,
} from "@/helpers/page-prerequisites-helper.js"; } 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. * 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 SCHEDULE_FETCH_DAYS = 15;
const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot"; 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. * 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 { export default {
name: "scheduling", name: "scheduling",
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -200,12 +274,13 @@ export default {
}, },
]; ];
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
// Get the first 3 providers from the shopProviderData const allShopProviders = resultMap.providers?.shopProviders ?? [];
const providers = resultMap.providers?.shopProviders?.slice(0, 3) ?? []; const providers = allShopProviders.slice(0, INITIAL_INSHOP_PROVIDER_COUNT);
const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null; const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null;
next(async (vm) => { next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
vm.inShopProvidersAndTimeslots = providers.map((provider) => ({ vm.allShopProviders = allShopProviders;
vm.inshopProvidersAndTimeSlots = providers.map((provider) => ({
provider, provider,
timeSlots: null, timeSlots: null,
})); }));
@ -215,38 +290,16 @@ export default {
const startDate = toDateString(0); const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1); const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const providerNumbers = providers.map((provider) => provider.providerNumber); const providerNumbers = providers.map((provider) => provider.providerNumber);
const timeSlotsPromiseResultMap = [ const timeSlotsResultMap = await fetchTimeSlotsBatch({
...(providerNumbers.length startDate,
? [ endDate,
{ providerNumbers,
resultKey: "inshopTimeSlots", zipCode: serviceZipCode,
promise: fetchInshopTimeSlots({ includeMobile: Boolean(mobileProviderNumber),
startDate, pageNameToLog: to.name,
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,
pageNameToLog: to.name,
}),
},
]
: []),
];
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
assignInshopTimeSlotsFromV2Response( assignInshopTimeSlotsFromV2Response(
vm.inShopProvidersAndTimeslots, vm.inshopProvidersAndTimeSlots,
timeSlotsResultMap.inshopTimeSlots timeSlotsResultMap.inshopTimeSlots
); );
if (vm.mobileProviderAndTimeSlot) { if (vm.mobileProviderAndTimeSlot) {
@ -269,6 +322,9 @@ export default {
serviceLocationText() { serviceLocationText() {
return this.getCmsContent("ServiceLocationText", "Text"); return this.getCmsContent("ServiceLocationText", "Text");
}, },
viewMoreShopsText() {
return this.getCmsContent("ViewMoreShopsWidget", "Text");
},
serviceZipCode() { serviceZipCode() {
return store.getters.order.serviceLocation.zipCode; return store.getters.order.serviceLocation.zipCode;
}, },
@ -300,7 +356,7 @@ export default {
}, },
availableDates() { availableDates() {
if (!this.datesLoaded) return null; if (!this.datesLoaded) return null;
const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) => const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) =>
(timeSlots?.days ?? []).map((d) => d.date) (timeSlots?.days ?? []).map((d) => d.date)
); );
const mobileDates = (this.mobileProviderAndTimeSlot?.timeSlots?.days ?? []).map( const mobileDates = (this.mobileProviderAndTimeSlot?.timeSlots?.days ?? []).map(
@ -308,6 +364,10 @@ export default {
); );
return [...new Set([...inshopDates, ...mobileDates])].sort(); return [...new Set([...inshopDates, ...mobileDates])].sort();
}, },
hasMoreShopsAvailable() {
const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length);
return this.inshopProvidersAndTimeSlots.length < maxVisible;
},
}, },
data() { data() {
return { return {
@ -316,11 +376,13 @@ export default {
datesLoaded: false, datesLoaded: false,
datePickerStartDate: toDateString(0), datePickerStartDate: toDateString(0),
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1), datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
inShopProvidersAndTimeslots: [], inshopProvidersAndTimeSlots: [],
allShopProviders: [],
mobileProviderAndTimeSlot: null, mobileProviderAndTimeSlot: null,
mobilePremiumAppointmentFee: null, mobilePremiumAppointmentFee: null,
selectedScheduling: null, selectedScheduling: null,
isWaitlistRequested: false, isWaitlistRequested: false,
isLoadingMoreShops: false,
}; };
}, },
methods: { methods: {
@ -328,7 +390,7 @@ export default {
if (!this.selectedDate) { if (!this.selectedDate) {
return []; return [];
} }
const providerEntry = this.inShopProvidersAndTimeslots.find( const providerEntry = this.inshopProvidersAndTimeSlots.find(
({ provider }) => provider.providerNumber === providerNumber ({ provider }) => provider.providerNumber === providerNumber
); );
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate); const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate);
@ -341,6 +403,45 @@ export default {
onMobileZipCodeClicked() { onMobileZipCodeClicked() {
// TODO: open service zip modal when zip edit is implemented for scheduling page // 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() { arePagePrerequisitesValid() {
const order = store.getters.order; const order = store.getters.order;
const logQueue = []; const logQueue = [];
@ -350,7 +451,7 @@ export default {
const insuranceInfo = hasInsuranceInfo(order, logQueue); const insuranceInfo = hasInsuranceInfo(order, logQueue);
const preReqResult = serviceZip && insuranceInfo && glassPartsOrRepair; const preReqResult = serviceZip && insuranceInfo && glassPartsOrRepair;
flushPagePrereqsLogs("scheduling.vue", preReqResult, logQueue);
return preReqResult; return preReqResult;
}, },
backButtonAction() { backButtonAction() {
@ -377,43 +478,21 @@ export default {
const newStartDate = toDateString(1, this.datePickerEndDate); const newStartDate = toDateString(1, this.datePickerEndDate);
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate); const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
const providerNumbers = this.inShopProvidersAndTimeslots.map( const providerNumbers = this.inshopProvidersAndTimeSlots.map(
({ provider }) => provider.providerNumber ({ provider }) => provider.providerNumber
); );
const promiseResultMap = [ const resultMap = await fetchTimeSlotsBatch({
...(providerNumbers.length startDate: newStartDate,
? [ endDate: newEndDate,
{ providerNumbers,
resultKey: "inshopTimeSlots", zipCode: this.serviceZipCode,
promise: fetchInshopTimeSlots({ includeMobile: Boolean(this.mobileProviderAndTimeSlot),
startDate: newStartDate, pageNameToLog: this.pageName,
endDate: newEndDate, });
providerNumbers,
pageNameToLog: this.pageName,
}),
},
]
: []),
...(this.mobileProviderAndTimeSlot
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate: newStartDate,
endDate: newEndDate,
zipCode: this.serviceZipCode,
pageNameToLog: this.pageName,
}),
},
]
: []),
];
const resultMap = await settleAllPromises(promiseResultMap);
appendInshopTimeSlotsFromV2Response( appendInshopTimeSlotsFromV2Response(
this.inShopProvidersAndTimeslots, this.inshopProvidersAndTimeSlots,
resultMap.inshopTimeSlots resultMap.inshopTimeSlots
); );
@ -449,6 +528,7 @@ export default {
inshopSchedulingCard, inshopSchedulingCard,
interceptOverlay, interceptOverlay,
waitlistQuestion, waitlistQuestion,
textLink,
}, },
}; };
</script> </script>
@ -476,4 +556,19 @@ h5 {
.card-slide-leave-to { .card-slide-leave-to {
opacity: 0; 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> </style>