DigitalConsumer.FixMyGlass/src/layouts/scheduling/scheduling.vue
Carl Nation 6d4b961d4b CASH-3108 Items to update on zip code change
CASH-3108 Items to update on zip code change
When the zip code changes on the new scheduling page, we need to reprice the recycle and mobile fees for the new zip and add to supporting items.  We need to add and remove these fees depending on the user selection. ie: mobile appointments.

Set MSR flags, isMSRFeeApplicable  & isMSRFeeCoveredByInsurance, when MSR applies and it’s a mobile appointment.
2026-08-04 06:37:09 -04:00

1143 lines
46 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 { partNumberStrings } from "@/constants/part-number-strings";
import baseMixin from "@/mixins/base-mixin";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
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 {
getPricedMobileFeePart,
getPricedRecycleFeePart,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-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";
const MOBILE_FEE_PART_TYPE = "MOBILE FEE";
/**
* 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,
}),
},
]
: []),
]);
}
/**
* Fetches serviceability details for a given zip code.
* @param {{ serviceZipCode: string, lineItems: any, pageNameToLog: string }} params
* @returns {Promise<{ isGlassServiceableInshop: boolean, isRecalibrationServiceableInshop: boolean, isGlassServiceableDropoff: boolean, isRecalibrationServiceableDropoff: boolean, isGlassServiceableMobile: boolean, isRecalibrationServiceableMobile: boolean }>}
*/
function getServiceabilityDetails(serviceZipCode, pageNameToLog) {
const lineItems = store.getters.lineItems;
return baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_SERVICEABILITY_DETAILS,
{
serviceZipCode: serviceZipCode,
lineItems: lineItems,
},
pageNameToLog,
false
);
}
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,
}),
},
{
resultKey: "mobileFeePart",
promise: getPricedMobileFeePart(serviceZipCode, to.name),
},
{
resultKey: "recycleFeePart",
promise: getPricedRecycleFeePart(serviceZipCode, 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.mobileFeePart = resultMap.mobileFeePart ?? null;
vm.recycleFeePart = resultMap.recycleFeePart ?? 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;
},
isInsurance() {
return store.getters.order.payment?.isInsurance;
},
isITAC() {
return store.getters.order.policy?.isItac;
},
isNoComp() {
return store.getters.order.policy?.isNoComp;
},
isCashItacNoComp() {
return !this.isInsurance || this.isITAC || this.isNoComp;
},
displayMSR() {
return (
experimentMixin.methods
.getSettingValue(experimentSettings.DISPLAY_MSR)
?.toLowerCase() === "true"
);
},
enableMSRSplitPay() {
return (
experimentMixin.methods
.getSettingValue(experimentSettings.ENABLE_MSR_SPLIT_PAY)
?.toLowerCase() === "true"
);
},
isMSRFeePartNumber() {
return (
this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_STATIC_RECAL_FEE ||
this.mobileFeePart?.partNumber === partNumberStrings.MOBILE_DUAL_RECAL_FEE
);
},
isMobileStaticRecalibrationApplicable() {
return (
this.displayMSR &&
this.isMSRFeePartNumber &&
(this.enableMSRSplitPay || this.isCashItacNoComp || this.mobileFeePart?.isInsurable)
);
},
},
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,
mobileFeePart: null,
recycleFeePart: 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 ?? "",
serviceabilityDetails: {
isGlassServiceableInshop: false,
isRecalibrationServiceableInshop: false,
isGlassServiceableDropoff: false,
isRecalibrationServiceableDropoff: false,
isGlassServiceableMobile: false,
isRecalibrationServiceableMobile: false,
},
};
},
methods: {
async loadFeeParts(serviceZipCode, pageNameToLog = this.pageName) {
const [mobileFeePart, recycleFeePart] = await Promise.all([
getPricedMobileFeePart(serviceZipCode, pageNameToLog),
getPricedRecycleFeePart(serviceZipCode, pageNameToLog),
]);
this.mobileFeePart = mobileFeePart ?? null;
this.recycleFeePart = recycleFeePart ?? null;
},
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;
const serviceabilityDetailsResultMap = await getServiceabilityDetails(
serviceZipCode,
pageNameToLog
);
if (serviceabilityDetailsResultMap?.data) {
this.serviceabilityDetails = serviceabilityDetailsResultMap.data;
}
},
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;
}
const serviceabilityDetailsResultMap = await getServiceabilityDetails(
zipCode,
this.pageName
);
if (serviceabilityDetailsResultMap?.data) {
this.serviceabilityDetails = serviceabilityDetailsResultMap.data;
}
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 Promise.all([this.loadSchedulingData(zipCode), this.loadFeeParts(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,
};
},
updateAndSaveSupportingItems(appointmentType) {
let supportingItems = store.getters.lineItems?.supportingItems;
let shouldSaveSupportingItems = false;
supportingItems =
!supportingItems && this.isMobileStaticRecalibrationApplicable
? []
: supportingItems;
// for insurance orders, fees can get removed causing supportingitems to be null or empty
if (!supportingItems) {
return;
}
// update recycle fee price
if (this.recycleFeePart) {
const recycleFeeIndex = supportingItems.findIndex(
(item) => item.partNumber == partNumberStrings.RECYCLE_FEE
);
if (recycleFeeIndex >= 0) {
supportingItems[recycleFeeIndex].laborAmount = this.recycleFeePart.laborAmount;
supportingItems[recycleFeeIndex].sellingPrice =
this.recycleFeePart.sellingPrice;
supportingItems[recycleFeeIndex].kitPrice = this.recycleFeePart.kitPrice;
shouldSaveSupportingItems = true;
}
}
// if we have a mobile fee, then save/update supporting items
if (appointmentType === AppointmentTypeStrings.MOBILE) {
if (this.mobileFeePart) {
const mobileFeeIndex = supportingItems.findIndex(
(item) => item.partType == MOBILE_FEE_PART_TYPE
);
if (mobileFeeIndex >= 0) {
supportingItems[mobileFeeIndex].laborAmount =
this.mobileFeePart.laborAmount;
supportingItems[mobileFeeIndex].sellingPrice =
this.mobileFeePart.sellingPrice;
supportingItems[mobileFeeIndex].kitPrice = this.mobileFeePart.kitPrice;
supportingItems[mobileFeeIndex].isInsurable =
this.mobileFeePart.isInsurable;
} else {
supportingItems.push(this.mobileFeePart);
}
shouldSaveSupportingItems = true;
}
} else {
const removeMobileFeeIndex = supportingItems.findIndex(
(item) => item.partType == MOBILE_FEE_PART_TYPE
);
if (removeMobileFeeIndex >= 0) {
supportingItems.splice(removeMobileFeeIndex, 1);
shouldSaveSupportingItems = true;
}
}
if (shouldSaveSupportingItems) {
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
}
},
updateAndSaveIsMSRFeeApplicable(appointmentType) {
const isMSRFeeApplicable =
this.isMobileStaticRecalibrationApplicable &&
appointmentType === AppointmentTypeStrings.MOBILE;
this.dispatchStoreAction(
this.storeActions.SAVE_IS_MSR_FEE_APPLICABLE,
isMSRFeeApplicable
);
},
updateAndSaveIsMSRFeeCoveredByInsurance() {
const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable;
this.dispatchStoreAction(
this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE,
isMSRFeeCoveredByInsurance
);
},
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.updateAndSaveSupportingItems(appointmentType);
this.updateAndSaveIsMSRFeeApplicable(appointmentType);
this.updateAndSaveIsMSRFeeCoveredByInsurance();
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>