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/HomePage.ts b/playwright-tests/pages/HomePage.ts index e2db5ac26..52ceca875 100644 --- a/playwright-tests/pages/HomePage.ts +++ b/playwright-tests/pages/HomePage.ts @@ -20,9 +20,8 @@ export class HomePage extends BasePage { readonly paymentOptionDropdown: Locator; readonly viewQuoteButton: Locator; - //Zip Entry - readonly enterServiceZipTextBox: Locator; - readonly zipEntryLetsGetStartedButton: Locator; + //Zip Entry + readonly widgetZipEntryField: Locator; readonly getQuoteAndScheduleButton: Locator; url = process.env['BASE_URL']!; @@ -31,7 +30,7 @@ export class HomePage extends BasePage { super(page); this.page = page; - this.letsGetStartedButton = this.page.locator('#zipCodeTextboxButton'); + this.letsGetStartedButton = this.page.getByRole('button', { name: 'Let\'s get started' }); this.cusmodalPopup = this.page.locator('#Cusmodalpopup'); this.closePopupButton = this.page.getByRole('button', { name: '×' }); @@ -47,8 +46,7 @@ export class HomePage extends BasePage { this.viewQuoteButton = this.page.locator('#ctaSubmit'); //Zip Entry - this.enterServiceZipTextBox = this.page.locator('#zipCodeTextbox'); - this.zipEntryLetsGetStartedButton = this.page.locator('#zipCodeTextbox'); + this.widgetZipEntryField = this.page.getByRole('textbox', { name: 'Enter service ZIP code' }); this.getQuoteAndScheduleButton = this.page.getByLabel('main').getByRole('link', { name: 'Get quote + schedule' }); } @@ -63,7 +61,7 @@ export class HomePage extends BasePage { async letsGetStarted(zip: string, enterFunnelWithZip: boolean) { if (enterFunnelWithZip) { - await this.zipEntryLetsGetStartedButton.fill(zip); + await this.widgetZipEntryField.fill(zip); /* await this.letsGetStartedButton.evaluate((element, zip) => { const currentHref = element.getAttribute('href') || ''; element.setAttribute('href', `${currentHref}?zipCode=${zip}`); 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/constants/analytics.js b/src/constants/analytics.js index 51b0c24b6..ff93e935a 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -43,6 +43,7 @@ const GaLabels = { ADDRESS_LOOKUP: "Address_Look_up", YES: "yes", NO: "no", + GLASS_CASH_QUOTE: "GlassCashQuote", }; const ValueToLogTypes = { diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index d25fce6a3..1a4cc4677 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -180,10 +180,6 @@ const endpoints = { url: "/analytics/api/v1/analytics/log-part-questions", method: "POST", }, - LogDigitalConsumer: { - url: "/analytics/api/v1/analytics/digitalconsumer-log", - method: "POST", - }, LogFmgSessionData: { url: "/analytics/api/v1/analytics/digitalconsumer-session-logging", method: "POST", diff --git a/src/constants/experiments.js b/src/constants/experiments.js index f8691fc46..97e049df0 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -19,12 +19,11 @@ const experimentSettings = { PIA_INSURANCE: "DisplayPIAInsurance", SUBMIT_ORDER_ENABLE_PIA: "SubmitOrder_Enable_PIA", IS_EMAIL_OPTIONAL: "isEmailOptional", - SERVICE_PACKAGE_DISCOUNT: "OfferServicePackageDiscount", - PROMO_ON_PACKAGE: "Offer_Promo_On_Pkg", RECAL_PRICE_REMOVE: "RecalPriceRemove", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold", DISPLAY_MSR: "DisplayMSR", + ENABLE_MSR_SPLIT_PAY: "EnableMSRSplitPay", DISPLAY_WAITLIST: "DisplayWaitlist2.0", WAITLIST_THRESHOLD_DAYS: "Waitlist_Threshold_Days", PRICING_BY_DAY: "DisplayPricingByDay", diff --git a/src/constants/part-type-strings.js b/src/constants/part-type-strings.js index 0118c0b2c..7f62088d5 100644 --- a/src/constants/part-type-strings.js +++ b/src/constants/part-type-strings.js @@ -9,7 +9,6 @@ const partTypeStrings = { MOBILE_FEE: "MOBILE FEE", REPAIR_FEE: "REPAIR FEE", EARLY_BIRD: "EARLY BIRD", - SERVICE_PACKAGE_DISCOUNT: "SERVICE PACKAGE DISCOUNT", QUOTE_PAGE_DISCOUNT: "QUOTE PAGE DISCOUNT", DONATION: "DONATION", WINDSHIELD: "WINDSHIELD", diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 3afd4df24..6dac6daa5 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -63,7 +63,6 @@ const storeActions = { LOG_CUSTOM_EVENT: "logCustomEvent", INITIALIZE_SESSION: "initializeSession", LOG_PART_QUESTIONS: "logPartQuestions", - LOG_DIGITALCONSUMER: "logDigitalConsumer", LOG_FMG_SESSION_DATA: "logFmgSessionData", // DEPENDENCY MUTATIONS @@ -114,6 +113,7 @@ const storeActions = { SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "saveIsRecalAcknowledgedForScheduling", SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected", SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable", + SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance", CREATE_SUBMITTED_STATE: "createSubmittedState", RESET_SUBMITTED_STATE: "resetSubmittedState", @@ -123,6 +123,8 @@ const storeActions = { SAVE_LOGGING_OPTION: "saveLoggingOption", UPDATE_HAS_TRIGGERED_ERROR: "updateHasTriggeredError", + GET_VALID_IDEMPOTENCY_KEY: "getValidIdempotencyKey", + CORRECT_IDEMPOTENCY_KEY_EXPIRY: "correctIdempotencyKeyExpiry", }; export { storeActions }; diff --git a/src/constants/store-mutations.js b/src/constants/store-mutations.js index 9b4b3719c..b1344dcb5 100644 --- a/src/constants/store-mutations.js +++ b/src/constants/store-mutations.js @@ -72,6 +72,7 @@ const storeMutations = { UPDATE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "updateIsRecalAcknowledgedForScheduling", UPDATE_IS_OEM_GLASS_SELECTED: "updateIsOemGlassSelected", UPDATE_IS_MSR_FEE_APPLICABLE: "updateIsMSRFeeApplicable", + UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE: "updateIsMSRFeeCoveredByInsurance", UPDATE_CASH_PRICE_SUBTOTAL: "updateCashPriceSubTotal", // EVENT BUS MUTATIONS @@ -98,6 +99,7 @@ const storeMutations = { UPDATE_LAST_PAGE_VISITED: "updateLastPageVisited", UPDATE_LOGGING_OPTION: "updateLoggingOption", UPDATE_HAS_TRIGGERED_ERROR: "updateHasTriggeredError", + UPDATE_IDEMPOTENCY_KEY: "updateIdempotencyKey", // EXPERIMENT MUTATIONS UPDATE_EXPERIMENTS: "updateExperiments", diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index e890e53cc..f4626caa5 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -77,7 +77,7 @@ i % 2 == 0 ? 'even' : 'odd', cartItem.isRemovable ? 'removable-cart-item' : '', ]"> - {{ cartItem.name }} + {{ cartItem.name }} + + {{ msrFeeCartItem.name }} + + Info Icon + + +
+
@@ -162,6 +179,7 @@ import textLink from "@/ux-components/text-link/text-link"; import textBlock from "@/digital-components/text-block/text-block"; import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal"; import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question"; +import removeMsrFeeModal from "./remove-msr-fee-modal/remove-msr-fee-modal.vue"; // Mixins import baseMixin from "@/mixins/base-mixin.js"; @@ -183,6 +201,7 @@ import { getSalesTax, getAmountDueWithDonation, } from "@/helpers/pricing-helper.js"; +import { formatToUSDollar } from "@/helpers/cms-content-helper"; // Constants import { partTypeStrings } from "@/constants/part-type-strings"; @@ -191,6 +210,7 @@ import { cartItemTypes } from "@/constants/cart-item-types"; import { coverageStatus, cartItemTypesCoveredByInsurance } from "@/constants/insurance"; import { experimentSettings } from "@/constants/experiments"; import { quotePageDiscountTable } from "@/constants/quote-page-discounts"; +import { partNumberStrings } from "@/constants/part-number-strings"; export default { name: "cart", @@ -203,6 +223,7 @@ export default { pageName: String, allowItemRemoval: Boolean, recyclingModalCmsWidgetName: String, + msrModalCmsWidgetName: String, showAsPaid: Boolean, isInsurance: Boolean, insuranceDeductible: Number, @@ -214,6 +235,7 @@ export default { isExpandedOnLoad: Boolean, isCollapsible: { type: Boolean, default: true }, isMSRFeeApplicable: Boolean, + IsMSRFeeCoveredByInsurance: Boolean, donationCartItem: Object, }, data() { @@ -311,6 +333,10 @@ export default { }, async removeItem(cartItemType, category) { + if (cartItemType == cartItemTypes.MOBILE_FEE && this.isMSRFeeApplicable) { + this.$refs.removeMsrFeeModal.openModal(); + return false; + } this.lineItems[category] = this.lineItems[category].filter( (lineItemsToKeep) => lineItemsToKeep.cartItemType != cartItemType ); @@ -375,6 +401,12 @@ export default { } return []; }, + shouldShowInfoIcon(cartItem) { + return cartItem == this.recycleFeeCartItem || cartItem == this.msrFeeCartItem; + }, + switchToInshop() { + this.$emit("switchToInshop"); + }, }, computed: { isCartReadyToLoad() { @@ -490,6 +522,10 @@ export default { cartItems.push(this.mobileFeeCartItem); } + if (this.msrFeeCartItem) { + cartItems.push(this.msrFeeCartItem); + } + if (this.quotePageDiscountCartItem) { cartItems.push(this.quotePageDiscountCartItem); } @@ -892,7 +928,9 @@ export default { let cartItem = null; let showMobileFeeCartItem = false; const mobileFeeLineItem = this.supportingItems.find( - (lineItem) => lineItem.partType == partTypeStrings.MOBILE_FEE + (lineItem) => + lineItem.partType == partTypeStrings.MOBILE_FEE && + lineItem.partNumber == partTypeStrings.MOBILE_FEE ); var mobileTotal = @@ -929,6 +967,68 @@ export default { } return cartItem; }, + msrFeeCartItemName() { + return this.getCmsContent("MSRFeeTextWidget", "Text"); + }, + getMsrFeeLineItem() { + return this.supportingItems.find( + (lineItem) => + lineItem.partType == partTypeStrings.MOBILE_FEE && + lineItem.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE + ); + }, + msrFeeCartItem() { + let cartItem = null; + const msrFeeLineItem = this.getMsrFeeLineItem; + const shouldCreateMsrFeeCartItem = + this.isMSRFeeApplicable && + this.isInsurance && + !this.IsMSRFeeCoveredByInsurance && + msrFeeLineItem && + this.msrFeeAmount > 0; + + if (shouldCreateMsrFeeCartItem) { + cartItem = { + name: this.msrFeeCartItemName, + category: cartItemCategories.SUPPORTING_ITEMS, + cartItemType: cartItemTypes.MOBILE_FEE, + isDisplayed: this.isInsurance, + isRemovable: true, + subTotal: 0, + salesTax: 0, + lineItems: [], + isCoveredByInsurance: this.IsMSRFeeCoveredByInsurance, + }; + msrFeeLineItem.cartItemType = cartItem.cartItemType; + cartItem.lineItems.push(msrFeeLineItem); + cartItem.subTotal += this.msrFeeAmount; + cartItem.salesTax += msrFeeLineItem.salesTax ?? 0; + } + return cartItem; + }, + msrFeeAmount() { + const msrFeeLineItem = this.getMsrFeeLineItem; + if (!msrFeeLineItem) { + return 0; + } + return ( + msrFeeLineItem.laborAmount + msrFeeLineItem.sellingPrice + msrFeeLineItem.kitPrice + ); + }, + msrModalTextBlock() { + let cmsContentText = this.getCmsContent("MSRModal", "BodyText"); + if (cmsContentText) { + cmsContentText = cmsContentText.replaceAll( + "{custom:mobileFee}", + formatToUSDollar(this.msrFeeAmount) + ); + cmsContentText = cmsContentText.replaceAll( + "{custom:submittedOrder.policy.insuranceCompanyName}", + this.insuranceCompanyName + ); + } + return cmsContentText; + }, quotePageDiscountCartItemName() { return this.getCmsContent("ServicePackageDiscountTextWidget", "Text"); }, @@ -1189,6 +1289,7 @@ export default { textLink, contentGroupModal, promoModalQuestion, + removeMsrFeeModal, }, }; @@ -1289,6 +1390,10 @@ export default { order: 3; margin-right: 100%; text-decoration: none; + + &#msr-fee-cartItem { + margin: 0 0 0 0.2rem; + } } } .package-type, diff --git a/src/fmg-components/cart/remove-msr-fee-modal/remove-msr-fee-modal.vue b/src/fmg-components/cart/remove-msr-fee-modal/remove-msr-fee-modal.vue new file mode 100644 index 000000000..6c2cd1fb6 --- /dev/null +++ b/src/fmg-components/cart/remove-msr-fee-modal/remove-msr-fee-modal.vue @@ -0,0 +1,153 @@ + + + + diff --git a/src/fmg-components/content-group-modal/content-group-modal.vue b/src/fmg-components/content-group-modal/content-group-modal.vue index 25c50b2df..e4a5241c4 100644 --- a/src/fmg-components/content-group-modal/content-group-modal.vue +++ b/src/fmg-components/content-group-modal/content-group-modal.vue @@ -26,6 +26,7 @@ export default { name: "content-group-modal", props: { cmsWidgetName: String, + customBodyText: String, footerButtonActionName: { type: String, default: null, @@ -43,6 +44,9 @@ export default { return this.getCmsContent(this.cmsWidgetName, "SubheaderText"); }, ModalBodyText() { + if (this.customBodyText) { + return this.customBodyText; + } return this.getCmsContent(this.cmsWidgetName, "BodyText"); }, ModalSubBodyText() { diff --git a/src/global-methods.js b/src/global-methods.js index 4f1dc601b..f408230f8 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -102,7 +102,6 @@ export default { }, (error) => { if ( - endpoint.toLowerCase().includes(endpoints.LogDigitalConsumer.url) || endpoint.toLowerCase().includes(endpoints.LogFmgSessionData.url) || endpoint.toLowerCase().includes(endpoints.LogPageView.url) || endpoint.toLowerCase().includes(endpoints.LogCustomEvent.url) || 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/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/pricing-helper.js b/src/helpers/pricing-helper.js index 68de04515..9564b5c1f 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -2,6 +2,7 @@ import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import baseMixin from "@/mixins/base-mixin.js"; import { deepClone } from "@/helpers/object-helper"; +import { partNumberStrings } from "@/constants/part-number-strings"; export function getDisplayAmountDue(lineItemsObject, includeTax = true) { return getAmountDue(lineItemsObject, includeTax).toLocaleString("en-US", { @@ -32,6 +33,10 @@ export function getAmountDue(lineItemsObject, includeTax = true) { if (store.getters.coverageIsVerified && !order.policy.isNoComp && !order.policy.isItac) { amountDue = order.policy.currentDeductible; + + if (order.isMSRFeeApplicable && !order.isMSRFeeCoveredByInsurance) { + amountDue += getMSRFeePartPrice(lineItemsObject?.supportingItems, includeTax); + } } if (lineItemsObject?.vaps) { @@ -100,6 +105,20 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) { return pricingResults[0]; } +export function getMSRFeePartPrice(supportingItems, includeTax) { + let msrFeePrice = 0; + const msrFeeLineItem = supportingItems?.find( + (lineItem) => lineItem.partNumber === partNumberStrings.MOBILE_STATIC_RECAL_FEE + ); + if (msrFeeLineItem) { + msrFeePrice = baseMixin?.methods?.getTotalPriceOfAllLineItemsAndChildParts( + [msrFeeLineItem], + includeTax + ); + } + return msrFeePrice; +} + export function addPricesToLineItems(lineItems, pricingLineItems) { lineItems.forEach((lineItem) => { const lineItemIndex = pricingLineItems.findIndex( diff --git a/src/layouts/confirmation/confirmation.vue b/src/layouts/confirmation/confirmation.vue index fa0e53425..e26c5f9b1 100644 --- a/src/layouts/confirmation/confirmation.vue +++ b/src/layouts/confirmation/confirmation.vue @@ -62,6 +62,7 @@ :showAsPaid="isPia" servicePackageOptionsCmsName="ServicePackageTitle" recyclingModalCmsWidgetName="RecycleModal" + msrModalCmsWidgetName="MSRModal" :isInsurance="isInsurance" :insuranceDeductible="currentDeductible" :insuranceCompanyName="insuranceCompanyName" @@ -70,7 +71,8 @@ :isItac="isItac" :isNoComp="isNoComp" :isExpandedOnLoad="false" - :isMSRFeeApplicable="isMSRFeeApplicable" /> + :isMSRFeeApplicable="isMSRFeeApplicable" + :IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance" />
@@ -419,6 +421,9 @@ export default { isMSRFeeApplicable() { return this.submittedOrder?.isMSRFeeApplicable; }, + isMSRFeeCoveredByInsurance() { + return this.submittedOrder?.isMSRFeeCoveredByInsurance; + }, isInsurance() { return this.submittedOrder?.payment?.isInsurance; }, diff --git a/src/layouts/estimate/estimate.vue b/src/layouts/estimate/estimate.vue index 60d050d73..36e27127a 100644 --- a/src/layouts/estimate/estimate.vue +++ b/src/layouts/estimate/estimate.vue @@ -62,6 +62,7 @@ import baseMixin from "@/mixins/base-mixin.js"; import { queryStrings } from "@/constants/query-strings"; import { nextTick } from "vue"; import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash"; +import { showFmgLoadingModal } from "@/helpers/loading-modal-helper"; // Define Validation Rules defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); @@ -169,7 +170,7 @@ export default { baseMixin.methods.ResetExternalParamsAndHideModal(); } } else { - baseMixin.methods.ResetExternalParamsAndHideModal(); + showFmgLoadingModal(false); } } else { baseMixin.methods.ResetExternalParamsAndHideModal(); 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..bcb827623 100644 --- a/src/layouts/payment-adyen/payment-adyen.vue +++ b/src/layouts/payment-adyen/payment-adyen.vue @@ -29,10 +29,12 @@ v-model="lineItems" servicePackageOptionsCmsName="ServicePackageTitle" recyclingModalCmsWidgetName="RecycleModal" + msrModalCmsWidgetName="MSRModal" :isInsurance="isInsurance" :insuranceDeductible="currentDeductible" :insuranceCompanyName="insuranceCompanyName" :showInsuranceCoverageAs="showInsuranceCoverageAs" + :isMSRFeeApplicable="isMSRFeeApplicable" :isItac="isItac" :isNoComp="isNoComp" :isCollapsible="false" /> @@ -83,7 +85,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,117 +146,26 @@ 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() { console.log(`Price = ${this.amountDue}`); console.log(`Adyen Price = ${this.adyenPriceTotal}`); - const requestBody = this.getAdyenInitRequestInfo(); + const requestBody = await this.getAdyenInitRequestInfo(); console.log(`Calling with:`); console.log(requestBody); @@ -340,6 +259,7 @@ export default { this.dropinComponent = dropin; dropin.mount("#adyen-container"); + await this.dispatchStoreAction(storeActions.CORRECT_IDEMPOTENCY_KEY_EXPIRY, expiryTime); }, async handleCompletedPayment(result) { @@ -521,19 +441,13 @@ export default { }, // Now a Method so it is always freshly called and not cached. - getIdempotencyKey() { - const currentDateTime = new Date(); - const currentHour = currentDateTime.getUTCHours(); - const currentDate = currentDateTime.getUTCDate(); - const id = this?.$store?.getters?.order?.referralCorrelationId; - const system = this.sourceSystem; - const total = this.adyenPriceTotal; - - return `${id}-${system}-${currentDate}-${currentHour}-${total}`; + async getIdempotencyKey() { + return await this.dispatchStoreAction(storeActions.GET_VALID_IDEMPOTENCY_KEY); }, // Now a Method so it is always freshly called and not cached. - getAdyenInitRequestInfo() { + async getAdyenInitRequestInfo() { + const key = await this.getIdempotencyKey(); return { sourceSystem: this.sourceSystem, referralSequenceNumber: this.$store.getters.order.referralSequenceNumber, @@ -547,10 +461,9 @@ export default { stateOrProvince: this.locationInfo.state, returnUrl: applicationConfig.PIA_ADYEN_RETURN_URL, email: this.$store.getters.order.customer.emailAddress, - IP: "127.0.0.1", // TODO firstName: this.$store.getters.order.customer.firstName, lastName: this.$store.getters.order.customer.lastName, - idempotencyKey: this.getIdempotencyKey(), + idempotencyKey: key, }; }, }, @@ -657,6 +570,9 @@ export default { insuranceCompanyName() { return this.$store.getters.policy.insuranceCompanyName; }, + isMSRFeeApplicable() { + return this.$store.getters.order.isMSRFeeApplicable; + }, lineItems() { return deepClone(this.$store.getters.lineItems); }, diff --git a/src/layouts/payment-method/payment-method.spec.js b/src/layouts/payment-method/payment-method.spec.js index 1bc123d3f..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,35 +179,7 @@ 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: [], }, diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index d333b4bbf..e5b08bdbc 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -26,6 +26,7 @@ pageName="payment-method" servicePackageOptionsCmsName="ServicePackageTitle" recyclingModalCmsWidgetName="RecycleModal" + msrModalCmsWidgetName="MSRModal" :isInsurance="isInsurance" :insuranceDeductible="currentDeductible" :insuranceCompanyName="insuranceCompanyName" @@ -35,7 +36,9 @@ :isNoComp="isNoComp" :isExpandedOnLoad="false" :isMSRFeeApplicable="isMSRFeeApplicable" - @itemRemoved="evaluatePromosAndTaxItemsOnOrder" /> + :IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance" + @itemRemoved="evaluatePromosAndTaxItemsOnOrder" + @switchToInshop="navigateToSchedulePage" /> { - 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; @@ -672,6 +611,13 @@ export default { openModal(modalName) { this.$refs[modalName].openModal(); }, + async navigateToSchedulePage() { + this.$refs.loadingModal.showModal(); + this.$router.navigateWithSaving( + this.navigationScenarios.CLICKED_SWITCH_TO_INSHOP, + this.pageName + ); + }, }, computed: { AppointmentType() { @@ -747,6 +693,9 @@ export default { isMSRFeeApplicable() { return this.$store.getters.order.isMSRFeeApplicable; }, + isMSRFeeCoveredByInsurance() { + return this.$store.getters.order.isMSRFeeCoveredByInsurance; + }, isInsurance() { return this.hasSubmittedOrder() ? this.getSubmittedOrder()?.payment.isInsurance 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..739376d94 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -47,10 +47,12 @@ v-model="lineItems" servicePackageOptionsCmsName="ServicePackageTitle" recyclingModalCmsWidgetName="RecycleModal" + msrModalCmsWidgetName="MSRModal" :isInsurance="isInsurance" :insuranceDeductible="currentDeductible" :insuranceCompanyName="insuranceCompanyName" :showInsuranceCoverageAs="showInsuranceCoverageAs" + :isMSRFeeApplicable="isMSRFeeApplicable" :isItac="isItac" :isNoComp="isNoComp" :isCollapsible="false" /> @@ -235,7 +237,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 +244,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"; @@ -402,6 +412,9 @@ export default { insuranceCompanyName() { return this.$store.getters.policy.insuranceCompanyName; }, + isMSRFeeApplicable() { + return this.$store.getters.order.isMSRFeeApplicable; + }, isPaypal() { if (this.paymentType == "pp") { return true; @@ -435,110 +448,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..8042b8f24 100644 --- a/src/layouts/quote/quote.spec.js +++ b/src/layouts/quote/quote.spec.js @@ -100,9 +100,6 @@ const mockExperimentSettings = experimentSettings; jest.mock("@/mixins/experiment-mixin.js", () => ({ methods: { getSettingValue(settingName) { - if (settingName === mockExperimentSettings.SERVICE_PACKAGE_DISCOUNT) { - return true; - } if ( settingName === mockExperimentSettings.INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL ) { @@ -295,6 +292,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 = { @@ -326,9 +364,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; const { wrapper } = setupMocks({}); @@ -350,59 +385,6 @@ describe("quote.vue", () => { // This should have its own test //expect(vm.isInsuranceSelected !== null).toBe(true); }); - test("Returns true if service package discount setting is true", async () => { - //Arrange - store.getters = { - lineItems: { - glassParts: ["item", "item2"], - }, - pageData: jest.fn((page) => { - if (page === "quote") { - return { saveProgressPopupSkipped: true }; - } - return {}; - }), - applicationUser: { - experiments: [], - }, - order: { - lineItems: { - glassParts: ["item", "item2"], - }, - payment: {}, - customer: { - emailAddress: "test@test.com", - }, - serviceLocation: { - zipCode: "12345", - zipCodeCtu: "value", - }, - }, - vehicle: { - cardId: "123", - }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, - }; - const { wrapper } = setupMocks({}); - - wrapper.vm.$route = { query: { isInsurance: "false" } }; - - const isServicePackageDiscount = experimentMixin.methods.getSettingValue( - experimentSettings.SERVICE_PACKAGE_DISCOUNT - ); - - //Act - await quote.beforeRouteEnter.call( - wrapper.vm, - { query: { fmgPage: "quote" } }, - undefined, - (c) => c(wrapper.vm) - ); - //Assert - expect(isServicePackageDiscount).toBe(true); - }); test("should default to insurance if query param 'isInsurance' is true", async () => { //Arrange store.getters = { @@ -435,9 +417,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; const { wrapper } = setupMocks({}); wrapper.vm.$route = { query: { isInsurance: "true" } }; @@ -485,9 +464,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; mockTierOnePrice = 200; const { wrapper } = setupMocks({}); @@ -536,9 +512,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; const { wrapper } = setupMocks({}); // Ensure that query param isn't overriding selection @@ -587,9 +560,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; const { wrapper } = setupMocks({}); // Ensure that query param isn't overriding selection @@ -639,9 +609,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; mockTierOnePrice = 200; const { wrapper } = setupMocks({}); @@ -693,9 +660,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; mockTierOnePrice = 505; const { wrapper } = setupMocks({}); @@ -745,9 +709,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; mockTierOnePrice = 505; const { wrapper } = setupMocks({}); @@ -798,9 +759,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; const { wrapper } = setupMocks({}); wrapper.vm.$route = { query: null }; @@ -921,9 +879,6 @@ describe("quote.vue", () => { vehicle: { cardId: "123", }, - experimentSettings: { - settingName: "SERVICE_PACKAGE_DISCOUNT", - }, }; // Set up the component diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 0bd095b77..106c04bfa 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -183,8 +183,14 @@ 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"; +import { GaLabels } from "@/constants/analytics"; defineRule("option-required", required(errorMessages.OPTION_REQUIRED)); @@ -584,12 +590,6 @@ export default { } // there are no active or inactive external parameters; use internal threshold if (internalThreshold) thresholdToUse = internalThreshold; - - vm.isInsuranceSelected = getIsInsuranceSelectedValue( - vm.availableLineItems, - thresholdToUse - ); - baseMixin.methods.ResetExternalParamsAndHideModal(); } else { // user came from an external source, however, the externalParms may have been reset on service-zip, property-questions, etc... if (getBoolFromString(store.getters.externalParameterQuote?.isInsurance)) { @@ -607,8 +607,6 @@ export default { ) { await vm.skip(insuranceSelection, servicePackage); } - - baseMixin.methods.ResetExternalParamsAndHideModal(); } else { if (externalThreshold) thresholdToUse = externalThreshold; @@ -616,15 +614,17 @@ export default { { debugLog("quote.vue tab select NOT EXTERNAL"); } - - vm.isInsuranceSelected = getIsInsuranceSelectedValue( - vm.availableLineItems, - thresholdToUse - ); - baseMixin.methods.ResetExternalParamsAndHideModal(); } } + if (vm.isInsuranceSelected == null) { + vm.isInsuranceSelected = getIsInsuranceSelectedValue( + vm.availableLineItems, + thresholdToUse + ); + } + baseMixin.methods.ResetExternalParamsAndHideModal(); + if (vm.skipToInsurance && !vm.showSaveProgressPopup) { // skip ahead only if no email popup. await vm.skip(true, externalParamPackageLabels.GLASS_ONLY); @@ -728,28 +728,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) { @@ -960,8 +959,7 @@ export default { priceLabel = price.toFixed(2); } } - const subTotalLabel = this.Variables?.CASH_SUBTOTAL; - this.pushVariableToDataLayer?.({ [subTotalLabel]: priceLabel }); + this.pushVariableToDataLayer?.({ [this.GaLabels.GLASS_CASH_QUOTE]: priceLabel }); }); }, async skip(insuranceSelection, packageSelection) { 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 b3e4742e7..f87faeec8 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 @@ -31,14 +31,10 @@ const mockExperimentSettings = experimentSettings; jest.mock("@/mixins/experiment-mixin.js", () => ({ methods: { getSettingValue(settingName) { - if (settingName === mockExperimentSettings.PROMO_ON_PACKAGE) { - return true; - } + return false; }, hasSetting(settingName) { - if (settingName === mockExperimentSettings.PROMO_ON_PACKAGE) { - return false; - } + return false; }, hasSettingEqualTo(settingName, settingValue) { return false; 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 9aca972bf..8068ac6c1 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -81,6 +81,16 @@ cmsWidgetName="AlertNoShopsWidget" v-if="displayNoShopsAlert" alertClass="alert-warning" /> +
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) { @@ -1177,6 +1183,13 @@ export default { isMSRFeeApplicable ); }, + updateAndSaveIsMSRFeeCoveredByInsurance() { + const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable; + this.dispatchStoreAction( + this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE, + isMSRFeeCoveredByInsurance + ); + }, updateAndSaveSupportingItems() { let supportingItems = store.getters.lineItems.supportingItems; let shouldSaveSupportingItems = false; @@ -1532,6 +1545,7 @@ export default { this.updateAndSaveSupportingItems(); this.updateAndSaveIsMSRFeeApplicable(); + this.updateAndSaveIsMSRFeeCoveredByInsurance(); this.updateSupportingItems(); @@ -2058,14 +2072,7 @@ export default { } }, async showMultiLocationModal() { - const showMultiLocationAppointment = experimentMixin.methods.getSettingValue( - experimentSettings.SHOW_MULTI_LOCATION_APPT - ); - - if ( - !this.selectedRouteCodeData?.routeCode && - showMultiLocationAppointment?.toLowerCase() === "true" - ) { + if (!this.selectedRouteCodeData?.routeCode) { let maxDayRangeToShowPmTimeslot = experimentMixin.methods.getSettingValue( experimentSettings.SHOW_PM_DAYS_MULTI_LOCATION ); diff --git a/src/layouts/service-zip/service-zip.vue b/src/layouts/service-zip/service-zip.vue index ab99adc6f..c82a0a0e3 100644 --- a/src/layouts/service-zip/service-zip.vue +++ b/src/layouts/service-zip/service-zip.vue @@ -80,6 +80,7 @@ import { saveSession } from "@/helpers/heritage-integration/order-helper.js"; import baseMixin from "@/mixins/base-mixin.js"; import { queryStrings } from "@/constants/query-strings"; import { routeData } from "@/router/constants/routes"; +import { showFmgLoadingModal } from "@/helpers/loading-modal-helper"; // Define Validation Rules defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); @@ -137,6 +138,13 @@ export default { }, false ); + vm.serviceZipCode = store.getters.externalParameterServiceZip.zipCode; + const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm); + if (isValid) { + showFmgLoadingModal(true); + vm.forwardButtonAction(); + return; + } } if (store.getters.externalParameterCustomer?.phoneNumber) { diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue index 5e128e867..abad0da7d 100644 --- a/src/layouts/vehicle-damage/vehicle-damage.vue +++ b/src/layouts/vehicle-damage/vehicle-damage.vue @@ -93,6 +93,7 @@ import { errorMessages } from "@/constants/error-messages"; import { damageLocationsCms } from "@/constants/damage-locations-cms.js"; import { damageLocationsSelected } from "@/constants/damage-locations-selected.js"; import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import { showFmgLoadingModal } from "@/helpers/loading-modal-helper"; import { debugLog } from "@/helpers/debug-log-helper"; import store from "@/store"; @@ -199,7 +200,7 @@ export default { return vm.forwardButtonAction(); } } - baseMixin.methods.ResetExternalParamsAndHideModal(); + showFmgLoadingModal(false); //clear any validation errors for external parameter flow const form = vm.$refs.theForm; @@ -209,7 +210,7 @@ export default { } } } else { - baseMixin.methods.ResetExternalParamsAndHideModal(); + showFmgLoadingModal(false); } }); }, diff --git a/src/layouts/vehicle/vehicle.spec.js b/src/layouts/vehicle/vehicle.spec.js index d4bc1f726..ba1c4c621 100644 --- a/src/layouts/vehicle/vehicle.spec.js +++ b/src/layouts/vehicle/vehicle.spec.js @@ -93,8 +93,11 @@ describe("vehicle.vue", () => { }); describe("vehicle.vue", () => { - test("should call forwardButtonAction if isExternalParameter is true and form is valid", async () => { + test("should call forwardButtonAction if isExternalParameter is true, has a year, make and model, and form is valid", async () => { store.getters.externalParameterState.isExternalParameter = true; + store.getters.externalParameterVehicle.year = "1886"; + store.getters.externalParameterVehicle.make = "Benz"; + store.getters.externalParameterVehicle.model = "Patent-Motorwagen"; // Create a shallow mount of MyComponent const { wrapper } = setupMocks(); // Set displayNoServiceAlert to false diff --git a/src/layouts/vehicle/vehicle.vue b/src/layouts/vehicle/vehicle.vue index 4521a6e2f..ac4a54f62 100644 --- a/src/layouts/vehicle/vehicle.vue +++ b/src/layouts/vehicle/vehicle.vue @@ -109,6 +109,7 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { applicationConfig } from "../../constants/application-config"; import { queryStrings } from "@/constants/query-strings"; import { getQuerystringParameter } from "@/helpers/querystring-helper"; +import { showFmgLoadingModal } from "@/helpers/loading-modal-helper"; import { debugLog } from "@/helpers/debug-log-helper"; //define validation rules @@ -252,17 +253,17 @@ export default { const matchingMake = resultMap.makeQuestionInitialData?.filter( (item) => item.toLowerCase() === - store.getters.externalParameterVehicle.make.toLowerCase() + store.getters.externalParameterVehicle.make?.toLowerCase() ); const matchingModel = resultMap.modelQuestionInitialData?.filter( (item) => item.toLowerCase() === - store.getters.externalParameterVehicle.model.toLowerCase() + store.getters.externalParameterVehicle.model?.toLowerCase() ); const matchingStyle = resultMap.styleQuestionInitialData?.filter( (item) => item.toLowerCase() === - store.getters.externalParameterVehicle.style.toLowerCase() + store.getters.externalParameterVehicle.style?.toLowerCase() ); baseMixin.methods.dispatchStoreAction( storeActions.UPDATE_EXTERNAL_PARAMETER_MMS, @@ -291,7 +292,12 @@ export default { resultMap.modelQuestionInitialData, resultMap.styleQuestionInitialData ); - if (store.getters.externalParameterState?.isExternalParameter) { + if ( + store.getters.externalParameterState?.isExternalParameter && + store.getters.externalParameterVehicle.year && + store.getters.externalParameterVehicle.make && + store.getters.externalParameterVehicle.model + ) { await vm.getVehicleDetails(); if (!vm.displayNoServiceAlert) { const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm); @@ -304,7 +310,7 @@ export default { baseMixin.methods.ResetExternalParamsAndHideModal(); } } else { - baseMixin.methods.ResetExternalParamsAndHideModal(); + showFmgLoadingModal(false); } }); }, diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 316553548..3ab575f58 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -132,51 +132,6 @@ export default { } }, - async logDigitalConsumer() { - const currentPageName = getPageNameFromRouter(); - const universes = store.getters.applicationUser.experiments; - - const variationNames = universes - .filter((item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL) - .map((item) => item.variationName) - .filter(Boolean); // removes undefined/null - - const conceptVariation = variationNames.length > 0 ? variationNames[0] : ""; - - const isConceptExposed = universes.find( - (item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL - )?.isExposed; - - const submittedOrder = baseMixin.methods.getSubmittedOrder(); - const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); - const hasSubmittedOrderAtConfirmationPage = - hasSubmittedOrder && currentPageName?.toLowerCase() == routeData.CONFIRMATION.name; - - var payload = { - actionName: `Browser page:${currentPageName}`, - referralSequenceNumber: hasSubmittedOrderAtConfirmationPage - ? submittedOrder.referralSequenceNumber - : store.getters.order.referralSequenceNumber, - referralNumber: hasSubmittedOrderAtConfirmationPage - ? submittedOrder.referralNumber - : store.getters.order.referralNumber, - workOrderId: hasSubmittedOrderAtConfirmationPage - ? submittedOrder.workOrderId - : store.getters.order.workOrderId, - workOrderNumber: hasSubmittedOrderAtConfirmationPage - ? submittedOrder.workOrderNumber - : store.getters.order.workOrderNumber, - conceptVariation: conceptVariation, - isConceptExposed: isConceptExposed, - }; - - await baseMixin.methods.dispatchStoreAction( - storeActions.LOG_DIGITALCONSUMER, - payload, - false - ); - }, - async pushEventForChatsToGA(category, action, label, pushToLogApp = false) { const currentPageName = getPageNameFromRouter(); const value = `2.0_${currentPageName}`; @@ -206,6 +161,11 @@ export default { // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. // This bucket data is then picked up by snowflake for analytics use. async pushFmgSessionData() { + var currentPageName = getPageNameFromRouter(true); + if (!currentPageName) { + return; + } + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); const submittedOrder = baseMixin.methods.getSubmittedOrder(); const order = hasSubmittedOrder ? submittedOrder : store.getters.order; @@ -247,22 +207,6 @@ export default { } var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`; - var currentPageName = getPageNameFromRouter(); - - // add query strings to the page name for debugging. on the vehicle page, if from an external link, pull it from the stash - if (currentPageName === "vehicle") { - if (!window.location.search) { - if (store.getters.externalParameterState?.qsStash) { - currentPageName += `${store.getters.externalParameterState.qsStash}`; - } - } else { - currentPageName += `${window.location.search}`; - } - } else { - if (window.location.search) { - currentPageName += `${window.location.search}`; - } - } var sessionData = {}; sessionData.currentPage = currentPageName; @@ -806,14 +750,14 @@ export default { ); coupons = promoString; } - const serviceZipPackage = submittedOrder.lineItems?.supportingItems?.find( - (x) => x.partType == "SERVICE PACKAGE DISCOUNT" + const additionalDiscounts = submittedOrder.lineItems?.supportingItems?.find( + (x) => x.partType == partTypeStrings.QUOTE_PAGE_DISCOUNT ); - if (isDefined(serviceZipPackage)) { + if (isDefined(additionalDiscounts)) { if (coupons) { coupons += ","; } - coupons += serviceZipPackage.partNumber; + coupons += additionalDiscounts.partNumber; } refSequenceNum = submittedOrder.referralSequenceNumber; @@ -846,17 +790,17 @@ export default { } } } + pushToDataLayerIfDefined({ + event: "commissionJunctionGtmData", + commissionJunctionGtmData: { + cj_commission_junction_event: cjEvent, + cj_referral_sequence_number: refSequenceNum, + cj_amount: amount.toFixed(2), + cj_repair_replace: repairReplace, + cj_coupon: coupons, + }, + }); } - pushToDataLayerIfDefined({ - event: "commissionJunctionGtmData", - commissionJunctionGtmData: { - cj_commission_junction_event: cjEvent, - cj_referral_sequence_number: refSequenceNum, - cj_amount: amount.toFixed(2), - cj_repair_replace: repairReplace, - cj_coupon: coupons, - }, - }); }, pushPageErrorToDataLayer(error) { @@ -911,6 +855,9 @@ export default { setSessionIdIfUnset(response.data.sessionId); } } + + // logging on session init so we capture complete query string data + await this.pushFmgSessionData(); }, noSession() { @@ -977,7 +924,7 @@ function pushToDataLayerIfDefined(data) { } } -function getPageNameFromRouter() { +function getPageNameFromRouter(useDefaultUrl = false) { if ( router && router.currentRoute && @@ -987,7 +934,9 @@ function getPageNameFromRouter() { return router.currentRoute.value.name; } - return window.location.href.replace(/\/$/, "").split("/").pop(); + return useDefaultUrl + ? window.location.search + : window.location.href.replace(/\/$/, "").split("/").pop(); } function getValueToLog(value, valueToLogType) { diff --git a/src/router/constants/navigation-scenarios.js b/src/router/constants/navigation-scenarios.js index 0d0ee836f..21bcc46f3 100644 --- a/src/router/constants/navigation-scenarios.js +++ b/src/router/constants/navigation-scenarios.js @@ -64,6 +64,7 @@ const navigationScenarios = { CLICKED_PAY_NOW: "CLICKED_PAY_NOW", CLICKED_PAY_NOW_ADYEN: "CLICKED_PAY_NOW_ADYEN", CLICKED_PAY_LATER: "CLICKED_PAY_LATER", + CLICKED_SWITCH_TO_INSHOP: "CLICKED_SWITCH_TO_INSHOP", PIA_ERROR: "PIA_ERROR", PIA_CC_ERROR: "PIA_CC_ERROR", PIA_APPLE_ERROR: "PIA_APPLE_ERROR", diff --git a/src/router/constants/routing-table.js b/src/router/constants/routing-table.js index 2d2f49a25..a7be5f387 100644 --- a/src/router/constants/routing-table.js +++ b/src/router/constants/routing-table.js @@ -553,6 +553,10 @@ const routingTable = function () { scenario: navigationScenarios.CLICKED_INSURANCE, destinationPageData: routeData.INSURANCE_COMPANY, }, + { + scenario: navigationScenarios.CLICKED_SWITCH_TO_INSHOP, + destinationPageData: routeData.SCHEDULE, + }, ], }, { diff --git a/src/router/methods/after-each.js b/src/router/methods/after-each.js index 608119399..43ed0d4b5 100644 --- a/src/router/methods/after-each.js +++ b/src/router/methods/after-each.js @@ -5,9 +5,6 @@ import { storeMutations } from "@/constants/store-mutations"; export async function afterEach(to, from) { store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name); - // digital consumer logging - analyticsMixin.methods.logDigitalConsumer(); - // digital consumer fmg session logging to snowflake analyticsMixin.methods.pushFmgSessionData(); diff --git a/src/router/methods/helpers/initialize-from-querystrings.js b/src/router/methods/helpers/initialize-from-querystrings.js index 8dff4423d..6b6cecff2 100644 --- a/src/router/methods/helpers/initialize-from-querystrings.js +++ b/src/router/methods/helpers/initialize-from-querystrings.js @@ -95,6 +95,7 @@ function updateExternalParameterState() { } if (externalParameterZipCode) { store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_ZIP_CODE, externalParameterZipCode); + store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.ACTIVE); } if (externalParameterEmail) { store.commit( diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js index fb9dd2cbc..162e5b914 100644 --- a/src/router/methods/route-logic/error.js +++ b/src/router/methods/route-logic/error.js @@ -19,7 +19,6 @@ export async function errorBeforeEnter(to, from) { nextPage: to?.name, }; - analyticsMixin.methods.logDigitalConsumer(); await handleHardError(errorPayload); return; } @@ -33,7 +32,6 @@ export async function errorBeforeEnter(to, from) { nextPage: to?.name, }; - analyticsMixin.methods.logDigitalConsumer(); await handleHardError(errorPayload); return; } else { diff --git a/src/store/index.js b/src/store/index.js index e91ec6c0b..8e04bd368 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -59,7 +59,7 @@ import { } from "@/helpers/recal-helper"; import { externalParameterStatus } from "@/constants/external-parameters"; import { experimentSettings } from "@/constants/experiments"; -import { addPricesToLineItems } from "@/helpers/pricing-helper"; +import { addPricesToLineItems, getAmountDue } from "@/helpers/pricing-helper"; // Export State const getDefaultState = () => { @@ -192,6 +192,7 @@ const getDefaultState = () => { isRecalAckOptIn: false, isRecalAcknowledgedForScheduling: "", isMSRFeeApplicable: false, + isMSRFeeCoveredByInsurance: false, }, applicationUser: { eventBus: [], @@ -207,6 +208,12 @@ const getDefaultState = () => { loggingOption: false, hasAlreadyTriggeredError: false, }, + idempotencyKeyFields: { + referralCorrelationId: null, + totalInCents: 0, + expiryTime: null, + idempotencyKey: null, + }, }; }; @@ -353,6 +360,9 @@ export const mutations = { updateIsMSRFeeApplicable(state, isMSRFeeApplicable) { state.order.isMSRFeeApplicable = isMSRFeeApplicable; }, + updateIsMSRFeeCoveredByInsurance(state, isMSRFeeCoveredByInsurance) { + state.order.isMSRFeeCoveredByInsurance = isMSRFeeCoveredByInsurance; + }, updateCashPriceSubTotal(state, cashPriceSubTotal) { state.order.cashPriceSubTotal = cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString(); @@ -862,6 +872,15 @@ export const mutations = { updateHasTriggeredError(state, hasTriggeredError) { state.applicationUser.hasAlreadyTriggeredError = hasTriggeredError; }, + updateIdempotencyKey( + state, + { idempotencyKey, referralCorrelationId, totalInCents, expiryTime } + ) { + state.idempotencyKeyFields.idempotencyKey = idempotencyKey; + state.idempotencyKeyFields.referralCorrelationId = referralCorrelationId; + state.idempotencyKeyFields.totalInCents = totalInCents; + state.idempotencyKeyFields.expiryTime = expiryTime; + }, }; // Export Getters @@ -1105,6 +1124,35 @@ export const getters = { state.order.customer.emailAddress ); }, + + isIdempotencyKeyValid: (state) => { + if (!state.idempotencyKeyFields?.idempotencyKey) { + return false; + } + + const referralCorrelationId = state.order?.referralCorrelationId; + if ( + !referralCorrelationId || + referralCorrelationId !== state.idempotencyKeyFields?.referralCorrelationId + ) { + return false; + } + + const total = getAmountDue(state.order?.lineItems); + const totalInCents = Math.round(total * 100); + if (totalInCents !== state.idempotencyKeyFields?.totalInCents) { + return false; + } + + const currentTime = new Date(); + const expiryTime = new Date(state.idempotencyKeyFields.expiryTime); + if (!expiryTime || expiryTime.getTime() < currentTime.getTime()) { + return false; + } + + // If all checks pass, then the key is valid. + return true; + }, }; // Export Actions @@ -1586,38 +1634,6 @@ export const actions = { }); }, - logDigitalConsumer( - context, - { - actionName, - referralSequenceNumber, - referralNumber, - workOrderId, - workOrderNumber, - conceptVariation, - isConceptExposed, - } - ) { - var payload = { - sessionId: getSessionIdValue(), - deviceId: getDeviceIdValue(), - actionName: actionName ?? "", - referralSequenceNumber: referralSequenceNumber ?? "", - referralNumber: referralNumber ?? "", - applicationName: baseMixin.methods.isMobileDevice() ? "2.0 Mobile" : "2.0", - workOrderId: workOrderId ?? "", - workOrderNumber: workOrderNumber ?? "", - conceptVariation: conceptVariation, - isConceptExposed: isConceptExposed, - }; - - return globalMethods.callHttpClient({ - method: endpoints.LogDigitalConsumer.method, - endpoint: endpoints.LogDigitalConsumer.url, - payload: payload, - }); - }, - logFmgSessionData( context, { @@ -1726,6 +1742,7 @@ export const actions = { method: endpoints.LogFmgSessionData.method, endpoint: endpoints.LogFmgSessionData.url, payload: payload, + logApiCall: false, }); }, @@ -2542,6 +2559,7 @@ export const actions = { lockToken: order.lockToken, isRecalAckOptIn: order.isRecalAckOptIn, isMSRFeeApplicable: order.isMSRFeeApplicable, + isMSRFeeCoveredByInsurance: order.isMSRFeeCoveredByInsurance, cashPriceSubTotal: order.cashPriceSubTotal, }, }, @@ -2974,6 +2992,13 @@ export const actions = { context.commit(storeMutations.UPDATE_IS_MSR_FEE_APPLICABLE, isMSRFeeApplicable); }, + saveIsMSRFeeCoveredByInsurance(context, isMSRFeeCoveredByInsurance) { + context.commit( + storeMutations.UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE, + isMSRFeeCoveredByInsurance + ); + }, + saveParentAccountNumber(context, parentAccountNumber) { context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber); }, @@ -3640,6 +3665,63 @@ export const actions = { updateHasTriggeredError(context, hasAlreadyTriggeredError) { context.commit(storeMutations.UPDATE_HAS_TRIGGERED_ERROR, hasAlreadyTriggeredError); }, + + getValidIdempotencyKey(context) { + if (context.getters.isIdempotencyKeyValid) { + return context.state.idempotencyKeyFields.idempotencyKey; + } + + // If invalid, need to regenerate. + const referralCorrelationId = context.state.order?.referralCorrelationId; + if (!referralCorrelationId) { + // Can't generate! + return null; + } + + const total = getAmountDue(context.state.order?.lineItems); + const totalInCents = Math.round(total * 100); + if (Number.isNaN(totalInCents)) { + // Can't generate! + return null; + } + + const currentTime = new Date(); + const nextHour = currentTime.getUTCHours() + 1; + currentTime.setUTCHours(nextHour); + + const newId = crypto.randomUUID(); + + const newKeyInfo = { + idempotencyKey: newId, + referralCorrelationId: referralCorrelationId, + totalInCents: totalInCents, + expiryTime: currentTime, + }; + + context.commit(storeMutations.UPDATE_IDEMPOTENCY_KEY, newKeyInfo); + + return newId; + }, + + correctIdempotencyKeyExpiry(context, expiryTime) { + if (!context.getters.isIdempotencyKeyValid) { + return; + } + + const currentExpiryTime = new Date(context.state.idempotencyKeyFields.expiryTime); + const expiryTimeAsDate = new Date(expiryTime); + + if (expiryTimeAsDate.getTime() < currentExpiryTime.getTime()) { + const existingKey = context.state.idempotencyKeyFields; + const newKey = { + referralCorrelationId: existingKey.referralCorrelationId, + totalInCents: existingKey.totalInCents, + idempotencyKey: existingKey.idempotencyKey, + expiryTime: expiryTimeAsDate, + }; + context.commit(storeMutations.UPDATE_IDEMPOTENCY_KEY, newKey); + } + }, }; export default createStore({ diff --git a/src/ux-components/alert/alert.vue b/src/ux-components/alert/alert.vue index 5c78853d4..deb7bca8b 100644 --- a/src/ux-components/alert/alert.vue +++ b/src/ux-components/alert/alert.vue @@ -7,9 +7,15 @@ isDismissible ? 'alert-dismissible' : '', this.alertClass, this.cssClassNameForCmsWidget, + this.hasBorder ? 'bordered' : '', ]">

Info Icon + Warning Icon {{ alertHeadline }}


@@ -63,7 +69,9 @@ export default { name: "alert", props: { showInfoIcon: Boolean, + showWarningIcon: Boolean, showHorizontalRow: Boolean, + hasBorder: Boolean, isDismissible: Boolean, alertClass: String, /* @@ -96,6 +104,9 @@ export default { infoIcon() { return require(`@/assets/img/icons/info-circle-blue.svg`); }, + warningIcon() { + return require(`@/assets/img/icons/alert-circle-yellow.svg`); + }, pageQueryString() { return applicationConfig.PAGE_QUERYSTRING; }, @@ -211,12 +222,19 @@ export default { background-color: $yellow-100; .alert-heading { color: $yellow-600; + + .warning-icon { + height: 1rem; + } } svg { fill: $yellow-600; width: 1rem; height: 1rem; } + hr { + border-color: $yellow-800; + } } &.alert-success { background-color: $green-100; @@ -229,6 +247,9 @@ export default { height: 1rem; } } + &.bordered { + border: 1px solid; + } & p { font-size: 0.875rem; }