diff --git a/jest.config.js b/jest.config.js index a757d5ada..c712c24da 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,7 +26,7 @@ module.exports = { testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"], coverageThreshold: { global: { - statements: 80, + statements: 77, // Got the go ahead from Mark to temporarily lower this. Taking out initialize component made the year,make,model and style coverage drop a bit. Once unit tests for license plate lookup, vin lookup and address lookup are in the coverage should go back up to 90 }, }, diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index 7d83642a6..05a55cc5c 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -108,12 +108,14 @@ const endpoints = { }, GetShopTimeSlots: { url: "/schedule/api/v1/schedule/shop-time-slots", - mockUrl: "https://mockey.qa.sagaws.net/service/shop-time-slots", // TODO: REMOVE MOCKURL method: "POST", }, - GetMobileEarlyBirdFee: { - url: "/parts/api/v1/parts/mobile-early-bird-fee", - mockUrl: "https://mockey.qa.sagaws.net/service/mobile-early-bird-fee", // TODO: REMOVE MOCKURL + GetMobileTimeSlots: { + url: "/schedule/api/v1/schedule/mobile-time-slots", + method: "POST", + }, + GetMobilePremiumFee: { + url: "/parts/api/v1/parts/mobile-premium-fee", method: "GET", }, SaveSession: { diff --git a/src/constants/experiments.js b/src/constants/experiments.js index f6d25f1a7..f89aad963 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -5,6 +5,7 @@ const experimentUniverses = { const experimentSettings = { GOOGLE_CUSTOM_DIMENSION_INDEX: "Google Custom Dimension Index", SUPPRESS_VIN_CAPTURE: "SuppressVinCapture", + DISPLAY_AVAILABILITY_INDICATORS: "DisplayAvailabilityIndicators", }; const experimentTriggers = { diff --git a/src/constants/schedule-constants.js b/src/constants/schedule-constants.js new file mode 100644 index 000000000..89b4a4c1a --- /dev/null +++ b/src/constants/schedule-constants.js @@ -0,0 +1,11 @@ +const AppointmentTypeStrings = { + IN_SHOP: "Inshop", + MOBILE: "Mobile", + DROP_OFF: "Dropoff", +}; + +const PREMIUM_TIME_SLOT_ID_FLAG = "-premium"; + +const PREMIUM_FEE_PART_TYPE = "EARLY BIRD"; + +export { AppointmentTypeStrings, PREMIUM_TIME_SLOT_ID_FLAG, PREMIUM_FEE_PART_TYPE }; diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 341fcd487..ebffdebde 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -33,8 +33,9 @@ const storeActions = { GET_MOBILE_FEE_PART: "getMobileFeePart", GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails", GET_SHOP_TIME_SLOTS: "getShopTimeSlots", + GET_MOBILE_TIME_SLOTS: "getMobileTimeSlots", GET_PROVIDERS: "getProviders", - GET_MOBILE_EARLY_BIRD_FEE: "getMobileEarlyBirdFee", + GET_MOBILE_PREMIUM_FEE: "getMobilePremiumFee", SAVE_SESSION: "saveSession", LOAD_SESSION: "loadSession", UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE: "updateStoreWithSaveSessionResponse", @@ -62,6 +63,7 @@ const storeActions = { SAVE_VEHICLE_STYLE: "saveVehicleStyle", SAVE_VEHICLE_DAMAGE: "saveVehicleDamage", SAVE_VIN_LOOKUP: "saveVinLookup", + SAVE_SERVICE_ZIP_CODE_INFO: "saveServiceZipCodeInfo", SAVE_SERVICE_LOCATION: "saveServiceLocation", SAVE_SCHEDULE: "saveSchedule", SAVE_EMAIL: "saveEmail", @@ -69,6 +71,7 @@ const storeActions = { SAVE_VIN: "saveVin", SAVE_REGISTRATION_ADDRESS_LOOKUP: "saveRegistrationAddressLookup", SAVE_GLASS_PARTS: "saveGlassParts", + SAVE_GLASS_PART_PRICES: "saveGlassPartPrices", SAVE_PART_QUESTION_ANSWERS: "savePartQuestionAnswers", RESET_MOLDING_AND_CAPABILITY_QUESTIONS_IF_NEEDED: "resetMoldingAndCapabilityQuestionAnswersIfNeeded", @@ -77,6 +80,8 @@ const storeActions = { SAVE_PAYMENT_TYPE: "savePaymentType", SAVE_PARENT_ACCOUNT_NUMBER: "saveParentAccountNumber", SAVE_SUPPORTING_ITEMS: "saveSupportingItems", + SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING: + "saveSupportingItemsSuppressingStateResetting", SAVE_VAPS: "saveVaps", }; diff --git a/src/constants/store-mutations.js b/src/constants/store-mutations.js index 8eeb70c99..939caf922 100644 --- a/src/constants/store-mutations.js +++ b/src/constants/store-mutations.js @@ -58,6 +58,10 @@ const storeMutations = { RESET_GLASS_PARTS_STATE: "resetGlassPartsState", RESET_STATE: "resetState", RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise", + RESET_SERVICE_LOCATION_APPOINTMENT_TYPE: "resetServiceLocationAppointmentType", + RESET_SERVICE_LOCATION_PROVIDER: "resetServiceLocationProvider", + RESET_SERVICE_LOCATION_MOBILE_ADDRESS: "resetServiceLocationMobileAddress", + RESET_SCHEDULE: "resetSchedule", // OTHER MUTATIONS UPDATE_PAGE_DATA: "updatePageData", diff --git a/src/digital-components/button-question/button-question.vue b/src/digital-components/button-question/button-question.vue index 12586283d..2c2c662dc 100644 --- a/src/digital-components/button-question/button-question.vue +++ b/src/digital-components/button-question/button-question.vue @@ -256,7 +256,9 @@ export default { }, watch: { modelValue(newValue, oldValue) { - this.resetField(); + this.resetField({ + value: newValue, + }); }, answers() { //once we get the answers to display from parent, see if we need a GA event to log what we showed diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index a2b8c6e9f..5cbb26cdc 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -22,7 +22,6 @@
-
SundayS
MondayM
TuesdayT
@@ -32,8 +31,8 @@
SaturdayS
{ + return week.weekStartDate.getMonth() !== week.weekEndDate.getMonth() ? true : false; + }; + const splitWeekIndex = weeks.findIndex(hasSplitWeek); + + if (splitWeekIndex > -1) { + const week1 = []; + const week2 = []; + let switchToWeek2 = false; + + for (let j = 0; j < 7; j++) { + const newDate = new Date(weeks[splitWeekIndex].weekStartDate); + newDate.setDate(newDate.getDate() + j); + if (newDate.getDate() === 1) switchToWeek2 = true; + if (switchToWeek2) { + week2.push(newDate); + } else { + week1.push(newDate); + } + } + + const week1EndDate = week1[week1.length - 1]; + const week2StartDate = week2[0]; + + if (week1EndDate < today) { + // replace week 1 with week 2 + weeks[splitWeekIndex].weekStartDate = week2StartDate; + } else { + const newWeek = { + weekNum: weeks[splitWeekIndex].weekNum, + weekStartDate: week2StartDate, + weekEndDate: weeks[splitWeekIndex].weekEndDate, + }; + weeks[splitWeekIndex].weekEndDate = week1EndDate; + weeks.splice(splitWeekIndex + 1, 0, newWeek); + weeks.pop(); + weeks.forEach((item, index) => { + if (index > splitWeekIndex) { + item.weekNum = item.weekNum + 1; + } + }); + } + } return weeks; }, async loadInitialData(config) { - // CALLED FROM CONSUMING COMPONENT BEFORE DATE-PICKER APPEARS - const todayDate = config.todayOverrideDateString - ? new Date(config.todayOverrideDateString) - : new Date(); + let todayDate; + if (this.today) { + todayDate = this.today; + } else if (config.todayOverrideDateString) { + todayDate = new Date(config.todayOverrideDateString); + } else { + todayDate = new Date(); + } - let todayMonthIndex = todayDate.getMonth() + 1; - let todayYearNum = todayDate.getFullYear(); - let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1); - let currentMonthEnd = new Date(todayYearNum, todayMonthIndex, 0); + const todayMonthIndex = todayDate.getMonth() + 1; + const todayYearNum = todayDate.getFullYear(); + // TODO - set up currentMonthStart if direction is PAST: + // let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1); + const currentMonthEnd = new Date(todayYearNum, todayMonthIndex, 0); let calendarViewDirection = "none"; if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; @@ -219,10 +265,10 @@ export default { config.initialViewRowsToShow ); - let initialViewStartDate = todayDate; - let initialViewEndDate = initialViewWeeks[initialViewWeeks.length - 1].weekEndDate; - let saturday1month = initialViewWeeks[0].weekEndDate.getMonth(); - let sunday5month = + const initialViewStartDate = todayDate; + const initialViewEndDate = initialViewWeeks[initialViewWeeks.length - 1].weekEndDate; + const firstSaturdayMonth = initialViewWeeks[0].weekEndDate.getMonth(); + const lastSundayMonth = initialViewWeeks[initialViewWeeks.length - 1].weekStartDate.getMonth(); let hideSomeDaysForInitialView = false; @@ -230,10 +276,10 @@ export default { // TODO - NEED TO CREATE OPPOSITE LOGIC FOR "past" DIRECTION LIKE BELOW vvvvv if (calendarViewDirection === "future") { - if (saturday1month !== sunday5month) { + if (firstSaturdayMonth !== lastSundayMonth) { hideSomeDaysForInitialView = true; } - if (initialViewStartDate.getMonth() === sunday5month) { + if (initialViewStartDate.getMonth() === lastSundayMonth) { hideSecondMonth = true; if (currentMonthEnd > initialViewEndDate) { // should part of 1st month be hidden? @@ -242,11 +288,12 @@ export default { } } - let myPromise = new Promise((resolve, reject) => { + const myPromise = new Promise((resolve, reject) => { const response = config.customSelectableDatesCallback( - initialViewStartDate, - initialViewEndDate, - store.getters.order.serviceLocation.appointmentType + initialViewStartDate.toISOString().split("T")[0], + initialViewEndDate.toISOString().split("T")[0], + store.getters.order.serviceLocation.appointmentType, + store.getters.order.serviceLocation.provider.providerNumber ); resolve(response); }); @@ -284,9 +331,9 @@ export default { // Growing from 0 to 1 time = Math.min(1, (timestamp - start) / duration); - let percentageNew = timingFunc(time); - let distanceToGo = targetY; - let thisDistance = percentageNew * distanceToGo; + const percentageNew = timingFunc(time); + const distanceToGo = targetY; + const thisDistance = percentageNew * distanceToGo; wrapper.scrollTo(0, initY + thisDistance); @@ -305,13 +352,11 @@ export default { }, async setCalendarData(config = {}) { - this.today = config.todayDate; this.hideSomeDaysForInitialView = config.hideSomeDaysForInitialView; - let hideSecondMonth = config.hideSecondMonth; + const hideSecondMonth = config.hideSecondMonth; const direction = config.calendarViewDirection; - - const monthsAfterToLoadOffset = 12; // TO BE MADE "CONSTANTS" - const monthsBeforeToLoadOffset = 36; // TO BE MADE "CONSTANTS" + const monthsAfterToLoadOffset = 12; + const monthsBeforeToLoadOffset = 36; config.initialShopTimeSlotsResponse.days.forEach((selectableDate) => { this.selectableDatesData.push(selectableDate); }); @@ -382,25 +427,25 @@ export default { } } - const monthEndDate = new Date(yearNum, monthIndex, 0); // BOTH - let monthEndDateNum = monthEndDate.getDate(); // BOTH + const monthEndDate = new Date(yearNum, monthIndex, 0); + let monthEndDateNum = monthEndDate.getDate(); if ( offset === 0 && calendarViewDirection === "past" && monthEndDateNum > this.currentWeekEndDateNum ) { - monthEndDateNum = this.currentWeekEndDateNum; // PAST + monthEndDateNum = this.currentWeekEndDateNum; } const monthStartDateNum = offset === 0 && calendarViewDirection === "future" ? this.currentWeekStartDateNum - : 1; // FUTURE - const monthStartDate = new Date(yearNum, monthIndex - 1, monthStartDateNum); // BOTH + : 1; + const monthStartDate = new Date(yearNum, monthIndex - 1, monthStartDateNum); - const startDateDayIndex = monthStartDate.getDay(); // FUTURE - const endDateDayIndex = monthEndDate.getDay(); // PAST + const startDateDayIndex = monthStartDate.getDay(); + const endDateDayIndex = monthEndDate.getDay(); if (Math.abs(offset) === 1 && hideSecondMonth) { monthClass = monthClass + " month-hidden"; @@ -424,19 +469,13 @@ export default { // populate dates array for (let i = monthStartDateNum; i <= monthEndDateNum; i++) { let dayClasses = ""; - let dateString = + const dateString = yearNum.toString() + "-" + forceTwoDigitString(monthIndex) + "-" + forceTwoDigitString(i); - const thisDate = { - year: yearNum, - month: monthIndex, - date: i, - dateString: dateString, - }; if (offset === 0 && i === this.todayDateNum) { dayClasses += "current-day"; } @@ -457,11 +496,9 @@ export default { const dateObject = { dateNum: i, dayClasses: dayClasses, - inputValue: thisDate, + inputValue: dateString, isSelectable: - this.selectableDatesData.findIndex( - (date) => date.dateString === dateString - ) > -1 + this.selectableDatesData.findIndex((date) => date.date === dateString) > -1 ? true : false, }; @@ -510,11 +547,11 @@ export default { if (monthToShow) { // make new API call with this month's start and end dates await this.updateSelectableDates( - monthToShow.dates[monthStartDateNum].inputValue.dateString, - monthToShow.dates[monthToShow.dates.length - 1].inputValue.dateString + monthToShow.dates[monthStartDateNum].inputValue, + monthToShow.dates[monthToShow.dates.length - 1].inputValue ); this.isLoading = false; - this.hideSomeDaysForInitialView = false; // if hid days on initial partial view, this removes hidden styling on days + this.hideSomeDaysForInitialView = false; // if hid days on initial partial view, this will reveal those days monthToShow.monthClass = monthToShow.monthClass.replace(" month-hidden", ""); this.scrollToElement(monthToShow.monthString); @@ -526,17 +563,18 @@ export default { const moreSelectableDates = await this.customSelectableDatesCallback( monthStart, monthEnd, - this.$store.getters.order.serviceLocation.appointmentType + this.$store.getters.order.serviceLocation.appointmentType, + this.$store.getters.order.serviceLocation.provider.providerNumber ); moreSelectableDates.days.forEach((selectableDate) => { const index = this.selectableDatesData.findIndex( - (obj) => obj.dateString === selectableDate.dateString + (dateObj) => dateObj.date === selectableDate.date ); - if (index === -1) this.selectableDatesData.push(selectableDate); + if (index === -1) this.selectableDatesData.push(selectableDate.date); this.months.forEach((month) => { - // TODO: avoid checking all calendar date; maybe only ones between monthStart and monthEnd as defined above? + // TODO: avoid checking all calendar dates; maybe only ones between monthStart and monthEnd as defined above? month.dates.forEach((date) => { - if (date.inputValue.dateString === selectableDate.dateString) { + if (date.inputValue === selectableDate.date) { date["isSelectable"] = true; } }); @@ -807,7 +845,6 @@ export default { .btn-link { font-weight: 500; text-underline-offset: 4px; - box-shadow: none !important; // TODO: FIX THIS position: absolute; bottom: 0; } diff --git a/src/digital-components/text-block/text-block.spec.js b/src/digital-components/text-block/text-block.spec.js index 89b2fdf36..c9825f36f 100644 --- a/src/digital-components/text-block/text-block.spec.js +++ b/src/digital-components/text-block/text-block.spec.js @@ -1,7 +1,7 @@ import { shallowMount } from "@vue/test-utils"; import TextBlock from "./text-block"; -describe("modal.vue", () => { +describe("text-block.vue", () => { it("Should display 'Text' when 'Text' is defined in the CMS", async () => { // Act const wrapper = shallowMount(TextBlock, { diff --git a/src/digital-components/text-block/text-block.vue b/src/digital-components/text-block/text-block.vue index c6a899309..0bc89591c 100644 --- a/src/digital-components/text-block/text-block.vue +++ b/src/digital-components/text-block/text-block.vue @@ -1,47 +1,93 @@ diff --git a/src/layouts/estimate/estimate.vue b/src/layouts/estimate/estimate.vue index 1ca0c9d48..e1c58bd49 100644 --- a/src/layouts/estimate/estimate.vue +++ b/src/layouts/estimate/estimate.vue @@ -229,7 +229,7 @@ export default { const zipCodeData = await this.getZipCodeData(this.serviceZipCode); await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.emailAddress, false); await this.dispatchStoreAction( - storeActions.SAVE_SERVICE_LOCATION, + storeActions.SAVE_SERVICE_ZIP_CODE_INFO, { zipCode: this.serviceZipCode, state: zipCodeData.state, diff --git a/src/layouts/molding-questions/molding-questions.spec.js b/src/layouts/molding-questions/molding-questions.spec.js index 13961df5a..9c003e748 100644 --- a/src/layouts/molding-questions/molding-questions.spec.js +++ b/src/layouts/molding-questions/molding-questions.spec.js @@ -109,7 +109,7 @@ store.getters = { damage: baseStoreGettersDamage, order: {}, }; -store.commit = jest.fn(); +store.dispatch = jest.fn(); afterEach(() => { // reset store after each test diff --git a/src/layouts/quote/cash-or-insurance-question/cash-or-insurance-question.vue b/src/layouts/quote/cash-or-insurance-question/cash-or-insurance-question.vue index 45fef683a..a2afb1a7c 100644 --- a/src/layouts/quote/cash-or-insurance-question/cash-or-insurance-question.vue +++ b/src/layouts/quote/cash-or-insurance-question/cash-or-insurance-question.vue @@ -5,7 +5,7 @@ :groupName="groupName" buttonTypeString="listButtonHorizontal" v-model="selectedValues" - :additionalButtonData="{ additionalButtonStyling: 'listButtonHorizontalStrong' }" + :additionalButtonData="additionalButtonData" isRequired />
@@ -27,6 +27,11 @@ export default { answersFromCms() { return this.getCmsContent(this.cmsWidgetName, "Answers"); }, + additionalButtonData() { + return { + additionalButtonStyling: "listButtonHorizontalStrong", + }; + }, selectedValues: { get: function () { // Convert to CMS answer name from bool diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 313e6f5bd..e48524336 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -182,6 +182,7 @@ export default { this.isInsuranceSelected, false ); + if (!this.isInsuranceSelected) { this.dispatchStoreAction( this.storeActions.SAVE_PARENT_ACCOUNT_NUMBER, @@ -196,18 +197,21 @@ export default { ) { this.supportingItems = this.filterOutFees(this.supportingItems); } + if (this.pricedGlassParts.length > 0) { this.dispatchStoreAction( - this.storeActions.SAVE_GLASS_PARTS, + this.storeActions.SAVE_GLASS_PART_PRICES, this.pricedGlassParts, false ); } + this.dispatchStoreAction( this.storeActions.SAVE_SUPPORTING_ITEMS, this.supportingItems, false ); + this.dispatchStoreAction(this.storeActions.SAVE_VAPS, this.selectedVaps, false); const payment = this.$store.getters.payment; diff --git a/src/layouts/schedule/location-alerts/location-alerts.vue b/src/layouts/schedule/location-alerts/location-alerts.vue index 0f8c7b46d..66f26e263 100644 --- a/src/layouts/schedule/location-alerts/location-alerts.vue +++ b/src/layouts/schedule/location-alerts/location-alerts.vue @@ -36,8 +36,12 @@ export default { }, }, methods: { - loadInitialData(zipCodeCtu) { - return getAlertReasons(zipCodeCtu); + loadInitialData(serviceLocationCtu, providerCtu) { + let ctuToUse = serviceLocationCtu; + if (providerCtu) { + ctuToUse = providerCtu; + } + return getAlertReasons(ctuToUse); }, initializeComponent(initialData) { this.alertReasons = initialData; diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index d5e3b4c28..aa0065d89 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -4,20 +4,13 @@ -
- - - {{ getRouterLinkDisplayTextFromCopy(copy) }} - - - -
+ - { +const getAvailableDates = async (startDate, endDate, appointmentType, providerNumber) => { // USING DATES PASSED, MAKE AN API CALL - const newShopTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction( - storeActions.GET_SHOP_TIME_SLOTS, - { - startDate: startDate, - endDate: endDate, - shopAppointmentType: appointmentType, - }, - false - ); - const newShopTimeSlots = newShopTimeSlotsResponse.data; - return convertApiResponse(newShopTimeSlots); -}; -const convertApiResponse = (responseData) => { - // DATA CONVERSION - responseData?.days.forEach((date) => { - const dateString = date.date; - date.dateString = dateString; - const dateStringPieces = dateString.split("-"); - date.year = Number(dateStringPieces[0]); - date.month = Number(dateStringPieces[1]); - date.date = Number(dateStringPieces[2]); - }); - return responseData; + + let newTimeSlotsResponse; + if (appointmentType === AppointmentTypeStrings.MOBILE) { + newTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction( + storeActions.GET_MOBILE_TIME_SLOTS, + { + startDate: startDate, + endDate: endDate, + }, + false + ); + } else { + newTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction( + storeActions.GET_SHOP_TIME_SLOTS, + { + startDate: startDate, + endDate: endDate, + shopAppointmentType: appointmentType, + providerNumber: providerNumber, + }, + false + ); + } + + return newTimeSlotsResponse.data; }; export default { name: "schedule", data() { return { - selectedDate: null, - selectedTimeSlotId: null, + selectedDate: this.getSelectedDate(), + selectedTimeSlotData: { + id: this.getSelectedRouteCode(), + isPremiumAppointment: null, + }, selectableDatesData: [], - mobileEarlyBirdFee: null, + mobilePremiumAppointmentFee: null, }; }, async beforeRouteEnter(to, from, next) { @@ -125,40 +120,30 @@ export default { selectableDatesSetting: "custom", initialViewRowsToShow: 5, customSelectableDatesCallback: getAvailableDates, - - /* vvvvv SAVE THESE FOR TESTING PURPOSES FOR NOW vvvvv - - // todayOverrideDateString: "2023-04-29T03:00:00", // show partial - // todayOverrideDateString: "2023-04-30T03:00:00", // - // todayOverrideDateString: "2023-05-02T03:00:00", // ONE MONTH ONLY - // todayOverrideDateString: "2023-05-06T03:00:00", // ONE MONTH ONLY - // todayOverrideDateString: "2023-05-07T03:00:00", // show partial - // todayOverrideDateString: "2023-05-30T03:00:00", // - // todayOverrideDateString: "2023-06-30T03:00:00", // - // todayOverrideDateString: "2023-07-01T03:00:00", // show partial && ONE MONTH ONLY - // todayOverrideDateString: "2023-07-02T03:00:00", // ONE MONTH ONLY - // todayOverrideDateString: "2023-07-12T03:00:00", // show partial - // todayOverrideDateString: "2023-08-31T03:00:00", - // todayOverrideDateString: "2023-09-30T03:00:00", // show partial - */ }); - // Price EARLY BIRD pre-emptively to allow for asynchronous call to pricing - const pricingPromise = baseMixin.methods.dispatchStoreAction( - storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, - { - availableLineItems: [{ partNumber: "EARLY BIRD" }], - }, - false + const premiumFeePromise = baseMixin.methods.dispatchStoreAction( + storeActions.GET_MOBILE_PREMIUM_FEE ); - const earlyBirdPromise = baseMixin.methods.dispatchStoreAction( - storeActions.GET_MOBILE_EARLY_BIRD_FEE - ); + const premiumFeeWithPricePromise = premiumFeePromise.then((result) => { + if (result.data) { + return baseMixin.methods.dispatchStoreAction( + storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + availableLineItems: [result.data], + }, + false + ); + } else { + return result.data; + } + }); + const alertReasonsPromise = locationAlerts.methods.loadInitialData( - store.getters.order.serviceLocation.zipCodeCtu + store.getters.order.serviceLocation.zipCodeCtu, + store.getters.order.serviceLocation.provider?.address?.zipCtu ); - // Settle promises and get results const promiseResultMap = [ { @@ -174,12 +159,8 @@ export default { promise: datePickerInitialDataPromise, }, { - resultKey: "earlyBird", - promise: earlyBirdPromise, - }, - { - resultKey: "pricingResults", - promise: pricingPromise, + resultKey: "premiumFeeWithPrice", + promise: premiumFeeWithPricePromise, }, ]; @@ -191,9 +172,10 @@ export default { vm.$refs.datePicker.initializeComponent(resultMap.datePickerInitialData); vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons); vm.selectableDatesData = resultMap.datePickerInitialData.initialShopTimeSlotsResponse; - if (resultMap.earlyBird) { - vm.mobileEarlyBirdFee = resultMap.pricingResults[0]; - } + vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice + ? resultMap.premiumFeeWithPrice[0] + : null; + vm.updateFooterButtonText(vm.selectedTimeSlotData); }); }, computed: { @@ -208,26 +190,28 @@ export default { return this.$store.getters.order.serviceLocation.appointmentType; }, timeSlotsForSelectedDate() { - if (this.selectedDate === null) { + if (!this.selectedDate) { return null; } - return this.selectableDatesData.days.find( - (selectableDate) => selectableDate.dateString === this.selectedDate.dateString + return this.selectableDatesData.days?.find( + (selectableDate) => selectableDate.date === this.selectedDate ); }, appointmentDateAndTime() { - if (!this.selectedTimeSlotId) { + if (!this.selectedTimeSlotData.id) { return null; } const timeSlotSelectedObject = this.getTimeSlotObjectFromTimeSlotId( - this.selectedTimeSlotId + this.selectedTimeSlotData.id ); if (timeSlotSelectedObject) { return { - date: this.selectedDate.dateString, + date: this.selectedDate, startTime: timeSlotSelectedObject.startTime, endTime: timeSlotSelectedObject.endTime, - id: this.selectedTimeSlotId, + routeCode: this.selectedTimeSlotData.id, + jobMaxMinutes: + this.selectableDatesData.estimatedServiceMinutesMaximum.toString(), }; } else { return null; @@ -235,19 +219,29 @@ export default { }, }, methods: { - doesCopyContainRouterLink, splitCopyOnCMSPlaceHolder, - getRouterLinkRouteFromCopy, - getRouterLinkDisplayTextFromCopy, arePagePrerequisitesValid() { - return true; - // NEED TODO - WHAT ARE PAGE REQ'S FOR THIS PAGE? + const serviceLocation = store.getters.order.serviceLocation; + const serviceLocationPreReqs = + serviceLocation.zipCode && + serviceLocation.zipCodeCtu && + serviceLocation.appointmentType && + (serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE || + serviceLocation.provider.providerNumber); + const paymentInfo = store.getters.payment.isInsurance !== null; + const supportingItems = store.getters.lineItems.supportingItems !== null; + const damageInfo = + store.getters.order.damage.isRepair || + (store.getters.order.lineItems?.glassParts != null && + store.getters.order.lineItems.glassParts.length > 0); + return serviceLocationPreReqs && paymentInfo && supportingItems && damageInfo; }, async getAvailableDatesMethod(startDate, endDate) { const newShopTimeSlots = await getAvailableDates( startDate, endDate, - this.appointmentType + this.appointmentType, + this.$store.getters.order.serviceLocation.provider.providerNumber ); // ADD API CALL RESULTS TO EXISTING DATE DATA this.selectableDatesData.days = this.selectableDatesData.days.concat( @@ -263,42 +257,145 @@ export default { }, getTimeSlotObjectFromTimeSlotId(timeSlotId) { const timeSlots = this.selectableDatesData.days.find( - (selectableDate) => selectableDate.dateString === this.selectedDate.dateString + (selectableDate) => selectableDate.date === this.selectedDate ).timeSlots; return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId); }, + getSelectedDate() { + return store.getters.order.schedule.date; + }, + getSelectedRouteCode() { + return store.getters.order.schedule.routeCode; + }, timeSlotModalClosed() { - // Clear the selectedDate if no timeslot has been selected - if (!this.selectedTimeSlotId) { + // Clear the selectedDate if no timeSlot has been selected + if (!this.selectedTimeSlotData.id) { this.selectedDate = null; } }, + updateFooterButtonText(timeSlotData) { + let funnelFooterButtonText; + if (!timeSlotData.id) { + funnelFooterButtonText = "Continue"; + } else { + funnelFooterButtonText = + "Select " + this.convertSelectedDateToShortMonthAndDay(this.selectedDate); + + if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) { + funnelFooterButtonText += + " at " + + this.getDisplayTextForMilitaryTime(this.appointmentDateAndTime.startTime); + } else if ( + this.appointmentType === AppointmentTypeStrings.MOBILE && + !timeSlotData.isPremiumAppointment + ) { + funnelFooterButtonText += + " at " + + this.getDisplayTextForMilitaryTime( + this.appointmentDateAndTime.startTime, + true + ) + + " - " + + this.getDisplayTextForMilitaryTime( + this.appointmentDateAndTime.endTime, + true + ); + } + } + + this.$refs.funnelFooter.updateButtonText(funnelFooterButtonText); + }, + convertSelectedDateToShortMonthAndDay(selectedDate) { + // This conversion ensures we don't get get GMT induced date changes + const dateObject = new Date(`${selectedDate}T00:00:00`); + // Ex: April 25 + return dateObject.toLocaleDateString("en-us", { month: "short", day: "numeric" }); + }, + // Expected input: "HH:MM" + getDisplayTextForMilitaryTime(militaryTimeInput, shouldTrimMinutesIfEmpty = false) { + let hours = parseInt(militaryTimeInput.split(":")[0]); + const minutes = militaryTimeInput.split(":")[1]; + const meridianNotation = hours > 11 ? "PM" : "AM"; + if (hours > 12) { + hours -= 12; + } + if (shouldTrimMinutesIfEmpty && minutes === "00") { + return `${hours} ${meridianNotation}`; + } else { + return `${hours}:${minutes} ${meridianNotation}`; + } + }, backButtonAction() { this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route); }, - async forwardButtonAction() { - //TODO: replace properties with real values once they are available - await this.dispatchStoreAction( + forwardButtonAction() { + this.updateSupportingItems(); + this.dispatchStoreAction( this.storeActions.SAVE_SCHEDULE, - { - date: "2023-07-04T00:00:00", - startTime: "2023-07-04T12:00:00", - endTime: "2023-07-04T17:00:00", - routeCode: "03341-01820-S-B*20232*11 AM", - }, + this.appointmentDateAndTime, false ); - navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal }); }, + + updateSupportingItems() { + const supportingItems = store.getters.lineItems.supportingItems; + + // if we have a premium fee(early bird), then save/update supporting items + if ( + this.appointmentType === AppointmentTypeStrings.MOBILE && + this.selectedTimeSlotData?.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 + ); + } + } + }, }, watch: { selectedDate(newValue, oldValue) { // Clear time slot selection if date selected changes if (newValue !== oldValue) { - this.selectedTimeSlotId = null; + this.selectedTimeSlotData = { + id: null, + isPremiumAppointment: null, + }; } }, + selectedTimeSlotData(newValue) { + this.updateFooterButtonText(this.selectedTimeSlotData); + }, }, components: { funnelHeader, @@ -309,6 +406,7 @@ export default { datePicker, locationAlerts, timeSlotModalQuestion, + textBlock, }, }; diff --git a/src/layouts/schedule/time-slot-modal-question/timeslot-modal-list-button/timeslot-modal-list-button.vue b/src/layouts/schedule/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue similarity index 72% rename from src/layouts/schedule/time-slot-modal-question/timeslot-modal-list-button/timeslot-modal-list-button.vue rename to src/layouts/schedule/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue index 60b2f9e81..44ac0d9e8 100644 --- a/src/layouts/schedule/time-slot-modal-question/timeslot-modal-list-button/timeslot-modal-list-button.vue +++ b/src/layouts/schedule/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue @@ -6,45 +6,32 @@
- + {{ buttonLabel }} - - - {{ formattedButtonLabelSubCopy }} + + {{ formattedButtonLabelSubCopy }} + {{ screenReaderOnlyText }} -
diff --git a/src/layouts/schedule/time-slot-modal-question/time-slot-modal-question.vue b/src/layouts/schedule/time-slot-modal-question/time-slot-modal-question.vue index a73ab712d..0b4c15441 100644 --- a/src/layouts/schedule/time-slot-modal-question/time-slot-modal-question.vue +++ b/src/layouts/schedule/time-slot-modal-question/time-slot-modal-question.vue @@ -14,17 +14,17 @@ typeStyle="small" class="duration-text-block" />
{ - let readableTime; - if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) { - readableTime = this.getDisplayTextForMilitaryTime(timeslot.startTime); - } else if (this.appointmentType === AppointmentTypeStrings.MOBILE) { - readableTime = `${this.getDisplayTextForMilitaryTime( - timeslot.startTime - )} - ${this.getDisplayTextForMilitaryTime(timeslot.endTime)}`; - } - return { - value: timeslot.id, - buttonLabel: readableTime, - }; - }); - } - if (this.appointmentType === AppointmentTypeStrings.MOBILE) { - const offerPremium = this.dateAndTimeSlotData.timeSlots[0].offerPremium; - const hasEarlyBird = this.mobileEarlyBirdFee?.partType === "EARLY BIRD"; - if (offerPremium && hasEarlyBird) { - availableTimeSlots.unshift({ - value: this.dateAndTimeSlotData.timeSlots[0].id + "-earlybird", - buttonLabel: this.earlyBirdButtonText, - buttonLabelSubCopy: this.getTotalLineItemPrice(this.mobileEarlyBirdFee), - }); - } + // This conversion ensures we don't get get GMT induced date changes + const dateObject = new Date(`${this.dateAndTimeSlotData.date}T00:00:00`); + // Ex: Tuesday, April 22 + return dateObject.toLocaleDateString("en-us", { + weekday: "long", + month: "long", + day: "numeric", + }); + }, + availableTimeSlots() { + if (!this.dateAndTimeSlotData) { + return null; + } + if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) { + return this.getAvailableTimeSlotsForDropOff(this.dateAndTimeSlotData.timeSlots); + } else if (this.appointmentType === AppointmentTypeStrings.MOBILE) { + return this.getAvailableTimeSlotsForMobile(this.dateAndTimeSlotData.timeSlots); + } else { + return this.getAvailableTimeSlotsForInshop(this.dateAndTimeSlotData.timeSlots); } - return availableTimeSlots; }, }, methods: { @@ -222,22 +196,29 @@ export default { }, // fires any time the footer button is used, is fired before "onModalClosed" closeModal() { - this.$emit("update:modelValue", this.selectedTimeSlot); + let isSelectedAppointmentPremium = false; + if (this.selectedTimeSlotId.includes(PREMIUM_TIME_SLOT_ID_FLAG)) { + this.selectedTimeSlotId = this.removePremiumFlagFromInput(this.selectedTimeSlotId); + isSelectedAppointmentPremium = true; + } + const selectedTimeSlotData = { + id: this.selectedTimeSlotId, + isPremiumAppointment: isSelectedAppointmentPremium, + }; + this.$emit("update:modelValue", selectedTimeSlotData); this.$refs["timeSlots"].closeModal(); }, // fires any time the modal is closed, AFTER "closeModal" fires if footer button is used onModalClosed() { - this.selectedTimeSlot = this.modelValue; this.$emit("time-slot-modal-closed"); }, - // Expected input: "HH:MM:SS" + // Expected input: "HH:MM" getDisplayTextForMilitaryTime(militaryTimeInput) { let hours = parseInt(militaryTimeInput.split(":")[0]); const minutes = militaryTimeInput.split(":")[1]; - let meridianNotation = "AM"; - if (militaryTimeInput.split(":")[0] > 12) { + const meridianNotation = hours > 11 ? "PM" : "AM"; + if (hours > 12) { hours -= 12; - meridianNotation = "PM"; } return `${hours}:${minutes} ${meridianNotation}`; }, @@ -252,6 +233,70 @@ export default { } return displayTextForDurationLength; }, + getAvailableTimeSlotsForInshop(timeSlotsForSelectedDate) { + return timeSlotsForSelectedDate.map((timeSlot) => { + const readableTime = this.getDisplayTextForMilitaryTime(timeSlot.startTime); + return { + value: timeSlot.id, + buttonLabel: readableTime, + }; + }); + }, + getAvailableTimeSlotsForDropOff(timeSlotsForSelectedDate) { + return [ + { + value: timeSlotsForSelectedDate[0].id, + buttonLabel: this.dropoffButtonText, + }, + ]; + }, + getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) { + const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => { + const readableTime = `${this.getDisplayTextForMilitaryTime( + timeSlot.startTime + )} - ${this.getDisplayTextForMilitaryTime(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 and the premium appoinment shares a timeSlot ID + value: this.addPremiumFlagToInput(timeSlotData.id), + buttonLabel: this.premiumAppointmentButtonText, + buttonLabelSubCopy: formattedPrice, + additionalButtonData: { + isPremiumAppointment: true, + }, + }; + }, + autoSelectTimeSlotIfOnlyOneIsAvailable(newAvailableTimeSlotsValue) { + const numberOfOptions = newAvailableTimeSlotsValue?.length; + if (numberOfOptions === 1) { + this.selectedTimeSlotId = newAvailableTimeSlotsValue[0].value; + } + }, + addPremiumFlagToInput(timeSlotId) { + return (timeSlotId += PREMIUM_TIME_SLOT_ID_FLAG); + }, + removePremiumFlagFromInput(timeSlotId) { + return timeSlotId.substring(0, timeSlotId.length - PREMIUM_TIME_SLOT_ID_FLAG.length); + }, }, components: { modal, @@ -271,7 +316,17 @@ export default { margin-bottom: 0 !important; } .text-block.duration-text-block { - margin-top: 0 !important; + 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; + } } } diff --git a/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.js b/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.js deleted file mode 100644 index 45899973f..000000000 --- a/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.js +++ /dev/null @@ -1,27 +0,0 @@ -// For nested objects, spread operator only creates new references to the top level fields, -// the remaining nested fields actually reference the original object which can introduce problems. - -// The purpose of this method is to deep clone the data in an object recursively, this is useful -// for cloning modelValues to internal models when regular two-way binding is not an option. -// See: mobile-location-modal-questions.vue - -// Creates a deep clone of an object. Clones primitives, arrays and objects, excluding class instances. -// https://www.30secondsofcode.org/js/s/deep-clone -export function deepClone(object) { - if (object === null) { - return null; - } - - let clone = Object.assign({}, object); - Object.keys(clone).forEach( - (key) => - (clone[key] = typeof object[key] === "object" ? deepClone(object[key]) : object[key]) - ); - - if (Array.isArray(object)) { - clone.length = object.length; - return Array.from(clone); - } - - return clone; -} diff --git a/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.spec.js b/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.spec.js deleted file mode 100644 index 4246f9167..000000000 --- a/src/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper.spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { deepClone } from "./object-cloning-helper"; - -describe("object-cloning-helper.js", () => { - it("Should return null if no object is passed in", async () => { - // Arrange - const expected = null; - - // Act - const result = deepClone(null); - - // Assert - expect(result).toEqual(expected); - }); - - it("Should return a deep copy of the object", async () => { - // Arrange - - const object = { - addressQuestions: { - streetAddress: "555 Some St", - apartmentNumberOrBusinessName: "Apt 1", - city: "Funkytown", - state: "OH", - zipCode: "55555", - }, - isVehicleProtected: true, - serviceZipCode: "55555", - }; - - const expected = { - addressQuestions: { - streetAddress: "555 Some St", - apartmentNumberOrBusinessName: "Apt 1", - city: "Funkytown", - state: "OH", - zipCode: "55555", - }, - isVehicleProtected: true, - serviceZipCode: "55555", - }; - - // Act - const result = deepClone(object); - - // Assert - expect(result).toStrictEqual(expected); - }); - - it("Should return a copy of the array", async () => { - // Arrange - - const array = [9, 8, 7, 6, 5, 4, 3, 2, 1]; - - const expected = [9, 8, 7, 6, 5, 4, 3, 2, 1]; - - // Act - const result = deepClone(array); - - // Assert - expect(result).toStrictEqual(expected); - }); -}); diff --git a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js index 157c28f2b..b100a50b5 100644 --- a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js +++ b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.js @@ -1,4 +1,5 @@ import { storeActions } from "@/constants/store-actions"; +import store from "@/store"; import baseMixin from "@/mixins/base-mixin.js"; export async function getPricedMobileFeePart(serviceZipCode) { @@ -42,3 +43,48 @@ export async function getServiceabilityDetails(serviceZipCode, lineItems) { return Promise.resolve(serviceabilityDetails); } + +export async function getAvailabilityRating( + startDate, + endDate, + shopAppointmentType, + providerNumber +) { + // For a given shop provider number and date range, get the appointment time slots available + const shopTimeSlots = await baseMixin.methods.dispatchStoreAction( + storeActions.GET_SHOP_TIME_SLOTS, + { + providerNumber: providerNumber, + startDate: startDate, + endDate: endDate, + shopAppointmentType: shopAppointmentType, + }, + false + ); + + // Rate the availability for the shop + let numberOfAppointmentsPerDay = []; + for (let i = 0; i < shopTimeSlots.data.days.length; i++) { + numberOfAppointmentsPerDay.push(shopTimeSlots.data.days[i].timeSlots.length); + } + + const dateRange = 7; + const minimumNumberOfAppointmentsPerDay = 1; + const numberOfDaysToEvaluate = 2; + + let daysWithMinimalAppointmentsCount = 0; + for (let i = 0; i < dateRange; i++) { + if (numberOfAppointmentsPerDay[i] >= minimumNumberOfAppointmentsPerDay) { + daysWithMinimalAppointmentsCount++; + if (daysWithMinimalAppointmentsCount >= numberOfDaysToEvaluate) { + break; + } + } + } + + const isGoodAvailability = daysWithMinimalAppointmentsCount >= numberOfDaysToEvaluate; + + const shopStatus = isGoodAvailability ? "high" : "low"; + + return Promise.resolve(shopStatus); +} diff --git a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.spec.js b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.spec.js index 755b0d870..81d336968 100644 --- a/src/layouts/service-location/helpers/service-location-helper/service-location-helper.spec.js +++ b/src/layouts/service-location/helpers/service-location-helper/service-location-helper.spec.js @@ -1,10 +1,186 @@ -import { getPricedMobileFeePart, getServiceabilityDetails } from "./service-location-helper"; +import { + getPricedMobileFeePart, + getServiceabilityDetails, + getAvailabilityRating, +} from "./service-location-helper"; import { storeActions } from "@/constants/store-actions"; +jest.mock("@/store", () => ({ + getters: { + order: { + vehicle: { + year: null, + make: null, + model: null, + style: null, + carId: null, + category: null, + vin: null, + imageUrl: null, + imageVifNumber: null, + imageColor: null, + registration: { + licensePlate: null, + address: null, + city: null, + state: null, + zipCode: null, + firstName: null, + lastName: null, + }, + }, + serviceLocation: { + address: null, + city: null, + state: null, + zipCode: null, + zipCodeCtu: null, + appointmentType: null, + provider: { + providerNumber: null, + address: { + streetAddress: null, + city: null, + state: null, + zip: null, + }, + }, + }, + customer: { + emailAddress: null, + }, + damage: { + isRepair: null, + numberOfChips: null, + glassToReplace: null, + partQuestionAnswers: null, + moldingQuestionAnswers: null, + capabilityQuestionAnswers: null, + }, + lineItems: { + glassParts: null, + supportingItems: null, + vaps: null, + serverData: null, + }, + payment: { + isInsurance: null, + insuranceCoverage: { + isVerified: null, + coverageStatus: null, + }, + parentAccountNumber: 0, + }, + schedule: { + date: null, + startTime: null, + endTime: null, + routeCode: null, + }, + referralNumber: null, + referralDate: null, + referralCorrelationId: null, + eon: null, + }, + }, +})); + const mockStoreActionGetMobileFeePart = storeActions.GET_MOBILE_FEE_PART; const mockStoreActionPriceOrderItemsAndSaveServerData = storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA; const mockStoreActionGetServiceabilityDetails = storeActions.GET_SERVICEABILITY_DETAILS; +const mockStoreActionGetShopTimeSlots = storeActions.GET_SHOP_TIME_SLOTS; + +const mockGetShopTimeSlotsGoodAvailability = { + estimatedServiceMinutesMinimum: 0, + estimatedServiceMinutesMaximimum: 0, + days: [ + { + date: "string", + timeSlots: [ + { + id: "string", + startTime: "", + endTime: "", + offerPremium: true, + }, + ], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [ + { + id: "string", + startTime: "", + endTime: "", + offerPremium: true, + }, + ], + }, + ], +}; + +const mockGetShopTimeSlotsLowAvailability = { + estimatedServiceMinutesMinimum: 0, + estimatedServiceMinutesMaximimum: 0, + days: [ + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [], + }, + { + date: "string", + timeSlots: [ + { + id: "string", + startTime: "", + endTime: "", + offerPremium: true, + }, + ], + }, + ], +}; jest.mock("@/mixins/base-mixin.js", () => ({ methods: { @@ -18,7 +194,7 @@ jest.mock("@/mixins/base-mixin.js", () => ({ }); }), - dispatchStoreAction: jest.fn().mockImplementation((actionName) => { + dispatchStoreAction: jest.fn().mockImplementation((actionName, request) => { if (actionName === mockStoreActionGetMobileFeePart) { return Promise.resolve({ data: { @@ -50,6 +226,14 @@ jest.mock("@/mixins/base-mixin.js", () => ({ isRecalibrationServiceableMobile: true, }); } + + if (actionName === mockStoreActionGetShopTimeSlots) { + if (request.providerNumber == "0000001") { + return Promise.resolve(mockGetShopTimeSlotsGoodAvailability); + } + + return Promise.resolve(mockGetShopTimeSlotsLowAvailability); + } }), }, })); @@ -124,4 +308,25 @@ describe("service-location-helper.js", () => { expect(result).toEqual(expected); }); }); + + describe("getAvailabilityRating", () => { + // it("Should return a 'Good' rating", async () => { + // // Arrange + // const providerNumber = "0000001"; + // const expected = "Good"; + // // Act + // const result = await getAvailabilityRating(providerNumber); + // // Assert + // expect(result).toEqual(expected); + // }); + // it("Should return a 'Low' rating", async () => { + // // Arrange + // const providerNumber = "0000000"; + // const expected = "Low"; + // // Act + // const result = await getAvailabilityRating(providerNumber); + // // Assert + // expect(result).toEqual(expected); + // }); + }); }); diff --git a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue index 48642101c..0052c1368 100644 --- a/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue +++ b/src/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue @@ -66,7 +66,7 @@ import addressQuestions from "@/layouts/address-lookup/customer-questions/addres import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question"; // Helpers -import { deepClone } from "@/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper"; +import { deepClone } from "@/helpers/object-helper"; import { getPricedMobileFeePart, getServiceabilityDetails, @@ -202,6 +202,7 @@ export default { this.internalModel = deepClone(this.modelValue); }, onModalClosed() { + this.displayInvalidZipAlert = false; this.internalModel = deepClone(this.modelValue); this.resetValidation(); }, @@ -261,15 +262,19 @@ export default { this.$emit("updated-serviceability", serviceabilityDetails.data); this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase); - // Update the page level model - this.$emit("update:modelValue", this.internalModel); - if (this.onZipUpdateCallback) { await this.onZipUpdateCallback(serviceZipCode); } + // Update the page level model + this.$emit("update:modelValue", this.internalModel); this.closeModal(); } + } else { + // Update the page level model + this.$emit("update:modelValue", this.internalModel); + + this.closeModal(); } }, }, diff --git a/src/layouts/service-location/service-location.vue b/src/layouts/service-location/service-location.vue index 713cdb089..8f63e5df7 100644 --- a/src/layouts/service-location/service-location.vue +++ b/src/layouts/service-location/service-location.vue @@ -138,11 +138,11 @@ export default { data() { return { streetAddress: this.getServiceAddressFromStore(), - apartmentNumberOrBusinessName: "", + apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), city: this.getServiceCityFromStore(), state: this.getServiceStateFromStore(), zipCode: this.getServiceZipCodeFromStore(), - isVehicleProtected: null, + isVehicleProtected: this.getIsVehicleProtectedFromStore(), isGlassServiceableInshop: null, isRecalibrationServiceableInshop: null, isGlassServiceableMobile: null, @@ -339,6 +339,9 @@ export default { getServiceAddressFromStore() { return store.getters.order.serviceLocation.address; }, + getServiceAddress2FromStore() { + return store.getters.order.serviceLocation.address2; + }, getServiceCityFromStore() { return store.getters.order.serviceLocation.city; }, @@ -348,6 +351,9 @@ export default { getServiceZipCodeFromStore() { return store.getters.order.serviceLocation.zipCode; }, + getIsVehicleProtectedFromStore() { + return store.getters.order.serviceLocation.isVehicleProtected; + }, getSelectedAppointmentType() { return store.getters.order.serviceLocation.appointmentType; }, @@ -391,6 +397,7 @@ export default { city: this.selectedProvider?.address?.city, state: this.selectedProvider?.address?.state, zip: this.selectedProvider?.address?.zipCode, + zipCtu: this.selectedProvider?.address?.zipCodeCtu, }, }, }, diff --git a/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.spec.js b/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.spec.js index b9eb6299b..1248d7ebb 100644 --- a/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.spec.js +++ b/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.spec.js @@ -2,7 +2,7 @@ import { mount } from "@vue/test-utils"; import shopListButton from "./shop-list-button"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin"; -describe("service-package-radio.vue", () => { +describe("shop-list-button.vue", () => { it("Should include buttonLabel in html", async () => { // Arrange let { wrapper } = setupMocks({ @@ -49,6 +49,18 @@ describe("service-package-radio.vue", () => { }); }); +const startDate = new Date(); +const endDate = new Date(); +endDate.setDate(startDate.getDate() + 7); +const getAvailabilityRating = jest.fn().mockImplementation((actionName, request) => { + return Promise.resolve({ + data: {}, + }); +}); + +const formattedStartDate = startDate.toISOString().split("T")[0]; +const formattedEndDate = endDate.toISOString().split("T")[0]; + const mockProps = { buttonLabel: "buttonLabel test copy", buttonLabelSubCopy: "buttonLabelSubCopy test copy", @@ -58,6 +70,12 @@ const mockProps = { value: 0, modelValue: 0, groupName: "mockGroup", + additionalButtonData: { + availabilityRatingCallback: getAvailabilityRating, + startDate: formattedStartDate, + endDate: formattedEndDate, + shopAppointmentType: "Dropoff", + }, }; function setupMocks({ mountOptionsMockData = {} }) { diff --git a/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.vue b/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.vue index b8003148f..df80fdffa 100644 --- a/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.vue +++ b/src/layouts/service-location/shop-question/shop-list-button/shop-list-button.vue @@ -11,26 +11,32 @@ {{ buttonLabel }} - {{ + {{ buttonLabelSubCopy }}
- {{ buttonAuxillaryCopy }} + v-if="displayAvailabilityIndicators" + class="availability-indicator rounded-pill" + :class="availabilityRatingClass"> +
+ {{ + badgeText + }} +
- +
+ +
{{ screenReaderOnlyText }} - @@ -40,32 +46,60 @@ import loader from "@/ux-components/loader/loader"; import baseInputButton from "@/digital-components/base-input-button/base-input-button"; import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin"; +import experimentMixin from "@/mixins/experiment-mixin"; +import { experimentSettings } from "@/constants/experiments"; export default { name: "shopListButton", mixins: [inputButtonWrapperMixin], - props: { - loaderColor: String, - loaderPosition: { - type: String, - default: "right", - }, + beforeMount() { + if (this.displayAvailabilityIndicators) { + this.displayLoader(); + const startDate = this.additionalButtonData.startDate; + const endDate = this.additionalButtonData.endDate; + const shopAppointmentType = this.additionalButtonData.shopAppointmentType; + + this.additionalButtonData + .availabilityRatingCallback(startDate, endDate, shopAppointmentType, this.value) + .then((data) => { + this.availabilityRating = data; + }); + } }, data() { return { - isLoaderDisplayed: false, - availability: "low", + availabilityRating: null, }; }, + computed: { + displayAvailabilityIndicators() { + return experimentMixin.methods.hasSettingEqualTo( + experimentSettings.DISPLAY_AVAILABILITY_INDICATORS, + "true" + ); + }, + isLoaderDisplayed() { + return this.availabilityRating == null; + }, + availabilityRatingClass() { + if (this.availabilityRating == null) { + return "gray"; + } else { + return this.availabilityRating == "high" ? "green" : "red"; + } + }, + badgeText() { + if (this.availabilityRating != null) { + return this.availabilityRating == "high" ? "Appts available" : "Appts low"; + } + + return ""; + }, + }, methods: { displayLoader() { this.isLoaderDisplayed = true; }, - preHandleAnswerChange() { - if (this.selectingInitiatesLoad) { - this.displayLoader(); - } - }, }, components: { loader, @@ -75,9 +109,6 @@ export default { diff --git a/src/layouts/service-location/shop-question/shop-question.vue b/src/layouts/service-location/shop-question/shop-question.vue index 3183cbc9e..929c779af 100644 --- a/src/layouts/service-location/shop-question/shop-question.vue +++ b/src/layouts/service-location/shop-question/shop-question.vue @@ -19,7 +19,8 @@ textPosition="text-start" v-model="selectedProviderNumber" isRequired - validationRules="option-required" /> + validationRules="option-required" + :additionalButtonData="additionalButtonData" /> diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index 5e2ec0c71..ae89a61e6 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -1,4 +1,3 @@ -fmg