947 lines
38 KiB
Vue
947 lines
38 KiB
Vue
<template>
|
|
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
|
|
<interceptOverlay v-if="isLoadingDates || isLoadingMoreShops" />
|
|
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
|
|
<div class="container page-container-grouped-styles">
|
|
<div class="row">
|
|
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
|
|
<funnelSubHeader
|
|
cmsWidgetName="FunnelSubHeaderWidget"
|
|
:overrideHeaderSubText="estimatedTimeText"
|
|
alignLeft />
|
|
<schedulingZipSearch
|
|
ref="schedulingZipSearch"
|
|
v-model="zipSearchCode"
|
|
:disabled="isLoadingDates"
|
|
:pageNameToLog="pageName"
|
|
@zip-searched="onZipSearched" />
|
|
<datePicker
|
|
:key="datePickerKey"
|
|
class="mt-5"
|
|
v-model="selectedDate"
|
|
:startDate="datePickerStartDate"
|
|
:endDate="datePickerEndDate"
|
|
:availableDates="availableDates"
|
|
:isLoadingDates="isLoadingDates"
|
|
@requestMoreDates="handleRequestMoreDates" />
|
|
<Transition name="card-slide" mode="out-in">
|
|
<div :key="selectedDate">
|
|
<mobileSchedulingCard
|
|
v-if="showMobileSchedulingCard"
|
|
class="mt-4"
|
|
v-model="selectedScheduling"
|
|
:providerNumber="mobileProviderAndTimeSlot.providerNumber"
|
|
:timeSlots="mobileTimeSlotsForSelectedDate"
|
|
:premiumTimeSlotPrice="premiumTimeSlotPrice"
|
|
:zipCode="serviceZipCode"
|
|
:showFreeFlag="showMobileFreeFlag"
|
|
:radioGroupName="schedulingRadioGroupName"
|
|
:isLoading="isLoadingDates"
|
|
@zip-code-clicked="onMobileZipCodeClicked" />
|
|
<inshopSchedulingCard
|
|
v-for="entry in inshopProvidersAndTimeSlots"
|
|
v-show="showInshopSchedulingCards"
|
|
:key="entry.provider.providerNumber"
|
|
class="mt-4"
|
|
v-model="selectedScheduling"
|
|
:provider="entry.provider"
|
|
:timeSlots="
|
|
getInshopTimeSlotsForSelectedDate(entry.provider.providerNumber)
|
|
"
|
|
:radioGroupName="schedulingRadioGroupName"
|
|
: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"
|
|
:availableDates="availableDates" />
|
|
<navbar
|
|
cmsWidgetName="FunnelFooterWidget"
|
|
ref="navbar"
|
|
@back-clicked="backButtonAction"
|
|
@ForwardClicked="forwardButtonAction" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<recalAckModal
|
|
modalWidgetName="RecalAckModalWidget"
|
|
agreementWidgetName="RecalAgreementQuestionWidget"
|
|
groupName="recalAckGroup"
|
|
@recalAcknowledged="onRecalAcknowledged"
|
|
ref="recalAckModal" />
|
|
</Form>
|
|
</template>
|
|
|
|
<script>
|
|
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
|
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
|
import navbar from "@/fmg-components/nav-bar/nav-bar";
|
|
import { Form } from "vee-validate";
|
|
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
|
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
|
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 schedulingZipSearch from "@/layouts/scheduling/scheduling-zip-search/scheduling-zip-search";
|
|
import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue";
|
|
import textLink from "@/ux-components/text-link/text-link";
|
|
import store from "@/store";
|
|
import { storeActions } from "@/constants/store-actions";
|
|
import {
|
|
AppointmentTypeStrings,
|
|
PREMIUM_FEE_PART_TYPE,
|
|
RECAL_ACK_YES,
|
|
} from "@/constants/schedule-constants";
|
|
import { isDropOffRouteCode } from "@/layouts/schedule/helpers/schedule-helper";
|
|
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
|
import { settleAllPromises } from "@/helpers/layout-helper";
|
|
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
|
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
|
|
import { partTypeStrings } from "@/constants/part-type-strings";
|
|
import {
|
|
flushPagePrereqsLogs,
|
|
hasServiceZipInfo,
|
|
hasGlassPartsOrRepairInfo,
|
|
hasInsuranceInfo,
|
|
} from "@/helpers/page-prerequisites-helper.js";
|
|
|
|
/**
|
|
* Returns a YYYY-MM-DD date string offset by the given number of days from a base date.
|
|
* @param {number} offsetDays - Number of days to offset (positive or negative).
|
|
* @param {Date} base - The starting date (defaults to today).
|
|
*/
|
|
function toDateString(offsetDays, base = new Date()) {
|
|
// Adding T00:00:00 ensures the date is treated as a local date in the browser's timezone.
|
|
const d = typeof base === "string" ? new Date(base + "T00:00:00") : new Date(base);
|
|
d.setDate(d.getDate() + offsetDays);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
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;
|
|
const INSHOP_MAPS_PLACE_NAME = "safelite,Safelite Autoglass";
|
|
|
|
/**
|
|
* Appends days from newSlots into entry.timeSlots, initializing it if absent.
|
|
* @param {{ timeSlots: { days: any[] } | null }} entry
|
|
* @param {{ days: any[] } | null | undefined} newSlots
|
|
*/
|
|
function appendDays(entry, newSlots) {
|
|
if (!newSlots) return;
|
|
if (!entry.timeSlots) {
|
|
entry.timeSlots = newSlots;
|
|
} else {
|
|
entry.timeSlots.days = [...(entry.timeSlots.days ?? []), ...(newSlots.days ?? [])];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assigns time slots from a v2 multi-provider response onto in-shop provider entries.
|
|
* @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries
|
|
* @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[], provisionalTriggers?: string[] }>, estimatedServiceMinutesMinimum?: number, estimatedServiceMinutesMaximum?: number } | null | undefined} multiProviderResponse
|
|
*/
|
|
function assignInshopTimeSlotsFromV2Response(entries, multiProviderResponse) {
|
|
const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? [];
|
|
entries.forEach((entry) => {
|
|
const matchedSlots =
|
|
providerTimeSlots.find((pts) => pts.providerNumber === entry.provider.providerNumber) ??
|
|
null;
|
|
if (matchedSlots) {
|
|
matchedSlots.estimatedServiceMinutesMinimum =
|
|
matchedSlots.estimatedServiceMinutesMinimum ??
|
|
multiProviderResponse?.estimatedServiceMinutesMinimum;
|
|
matchedSlots.estimatedServiceMinutesMaximum =
|
|
matchedSlots.estimatedServiceMinutesMaximum ??
|
|
multiProviderResponse?.estimatedServiceMinutesMaximum;
|
|
}
|
|
entry.timeSlots = matchedSlots;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Appends days from a v2 multi-provider response onto existing in-shop provider entries.
|
|
* @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries
|
|
* @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[] }> } | null | undefined} multiProviderResponse
|
|
*/
|
|
function appendInshopTimeSlotsFromV2Response(entries, multiProviderResponse) {
|
|
const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? [];
|
|
entries.forEach((entry) => {
|
|
const newSlots = providerTimeSlots.find(
|
|
(pts) => pts.providerNumber === entry.provider.providerNumber
|
|
);
|
|
appendDays(entry, newSlots);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a promise for inshop time slots for multiple providers.
|
|
* @param {{ startDate: string, endDate: string, providerNumbers: string[], pageNameToLog: string }} params
|
|
*/
|
|
function fetchInshopTimeSlots({ startDate, endDate, providerNumbers, pageNameToLog }) {
|
|
return store.dispatch("getShopTimeSlotsV2", {
|
|
payload: {
|
|
startDate,
|
|
endDate,
|
|
shopAppointmentType: "InshopOrDropoff",
|
|
providerNumbers,
|
|
},
|
|
pageNameToLog,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a promise for mobile time slots.
|
|
* @param {{ startDate: string, endDate: string, zipCode: string, pageNameToLog: string }} params
|
|
*/
|
|
function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) {
|
|
return store.dispatch("getMobileTimeSlotsV2", {
|
|
payload: {
|
|
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) {
|
|
const serviceZipCode = store.getters.order.serviceLocation.zipCode;
|
|
|
|
const promiseResultMap = [
|
|
{
|
|
resultKey: "cmsContent",
|
|
promise: fetchCmsContentForPage(to.name),
|
|
},
|
|
{
|
|
resultKey: "mobilePremiumFee",
|
|
promise: store.dispatch("getMobilePremiumFee", {
|
|
pageNameToLog: to.name,
|
|
}),
|
|
},
|
|
{
|
|
resultKey: "providers",
|
|
promise: store.dispatch("getProviders", {
|
|
payload: { serviceZipCode },
|
|
pageNameToLog: to.name,
|
|
}),
|
|
},
|
|
];
|
|
const resultMap = await settleAllPromises(promiseResultMap);
|
|
next(async (vm) => {
|
|
vm.setCmsContent(resultMap.cmsContent);
|
|
await vm.loadSchedulingData(serviceZipCode, {
|
|
providersResult: resultMap.providers,
|
|
pageNameToLog: to.name,
|
|
});
|
|
vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null;
|
|
vm.isLoadingDates = false;
|
|
});
|
|
},
|
|
watch: {
|
|
selectedDate() {
|
|
this.selectedScheduling = null;
|
|
},
|
|
},
|
|
computed: {
|
|
schedulingRadioGroupName() {
|
|
return SCHEDULING_RADIO_GROUP_NAME;
|
|
},
|
|
serviceLocationText() {
|
|
return this.getCmsContent("ServiceLocationText", "Text");
|
|
},
|
|
viewMoreShopsText() {
|
|
return this.getCmsContent("ViewMoreShopsWidget", "Text");
|
|
},
|
|
estimatedTimeText() {
|
|
if (!this.estimatedServiceMinutesMinimum || !this.estimatedServiceMinutesMaximum) {
|
|
return " ";
|
|
}
|
|
const durationText = getDisplayTextForDurationLength(
|
|
this.estimatedServiceMinutesMinimum,
|
|
this.estimatedServiceMinutesMaximum
|
|
);
|
|
return this.getCmsContent("FunnelSubHeaderWidget", "HeaderSubText").replace(
|
|
"{custom:DURATION}",
|
|
durationText
|
|
);
|
|
},
|
|
serviceZipCode() {
|
|
return store.getters.order.serviceLocation.zipCode;
|
|
},
|
|
showMobileSchedulingCard() {
|
|
return Boolean(
|
|
this.mobileProviderAndTimeSlot && (this.selectedDate || this.isLoadingDates)
|
|
);
|
|
},
|
|
showInshopSchedulingCards() {
|
|
return this.selectedDate || this.isLoadingDates;
|
|
},
|
|
mobileTimeSlotsForSelectedDate() {
|
|
if (!this.selectedDate) {
|
|
return [];
|
|
}
|
|
const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find(
|
|
(d) => d.date === this.selectedDate
|
|
);
|
|
return day?.timeSlots ?? [];
|
|
},
|
|
premiumTimeSlotPrice() {
|
|
if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) {
|
|
return null;
|
|
}
|
|
return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee);
|
|
},
|
|
showMobileFreeFlag() {
|
|
return true;
|
|
},
|
|
availableDates() {
|
|
if (!this.datesLoaded) return null;
|
|
const inshopDates = this.inshopProvidersAndTimeSlots.flatMap(({ timeSlots }) =>
|
|
(timeSlots?.days ?? []).map((d) => d.date)
|
|
);
|
|
const mobileDates = (this.mobileProviderAndTimeSlot?.timeSlots?.days ?? []).map(
|
|
(d) => d.date
|
|
);
|
|
return [...new Set([...inshopDates, ...mobileDates])].sort();
|
|
},
|
|
hasMoreShopsAvailable() {
|
|
const maxVisible = Math.min(MAX_INSHOP_PROVIDERS, this.allShopProviders.length);
|
|
return this.inshopProvidersAndTimeSlots.length < maxVisible;
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
selectedDate: null,
|
|
isLoadingDates: true,
|
|
datesLoaded: false,
|
|
datePickerStartDate: toDateString(0),
|
|
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
|
|
estimatedServiceMinutesMinimum: null,
|
|
estimatedServiceMinutesMaximum: null,
|
|
inshopProvidersAndTimeSlots: [],
|
|
allShopProviders: [],
|
|
mobileProviderAndTimeSlot: null,
|
|
mobilePremiumAppointmentFee: null,
|
|
selectedScheduling: null,
|
|
isWaitlistRequested: false,
|
|
isLoadingMoreShops: false,
|
|
zipSearchCode: store.getters.order.serviceLocation.zipCode ?? "",
|
|
datePickerKey: 0,
|
|
billToAccountNumber: store.getters.order.payment?.billToAccountNumber ?? null,
|
|
isRecalAcknowledgedForScheduling:
|
|
store.getters.order.isRecalAcknowledgedForScheduling ?? "",
|
|
};
|
|
},
|
|
methods: {
|
|
async loadSchedulingData(
|
|
serviceZipCode,
|
|
{ providersResult = null, pageNameToLog = this.pageName } = {}
|
|
) {
|
|
let providersData = providersResult;
|
|
if (!providersData) {
|
|
providersData = await store.dispatch("getProviders", {
|
|
payload: { serviceZipCode },
|
|
pageNameToLog,
|
|
});
|
|
}
|
|
if (providersData?.shopProviders === undefined) {
|
|
providersData = providersData?.data ?? {};
|
|
}
|
|
|
|
const allShopProviders = providersData.shopProviders ?? [];
|
|
const providers = allShopProviders.slice(0, INITIAL_INSHOP_PROVIDER_COUNT);
|
|
const mobileProviderNumber = providersData?.mobileProviderNumber ?? null;
|
|
|
|
this.allShopProviders = allShopProviders;
|
|
this.inshopProvidersAndTimeSlots = providers.map((provider) => ({
|
|
provider,
|
|
timeSlots: null,
|
|
}));
|
|
this.mobileProviderAndTimeSlot = mobileProviderNumber
|
|
? { providerNumber: mobileProviderNumber, timeSlots: null }
|
|
: null;
|
|
|
|
var gaLabel = this.GaLabels.NO;
|
|
if (this.mobileProviderAndTimeSlot) {
|
|
gaLabel = this.GaLabels.YES;
|
|
}
|
|
this.pushEventToGA(
|
|
this.GaCategories.APPOINTMENT,
|
|
this.GaActions.MOBILE_AVAILABLE,
|
|
gaLabel,
|
|
true
|
|
);
|
|
|
|
const startDate = toDateString(0);
|
|
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
|
|
const providerNumbers = providers.map((provider) => provider.providerNumber);
|
|
const timeSlotsResultMap = await fetchTimeSlotsBatch({
|
|
startDate,
|
|
endDate,
|
|
providerNumbers,
|
|
zipCode: serviceZipCode,
|
|
includeMobile: Boolean(mobileProviderNumber),
|
|
pageNameToLog,
|
|
});
|
|
|
|
if (timeSlotsResultMap.inshopTimeSlots) {
|
|
this.estimatedServiceMinutesMinimum =
|
|
timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMinimum;
|
|
this.estimatedServiceMinutesMaximum =
|
|
timeSlotsResultMap.inshopTimeSlots.estimatedServiceMinutesMaximum;
|
|
} else if (timeSlotsResultMap.mobileTimeSlots) {
|
|
this.estimatedServiceMinutesMinimum =
|
|
timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMinimum;
|
|
this.estimatedServiceMinutesMaximum =
|
|
timeSlotsResultMap.mobileTimeSlots.estimatedServiceMinutesMaximum;
|
|
} else {
|
|
this.estimatedServiceMinutesMinimum = null;
|
|
this.estimatedServiceMinutesMaximum = null;
|
|
}
|
|
|
|
assignInshopTimeSlotsFromV2Response(
|
|
this.inshopProvidersAndTimeSlots,
|
|
timeSlotsResultMap.inshopTimeSlots
|
|
);
|
|
if (this.mobileProviderAndTimeSlot) {
|
|
this.mobileProviderAndTimeSlot.timeSlots =
|
|
timeSlotsResultMap.mobileTimeSlots ?? null;
|
|
}
|
|
this.datesLoaded = true;
|
|
},
|
|
getInshopTimeSlotsForSelectedDate(providerNumber) {
|
|
if (!this.selectedDate) {
|
|
return [];
|
|
}
|
|
const providerEntry = this.inshopProvidersAndTimeSlots.find(
|
|
({ provider }) => provider.providerNumber === providerNumber
|
|
);
|
|
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate);
|
|
return day?.timeSlots ?? [];
|
|
},
|
|
buildInshopMapsQuery(address) {
|
|
const streetAddress = address?.streetAddress?.trim();
|
|
if (!streetAddress) {
|
|
return null;
|
|
}
|
|
const street = [streetAddress, address.streetAddress2?.trim()]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return `${INSHOP_MAPS_PLACE_NAME}, ${street}, ${address.city}, ${address.state} ${address.zipCode}`;
|
|
},
|
|
buildInshopMapsUrl(address) {
|
|
const query = this.buildInshopMapsQuery(address);
|
|
if (!query) {
|
|
return null;
|
|
}
|
|
if (this.isAppleBrowser()) {
|
|
return `https://maps.apple.com/?q=${encodeURIComponent(query)}`;
|
|
}
|
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
|
},
|
|
onInshopAddressClicked(provider) {
|
|
const url = this.buildInshopMapsUrl(provider?.address);
|
|
if (!url) {
|
|
return;
|
|
}
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
},
|
|
onMobileZipCodeClicked() {
|
|
this.$refs.schedulingZipSearch?.focusZipInput();
|
|
},
|
|
async onZipSearched({ zipCode, billToAccountNumber }) {
|
|
// When recal ack applies, changing zip must re-run parts selection via service-zip.
|
|
if (this.shouldShowRecalAckModal()) {
|
|
this.$router.navigateWithSaving(
|
|
this.navigationScenarios.ZIP_CODE_CHANGED_RECAL_ACK,
|
|
this.pageName
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.billToAccountNumber = billToAccountNumber;
|
|
this.isLoadingDates = true;
|
|
try {
|
|
this.selectedDate = null;
|
|
this.selectedScheduling = null;
|
|
this.isWaitlistRequested = false;
|
|
this.isLoadingMoreShops = false;
|
|
this.datesLoaded = false;
|
|
this.datePickerKey += 1;
|
|
this.datePickerStartDate = toDateString(0);
|
|
this.datePickerEndDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
|
|
await this.loadSchedulingData(zipCode);
|
|
} finally {
|
|
this.isLoadingDates = false;
|
|
}
|
|
},
|
|
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 = [];
|
|
|
|
const serviceZip = hasServiceZipInfo(order, logQueue);
|
|
const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
|
|
const insuranceInfo = hasInsuranceInfo(order, logQueue);
|
|
|
|
const preReqResult = serviceZip && insuranceInfo && glassPartsOrRepair;
|
|
flushPagePrereqsLogs("scheduling.vue", preReqResult, logQueue);
|
|
return preReqResult;
|
|
},
|
|
backButtonAction() {
|
|
const payment = store.getters.order.payment;
|
|
if (
|
|
payment?.isInsurance &&
|
|
(payment?.insuranceCoverage?.isVerified ||
|
|
store.getters.order.referralNumber.length === 6)
|
|
) {
|
|
navigateToHeritageFunnel({
|
|
shouldSaveSession: false,
|
|
pageNameToLog: this.pageName,
|
|
navType: "back",
|
|
});
|
|
} else {
|
|
this.$router.navigateWithoutSaving(
|
|
this.navigationScenarios.CLICKED_BACK,
|
|
this.pageName
|
|
);
|
|
}
|
|
},
|
|
async handleRequestMoreDates() {
|
|
this.isLoadingDates = true;
|
|
|
|
const newStartDate = toDateString(1, this.datePickerEndDate);
|
|
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
|
|
const providerNumbers = this.inshopProvidersAndTimeSlots.map(
|
|
({ provider }) => provider.providerNumber
|
|
);
|
|
|
|
const resultMap = await fetchTimeSlotsBatch({
|
|
startDate: newStartDate,
|
|
endDate: newEndDate,
|
|
providerNumbers,
|
|
zipCode: this.serviceZipCode,
|
|
includeMobile: Boolean(this.mobileProviderAndTimeSlot),
|
|
pageNameToLog: this.pageName,
|
|
});
|
|
|
|
appendInshopTimeSlotsFromV2Response(
|
|
this.inshopProvidersAndTimeSlots,
|
|
resultMap.inshopTimeSlots
|
|
);
|
|
|
|
if (this.mobileProviderAndTimeSlot) {
|
|
appendDays(this.mobileProviderAndTimeSlot, resultMap.mobileTimeSlots);
|
|
}
|
|
|
|
this.datePickerEndDate = newEndDate;
|
|
this.isLoadingDates = false;
|
|
},
|
|
getInShopOrDropOffApptType(selectedScheduling = this.selectedScheduling) {
|
|
if (selectedScheduling?.appointmentType === AppointmentTypeStrings.MOBILE) {
|
|
return AppointmentTypeStrings.MOBILE;
|
|
}
|
|
return isDropOffRouteCode(selectedScheduling?.routeCodeId)
|
|
? AppointmentTypeStrings.DROP_OFF
|
|
: AppointmentTypeStrings.IN_SHOP;
|
|
},
|
|
getSelectedProvider(appointmentType, selectedScheduling = this.selectedScheduling) {
|
|
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
|
return {
|
|
providerNumber: selectedScheduling?.providerNumber,
|
|
address: {
|
|
streetAddress: null,
|
|
city: null,
|
|
state: null,
|
|
zipCode: null,
|
|
zipCodeCtu: null,
|
|
},
|
|
};
|
|
}
|
|
return (
|
|
this.inshopProvidersAndTimeSlots.find(
|
|
({ provider }) => provider.providerNumber === selectedScheduling?.providerNumber
|
|
)?.provider ?? null
|
|
);
|
|
},
|
|
getSelectedTimeSlot(
|
|
appointmentType,
|
|
selectedScheduling = this.selectedScheduling,
|
|
selectedDate = this.selectedDate
|
|
) {
|
|
const routeCodeId = selectedScheduling?.routeCodeId;
|
|
if (!routeCodeId || !selectedDate) {
|
|
return null;
|
|
}
|
|
|
|
let timeSlots;
|
|
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
|
const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find(
|
|
(d) => d.date === selectedDate
|
|
);
|
|
timeSlots = day?.timeSlots ?? [];
|
|
} else {
|
|
const providerEntry = this.inshopProvidersAndTimeSlots.find(
|
|
({ provider }) => provider.providerNumber === selectedScheduling.providerNumber
|
|
);
|
|
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === selectedDate);
|
|
timeSlots = day?.timeSlots ?? [];
|
|
}
|
|
|
|
return timeSlots.find((timeSlot) => timeSlot.id === routeCodeId) ?? null;
|
|
},
|
|
getEstimatedServiceMinutes(appointmentType, selectedScheduling = this.selectedScheduling) {
|
|
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
|
return {
|
|
minimum:
|
|
this.mobileProviderAndTimeSlot?.timeSlots?.estimatedServiceMinutesMinimum,
|
|
maximum:
|
|
this.mobileProviderAndTimeSlot?.timeSlots?.estimatedServiceMinutesMaximum,
|
|
};
|
|
}
|
|
|
|
const providerEntry = this.inshopProvidersAndTimeSlots.find(
|
|
({ provider }) => provider.providerNumber === selectedScheduling?.providerNumber
|
|
);
|
|
return {
|
|
minimum: providerEntry?.timeSlots?.estimatedServiceMinutesMinimum,
|
|
maximum: providerEntry?.timeSlots?.estimatedServiceMinutesMaximum,
|
|
};
|
|
},
|
|
updateSupportingItems(selectedScheduling = this.selectedScheduling) {
|
|
const supportingItems = store.getters.lineItems?.supportingItems;
|
|
if (!supportingItems) {
|
|
return;
|
|
}
|
|
|
|
const isPremiumMobile =
|
|
selectedScheduling?.appointmentType === AppointmentTypeStrings.MOBILE &&
|
|
selectedScheduling?.isPremiumAppointment;
|
|
|
|
if (isPremiumMobile && this.mobilePremiumAppointmentFee) {
|
|
const premiumFeeIndex = supportingItems.findIndex(
|
|
(item) => item.partType == PREMIUM_FEE_PART_TYPE
|
|
);
|
|
|
|
if (premiumFeeIndex > -1) {
|
|
supportingItems[premiumFeeIndex].laborAmount =
|
|
this.mobilePremiumAppointmentFee.laborAmount;
|
|
supportingItems[premiumFeeIndex].sellingPrice =
|
|
this.mobilePremiumAppointmentFee.sellingPrice;
|
|
supportingItems[premiumFeeIndex].kitPrice =
|
|
this.mobilePremiumAppointmentFee.kitPrice;
|
|
} else {
|
|
supportingItems.push(this.mobilePremiumAppointmentFee);
|
|
}
|
|
} else {
|
|
const removePremiumFeeIndex = supportingItems.findIndex(
|
|
(item) => item.partType == PREMIUM_FEE_PART_TYPE
|
|
);
|
|
if (removePremiumFeeIndex >= 0) {
|
|
supportingItems.splice(removePremiumFeeIndex, 1);
|
|
}
|
|
}
|
|
|
|
this.dispatchStoreAction(
|
|
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
|
|
supportingItems,
|
|
false
|
|
);
|
|
},
|
|
async forwardButtonAction() {
|
|
if (this.shouldShowRecalAckModal()) {
|
|
this.$refs.navbar.removeLoader();
|
|
this.$refs.recalAckModal.openModal();
|
|
return;
|
|
}
|
|
|
|
const selectedScheduling = this.selectedScheduling;
|
|
const selectedDate = this.selectedDate;
|
|
|
|
await store.dispatch(
|
|
storeActions.SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING,
|
|
this.isRecalAcknowledgedForScheduling
|
|
);
|
|
|
|
const appointmentType = this.getInShopOrDropOffApptType(selectedScheduling);
|
|
const selectedProvider = this.getSelectedProvider(appointmentType, selectedScheduling);
|
|
const serviceLocation = store.getters.order.serviceLocation;
|
|
const isMobile = appointmentType === AppointmentTypeStrings.MOBILE;
|
|
|
|
let zipCodeCtu = serviceLocation.zipCodeCtu;
|
|
if (
|
|
!isMobile &&
|
|
selectedProvider?.address?.zipCodeCtu &&
|
|
zipCodeCtu !== selectedProvider.address.zipCodeCtu
|
|
) {
|
|
zipCodeCtu = selectedProvider.address.zipCodeCtu;
|
|
}
|
|
|
|
await this.dispatchStoreAction(
|
|
this.storeActions.SAVE_SERVICE_LOCATION,
|
|
{
|
|
address: isMobile ? serviceLocation.address : "",
|
|
address2: isMobile ? serviceLocation.address2 : "",
|
|
city: isMobile ? serviceLocation.city : "",
|
|
state: serviceLocation.state,
|
|
zipCode: serviceLocation.zipCode,
|
|
zipCodeCtu: zipCodeCtu,
|
|
appointmentType,
|
|
isVehicleProtected: isMobile ? serviceLocation.isVehicleProtected : null,
|
|
provider: {
|
|
providerNumber: selectedProvider?.providerNumber,
|
|
address: {
|
|
streetAddress: selectedProvider?.address?.streetAddress ?? null,
|
|
city: selectedProvider?.address?.city ?? null,
|
|
state: selectedProvider?.address?.state ?? null,
|
|
zipCode: selectedProvider?.address?.zipCode ?? null,
|
|
zipCodeCtu: selectedProvider?.address?.zipCodeCtu ?? null,
|
|
},
|
|
},
|
|
},
|
|
false
|
|
);
|
|
|
|
if (this.billToAccountNumber) {
|
|
this.dispatchStoreAction(
|
|
this.storeActions.SAVE_BILL_TO_ACCOUNT_NUMBER,
|
|
this.billToAccountNumber,
|
|
false
|
|
);
|
|
}
|
|
|
|
this.updateSupportingItems(selectedScheduling);
|
|
|
|
const selectedTimeSlot = this.getSelectedTimeSlot(
|
|
appointmentType,
|
|
selectedScheduling,
|
|
selectedDate
|
|
);
|
|
const estimatedServiceMinutes = this.getEstimatedServiceMinutes(
|
|
appointmentType,
|
|
selectedScheduling
|
|
);
|
|
|
|
this.dispatchStoreAction(
|
|
this.storeActions.SAVE_SCHEDULE,
|
|
{
|
|
date: selectedDate,
|
|
routeCode: selectedScheduling?.routeCodeId ?? null,
|
|
startTime: selectedTimeSlot?.startTime ?? null,
|
|
endTime: selectedTimeSlot?.endTime ?? null,
|
|
jobMinMinutes: estimatedServiceMinutes.minimum?.toString() ?? null,
|
|
jobMaxMinutes: estimatedServiceMinutes.maximum?.toString() ?? null,
|
|
},
|
|
false
|
|
);
|
|
|
|
if (this.isWaitlistRequested !== null && this.isWaitlistRequested !== undefined) {
|
|
this.dispatchStoreAction(
|
|
this.storeActions.SAVE_WAITLIST_REQUESTED,
|
|
this.isWaitlistRequested,
|
|
false
|
|
);
|
|
}
|
|
|
|
if (isMobile) {
|
|
this.$router.navigateWithSaving(
|
|
this.navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
|
|
this.pageName
|
|
);
|
|
} else {
|
|
this.$router.navigateWithSaving(
|
|
this.navigationScenarios.CLICKED_FORWARD,
|
|
this.pageName
|
|
);
|
|
}
|
|
},
|
|
shouldShowRecalAckModal() {
|
|
if (this.isRecalAcknowledgedForScheduling === RECAL_ACK_YES) {
|
|
return false;
|
|
}
|
|
|
|
const glassParts = store.getters.order.lineItems?.glassParts ?? [];
|
|
return glassParts.some(
|
|
(part) =>
|
|
part.partType === partTypeStrings.WINDSHIELD &&
|
|
part.requiresRecalibration === true &&
|
|
part.canSafeliteRecalibrate === false
|
|
);
|
|
},
|
|
onRecalAcknowledged(isAcknowledged) {
|
|
this.isRecalAcknowledgedForScheduling = isAcknowledged;
|
|
this.forwardButtonAction();
|
|
},
|
|
},
|
|
components: {
|
|
funnelHeader,
|
|
funnelSubHeader,
|
|
navbar,
|
|
Form,
|
|
datePicker,
|
|
mobileSchedulingCard,
|
|
inshopSchedulingCard,
|
|
interceptOverlay,
|
|
waitlistQuestion,
|
|
schedulingZipSearch,
|
|
recalAckModal,
|
|
textLink,
|
|
},
|
|
};
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
.dark-header {
|
|
color: $black;
|
|
}
|
|
h5 {
|
|
line-height: 32px;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.card-slide-enter-active {
|
|
transition:
|
|
opacity 0.3s ease-out,
|
|
transform 0.3s ease-out;
|
|
}
|
|
.card-slide-leave-active {
|
|
transition: opacity 0.32s ease-in;
|
|
}
|
|
.card-slide-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(24px);
|
|
}
|
|
.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>
|