diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 162d90bf..8429f3ad 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -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' diff --git a/src/constants/schedule-constants.js b/src/constants/schedule-constants.js index a728f1d4..35232c04 100644 --- a/src/constants/schedule-constants.js +++ b/src/constants/schedule-constants.js @@ -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 +}; diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue new file mode 100644 index 00000000..b994487c --- /dev/null +++ b/src/digital-components/date-picker/date-picker.vue @@ -0,0 +1,1062 @@ + + + + + Select a day and time + + + + {{ month.monthLabel }} {{ month.yearNum?.toString() }} + + + = Available + + + + + + + + + + SundayS + + + MondayM + + + TuesdayT + + + WednesdayW + + + ThursdayT + + + FridayF + + + SaturdayS + + + + + {{ date.dateNum.toString() }} + + + + + + + + + + View more dates + + + + + + + + diff --git a/src/digital-components/date-picker/mixins/constants.js b/src/digital-components/date-picker/mixins/constants.js new file mode 100644 index 00000000..70d4d383 --- /dev/null +++ b/src/digital-components/date-picker/mixins/constants.js @@ -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 }; diff --git a/src/digital-components/date-picker/mixins/helpers.js b/src/digital-components/date-picker/mixins/helpers.js new file mode 100644 index 00000000..77c61fc9 --- /dev/null +++ b/src/digital-components/date-picker/mixins/helpers.js @@ -0,0 +1,10 @@ +const selectableDaysOptions = Object.freeze({ + CUSTOM: 'custom', + PAST: 'past' +}); + +const requiredParameter = () => { + throw new Error('parameter is required'); +}; + +export { selectableDaysOptions, requiredParameter }; diff --git a/src/helpers/date-helper.js b/src/helpers/date-helper.js new file mode 100644 index 00000000..ed943174 --- /dev/null +++ b/src/helpers/date-helper.js @@ -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; diff --git a/src/helpers/service-location-helper.js b/src/helpers/service-location-helper.js index a8ac5503..63f4c711 100644 --- a/src/helpers/service-location-helper.js +++ b/src/helpers/service-location-helper.js @@ -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 = diff --git a/src/layouts/schedule-page/helpers/schedule-helper.js b/src/layouts/schedule-page/helpers/schedule-helper.js index 7347bfca..6f41d306 100644 --- a/src/layouts/schedule-page/helpers/schedule-helper.js +++ b/src/layouts/schedule-page/helpers/schedule-helper.js @@ -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; diff --git a/src/layouts/schedule-page/schedule-page.vue b/src/layouts/schedule-page/schedule-page.vue index 8ba01a3e..62f8f000 100644 --- a/src/layouts/schedule-page/schedule-page.vue +++ b/src/layouts/schedule-page/schedule-page.vue @@ -21,6 +21,15 @@ + { + 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() { } } }; + diff --git a/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-question.vue b/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-question.vue new file mode 100644 index 00000000..4fe1774a --- /dev/null +++ b/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-question.vue @@ -0,0 +1,520 @@ + + + + + + + + + + + + + + diff --git a/src/store/index.js b/src/store/index.js index 20b48012..c9a98be6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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;