Merge pull request #3293 from Safelite/feature/CASH-2820
CASH-2820 | View More Shops
This commit is contained in:
commit
9ce6b3b412
2 changed files with 178 additions and 83 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue