diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 000000000..30e99a02e --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] + } \ No newline at end of file diff --git a/playwright-tests/.env b/playwright-tests/.env index 2b8d27d3e..179424e5e 100644 --- a/playwright-tests/.env +++ b/playwright-tests/.env @@ -10,6 +10,7 @@ SKIP_CONTENT_SITE="false" # Experiments Flag IS_MOBILEFIRST="false" IS_ADYENPAYMENTS="false" +IS_MULTILOCATIONPOPUP="false" # Base URLs by environment # qa diff --git a/playwright-tests/.env.dev b/playwright-tests/.env.dev index 8d2786918..de2c3c0f2 100644 --- a/playwright-tests/.env.dev +++ b/playwright-tests/.env.dev @@ -10,6 +10,7 @@ SKIP_CONTENT_SITE="false" # Experiments Flag IS_MOBILEFIRST="false" IS_ADYENPAYMENTS="false" +IS_MULTILOCATIONPOPUP="false" # Base URLs by environment # qa diff --git a/playwright-tests/framework/TestData.ts b/playwright-tests/framework/TestData.ts index f863a74fd..6a87dfd8f 100644 --- a/playwright-tests/framework/TestData.ts +++ b/playwright-tests/framework/TestData.ts @@ -18,5 +18,6 @@ export function getDefaultExperimentsData(): IExperiments { return { isAdyenPayments: !!process.env.IS_ADYENPAYMENTS && process.env.IS_ADYENPAYMENTS !== "" ? process.env.IS_ADYENPAYMENTS === "true" : false, isMobileFirst: !!process.env.IS_MOBILEFIRST && process.env.IS_MOBILEFIRST !== "" ? process.env.IS_MOBILEFIRST === "true" : false, + isMultiLocationPopup: !!process.env.IS_MULTILOCATIONPOPUP && process.env.IS_MULTILOCATIONPOPUP !== "" ? process.env.IS_MULTILOCATIONPOPUP === "true" : false, } } diff --git a/playwright-tests/framework/localTypes/IExperiments.ts b/playwright-tests/framework/localTypes/IExperiments.ts index 78935f480..bfe13c864 100644 --- a/playwright-tests/framework/localTypes/IExperiments.ts +++ b/playwright-tests/framework/localTypes/IExperiments.ts @@ -1,4 +1,5 @@ export interface IExperiments { isMobileFirst: boolean, - isAdyenPayments: boolean + isAdyenPayments: boolean, + isMultiLocationPopup: boolean } \ No newline at end of file diff --git a/playwright-tests/pages/BasePage.ts b/playwright-tests/pages/BasePage.ts index 07d254391..e9fe835dc 100644 --- a/playwright-tests/pages/BasePage.ts +++ b/playwright-tests/pages/BasePage.ts @@ -257,8 +257,12 @@ export class BasePage { : "MobileFirstAppointment=MobileFirstAppt_V1=MobileFirstAppt_CONTROL=true"; experimentsURLExtension += experiments?.isAdyenPayments - ? ",Adyen%20Payments=Adyen%20Payment%20Test=Adyen%20Payment%20(Test)" - : ",Adyen%20Payments=Adyen%20Payment%20Test=CyberSource%20(Control)"; + ? ",Adyen%20Payments=Adyen%20Payment%20Test=Adyen%20Payment%20(Test)=true" + : ",Adyen%20Payments=Adyen%20Payment%20Test=CyberSource%20(Control)=true"; + + experimentsURLExtension += experiments?.isMultiLocationPopup + ? ",MultiLocationPopup=MultiLocationPopup_V1=MultiLocationPopup_TEST=true" + : ",MultiLocationPopup=MultiLocationPopup_V1=MultiLocationPopup_CONTROL=true"; } else { console.log("Url extension without query string"); } diff --git a/playwright-tests/pages/PaymentAdyenPage.ts b/playwright-tests/pages/PaymentAdyenPage.ts new file mode 100644 index 000000000..82c876af0 --- /dev/null +++ b/playwright-tests/pages/PaymentAdyenPage.ts @@ -0,0 +1,81 @@ +import { type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IPaymentDetails } from 'safelite-playwright-core'; +import { ITestData } from 'framework/TestData'; +import { PaypalPage } from './PaypalPage'; + +export class PaymentAdyenPage extends BasePage { + readonly page: Page; + readonly creditOrDebitCardButton: Locator; + readonly nameOnCardTextField: Locator; + readonly cardNumberTextField: Locator; + readonly expiryDateTextField: Locator; + readonly cvvTextField: Locator; + readonly countryDropDown: Locator; + readonly billingAddressTextField: Locator; + readonly cityTextField: Locator; + readonly stateDropDown: Locator; + readonly billingZipTextField: Locator; + readonly submitPaymentButton: Locator; + readonly payPalButton: Locator; + readonly navigateToPaypalButton: Locator; + readonly afterPayButton: Locator; + readonly navigateToAfterPayButton: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.creditOrDebitCardButton = page.locator('.adyen-checkout__payment-method--credit button'); + this.cardNumberTextField = page.frameLocator('iframe[title="Iframe for card number"]').locator('input[id*="adyen-checkout-encryptedCardNumber"]'); + this.expiryDateTextField = page.frameLocator('iframe[title="Iframe for expiry date"]').locator('input[id*="adyen-checkout-encryptedExpiryDate"]'); + this.cvvTextField = page.frameLocator('iframe[title="Iframe for security code"]').locator('input[id*="adyen-checkout-encryptedSecurityCode"]'); + this.nameOnCardTextField = page .locator('input[id*="adyen-checkout-holderName"]'); + this.countryDropDown = page.locator('input[id*="adyen-checkout-country"]'); + this.billingAddressTextField = page.locator('input[id*="adyen-checkout-street"]'); + this.cityTextField = page.locator('input[id*="adyen-checkout-city"]'); + this.stateDropDown = page.locator('input[id*="adyen-checkout-stateOrProvince"]'); + this.billingZipTextField = page.locator('input[id*="adyen-checkout-postalCode"]'); + this.submitPaymentButton = page.locator('button.adyen-checkout__button'); + this.payPalButton = page.locator('button[id*="button-paypal"]'); + this.navigateToPaypalButton = page.frameLocator('iframe[title="PayPal-paypal"]:first-of-type').locator('div[role="link"][class*="paypal-button"]'); + this.afterPayButton = page.locator('button[id*="button-redirect"]'); + this.navigateToAfterPayButton = page.locator('div.adyen-checkout__payment-method--afterpaytouch_US button.adyen-checkout__button'); + + } + + async populateAdyenCreditCardDetails(testData: Partial){ + const { paymentDetails, customerDetails } = testData; + + paymentDetails!.cardNumber = '5100 0600 0000 0002'; + paymentDetails!.expirationMonth = "12 - December"; + paymentDetails!.expirationYear = "2029"; + paymentDetails!.cvv = "737"; + + await this.creditOrDebitCardButton.click(); + await this.cardNumberTextField.fill(paymentDetails!.cardNumber || ''); + + const expirationDate = `${(paymentDetails!.expirationMonth!.split(' ')[0] || '').padStart(2, '0')} + ${paymentDetails!.expirationYear!.toString().slice(-2)}`; + await this.expiryDateTextField.pressSequentially(expirationDate); + + await this.cvvTextField.fill(paymentDetails!.cvv!); + await this.nameOnCardTextField.fill(customerDetails!.firstName! + ' ' + customerDetails!.lastName!); + await this.billingAddressTextField.fill(paymentDetails!.billingAddress!.street); + await this.countryDropDown.pressSequentially("United States"); + await this.countryDropDown.click(); + await this.page.getByRole('option', { name: 'United States', exact: true }).click(); + + await this.cityTextField.fill(paymentDetails!.billingAddress!.city); + await this.stateDropDown.pressSequentially(paymentDetails!.billingAddress!.state); + await this.stateDropDown.click(); + await this.page.locator(".adyen-checkout__field--stateOrProvince li").nth(0).click(); + await this.billingZipTextField.fill(paymentDetails!.billingAddress!.postalCode); + await this.submitPaymentButton.click(); + } + + async navigateToAdyenPaypalCheckout(): Promise { + await this.payPalButton.click(); + const paypalPage = this.page.waitForEvent('popup'); + await this.navigateToPaypalButton.click(); + return new PaypalPage(await paypalPage); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/PaymentMethodPage.ts b/playwright-tests/pages/PaymentMethodPage.ts index 248ce845f..215ccff30 100644 --- a/playwright-tests/pages/PaymentMethodPage.ts +++ b/playwright-tests/pages/PaymentMethodPage.ts @@ -8,6 +8,8 @@ import { AfterpayPage } from './AfterpayPage'; import { PaypalPage } from './PaypalPage'; import { ITestData } from 'framework/TestData'; import { step } from 'framework/localTypes/Step'; +import { IExperiments } from 'framework/localTypes/IExperiments'; +import { PaymentAdyenPage } from './PaymentAdyenPage'; export class PaymentMethodPage extends BasePage { readonly page: Page; @@ -25,7 +27,9 @@ export class PaymentMethodPage extends BasePage { readonly submitButton: Locator; readonly recalibrationCheckbox: Locator; readonly paymentPage: PaymentPage; + readonly paymentAdyenPage: PaymentAdyenPage; readonly paypalPage: PaypalPage; + readonly afterpayPage: AfterpayPage readonly afterPayBreakoutSection: Locator; readonly afterPayToggle: Locator; @@ -59,7 +63,9 @@ export class PaymentMethodPage extends BasePage { this.recalibrationCheckbox = this.page.locator('label:has(>input[name=\'recalAckOptIn\'])'); // this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1); this.paymentPage = new PaymentPage(page); + this.paymentAdyenPage = new PaymentAdyenPage(page); this.paypalPage = new PaypalPage(page); + this.afterpayPage = new AfterpayPage(page); // Payment details validation locators this.reviewTable = this.page.locator('div.review-table'); @@ -191,35 +197,57 @@ export class PaymentMethodPage extends BasePage { return text; } - async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) { + async executePayment(testData: Partial) { const browserContext = this.page.context(); + const { paymentDetails, isRecalVehicle, experiments } = testData; - switch (paymentDetails.paymentType) { + switch (paymentDetails!.paymentType) { case PaymentType.Credit: await this.selectCreditCard(); await this.nextPage(); - await this.paymentPage.populateCreditCardDetails(paymentDetails); + experiments!.isAdyenPayments + ? await this.paymentAdyenPage.populateAdyenCreditCardDetails(testData) + : await this.paymentPage.populateCreditCardDetails(paymentDetails!); break; case PaymentType.AfterPay: await this.payInFourButton.click(); await this.continueButton.click(); - // Capture popup - const afterpayPopup = await browserContext.waitForEvent('page'); - const afterpayPage = new AfterpayPage(afterpayPopup); + if (experiments?.isAdyenPayments) { + await this.paymentAdyenPage.afterPayButton.click(); + await this.paymentAdyenPage.navigateToAfterPayButton.click(); + await this.afterpayPage.executeAfterpayPayment(paymentDetails!); + } else { + // Capture popup + const afterpayPopup = await browserContext.waitForEvent('page'); + const afterpayPage = new AfterpayPage(afterpayPopup); + + // Execute payment + await afterpayPage.executeAfterpayPayment(paymentDetails!); + } - // Execute payment - await afterpayPage.executeAfterpayPayment(paymentDetails); break; case PaymentType.Paypal: await this.selectPaypal(); // TODO: Click paypal button await this.nextPage(); - await this.paymentPage.navigateToPaypalCheckout(); - await this.paypalPage.completePaypalPurchase(paymentDetails); + const paypalPage = experiments?.isAdyenPayments + ? await this.paymentAdyenPage.navigateToAdyenPaypalCheckout() + : await this.paymentPage.navigateToPaypalCheckout(); + + /*if (experiments?.isAdyenPayments) { + // Capture popup + const payPalPopup = await this.page.waitForEvent('popup'); + const paypalPage = new PaypalPage(payPalPopup); + paypalPage.completePaypalPurchase(testData); + } else { + await this.paypalPage.completePaypalPurchase(testData); + }*/ + await paypalPage.completePaypalPurchase(testData); + break; case PaymentType.PayAtService: - await this.selectPayAtService(isRecalVehicle); + await this.selectPayAtService(isRecalVehicle!); await this.nextPage(); break; case PaymentType.PayWithInsurance: @@ -527,7 +555,7 @@ export class PaymentMethodPage extends BasePage { @step("PaymentMethodPage >> Select Payment Method: ") async handlePaymentMethodPage(testData: Partial) { - const { servicePackage, isRecalVehicle, paymentDetails, isForcedOEM } = testData; + const { servicePackage, isRecalVehicle, paymentDetails, isForcedOEM, experiments } = testData; await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage); await this.validatePaymentDetailsPage(testData); @@ -542,7 +570,8 @@ export class PaymentMethodPage extends BasePage { await this.verifyVAPS(); } if (paymentDetails?.paymentType) { - await this.executePayment(paymentDetails!, isRecalVehicle!); + // await this.executePayment(paymentDetails!, isRecalVehicle!, experiments!); + await this.executePayment(testData); } else { await this.nextPage(); } diff --git a/playwright-tests/pages/PaymentPage.ts b/playwright-tests/pages/PaymentPage.ts index 9bc95fb66..ca51450df 100644 --- a/playwright-tests/pages/PaymentPage.ts +++ b/playwright-tests/pages/PaymentPage.ts @@ -1,6 +1,7 @@ import { type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; import { IPaymentDetails } from 'safelite-playwright-core'; +import { PaypalPage } from './PaypalPage'; export class PaymentPage extends BasePage { readonly page: Page; @@ -43,8 +44,8 @@ export class PaymentPage extends BasePage { await this.submitPaymentButton.click(); } - async navigateToPaypalCheckout() { + async navigateToPaypalCheckout(): Promise { await this.payPalButton.click(); + return new PaypalPage(this.page); } - } \ No newline at end of file diff --git a/playwright-tests/pages/PaypalPage.ts b/playwright-tests/pages/PaypalPage.ts index d0601ffcb..650acc27a 100644 --- a/playwright-tests/pages/PaypalPage.ts +++ b/playwright-tests/pages/PaypalPage.ts @@ -1,6 +1,6 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; -import { IPaymentDetails } from 'safelite-playwright-core'; +import { ITestData } from 'framework/TestData'; export class PaypalPage extends BasePage { readonly page: Page; @@ -30,24 +30,28 @@ export class PaypalPage extends BasePage { this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' }); } - async completePaypalPurchase(paymentDetails: IPaymentDetails){ + async completePaypalPurchase(testData: Partial){ + + const {paymentDetails, experiments } = testData; // Wait for the PayPal login page to load await this.page.waitForFunction(() => window.location.href.includes('sandbox.paypal.com'), null, { timeout: 10000 }); // handle flow with and without the "Log in with a password instead" link if (await this.loginWithPasswordButton.isVisible()) { await this.loginWithPasswordButton.click(); - await this.passwordTextBox.fill(paymentDetails.password!); + await this.passwordTextBox.fill(paymentDetails!.password!); await this.paypalLoginButton.click(); await this.completePurchaseButton.click(); } else { await this.usernameTextBox.waitFor({ state: 'visible' }); await this.page.screenshot({ path: `test-results\\ortoni-data\\paypal-username-${Date.now()}.png`, fullPage: true }); - await this.usernameTextBox.fill(paymentDetails.username!); + await this.usernameTextBox.fill(paymentDetails!.username!); await this.nextButton.click(); - await this.tryAnotherWayButton.click(); - await this.usePasswordInsteadButton.click(); - await this.passwordTextBox.fill(paymentDetails.password!); + if (!experiments!.isAdyenPayments){ + await this.tryAnotherWayButton.click(); + await this.usePasswordInsteadButton.click(); + } + await this.passwordTextBox.fill(paymentDetails!.password!); await this.paypalLoginButton.click(); await this.payWithRadioButton.click(); await this.page.waitForTimeout(2000); diff --git a/src/assets/img/icons/alert-circle-yellow.svg b/src/assets/img/icons/alert-circle-yellow.svg new file mode 100644 index 000000000..904aefb98 --- /dev/null +++ b/src/assets/img/icons/alert-circle-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/icons/info-circle-blue.svg b/src/assets/img/icons/info-circle-blue.svg new file mode 100644 index 000000000..fca619fb0 --- /dev/null +++ b/src/assets/img/icons/info-circle-blue.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/constants/analytics.js b/src/constants/analytics.js index e156de11b..51b0c24b6 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -15,6 +15,8 @@ const GaCategories = { APPOINTMENT: "Appointment", SERVICE_LOCATION: "service-location", CONFIRMATION_CLICKED: "confirmation clicked", + INSHOP_CONFIRMATION_CLICKED: "inshop confirmation clicked", + MOBILE_CONFIRMATION_CLICKED: "mobile confirmation clicked", RECAL_DISCLAIMER: "Recalibration disclaimer", }; diff --git a/src/constants/experiments.js b/src/constants/experiments.js index f81484058..f8691fc46 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -5,6 +5,7 @@ const experimentUniverses = { IGQ_SkipQuote: "NextGen_IGQSkipToInsurance", AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay", MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment", + MULTI_LOCATION_POPUP: "MultiLocationPopup", }; const experimentSettings = { @@ -40,7 +41,13 @@ const experimentSettings = { SHOW_MOBILE_FIRST_POPUP_REPLACE_WITHOUT_MSR: "Show_Mobile_First_Popup_Replace_Without_MSR", SHOW_MOBILE_FIRST_POPUP_REPLACE_WITH_MSR: "Show_Mobile_First_Popup_Replace_With_MSR", OFFER_OEM: "OfferOem", + SHOW_MULTI_LOCATION_APPT: "ShowMultiLocationAppt", + SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION: "Show_NoAvailableDaysMultiLocation", + SHOW_NO_PM_DAYS_MULTI_LOCATION: "Show_NoPMDaysMultiLocation", + SHOW_PM_DAYS_MULTI_LOCATION: "Show_PMDaysMultiLocation", USE_ADYEN_PAYMENT: "UseAdyenPayment", + SHOW_UNVERIFIED_DEDUCT_ENTRY: "ShowUnverifiedDeducEntry", + SERVICE_PACKAGE_ORDER: "ServicePackageOrder", }; const experimentTriggers = { diff --git a/src/digital-components/button-question/button-question.vue b/src/digital-components/button-question/button-question.vue index 9862633ad..417c88809 100644 --- a/src/digital-components/button-question/button-question.vue +++ b/src/digital-components/button-question/button-question.vue @@ -226,6 +226,7 @@ export default { let classes; switch (this.buttonTypeString) { case "timeSlotModalListButton": + case "multiLocationRadioButton": case "listButton": classes = "w-100"; break; @@ -265,6 +266,8 @@ export default { classes += " radio-button-container"; } else if (this.buttonTypeString == "servicePackageRadio") { classes = "package-wrapper col"; + } else if (this.buttonTypeString == "multiLocationRadioButton") { + classes = "multi-location-wrapper col"; } if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) { diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 59297ae05..ae6ca6229 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -153,6 +153,8 @@ import { convertDateToDateString, convertDateStringToDate, getTodayDateString, + getInitialViewWeeks, + getMonthEnd, } from "@/layouts/schedule/helpers/schedule-helper"; import { useField, ErrorMessage } from "vee-validate"; import { deepClone } from "@/helpers/object-helper"; @@ -291,140 +293,6 @@ export default { } this.$emit("date-selected", date); }, - getWeekStartDate(dateString) { - const date = convertDateStringToDate(dateString); - const dayOfWeek = date.getDay(); - // Subtract the day of the week from date to get the date of Sunday - const sunday = new Date(date); - sunday.setDate(sunday.getDate() - dayOfWeek); - return convertDateToDateString(sunday); - }, - getWeekEndDate(dateString) { - const date = convertDateStringToDate(dateString); - const dayOfWeek = date.getDay(); - const daysUntilSaturday = 6 - dayOfWeek; // Calculate the number of days until Saturday - // Clone the given date and add the remaining days until Saturday - const saturday = new Date(date); - saturday.setDate(date.getDate() + daysUntilSaturday); - return convertDateToDateString(saturday); - }, - getNextWeekSunday(dateString) { - const date = convertDateStringToDate(dateString); - const dayOfWeek = date.getDay(); - const daysUntilNextSunday = 7 - dayOfWeek; // Calculate the number of days until the next Sunday - // Clone the given date and add the remaining days until Sunday - const nextSunday = new Date(date); - nextSunday.setDate(date.getDate() + daysUntilNextSunday); - return convertDateToDateString(nextSunday); - }, - getMonthEnd(dateStr) { - // convert string to date, do date calc, then return a string back - const date = new Date(dateStr.split("-")[0], parseInt(dateStr.split("-")[1]), 0); - return convertDateToDateString(date); - }, - getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDate) { - // TODO: this only is for future direction; need to create logic for past direction - const weeks = []; - let weekStartDate = this.getWeekStartDate(todayString); - let weekEndDate = this.getWeekEndDate(todayString); - - if (preSelectedDate) { - let preSelectedDateMonthEnd = this.getMonthEnd( - preSelectedDate.replace("-mobile", "") - ); - let monthEndsByThisWeek = false; - let i = 0; - while (!monthEndsByThisWeek && i < 52) { - if (i > 0) { - weekStartDate = this.getNextWeekSunday(weekEndDate); - weekEndDate = this.getWeekEndDate(weekStartDate); - - if ( - (preSelectedDateMonthEnd > weekStartDate && - preSelectedDateMonthEnd < weekEndDate) || - preSelectedDateMonthEnd === weekStartDate || - preSelectedDateMonthEnd === weekEndDate - ) { - weekEndDate = preSelectedDateMonthEnd; - monthEndsByThisWeek = true; - } else if (preSelectedDateMonthEnd < weekEndDate) { - // Additional case: if month ends before this week (but not necessarily during it), - // still cut the loop here, but don't truncate the week. - - monthEndsByThisWeek = true; - } - } - weeks.push({ - weekNum: i + 1, - weekStartDate: weekStartDate, - weekEndDate: weekEndDate, - }); - i++; - } - } else { - for (let i = 0; i < initialViewRowsToShow; i++) { - if (i > 0) { - weekStartDate = this.getNextWeekSunday(weekEndDate); - weekEndDate = this.getWeekEndDate(weekStartDate); - } - weeks.push({ - weekNum: i + 1, - weekStartDate: weekStartDate, - weekEndDate: weekEndDate, - }); - } - // If any of these weeks is split between two months, then make them 2 separate "weeks" - // (a week split between two months is considered 2 weeks per business requirements) - const hasSplitWeek = (week) => { - return week.weekStartDate.split("-")[1] !== week.weekEndDate.split("-")[1] - ? true - : false; - }; - const splitWeekIndex = weeks.findIndex(hasSplitWeek); - - if (splitWeekIndex > -1) { - const week1 = []; - const week2 = []; - let switchToWeek2 = false; - - for (let j = 0; j < 7; j++) { - const newDate = convertDateStringToDate( - weeks[splitWeekIndex].weekStartDate - ); - newDate.setDate(newDate.getDate() + j); - if (newDate.getDate() === 1) switchToWeek2 = true; - if (switchToWeek2) { - week2.push(convertDateToDateString(newDate)); - } else { - week1.push(convertDateToDateString(newDate)); - } - } - - const week1EndDate = week1[week1.length - 1]; - const week2StartDate = week2[0]; - - if (week1EndDate < todayString) { - // replace week 1 with week 2 - weeks[splitWeekIndex].weekStartDate = week2StartDate; - } else { - const newWeek = { - weekNum: weeks[splitWeekIndex].weekNum, - weekStartDate: week2StartDate, - weekEndDate: weeks[splitWeekIndex].weekEndDate, - }; - weeks[splitWeekIndex].weekEndDate = week1EndDate; - weeks.splice(splitWeekIndex + 1, 0, newWeek); - weeks.pop(); - weeks.forEach((item, index) => { - if (index > splitWeekIndex) { - item.weekNum = item.weekNum + 1; - } - }); - } - } - } - return weeks; - }, async loadInitialData(config) { /* ** NOTE: this _could_ be called by a parent before fully loaded, so FYI component data or computeds might not be available @@ -444,10 +312,10 @@ export default { if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; if (config.selectableDatesSetting === "custom") calendarViewDirection = "future"; - const currentMonthEnd = this.getMonthEnd(todayDateString); + const currentMonthEnd = getMonthEnd(todayDateString); // TODO - set up currentMonthStart if direction is PAST: // let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1); - const initialViewWeeks = this.getInitialViewWeeks( + const initialViewWeeks = getInitialViewWeeks( todayDateString, config.initialViewRowsToShow, config.preSelectedDate diff --git a/src/experiment-components/donation-block.vue b/src/experiment-components/donation-block.vue index f93c96520..e48e66860 100644 --- a/src/experiment-components/donation-block.vue +++ b/src/experiment-components/donation-block.vue @@ -31,23 +31,15 @@
- - - - - + + + + +
@@ -264,17 +256,17 @@ export default { background: $blue; } input { - &#one:checked { + &#zero:checked { ~ .indicator-bar { transform: translateX(2rem); } } - &#three:checked { + &#one:checked { ~ .indicator-bar { transform: translateX(calc(100% + 2rem)); } } - &#five:checked { + &#two:checked { ~ .indicator-bar { transform: translateX(calc(200% + 2rem)); } diff --git a/src/fmg-components/promo-modal-question/promo-modal-question.vue b/src/fmg-components/promo-modal-question/promo-modal-question.vue index 1e87d5bb5..bb3b390a8 100644 --- a/src/fmg-components/promo-modal-question/promo-modal-question.vue +++ b/src/fmg-components/promo-modal-question/promo-modal-question.vue @@ -317,6 +317,10 @@ export default { serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, pricedLineItems: pricedLineItemsToTax, + serviceLocationAddressLine1: + store.getters.order.serviceLocation?.address, + serviceLocationAddressLine2: + store.getters.order.serviceLocation?.address2, }, "payment-method", false diff --git a/src/helpers/cms-content-helper.js b/src/helpers/cms-content-helper.js index 727add296..450875d0f 100644 --- a/src/helpers/cms-content-helper.js +++ b/src/helpers/cms-content-helper.js @@ -343,3 +343,14 @@ export function splitCMSCopyOnParagraphTag(copy) { export function splitCMSCopyOnBR(copy) { return copy.split("
"); } + +// This function takes in a number and returns it formatted as USD currency, with or without cents depending on if the number is an integer or not. +export function formatToUSDollar(amount) { + const isInteger = amount % 1 === 0; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: isInteger ? 0 : 2, + maximumFractionDigits: 2, + }).format(amount); +} diff --git a/src/helpers/heritage-integration/order-helper.js b/src/helpers/heritage-integration/order-helper.js index 1c0922ba0..a3dc363f5 100644 --- a/src/helpers/heritage-integration/order-helper.js +++ b/src/helpers/heritage-integration/order-helper.js @@ -20,7 +20,6 @@ export async function loadSessionIfPresent(isConceptInsurance, pageNameToLog) { // Do nothing if there is no cookie or session to use for loading. if ( funnelCookie == null || - funnelCookie.SavedSessionId == null || funnelCookie.ReferralCorrelationId == null || !funnelCookie.ReferralNumber ) { @@ -128,7 +127,7 @@ async function loadSession( const response = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.LOAD_SESSION, { - savedSessionId: savedSessionId?.toString(), + savedSessionId: savedSessionId != null ? savedSessionId.toString() : null, referralNumber, referralDate, referralCorrelationId, diff --git a/src/helpers/page-prerequisites-helper.js b/src/helpers/page-prerequisites-helper.js new file mode 100644 index 000000000..9a7a4fcbc --- /dev/null +++ b/src/helpers/page-prerequisites-helper.js @@ -0,0 +1,281 @@ +import { debugLog } from "@/helpers/debug-log-helper.js"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; +import { coverageStatus } from "@/constants/insurance"; +import { applicationConfig } from "@/constants/application-config"; + +/** + * Logs the start of the page prerequisites check. + * Should be used on pages before the page prerequisites check is executed. + * @param {string} source - The source of the page prerequisites check. + * @param {boolean} preReqResult - The result of the page prerequisites check. + */ +export function logPagePrereqsStart(source, preReqResult) { + // prettier-ignore + { + debugLog(`--- ${source} pagePrereqs start ---`, null, !preReqResult); + } +} + +/** + * Logs the end of the page prerequisites check. + * Should be used on pages after the page prerequisites check is executed. + * @param {string} source - The source of the page prerequisites check. + * @param {boolean} preReqResult - The result of the page prerequisites check. + */ +export function logPagePrereqsEnd(source, preReqResult) { + // prettier-ignore + { + debugLog(`--- ${source} pagePrereqs end ---`, null, !preReqResult); + } +} + +/** + * Flushes the page prerequisites logs. + * @param {string} source - The source of the page prerequisites check. This is the page name most places. + * @param {boolean} preReqResult - The result of the page prerequisites check. + * @param {Array} logQueue - The queue of log functions. + */ +export function flushPagePrereqsLogs(source, preReqResult, logQueue) { + logPagePrereqsStart(source, preReqResult); + logQueue.forEach((fn) => fn()); + logPagePrereqsEnd(source, preReqResult); +} + +/** + * Pushes a log function to the log queue. + * @param {Array} logQueue - The queue of log functions. + * @param {Function} logFn - The log function to be executed. + */ +function queueLogging(logQueue, logFn) { + if (logQueue) { + logQueue.push(logFn); + } +} + +/** + * Checks whether the service zip code information is valid for the order. + * Should be used on pages after service zip code collection is complete. + * Returns true if the zip code and zip code CTU are present on serviceLocation. + */ +export function hasServiceZipInfo(order, logQueue = null) { + const serviceLocation = order.serviceLocation; + const result = !!(serviceLocation.zipCode && serviceLocation.zipCodeCtu); + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasServiceZipInfo:", result, !result); + debugLog("serviceLocation.zipCode:", serviceLocation.zipCode, !result); + debugLog("serviceLocation.zipCodeCtu:", serviceLocation.zipCodeCtu, !result); + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the service location information is valid for the appointment. + * Returns true if the required info is present for the chosen appointment type. + * For MOBILE: address, city, state, and zipCode must be present on serviceLocation. + * For INSHOP/DROPOFF: all provider address fields must be present. + */ +export function hasServiceLocationInfo(order, logQueue = null) { + const serviceLocation = order.serviceLocation; + const mobileReqs = !!( + serviceLocation.address && + serviceLocation.city && + serviceLocation.state && + serviceLocation.zipCode + ); + + const providerLocation = serviceLocation.provider.address; + const dropOffInshopReqs = !!( + providerLocation.streetAddress && + providerLocation.city && + providerLocation.state && + providerLocation.zipCode + ); + + const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; + const result = (isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs); + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasServiceLocationInfo mobileReqs:", mobileReqs, !result); + debugLog("serviceLocation.address:", serviceLocation.address, !result); + debugLog("serviceLocation.city:", serviceLocation.city, !result); + debugLog("serviceLocation.state:", serviceLocation.state, !result); + debugLog("serviceLocation.zipCode:", serviceLocation.zipCode, !result); + debugLog("dropOffInshopReqs:", dropOffInshopReqs, !result); + debugLog("providerLocation.streetAddress:", providerLocation.streetAddress, !result); + debugLog("providerLocation.city:", providerLocation.city, !result); + debugLog("providerLocation.state:", providerLocation.state, !result); + debugLog("providerLocation.zipCode:", providerLocation.zipCode, !result); + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the insurance information is valid for the order. To be used after all insurance info collection is complete. + * Returns true if cash selected or if insurance is selected and some insurance fields are present. + */ +export function hasInsuranceInfo(order, logQueue = null) { + const isInsurance = order.payment?.isInsurance; + const insuranceCoverageStatus = order.payment?.insuranceCoverage?.coverageStatus; + const currentDeductible = order.policy?.currentDeductible; + const parentAccountNumber = order.payment?.parentAccountNumber; + const policyNumber = order.policy?.policyNumber; + + let result = false; + if (isInsurance !== null) { + if (isInsurance === false) { + result = true; // Cash selected - no need for policy number or parent account + } else { + const parentAccountNotCash = + parentAccountNumber !== applicationConfig.CASH_PARENT_ACCOUNT_NUMBER; + const hasPolicyNumber = !!policyNumber; + + if (!parentAccountNotCash || !hasPolicyNumber) { + result = false; + } else if ( + insuranceCoverageStatus === coverageStatus.VERIFIED && + !(currentDeductible === 0 || currentDeductible > 0) + ) { + result = false; // if coverageStatus is verified there must be a valid deductible also + } else { + result = true; + } + } + } + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasInsuranceInfo:", result, !result); + if (isInsurance === true) { + debugLog("hasInsuranceInfo parentAccountNumber:", parentAccountNumber, !result); + debugLog("hasInsuranceInfo policyNumber:", policyNumber, !result); + } + if (insuranceCoverageStatus === coverageStatus.VERIFIED) { + debugLog("hasInsuranceInfo coverageStatus:", insuranceCoverageStatus, !result); + debugLog("hasInsuranceInfo currentDeductible:", currentDeductible, !result); + } + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the scheduling information is valid for the order. + * Should be used on pages after scheduling info collection is complete. + * Returns true if the required info is present for the chosen appointment type. + * For MOBILE: date, startTime, endTime, jobMaxMinutes, and jobMinMinutes must be present on schedule. + * For INSHOP/DROPOFF: all provider scheduling fields must be present. + */ +export function hasSchedulingInfo(order, logQueue = null) { + const schedule = order.schedule; + const result = !!( + schedule.date && + schedule.startTime && + schedule.endTime && + schedule.jobMaxMinutes && + schedule.jobMinMinutes + ); + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasSchedulingInfo:", result, !result); + debugLog("schedule.date:", schedule.date, !result); + debugLog("schedule.startTime:", schedule.startTime, !result); + debugLog("schedule.endTime:", schedule.endTime, !result); + debugLog("schedule.jobMaxMinutes:", schedule.jobMaxMinutes, !result); + debugLog("schedule.jobMinMinutes:", schedule.jobMinMinutes, !result); + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the customer information is valid for the order. + * Should be used on pages after customer info collection is complete. + * Returns true if the firstName, lastName, phoneNumber, and emailAddress are present on customer. + */ +export function hasCustomerInfo(order, logQueue = null) { + const customer = order.customer; + const result = !!( + customer.firstName && + customer.lastName && + customer.phoneNumber && + customer.emailAddress + ); + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasCustomerInfo:", result, !result); + debugLog("customer.firstName:", customer.firstName, !result); + debugLog("customer.lastName:", customer.lastName, !result); + debugLog("customer.phoneNumber:", customer.phoneNumber, !result); + debugLog("customer.emailAddress:", customer.emailAddress, !result); + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the glass parts or repair information is valid for the order. + * Should be used on pages after glass parts or repair info collection is complete. + * Returns true if the isRepair is true or if the glassParts array is not empty. + */ +export function hasGlassPartsOrRepairInfo(order, logQueue = null) { + const isRepair = order.damage?.isRepair; + const glassParts = order.lineItems?.glassParts; + const hasGlassParts = glassParts != null && glassParts.length > 0; + const result = !!isRepair || hasGlassParts; + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasGlassPartsOrRepairInfo:", result, !result); + debugLog("damage.isRepair:", isRepair, !result); + debugLog("lineItems.glassParts:", glassParts, !result); + debugLog("", null, !result); + } + }); + + return result; +} + +/** + * Checks whether the payment method information is valid for the order. + * Should only be used on pages after payment method info collection is complete which is only the payment collection pages. + * Returns true if the isPia is true or if the piaType is set. + */ +export function hasPaymentMethodInfo(order, logQueue = null) { + const payment = order.payment; + const result = payment.isPia !== null && (payment.isPia || !!payment.piaType); + + queueLogging(logQueue, () => { + // prettier-ignore + { + debugLog("hasPaymentMethodInfo:", result, !result); + debugLog("payment.isPia:", payment?.isPia, !result); + debugLog("payment.piaType:", payment?.piaType, !result); + debugLog("", null, !result); + } + }); + + return result; +} diff --git a/src/helpers/page-prerequisites-helper.spec.js b/src/helpers/page-prerequisites-helper.spec.js new file mode 100644 index 000000000..a619720c2 --- /dev/null +++ b/src/helpers/page-prerequisites-helper.spec.js @@ -0,0 +1,716 @@ +import * as pagePrereqsHelper from "@/helpers/page-prerequisites-helper"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; +import { coverageStatus } from "@/constants/insurance"; + +jest.mock("@/helpers/debug-log-helper.js", () => ({ + debugLog: jest.fn(), +})); + +const { debugLog } = require("@/helpers/debug-log-helper.js"); + +describe("page-prerequisites-helper.js", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("logPagePrereqsStart", () => { + test("calls debugLog with source and forceLog when preReqResult is false", () => { + pagePrereqsHelper.logPagePrereqsStart("payment.vue", false); + + expect(debugLog).toHaveBeenCalledWith( + "--- payment.vue pagePrereqs start ---", + null, + true + ); + }); + + test("calls debugLog with forceLog false when preReqResult is true", () => { + pagePrereqsHelper.logPagePrereqsStart("payment.vue", true); + + expect(debugLog).toHaveBeenCalledWith( + "--- payment.vue pagePrereqs start ---", + null, + false + ); + }); + }); + + describe("flushPagePrereqsLogs", () => { + test("calls logPagePrereqsStart, executes logQueue, and logPagePrereqsEnd", () => { + const logQueue = [jest.fn(), jest.fn()]; + + pagePrereqsHelper.flushPagePrereqsLogs("payment.vue", true, logQueue); + + expect(debugLog).toHaveBeenCalledWith( + "--- payment.vue pagePrereqs start ---", + null, + false + ); + expect(debugLog).toHaveBeenCalledWith( + "--- payment.vue pagePrereqs end ---", + null, + false + ); + expect(logQueue[0]).toHaveBeenCalled(); + expect(logQueue[1]).toHaveBeenCalled(); + }); + }); + + describe("logPagePrereqsEnd", () => { + test("calls debugLog with source and forceLog when preReqResult is false", () => { + pagePrereqsHelper.logPagePrereqsEnd("payment-method.vue", false); + + expect(debugLog).toHaveBeenCalledWith( + "--- payment-method.vue pagePrereqs end ---", + null, + true + ); + }); + + test("calls debugLog with forceLog false when preReqResult is true", () => { + pagePrereqsHelper.logPagePrereqsEnd("payment-method.vue", true); + + expect(debugLog).toHaveBeenCalledWith( + "--- payment-method.vue pagePrereqs end ---", + null, + false + ); + }); + }); + + describe("hasServiceZipInfo", () => { + test("returns true when zipCode and zipCodeCtu are set", () => { + const order = { + serviceLocation: { zipCode: "12345", zipCodeCtu: "12345" }, + }; + + const result = pagePrereqsHelper.hasServiceZipInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when zipCode is missing", () => { + const order = { + serviceLocation: { zipCode: null, zipCodeCtu: "12345" }, + }; + + const result = pagePrereqsHelper.hasServiceZipInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when zipCodeCtu is missing", () => { + const order = { + serviceLocation: { zipCode: "12345", zipCodeCtu: null }, + }; + + const result = pagePrereqsHelper.hasServiceZipInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when both are empty strings", () => { + const order = { + serviceLocation: { zipCode: "", zipCodeCtu: "" }, + }; + + const result = pagePrereqsHelper.hasServiceZipInfo(order); + + expect(result).toBe(false); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { + serviceLocation: { zipCode: "12345", zipCodeCtu: "12345" }, + }; + + pagePrereqsHelper.hasServiceZipInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { + serviceLocation: { zipCode: "12345", zipCodeCtu: "12345" }, + }; + const logQueue = []; + + pagePrereqsHelper.hasServiceZipInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); + + describe("hasServiceLocationInfo", () => { + const baseOrder = { + serviceLocation: { + provider: { + address: { + streetAddress: "123 Provider St", + city: "ProviderCity", + state: "OH", + zipCode: "00000", + }, + }, + }, + }; + + test("returns true for mobile when address, city, state, zipCode are set", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + address: "123 Main St", + city: "Anytown", + state: "OH", + zipCode: "12345", + appointmentType: AppointmentTypeStrings.MOBILE, + provider: baseOrder.serviceLocation.provider, + }, + }; + + const result = pagePrereqsHelper.hasServiceLocationInfo(order); + + expect(result).toBe(true); + }); + + test("returns false for mobile when address is missing", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + address: null, + city: "Anytown", + state: "OH", + zipCode: "12345", + appointmentType: AppointmentTypeStrings.MOBILE, + provider: baseOrder.serviceLocation.provider, + }, + }; + + const result = pagePrereqsHelper.hasServiceLocationInfo(order); + + expect(result).toBe(false); + }); + + test("returns true for drop-off when provider address has all fields", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + appointmentType: AppointmentTypeStrings.DROP_OFF, + }, + }; + + const result = pagePrereqsHelper.hasServiceLocationInfo(order); + + expect(result).toBe(true); + }); + + test("returns false for drop-off when provider streetAddress is missing", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + appointmentType: AppointmentTypeStrings.DROP_OFF, + provider: { + address: { + streetAddress: null, + city: "ProviderCity", + state: "OH", + zipCode: "00000", + }, + }, + }, + }; + + const result = pagePrereqsHelper.hasServiceLocationInfo(order); + + expect(result).toBe(false); + }); + + test("returns true for in-shop when provider address has all fields", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + appointmentType: AppointmentTypeStrings.IN_SHOP, + }, + }; + + const result = pagePrereqsHelper.hasServiceLocationInfo(order); + + expect(result).toBe(true); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + appointmentType: AppointmentTypeStrings.MOBILE, + address: "123", + city: "City", + state: "OH", + zipCode: "12345", + provider: baseOrder.serviceLocation.provider, + }, + }; + + pagePrereqsHelper.hasServiceLocationInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { + ...baseOrder, + serviceLocation: { + ...baseOrder.serviceLocation, + appointmentType: AppointmentTypeStrings.MOBILE, + address: "123", + city: "City", + state: "OH", + zipCode: "12345", + provider: baseOrder.serviceLocation.provider, + }, + }; + const logQueue = []; + + pagePrereqsHelper.hasServiceLocationInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + expect(typeof logQueue[0]).toBe("function"); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); + + describe("hasInsuranceInfo", () => { + test("returns false when isInsurance is null", () => { + const order = { payment: { isInsurance: null } }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("returns true when isInsurance is false (cash selected)", () => { + const order = { + payment: { + isInsurance: false, + insuranceCoverage: { coverageStatus: coverageStatus.PENDING }, + }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(true); + }); + + test("returns true when isInsurance is true and coverage is not verified", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.PENDING }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123" }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(true); + }); + + test("returns true when isInsurance is true and coverage is verified with valid deductible", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.VERIFIED }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123", currentDeductible: 100 }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(true); + }); + + test("returns true when coverage is verified and deductible is 0", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.VERIFIED }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123", currentDeductible: 0 }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when isInsurance is true but parentAccountNumber is CASH_PARENT_ACCOUNT_NUMBER", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.PENDING }, + parentAccountNumber: 167132, + }, + policy: { policyNumber: "POL123" }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when isInsurance is true but policyNumber is missing", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.PENDING }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: null }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when coverage is verified but deductible is null", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.VERIFIED }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123", currentDeductible: null }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when coverage is verified but deductible is undefined", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.VERIFIED }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123", currentDeductible: undefined }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when coverage is verified but deductible is negative", () => { + const order = { + payment: { + isInsurance: true, + insuranceCoverage: { coverageStatus: coverageStatus.VERIFIED }, + parentAccountNumber: 999999, + }, + policy: { policyNumber: "POL123", currentDeductible: -1 }, + }; + + const result = pagePrereqsHelper.hasInsuranceInfo(order); + + expect(result).toBe(false); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { payment: { isInsurance: null } }; + const logQueue = []; + + pagePrereqsHelper.hasInsuranceInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); + + describe("hasSchedulingInfo", () => { + const validSchedule = { + date: "2024-01-15", + startTime: "09:00", + endTime: "17:00", + jobMinMinutes: "30", + jobMaxMinutes: "45", + }; + + test("returns true when all schedule fields are set", () => { + const order = { schedule: validSchedule }; + + const result = pagePrereqsHelper.hasSchedulingInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when date is missing", () => { + const order = { + schedule: { ...validSchedule, date: null }, + }; + + const result = pagePrereqsHelper.hasSchedulingInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when startTime is missing", () => { + const order = { + schedule: { ...validSchedule, startTime: null }, + }; + + const result = pagePrereqsHelper.hasSchedulingInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when jobMinMinutes is missing", () => { + const order = { + schedule: { ...validSchedule, jobMinMinutes: null }, + }; + + const result = pagePrereqsHelper.hasSchedulingInfo(order); + + expect(result).toBe(false); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { schedule: validSchedule }; + + pagePrereqsHelper.hasSchedulingInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { schedule: validSchedule }; + const logQueue = []; + + pagePrereqsHelper.hasSchedulingInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); + + describe("hasCustomerInfo", () => { + const validCustomer = { + firstName: "John", + lastName: "Doe", + phoneNumber: "555-555-5555", + emailAddress: "john@example.com", + }; + + test("returns true when all customer fields are set", () => { + const order = { customer: validCustomer }; + + const result = pagePrereqsHelper.hasCustomerInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when firstName is missing", () => { + const order = { + customer: { ...validCustomer, firstName: null }, + }; + + const result = pagePrereqsHelper.hasCustomerInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when emailAddress is missing", () => { + const order = { + customer: { ...validCustomer, emailAddress: "" }, + }; + + const result = pagePrereqsHelper.hasCustomerInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when phoneNumber is empty", () => { + const order = { + customer: { ...validCustomer, phoneNumber: "" }, + }; + + const result = pagePrereqsHelper.hasCustomerInfo(order); + + expect(result).toBe(false); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { customer: validCustomer }; + + pagePrereqsHelper.hasCustomerInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + }); + + describe("hasGlassPartsOrRepairInfo", () => { + test("returns true when isRepair is true", () => { + const order = { + damage: { isRepair: true }, + lineItems: { glassParts: [] }, + }; + + const result = pagePrereqsHelper.hasGlassPartsOrRepairInfo(order); + + expect(result).toBe(true); + }); + + test("returns true when glassParts has items", () => { + const order = { + damage: { isRepair: false }, + lineItems: { glassParts: [{ id: "1" }] }, + }; + + const result = pagePrereqsHelper.hasGlassPartsOrRepairInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when isRepair is false and glassParts is empty", () => { + const order = { + damage: { isRepair: false }, + lineItems: { glassParts: [] }, + }; + + const result = pagePrereqsHelper.hasGlassPartsOrRepairInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when isRepair is false and glassParts is null", () => { + const order = { + damage: { isRepair: false }, + lineItems: { glassParts: null }, + }; + + const result = pagePrereqsHelper.hasGlassPartsOrRepairInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when damage and lineItems are undefined", () => { + const order = {}; + + const result = pagePrereqsHelper.hasGlassPartsOrRepairInfo(order); + + expect(result).toBe(false); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { + damage: { isRepair: true }, + lineItems: { glassParts: [] }, + }; + + pagePrereqsHelper.hasGlassPartsOrRepairInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { + damage: { isRepair: true }, + lineItems: { glassParts: [] }, + }; + const logQueue = []; + + pagePrereqsHelper.hasGlassPartsOrRepairInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); + + describe("hasPaymentMethodInfo", () => { + test("returns true when isPia is true and piaType is set", () => { + const order = { + payment: { isPia: true, piaType: "Afterpay" }, + }; + + const result = pagePrereqsHelper.hasPaymentMethodInfo(order); + + expect(result).toBe(true); + }); + + test("returns true when isPia is false and piaType is set", () => { + const order = { + payment: { isPia: false, piaType: "PayLater" }, + }; + + const result = pagePrereqsHelper.hasPaymentMethodInfo(order); + + expect(result).toBe(true); + }); + + test("returns false when isPia is null", () => { + const order = { + payment: { isPia: null, piaType: "Afterpay" }, + }; + + const result = pagePrereqsHelper.hasPaymentMethodInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when isPia is false and piaType is null", () => { + const order = { + payment: { isPia: false, piaType: null }, + }; + + const result = pagePrereqsHelper.hasPaymentMethodInfo(order); + + expect(result).toBe(false); + }); + + test("returns false when isPia is false and piaType is empty string", () => { + const order = { + payment: { isPia: false, piaType: "" }, + }; + + const result = pagePrereqsHelper.hasPaymentMethodInfo(order); + + expect(result).toBe(false); + }); + + test("does not add to logQueue when logQueue is null", () => { + const order = { payment: { isPia: true, piaType: "Afterpay" } }; + + pagePrereqsHelper.hasPaymentMethodInfo(order, null); + + expect(debugLog).not.toHaveBeenCalled(); + }); + + test("adds log functions to logQueue when logQueue is provided", () => { + const order = { payment: { isPia: true, piaType: "Afterpay" } }; + const logQueue = []; + + pagePrereqsHelper.hasPaymentMethodInfo(order, logQueue); + + expect(logQueue).toHaveLength(1); + logQueue[0](); + expect(debugLog).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/helpers/vehicle-helper.js b/src/helpers/vehicle-helper.js new file mode 100644 index 000000000..cd94d7bd5 --- /dev/null +++ b/src/helpers/vehicle-helper.js @@ -0,0 +1,11 @@ +import store from "@/store"; + +export function hasVehicleInfo() { + return ( + store.getters.order?.vehicle.year && + store.getters.order?.vehicle?.make && + store.getters.order?.vehicle?.model && + store.getters.order?.vehicle?.style && + store.getters.order?.vehicle?.carId + ); +} diff --git a/src/layouts/confirmation/confirmation.vue b/src/layouts/confirmation/confirmation.vue index a9c964268..fa0e53425 100644 --- a/src/layouts/confirmation/confirmation.vue +++ b/src/layouts/confirmation/confirmation.vue @@ -470,7 +470,8 @@ export default { } }, donationValues() { - return [1, 3, 5]; + var donationAmounts = this.getCmsContent("DonationAmountsWidget", "Text"); + return donationAmounts.split(",").map(Number); }, lineItemsWithoutDonation() { const lineItemsClone = deepClone(this.lineItems); @@ -531,10 +532,11 @@ export default { window.location.assign(location.protocol + "//" + location.host); }, async addDonationToOrder() { + const selectedItemNumber = this.donationValues.indexOf(Number(this.donationAmount)); const donationResponse = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.SUBMIT_DONATION_PART_TO_SV2, { - donationAmount: parseInt(this.donationAmount), + selectedItemNumber: selectedItemNumber, submittedOrder: this.submittedOrder, }, false diff --git a/src/layouts/mobile-details/mobile-details.spec.js b/src/layouts/mobile-details/mobile-details.spec.js index a88dcab3d..a0c548a54 100644 --- a/src/layouts/mobile-details/mobile-details.spec.js +++ b/src/layouts/mobile-details/mobile-details.spec.js @@ -89,18 +89,53 @@ describe("mobile-details.vue", () => { // Assert expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled(); }); - test("arePagePrerequisitesValid should be true ", async () => { - //Arrange - const { wrapper } = setupMocks({ - mixins: [mockMixin], - attachTo: document.body, + describe("arePagePrerequisitesValid", () => { + test("returns true when all prerequisites are valid", () => { + const { wrapper } = setupMocks({ + mixins: [mockMixin], + attachTo: document.body, + }); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(true); }); - //Act - let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); + test("returns falsy when service zip info is missing", () => { + store.getters.order.serviceLocation.zipCode = null; + const { wrapper } = setupMocks({ + mixins: [mockMixin], + attachTo: document.body, + }); - //Assert - expect(arePagePrerequisitesValid).toBe(true); + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBeFalsy(); + }); + + test("returns false when appointment type is not mobile", () => { + store.getters.order.serviceLocation.appointmentType = "Inshop"; + const { wrapper } = setupMocks({ + mixins: [mockMixin], + attachTo: document.body, + }); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when scheduling info is missing", () => { + store.getters.order.schedule.date = null; + const { wrapper } = setupMocks({ + mixins: [mockMixin], + attachTo: document.body, + }); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); }); test("if the back button is clicked, navigate back", async () => { diff --git a/src/layouts/mobile-details/mobile-details.vue b/src/layouts/mobile-details/mobile-details.vue index b6978a7ff..cb98386e6 100644 --- a/src/layouts/mobile-details/mobile-details.vue +++ b/src/layouts/mobile-details/mobile-details.vue @@ -78,6 +78,7 @@ import { settleAllPromises } from "@/helpers/layout-helper"; import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import textLink from "@/ux-components/text-link/text-link"; import analyticsMixin from "@/mixins/analytics-mixin"; +import { flushPagePrereqsLogs, hasSchedulingInfo } from "@/helpers/page-prerequisites-helper"; export default { name: "MobileDetails", data() { @@ -110,6 +111,9 @@ export default { }, methods: { arePagePrerequisitesValid() { + const logQueue = []; + const order = store.getters.order; + const serviceLocation = store.getters.order.serviceLocation; const serviceLocationPreReqs = serviceLocation.zipCode && @@ -118,16 +122,12 @@ export default { serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; // Schedule - const schedule = store.getters.order.schedule; - const scheduleReqs = !!( - schedule.date && - schedule.startTime && - schedule.endTime && - schedule.jobMaxMinutes && - schedule.jobMinMinutes - ); + const scheduleInfoPreReqs = hasSchedulingInfo(order, logQueue); + + const preReqResult = serviceLocationPreReqs && scheduleInfoPreReqs; + + flushPagePrereqsLogs("mobile-details.vue", preReqResult, logQueue); - const preReqResult = serviceLocationPreReqs && scheduleReqs; return preReqResult; }, getServiceAddressFromStore() { diff --git a/src/layouts/payment-adyen/payment-adyen.spec.js b/src/layouts/payment-adyen/payment-adyen.spec.js new file mode 100644 index 000000000..26d3a6aec --- /dev/null +++ b/src/layouts/payment-adyen/payment-adyen.spec.js @@ -0,0 +1,214 @@ +import paymentAdyen from "@/layouts/payment-adyen/payment-adyen.vue"; +import { shallowMount } from "@vue/test-utils"; +import { getMountOptions } from "@/helpers/unit-test-helper"; +import store from "@/store"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; + +jest.mock("@/helpers/pricing-helper.js", () => ({ + getAmountDue: jest.fn().mockReturnValue(100), +})); + +jest.mock("@/helpers/cms-content-helper", () => ({ + fetchCmsContentForPage: () => Promise.resolve("content"), +})); + +jest.mock("@/helpers/layout-helper", () => ({ + settleAllPromises: jest.fn().mockResolvedValue({ cmsContent: "content" }), +})); + +jest.mock("@/helpers/loading-modal-helper", () => ({ + showFmgLoadingModal: jest.fn(), +})); + +jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({ + submitWorkOrder: jest.fn(), +})); + +jest.mock("@/helpers/debug-log-helper.js", () => ({ + debugLog: jest.fn(), +})); + +// Component that skips Adyen initialization in mounted (only needed for arePagePrerequisitesValid tests) +const PaymentAdyenTestComponent = { + ...paymentAdyen, + mounted() { + // Stub - skip initializeAdyen to avoid Adyen/API setup + }, +}; + +const createValidOrder = () => ({ + vehicle: { + year: "2020", + make: "acura", + model: "mdx", + carId: "dummyCarId", + }, + serviceLocation: { + address: "123 Main St", + address2: "", + city: "Columbus", + state: "OH", + zipCode: "43235", + zipCodeCtu: "43235", + appointmentType: AppointmentTypeStrings.IN_SHOP, + provider: { + address: { + streetAddress: "456 Provider St", + city: "Columbus", + state: "OH", + zipCode: "43235", + zipCodeCtu: "43235", + }, + }, + }, + customer: { + firstName: "John", + lastName: "Doe", + emailAddress: "john.doe@example.com", + phoneNumber: "555-555-5555", + }, + payment: { + isInsurance: false, + isPia: true, + piaType: "CREDIT_CARD", + insuranceCoverage: {}, + }, + policy: { + currentDeductible: 0, + policyNumber: null, + }, + schedule: { + date: "2024-01-15", + startTime: "09:00", + endTime: "10:00", + jobMinMinutes: "30", + jobMaxMinutes: "45", + }, + workOrderNumber: "WO-123456", + referralCorrelationId: "ref-123", + referralSequenceNumber: 1, + damage: { isRepair: false }, + lineItems: { + glassParts: [{ id: "part1", partType: "WINDSHIELD" }], + }, +}); + +describe("payment-adyen.vue", () => { + beforeEach(() => { + store.getters = { + order: createValidOrder(), + damage: {}, + lineItems: { glassParts: [] }, + payment: {}, + policy: {}, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("arePagePrerequisitesValid", () => { + test("returns true when all prerequisites are valid", () => { + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(true); + }); + + test("returns false when service location info is missing", () => { + store.getters.order.serviceLocation.address = null; + store.getters.order.serviceLocation.provider.address.streetAddress = null; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when insurance info is invalid", () => { + store.getters.order.payment.isInsurance = null; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when scheduling info is missing", () => { + store.getters.order.schedule.date = null; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when customer info is missing", () => { + store.getters.order.customer.firstName = null; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when payment method info is invalid", () => { + store.getters.order.payment.isPia = null; + store.getters.order.payment.piaType = null; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when glass parts or repair info is missing", () => { + store.getters.order.damage.isRepair = false; + store.getters.order.lineItems.glassParts = []; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns true for mobile appointment type when address fields are present", () => { + store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE; + store.getters.order.serviceLocation.address = "123 Mobile St"; + store.getters.order.serviceLocation.city = "Columbus"; + store.getters.order.serviceLocation.state = "OH"; + store.getters.order.serviceLocation.zipCode = "43235"; + const wrapper = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(true); + }); + }); +}); + +function setupMocks({ customMountOptions } = {}) { + const mountOptions = getMountOptions({ + ...customMountOptions, + store, + route: { name: "payment-adyen" }, + router: { + navigateWithSaving: jest.fn(), + navigateWithoutSaving: jest.fn(), + navigateWithPageData: jest.fn(), + }, + }); + mountOptions.global.mocks["navigationScenarios"] = {}; + mountOptions.mixins = [ + { + methods: { + getCmsContent: jest.fn(), + setCmsContent: jest.fn(), + }, + }, + ]; + + return shallowMount(PaymentAdyenTestComponent, mountOptions); +} diff --git a/src/layouts/payment-adyen/payment-adyen.vue b/src/layouts/payment-adyen/payment-adyen.vue index cddb2189c..adb0a1210 100644 --- a/src/layouts/payment-adyen/payment-adyen.vue +++ b/src/layouts/payment-adyen/payment-adyen.vue @@ -83,7 +83,15 @@ import cart from "@/fmg-components/cart/cart"; import { coverageStatus } from "@/constants/insurance"; import { deepClone } from "@/helpers/object-helper"; import store from "@/store"; -import { debugLog } from "@/helpers/debug-log-helper.js"; +import { + flushPagePrereqsLogs, + hasServiceLocationInfo, + hasInsuranceInfo, + hasSchedulingInfo, + hasCustomerInfo, + hasPaymentMethodInfo, + hasGlassPartsOrRepairInfo, +} from "@/helpers/page-prerequisites-helper.js"; export default { name: "payment-adyen", @@ -136,110 +144,19 @@ export default { }, arePagePrerequisitesValid() { - // Service Location - const serviceLocation = store.getters.order.serviceLocation; - const mobileReqs = !!( - serviceLocation.address && - serviceLocation.city && - serviceLocation.state && - serviceLocation.zipCode - ); - - const providerLocation = serviceLocation.provider.address; - const dropOffInshopReqs = !!( - providerLocation.streetAddress && - providerLocation.city && - providerLocation.state && - providerLocation.zipCode - ); - - const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; - - const serviceLocationReqs = - (isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs); - - // Insurance - const isInsuranceSet = store.getters.order.payment.isInsurance !== null; - - // Schedule - const schedule = store.getters.order.schedule; - const scheduleReqs = !!( - schedule.date && - schedule.startTime && - schedule.endTime && - schedule.jobMaxMinutes && - schedule.jobMinMinutes - ); - - // Customer - const customer = store.getters.order.customer; - const customerReqs = !!( - customer.firstName && - customer.lastName && - customer.phoneNumber && - customer.emailAddress - ); - - const paymentMethodReqs = - store.getters.order.payment.isPia !== null && - (store.getters.order.payment.isPia || !!store.getters.order.payment.piaType); - - const preReqResult = - serviceLocationReqs && - isInsuranceSet && - scheduleReqs && - customerReqs && - paymentMethodReqs; - - // prettier-ignore - { - debugLog("--- payment-adyen.vue pagePrereqs start ---", null, !preReqResult); - debugLog("mobileReqs:", mobileReqs, !preReqResult); - debugLog("serviceLocation.address:", serviceLocation.address, !preReqResult); - debugLog("serviceLocation.city:", serviceLocation.city, !preReqResult); - debugLog("serviceLocation.state:", serviceLocation.state, !preReqResult); - debugLog("serviceLocation.zipCode:", serviceLocation.zipCode, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("dropOffInshopReqs:", dropOffInshopReqs, !preReqResult); - debugLog("serviceLocationReqs:", serviceLocationReqs, !preReqResult); - debugLog("providerLocation.streetAddress:", providerLocation.streetAddress, !preReqResult); - debugLog("providerLocation.city:", providerLocation.city, !preReqResult); - debugLog("providerLocation.state:", providerLocation.state, !preReqResult); - debugLog("providerLocation.zipCode:", providerLocation.zipCode, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("isInsuranceSet:", isInsuranceSet, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("scheduleReqs:", scheduleReqs, !preReqResult); - debugLog("schedule.date:", schedule.date, !preReqResult); - debugLog("schedule.startTime:", schedule.startTime, !preReqResult); - debugLog("schedule.endTime:", schedule.endTime, !preReqResult); - debugLog("schedule.jobMaxMinutes:", schedule.jobMaxMinutes, !preReqResult); - debugLog("schedule.jobMinMinutes:", schedule.jobMinMinutes, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("customerReqs:", customerReqs, !preReqResult); - debugLog("customer.firstName:", customer.firstName, !preReqResult); - debugLog("customer.lastName:", customer.lastName, !preReqResult); - debugLog("customer.phoneNumber:", customer.phoneNumber, !preReqResult); - debugLog("customer.emailAddress:", customer.emailAddress, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("paymentMethodReqs:", paymentMethodReqs, !preReqResult); - debugLog("store.getters.order.payment.isPia:", store.getters.order?.payment?.isPia, !preReqResult); - debugLog("store.getters.order.payment.piaType:", store.getters.order?.payment?.piaType, !preReqResult); - - debugLog("--- payment-adyen.vue pagePrereqs end ---", null, !preReqResult); - } - - return preReqResult; + const order = store.getters.order; + const logQueue = []; + const results = [ + hasServiceLocationInfo(order, logQueue), + hasInsuranceInfo(order, logQueue), + hasSchedulingInfo(order, logQueue), + hasCustomerInfo(order, logQueue), + hasPaymentMethodInfo(order, logQueue), + hasGlassPartsOrRepairInfo(order, logQueue), + ]; + const result = results.every(Boolean); + flushPagePrereqsLogs("payment-adyen.vue", result, logQueue); + return result; }, async initializeAdyen() { diff --git a/src/layouts/payment-method/payment-method.spec.js b/src/layouts/payment-method/payment-method.spec.js index 76d4daa1e..f539017d4 100644 --- a/src/layouts/payment-method/payment-method.spec.js +++ b/src/layouts/payment-method/payment-method.spec.js @@ -8,6 +8,7 @@ import store from "@/store"; import { experimentSettings } from "@/constants/experiments"; import { storeMutations } from "@/constants/store-mutations"; import globalMethods from "@/global-methods"; +import { AppointmentTypeStrings } from "@/constants/schedule-constants"; globalMethods.callHttpClient = jest.fn(); @@ -29,8 +30,64 @@ jest.mock("@/helpers/pricing-helper.js", () => ({ getAmountDue: jest.fn(), })); +jest.mock("@/helpers/debug-log-helper.js", () => ({ + debugLog: jest.fn(), +})); + let piaDisabledFlag = false; +const createValidOrderForPaymentMethod = () => ({ + payment: { + isPia: false, + isInsurance: false, + insuranceCoverage: { isVerified: true }, + }, + lineItems: { + glassParts: [{ id: "part1", partType: "WINDSHIELD" }], + supportingItems: [], + vaps: [], + promos: [], + }, + policy: { + currentDeductible: 123, + isNoComp: false, + isItac: false, + }, + serviceLocation: { + appointmentType: AppointmentTypeStrings.MOBILE, + address: "123 Test Ave", + address2: "", + city: "Anytown", + state: "OH", + zipCode: "00000", + zipCodeCtu: "00000", + provider: { + address: { + streetAddress: "456 Provider St", + city: "Anytown", + state: "OH", + zipCode: "00000", + zipCodeCtu: "00000", + }, + }, + }, + customer: { + firstName: "John", + lastName: "Doe", + emailAddress: "john@example.com", + phoneNumber: "555-555-5555", + }, + schedule: { + date: "2024-01-15", + startTime: "09:00", + endTime: "10:00", + jobMinMinutes: "30", + jobMaxMinutes: "45", + }, + vehicle: { year: 2020, make: "Toyota", model: "Camry", carId: "12345" }, + damage: { isRepair: false }, +}); + describe("payment-method.vue", () => { describe("navigation", () => { test("if the back button is clicked, navigate back", async () => { @@ -54,6 +111,63 @@ describe("payment-method.vue", () => { expect(wrapper.vm.$router.navigateWithoutSaving).toHaveBeenCalled(); }); }); + + describe("arePagePrerequisitesValid", () => { + test("returns true when all prerequisites are valid", () => { + const { wrapper } = setupMocks(); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(true); + }); + + test("returns false when service location info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.serviceLocation.address = null; + store.getters.order.serviceLocation.provider.address.streetAddress = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when insurance info is invalid", () => { + const { wrapper } = setupMocks(); + store.getters.order.payment.isInsurance = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when scheduling info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.schedule.date = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when customer info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.customer.firstName = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when glass parts or repair info is missing", () => { + const { wrapper } = setupMocks(); + store.getters.order.damage.isRepair = false; + store.getters.order.lineItems.glassParts = []; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + }); }); function setupMocks() { @@ -65,34 +179,9 @@ function setupMocks() { vaps: [], promos: [], }, - order: { - payment: { - isPia: false, - insuranceCoverage: { - isVerified: true, - }, - }, - lineItems: { - glassParts: [], - supportingItems: [], - vaps: [], - promos: [], - }, - policy: { - currentDeductible: 123, - isNoComp: false, - isItac: false, - }, - serviceLocation: { - appointmentType: "Mobile", - address: "123 Test Ave", - city: "Anytown", - state: "OH", - zipCode: "00000", - provider: { providerNumber: "0000567" }, - }, - vehicle: { year: 2020, make: "Toyota", model: "Camry", carId: "12345" }, - damage: { isRepair: false }, + order: createValidOrderForPaymentMethod(), + applicationUser: { + experiments: [], }, policy: { currentDeductible: 123, diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 2a6d1d9ed..48f076f84 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -53,6 +53,17 @@ v-bind:isDismissible="false" /> +
+ +
+
@@ -167,7 +178,14 @@ import { containsRecalParts } from "@/helpers/recal-helper"; import { getBoolFromString } from "@/helpers/boolean-helper"; import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; -import { debugLog } from "@/helpers/debug-log-helper"; +import { + flushPagePrereqsLogs, + hasServiceLocationInfo, + hasInsuranceInfo, + hasSchedulingInfo, + hasCustomerInfo, + hasGlassPartsOrRepairInfo, +} from "@/helpers/page-prerequisites-helper.js"; import { ErrorMessage } from "vee-validate"; import { Field } from "vee-validate"; import experimentMixin from "../../mixins/experiment-mixin"; @@ -311,6 +329,8 @@ export default { serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, pricedLineItems: lineItemsForCart, + serviceLocationAddressLine1: store.getters.order.serviceLocation?.address, + serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2, }, "payment-method", false @@ -368,91 +388,19 @@ export default { this.$router.navigateWithoutSaving(scenarioName, this.pageName); }, arePagePrerequisitesValid() { - // Service Location - const serviceLocation = store.getters.order.serviceLocation; - const mobileReqs = !!( - serviceLocation.address && - serviceLocation.city && - serviceLocation.state && - serviceLocation.zipCode - ); - - const providerLocation = serviceLocation.provider.address; - const dropOffInshopReqs = !!( - providerLocation.streetAddress && - providerLocation.city && - providerLocation.state && - providerLocation.zipCode - ); - - const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; - - const serviceLocationReqs = - (isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs); - - // Insurance - const isInsurance = store.getters.order.payment.isInsurance; - const insuranceCoverageStatus = store.getters.payment.insuranceCoverage.coverageStatus; - const currentDeductible = store.getters.policy.currentDeductible; - const isInsuranceSet = () => { - if (isInsurance !== null) { - if ( - insuranceCoverageStatus === coverageStatus.VERIFIED && - !(currentDeductible === 0 || currentDeductible > 0) - ) { - return false; // if coverageStatus is verified there must be a valid deductible also - } - return true; - } - return false; - }; - - // Schedule - const schedule = store.getters.order.schedule; - const scheduleReqs = !!( - schedule.date && - schedule.startTime && - schedule.endTime && - schedule.jobMaxMinutes && - schedule.jobMinMinutes - ); - - // Customer - const customer = store.getters.order.customer; - const customerReqs = !!( - customer.firstName && - customer.lastName && - customer.phoneNumber && - customer.emailAddress - ); - - const preReqResult = - serviceLocationReqs && isInsuranceSet() && scheduleReqs && customerReqs; - - // prettier-ignore - { - debugLog("--- payment-method.vue pagePrereqs start ---", null, !preReqResult); - debugLog("serviceLocationRequs::isMobile:", isMobile, !preReqResult); - debugLog("serviceLocationReqs::mobileReqs:", mobileReqs, !preReqResult); - debugLog("serviceLocationReqs::dropOffInshopReqs:", dropOffInshopReqs, !preReqResult); - debugLog("serviceLocationReqs:: result", serviceLocationReqs, !preReqResult); - - debugLog("isInsuranceSet::", isInsuranceSet(), !preReqResult); - - debugLog("scheduleReqs::schedule.date:", schedule.date, !preReqResult); - debugLog("scheduleReqs::schedule.startTime:", schedule.startTime, !preReqResult); - debugLog("scheduleReqs::schedule.endTime:", schedule.endTime, !preReqResult); - debugLog("scheduleReqs::schedule.jobMaxMinutes:", schedule.jobMaxMinutes, !preReqResult); - debugLog("scheduleReqs::schedule.jobMinMinutes:", schedule.jobMinMinutes, !preReqResult); - - debugLog("customerReqs::customer.firstName:", customer.firstName, !preReqResult); - debugLog("customerReqs::customer.lastName:", customer.lastName, !preReqResult); - debugLog("customerReqs::customer.phoneNumber:", customer.phoneNumber, !preReqResult); - debugLog("customerReqs::customer.emailAddress:", customer.emailAddress, !preReqResult); - debugLog("--- payment-method.vue pagePrereqs end ---", null, !preReqResult); - } - - return preReqResult; + const order = store.getters.order; + const logQueue = []; + const results = [ + hasServiceLocationInfo(order, logQueue), + hasInsuranceInfo(order, logQueue), + hasSchedulingInfo(order, logQueue), + hasCustomerInfo(order, logQueue), + hasGlassPartsOrRepairInfo(order, logQueue), + ]; + //false if any check is false but calls all checks for logging purposes + const result = results.every(Boolean); + flushPagePrereqsLogs("payment-method.vue", result, logQueue); + return result; }, getPaymentMethodFromStore() { const piaType = store.getters.order.payment.piaType; @@ -515,6 +463,10 @@ export default { serviceLocationState: this.$store.getters.order.serviceLocation.state, serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode, pricedLineItems: this.lineItems, + serviceLocationAddressLine1: + this.$store.getters.order.serviceLocation?.address, + serviceLocationAddressLine2: + this.$store.getters.order.serviceLocation?.address2, }, "payment-method", false @@ -639,6 +591,8 @@ export default { serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, pricedLineItems: this.lineItems, + serviceLocationAddressLine1: store.getters.order.serviceLocation?.address, + serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2, }, "payment-method", false @@ -865,6 +819,13 @@ export default { shouldDisplayPiaAlert() { return getBoolFromString(this.$route?.query?.piaError); }, + shouldDisplayUnverifiedInsuranceAlert() { + const shouldDisplayUnverifiedDeduct = experimentMixin.methods.hasSettingEqualTo( + experimentSettings.SHOW_UNVERIFIED_DEDUCT_ENTRY, + "true" + ); + return shouldDisplayUnverifiedDeduct; + }, // Necessary to make the watcher of lineItems work // JavaScript does not keep a record of the old value, only a reference to it's location in the memory. // This creates a separate location for oldValue/newValue so they can be compared @@ -1033,7 +994,15 @@ export default { } } } + + .alert.alert-info.widget-name-UnverifiedInsuranceAlertWidget { + :deep(.alert-heading) { + color: $black; + font-weight: 600; + } + } } + :deep(span.label), :deep(span) { font-family: UrbanistSemibold, Arial, Helvetica, sans-serif; diff --git a/src/layouts/payment/payment.spec.js b/src/layouts/payment/payment.spec.js index 1b36d0644..6f655fa0a 100644 --- a/src/layouts/payment/payment.spec.js +++ b/src/layouts/payment/payment.spec.js @@ -258,6 +258,52 @@ describe("payment.vue", () => { expect(result).toBe(true); }); + test("Returns false when service location info is missing", () => { + const wrapper = setupMocks({}); + store.getters.order.serviceLocation.provider.address.streetAddress = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("Returns false when insurance info is invalid", () => { + const wrapper = setupMocks({}); + store.getters.order.payment.isInsurance = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("Returns false when scheduling info is missing", () => { + const wrapper = setupMocks({}); + store.getters.order.schedule.date = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("Returns false when customer info is missing", () => { + const wrapper = setupMocks({}); + store.getters.order.customer.firstName = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("Returns false when glass parts or repair info is missing", () => { + const wrapper = setupMocks({}); + store.getters.order.damage.isRepair = false; + store.getters.order.lineItems.glassParts = []; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + describe("Payment method cases", () => { test("Returns false if pia options are not valid", () => { // Arrange diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index 6c547f3d2..a8c3653b1 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -235,7 +235,6 @@ import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { settleAllPromises } from "@/helpers/layout-helper"; import { applicationConfig } from "@/constants/application-config"; import { paymentMethods } from "@/constants/payment-method-constants"; -import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import baseMixin from "@/mixins/base-mixin.js"; import { deepClone } from "@/helpers/object-helper"; import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js"; @@ -243,6 +242,15 @@ import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer. import { coverageStatus } from "@/constants/insurance"; import { getDisplayAmountDue, getAmountDue } from "@/helpers/pricing-helper.js"; import { debugLog } from "@/helpers/debug-log-helper.js"; +import { + flushPagePrereqsLogs, + hasServiceLocationInfo, + hasInsuranceInfo, + hasSchedulingInfo, + hasCustomerInfo, + hasPaymentMethodInfo, + hasGlassPartsOrRepairInfo, +} from "@/helpers/page-prerequisites-helper.js"; import buttonQuestion from "@/digital-components/button-question/button-question"; import paymentMethodListButton from "@/layouts/payment-method/payment-method-question/payment-method-list-button/payment-method-list-button"; @@ -435,110 +443,19 @@ export default { }, methods: { arePagePrerequisitesValid() { - // Service Location - const serviceLocation = store.getters.order.serviceLocation; - const mobileReqs = !!( - serviceLocation.address && - serviceLocation.city && - serviceLocation.state && - serviceLocation.zipCode - ); - - const providerLocation = serviceLocation.provider.address; - const dropOffInshopReqs = !!( - providerLocation.streetAddress && - providerLocation.city && - providerLocation.state && - providerLocation.zipCode - ); - - const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; - - const serviceLocationReqs = - (isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs); - - // Insurance - const isInsuranceSet = store.getters.order.payment.isInsurance !== null; - - // Schedule - const schedule = store.getters.order.schedule; - const scheduleReqs = !!( - schedule.date && - schedule.startTime && - schedule.endTime && - schedule.jobMaxMinutes && - schedule.jobMinMinutes - ); - - // Customer - const customer = store.getters.order.customer; - const customerReqs = !!( - customer.firstName && - customer.lastName && - customer.phoneNumber && - customer.emailAddress - ); - - const paymentMethodReqs = - store.getters.order.payment.isPia !== null && - (store.getters.order.payment.isPia || !!store.getters.order.payment.piaType); - - const preReqResult = - serviceLocationReqs && - isInsuranceSet && - scheduleReqs && - customerReqs && - paymentMethodReqs; - - // prettier-ignore - { - debugLog("--- payment.vue pagePrereqs start ---", null, !preReqResult); - debugLog("mobileReqs:", mobileReqs, !preReqResult); - debugLog("serviceLocation.address:", serviceLocation.address, !preReqResult); - debugLog("serviceLocation.city:", serviceLocation.city, !preReqResult); - debugLog("serviceLocation.state:", serviceLocation.state, !preReqResult); - debugLog("serviceLocation.zipCode:", serviceLocation.zipCode, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("dropOffInshopReqs:", dropOffInshopReqs, !preReqResult); - debugLog("serviceLocationReqs:", serviceLocationReqs, !preReqResult); - debugLog("providerLocation.streetAddress:", providerLocation.streetAddress, !preReqResult); - debugLog("providerLocation.city:", providerLocation.city, !preReqResult); - debugLog("providerLocation.state:", providerLocation.state, !preReqResult); - debugLog("providerLocation.zipCode:", providerLocation.zipCode, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("isInsuranceSet:", isInsuranceSet, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("scheduleReqs:", scheduleReqs, !preReqResult); - debugLog("schedule.date:", schedule.date, !preReqResult); - debugLog("schedule.startTime:", schedule.startTime, !preReqResult); - debugLog("schedule.endTime:", schedule.endTime, !preReqResult); - debugLog("schedule.jobMaxMinutes:", schedule.jobMaxMinutes, !preReqResult); - debugLog("schedule.jobMinMinutes:", schedule.jobMinMinutes, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("customerReqs:", customerReqs, !preReqResult); - debugLog("customer.firstName:", customer.firstName, !preReqResult); - debugLog("customer.lastName:", customer.lastName, !preReqResult); - debugLog("customer.phoneNumber:", customer.phoneNumber, !preReqResult); - debugLog("customer.emailAddress:", customer.emailAddress, !preReqResult); - - debugLog("", null, !preReqResult); - - debugLog("paymentMethodReqs:", paymentMethodReqs, !preReqResult); - debugLog("store.getters.order.payment.isPia:", store.getters.order?.payment?.isPia, !preReqResult); - debugLog("store.getters.order.payment.piaType:", store.getters.order?.payment?.piaType, !preReqResult); - - debugLog("--- payment.vue pagePrereqs end ---", null, !preReqResult); - } - - return preReqResult; + const order = store.getters.order; + const logQueue = []; + const results = [ + hasServiceLocationInfo(order, logQueue), + hasInsuranceInfo(order, logQueue), + hasSchedulingInfo(order, logQueue), + hasCustomerInfo(order, logQueue), + hasPaymentMethodInfo(order, logQueue), + hasGlassPartsOrRepairInfo(order, logQueue), + ]; + const result = results.every(Boolean); + flushPagePrereqsLogs("payment.vue", result, logQueue); + return result; }, getAnswersNullSafe(widgetName) { const rawData = this.getCmsContent(widgetName, "Answers"); diff --git a/src/layouts/quote/quote.spec.js b/src/layouts/quote/quote.spec.js index 60dd548ad..d9a36f11d 100644 --- a/src/layouts/quote/quote.spec.js +++ b/src/layouts/quote/quote.spec.js @@ -295,6 +295,47 @@ describe("quote.vue", () => { expect(arePagePrerequisitesValid).toBe(false); }); + + test("should fail arePagePrerequisitesValid when service zip info is missing", () => { + store.getters = { + order: { + lineItems: { glassParts: ["item"] }, + serviceLocation: { + zipCode: null, + zipCodeCtu: "value", + }, + damage: { isRepair: false }, + payment: { insuranceCoverage: { isVerified: false } }, + referralNumber: "1234567", + }, + }; + const { wrapper } = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("should fail arePagePrerequisitesValid when insurance coverage is verified", () => { + store.getters = { + order: { + lineItems: { glassParts: ["item"] }, + serviceLocation: { + zipCode: "12345", + zipCodeCtu: "value", + }, + damage: { isRepair: false }, + payment: { insuranceCoverage: { isVerified: true } }, + referralNumber: "1234567", + }, + }; + const { wrapper } = setupMocks({}); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + test("should have non-null values for necessary data members after 'beforeRouteEnter'", async () => { //Arrange store.getters = { diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 0bd095b77..82249e790 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -183,6 +183,11 @@ import { routeData } from "@/router/constants/routes"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { storeMutations } from "@/constants/store-mutations"; import { debugLog } from "@/helpers/debug-log-helper"; +import { + flushPagePrereqsLogs, + hasServiceZipInfo, + hasGlassPartsOrRepairInfo, +} from "@/helpers/page-prerequisites-helper.js"; import { savePageData } from "@/router/methods/helpers/save-page-data"; import { quotePageDiscountTable } from "../../constants/quote-page-discounts"; @@ -728,28 +733,27 @@ export default { this.$refs[modalName].openModal(); }, arePagePrerequisitesValid() { - const payment = store.getters.order.payment; + const order = store.getters.order; + const logQueue = []; + const isVerifiedOk = + order.payment?.insuranceCoverage?.isVerified == null || + order.payment?.insuranceCoverage?.isVerified === false; - const preReqResult = - store.getters.order.serviceLocation.zipCode && - store.getters.order.serviceLocation.zipCodeCtu && - (store.getters.order.damage.isRepair || - (store.getters.order.lineItems?.glassParts != null && - store.getters.order.lineItems.glassParts.length > 0)) && - (payment.insuranceCoverage?.isVerified == null || - payment.insuranceCoverage?.isVerified === false); + const results = [ + hasServiceZipInfo(order, logQueue), + hasGlassPartsOrRepairInfo(order, logQueue), + ]; + const preReqResult = results.every(Boolean) && isVerifiedOk; - // prettier-ignore - { - debugLog("--- quote.vue pagePrereqs start ---", null, !preReqResult); - debugLog("store.getters.order.serviceLocation.zipCode:", store.getters.order.serviceLocation?.zipCode, !preReqResult); - debugLog("store.getters.order.serviceLocation.zipCodeCtu:", store.getters.order.serviceLocation?.zipCodeCtu, !preReqResult); - debugLog("store.getters.order.damage.isRepair:", store.getters.order.damage?.isRepair, !preReqResult); - debugLog("store.getters.order.lineItems?.glassParts:", store.getters.order.lineItems?.glassParts, !preReqResult); - debugLog("store.getters.order.payemt.insuranceCoverage?.isVerified:", store.getters.order.payment?.insuranceCoverage?.isVerified, !preReqResult); - debugLog("--- quote.vue pagePrereqs end ---", null, !preReqResult); - } + logQueue.push(() => { + debugLog( + "payment.insuranceCoverage?.isVerified:", + order.payment?.insuranceCoverage?.isVerified, + !preReqResult + ); + }); + flushPagePrereqsLogs("quote.vue", preReqResult, logQueue); return preReqResult; }, vapsItemsSelectedAction(vapsItemsSelected) { diff --git a/src/layouts/quote/service-package-question/service-package-question.spec.js b/src/layouts/quote/service-package-question/service-package-question.spec.js index a44dc463c..b3e4742e7 100644 --- a/src/layouts/quote/service-package-question/service-package-question.spec.js +++ b/src/layouts/quote/service-package-question/service-package-question.spec.js @@ -35,6 +35,11 @@ jest.mock("@/mixins/experiment-mixin.js", () => ({ return true; } }, + hasSetting(settingName) { + if (settingName === mockExperimentSettings.PROMO_ON_PACKAGE) { + return false; + } + }, hasSettingEqualTo(settingName, settingValue) { return false; }, diff --git a/src/layouts/quote/service-package-question/service-package-question.vue b/src/layouts/quote/service-package-question/service-package-question.vue index 436e98b24..72472e195 100644 --- a/src/layouts/quote/service-package-question/service-package-question.vue +++ b/src/layouts/quote/service-package-question/service-package-question.vue @@ -163,6 +163,16 @@ export default { })); this.$emit("currentNumberOfPackages", modifiedAnswers.length); + + if (this.experimentPackageOrder()) { + const orderedKeys = this.experimentPackageOrder() + .split(",") + .map((key) => key.trim()); + modifiedAnswers.sort( + (a, b) => orderedKeys.indexOf(a.value) - orderedKeys.indexOf(b.value) + ); + } + return modifiedAnswers; }, isDiscountOnOrder() { @@ -235,6 +245,11 @@ export default { ) ); }, + experimentPackageOrder() { + return experimentMixin.methods.hasSetting(experimentSettings.SERVICE_PACKAGE_ORDER) + ? experimentMixin.methods.getSettingValue(experimentSettings.SERVICE_PACKAGE_ORDER) + : false; + }, getButtonTypeObject() { if (this.isAfterpayBreakoutDisplay()) { return this.servicePackageRadioAfterpayExperiment; diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 4952ce2a9..799d4b92f 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -79,3 +79,137 @@ export function getTodayDate() { export function getTodayDateString() { return convertDateToDateString(getTodayDate()); } + +export function getMonthEnd(dateStr) { + // convert string to date, do date calc, then return a string back + const date = new Date(dateStr.split("-")[0], parseInt(dateStr.split("-")[1]), 0); + return convertDateToDateString(date); +} + +export function getWeekStartDate(dateString) { + const date = convertDateStringToDate(dateString); + const dayOfWeek = date.getDay(); + // Subtract the day of the week from date to get the date of Sunday + const sunday = new Date(date); + sunday.setDate(sunday.getDate() - dayOfWeek); + return convertDateToDateString(sunday); +} + +export function getWeekEndDate(dateString) { + const date = convertDateStringToDate(dateString); + const dayOfWeek = date.getDay(); + const daysUntilSaturday = 6 - dayOfWeek; // Calculate the number of days until Saturday + // Clone the given date and add the remaining days until Saturday + const saturday = new Date(date); + saturday.setDate(date.getDate() + daysUntilSaturday); + return convertDateToDateString(saturday); +} + +export function getNextWeekSunday(dateString) { + const date = convertDateStringToDate(dateString); + const dayOfWeek = date.getDay(); + const daysUntilNextSunday = 7 - dayOfWeek; // Calculate the number of days until the next Sunday + // Clone the given date and add the remaining days until Sunday + const nextSunday = new Date(date); + nextSunday.setDate(date.getDate() + daysUntilNextSunday); + return convertDateToDateString(nextSunday); +} + +export function getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDate) { + const weeks = []; + let weekStartDate = getWeekStartDate(todayString); + let weekEndDate = getWeekEndDate(todayString); + + if (preSelectedDate) { + let preSelectedDateMonthEnd = getMonthEnd(preSelectedDate.replace("-mobile", "")); + let monthEndsByThisWeek = false; + let i = 0; + while (!monthEndsByThisWeek && i < 52) { + if (i > 0) { + weekStartDate = getNextWeekSunday(weekEndDate); + weekEndDate = getWeekEndDate(weekStartDate); + + if ( + (preSelectedDateMonthEnd > weekStartDate && + preSelectedDateMonthEnd < weekEndDate) || + preSelectedDateMonthEnd === weekStartDate || + preSelectedDateMonthEnd === weekEndDate + ) { + weekEndDate = preSelectedDateMonthEnd; + monthEndsByThisWeek = true; + } else if (preSelectedDateMonthEnd < weekEndDate) { + // Additional case: if month ends before this week (but not necessarily during it), + // still cut the loop here, but don't truncate the week. + + monthEndsByThisWeek = true; + } + } + weeks.push({ + weekNum: i + 1, + weekStartDate: weekStartDate, + weekEndDate: weekEndDate, + }); + i++; + } + } else { + for (let i = 0; i < initialViewRowsToShow; i++) { + if (i > 0) { + weekStartDate = getNextWeekSunday(weekEndDate); + weekEndDate = getWeekEndDate(weekStartDate); + } + weeks.push({ + weekNum: i + 1, + weekStartDate: weekStartDate, + weekEndDate: weekEndDate, + }); + } + // If any of these weeks is split between two months, then make them 2 separate "weeks" + // (a week split between two months is considered 2 weeks per business requirements) + const hasSplitWeek = (week) => { + return week.weekStartDate.split("-")[1] !== week.weekEndDate.split("-")[1] + ? true + : false; + }; + const splitWeekIndex = weeks.findIndex(hasSplitWeek); + + if (splitWeekIndex > -1) { + const week1 = []; + const week2 = []; + let switchToWeek2 = false; + + for (let j = 0; j < 7; j++) { + const newDate = convertDateStringToDate(weeks[splitWeekIndex].weekStartDate); + newDate.setDate(newDate.getDate() + j); + if (newDate.getDate() === 1) switchToWeek2 = true; + if (switchToWeek2) { + week2.push(convertDateToDateString(newDate)); + } else { + week1.push(convertDateToDateString(newDate)); + } + } + + const week1EndDate = week1[week1.length - 1]; + const week2StartDate = week2[0]; + + if (week1EndDate < todayString) { + // replace week 1 with week 2 + weeks[splitWeekIndex].weekStartDate = week2StartDate; + } else { + const newWeek = { + weekNum: weeks[splitWeekIndex].weekNum, + weekStartDate: week2StartDate, + weekEndDate: weeks[splitWeekIndex].weekEndDate, + }; + weeks[splitWeekIndex].weekEndDate = week1EndDate; + weeks.splice(splitWeekIndex + 1, 0, newWeek); + weeks.pop(); + weeks.forEach((item, index) => { + if (index > splitWeekIndex) { + item.weekNum = item.weekNum + 1; + } + }); + } + } + } + return weeks; +} diff --git a/src/layouts/schedule/multi-location-modal/multi-location-modal.vue b/src/layouts/schedule/multi-location-modal/multi-location-modal.vue new file mode 100644 index 000000000..85afe5211 --- /dev/null +++ b/src/layouts/schedule/multi-location-modal/multi-location-modal.vue @@ -0,0 +1,358 @@ + + + + diff --git a/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.spec.js b/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.spec.js new file mode 100644 index 000000000..f305d4e2e --- /dev/null +++ b/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.spec.js @@ -0,0 +1,121 @@ +import { mount } from "@vue/test-utils"; +import multiLocationRadio from "./multi-location-radio"; +import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin"; + +describe("multi-location-radio.vue", () => { + it("Should include buttonLabel in html", async () => { + // Arrange + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: mockProps, + }, + }); + + // Act + const outputHtml = wrapper.html(); + + // Assert + expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabel"])); + }); + + it("Should include buttonLabelSubCopy in html", async () => { + // Arrange + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: mockProps, + }, + }); + + // Act + const outputHtml = wrapper.html(); + + // Assert + expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabelSubCopy"])); + }); + it("Should include buttonFooterCopy in html", async () => { + // Arrange + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: mockProps, + }, + }); + + // Act + const outputHtml = wrapper.html(); + + // Assert + expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonFooterCopy"])); + }); + it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when provided with a
  • ...
  • (x5)
buttonBodyCopy", async () => { + // Arrange + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: mockProps, + }, + }); + // Act + const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy); + + // Assert + expect(results.length).toBe(5); + }); + it("Should get strings from getArrayOfListItemsFromRawCmsCopy without
    or
  • tags when provided with a
    • ...
    buttonBodyCopy", async () => { + // Arrange + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: mockProps, + }, + }); + + // Act + const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy); + + // Assert + const fileredResults = results.filter((result) => { + return ( + result.includes("
      ") || + result.includes("
    ") || + result.includes("
  • ") || + result.includes("
  • ") + ); + }); + expect(fileredResults.length).toBe(0); + }); + it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when buttonBodyCopy has 6 total
  • ...
  • , but one is empty ", async () => { + // Arrange + const moddedProps = mockProps; + moddedProps["buttonBodyCopy"] = + "
    • buttonBodyCopy test copy
    • 2
    • 3
    • 4
    • 5
    "; + let { wrapper } = setupMocks({ + mountOptionsMockData: { + propsData: moddedProps, + }, + }); + + // Act + const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy); + + // Assert + expect(results.length).toBe(5); + }); +}); + +const mockProps = { + groupName: "group-name", + buttonLabel: "buttonLabel test copy", + buttonLabelAuxillaryCopy: "buttonLabelAuxillaryCopy test copy", + buttonLabelSubCopy: "buttonLabelSubCopy test copy", + buttonBodyCopy: + "
    • buttonBodyCopy test copy
    • 2
    • 3
    • 4
    • 5
    ", + buttonFooterCopy: "buttonFooterCopy test copy", + buttonAuxillaryCopy: "buttonAuxillaryCopy test copy", +}; + +function setupMocks({ mountOptionsMockData = {} }) { + const wrapper = mount(multiLocationRadio, { + ...mountOptionsMockData, + mixins: [inputButtonWrapperMixin], + }); + + return { wrapper }; +} diff --git a/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.vue b/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.vue new file mode 100644 index 000000000..06ba006ac --- /dev/null +++ b/src/layouts/schedule/multi-location-modal/multi-location-radio/multi-location-radio.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/src/layouts/schedule/schedule.spec.js b/src/layouts/schedule/schedule.spec.js index 7d5cdcec1..9e7952fd0 100644 --- a/src/layouts/schedule/schedule.spec.js +++ b/src/layouts/schedule/schedule.spec.js @@ -57,6 +57,40 @@ jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({ navigateToHeritageFunnel: jest.fn(), })); +jest.mock("@/helpers/debug-log-helper.js", () => ({ + debugLog: jest.fn(), +})); + +const createValidOrderForSchedule = () => ({ + payment: { + isInsurance: false, + insuranceCoverage: { isVerified: false }, + }, + referralNumber: "", + serviceLocation: { + address: "", + address2: "", + city: "", + state: "", + zipCode: "00000", + zipCodeCtu: "000", + appointmentType: null, + provider: {}, + isVehicleProtected: false, + }, + schedule: { + date: "", + routeCode: "", + startTime: "", + endTime: "", + jobMinMinutes: null, + jobMaxMinutes: null, + }, + policy: { isItac: false, isNoComp: false }, + damage: { isRepair: true }, + lineItems: { glassParts: [] }, +}); + describe("schedule.vue", () => { describe("navigation", () => { test("backButtonAction => non-insurance: navigateWithoutSaving called", () => { @@ -149,8 +183,67 @@ describe("schedule.vue", () => { expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled(); }); }); + + describe("arePagePrerequisitesValid", () => { + test("returns true when all prerequisites are valid", () => { + const { wrapper } = setupMocksForArePagePrerequisitesValid(); + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(true); + }); + + test("returns false when service zip info is missing", () => { + const { wrapper } = setupMocksForArePagePrerequisitesValid(); + store.getters.order.serviceLocation.zipCode = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when glass parts or repair info is missing", () => { + const { wrapper } = setupMocksForArePagePrerequisitesValid(); + store.getters.order.damage.isRepair = false; + store.getters.order.lineItems.glassParts = []; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when insurance info is invalid", () => { + const { wrapper } = setupMocksForArePagePrerequisitesValid(); + store.getters.order.payment.isInsurance = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + + test("returns false when supporting items missing for cash order", () => { + const { wrapper } = setupMocksForArePagePrerequisitesValid(); + store.getters.order.payment.isInsurance = false; + store.getters.lineItems.supportingItems = null; + + const result = wrapper.vm.arePagePrerequisitesValid(); + + expect(result).toBe(false); + }); + }); }); +function setupMocksForArePagePrerequisitesValid() { + store.getters = { + ...storeMocked.getters, + order: createValidOrderForSchedule(), + lineItems: { + supportingItems: [], + }, + }; + return setupMocks({ store }); +} + function setupMocks(mountOptionsMockData = {}) { const route = { name: "schedule" }; const defaultMountOptions = { diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index e20c025fb..c13026d36 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -5,7 +5,12 @@ + @confirm-appointment="updateMobileFirstTimeSlotandNavigateForward" /> +
    + :class="[ + isMobileFirstModalOpen || isMultiLocationModalOpen ? 'hidden-background' : '', + ]">
    +
    { - vm.showMobileFirstModal(); + vm.initializeDatePicker().then(async () => { + // vm.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST + await vm.showMultiLocationModal(); vm.hideLoadingModal(); vm.calendarLoadingStatus = "none"; if (!vm.preSelectedDate) vm.setSelectedDateToFirstAvailable(); @@ -679,8 +705,7 @@ export default { isMobileStaticRecalibrationApplicable() { return ( this.displayMSR && - this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE && - (this.isCashItacNoComp || this.mobileFeePart?.isInsurable) + this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE ); }, displayMSR() { @@ -690,6 +715,28 @@ export default { ?.toLowerCase() === "true" ); }, + isMSRFeeNotCoveredByInsurance() { + return ( + this.isMobileSelected && + this.isMobileStaticRecalibrationApplicable && + this.mobileFeeHasPrice && + this.isInsurance && + !this.mobileFeePart?.isInsurable + ); + }, + MSRFeeAlertHeadlineText() { + return this.getCmsContent("AlertMSRNotCoveredByInsuranceWidget", "HeadlineText"); + }, + MSRFeeAlertBodyText() { + const cmsContentText = this.getCmsContent( + "AlertMSRNotCoveredByInsuranceWidget", + "BodyText" + ); + return cmsContentText.replaceAll( + "{custom:mobileFee}", + formatToUSDollar(this.mobileFee) + ); + }, isCashItacNoComp() { return !this.isInsurance || this.isITAC || this.isNoComp; }, @@ -805,13 +852,6 @@ export default { }; }, selectedShopAnswer() { - const toTitleCase = (str) => { - if (!str) return ""; - return str.replace(/\w\S*/g, function (txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - }); - }; - if ( this.selectedProvider && this.selectedProvider.address && @@ -820,18 +860,11 @@ export default { const provider = this.shopProviderData?.shopProviders?.find( (p) => p.providerNumber === this.selectedProvider?.providerNumber ); - const streetAddress = toTitleCase(this.selectedProvider.address.streetAddress); - const city = toTitleCase(this.selectedProvider.address.city); - const state = this.selectedProvider.address.state; - const zipCode = this.selectedProvider.address.zipCode; - const distanceInMiles = provider ? Math.round(provider.distanceInMiles * 2) / 2 : 0; + const distanceInMiles = provider?.distanceInMiles + ? Math.round(provider.distanceInMiles * 2) / 2 + : 0; - return { - city: `${city}`, - distance: `${distanceInMiles} mi`, - address1: `${streetAddress}`, - address2: `${city}, ${state} ${zipCode}`, - }; + return this.getFormattedShopAddress(provider?.address, distanceInMiles); } return []; }, @@ -902,6 +935,15 @@ export default { ? this.$refs.mobileFirstModal?.getIsModalOpen() : false; }, + isMultiLocationModalOpen() { + // Only return false if we're done loading the popup + if (this.isLoadingMultiLocationPopup) { + return true; // Still loading, keep background hidden + } + return this.selectableDatesMobile.days && this.selectableDatesInshop.days + ? this.$refs.multiLocationModal?.getIsModalOpen() + : false; + }, displayWaitList() { if (!this.appointmentType) return false; const firstMobileDateString = @@ -950,86 +992,65 @@ export default { return false; }, hasSelectableDatesLoaded() { - if (this.isMobileSelected) { + if (this.isServiceableMobile) { return this.selectableDatesMobile?.days?.length > 0; } else { return this.selectableDatesInshop?.days?.length > 0; } }, + isShowMobileFirstAppt() { + return experimentMixin.methods.hasSettingEqualTo( + experimentSettings.SHOW_MOBILE_FIRST_APPT, + "true" + ); + }, + isShowMultiLocationPopup() { + return experimentMixin.methods.hasSettingEqualTo( + experimentSettings.SHOW_MULTI_LOCATION_APPT, + "true" + ); + }, + initialViewStartDate() { + return getTodayDateString(); + }, + initialViewEndDate() { + const initialViewWeeks = getInitialViewWeeks( + this.initialViewStartDate, + NUMBER_OF_CALENDAR_ROWS_TO_SHOW_FOR_INITIAL_VIEW, + this.preSelectedDate + ); + return initialViewWeeks[initialViewWeeks.length - 1].weekEndDate; + }, }, methods: { splitCopyOnCMSPlaceHolder, arePagePrerequisitesValid() { - const paymentInfo = store.getters.payment.isInsurance !== null; - const damageInfo = - store.getters.order.damage.isRepair || - (store.getters.order.lineItems?.glassParts != null && - store.getters.order.lineItems.glassParts.length > 0); - const parentAccountNumberInfo = - store.getters.payment.parentAccountNumber !== - applicationConfig.CASH_PARENT_ACCOUNT_NUMBER; - const policyNumberInfo = store.getters.order.policy.policyNumber; - const zipCodeInfo = store.getters.order.serviceLocation.zipCode; - const supportingItemsInfo = store.getters.lineItems.supportingItems; + const order = store.getters.order; + const logQueue = []; + + const serviceZip = hasServiceZipInfo(order, logQueue); + const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue); + const insuranceInfo = hasInsuranceInfo(order, logQueue); // insurance drops the recycle fee on replace orders so it won't be in supportingItems - const insurancePreReqResult = - parentAccountNumberInfo && - policyNumberInfo && - zipCodeInfo && - paymentInfo && - damageInfo; - const cashPreReqResult = - supportingItemsInfo && zipCodeInfo && paymentInfo && damageInfo; + const supportingItemsInfo = store.getters.lineItems?.supportingItems; + const supportingItemsPreReqs = order.payment?.isInsurance + ? true + : !!supportingItemsInfo; - const outputDebugLog = (preReqResult) => { - // prettier-ignore - debugLog("--- schedule.vue pagePrereqs start ---", null, !preReqResult); - debugLog( - "store.getters.payment.isInsurance:", - store.getters.payment?.isInsurance, - !preReqResult - ); - debugLog( - "store.getters.order.damage.isRepair:", - store.getters.order.damage?.isRepair, - !preReqResult - ); - debugLog( - "store.getters.order.lineItems.glassParts:", - store.getters.order.lineItems?.glassParts, - !preReqResult - ); - debugLog( - "store.getters.payment.parentAccountNumber:", - store.getters.payment.parentAccountNumber, - !preReqResult - ); - debugLog( - "store.getters.order.policy.policyNumber:", - store.getters.order.policy.policyNumber, - !preReqResult - ); - debugLog( - "store.getters.order.serviceLocation.zipCode:", - store.getters.order.serviceLocation.zipCode, - !preReqResult - ); + const preReqResult = + serviceZip && insuranceInfo && glassPartsOrRepair && supportingItemsPreReqs; + + logQueue.push(() => { debugLog( "store.getters.lineItems.supportingItems:", - store.getters.lineItems.supportingItems, - !preReqResult + store.getters.lineItems?.supportingItems, + !supportingItemsPreReqs ); - debugLog("--- schedule.vue pagePrereqs end ---", null, !preReqResult); - }; + }); - if (store.getters.payment.isInsurance) { - outputDebugLog(insurancePreReqResult); - return insurancePreReqResult; - } else { - outputDebugLog(cashPreReqResult); - return cashPreReqResult; - } + flushPagePrereqsLogs("schedule.vue", preReqResult, logQueue); + return preReqResult; }, setDataOnLoad(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) { @@ -1241,7 +1262,8 @@ export default { } } this.initializeDatePicker().then(() => { - this.showMobileFirstModal(); + // this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST + this.showMultiLocationModal(); this.hideLoadingModal(); this.calendarLoadingStatus = "none"; this.setSelectedDateToFirstAvailable(); @@ -1275,7 +1297,7 @@ export default { }, async initializeDatePicker() { - this.isShowMobileFirstAppt && this.showLoadingModal(); + this.isShowMultiLocationPopup && this.showLoadingModal(); const includeMobileTimeSlots = this.isServiceableMobile; const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff; @@ -1787,7 +1809,8 @@ export default { this.zipCodeCtu = newZipCode.zipCodeCtu; this.selectedDate = null; this.initializeDatePicker().then(() => { - this.showMobileFirstModal(); + // this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST + this.showMultiLocationModal(); this.hideLoadingModal(); this.calendarLoadingStatus = "none"; this.setSelectedDateToFirstAvailable(); @@ -1842,10 +1865,13 @@ export default { } else { this.appointmentType = null; } - this.setSelectedDateToFirstAvailable(); // v-if on appointmentTypeQuestion ensures dates have been loaded by now + this.setSelectedDateToFirstAvailable(); }, setSelectedDateToFirstAvailable() { - this.selectedDate = this.getFirstAvailableDate(); + if (this.hasSelectableDatesLoaded) { + // ensure dates have been loaded by now + this.selectedDate = this.getFirstAvailableDate(); + } }, showRecalAcknowledgementModal() { if (this.shouldShowRecalAckModal()) { @@ -1899,7 +1925,7 @@ export default { const order = this.$store.getters.order; const isRepair = order.damage.isRepair; - let preSelectedMobileAppointment = null; + let promotedMobileAppointment = null; const todaysDate = getTodayDate(); let firstMobileAMAppt = await this.getFirstAvailableMobileApptByTOD("AM"); let firstMobilePMAppt = await this.getFirstAvailableMobileApptByTOD("PM"); @@ -1997,7 +2023,7 @@ export default { if (this.isShowMobileFirstAppt) { if (pmMobileDays == 0) { // Selects the first available time slot - preSelectedMobileAppointment = + promotedMobileAppointment = numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate ? firstMobileAMAppt : firstMobilePMAppt; @@ -2006,20 +2032,20 @@ export default { numberOfDaysToFirstMobilePMDate <= pmMobileDays ) { // Selects the first available PM time slot - preSelectedMobileAppointment = firstMobilePMAppt; + promotedMobileAppointment = firstMobilePMAppt; } else if ( firstMobilePMDate && numberOfDaysToFirstMobilePMDate >= noPMMobileDays ) { // Selects the first available AM time slot. If none, selects the first available PM time slot - preSelectedMobileAppointment = firstMobileAMAppt + promotedMobileAppointment = firstMobileAMAppt ? firstMobileAMAppt : firstMobilePMAppt; } - if (preSelectedMobileAppointment) { + if (promotedMobileAppointment) { this.$refs.mobileFirstModal.setSelectedAppointment( - preSelectedMobileAppointment + promotedMobileAppointment ); this.$refs.mobileFirstModal.openModal(); } @@ -2027,11 +2053,158 @@ export default { } } }, + async showMultiLocationModal() { + const showMultiLocationAppointment = experimentMixin.methods.getSettingValue( + experimentSettings.SHOW_MULTI_LOCATION_APPT + ); + + if ( + !this.selectedRouteCodeData?.routeCode && + showMultiLocationAppointment?.toLowerCase() === "true" + ) { + let maxDayRangeToShowPmTimeslot = experimentMixin.methods.getSettingValue( + experimentSettings.SHOW_PM_DAYS_MULTI_LOCATION + ); + let maxDayRangeToShowNoPmTimeslot = experimentMixin.methods.getSettingValue( + experimentSettings.SHOW_NO_PM_DAYS_MULTI_LOCATION + ); + let maxDayRangeToShowAnyTimeslot = experimentMixin.methods.getSettingValue( + experimentSettings.SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION + ); + let promotedMobileAppointment = null; + let promotedInshopAppointment = null; + const todaysDate = getTodayDate(); + + // Helper function to calculate days until appointment (handles null/undefined) + const calculateDaysUntilDate = (appointmentObj) => { + const dateString = appointmentObj?.date; + return dateString + ? (new Date(dateString) - todaysDate) / (1000 * 60 * 60 * 24) + : null; + }; + + // Fetch all appointments + const [firstMobileAMAppt, firstMobilePMAppt, firstInshopAppts] = await Promise.all([ + this.getFirstAvailableMobileApptByTOD("AM"), + this.getFirstAvailableMobileApptByTOD("PM"), + this.getFirstAvailableInshopApptsByTOD(["AM", "PM"]), + ]); + + // Extract dates + const firstMobileAMDate = firstMobileAMAppt?.date; + const firstMobilePMDate = firstMobilePMAppt?.date; + const firstInshopAMDate = firstInshopAppts[0]?.date; + const firstInshopPMDate = firstInshopAppts[1]?.date; + + // Calculate days until appointments + const numberOfDaysToFirstMobileAMDate = calculateDaysUntilDate(firstMobileAMAppt); + const numberOfDaysToFirstMobilePMDate = calculateDaysUntilDate(firstMobilePMAppt); + const numberOfDaysToFirstInshopAMDate = calculateDaysUntilDate(firstInshopAppts[0]); + const numberOfDaysToFirstInshopPMDate = calculateDaysUntilDate(firstInshopAppts[1]); + + const shouldExposeMultiLocationModal = () => { + const isMobileAppointmentInDateRange = + (firstMobileAMDate && + numberOfDaysToFirstMobileAMDate <= maxDayRangeToShowAnyTimeslot) || + (firstMobilePMDate && + numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowAnyTimeslot); + const isInshopAppointmentInDateRange = + (firstInshopAMDate && + numberOfDaysToFirstInshopAMDate <= maxDayRangeToShowAnyTimeslot) || + (firstInshopPMDate && + numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowAnyTimeslot); + + return isMobileAppointmentInDateRange && isInshopAppointmentInDateRange; + }; + + if (shouldExposeMultiLocationModal()) { + const multiLocationPopupExperiment = + store.getters.applicationUser.experiments.find( + (e) => e.universeName === experimentUniverses.MULTI_LOCATION_POPUP + ); + const hasExposedMultiLocationPopup = multiLocationPopupExperiment?.isExposed; + + if (!hasExposedMultiLocationPopup && multiLocationPopupExperiment) { + baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.LOG_EXPERIMENT_EXPOSURE_AND_UPDATE_STORE, + { + userId: getUserIdValue(), + deviceId: getDeviceIdValue(), + sessionKey: getSessionKeyValue(), + pageName: "schedule", + experiment: multiLocationPopupExperiment, + }, + "schedule", + false + ); + } + if (this.isShowMultiLocationPopup) { + if (maxDayRangeToShowPmTimeslot == 0) { + // Selects the first available mobile time slot + promotedMobileAppointment = + numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate + ? firstMobileAMAppt + : firstMobilePMAppt; + // Selects the first available inshop time slot + promotedInshopAppointment = + numberOfDaysToFirstInshopAMDate <= numberOfDaysToFirstInshopPMDate + ? firstInshopAppts[0] + : firstInshopAppts[1]; + } else { + if ( + firstMobilePMDate && + numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowPmTimeslot + ) { + // Selects the first available PM time slot + promotedMobileAppointment = firstMobilePMAppt; + } else if ( + firstMobilePMDate && + numberOfDaysToFirstMobilePMDate >= maxDayRangeToShowNoPmTimeslot + ) { + // Selects the first available AM time slot. If none, selects the first available PM time slot + promotedMobileAppointment = firstMobileAMAppt + ? firstMobileAMAppt + : firstMobilePMAppt; + } + + if ( + firstInshopPMDate && + numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowPmTimeslot + ) { + // Selects the first available PM time slot + promotedInshopAppointment = firstInshopAppts[1]; + } else if ( + firstInshopPMDate && + numberOfDaysToFirstInshopPMDate >= maxDayRangeToShowNoPmTimeslot + ) { + // Selects the first available AM time slot. If none, selects the first available PM time slot + promotedInshopAppointment = firstInshopAppts[0] + ? firstInshopAppts[0] + : firstInshopAppts[1]; + } + } + + if (promotedMobileAppointment && promotedInshopAppointment) { + this.$refs.multiLocationModal.setPromotedMobileAppointment( + promotedMobileAppointment + ); + this.$refs.multiLocationModal.setPromotedInshopAppointment( + promotedInshopAppointment + ); + this.$refs.multiLocationModal.openModal(); + } + } + } + } + this.isLoadingMultiLocationPopup = false; + }, + async getFirstAvailableMobileApptByTOD(timeOfDay) { - if (!this.selectableDatesMobile?.days?.length) { + const selectableDays = this.selectableDatesMobile?.days; + if (!selectableDays?.length) { return null; } else { - for (const dateObj of this.selectableDatesMobile.days) { + for (const dateObj of selectableDays) { let matchingTimeSlot = { estimatedServiceMinutes: { minimum: this.estimatedServiceMinutesMinimum, @@ -2053,7 +2226,137 @@ export default { return null; } }, - updateTimeSlotandNavigateForward(timeSlotObj) { + + findMatchingInshopAppt(selectableDays, timeOfDay, provider) { + if (!selectableDays?.length) return null; + for (const dateObj of selectableDays) { + const distanceInMiles = provider?.distanceInMiles + ? Math.round(provider.distanceInMiles * 2) / 2 + : 0; + const providerAddress = this.getFormattedShopAddress( + provider?.address, + distanceInMiles + ); + let matchingTimeSlot = { + estimatedServiceMinutes: { + minimum: this.estimatedServiceMinutesMinimum, + maximum: this.estimatedServiceMinutesMaximum, + }, + timeSlot: null, + date: null, + addressCopy: providerAddress.address1 + ", " + providerAddress.address2, + provider: provider, + }; + const isMatchingApptDay = dateObj.timeSlots.some( + (slot) => + slot.id.includes(timeOfDay) && + (matchingTimeSlot.timeSlot = slot) && + (matchingTimeSlot.date = dateObj.date) + ); + if (isMatchingApptDay && matchingTimeSlot) { + return matchingTimeSlot; + } + } + return null; + }, + + async getFirstAvailableInshopApptsByTOD(timeOfDayArray) { + let matchingApptNearestAM1 = this.findMatchingInshopAppt( + this.selectableDatesInshop?.days, + timeOfDayArray[0], + this.selectedProvider + ); + let matchingApptNearestPM1 = this.findMatchingInshopAppt( + this.selectableDatesInshop?.days, + timeOfDayArray[1], + this.selectedProvider + ); + let matchingApptNearestAM2; + let matchingApptNearestPM2; + let matchingApptNearestAM3; + let matchingApptNearestPM3; + + const providerNearest2 = this.shopProviderData.shopProviders[1]; + const providerNearest3 = this.shopProviderData.shopProviders[2]; + + if (providerNearest2?.distanceInMiles && providerNearest2.distanceInMiles < 25) { + this.calendarLoadingStatus = "more"; + const selectableDaysFromProviderNearest2 = await getScheduleApiResponse({ + startDateString: this.initialViewStartDate, + endDateString: this.initialViewEndDate, + inshopProviderNumber: providerNearest2.providerNumber, + zipCode: this.zipCode, + includeMobileTimeSlots: false, + includeInshopTimeSlots: true, + }); + matchingApptNearestAM2 = this.findMatchingInshopAppt( + selectableDaysFromProviderNearest2?.inshopTimeSlotsData?.days, + "AM", + providerNearest2 + ); + matchingApptNearestPM2 = this.findMatchingInshopAppt( + selectableDaysFromProviderNearest2?.inshopTimeSlotsData?.days, + "PM", + providerNearest2 + ); + } + if (providerNearest3?.distanceInMiles && providerNearest3?.distanceInMiles < 25) { + this.calendarLoadingStatus = "more"; + const selectableDaysFromProviderNearest3 = await getScheduleApiResponse({ + startDateString: this.initialViewStartDate, + endDateString: this.initialViewEndDate, + inshopProviderNumber: providerNearest3.providerNumber, + zipCode: this.zipCode, + includeMobileTimeSlots: false, + includeInshopTimeSlots: true, + }); + matchingApptNearestAM3 = this.findMatchingInshopAppt( + selectableDaysFromProviderNearest3?.inshopTimeSlotsData?.days, + "AM", + providerNearest3 + ); + matchingApptNearestPM3 = this.findMatchingInshopAppt( + selectableDaysFromProviderNearest3?.inshopTimeSlotsData?.days, + "PM", + providerNearest3 + ); + } + this.calendarLoadingStatus = "none"; + + // WHICH OF THE 3 INSHOP APPTS IS THE EARLIEST? + const compareAppointments = (appt1, appt2) => { + // Handle null/undefined appointments + if (!appt1?.date) return 1; // appt1 is later (or invalid) + if (!appt2?.date) return -1; // appt2 is later (or invalid) + + // Compare dates first (YYYY-MM-DD format allows string comparison) + if (appt1.date < appt2.date) return -1; // appt1 is earlier + if (appt1.date > appt2.date) return 1; // appt2 is earlier + + // Dates are equal, compare times (HH:MM format allows string comparison) + const time1 = appt1.timeSlot?.startTime || "23:59"; + const time2 = appt2.timeSlot?.startTime || "23:59"; + + if (time1 < time2) return -1; // appt1 is earlier + if (time1 > time2) return 1; // appt2 is earlier + + return 0; // Completely equal + }; + // Find earliest AM appointment + const preferredApptAM = + [matchingApptNearestAM1, matchingApptNearestAM2, matchingApptNearestAM3] + .filter((appt) => appt?.date) // Remove null/undefined + .sort(compareAppointments)[0] || null; + + // Find earliest PM appointment + const preferredApptPM = + [matchingApptNearestPM1, matchingApptNearestPM2, matchingApptNearestPM3] + .filter((appt) => appt?.date) + .sort(compareAppointments)[0] || null; + + return [preferredApptAM, preferredApptPM]; + }, + updateMobileFirstTimeSlotandNavigateForward(timeSlotObj) { this.selectedMobileFirstAppointment = true; this.appointmentType = AppointmentTypeStrings.MOBILE; this.selectedDate = timeSlotObj.date; @@ -2061,15 +2364,68 @@ export default { this.updateTimeSlot(timeSlotObj); this.forwardButtonAction(); }, + updateMultiLocationTimeSlotandNavigateForward({ selectedTimeSlot, appointmentType }) { + if (!selectedTimeSlot) return; + this.selectedMultiLocationAppointment = true; + this.selectedDate = selectedTimeSlot.date; + if (appointmentType?.toLowerCase() == "mobile") { + this.appointmentType = AppointmentTypeStrings.MOBILE; + this.updateSelectedProvider(); + this.updateTimeSlot(selectedTimeSlot); + } else if (appointmentType?.toLowerCase() == "inshop") { + this.appointmentType = AppointmentTypeStrings.IN_SHOP; + + this.updateSelectedProvider(selectedTimeSlot.provider); + + // UPDATE SELECTED TIME SLOT INFO + this.selectedTimeSlotInfo = { + timeSlot: { + date: selectedTimeSlot.date, + routeCode: selectedTimeSlot.routeCode, + startTime: selectedTimeSlot.timeSlot.startTime, + endTime: selectedTimeSlot.timeSlot.endTime, + jobMaxMinutes: this.estimatedServiceMinutesMaximum.toString(), + jobMinMinutes: this.estimatedServiceMinutesMinimum.toString(), + }, + isPremiumAppointment: selectedTimeSlot.isPremiumAppointment ? true : false, + }; + + // UPDATE APPT TYPE + this.appointmentType = this.getInShopOrDropOffApptType(selectedTimeSlot.routeCode); + } + this.forwardButtonAction(); + }, updateRecalAcknowledgedAndNavigateForward(isAcknowledged) { this.isRecalAcknowledgedForScheduling = isAcknowledged; this.forwardButtonAction(); }, + getFormattedShopAddress(providerAddress, distanceInMiles) { + const toTitleCase = (str) => { + if (!str) return ""; + return str.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + }); + }; + + if (!providerAddress) return {}; + + const streetAddress = toTitleCase(providerAddress.streetAddress); + const city = toTitleCase(providerAddress.city); + const state = providerAddress.state; + const zipCode = providerAddress.zipCode; + + return { + city: city, + distance: `${distanceInMiles} mi`, + address1: streetAddress, + address2: `${city}, ${state} ${zipCode}`, + }; + }, }, watch: { appointmentTypeFromAppointmentTypeQuestion: { handler(newValue, oldValue) { - this.handleAppointmentTypeChange(newValue); // v-if on appointmentTypeQuestion ensures dates have been loaded by now + this.handleAppointmentTypeChange(newValue); }, }, }, @@ -2084,6 +2440,7 @@ export default { textBlock, timeSlotQuestion, mobileFirstModal, + multiLocationModal, recalAckModal, alert, serviceZipModalQuestion, diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index 7b313dbf7..5e128e867 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -200,6 +200,13 @@ export default { } } baseMixin.methods.ResetExternalParamsAndHideModal(); + + //clear any validation errors for external parameter flow + const form = vm.$refs.theForm; + if (form) { + form.setFieldError("DamageLocationQuestion", undefined); + form.setFieldTouched("DamageLocationQuestion", false, false); + } } } else { baseMixin.methods.ResetExternalParamsAndHideModal(); diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 238bed59d..316553548 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -291,6 +291,7 @@ export default { sessionData.isPia = order?.payment?.isPia ?? false; sessionData.piaType = order?.payment?.piaType; sessionData.parentAccountNumber = order?.payment?.parentAccountNumber; + sessionData.billToAccountNumber = order?.payment?.billToAccountNumber; sessionData.settledTenderAmount = order?.settledTenderAmount; sessionData.recalRequired = containsRecalParts(order?.lineItems); sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); diff --git a/src/mixins/base-mixin.js b/src/mixins/base-mixin.js index 813a3189a..4ca139275 100644 --- a/src/mixins/base-mixin.js +++ b/src/mixins/base-mixin.js @@ -209,6 +209,15 @@ export default { userAgent.includes("webOS") ); }, + isAppleBrowser() { + const userAgent = navigator.userAgent; + return ( + userAgent.includes("iPod") || + userAgent.includes("iPad") || + userAgent.includes("iPhone") || + userAgent.includes("Mac") + ); + }, getSubmittedOrder() { return JSON.parse( window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE) diff --git a/src/router/methods/helpers/consume-referral-info.js b/src/router/methods/helpers/consume-referral-info.js index 6393eedc7..c28ed7669 100644 --- a/src/router/methods/helpers/consume-referral-info.js +++ b/src/router/methods/helpers/consume-referral-info.js @@ -15,11 +15,14 @@ export async function consumeReferralQuerystrings() { // If no referral number in querystring, check if heritage funnel updated last. // This would indicate a customer that went to heritage but did not return through the // normal route. ie: left the site in search of a discount and returned without querystrings. + + //or if a referral number is in query string check if heritage funnel updated last + // This would indicate a customer that returned through normal route like from sfa email const didHeritageFunnelUpdateLast = getFunnelCookie()?.DidHeritageFunnelUpdateLast; - if (didHeritageFunnelUpdateLast && !referralNumber) { - referralNumber = getFunnelCookie().ReferralNumber; - parentAccount = getFunnelCookie().ReferralParentAccountNumber; - correlationId = getFunnelCookie().ReferralCorrelationId; + if (didHeritageFunnelUpdateLast) { + referralNumber = referralNumber ?? getFunnelCookie().ReferralNumber; + parentAccount = parentAccount ?? getFunnelCookie().ReferralParentAccountNumber; + correlationId = correlationId ?? getFunnelCookie().ReferralCorrelationId; referralDate = getFunnelCookie().ReferralDate; savedSessionId = getFunnelCookie().SavedSessionId; } diff --git a/src/router/methods/route-logic/vehicle.js b/src/router/methods/route-logic/vehicle.js index 39fa107ab..e7ff41fc0 100644 --- a/src/router/methods/route-logic/vehicle.js +++ b/src/router/methods/route-logic/vehicle.js @@ -3,6 +3,7 @@ import { getBoolFromString } from "@/helpers/boolean-helper"; import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { routeData } from "@/router/constants/routes"; import { isVerifiedInsurance } from "@/router/methods/helpers/requires-verified-redirect"; +import { hasVehicleInfo } from "@/helpers/vehicle-helper"; import store from "@/store"; import { queryStrings } from "@/constants/query-strings"; import baseMixin from "@/mixins/base-mixin"; @@ -33,7 +34,7 @@ export async function vehicleBeforeEnter(to, from) { } } - if (isVerifiedInsurance()) { + if (isVerifiedInsurance() && hasVehicleInfo()) { return { name: routeData.VEHICLE_DAMAGE.name, replace: true, diff --git a/src/store/index.js b/src/store/index.js index 73e3c4cbe..10218892c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1645,6 +1645,7 @@ export const actions = { isPia, piaType, parentAccountNumber, + billToAccountNumber, settledTenderAmount, recalRequired, recalType, @@ -1718,6 +1719,7 @@ export const actions = { totalPrice: totalPrice, userAgent: userAgent, cashPriceSubTotal: cashPriceSubTotal, + billToAccountNumber: billToAccountNumber, }; return globalMethods.callHttpClient({ @@ -2126,14 +2128,14 @@ export const actions = { }, async submitDonationPartToSV2( context, - { payload: { donationAmount, submittedOrder }, pageNameToLog } + { payload: { selectedItemNumber, submittedOrder }, pageNameToLog } ) { const state = JSON.parse( window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE) ); const order = state.order; const payload = { - donationAmount: parseInt(donationAmount), + selectedItemNumber: selectedItemNumber, savedSessionId: state.applicationUser.savedSessionId, paymentInfo: { parentAccountNumber: order.payment.parentAccountNumber, @@ -3138,6 +3140,8 @@ export const actions = { serviceLocationState, serviceLocationZipCode, pricedLineItems, + serviceLocationAddressLine1, + serviceLocationAddressLine2, }, pageNameToLog, } @@ -3171,6 +3175,8 @@ export const actions = { appointmentType: appointmentType, pricedLineItems: lineItemsWithOnlyPriceInfo, serviceLocation: { + addressLine1: appointmentType == "Mobile" ? serviceLocationAddressLine1 : null, + addressLine2: appointmentType == "Mobile" ? serviceLocationAddressLine2 : null, city: appointmentType == "Mobile" ? serviceLocationCity : null, state: appointmentType == "Mobile" ? serviceLocationState : null, zipCode: appointmentType == "Mobile" ? serviceLocationZipCode : null, diff --git a/src/styles/mixins/customMixins.scss b/src/styles/mixins/customMixins.scss index 62abcc82d..4ee56d5f1 100644 --- a/src/styles/mixins/customMixins.scss +++ b/src/styles/mixins/customMixins.scss @@ -20,3 +20,9 @@ } // Usage: @include responsive-font-size-md(mobile-font-size-in-rem, desktop-font-size-in-rem); // Example: @include responsive-font-size-md(0.875rem, 1rem); + +// Filter to use on SVG elements that are background +@mixin blue-filter { + filter: brightness(0) saturate(100%) invert(31%) sepia(68%) saturate(1676%) hue-rotate(186deg) + brightness(93%) contrast(110%); +} diff --git a/src/styles/ux-variables.scss b/src/styles/ux-variables.scss index 76035c6ca..9ad9381a7 100644 --- a/src/styles/ux-variables.scss +++ b/src/styles/ux-variables.scss @@ -127,6 +127,12 @@ $body-color: $gray-600; $font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif; $font-family-monospace: UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +$font-family-sans-serif-semibold: UrbanistSemibold, Arial, Helvetica, sans-serif; +$font-family-sans-serif-bold: UrbanistBold, Arial, Helvetica, sans-serif; +$font-family-sans-serif-extrabold: UrbanistExtraBold, Arial, Helvetica, sans-serif; +$font-family-sans-serif-medium-italic: UrbanistMedium_Italic, Arial, Helvetica, sans-serif; +$font-family-sans-serif-bold-italic: UrbanistBold_Italic, Arial, Helvetica, sans-serif; + // stylelint-enable value-keyword-case $font-family-base: $font-family-sans-serif; $font-family-code: $font-family-monospace; diff --git a/src/ux-components/alert/alert.vue b/src/ux-components/alert/alert.vue index cccab36f0..deb7bca8b 100644 --- a/src/ux-components/alert/alert.vue +++ b/src/ux-components/alert/alert.vue @@ -7,8 +7,18 @@ isDismissible ? 'alert-dismissible' : '', this.alertClass, this.cssClassNameForCmsWidget, + this.hasBorder ? 'bordered' : '', ]"> -

    {{ alertHeadline }}

    +

    + Info Icon + Warning Icon + {{ alertHeadline }} +

    +