diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index f201efba..06ecde94 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -11,6 +11,22 @@ const endpoints = Object.freeze({ url: (applicationAbbreviation, pageName) => `/content/api/v1/content/${applicationAbbreviation}/${pageName}`, method: 'GET' }, + GetAlertReasons: { + 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/error-messages.js b/src/constants/error-messages.js index 90f6f6f9..0c8d4719 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -41,6 +41,8 @@ const errorMessages = Object.freeze({ LOSS_STATE_REQUIRED: 'Please select an option', LOSS_DATE_REQUIRED: 'Please select a date. Format must be MM/DD/YYYY and the date must be not in the future', + DAMAGE_DATE_REQUIREMENT: + 'Damage date must be within the past 10 years', DAMAGE_OPTION_REQUIRED: 'Please select an option', POLICYHOLDER_FIRST_NAME_REQUIRED: 'Please enter the policyholder first name', @@ -51,7 +53,8 @@ const errorMessages = Object.freeze({ MAKE_REQUIRED: 'Please select your vehicle make', MODEL_REQUIRED: 'Please select your vehicle model', STYLE_REQUIRED: 'Please select your vehicle style', - MOBILE_LOCATION_REQUIRED: 'Please enter your service address' + MOBILE_LOCATION_REQUIRED: 'Please enter your service address', + DATE_REQUIRED: 'Please select a date' }); export default errorMessages; diff --git a/src/constants/schedule-constants.js b/src/constants/schedule-constants.js index eaa0d5fa..af0c6206 100644 --- a/src/constants/schedule-constants.js +++ b/src/constants/schedule-constants.js @@ -1,6 +1,7 @@ const AppointmentTypeStrings = { IN_SHOP: 'Inshop', MOBILE: 'Mobile', + MOBILE_NOT_ITAC: 'Mobile-Not-ITAC', DROP_OFF: 'Dropoff' }; const PREMIUM_FEE_PART_TYPE = 'EARLY BIRD'; @@ -10,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..b57dcdc1 --- /dev/null +++ b/src/digital-components/date-picker/date-picker.vue @@ -0,0 +1,1064 @@ + + + + + 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/cms-content-helper.js b/src/helpers/cms-content-helper.js index 7d03c8fa..b3130ac7 100644 --- a/src/helpers/cms-content-helper.js +++ b/src/helpers/cms-content-helper.js @@ -271,7 +271,9 @@ function getStoreValueFromString(str) { // eslint-disable-next-line no-restricted-syntax for (const s of str.split('.')) { if (s === 'getters') continue; // For backward compatibility - if (typeof storeOrStateObject[s] !== 'undefined') { + // TODO: I don't think this next line is doing what they think it's doing. + // eslint-disable-next-line eqeqeq, valid-typeof + if (typeof storeOrStateObject[s] != undefined) { storeOrStateObject = storeOrStateObject[s]; } else { break; 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/iss-components/site-header/menu-modal/menu-modal.vue b/src/iss-components/site-header/menu-modal/menu-modal.vue index 3c248abf..ddeee120 100644 --- a/src/iss-components/site-header/menu-modal/menu-modal.vue +++ b/src/iss-components/site-header/menu-modal/menu-modal.vue @@ -162,7 +162,7 @@ export default { border-top: 1px solid $gray-300; overflow-x: visible; overflow-y: visible; - z-index: 2; + z-index: 3; .modal-body { padding: 2rem; .ccpa-icon { diff --git a/src/iss-components/site-sub-header/site-sub-header.vue b/src/iss-components/site-sub-header/site-sub-header.vue index 4cd0af1d..2c998fb3 100644 --- a/src/iss-components/site-sub-header/site-sub-header.vue +++ b/src/iss-components/site-sub-header/site-sub-header.vue @@ -19,7 +19,7 @@ :class="justifySubheader">

+ :class="[alternateFormatting, subTextClasses]">

@@ -49,7 +49,8 @@ export default { issContainingPage: String, justification: String, stripRteStyle: Boolean, - subContentProperty: String + subContentProperty: String, + subTextClasses: String }, emits: ['click-event'], computed: { diff --git a/src/layouts/coverage-statement/coverage-statement.spec.js b/src/layouts/coverage-statement/coverage-statement.spec.js index d4c000b2..68ce7fd0 100644 --- a/src/layouts/coverage-statement/coverage-statement.spec.js +++ b/src/layouts/coverage-statement/coverage-statement.spec.js @@ -795,3 +795,26 @@ describe.skip('coverageStatement.vue', () => { }); }); }); + +describe('coverageStatement.vue-working', () => { + describe('Navigation', () => { + test('Function called on any FORWARD navigation', () => { + // Arrange + const mainInitialState = { + order: { + policy: { + isITAC: null + } + } + }; + const { wrapper } = getMountedComponent(mainInitialState); + + // Act + wrapper.vm.forwardButtonAction(); + + // Assert + expect(wrapper.vm.mainStore.updatePolicyITACFlag) + .toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/layouts/coverage-statement/coverage-statement.vue b/src/layouts/coverage-statement/coverage-statement.vue index 9166c994..2c3166d0 100644 --- a/src/layouts/coverage-statement/coverage-statement.vue +++ b/src/layouts/coverage-statement/coverage-statement.vue @@ -77,6 +77,10 @@ isRequired :validationRules="rules.selectionRequired"> + diff --git a/src/layouts/schedule-page/helpers/schedule-helper.js b/src/layouts/schedule-page/helpers/schedule-helper.js new file mode 100644 index 00000000..6f41d306 --- /dev/null +++ b/src/layouts/schedule-page/helpers/schedule-helper.js @@ -0,0 +1,94 @@ +import { useMainStore } from '@/store'; + +const getAlertReasons = async (ctu) => { + const store = useMainStore(); + const alertReasons = await store.getAlertReasonsByCtu(ctu); + + return Promise.resolve(alertReasons); +}; + +// This function will go away after testing. +// The Service already went through testing and had these removed from it. +export async function mockGetAlertReasons(ctu) { + let retList = []; + if (ctu === '01853') { // Ocala, FL 34470 + retList = [ + 'Hurricane', 'ExtremeTemperature' + ]; + } else if (ctu === '01814') { // Phoenix, AZ 85026 + retList = [ + 'ExtremeTemperature' + ]; + } else if (ctu === '01845') { // Raleigh, NC 27601 + retList = [ + 'Hurricane' + ]; + } + 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/location-alerts/location-alerts.vue b/src/layouts/schedule-page/location-alerts/location-alerts.vue new file mode 100644 index 00000000..46a84e5c --- /dev/null +++ b/src/layouts/schedule-page/location-alerts/location-alerts.vue @@ -0,0 +1,57 @@ + + diff --git a/src/layouts/schedule-page/schedule-page.spec.js b/src/layouts/schedule-page/schedule-page.spec.js new file mode 100644 index 00000000..1e348080 --- /dev/null +++ b/src/layouts/schedule-page/schedule-page.spec.js @@ -0,0 +1,184 @@ +// Components +import schedule from '@/layouts/schedule-page/schedule-page.vue'; + +// Supporting Files +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; +import { getMountOptions } from '@/helpers/unit-test-helper.js'; +import { useMainStore } from '@/store/index.js'; + +const mockMixin = { + methods: { + getCmsContent: jest.fn().mockImplementation(() => ''), + setCmsContent: jest.fn() + } +}; + +const footerStub = { + render: () => {}, + methods: { + updateButtonText: jest.fn() + } +}; + +const loadingModalStub = { + render: () => {}, + methods: { + showModal: jest.fn(), + hideModal: jest.fn() + } +}; + +function getShallowMountedComponent(initialData = {}, methodToRun = () => {}) { + const mountOptions = getMountOptions({ + router: { + navigate: jest.fn() + } + }); + + // mountOptions.global.mocks["$store"] = store; + + mountOptions.global.stubs = { + siteFooter: footerStub, + siteHeader: true, + recalModal: true, + contentGroupModal: true, + alert: true, + loadingModal: loadingModalStub + }; + + methodToRun(); + + mountOptions.mixins = [mockMixin]; + mountOptions.data = () => ( + initialData + ); + + const wrapper = shallowMount(schedule, mountOptions); + return { wrapper }; +} + +beforeEach(() => { + const testingPinia = createTestingPinia({ + initialState: { + main: { + order: { + schedule: { + date: '2019-01-01', + startTime: '09:00', + endTime: '10:00', + routeCode: '000' + }, + lineItems: { + glassParts: [ + { + partNumber: 'ABC123' + } + ], + supportingItems: [] + }, + serviceLocation: { + appointmentType: 'Inshop', + zipCode: '12345', + zipCodeCtu: '01234', + provider: { + providerNumber: '123' + } + }, + damage: { + isRepair: false + }, + referralNumber: '1234567' + }, + payment: { + isInsurance: true + }, + lineItems: { + glassParts: [], + supportingItems: [] + } + } + } + }); + useMainStore(testingPinia); +}); + +describe('schedule-page.vue', () => { + describe('Initial Load', () => { + test('Should pass arePagePrerequisitesValid with a mobile order and no providerNumber', () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + wrapper.vm.mainStore.order.serviceLocation.appointmentType = 'Mobile'; + wrapper.vm.mainStore.order.serviceLocation.provider = null; + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBe(true); + }); + test('Should pass arePagePrerequisitesValid with an inshop order and providerNumber', () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBe(true); + }); + test('Should fail arePagePrerequisitesValid with an inshop order and no providerNumber', () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + wrapper.vm.mainStore.order.serviceLocation.provider.providerNumber = null; + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBeFalsy(); + }); + test('Should fail arePagePrerequisitesValid without isInsurance', () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + wrapper.vm.mainStore.payment.isInsurance = null; + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBe(false); + }); + test('Should fail arePagePrerequisitesValid if supportingItems is null', async () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + wrapper.vm.mainStore.order.lineItems.supportingItems = null; + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBe(false); + }); + test('Should fail arePagePrerequisitesValid with an replace with no glass parts', async () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + wrapper.vm.mainStore.order.lineItems.glassParts = []; + + // Act + const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + + // Assert + expect(arePagePrerequisitesValid).toBe(false); + }); + }); + describe('Rendering', () => { + test('Schedule page loads', () => { + // Arrange + const { wrapper } = getShallowMountedComponent(); + + // Assert + expect(wrapper).toBeTruthy(); + }); + }); +}); diff --git a/src/layouts/schedule-page/schedule-page.vue b/src/layouts/schedule-page/schedule-page.vue index cbf42097..673f32a0 100644 --- a/src/layouts/schedule-page/schedule-page.vue +++ b/src/layouts/schedule-page/schedule-page.vue @@ -5,18 +5,56 @@ @submit="onSubmit" @invalidSubmit="onInvalidSubmit">
-
- -
-

Placeholder for schedule page

- -
+ + + +
+ + + +
@@ -24,41 +62,209 @@ + + diff --git a/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue b/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue new file mode 100644 index 00000000..c1cfecbf --- /dev/null +++ b/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue @@ -0,0 +1,118 @@ + + + + + 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..d671bb1b --- /dev/null +++ b/src/layouts/schedule-page/time-slot-modal-question/time-slot-modal-question.vue @@ -0,0 +1,525 @@ + + + + + diff --git a/src/layouts/service-location/appointment-type-question/appointment-type-question.vue b/src/layouts/service-location/appointment-type-question/appointment-type-question.vue index 25e7a529..dc7dadb2 100644 --- a/src/layouts/service-location/appointment-type-question/appointment-type-question.vue +++ b/src/layouts/service-location/appointment-type-question/appointment-type-question.vue @@ -51,13 +51,15 @@ export default { return this.getCmsContent(this.cmsWidgetName, 'Answers'); }, answersToDisplay() { - const shouldShowMobile = this.isServiceableMobile; + const shouldShowMobile = this.isServiceableMobile && this.isITAC; + const shouldShowMobileNotITAC = this.isServiceableMobile && !this.isITAC; const shouldShowInshop = this.isServiceableInshop; const shouldShowDropoff = this.isServiceableInshop && !useMainStore().damage.isRepair; return this.answersFromCms ? this.answersFromCms.filter((answer) => ( (answer.Name === AppointmentTypeStrings.IN_SHOP && shouldShowInshop) || (answer.Name === AppointmentTypeStrings.MOBILE && shouldShowMobile) + || (answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC && shouldShowMobileNotITAC) || (answer.Name === AppointmentTypeStrings.DROP_OFF && shouldShowDropoff) )) : []; @@ -70,6 +72,9 @@ export default { this.$emit('update:modelValue', newValue); } }, + isITAC() { + return useMainStore().policy.isITAC; + }, isMobileOnly() { return this.isServiceableMobile && !this.isServiceableInshop; } @@ -80,9 +85,14 @@ export default { // If there is only one option to display and that option is 'Mobile' then select it if ( newValue.length === 1 - && newValue.findIndex((answer) => answer.Name === 'Mobile') !== -1 + && newValue.findIndex((answer) => (answer.Name === AppointmentTypeStrings.MOBILE + || answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC)) !== -1 ) { - this.selectedValues = 'Mobile'; + if (this.isITAC) { + this.selectedValues = AppointmentTypeStrings.MOBILE; + } else { + this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC; + } } }, immediate: true @@ -90,7 +100,11 @@ export default { isMobileOnly: { handler(newValue) { if (newValue) { - this.selectedValues = 'Mobile'; + if (this.isITAC) { + this.selectedValues = AppointmentTypeStrings.MOBILE; + } else { + this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC; + } } } } 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 d6d850b6..3829dd76 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 @@ -31,6 +31,7 @@
@@ -81,6 +82,7 @@ import addressQuestions from '@/iss-components/address-questions/address-questio import vehicleProtectedQuestion from '@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question.vue'; // Helpers +import { useMainStore } from '@/store'; import { getPricedMobileFeePart, getServiceabilityDetails, @@ -167,6 +169,9 @@ export default { }; }, computed: { + isITAC() { + return useMainStore().policy.isITAC; + }, mobileLocationLinkPromptText() { return this.getCmsContent(this.linkWidgetName, 'HeaderText'); }, diff --git a/src/layouts/service-location/service-location.vue b/src/layouts/service-location/service-location.vue index 75b735f7..62484df8 100644 --- a/src/layouts/service-location/service-location.vue +++ b/src/layouts/service-location/service-location.vue @@ -59,7 +59,7 @@ cmsWidgetName="AppointmentTypeQuestionWidget" validationRules="option-required" />