This commit is contained in:
Michaela Brydon 2023-10-11 13:54:36 -04:00
commit 96348c48eb
29 changed files with 2905 additions and 85 deletions

View file

@ -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'

View file

@ -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;

View file

@ -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
};

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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;

View file

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

View file

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

View file

@ -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 {

View file

@ -19,7 +19,7 @@
:class="justifySubheader">
<p
class="fw-normal mb-0"
:class="alternateFormatting">
:class="[alternateFormatting, subTextClasses]">
<span v-html="subText"> </span>
</p>
</div>
@ -49,7 +49,8 @@ export default {
issContainingPage: String,
justification: String,
stripRteStyle: Boolean,
subContentProperty: String
subContentProperty: String,
subTextClasses: String
},
emits: ['click-event'],
computed: {

View file

@ -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);
});
});
});

View file

@ -77,6 +77,10 @@
isRequired
:validationRules="rules.selectionRequired">
</buttonQuestion>
<text-block
v-if="displayQuote"
cmsWidgetName="DisclaimerWidget"
typeStyle="caption" />
</div>
<siteFooter
ref="siteFooter"
@ -111,6 +115,7 @@ import alert from '@/ux-components/alert/alert.vue';
import contentGroupModal from '@/iss-components/content-group-modal/content-group-modal.vue';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import loadingModal from '@/iss-components/loading-modal/loading-modal.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
// Import Supporting Files
import { fetchCmsContentForPage, setupModalLinks, setupModalLink, processIfStatements } from '@/helpers/cms-content-helper.js';
@ -134,14 +139,13 @@ export default {
alert,
contentGroupModal,
buttonQuestion,
loadingModal
loadingModal,
textBlock
},
mixins: [baseFormMixin, vehicleQuestionsMixin],
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to?.query?.issPage);
const wipersPromise = await useMainStore().getWipers();
const rainDefensePromise = await useMainStore().getRainDefense();
const supportingItemsPromise = await useMainStore().getSupportingItems();
// Settle promises and get results
@ -150,14 +154,6 @@ export default {
resultKey: 'cmsContent',
promise: cmsContentPromise
},
{
resultKey: 'wipers',
promise: wipersPromise
},
{
resultKey: 'rainDefense',
promise: rainDefensePromise
},
{
resultKey: 'supportingItems',
promise: supportingItemsPromise
@ -169,9 +165,7 @@ export default {
? JSON.parse(JSON.stringify(useMainStore().order.lineItems.glassParts))
: [];
const availableLineItems = [
resultMap.rainDefense,
...(resultMap.supportingItems ?? []),
...(resultMap.wipers ?? []),
...(clonedGlassParts ?? [])
];
@ -335,6 +329,7 @@ export default {
nextStepsBody(newValue, oldValue) {
if (newValue !== oldValue) {
setupModalLink(this, 'RecalModal');
setupModalLink(this, 'DeductibleModal');
}
}
},
@ -357,6 +352,7 @@ export default {
return this.navigateForward();
},
async navigateForward() {
useMainStore().updatePolicyITACFlag(this.verifiedITAC);
if (this.unverified || this.verifiedDeductible) {
this.mainStore.saveSupportingItems(this.supportingItems);
this.$router.navigate(
@ -465,7 +461,6 @@ export default {
</script>
<style lang="scss" scoped>
.cost {
color: $green;
font-size: 2rem;
@ -512,5 +507,8 @@ export default {
margin-top: 0.5rem;
}
}
.modal {
overflow: hidden;
}
</style>

View file

@ -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;

View file

@ -0,0 +1,57 @@
<template>
<alert
v-for="alert in prefixedAlertReasons"
:key="alert.cmsWidgetName"
:ref="alert.cmsWidgetName"
class="mt-2 mb-3"
:cmsWidgetName="alert.cmsWidgetName"
alertClass="alert-warning" />
</template>
<script>
import alert from '@/ux-components/alert/alert.vue';
import { mockGetAlertReasons } from '@/layouts/schedule-page/helpers/schedule-helper';
export default {
name: 'location-alerts',
components: {
alert
},
props: {
cmsWidgetPrefix: {
type: String,
default: ''
}
},
data() {
return {
alertReasons: []
};
},
computed: {
prefixedAlertReasons() {
return this.alertReasons.reduce((newObj, alertReason) => {
newObj.push({
cmsWidgetName: `${this.cmsWidgetPrefix}${alertReason}`,
alertReason
});
return newObj;
}, []);
}
},
methods: {
loadInitialData(serviceLocationCtu, providerCtu) {
let ctuToUse = serviceLocationCtu;
if (providerCtu) {
ctuToUse = providerCtu;
}
return mockGetAlertReasons(ctuToUse); // Change to getAlertReasons after testing.
},
initializeComponent(initialData) {
this.alertReasons = initialData;
},
cmsHeadlineTextFound(widgetName) {
return this.getCmsContent(widgetName, 'HeadlineText') !== '';
}
}
};
</script>

View file

@ -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();
});
});
});

View file

@ -5,18 +5,56 @@
@submit="onSubmit"
@invalidSubmit="onInvalidSubmit">
<div class="page-container-grouped-styles">
<div class="fade-on-route-transition position-relative">
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<div class="container-fluid pb-2">
<p>Placeholder for schedule page</p>
<siteFooter
ref="siteFooter"
class="mt-5"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@ForwardClicked="forwardButtonAction"
@backClicked="backButtonAction" />
</div>
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<siteSubHeader
cmsWidgetName="ScheduleSubHeaderWidget"
subTextClasses="text-center small sub-text"
class="mt-5" />
<template v-if="ChangeShopLink.length">
<textBlock
cmsWidgetName="ChangeShopLink"
justifyText="center"
class="mb-3 text-link-small"
:marginTopSizeOverride="1" />
</template>
<div class="main-content-container">
<locationAlerts
ref="locationAlerts"
cmsWidgetPrefix="LocationAlert-" />
<datePicker
ref="datePicker"
v-model="selectedDate"
customComponentId="dateQuestion"
selectableDatesSetting="custom"
class="text-link-small"
:customSelectableDatesCallback="getAvailableDatesMethod"
validationRules="date-required"
@dateClicked="openInshopTimeSlotsModal" />
<timeSlotModalQuestion
ref="timeSlotModalQuestion"
v-model="selectedTimeSlotInfo"
customComponentId="timeSlotModalQuestion"
cmsWidgetName="TimeSlotModalQuestion"
mobilePremiumCmsWidgetName="MobilePremiumTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
:selectedDate="selectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:estimatedServiceMinutesMinimum="selectableDatesData.estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="selectableDatesData.estimatedServiceMinutesMaximum"
validationRules="time-slot-selection-required"
@timeSlotModalClosed="timeSlotModalClosed" />
<siteFooter
ref="navbar"
class="mt-5"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@backClicked="backButtonAction"
@forwardClicked="forwardButtonAction" />
</div>
</div>
</Form>
@ -24,41 +62,209 @@
<script>
// Components
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import locationAlerts from '@/layouts/schedule-page/location-alerts/location-alerts.vue';
import datePicker from '@/digital-components/date-picker/date-picker.vue';
import timeSlotModalQuestion from '@/layouts/schedule-page/time-slot-modal-question/time-slot-modal-question.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
// Supporting files
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import {
calcDaysBetweenDates,
convertDateStringToDate,
sumDateString
} from '@/layouts/schedule-page/helpers/schedule-helper';
import {
AppointmentTypeStrings,
GET_MOBILE_TIME_SLOTS,
GET_SHOP_TIME_SLOTS,
PREMIUM_FEE_PART_TYPE
} from '@/constants/schedule-constants.js';
import { fetchCmsContentForPage, splitCopyOnCMSPlaceHolder } from '@/helpers/cms-content-helper';
import settleAllPromises from '@/helpers/layout-helper';
import { Form } from 'vee-validate';
import { Form, defineRule } from 'vee-validate';
import BaseFormMixin from '@/mixins/base-form-mixin.js';
import errorMessages from '@/constants/error-messages';
import { required } from '@/helpers/validation-rules';
import { useMainStore } from '@/store';
// DEFINE VALIDATION RULES
defineRule('date-required', required(errorMessages.DATE_REQUIRED));
defineRule('time-slot-selection-required', (value) => {
if (value?.timeSlot?.routeCode == null) {
return errorMessages.DATE_REQUIRED;
}
return true;
});
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const apiEndDateLimit = sumDateString(startDateString, TIME_SLOTS_CALL_DAYS_LIMIT);
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
const storeActionConfigs = [];
const timeSlotsData = {};
timeSlotsData.days = [];
let apiStartDate = startDateString;
let apiEndDate = endDateString;
for (let i = 1; i <= apiCallsCount; i++) {
let storeActionConfig;
if (i > 1) {
apiStartDate = sumDateString(apiEndDate, 1);
apiEndDate = sumDateString(apiStartDate, TIME_SLOTS_CALL_DAYS_LIMIT);
if (i === apiCallsCount) {
apiEndDate = endDateString;
}
} else if (apiEndDate > apiEndDateLimit) {
apiEndDate = apiEndDateLimit;
}
if (appointmentType === AppointmentTypeStrings.MOBILE
|| appointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC) {
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.shopAppointmentType,
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,
timeSlotModalQuestion,
siteFooter,
textBlock,
// eslint-disable-next-line vue/no-reserved-component-names
Form
},
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 premiumFeePromise = useMainStore().getMobilePremiumFee();
const premiumFeeWithPricePromise = premiumFeePromise.then((result) => {
if (result.data) {
return useMainStore().priceOrderItemsAndSaveServerData(result.data);
}
return result.data;
});
const alertReasonsPromise = locationAlerts.methods.loadInitialData(
useMainStore().order.serviceLocation.zipCodeCtu,
useMainStore().order.serviceLocation.provider?.address?.zipCodeCtu
);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: 'cmsContent',
promise: cmsContentPromise
}];
},
{
resultKey: 'alertReasons',
promise: alertReasonsPromise
},
{
resultKey: 'datePickerInitialData',
promise: datePickerInitialDataPromise
},
{
resultKey: 'premiumFeeWithPrice',
promise: premiumFeeWithPricePromise
}
];
// use resultMap to populate layout content.
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, resultMap.premiumFeeWithPrice);
vm.updateFooterButtonText(vm.selectedTimeSlotInfo);
});
},
setup() {
@ -66,15 +272,61 @@ export default {
return { mainStore };
},
data() {
return {
selectedDate: this.getSelectedDate(),
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesData: [],
mobilePremiumAppointmentFee: null
};
},
computed: {
ChangeShopLinkText() {
return this.getCmsContent('ChangeShopLink', 'Text');
},
ChangeShopLink() {
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
appointmentType() {
return useMainStore().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: {
splitCopyOnCMSPlaceHolder,
arePagePrerequisitesValid() {
const { serviceLocation } = useMainStore().order;
const serviceLocationPreReqs = serviceLocation.zipCode
&& serviceLocation.zipCodeCtu
&& serviceLocation.appointmentType
&& (serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE
&& ((serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE
|| serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC)
|| serviceLocation.provider.providerNumber);
const paymentInfo = useMainStore().payment.isInsurance !== null;
const supportingItems = useMainStore().lineItems.supportingItems !== null;
@ -82,19 +334,167 @@ export default {
useMainStore().order.damage.isRepair
|| (useMainStore().order.lineItems?.glassParts != null
&& useMainStore().order.lineItems.glassParts.length > 0);
return serviceLocationPreReqs && paymentInfo && supportingItems && damageInfo;
},
setData(initialShopTimeSlotsResponse, premiumFeeWithPriceResponse) {
this.selectableDatesData = initialShopTimeSlotsResponse;
this.mobilePremiumAppointmentFee = premiumFeeWithPriceResponse
? premiumFeeWithPriceResponse[0]
: null;
},
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.mainStore.saveSupportingItemsSuppressingStateResetting(supportingItems);
} 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.mainStore.saveSupportingItemsSuppressingStateResetting(supportingItems);
}
}
},
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
// validate and save here
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD, this.$route);
},
this.updateSupportingItems();
this.mainStore.saveSchedule(this.selectedTimeSlotInfo.timeSlot);
navigateForward() {
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD, this.$route);
}
}
};
</script>
<style lang="scss" scoped>
$page-side-padding: 1.5rem;
.page-container-grouped-styles {
overflow: auto;
.main-content-container {
padding: 0 1.5rem !important;
}
}
:deep(.text-link-small) {
a, .btn-link {
font-size: 0.875rem;
line-height: 1.5;
}
}
:deep(.subheader-primary) {
h5.dark-header {
margin-bottom: 0.25rem !important;
}
}
</style>

View file

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

View file

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

View file

@ -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;
}
}
}
}

View file

@ -31,6 +31,7 @@
</span>
</div>
<textBlock
v-if="isITAC"
:customText="mobileFeeText"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="caption" />
@ -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');
},

View file

@ -59,7 +59,7 @@
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" />
<mobileLocationModalQuestions
v-if="selectedAppointmentType === 'Mobile'"
v-if="isMobileLocationDisplayed"
ref="mobileLocationQuestions"
v-model="mobileLocationQuestions"
customComponentId="mobileLocationQuestions"
@ -94,6 +94,7 @@
</template>
<script>
// Import Supporting Files
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import settleAllPromises from '@/helpers/layout-helper';
import { required } from '@/helpers/validation-rules';
@ -260,7 +261,8 @@ export default {
this.isVehicleProtected = newValue.isVehicleProtected;
if (newValue.zipCode !== this.zipCode) {
if (!this.selectedAppointmentType === 'Mobile') {
if (!(this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC)) {
this.selectedAppointmentType = null;
}
this.selectedProvider = null;
@ -289,6 +291,10 @@ export default {
isAppointmentTypeDisplayed() {
return this.zipCode && !this.displayNoShopsAlert;
},
isMobileLocationDisplayed() {
return this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC;
},
requiresInshopRecalibration() {
// Specifically check for isRecalibrationServiceableMobile === false, not null or true.
return (

View file

@ -36,7 +36,7 @@
:text="showMoreShopsLinkText"
href="#!"
:aria-label="showMoreShopsLinkText"
@click-event="getNextShopsFromList" />
@clickEvent="getNextShopsFromList" />
</div>
</transition>
</template>
@ -48,6 +48,7 @@ import buttonQuestion from '@/digital-components/button-question/button-question
import textLink from '@/ux-components/text-link/text-link.vue';
// Supporting files
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import baseMixin from '@/mixins/base-mixin.js';
import { defineRule } from 'vee-validate';
import { required } from '@/helpers/validation-rules';
@ -141,7 +142,7 @@ export default {
await nextTick();
if (newValue !== 'Mobile') {
if (newValue !== AppointmentTypeStrings.MOBILE && newValue !== AppointmentTypeStrings.MOBILE_NOT_ITAC) {
this.getNextShopsFromList();
}
}
@ -253,6 +254,8 @@ export default {
</script>
<style lang="scss">
@import "@/styles/ux-variables-svg-strings.scss";
.shop-question {
margin-top: 1rem;
text-align: center;
@ -264,7 +267,7 @@ export default {
}
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background: url($svg-drop-off-alert);
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;

View file

@ -198,6 +198,16 @@ defineRule(
'policy-zip-format',
regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.POLICY_ZIP_FORMAT)
);
defineRule('loss-date-gt-10-years', (value) => {
const lossDate = new Date(Date.parse(`${value}T00:00:00`));
const today = new Date();
const tenYearsAgo = new Date(today.getFullYear() - 10, today.getMonth(), today.getDate(), 0, 0, 0, 0);
if (lossDate < tenYearsAgo) {
return errorMessages.DAMAGE_DATE_REQUIREMENT;
}
return true;
});
export default {
name: 'welcome-page',
@ -247,7 +257,7 @@ export default {
rules: {
policyNumber: 'policy-number-required',
policyZip: 'policy-zip-required|policy-zip-format',
lossDate: 'loss-date-required',
lossDate: 'loss-date-required|loss-date-gt-10-years',
damageOption: 'damage-option-required',
phoneNumber: `${globalRules.PHONE_NUMBER_REQUIRED}|${globalRules.PHONE_NUMBER_FORMAT}`,
email: `${globalRules.EMAIL_ADDRESS_REQUIRED}|${globalRules.EMAIL_ADDRESS_FORMAT}`,

View file

@ -54,6 +54,9 @@ const navigationScenarios = Object.freeze({
CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS: 'CLICKED_BACK_WITH_VIN_AND_NO_MORE_QUESTIONS',
CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS: 'CLICKED_BACK_WITH_NO_VIN_NOR_QUESTIONS',
// Schedule
CLICKED_CHANGE_LOCATION: 'CLICKED_CHANGE_LOCATION',
// Coverage Statement
CLICKED_BACK_WITH_REPAIR: 'CLICKED_BACK_WITH_REPAIR',
CLICKED_FORWARD_WITH_INVALID_STATE: 'CLICKED_FORWARD_WITH_INVALID_STATE',

View file

@ -555,6 +555,10 @@ const routingTable = () => [
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationIssPageValue: issPageValues.CONTACT_DETAILS
},
{
scenario: navigationScenarios.CLICKED_CHANGE_LOCATION,
destinationIssPageValue: issPageValues.SERVICE_LOCATION
}
]
},

View file

@ -11,10 +11,24 @@ import applicationConfig from '@/constants/application-config';
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 { AppointmentTypeStrings, 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: {
@ -60,6 +74,7 @@ const getDefaultState = () => ({
repair: null, // numerical value; how much customer owes on deductible in repair case
replace: null // numerical value; how much customer owes on deductible in replace case,
},
isITAC: null,
vehicles: [],
endorsementQuestionAnswers: null
},
@ -106,7 +121,8 @@ const getDefaultState = () => ({
isInsurance: true, // TODO delete; irrelevant to ISS
insuranceCoverage: {
isVerified: false,
coverageStatus: coverageStatuses.PENDING
coverageStatus: coverageStatuses.PENDING,
claimNumber: null
},
parentAccountNumber: 0
},
@ -123,7 +139,8 @@ const getDefaultState = () => ({
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null
jobMaxMinutes: null,
jobMinMinutes: null
},
referralNumber: null,
referralDate: null,
@ -178,6 +195,9 @@ export const useMainStore = defineStore({
payment: (state) => state.order.payment,
policy: (state) => state.order.policy,
hasAnyNonWindshieldGlassParts: (state) => !state.order.policy.isDamageGlassOnly,
isMobileAppointment: (state) => state.order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE
|| state.order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC,
isDropOffAppointment: (state) => state.order.serviceLocation.appointmentType === AppointmentTypeStrings.DROP_OFF,
isClaimRegistrationRequired: (state) => state.issConfig.isClaimRegistrationRequired,
eventBusItem: (state) => (eventCategory, eventSubCategory) => {
const matchedEvent = state.applicationUser.eventBus.find(({ category, subCategory }) => category === eventCategory && subCategory === eventSubCategory);
@ -464,17 +484,20 @@ export const useMainStore = defineStore({
}).then((response) => {
const registerClaimFailed = response.data.isError;
order.payment.insuranceCoverage.isVerified = !registerClaimFailed;
order.payment.insuranceCoverage.claimNumber = null;
if (registerClaimFailed) {
this.order.payment.insuranceCoverage.coverageStatus = coverageStatuses.PENDING;
} else if (this.policy.noCoverage) {
this.order.payment.insuranceCoverage.coverageStatus = coverageStatuses.NO_COMP;
} else {
this.order.payment.insuranceCoverage.coverageStatus = coverageStatuses.VERIFIED;
this.order.payment.insuranceCoverage.claimNumber = response.data.claimNumber;
}
return resolve(response);
}, (error) => {
this.order.payment.insuranceCoverage.isVerified = false;
this.order.payment.insuranceCoverage.coverageStatus = coverageStatuses.PENDING;
this.order.payment.insuranceCoverage.claimNumber = null;
return reject(error);
});
});
@ -608,7 +631,75 @@ export const useMainStore = defineStore({
}
});
},
getMobilePremiumFee() {
const damageType = this.damage.isRepair ? 'Repair' : 'Replace';
const paymentType = this.order.payment.isInsurance ? 'Insurance' : 'Cash';
return globalMethods.callHttpClient({
method: endpoints.GetMobilePremiumFee.method,
endpoint: `${endpoints.GetMobilePremiumFee.url}/${paymentType}/${damageType}`,
logApiCall: true
});
},
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.issConfig.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;
@ -631,7 +722,7 @@ export const useMainStore = defineStore({
endDate,
shopAppointmentType,
applicationName: applicationConfig.APPLICATION_NAME,
parentAccountNumber: this.payment.parentAccountNumber,
parentAccountNumber: this.issConfig.parentAccountNumber, // this.payment.parentAccountNumber,
carId: vehicle.carId,
lineItems,
glassPieces,
@ -645,9 +736,7 @@ export const useMainStore = defineStore({
hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length,
hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length,
hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length,
hasManuallySelectedParts:
!!this.applicationUser.pageData['vehicle-parts']?.partsOrQuestions
.length
hasManuallySelectedParts: !!this.applicationUser.pageData['vehicle-parts']?.partsOrQuestions.length
},
vehicle: {
year: vehicle.year,
@ -871,7 +960,8 @@ export const useMainStore = defineStore({
policyFirstName: customer.firstName,
policyLastName: customer.lastName,
policyPhoneNumber: customer.phoneNumber,
policyEmail: customer.emailAddress
policyEmail: customer.emailAddress,
policyState: customer.address.state
},
policyNumber: policy.policyNumber,
policyZipCode: policy.policyZipCode,
@ -902,7 +992,8 @@ export const useMainStore = defineStore({
payment: {
InsuranceCoverage: {
isVerified: payment.insuranceCoverage?.isVerified ?? false,
coverageStatus: payment.insuranceCoverage?.coverageStatus
coverageStatus: payment.insuranceCoverage?.coverageStatus,
claimNumber: payment.insuranceCoverage?.claimNumber
},
isInsurance: payment.isInsurance ?? true,
parentAccountNumber: this.issConfig.parentAccountNumber
@ -934,7 +1025,8 @@ export const useMainStore = defineStore({
startTime: schedule.startTime,
endTime: schedule.endTime,
routeCode: schedule.routeCode,
jobMaxMinutes: schedule.jobMaxMinutes
jobMaxMinutes: schedule.jobMaxMinutes,
jobMinMinutes: schedule.jobMinMinutes
},
referralDate: this.order.referralDate,
referralNumber: this.order.referralNumber?.toString(),
@ -1127,16 +1219,16 @@ export const useMainStore = defineStore({
this.order.vehicle.registration.lastName = registrationInfo?.lastName;
},
updateServiceLocation(serviceLocationInfo) {
state.order.serviceLocation.address = serviceLocationInfo.address;
state.order.serviceLocation.address2 = serviceLocationInfo.address2;
state.order.serviceLocation.city = serviceLocationInfo.city;
state.order.serviceLocation.state = serviceLocationInfo.state;
state.order.serviceLocation.zipCode = serviceLocationInfo.zipCode;
state.order.serviceLocation.zipCodeCtu = serviceLocationInfo.zipCodeCtu;
state.order.serviceLocation.appointmentType = serviceLocationInfo.appointmentType;
state.order.serviceLocation.isVehicleProtected = serviceLocationInfo.isVehicleProtected;
this.order.serviceLocation.address = serviceLocationInfo.address;
this.order.serviceLocation.address2 = serviceLocationInfo.address2;
this.order.serviceLocation.city = serviceLocationInfo.city;
this.order.serviceLocation.state = serviceLocationInfo.state;
this.order.serviceLocation.zipCode = serviceLocationInfo.zipCode;
this.order.serviceLocation.zipCodeCtu = serviceLocationInfo.zipCodeCtu;
this.order.serviceLocation.appointmentType = serviceLocationInfo.appointmentType;
this.order.serviceLocation.isVehicleProtected = serviceLocationInfo.isVehicleProtected;
state.order.serviceLocation.provider = {
this.order.serviceLocation.provider = {
providerNumber: serviceLocationInfo.provider?.providerNumber,
address: {
streetAddress: serviceLocationInfo.provider?.address?.streetAddress,
@ -1251,7 +1343,7 @@ export const useMainStore = defineStore({
this.order.schedule.endTime = null;
this.order.schedule.routeCode = null;
this.order.schedule.jobMaxMinutes = null;
// this.order.schedule.jobMinMinutes = null;
this.order.schedule.jobMinMinutes = null;
// premium appointment fee used on schedule page also needs reset when schedule is reset
const { supportingItems } = this.order.lineItems;
@ -1396,6 +1488,9 @@ export const useMainStore = defineStore({
this.order.customer.lastName = customerQuestions.lastName;
this.order.serviceLocation.zipCode = customerQuestions.addressQuestions.zipCode;
},
updatePolicyITACFlag(isITAC) {
this.order.policy.isITAC = isITAC;
},
savePartQuestionAnswers(partQuestionAnswersArray) {
// if part question answers have changed, reset subsequent question answers
const sortedPreviousResultsArray = sortArrayOfObjectsByPropertyValue(this.order.damage.partQuestionAnswers, 'result');
@ -1453,6 +1548,10 @@ export const useMainStore = defineStore({
this.order.lineItems.glassParts = glassParts;
},
saveSupportingItems(supportingItems) {
// TODO: RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES
this.order.lineItems.supportingItems = supportingItems;
},
saveSupportingItemsSuppressingStateResetting(supportingItems) {
this.order.lineItems.supportingItems = supportingItems;
},
saveVaps(vaps) {
@ -1522,6 +1621,31 @@ export const useMainStore = defineStore({
}
},
// Location API Actions
getAlertReasonsByCtu(ctu) {
return globalMethods.callHttpClient({
method: endpoints.GetAlertReasons.method,
endpoint: `${endpoints.GetAlertReasons.url}/${ctu}`,
payload: {},
logApiCall: true
});
},
// Schedule Actions
saveSchedule(scheduleInfo) {
this.updateSchedule(scheduleInfo);
},
updateSchedule(scheduleInfo) {
if (scheduleInfo) {
this.order.schedule.date = scheduleInfo.date;
this.order.schedule.startTime = scheduleInfo.startTime;
this.order.schedule.endTime = scheduleInfo.endTime;
this.order.schedule.routeCode = scheduleInfo.routeCode;
this.order.schedule.jobMaxMinutes = scheduleInfo.jobMaxMinutes;
this.order.schedule.jobMinMinutes = scheduleInfo.jobMinMinutes;
}
},
// Analytics Actions
logExperimentExposure({ userId, sessionKey, pageName, experiment }) {
return globalMethods.callHttpClient({
@ -1803,6 +1927,7 @@ export const useMainStore = defineStore({
this.order.policy.damageCause = null;
this.order.policy.damageCity = null;
this.order.policy.damageState = null;
this.order.policy.isITAC = null;
this.order.customer.phoneNumber = null;
this.order.customer.emailAddress = null;
this.order.customer.firstName = null;

View file

@ -4,6 +4,7 @@ import globalMethods from '@/global-methods.js';
import { getRandomString, getRandomGuid, getRandomInt, getRandomBoolean } from '@/helpers/data-generation.js';
import coverageStatuses from '@/constants/coverage-statuses.js';
import { endpoints } from '@/constants/endpoints';
import { AppointmentTypeStrings } from '@/constants/schedule-constants';
describe('Store', () => {
let store;
@ -396,6 +397,7 @@ describe('Store', () => {
expect(globalMethods.callHttpClient).toHaveBeenCalled();
expect(store.payment.insuranceCoverage.isVerified).toBe(true);
expect(store.payment.insuranceCoverage.coverageStatus).toBe(coverageStatuses.NO_COMP);
expect(store.payment.insuranceCoverage.claimNumber).toBe(null);
});
it('successful response with coverage => isVerified true and coverage status verified', async () => {
@ -422,11 +424,12 @@ describe('Store', () => {
expect(globalMethods.callHttpClient).toHaveBeenCalled();
expect(store.payment.insuranceCoverage.isVerified).toBe(true);
expect(store.payment.insuranceCoverage.coverageStatus).toBe(coverageStatuses.VERIFIED);
expect(store.payment.insuranceCoverage.claimNumber).toBe(response.data.claimNumber);
});
it('Call to client returns exception, resulting in object with error property being returned', async () => {
// Arrange
expect.assertions(4);
expect.assertions(5);
const error = 'this is the error';
globalMethods.callHttpClient = jest.fn().mockReturnValue(Promise.reject(error));
@ -439,6 +442,7 @@ describe('Store', () => {
expect(globalMethods.callHttpClient).toHaveBeenCalled();
expect(store.payment.insuranceCoverage.isVerified).toBe(false);
expect(store.payment.insuranceCoverage.coverageStatus).toBe(coverageStatuses.PENDING);
expect(store.payment.insuranceCoverage.claimNumber).toBe(null);
});
});
@ -624,6 +628,7 @@ describe('Store', () => {
const customerLastName = getRandomString(6, 6);
const customerEmail = getRandomString(6, 6);
const customerPhoneNumber = getRandomString(6, 6);
const customerState = getRandomString(2, 2);
const policyNumber = getRandomString(6, 6);
const policyZipCode = getRandomString(6, 6);
const policyLookupSuccessful = getRandomString(6, 6);
@ -636,6 +641,7 @@ describe('Store', () => {
store.order.customer.lastName = customerLastName;
store.order.customer.emailAddress = customerEmail;
store.order.customer.phoneNumber = customerPhoneNumber;
state.order.customer.address.state = customerState;
store.order.policy.policyNumber = policyNumber;
store.order.policy.policyZipCode = policyZipCode;
store.order.policy.policyLookupSuccessful = policyLookupSuccessful;
@ -653,7 +659,8 @@ describe('Store', () => {
policyFirstName: customerFirstName,
policyLastName: customerLastName,
policyPhoneNumber: customerPhoneNumber,
policyEmail: customerEmail
policyEmail: customerEmail,
policyState: customerState
}),
policyNumber,
policyZipCode,
@ -687,6 +694,7 @@ describe('Store', () => {
store.order.customer.address.city = city;
store.order.customer.address.state = state;
store.order.customer.address.zipCode = zipCode;
globalMethods.callHttpClient = jest.fn().mockReturnValue(Promise.resolve({}));
// Act
@ -720,6 +728,7 @@ describe('Store', () => {
store.order.lineItems.glassParts = glassParts;
store.order.lineItems.supportingItems = supportingItems;
store.order.lineItems.vaps = vaps;
globalMethods.callHttpClient = jest.fn().mockReturnValue(Promise.resolve({}));
// Act
@ -776,6 +785,7 @@ describe('Store', () => {
const state = getRandomString(6, 6);
const zipCode = getRandomString(6, 6);
const zipCodeCtu = getRandomString(6, 6);
store.order.contactInfo.notesForTechnician = notesForTechnician;
store.order.serviceLocation.address = address;
store.order.serviceLocation.city = city;
@ -810,11 +820,14 @@ describe('Store', () => {
const endTime = getRandomString(6, 6);
const routeCode = getRandomString(6, 6);
const jobMaxMinutes = getRandomString(6, 6);
const jobMinMinutes = getRandomString(6, 6);
store.order.schedule.date = date;
store.order.schedule.startTime = startTime;
store.order.schedule.endTime = endTime;
store.order.schedule.routeCode = routeCode;
store.order.schedule.jobMaxMinutes = jobMaxMinutes;
store.order.schedule.jobMinMinutes = jobMinMinutes;
globalMethods.callHttpClient = jest.fn().mockReturnValue(Promise.resolve({}));
// Act
@ -828,7 +841,8 @@ describe('Store', () => {
startTime,
endTime,
routeCode,
jobMaxMinutes
jobMaxMinutes,
jobMinMinutes
})
})
}));
@ -1396,4 +1410,101 @@ describe('Store', () => {
expect(store.applicationUser.duplicateOrders.length).toBe(0);
});
});
describe('updatePolicyITACFlag method', () => {
it('updatePolicyITACFlag updates policy.isITAC flag in store', () => {
// Arrange
const moqIsITAC = getRandomBoolean();
// Act
store.updatePolicyITACFlag(moqIsITAC);
// Assert
expect(store.order.policy.isITAC).toEqual(moqIsITAC);
});
});
describe('isMobileAppointment', () => {
it('Should return true for mobile appointments', () => {
// Arrange
// Act
store.updateServiceLocation({
appointmentType: AppointmentTypeStrings.MOBILE
});
// Assert
expect(store.isMobileAppointment).toBe(true);
});
it('Should return true for mobile-insurance appointments', () => {
// Arrange
// Act
store.updateServiceLocation({
appointmentType: AppointmentTypeStrings.MOBILE_NOT_ITAC
});
// Assert
expect(store.isMobileAppointment).toBe(true);
});
it('Should return false for non-mobile appointments', () => {
// Arrange
// Act
store.updateServiceLocation({
appointmentType: AppointmentTypeStrings.IN_SHOP
});
// Assert
expect(store.isMobileAppointment).toBe(false);
});
it('Should return false for null appointments', () => {
// Arrange
// Act
store.updateServiceLocation({ appointmentType: null });
// Assert
expect(store.isMobileAppointment).toBe(false);
});
});
describe('isDropoffAppointment', () => {
it('Should return true for drop-off appointments', () => {
// Arrange
// Act
store.updateServiceLocation({
appointmentType: AppointmentTypeStrings.DROP_OFF
});
// Assert
expect(store.isDropOffAppointment).toBe(true);
});
it('Should return false for non-drop-off appointments', () => {
// Arrange
// Act
store.updateServiceLocation({
appointmentType: AppointmentTypeStrings.MOBILE
});
// Assert
expect(store.isDropOffAppointment).toBe(false);
});
it('Should return false for null appointments', () => {
// Arrange
// Act
store.updateServiceLocation({ appointmentType: null });
// Assert
expect(store.isDropOffAppointment).toBe(false);
});
});
});

View file

@ -0,0 +1,3 @@
$svg-date-picker-nav-back-button: "data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.331685 6.00121C0.330445 5.82446 0.399256 5.65442 0.523053 5.52832L5.84499 0.198256C5.97188 0.0713149 6.14397 -8.63821e-08 6.32341 -6.72174e-08C6.50285 -4.80527e-08 6.67495 0.071315 6.80183 0.198256C6.92872 0.325198 7 0.497368 7 0.67689C7 0.856412 6.92872 1.02858 6.80183 1.15552L1.94874 6.00121L6.80183 10.8526C6.92745 10.9796 6.99751 11.1512 6.99662 11.3299C6.99572 11.5085 6.92393 11.6794 6.79705 11.8051C6.67016 11.9308 6.49857 12.0009 6.32003 12C6.14148 11.9991 5.97061 11.9273 5.84499 11.8003L0.526881 6.47601C0.401568 6.34983 0.331375 6.17908 0.331685 6.00121Z' fill='%231574A1'/%3E%3C/svg%3E%0A";
$svg-date-picker-forward-button: "data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.66831 5.99879C6.66955 6.17554 6.60074 6.34558 6.47695 6.47168L1.15501 11.8017C1.02812 11.9287 0.85603 12 0.676587 12C0.497145 12 0.325053 11.9287 0.198168 11.8017C0.0712831 11.6748 7.268e-09 11.5026 8.07183e-09 11.3231C8.87567e-09 11.1436 0.0712831 10.9714 0.198168 10.8445L5.05126 5.99879L0.198168 1.14736C0.0725521 1.02042 0.00248585 0.848755 0.00338306 0.670131C0.00428028 0.491506 0.0760674 0.320554 0.202952 0.194881C0.329837 0.0692091 0.501426 -0.000888818 0.679971 9.54485e-06C0.858515 0.000906955 1.02939 0.0727263 1.15501 0.199668L6.47312 5.52399C6.59843 5.65017 6.66862 5.82092 6.66831 5.99879Z' fill='%231574A1'/%3E%3C/svg%3E%0A";
$svg-drop-off-alert: "data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A";