Initial Schedule Date Picker check-in ISS

This commit is contained in:
Bill Richardson 2023-10-03 15:33:01 -04:00
parent ef72f212f7
commit 4afa129284
12 changed files with 2218 additions and 17 deletions

View file

@ -15,6 +15,18 @@ const endpoints = Object.freeze({
url: '/location/api/v1/location/alert-reasons',
method: 'GET'
},
GetShopTimeSlots: {
url: '/schedule/api/v1/schedule/shop-time-slots',
method: 'POST'
},
GetMobileTimeSlots: {
url: '/schedule/api/v1/schedule/mobile-time-slots',
method: 'POST'
},
GetMobilePremiumFee: {
url: '/parts/api/v1/parts/mobile-premium-fee',
method: 'GET'
},
GetVehicleYears: {
url: '/vehicle/api/v1/vehicle/years',
method: 'GET'

View file

@ -11,4 +11,14 @@ const RouteCodeFlags = {
OVERNIGHT_DROP_OFF: 'OVERNIGHT DROP OFF'
};
export { AppointmentTypeStrings, PREMIUM_TIME_SLOT_ID_FLAG, PREMIUM_FEE_PART_TYPE, RouteCodeFlags };
const GET_SHOP_TIME_SLOTS = 'getShopTimeSlots';
const GET_MOBILE_TIME_SLOTS = 'getMobileTimeSlots';
export {
AppointmentTypeStrings,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE,
RouteCodeFlags,
GET_SHOP_TIME_SLOTS,
GET_MOBILE_TIME_SLOTS
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
const TIMINGFUNC_MAP = {
linear: (t) => t,
'ease-in': (t) => t * t,
'ease-out': (t) => t * (2 - t),
'ease-in-out': (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
};
const BUFFER_OFFSET = 10;
const MONTHS_OF_YEAR = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
const DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export { TIMINGFUNC_MAP, BUFFER_OFFSET, MONTHS_OF_YEAR, DAYS_OF_WEEK };

View file

@ -0,0 +1,10 @@
const selectableDaysOptions = Object.freeze({
CUSTOM: 'custom',
PAST: 'past'
});
const requiredParameter = () => {
throw new Error('parameter is required');
};
export { selectableDaysOptions, requiredParameter };

View file

@ -0,0 +1,12 @@
const getDateDifferenceInDays = (startDate, endDate) => {
const date1 = new Date(endDate);
date1.setHours(0, 0, 0, 0);
const date2 = new Date(startDate);
date2.setHours(0, 0, 0, 0);
// To calculate the time difference of two dates
const DifferenceInTime = date1.getTime() - date2.getTime();
// To calculate the no. of days between two dates
return DifferenceInTime / (1000 * 3600 * 24);
};
export default getDateDifferenceInDays;

View file

@ -46,15 +46,7 @@ export async function getAvailabilityRating(
providerNumber
) {
// For a given shop provider number and date range, get the appointment time slots available
const shopTimeSlots = await useMainStore().getShopTimeSlots(
{
providerNumber,
startDate,
endDate,
shopAppointmentType
},
false
);
const shopTimeSlots = await useMainStore().getShopTimeSlots(startDate, endDate, shopAppointmentType, providerNumber);
const numberOfDaysToEvaluate = 2;
const isGoodAvailability =

View file

@ -27,4 +27,68 @@ export async function mockGetAlertReasons(ctu) {
return Promise.resolve(retList);
}
export function calcDaysBetweenDates(dateString1, dateString2) {
const date1 = new Date(dateString1);
const date2 = new Date(dateString2);
const timeDifference = Math.abs(date2 - date1); // Calculate the time difference in milliseconds
return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); // Convert milliseconds to days
}
export function convertDateToDateString(date) {
// returns YYYY-MM-DD format
if (date instanceof Date !== true) return null;
return (
`${date.getFullYear()
}-${
(`0${date.getMonth() + 1}`).slice(-2)
}-${
(`0${date.getDate()}`).slice(-2)}`
);
}
export function convertDateStringToDate(dateString) {
// dateString must be YYYY-MM-DD format
if (typeof dateString !== 'string') return null;
const dateParts = dateString.split('-');
return new Date(dateParts[0], parseInt(dateParts[1], 10) - 1, dateParts[2]);
}
export function sumDateString(dateString, daysToAdd) {
// dateString must be YYYY-MM-DD format
if (typeof dateString !== 'string') return null;
const date = convertDateStringToDate(dateString);
date.setDate(date.getDate() + daysToAdd);
return convertDateToDateString(date);
}
export function militaryToTwelveHourTime(timeString) {
// Expected input: "HH:MM"
if (typeof timeString !== 'string') return null;
let hours = parseInt(timeString.split(':')[0], 10);
const minutes = timeString.split(':')[1];
const meridianNotation = hours > 11 ? 'PM' : 'AM';
if (hours > 12) {
hours -= 12;
}
return `${hours}:${minutes} ${meridianNotation}`;
}
export function getDisplayTextForDurationLength(durationMinimum, durationMaximum) {
const isLongAppointment = durationMaximum >= 120;
const isDurationRange = durationMinimum !== durationMaximum;
const adjustedMinimum = isLongAppointment ? durationMinimum / 60 : durationMinimum;
const adjustedMaximum = isLongAppointment ? durationMaximum / 60 : durationMaximum;
const durationText = isDurationRange
? `${adjustedMinimum} - ${adjustedMaximum}`
: adjustedMinimum;
const unitText = isLongAppointment ? 'hours' : 'minutes';
return `${durationText} ${unitText}`;
}
export default getAlertReasons;

View file

@ -21,6 +21,15 @@
<locationAlerts
ref="locationAlerts"
cmsWidgetPrefix="LocationAlert-" />
<datePicker
ref="datePicker"
v-model="selectedDate"
customComponentId="dateQuestion"
selectableDatesSetting="custom"
class="text-link-small"
:customSelectableDatesCallback="getAvailableDatesMethod"
validationRules="date-required"
@date-clicked="openInshopTimeSlotsModal" />
<siteFooter
ref="siteFooter"
class="mt-5"
@ -37,23 +46,141 @@
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import locationAlerts from '@/layouts/schedule-page/location-alerts/location-alerts.vue';
import datePicker from '@/digital-components/date-picker/date-picker.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
// Supporting files
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import baseMixin from '@/mixins/base-mixin.js';
import {
calcDaysBetweenDates,
convertDateStringToDate,
sumDateString
} from '@/layouts/schedule-page/helpers/schedule-helper';
import {
AppointmentTypeStrings,
GET_MOBILE_TIME_SLOTS,
GET_SHOP_TIME_SLOTS,
PREMIUM_FEE_PART_TYPE
} from '@/constants/schedule-constants.js';
import { fetchCmsContentForPage, splitCopyOnCMSPlaceHolder } from '@/helpers/cms-content-helper';
import settleAllPromises from '@/helpers/layout-helper';
import { Form } from 'vee-validate';
import { Form, defineRule } from 'vee-validate';
import BaseFormMixin from '@/mixins/base-form-mixin.js';
import errorMessages from '@/constants/error-messages';
import { required } from '@/helpers/validation-rules';
import { useMainStore } from '@/store';
// DEFINE VALIDATION RULES
defineRule('date-required', required(errorMessages.DATE_REQUIRED));
defineRule('time-slot-selection-required', (value) => {
if (value?.timeSlot?.routeCode == null) {
return errorMessages.DATE_REQUIRED;
}
return true;
});
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const apiEndDateLimit = sumDateString(startDateString, TIME_SLOTS_CALL_DAYS_LIMIT);
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
const storeActionConfigs = [];
const timeSlotsData = {};
timeSlotsData.days = [];
let apiStartDate = startDateString;
let apiEndDate = endDateString;
for (let i = 1; i <= apiCallsCount; i++) {
let storeActionConfig;
if (i > 1) {
apiStartDate = sumDateString(apiEndDate, 1);
apiEndDate = sumDateString(apiStartDate, TIME_SLOTS_CALL_DAYS_LIMIT);
if (i === apiCallsCount) {
apiEndDate = endDateString;
}
} else if (apiEndDate > apiEndDateLimit) {
apiEndDate = apiEndDateLimit;
}
if (appointmentType === AppointmentTypeStrings.MOBILE) {
storeActionConfig = {
storeAction: GET_MOBILE_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate
}
};
} else {
storeActionConfig = {
storeAction: GET_SHOP_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
shopAppointmentType: appointmentType,
providerNumber
}
};
}
if (apiStartDate < apiEndDate) storeActionConfigs.push(storeActionConfig);
}
const timeSlotsResponsesData = {
days: []
};
function compareDayStrings(a, b) {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
}
const makeParallelCalls = async () => {
await Promise.all(storeActionConfigs.map(async (storeAction) => {
let timeSlotsResponse = null;
if (storeAction.storeAction === GET_SHOP_TIME_SLOTS) {
timeSlotsResponse = await useMainStore().getShopTimeSlots(
storeAction.payload.startDate,
storeAction.payload.endDate,
storeAction.payload.appointmentType,
storeAction.payload.providerNumber
);
} else {
timeSlotsResponse = await useMainStore().getMobileTimeSlots(storeAction.payload.startDate, storeAction.payload.endDate);
}
timeSlotsResponsesData.estimatedServiceMinutesMinimum = timeSlotsResponse.data.estimatedServiceMinutesMinimum;
timeSlotsResponsesData.estimatedServiceMinutesMaximum = timeSlotsResponse.data.estimatedServiceMinutesMaximum;
timeSlotsResponsesData.days = [
...timeSlotsResponsesData.days,
...timeSlotsResponse.data.days
];
}));
};
return makeParallelCalls().then(() => {
// sort days chronologically
timeSlotsResponsesData.days.sort(compareDayStrings);
return timeSlotsResponsesData;
});
};
export default {
name: 'schedule-page',
components: {
siteHeader,
siteSubHeader,
locationAlerts,
datePicker,
siteFooter,
textBlock,
// eslint-disable-next-line vue/no-reserved-component-names
@ -62,8 +189,20 @@ export default {
mixins: [BaseFormMixin],
async beforeRouteEnter(to, from, next) {
// Call APIs
let preSelectedDate = await useMainStore().order.schedule.date;
if (!preSelectedDate || preSelectedDate.startTime === null) {
preSelectedDate = null;
}
const cmsContentPromise = fetchCmsContentForPage(to.query.issPage);
const datePickerInitialDataPromise = await datePicker.methods.loadInitialData({
selectableDatesSetting: 'custom',
initialViewRowsToShow: 5,
customSelectableDatesCallback: getAvailableDates,
preSelectedDate
});
const alertReasonsPromise = locationAlerts.methods.loadInitialData(
useMainStore().order.serviceLocation.zipCodeCtu,
useMainStore().order.serviceLocation.provider?.address?.zipCodeCtu
@ -78,6 +217,10 @@ export default {
{
resultKey: 'alertReasons',
promise: alertReasonsPromise
},
{
resultKey: 'datePickerInitialData',
promise: datePickerInitialDataPromise
}
];
@ -85,7 +228,10 @@ export default {
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.datePicker.initializeComponent(resultMap.datePickerInitialData);
vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons);
vm.setData(resultMap.datePickerInitialData.initialShopTimeSlotsResponse);
vm.updateFooterButtonText(vm.selectedTimeSlotInfo);
});
},
setup() {
@ -100,8 +246,38 @@ export default {
return this.getCmsContent('ChangeShopLink', 'Text');
},
ChangeShopLink() {
// Splits content when brackets are found in text so that text can be looped through and router-link can be injected when needed
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
appointmentType() {
return this.mainStore.order.serviceLocation.appointmentType;
},
timeSlotsForSelectedDate() {
if (!this.selectedDate) {
return null;
}
return this.selectableDatesData.days?.find((selectableDate) => selectableDate.date === this.selectedDate);
}
},
watch: {
selectedDate(newValue, oldValue) {
// Clear time slot selection if date selected changes
if (newValue !== oldValue) {
this.selectedTimeSlotInfo = {
timeSlot: {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null
},
isPremiumAppointment: null
};
}
},
selectedTimeSlotInfo(newValue) {
this.updateFooterButtonText(newValue);
}
},
methods: {
@ -121,20 +297,146 @@ export default {
&& useMainStore().order.lineItems.glassParts.length > 0);
return serviceLocationPreReqs && paymentInfo && supportingItems && damageInfo;
},
setData(initialShopTimeSlotsResponse) {
this.selectableDatesData = initialShopTimeSlotsResponse;
},
async getAvailableDatesMethod(startDate, endDate) {
const newShopTimeSlots = await getAvailableDates(
startDate,
endDate,
this.appointmentType,
this.mainStore.order.serviceLocation.provider.providerNumber
);
// ADD API CALL RESULTS TO EXISTING DATE DATA
this.selectableDatesData.days = this.selectableDatesData.days.concat(newShopTimeSlots.days);
return newShopTimeSlots;
},
getAvailableDates,
getServiceZipCtuCodeFromStore() {
return this.mainStore.order.serviceLocation.zipCodeCtu;
},
openInshopTimeSlotsModal() {
this.$refs.timeSlotModalQuestion.openModal();
},
getSelectedDate() {
return this.mainStore.order.schedule.date;
},
getSelectedTimeSlotInfo() {
const supportingItems = this.getSupportingItems();
const isPremiumAppointment =
!!supportingItems.filter((lineItem) => lineItem.partType === PREMIUM_FEE_PART_TYPE)
.length > 0;
const selectedTimeSlotInfo = {
timeSlot: this.mainStore.order.schedule,
isPremiumAppointment
};
return selectedTimeSlotInfo;
},
getSupportingItems() {
return this.mainStore.lineItems.supportingItems;
},
timeSlotModalClosed() {
// Clear the selectedDate if no timeSlot has been selected
if (this.selectedTimeSlotInfo.timeSlot.routeCode == null) {
this.selectedDate = null;
}
},
updateFooterButtonText(timeSlotInfo) {
let navbarButtonText;
if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) {
navbarButtonText = 'Continue';
} else {
navbarButtonText = `Select ${this.convertSelectedDateToShortMonthAndDay(timeSlotInfo.timeSlot.date)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(timeSlotInfo.timeSlot.startTime)}`;
} else if (
this.appointmentType === AppointmentTypeStrings.MOBILE
&& !timeSlotInfo.isPremiumAppointment
) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime,
true
)} - ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.endTime,
true
)}`;
}
}
this.$refs.navbar.updateButtonText(navbarButtonText);
},
convertSelectedDateToShortMonthAndDay(selectedDate) {
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(selectedDate);
return dateObject.toLocaleDateString('en-us', { month: 'short', day: 'numeric' });
},
getDisplayTextForMilitaryTime(militaryTimeInput, shouldTrimMinutesIfEmpty = false) {
// Expected input: "HH:MM"
let hours = parseInt(militaryTimeInput.split(':')[0], 10);
const minutes = militaryTimeInput.split(':')[1];
const meridianNotation = hours > 11 ? 'PM' : 'AM';
if (hours > 12) {
hours -= 12;
}
if (shouldTrimMinutesIfEmpty && minutes === '00') {
return `${hours} ${meridianNotation}`;
}
return `${hours}:${minutes} ${meridianNotation}`;
},
updateSupportingItems() {
const supportingItems = this.getSupportingItems();
// if we have a premium fee(early bird), then save/update supporting items
if (
this.appointmentType === AppointmentTypeStrings.MOBILE
&& this.selectedTimeSlotInfo?.isPremiumAppointment
) {
const earlyBirdIndex = supportingItems.findIndex((item) => item.partType === PREMIUM_FEE_PART_TYPE);
if (earlyBirdIndex >= 0) {
supportingItems[earlyBirdIndex].laborAmount =
this.mobilePremiumAppointmentFee.laborAmount;
supportingItems[earlyBirdIndex].selingPrice =
this.mobilePremiumAppointmentFee.selingPrice;
supportingItems[earlyBirdIndex].kitPrice =
this.mobilePremiumAppointmentFee.kitPrice;
} else {
supportingItems.push(this.mobilePremiumAppointmentFee);
}
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
} else {
// if it's not a mobile and/or premium early bird, then make sure we remove any that may have been added
const removeEarlyBirdIndex = supportingItems.findIndex((item) => item.partType === PREMIUM_FEE_PART_TYPE);
if (removeEarlyBirdIndex >= 0) {
supportingItems.splice(removeEarlyBirdIndex, 1);
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
}
}
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
// validate and save here
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD, this.$route);
},
navigateForward() {
}
}
};
</script>
<style lang="scss" scoped>
$page-side-padding: 1.5rem;

View file

@ -0,0 +1,118 @@
<template>
<baseInputButton
v-bind="$props"
v-model="selectedValue"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2">
<div
:aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4">
<span
class="m-0 position-relative"
:class="textPosition">
{{ buttonLabel }}
<span
v-if="buttonLabelSubCopy"
class="premium-appointment-price"
:class="textPosition">
{{ formattedButtonLabelSubCopy }}
</span>
</span>
<span
v-if="screenReaderOnlyText"
class="sr-only">
{{ screenReaderOnlyText }}
</span>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from '@/digital-components/base-input-button/base-input-button.vue';
import inputButtonWrapperMixin from '@/mixins/input-button-wrapper-mixin';
export default {
name: 'time-slot-modal-list-button',
components: {
baseInputButton
},
mixins: [inputButtonWrapperMixin],
computed: {
formattedButtonLabelSubCopy() {
return this.buttonLabelSubCopy;
}
},
methods: {
displayLoader() {
this.isLoaderDisplayed = true;
},
preHandleAnswerChange() {
if (this.selectingInitiatesLoad) {
this.displayLoader();
}
}
}
};
</script>
<style lang="scss" scoped>
.list-button {
outline: none;
input[type="radio"],
input[type="checkbox"] {
position: static; //override bootstrap
&:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content {
color: $black;
font-weight: 500;
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
span.premium-appointment-price {
background: $green-200;
}
}
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {
font-weight: 500;
}
&:checked + .list-button-content span:nth-child(2) {
font-weight: 400;
color: $gray-600;
}
}
}
.list-button-content {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span.premium-appointment-price {
position: absolute;
background: $green-100;
border-radius: 4.5rem;
line-height: 1.25rem;
color: $green-700;
font-size: 0.75rem;
margin-left: 4px;
padding: 2px 8px;
font-weight: 500;
}
}
.position-relative {
position: relative;
}
</style>

View file

@ -0,0 +1,520 @@
<template>
<modal
:ref="modalName"
:headerText="dateSelectedReadableDate"
:footerButtonText="footerCloseButtonText"
:onModalClosedCallback="onModalClosed"
class="time-slots-modal"
@isModalOpened="setModalStatus"
@footer-button-event="setSelectedTimeSlot">
<template v-if="isModalOpened">
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
justifyText="center"
typeStyle="small"
class="duration-text-block" />
<buttonQuestion
ref="buttonQuestion"
v-model="selectedRouteCode"
buttonTypeString="timeSlotModalListButton"
:buttonTypeObject="timeSlotModalListButton"
class="mt-5"
:answers="availableTimeSlots"
groupName="chooseTimeSlot"
textPosition="text-center"
isRequired
validationRules="time-slot-required" />
<div
v-if="supplementalInformationBlock"
class="mt-1 mb-2 supplemental-information"
v-html="supplementalInformationBlock"></div>
<textBlock
v-show="disclaimerTextBlockCopy"
:customText="disclaimerTextBlockCopy"
justifyText="left"
typeStyle="caption"
class="mb-2" />
</template>
</modal>
</template>
<script>
// Components
import modal from '@/digital-components/modal/modal.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
// Helpers
import { deepClone } from '@/helpers/object-helper';
// Validation
import { defineRule, useField } from 'vee-validate';
import errorMessages from '@/constants/error-messages';
import { required } from '@/helpers/validation-rules';
// Constants
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE
} from '@/constants/schedule-constants';
import {
convertDateStringToDate,
militaryToTwelveHourTime,
getDisplayTextForDurationLength
} from '@/layouts/schedule-page/helpers/schedule-helper';
import timeSlotModalListButton from './time-slot-modal-list-button/time-slot-modal-list-button.vue';
const cmsWidgetFieldMappings = {
MODAL_CLOSE_BUTTON: 'FooterText',
SUPPLEMENTAL_INFORMATION: 'BodyText',
TIME_SLOT_BUTTON: 'HeaderText',
DISCLAIMER: 'FooterText',
DURATION: 'SubheaderText'
};
// Validation for the modal button
defineRule('time-slot-required', required(errorMessages.OPTION_REQUIRED));
export default {
name: 'time-slot-modal-question',
components: {
modal,
textBlock,
buttonQuestion
},
props: {
modelValue: {
type: Object,
default: () => ({
timeSlot: {
routeCode: null,
date: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null
},
isPremiumAppointment: null
})
},
cmsWidgetName: String,
mobileCmsWidgetName: String,
mobilePremiumCmsWidgetName: String,
dropoffCmsWidgetName: String,
sameDayDropOffCmsWidgetName: String,
overnightDropOffCmsWidgetName: String,
appointmentType: String,
timeSlotsForSelectedDate: Object,
premiumAppointmentFee: Object,
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
validationRules: String,
customComponentId: String,
selectedDate: String
},
emits: ['update:modelValue', 'time-slot-modal-closed'],
setup(props) {
const uuid = crypto.randomUUID();
const componentId = !props.customComponentId
? `component-${uuid}`
: props.customComponentId;
const { modelValue } = deepClone(props);
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue
};
const { errorMessage, handleChange, meta, validate, errors } = useField(
componentId,
props.validationRules,
fieldOptions
);
return {
componentId,
errorMessage,
handleChange,
validate,
meta,
errors
};
},
data() {
return {
isModalOpened: false,
selectedRouteCode: this.getSelectedRouteCode(),
timeSlotModalListButton
};
},
computed: {
modalName() {
return 'timeSlots';
},
modal() {
return this.$refs[this.modalName];
},
supplementalInformationBlock() {
let appointmentTypeCmsWidgetName;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return null;
} if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
appointmentTypeCmsWidgetName = this.selectedRouteCode?.includes(PREMIUM_TIME_SLOT_ID_FLAG)
? this.mobilePremiumCmsWidgetName
: this.mobileCmsWidgetName;
} else {
if (!this.selectedRouteCode) {
return null;
}
appointmentTypeCmsWidgetName =
this.getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
this.selectedRouteCode,
true
);
}
return this.getCmsContent(
appointmentTypeCmsWidgetName,
cmsWidgetFieldMappings.SUPPLEMENTAL_INFORMATION
);
},
footerCloseButtonText() {
return this.getCmsContent(
this.cmsWidgetName,
cmsWidgetFieldMappings.MODAL_CLOSE_BUTTON
);
},
premiumAppointmentButtonText() {
return this.getCmsContent(
this.mobilePremiumCmsWidgetName,
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
dropoffButtonText() {
return this.getCmsContent(
this.dropoffCmsWidgetName,
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
sameDayDropoffButtonText() {
return this.getCmsContent(
this.sameDayDropOffCmsWidgetName,
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
overnightDropoffButtonText() {
return this.getCmsContent(
this.overnightDropOffCmsWidgetName,
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
dropoffDisclaimerText() {
return this.getCmsContent(this.dropoffCmsWidgetName, cmsWidgetFieldMappings.DISCLAIMER);
},
sameDayDropOffDisclaimerText() {
return this.getCmsContent(
this.sameDayDropOffCmsWidgetName,
cmsWidgetFieldMappings.DISCLAIMER
);
},
overnightDropOffDisclaimerText() {
return this.getCmsContent(
this.overnightDropOffCmsWidgetName,
cmsWidgetFieldMappings.DISCLAIMER
);
},
disclaimerTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
if (this.selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropOffDisclaimerText;
}
return this.dropoffDisclaimerText;
} if (this.selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffDisclaimerText;
}
return null;
}
return null;
},
dropOffDurationText() {
return this.getCmsContent(this.dropoffCmsWidgetName, cmsWidgetFieldMappings.DURATION);
},
sameDayDropoffDurationText() {
return this.getCmsContent(
this.sameDayDropOffCmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
},
overnightDropoffDurationText() {
return this.getCmsContent(
this.overnightDropOffCmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
this.cmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
return `${inshopDurationTextWithoutTime} ${inshopDurationTime}`;
},
durationTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return null;
} if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return this.inshopDurationText;
}
if (this.selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropoffDurationText;
} if (this.selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropoffDurationText;
}
return this.dropOffDurationText;
}
return null;
},
isSameDay() {
if (!this.timeSlotsForSelectedDate) {
return false;
}
const selectedDate = this.timeSlotsForSelectedDate.date;
const todaysDate = new Date().toISOString().split('T')[0];
return selectedDate === todaysDate;
},
dateSelectedReadableDate() {
if (!this.timeSlotsForSelectedDate) {
return null;
}
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(this.timeSlotsForSelectedDate.date);
// Ex: Tuesday, April 22
return dateObject.toLocaleDateString('en-us', {
weekday: 'long',
month: 'long',
day: 'numeric'
});
},
availableTimeSlots() {
if (!this.timeSlotsForSelectedDate) {
return null;
}
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
return this.getAvailableTimeSlotsForDropOff(this.timeSlotsForSelectedDate.timeSlots);
} if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return this.getAvailableTimeSlotsForMobile(this.timeSlotsForSelectedDate.timeSlots);
}
return this.getAvailableTimeSlotsForInshop(this.timeSlotsForSelectedDate.timeSlots);
}
},
watch: {
modelValue: {
handler(newValue) {
this.handleChange(newValue);
},
deep: true
},
selectedDate: {
handler() {
this.selectedRouteCode = null;
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
}
}
},
methods: {
openModal() {
this.modal.openModal();
},
setModalStatus(isOpened) {
this.isModalOpened = isOpened;
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.$emit('time-slot-modal-closed');
},
async setSelectedTimeSlot() {
this.$emit(
'update:modelValue',
this.getSelectedTimeSlotInfoObject(this.selectedRouteCode)
);
this.closeModal();
},
getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
selectedRouteCode,
isSameDayRelevant = false
) {
if (selectedRouteCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffCmsWidgetName;
}
if (selectedRouteCode.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
return this.isSameDay && isSameDayRelevant
? this.sameDayDropOffCmsWidgetName
: this.dropoffCmsWidgetName;
}
return '';
},
getAvailableTimeSlotsForInshop(timeSlotsForSelectedDate) {
return timeSlotsForSelectedDate.map((timeSlot) => {
const readableTime = militaryToTwelveHourTime(timeSlot.startTime);
return {
value: timeSlot.id,
buttonLabel: readableTime
};
});
},
getAvailableTimeSlotsForDropOff(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
let buttonLabelValue;
if (timeSlot.id.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
buttonLabelValue = this.overnightDropoffButtonText;
} else if (this.isSameDay) {
buttonLabelValue = this.sameDayDropoffButtonText;
} else {
buttonLabelValue = this.dropoffButtonText;
}
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue
};
});
return availableTimeSlots;
},
getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
const readableTime = `${militaryToTwelveHourTime(timeSlot.startTime)} - ${militaryToTwelveHourTime(timeSlot.endTime)}`;
return {
value: timeSlot.id,
buttonLabel: readableTime
};
});
const isPremiumTimeSlot = timeSlotsForSelectedDate[0].offerPremium;
const hasPremiumPartAvailable =
this.premiumAppointmentFee?.partType === PREMIUM_FEE_PART_TYPE;
if (isPremiumTimeSlot && hasPremiumPartAvailable) {
availableTimeSlots.unshift(this.getPremiumAppointmentTimeSlot(timeSlotsForSelectedDate[0]));
}
return availableTimeSlots;
},
getPremiumAppointmentTimeSlot(timeSlotData) {
const formattedPrice =
`+$${this.getTotalLineItemPrice(this.premiumAppointmentFee).toFixed(2)}`;
return {
// Unique value is required for each <input> and the premium appoinment shares an id
value: this.addPremiumFlagToInput(timeSlotData.id),
buttonLabel: this.premiumAppointmentButtonText,
buttonLabelSubCopy: formattedPrice,
additionalButtonData: {
isPremiumAppointment: true
}
};
},
getSelectedRouteCode() {
let selectedRouteCode;
if (!this.modelValue?.timeSlot) {
selectedRouteCode = null;
}
if (this.modelValue?.isPremiumAppointment) {
selectedRouteCode = this.addPremiumFlagToInput(this.modelValue?.timeSlot?.routeCode);
} else {
selectedRouteCode = this.modelValue?.timeSlot?.routeCode;
}
return selectedRouteCode;
},
autoSelectTimeSlotIfOnlyOneIsAvailable() {
const numberOfOptions = this.availableTimeSlots?.length;
if (numberOfOptions === 1) {
this.selectedRouteCode = this.availableTimeSlots[0].value;
}
},
addPremiumFlagToInput(routeCode) {
return (`${routeCode}${PREMIUM_TIME_SLOT_ID_FLAG}`);
},
removePremiumFlagFromInput(routeCode) {
return routeCode.replace(PREMIUM_TIME_SLOT_ID_FLAG, '');
},
getSelectedTimeSlotInfoObject(routeCode) {
let routeCodeToUse = routeCode;
const routeCodeIncludesPremium = routeCode?.includes(PREMIUM_TIME_SLOT_ID_FLAG);
if (routeCodeIncludesPremium) {
routeCodeToUse = this.removePremiumFlagFromInput(routeCode);
}
const timeSlot = this.timeSlotsForSelectedDate?.timeSlots?.find((ts) => ts.id === routeCodeToUse);
if (timeSlot) {
return {
timeSlot: {
date: this.timeSlotsForSelectedDate.date,
routeCode: timeSlot.id,
startTime: timeSlot.startTime,
endTime: timeSlot.endTime,
jobMaxMinutes: this.estimatedServiceMinutesMaximum.toString(),
jobMinMinutes: this.estimatedServiceMinutesMinimum.toString()
},
isPremiumAppointment: !!routeCodeIncludesPremium
};
}
return {
timeSlot: {
date: null,
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null,
jobMinMinutes: null
},
isPremiumAppointment: null
};
}
}
};
</script>
<style lang="scss">
.time-slots-modal.modal.modal-component {
.modal-header {
padding-bottom: 0;
margin-bottom: 0 !important;
}
.text-block.duration-text-block {
margin-top: 4px !important;
}
.supplemental-information {
line-height: 1.5rem;
font-size: 0.875rem;
li strong {
font-weight: $font-weight-bold;
}
li:not(:last-child) {
margin-bottom: 8px;
}
}
}
</style>

View file

@ -12,9 +12,23 @@ import issPageValues from '@/router/router-constants/issPage-values';
import damageLocationsSelected from '@/constants/damage-locations-selected';
import coverageStatuses from '@/constants/coverage-statuses';
import { PREMIUM_FEE_PART_TYPE } from '@/constants/schedule-constants';
import getDateDifferenceInDays from '@/helpers/date-helper';
const storeId = 'main';
function getTimeSlotsAdditionalEventData(
provisionalTriggers,
zipCode,
firstAvailableAppointmentDateString,
shopAppointmentType
) {
let numberOfDays = null;
if (firstAvailableAppointmentDateString) numberOfDays = getDateDifferenceInDays(new Date(), firstAvailableAppointmentDateString);
if (shopAppointmentType) return `FirstAvailableAppointment:${numberOfDays},Zip:${zipCode},ShopAppointmentType:${shopAppointmentType},ProvisionalTriggers:${provisionalTriggers.join(',')}`;
return `FirstAvailableAppointment:${numberOfDays},Zip:${zipCode},ProvisionalTriggers:${provisionalTriggers.join(',')}`;
}
const getDefaultState = () => ({
order: {
vehicle: {
@ -600,6 +614,65 @@ export const useMainStore = defineStore({
});
},
getMobileTimeSlots(startDate, endDate) {
const { order } = this;
const { vehicle } = this.order;
let lineItems = [
...(order.lineItems.supportingItems ?? []),
...(order.lineItems.vaps ?? []),
...getFlattenedArrayOfLineItemsWithChildParts(order.lineItems.glassParts)
];
lineItems = lineItems.map((lineItem) => ({
partNumber: lineItem.partNumber,
partType: lineItem.partType
}));
const glassPieces = order.damage.glassToReplace
? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace)
: [];
const payload = {
startDate,
endDate,
applicationName: applicationConfig.APPLICATION_NAME,
parentAccountNumber: this.payment.parentAccountNumber,
carId: vehicle.carId,
lineItems,
glassPieces,
eon: order.eon,
coverage: {
status: '',
deductible: 0,
additionalAuthFlag: ''
},
partSelection: {
hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length,
hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length,
hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length,
hasManuallySelectedParts:
!!this.applicationUser.pageData['vehicle-parts']?.partsOrQuestions
.length
},
vehicle: {
year: vehicle.year,
make: vehicle.make,
model: vehicle.model,
style: vehicle.style,
vin: vehicle.vin ?? ''
},
zipCode: order.serviceLocation.zipCode
};
return globalMethods.callHttpClient({
method: endpoints.GetMobileTimeSlots.method,
endpoint: endpoints.GetMobileTimeSlots.url,
payload,
logApiCall: true,
additionalSuccessEventDataHandler: (response) =>
getTimeSlotsAdditionalEventData(
response.data.provisionalTriggers,
order.serviceLocation.zipCode,
response.data.days?.[0]?.date
)
});
},
getShopTimeSlots(startDate, endDate, shopAppointmentType, providerNumber) {
const { order } = this;
const { vehicle } = this.order;