diff --git a/package-lock.json b/package-lock.json index 1ac54acd..0ab28ced 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "maska": "^2.1.1", "pinia": "^2.1.4", "pinia-plugin-persistedstate": "^2.2.0", + "uuid": "^9.0.1", "vee-validate": "^4.5.7", "vue": "^3.3.4", "vue-plugin-load-script": "^2.1.0", @@ -11599,6 +11600,15 @@ "node": ">=10" } }, + "node_modules/jest-junit/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/jest-leak-detector": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", @@ -16662,6 +16672,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -17730,10 +17749,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index b196ea43..82ca9450 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "maska": "^2.1.1", "pinia": "^2.1.4", "pinia-plugin-persistedstate": "^2.2.0", + "uuid": "^9.0.1", "vee-validate": "^4.5.7", "vue": "^3.3.4", "vue-plugin-load-script": "^2.1.0", diff --git a/src/assets/img/icons/Google.svg b/src/assets/img/icons/Google.svg new file mode 100644 index 00000000..606351ec --- /dev/null +++ b/src/assets/img/icons/Google.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/Outlook.com.svg b/src/assets/img/icons/Outlook.com.svg new file mode 100644 index 00000000..467b585a --- /dev/null +++ b/src/assets/img/icons/Outlook.com.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/Outlook.svg b/src/assets/img/icons/Outlook.svg new file mode 100644 index 00000000..f974fb13 --- /dev/null +++ b/src/assets/img/icons/Outlook.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/img/icons/Yahoo.svg b/src/assets/img/icons/Yahoo.svg new file mode 100644 index 00000000..0206eccb --- /dev/null +++ b/src/assets/img/icons/Yahoo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/img/icons/add-to-calendar.svg b/src/assets/img/icons/add-to-calendar.svg new file mode 100644 index 00000000..fd621c5c --- /dev/null +++ b/src/assets/img/icons/add-to-calendar.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/img/icons/iCal.svg b/src/assets/img/icons/iCal.svg new file mode 100644 index 00000000..399bcdea --- /dev/null +++ b/src/assets/img/icons/iCal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/constants/application-config.js b/src/constants/application-config.js index ce39577b..7230f485 100644 --- a/src/constants/application-config.js +++ b/src/constants/application-config.js @@ -19,7 +19,11 @@ const applicationConfig = Object.freeze({ ISS_DEV_CMS_DOMAIN: 'https://digitalisscms.dev.safelite.io', CASH_PARENT_ACCOUNT_NUMBER: 167132, ITAC_FAIR_AND_REASONABLE_BILLTO: '214616', // Lib Mutal FnR BillTo - ITAC_CASH_BILLTO: '283393' // Lib Mutal ITAC Cash BillTo + ITAC_CASH_BILLTO: '283393', // Lib Mutal ITAC Cash BillTo + GOOGLE_CALENDAR: 'https://www.google.com/calendar/render?action=TEMPLATE', + YAHOO_CALENDAR: 'https://calendar.yahoo.com/?v=60', + OUTLOOK_CALENDAR: + 'https://outlook.office.com/calendar/deeplink/compose?path=/calendar/action/compose&rru=addevent' }); export default applicationConfig; diff --git a/src/constants/calendar-options.js b/src/constants/calendar-options.js new file mode 100644 index 00000000..22e9d5ef --- /dev/null +++ b/src/constants/calendar-options.js @@ -0,0 +1,8 @@ +const calendarOptions = Object.freeze({ + ICAL: 'iCal', + GOOGLE: 'Google', + OUTLOOK: 'Outlook', + OUTLOOKCOM: 'Outlook.com', + YAHOO: 'Yahoo' +}); +export default calendarOptions; diff --git a/src/constants/calendar-status.js b/src/constants/calendar-status.js new file mode 100644 index 00000000..5174f33a --- /dev/null +++ b/src/constants/calendar-status.js @@ -0,0 +1,7 @@ +const calendarStatus = Object.freeze({ + FREE: 'Free', + BUSY: 'Busy', + TENTATIVE: 'Tentative', + OUT_OF_THE_OFFICE: 'OutOfTheOffice' +}); +export default calendarStatus; diff --git a/src/constants/service-type.js b/src/constants/service-type.js new file mode 100644 index 00000000..ebbec62d --- /dev/null +++ b/src/constants/service-type.js @@ -0,0 +1,6 @@ +const serviceType = Object.freeze({ + REPLACEMENT: 'replacement', + REPAIR: 'repair', + REPLACEMENT_AND_RECALIBRATION: 'replacement and recalibration' +}); +export default serviceType; diff --git a/src/helpers/add-to-calendar-helper.js b/src/helpers/add-to-calendar-helper.js new file mode 100644 index 00000000..b00f891b --- /dev/null +++ b/src/helpers/add-to-calendar-helper.js @@ -0,0 +1,75 @@ +import calendarStatus from '@/constants/calendar-status'; +import { getDateFormat } from '@/helpers/date-helper'; + +function ToCalendarFileString(str, replacementArg = '

') { + return str.replace('\r\n', replacementArg); +} + +export function getCalendarFile(calFile) { + const dateFormat = 'yyyyMMddTHHmmss'; + const calEvent = []; + calEvent.push('BEGIN:VCALENDAR'); + calEvent.push('VERSION:2.0'); + calEvent.push('BEGIN:VEVENT'); + calEvent.push(`DTSTAMP:${calFile.TimeStamp}`); + calEvent.push(`UID:${calFile.UniqueId}@safelite.com`); + calEvent.push('PRODID:noreply@safelite.com'); + switch (calFile.Status) { + case calendarStatus.BUSY: + calEvent.push('X-MICROSOFT-CDO-BUSYSTATUS:BUSY'); + break; + case calendarStatus.FREE: + calEvent.push('TRANSP:TRANSPARENT'); + break; + case calendarStatus.TENTATIVE: + calEvent.push('STATUS:TENTATIVE'); + break; + case calendarStatus.OUT_OF_THE_OFFICE: + calEvent.push('X-MICROSOFT-CDO-BUSYSTATUS:OOF'); + break; + default: + // throw new Exception("Invalid CalendarStatus"); + } + if (calFile.AllDayEvent) { + calEvent.push(`DTSTART;VALUE=DATE:${getDateFormat(calFile.StartDate, dateFormat)}`); + calEvent.push(`DTEND;;VALUE=DATE:${getDateFormat(calFile.EndDate, dateFormat)}`); + } else { + calEvent.push(`DTSTART:${getDateFormat(calFile.StartDate, dateFormat)}`); + calEvent.push(`DTEND:${getDateFormat(calFile.EndDate, dateFormat)}`); + } + + calEvent.push(`SUMMARY:${ToCalendarFileString(calFile.Subject, ' ')}`); + if (calFile.Location != null && calFile.Location !== '') { + calEvent.push(`LOCATION:${ToCalendarFileString(calFile.Location, ' ')}`); + } + if (calFile.Body != null && calFile.Body !== '') { + if (calFile.IsHTML) { + calEvent.push(`DESCRIPTION:${ToCalendarFileString(calFile.Body)}`); + calEvent.push('X-ALT-DESC;FMTTYPE=text/html:' + + '' + + '' + + '

' + + `${ + ToCalendarFileString(calFile.Body) + }`); + } else { + calEvent.push(`DESCRIPTION:${ToCalendarFileString(calFile.Body)}`); + } + } + calEvent.push('END:VEVENT'); + calEvent.push('END:VCALENDAR'); + return calEvent.join('\r\n'); +} + +export function download(filename, fileBody) { + const element = document.createElement('a'); + element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(fileBody)}`); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} diff --git a/src/helpers/date-helper.js b/src/helpers/date-helper.js index be8161e8..70f598a6 100644 --- a/src/helpers/date-helper.js +++ b/src/helpers/date-helper.js @@ -104,3 +104,72 @@ export function get12HourTimeFormat(time) { } return timeString.join(''); // return adjusted time or original string } + +export function getDateFormat(date, format) { + if (date instanceof Date !== true) return; + const year = date.getFullYear(); + const month = (`0${date.getMonth() + 1}`).slice(-2); + const day = (`0${date.getDate()}`).slice(-2); + const hour = (`0${date.getHours()}`).slice(-2); + const minute = (`0${date.getMinutes()}`).slice(-2); + const second = (`0${date.getSeconds()}`).slice(-2); + // eslint-disable-next-line consistent-return + return format + .replace('yyyy', year) + .replace('MM', month) + .replace('dd', day) + .replace('hh', hour) + .replace('HH', hour) + .replace('mm', minute) + .replace('ss', second); +} + +export function padTo2Digits(time) { + // Use the built-in method toString() with a radix of 10 to convert the time value to a decimal string + // eslint-disable-next-line no-param-reassign + time = time.toString(10); + // Use the conditional operator to check if the length of the string is less than 2 + return time.length < 2 + // If yes, prepend a '0' to the string and return it + // If no, return the original string + ? `0${time}` : time; +} + +export function convertMsToTime(milliseconds) { + let seconds = Math.floor(milliseconds / 1000); + let minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + seconds %= 60; + minutes %= 60; + // commenting to get 24 time format + // hours = hours % 24; + + return `${padTo2Digits(hours)}${padTo2Digits(minutes)}`; +} + +export function calculateDuration(startDate, endDate) { + if (startDate instanceof Date !== true) return; + if (endDate instanceof Date !== true) return; + // eslint-disable-next-line consistent-return + return convertMsToTime(endDate - startDate); +} + +export function combineDateAndTime(date, time) { + // Use the Date.parse() method to convert the date and time strings to a numeric value + const timestamp = Date.parse(`${date}T${time}`); + // Use the new Date() constructor to create a new date object from the numeric value + const newDate = new Date(timestamp); + // Return the new date object + return newDate; +} +export function addMinutes(date, minutes) { + return new Date(date.getTime() + minutes * 60000); +} +export function shortTimeString(date) { + // Use a ternary operator to check if the input is a valid date object + return date instanceof Date + // Use the built-in method toLocaleTimeString() to get the short time string in the current locale + // Return undefined if the input is not a valid date object + ? date.toLocaleTimeString('en-us', { hour: 'numeric', minute: 'numeric', hour12: true }) : undefined; +} diff --git a/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.spec.js b/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.spec.js new file mode 100644 index 00000000..7efff67b --- /dev/null +++ b/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.spec.js @@ -0,0 +1,187 @@ +// Components +import calendarOptions from '@/constants/calendar-options'; + +// Supporting Files +import { shallowMount } from '@vue/test-utils'; +import { getMountOptions } from '@/helpers/unit-test-helper.js'; +import { useMainStore } from '@/store'; +import addToCalendar from '@/layouts/order-confirmation/add-to-calendar/add-to-calendar.vue'; + +const appointmentText = 'Appointment content'; +function setupMocks({ customMountOptions }) { + const mountOptions = getMountOptions({ + ...customMountOptions + }); + mountOptions.mixins = [ + { + methods: { + getCmsContent: jest.fn().mockImplementation(() => appointmentText) + } + } + ]; + const wrapper = shallowMount(addToCalendar, mountOptions); + wrapper.vm.$refs.calendarModalQuestion.openModal = jest.fn(); + return { wrapper }; +} + +beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + useMainStore().submittedOrder = { + schedule: { + date: '2019-01-01', + startTime: '09:00', + endTime: '10:00', + routeCode: '000' + }, + lineItems: { + glassParts: [ + { + partNumber: 'ABC123' + } + ], + supportingItems: [] + }, + serviceLocation: { + address: '', + address2: null, + city: '', + state: 'AZ', + appointmentType: 'Inshop', + zipCode: '12345', + zipCodeCtu: '01234', + provider: { + providerNumber: '123', + address: { + streetAddress: 'test1', + city: 'test', + state: 'AZ', + zipCode: '12345', + zipCodeCtu: '01234' + } + } + }, + damage: { + isRepair: false + }, + referralNumber: '1234567', + payment: { + isInsurance: true + } + }; +}); +afterEach(() => { + useMainStore().submittedOrder = {}; + jest.restoreAllMocks(); + jest.clearAllMocks(); +}); + +describe('Add-to-calendar methods...', () => { + test('Add-to-calendar should trigger openModal method', () => { + // Arrange + + const { wrapper } = setupMocks({ + customMountOptions: { + propsData: { + appointment: { + RefrrlSeqNum: '123456', + StartDate: new Date(2023, 9, 11, 12, 0, 0), + EndDate: new Date(2023, 9, 11, 13, 0, 0), + Subject: 'Meeting with John', + Location: 'Conference Room', + Body: 'Discuss the project progress', + IsHTML: true, + Duration: '1220' + } + } + } + }); + + // Act + wrapper.vm.openCalendarModal(); + + // Assert + expect(wrapper.vm.$refs.calendarModalQuestion.openModal).toBeCalled(); + }); + + test('getCalendarData should return expected model value.', () => { + // Arrange + const type = calendarOptions.OUTLOOKCOM; + const { wrapper } = setupMocks({ + customMountOptions: { + propsData: { + appointment: { + RefrrlSeqNum: '123456', + StartDate: new Date(2023, 9, 11, 12, 0, 0), + EndDate: new Date(2023, 9, 11, 13, 0, 0), + Subject: 'Meeting with John', + Location: 'Conference Room', + Body: 'Discuss the project progress', + IsHTML: true, + Duration: '1220' + } + } + } + }); + + // Act + const testValue = wrapper.vm.getCalendarData(type); + + // Assert + expect(testValue.name).toEqual(calendarOptions.OUTLOOKCOM); + }); + + test('setCalendarAppointment should call getAppointment method for iCAL and Outlook.', () => { + // Arrange + const newValue = { name: calendarOptions.OUTLOOK }; + const { wrapper } = setupMocks({ + customMountOptions: { + propsData: { + appointment: { + RefrrlSeqNum: '123456', + StartDate: new Date(2023, 9, 11, 12, 0, 0), + EndDate: new Date(2023, 9, 11, 13, 0, 0), + Subject: 'Meeting with John', + Location: 'Conference Room', + Body: 'Discuss the project progress', + IsHTML: true, + Duration: '1220' + } + } + } + }); + wrapper.vm.getAppointment = jest.fn(); + // Act + wrapper.vm.setCalendarAppointment(newValue); + + // Assert + expect(wrapper.vm.getAppointment).toHaveBeenCalled(); + }); + test('setCalendarAppointment should call window.open for Google, yahoo and Outlook.com.', () => { + // Arrange + const newValue = { name: calendarOptions.GOOGLE }; + const { wrapper } = setupMocks({ + customMountOptions: { + propsData: { + appointment: { + RefrrlSeqNum: '123456', + StartDate: new Date(2023, 9, 11, 12, 0, 0), + EndDate: new Date(2023, 9, 11, 13, 0, 0), + Subject: 'Meeting with John', + Location: 'Conference Room', + Body: 'Discuss the project progress', + IsHTML: true, + Duration: '1220' + } + } + } + }); + window.open = jest.fn(); + + // Act + wrapper.vm.setCalendarAppointment(newValue); + + // Assert + expect(window.open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.vue b/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.vue new file mode 100644 index 00000000..ebd7eb47 --- /dev/null +++ b/src/layouts/order-confirmation/add-to-calendar/add-to-calendar.vue @@ -0,0 +1,325 @@ + + + diff --git a/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-list-button/calendar-modal-list-button.vue b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-list-button/calendar-modal-list-button.vue new file mode 100644 index 00000000..814d10fe --- /dev/null +++ b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-list-button/calendar-modal-list-button.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.spec.js b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.spec.js new file mode 100644 index 00000000..51b43508 --- /dev/null +++ b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import calendarModalQuestion from '@/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.vue'; + +describe('calendar-modal-question.vue', () => { + test('When selected calendar option is changed, a correctly selectedCalendarOption should be emitted', async () => { + // Arrange + const url = 'iCal url'; + const name = 'iCal'; + const icon = 'iCal.svg'; + const expectedEmit = [ + [ + { + url, + name, + icon + } + ] + ]; + + const wrapper = shallowMount(calendarModalQuestion, { + propsData: { + calendarOptionsData: [ + { + url, + name, + icon + } + ] + } + }); + + wrapper.vm.$refs.calendarOptions.closeModal = jest.fn(); + wrapper.vm.selectedCalendarOption = name; + + // Act + wrapper.vm.setSelectedCalendarOption(); + + // Assert + expect(wrapper.emitted()['update:modelValue']).toEqual(expectedEmit); + }); +}); diff --git a/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.vue b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.vue new file mode 100644 index 00000000..702abf1b --- /dev/null +++ b/src/layouts/order-confirmation/add-to-calendar/calendar-modal-question/calendar-modal-question.vue @@ -0,0 +1,180 @@ + + + diff --git a/src/layouts/order-confirmation/order-confirmation.spec.js b/src/layouts/order-confirmation/order-confirmation.spec.js index 99ba8fa7..47e0f137 100644 --- a/src/layouts/order-confirmation/order-confirmation.spec.js +++ b/src/layouts/order-confirmation/order-confirmation.spec.js @@ -39,6 +39,10 @@ const vehicleBannerStub = { render: () => {} }; +const addToCalendarStub = { + render: () => {} +}; + const initialStore = { order: { schedule: { @@ -127,7 +131,8 @@ function getMountedComponent(mainInitialState = {}, initialData = {}, methodToRu mountOptions.global.stubs = { siteFooter: footerStub, siteHeader: headerStub, - vehicleBanner: vehicleBannerStub + vehicleBanner: vehicleBannerStub, + addToCalendar: addToCalendarStub }; const testingPinia = createTestingPinia({ diff --git a/src/layouts/order-confirmation/order-confirmation.vue b/src/layouts/order-confirmation/order-confirmation.vue index fa7a4c14..7ee31d95 100644 --- a/src/layouts/order-confirmation/order-confirmation.vue +++ b/src/layouts/order-confirmation/order-confirmation.vue @@ -25,6 +25,19 @@

{{ appointmentDateFormatted }}

{{ appointmentTimeFormatted }}

+
@@ -52,6 +65,7 @@ import siteHeader from '@/iss-components/site-header/site-header.vue'; import vehicleBanner from '@/iss-components/vehicle-banner/vehicle-banner.vue'; import siteFooter from '@/iss-components/site-footer/site-footer.vue'; +import addToCalendar from '@/layouts/order-confirmation/add-to-calendar/add-to-calendar.vue'; // Supporting files import { fetchCmsContentForPage, processIfStatements } from '@/helpers/cms-content-helper'; import settleAllPromises from '@/helpers/layout-helper'; @@ -72,7 +86,8 @@ export default { Form, siteHeader, vehicleBanner, - siteFooter + siteFooter, + addToCalendar }, mixins: [BaseFormMixin], async beforeRouteEnter(to, from, next) { @@ -180,7 +195,7 @@ export default { }, providerFullAddress() { // eslint-disable-next-line max-len - return `${this.providerAddress},
${this.providerCity}, ${this.providerState} ${this.providerZipCode}`; + return this.submittedOrder.serviceLocation?.provider?.address ? `${this.providerAddress},
${this.providerCity}, ${this.providerState} ${this.providerZipCode}` : ''; }, appointmentWordingText() { switch (this.appointmentType) { @@ -297,15 +312,15 @@ $page-side-padding: 1.5rem; .appointment-details { margin-bottom: 1.5rem; padding: 1rem 1.5rem 1.5rem; - box-shadow: 0 3px 10px rgb(0 0 0 / 0.2); - border-radius: 5px; + box-shadow: 0 0.188rem 0.625rem rgb(0 0 0 / 0.2); + border-radius: 0.313rem; } .appointment-date-time P { color: $black; font-size: $h5-font-size; line-height: map-get($spacers, 6); - margin-bottom: 0.5rem; + margin-bottom: 0; + p { font-size: map-get($spacers, 4);