Merge branch 'develop' into dependabot/npm_and_yarn/multi-75e6bc5210

This commit is contained in:
matthew-sykes 2026-03-17 10:23:52 -04:00 committed by GitHub
commit 093c1f67e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 3400 additions and 662 deletions

9
jsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View file

@ -10,6 +10,7 @@ SKIP_CONTENT_SITE="false"
# Experiments Flag # Experiments Flag
IS_MOBILEFIRST="false" IS_MOBILEFIRST="false"
IS_ADYENPAYMENTS="false" IS_ADYENPAYMENTS="false"
IS_MULTILOCATIONPOPUP="false"
# Base URLs by environment # Base URLs by environment
# qa # qa

View file

@ -10,6 +10,7 @@ SKIP_CONTENT_SITE="false"
# Experiments Flag # Experiments Flag
IS_MOBILEFIRST="false" IS_MOBILEFIRST="false"
IS_ADYENPAYMENTS="false" IS_ADYENPAYMENTS="false"
IS_MULTILOCATIONPOPUP="false"
# Base URLs by environment # Base URLs by environment
# qa # qa

View file

@ -18,5 +18,6 @@ export function getDefaultExperimentsData(): IExperiments {
return { return {
isAdyenPayments: !!process.env.IS_ADYENPAYMENTS && process.env.IS_ADYENPAYMENTS !== "" ? process.env.IS_ADYENPAYMENTS === "true" : false, 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, 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,
} }
} }

View file

@ -1,4 +1,5 @@
export interface IExperiments { export interface IExperiments {
isMobileFirst: boolean, isMobileFirst: boolean,
isAdyenPayments: boolean isAdyenPayments: boolean,
isMultiLocationPopup: boolean
} }

View file

@ -257,8 +257,12 @@ export class BasePage {
: "MobileFirstAppointment=MobileFirstAppt_V1=MobileFirstAppt_CONTROL=true"; : "MobileFirstAppointment=MobileFirstAppt_V1=MobileFirstAppt_CONTROL=true";
experimentsURLExtension += experiments?.isAdyenPayments experimentsURLExtension += experiments?.isAdyenPayments
? ",Adyen%20Payments=Adyen%20Payment%20Test=Adyen%20Payment%20(Test)" ? ",Adyen%20Payments=Adyen%20Payment%20Test=Adyen%20Payment%20(Test)=true"
: ",Adyen%20Payments=Adyen%20Payment%20Test=CyberSource%20(Control)"; : ",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 { } else {
console.log("Url extension without query string"); console.log("Url extension without query string");
} }

View file

@ -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<ITestData>){
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<PaypalPage> {
await this.payPalButton.click();
const paypalPage = this.page.waitForEvent('popup');
await this.navigateToPaypalButton.click();
return new PaypalPage(await paypalPage);
}
}

View file

@ -8,6 +8,8 @@ import { AfterpayPage } from './AfterpayPage';
import { PaypalPage } from './PaypalPage'; import { PaypalPage } from './PaypalPage';
import { ITestData } from 'framework/TestData'; import { ITestData } from 'framework/TestData';
import { step } from 'framework/localTypes/Step'; import { step } from 'framework/localTypes/Step';
import { IExperiments } from 'framework/localTypes/IExperiments';
import { PaymentAdyenPage } from './PaymentAdyenPage';
export class PaymentMethodPage extends BasePage { export class PaymentMethodPage extends BasePage {
readonly page: Page; readonly page: Page;
@ -25,7 +27,9 @@ export class PaymentMethodPage extends BasePage {
readonly submitButton: Locator; readonly submitButton: Locator;
readonly recalibrationCheckbox: Locator; readonly recalibrationCheckbox: Locator;
readonly paymentPage: PaymentPage; readonly paymentPage: PaymentPage;
readonly paymentAdyenPage: PaymentAdyenPage;
readonly paypalPage: PaypalPage; readonly paypalPage: PaypalPage;
readonly afterpayPage: AfterpayPage
readonly afterPayBreakoutSection: Locator; readonly afterPayBreakoutSection: Locator;
readonly afterPayToggle: Locator; readonly afterPayToggle: Locator;
@ -59,7 +63,9 @@ export class PaymentMethodPage extends BasePage {
this.recalibrationCheckbox = this.page.locator('label:has(>input[name=\'recalAckOptIn\'])'); this.recalibrationCheckbox = this.page.locator('label:has(>input[name=\'recalAckOptIn\'])');
// this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1); // this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1);
this.paymentPage = new PaymentPage(page); this.paymentPage = new PaymentPage(page);
this.paymentAdyenPage = new PaymentAdyenPage(page);
this.paypalPage = new PaypalPage(page); this.paypalPage = new PaypalPage(page);
this.afterpayPage = new AfterpayPage(page);
// Payment details validation locators // Payment details validation locators
this.reviewTable = this.page.locator('div.review-table'); this.reviewTable = this.page.locator('div.review-table');
@ -191,35 +197,57 @@ export class PaymentMethodPage extends BasePage {
return text; return text;
} }
async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) { async executePayment(testData: Partial<ITestData>) {
const browserContext = this.page.context(); const browserContext = this.page.context();
const { paymentDetails, isRecalVehicle, experiments } = testData;
switch (paymentDetails.paymentType) { switch (paymentDetails!.paymentType) {
case PaymentType.Credit: case PaymentType.Credit:
await this.selectCreditCard(); await this.selectCreditCard();
await this.nextPage(); await this.nextPage();
await this.paymentPage.populateCreditCardDetails(paymentDetails); experiments!.isAdyenPayments
? await this.paymentAdyenPage.populateAdyenCreditCardDetails(testData)
: await this.paymentPage.populateCreditCardDetails(paymentDetails!);
break; break;
case PaymentType.AfterPay: case PaymentType.AfterPay:
await this.payInFourButton.click(); await this.payInFourButton.click();
await this.continueButton.click(); await this.continueButton.click();
// Capture popup if (experiments?.isAdyenPayments) {
const afterpayPopup = await browserContext.waitForEvent('page'); await this.paymentAdyenPage.afterPayButton.click();
const afterpayPage = new AfterpayPage(afterpayPopup); 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; break;
case PaymentType.Paypal: case PaymentType.Paypal:
await this.selectPaypal(); await this.selectPaypal();
// TODO: Click paypal button // TODO: Click paypal button
await this.nextPage(); await this.nextPage();
await this.paymentPage.navigateToPaypalCheckout(); const paypalPage = experiments?.isAdyenPayments
await this.paypalPage.completePaypalPurchase(paymentDetails); ? 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; break;
case PaymentType.PayAtService: case PaymentType.PayAtService:
await this.selectPayAtService(isRecalVehicle); await this.selectPayAtService(isRecalVehicle!);
await this.nextPage(); await this.nextPage();
break; break;
case PaymentType.PayWithInsurance: case PaymentType.PayWithInsurance:
@ -527,7 +555,7 @@ export class PaymentMethodPage extends BasePage {
@step("PaymentMethodPage >> Select Payment Method: ") @step("PaymentMethodPage >> Select Payment Method: ")
async handlePaymentMethodPage(testData: Partial<ITestData>) { async handlePaymentMethodPage(testData: Partial<ITestData>) {
const { servicePackage, isRecalVehicle, paymentDetails, isForcedOEM } = testData; const { servicePackage, isRecalVehicle, paymentDetails, isForcedOEM, experiments } = testData;
await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage); await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage);
await this.validatePaymentDetailsPage(testData); await this.validatePaymentDetailsPage(testData);
@ -542,7 +570,8 @@ export class PaymentMethodPage extends BasePage {
await this.verifyVAPS(); await this.verifyVAPS();
} }
if (paymentDetails?.paymentType) { if (paymentDetails?.paymentType) {
await this.executePayment(paymentDetails!, isRecalVehicle!); // await this.executePayment(paymentDetails!, isRecalVehicle!, experiments!);
await this.executePayment(testData);
} else { } else {
await this.nextPage(); await this.nextPage();
} }

View file

@ -1,6 +1,7 @@
import { type Locator, type Page } from '@playwright/test'; import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage'; import { BasePage } from './BasePage';
import { IPaymentDetails } from 'safelite-playwright-core'; import { IPaymentDetails } from 'safelite-playwright-core';
import { PaypalPage } from './PaypalPage';
export class PaymentPage extends BasePage { export class PaymentPage extends BasePage {
readonly page: Page; readonly page: Page;
@ -43,8 +44,8 @@ export class PaymentPage extends BasePage {
await this.submitPaymentButton.click(); await this.submitPaymentButton.click();
} }
async navigateToPaypalCheckout() { async navigateToPaypalCheckout(): Promise<PaypalPage> {
await this.payPalButton.click(); await this.payPalButton.click();
return new PaypalPage(this.page);
} }
} }

View file

@ -1,6 +1,6 @@
import { expect, type Locator, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage'; import { BasePage } from './BasePage';
import { IPaymentDetails } from 'safelite-playwright-core'; import { ITestData } from 'framework/TestData';
export class PaypalPage extends BasePage { export class PaypalPage extends BasePage {
readonly page: Page; readonly page: Page;
@ -30,24 +30,28 @@ export class PaypalPage extends BasePage {
this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' }); this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' });
} }
async completePaypalPurchase(paymentDetails: IPaymentDetails){ async completePaypalPurchase(testData: Partial<ITestData>){
const {paymentDetails, experiments } = testData;
// Wait for the PayPal login page to load // Wait for the PayPal login page to load
await this.page.waitForFunction(() => window.location.href.includes('sandbox.paypal.com'), null, { timeout: 10000 }); 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 // handle flow with and without the "Log in with a password instead" link
if (await this.loginWithPasswordButton.isVisible()) { if (await this.loginWithPasswordButton.isVisible()) {
await this.loginWithPasswordButton.click(); await this.loginWithPasswordButton.click();
await this.passwordTextBox.fill(paymentDetails.password!); await this.passwordTextBox.fill(paymentDetails!.password!);
await this.paypalLoginButton.click(); await this.paypalLoginButton.click();
await this.completePurchaseButton.click(); await this.completePurchaseButton.click();
} else { } else {
await this.usernameTextBox.waitFor({ state: 'visible' }); await this.usernameTextBox.waitFor({ state: 'visible' });
await this.page.screenshot({ path: `test-results\\ortoni-data\\paypal-username-${Date.now()}.png`, fullPage: true }); 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.nextButton.click();
await this.tryAnotherWayButton.click(); if (!experiments!.isAdyenPayments){
await this.usePasswordInsteadButton.click(); await this.tryAnotherWayButton.click();
await this.passwordTextBox.fill(paymentDetails.password!); await this.usePasswordInsteadButton.click();
}
await this.passwordTextBox.fill(paymentDetails!.password!);
await this.paypalLoginButton.click(); await this.paypalLoginButton.click();
await this.payWithRadioButton.click(); await this.payWithRadioButton.click();
await this.page.waitForTimeout(2000); await this.page.waitForTimeout(2000);

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="21" viewBox="0 0 20 21">
<path fill="#E86421" fill-rule="nonzero" d="M10 .5c5.523 0 10 4.477 10 10s-4.477 10-10 10-10-4.477-10-10S4.477.5 10 .5zm.043 13.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm.77-9.2h-1.54l-.088.009a.502.502 0 0 0-.299.193.66.66 0 0 0-.125.47l.747 6.806.017.096c.065.249.263.425.495.426l.084-.008c.22-.042.396-.246.427-.51l.793-6.805.004-.102a.651.651 0 0 0-.127-.37.489.489 0 0 0-.388-.205z"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C6.41775 0 4.87104 0.469192 3.55544 1.34824C2.23985 2.22729 1.21447 3.47672 0.608967 4.93853C0.00346626 6.40034 -0.15496 8.00887 0.153721 9.56072C0.462403 11.1126 1.22433 12.538 2.34315 13.6569C3.46197 14.7757 4.88743 15.5376 6.43928 15.8463C7.99113 16.155 9.59966 15.9965 11.0615 15.391C12.5233 14.7855 13.7727 13.7602 14.6518 12.4446C15.5308 11.129 16 9.58225 16 8C16 5.87827 15.1571 3.84344 13.6569 2.34315C12.1566 0.842855 10.1217 0 8 0V0ZM8.4672 11.4C8.4672 11.5485 8.4082 11.691 8.30318 11.796C8.19816 11.901 8.05572 11.96 7.9072 11.96C7.75868 11.96 7.61624 11.901 7.51122 11.796C7.4062 11.691 7.3472 11.5485 7.3472 11.4V7.08C7.3472 6.93148 7.4062 6.78904 7.51122 6.68402C7.61624 6.579 7.75868 6.52 7.9072 6.52C8.05572 6.52 8.19816 6.579 8.30318 6.68402C8.4082 6.78904 8.4672 6.93148 8.4672 7.08V11.4ZM7.9072 5.4C7.79645 5.4 7.68818 5.36716 7.59608 5.30562C7.50399 5.24409 7.43222 5.15663 7.38983 5.0543C7.34745 4.95198 7.33636 4.83938 7.35796 4.73075C7.37957 4.62212 7.43291 4.52234 7.51122 4.44402C7.58954 4.3657 7.68932 4.31237 7.79795 4.29076C7.90658 4.26915 8.01918 4.28024 8.12151 4.32263C8.22383 4.36501 8.31129 4.43679 8.37283 4.52888C8.43436 4.62097 8.4672 4.72924 8.4672 4.84C8.46741 4.9136 8.45307 4.98651 8.42501 5.05455C8.39694 5.12259 8.3557 5.18441 8.30365 5.23645C8.25161 5.28849 8.18979 5.32973 8.12176 5.3578C8.05372 5.38587 7.9808 5.40021 7.9072 5.4Z" fill="#0070D1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -15,6 +15,8 @@ const GaCategories = {
APPOINTMENT: "Appointment", APPOINTMENT: "Appointment",
SERVICE_LOCATION: "service-location", SERVICE_LOCATION: "service-location",
CONFIRMATION_CLICKED: "confirmation clicked", CONFIRMATION_CLICKED: "confirmation clicked",
INSHOP_CONFIRMATION_CLICKED: "inshop confirmation clicked",
MOBILE_CONFIRMATION_CLICKED: "mobile confirmation clicked",
RECAL_DISCLAIMER: "Recalibration disclaimer", RECAL_DISCLAIMER: "Recalibration disclaimer",
}; };

View file

@ -5,6 +5,7 @@ const experimentUniverses = {
IGQ_SkipQuote: "NextGen_IGQSkipToInsurance", IGQ_SkipQuote: "NextGen_IGQSkipToInsurance",
AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay", AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay",
MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment", MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment",
MULTI_LOCATION_POPUP: "MultiLocationPopup",
}; };
const experimentSettings = { const experimentSettings = {
@ -40,7 +41,13 @@ const experimentSettings = {
SHOW_MOBILE_FIRST_POPUP_REPLACE_WITHOUT_MSR: "Show_Mobile_First_Popup_Replace_Without_MSR", SHOW_MOBILE_FIRST_POPUP_REPLACE_WITHOUT_MSR: "Show_Mobile_First_Popup_Replace_Without_MSR",
SHOW_MOBILE_FIRST_POPUP_REPLACE_WITH_MSR: "Show_Mobile_First_Popup_Replace_With_MSR", SHOW_MOBILE_FIRST_POPUP_REPLACE_WITH_MSR: "Show_Mobile_First_Popup_Replace_With_MSR",
OFFER_OEM: "OfferOem", OFFER_OEM: "OfferOem",
SHOW_MULTI_LOCATION_APPT: "ShowMultiLocationAppt",
SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION: "Show_NoAvailableDaysMultiLocation",
SHOW_NO_PM_DAYS_MULTI_LOCATION: "Show_NoPMDaysMultiLocation",
SHOW_PM_DAYS_MULTI_LOCATION: "Show_PMDaysMultiLocation",
USE_ADYEN_PAYMENT: "UseAdyenPayment", USE_ADYEN_PAYMENT: "UseAdyenPayment",
SHOW_UNVERIFIED_DEDUCT_ENTRY: "ShowUnverifiedDeducEntry",
SERVICE_PACKAGE_ORDER: "ServicePackageOrder",
}; };
const experimentTriggers = { const experimentTriggers = {

View file

@ -226,6 +226,7 @@ export default {
let classes; let classes;
switch (this.buttonTypeString) { switch (this.buttonTypeString) {
case "timeSlotModalListButton": case "timeSlotModalListButton":
case "multiLocationRadioButton":
case "listButton": case "listButton":
classes = "w-100"; classes = "w-100";
break; break;
@ -265,6 +266,8 @@ export default {
classes += " radio-button-container"; classes += " radio-button-container";
} else if (this.buttonTypeString == "servicePackageRadio") { } else if (this.buttonTypeString == "servicePackageRadio") {
classes = "package-wrapper col"; classes = "package-wrapper col";
} else if (this.buttonTypeString == "multiLocationRadioButton") {
classes = "multi-location-wrapper col";
} }
if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) { if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) {

View file

@ -153,6 +153,8 @@ import {
convertDateToDateString, convertDateToDateString,
convertDateStringToDate, convertDateStringToDate,
getTodayDateString, getTodayDateString,
getInitialViewWeeks,
getMonthEnd,
} from "@/layouts/schedule/helpers/schedule-helper"; } from "@/layouts/schedule/helpers/schedule-helper";
import { useField, ErrorMessage } from "vee-validate"; import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
@ -291,140 +293,6 @@ export default {
} }
this.$emit("date-selected", date); this.$emit("date-selected", date);
}, },
getWeekStartDate(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
// Subtract the day of the week from date to get the date of Sunday
const sunday = new Date(date);
sunday.setDate(sunday.getDate() - dayOfWeek);
return convertDateToDateString(sunday);
},
getWeekEndDate(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
const daysUntilSaturday = 6 - dayOfWeek; // Calculate the number of days until Saturday
// Clone the given date and add the remaining days until Saturday
const saturday = new Date(date);
saturday.setDate(date.getDate() + daysUntilSaturday);
return convertDateToDateString(saturday);
},
getNextWeekSunday(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
const daysUntilNextSunday = 7 - dayOfWeek; // Calculate the number of days until the next Sunday
// Clone the given date and add the remaining days until Sunday
const nextSunday = new Date(date);
nextSunday.setDate(date.getDate() + daysUntilNextSunday);
return convertDateToDateString(nextSunday);
},
getMonthEnd(dateStr) {
// convert string to date, do date calc, then return a string back
const date = new Date(dateStr.split("-")[0], parseInt(dateStr.split("-")[1]), 0);
return convertDateToDateString(date);
},
getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDate) {
// TODO: this only is for future direction; need to create logic for past direction
const weeks = [];
let weekStartDate = this.getWeekStartDate(todayString);
let weekEndDate = this.getWeekEndDate(todayString);
if (preSelectedDate) {
let preSelectedDateMonthEnd = this.getMonthEnd(
preSelectedDate.replace("-mobile", "")
);
let monthEndsByThisWeek = false;
let i = 0;
while (!monthEndsByThisWeek && i < 52) {
if (i > 0) {
weekStartDate = this.getNextWeekSunday(weekEndDate);
weekEndDate = this.getWeekEndDate(weekStartDate);
if (
(preSelectedDateMonthEnd > weekStartDate &&
preSelectedDateMonthEnd < weekEndDate) ||
preSelectedDateMonthEnd === weekStartDate ||
preSelectedDateMonthEnd === weekEndDate
) {
weekEndDate = preSelectedDateMonthEnd;
monthEndsByThisWeek = true;
} else if (preSelectedDateMonthEnd < weekEndDate) {
// Additional case: if month ends before this week (but not necessarily during it),
// still cut the loop here, but don't truncate the week.
monthEndsByThisWeek = true;
}
}
weeks.push({
weekNum: i + 1,
weekStartDate: weekStartDate,
weekEndDate: weekEndDate,
});
i++;
}
} else {
for (let i = 0; i < initialViewRowsToShow; i++) {
if (i > 0) {
weekStartDate = this.getNextWeekSunday(weekEndDate);
weekEndDate = this.getWeekEndDate(weekStartDate);
}
weeks.push({
weekNum: i + 1,
weekStartDate: weekStartDate,
weekEndDate: weekEndDate,
});
}
// If any of these weeks is split between two months, then make them 2 separate "weeks"
// (a week split between two months is considered 2 weeks per business requirements)
const hasSplitWeek = (week) => {
return week.weekStartDate.split("-")[1] !== week.weekEndDate.split("-")[1]
? true
: false;
};
const splitWeekIndex = weeks.findIndex(hasSplitWeek);
if (splitWeekIndex > -1) {
const week1 = [];
const week2 = [];
let switchToWeek2 = false;
for (let j = 0; j < 7; j++) {
const newDate = convertDateStringToDate(
weeks[splitWeekIndex].weekStartDate
);
newDate.setDate(newDate.getDate() + j);
if (newDate.getDate() === 1) switchToWeek2 = true;
if (switchToWeek2) {
week2.push(convertDateToDateString(newDate));
} else {
week1.push(convertDateToDateString(newDate));
}
}
const week1EndDate = week1[week1.length - 1];
const week2StartDate = week2[0];
if (week1EndDate < todayString) {
// replace week 1 with week 2
weeks[splitWeekIndex].weekStartDate = week2StartDate;
} else {
const newWeek = {
weekNum: weeks[splitWeekIndex].weekNum,
weekStartDate: week2StartDate,
weekEndDate: weeks[splitWeekIndex].weekEndDate,
};
weeks[splitWeekIndex].weekEndDate = week1EndDate;
weeks.splice(splitWeekIndex + 1, 0, newWeek);
weeks.pop();
weeks.forEach((item, index) => {
if (index > splitWeekIndex) {
item.weekNum = item.weekNum + 1;
}
});
}
}
}
return weeks;
},
async loadInitialData(config) { async loadInitialData(config) {
/* /*
** NOTE: this _could_ be called by a parent before fully loaded, so FYI component data or computeds might not be available ** NOTE: this _could_ be called by a parent before fully loaded, so FYI component data or computeds might not be available
@ -444,10 +312,10 @@ export default {
if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; if (config.selectableDatesSetting === "past") calendarViewDirection = "past";
if (config.selectableDatesSetting === "custom") calendarViewDirection = "future"; if (config.selectableDatesSetting === "custom") calendarViewDirection = "future";
const currentMonthEnd = this.getMonthEnd(todayDateString); const currentMonthEnd = getMonthEnd(todayDateString);
// TODO - set up currentMonthStart if direction is PAST: // TODO - set up currentMonthStart if direction is PAST:
// let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1); // let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1);
const initialViewWeeks = this.getInitialViewWeeks( const initialViewWeeks = getInitialViewWeeks(
todayDateString, todayDateString,
config.initialViewRowsToShow, config.initialViewRowsToShow,
config.preSelectedDate config.preSelectedDate

View file

@ -31,23 +31,15 @@
<div class="donation-slider my-4"> <div class="donation-slider my-4">
<input <input
name="amount" name="amount"
id="one" id="zero"
type="radio" type="radio"
:value="donationValues[0]" :value="donationValues[0]"
checked /> checked />
<label for="one" class="label">$1</label> <label for="zero" class="label">${{ donationValues[0] }}</label>
<input <input name="amount" id="one" type="radio" :value="donationValues[1]" />
name="amount" <label for="one" class="label">${{ donationValues[1] }}</label>
id="three" <input name="amount" id="two" type="radio" :value="donationValues[2]" />
type="radio" <label for="two" class="label">${{ donationValues[2] }}</label>
:value="donationValues[1]" />
<label for="three" class="label">$3</label>
<input
name="amount"
id="five"
type="radio"
:value="donationValues[2]" />
<label for="five" class="label">$5</label>
<div class="indicator-bar"></div> <div class="indicator-bar"></div>
</div> </div>
</div> </div>
@ -264,17 +256,17 @@ export default {
background: $blue; background: $blue;
} }
input { input {
&#one:checked { &#zero:checked {
~ .indicator-bar { ~ .indicator-bar {
transform: translateX(2rem); transform: translateX(2rem);
} }
} }
&#three:checked { &#one:checked {
~ .indicator-bar { ~ .indicator-bar {
transform: translateX(calc(100% + 2rem)); transform: translateX(calc(100% + 2rem));
} }
} }
&#five:checked { &#two:checked {
~ .indicator-bar { ~ .indicator-bar {
transform: translateX(calc(200% + 2rem)); transform: translateX(calc(200% + 2rem));
} }

View file

@ -317,6 +317,10 @@ export default {
serviceLocationZipCode: serviceLocationZipCode:
store.getters.order.serviceLocation.zipCode, store.getters.order.serviceLocation.zipCode,
pricedLineItems: pricedLineItemsToTax, pricedLineItems: pricedLineItemsToTax,
serviceLocationAddressLine1:
store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2:
store.getters.order.serviceLocation?.address2,
}, },
"payment-method", "payment-method",
false false

View file

@ -343,3 +343,14 @@ export function splitCMSCopyOnParagraphTag(copy) {
export function splitCMSCopyOnBR(copy) { export function splitCMSCopyOnBR(copy) {
return copy.split("<br>"); return copy.split("<br>");
} }
// 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);
}

View file

@ -20,7 +20,6 @@ export async function loadSessionIfPresent(isConceptInsurance, pageNameToLog) {
// Do nothing if there is no cookie or session to use for loading. // Do nothing if there is no cookie or session to use for loading.
if ( if (
funnelCookie == null || funnelCookie == null ||
funnelCookie.SavedSessionId == null ||
funnelCookie.ReferralCorrelationId == null || funnelCookie.ReferralCorrelationId == null ||
!funnelCookie.ReferralNumber !funnelCookie.ReferralNumber
) { ) {
@ -128,7 +127,7 @@ async function loadSession(
const response = await baseMixin.methods.dispatchStoreActionWithLogging( const response = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOAD_SESSION, storeActions.LOAD_SESSION,
{ {
savedSessionId: savedSessionId?.toString(), savedSessionId: savedSessionId != null ? savedSessionId.toString() : null,
referralNumber, referralNumber,
referralDate, referralDate,
referralCorrelationId, referralCorrelationId,

View file

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

View file

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

View file

@ -0,0 +1,11 @@
import store from "@/store";
export function hasVehicleInfo() {
return (
store.getters.order?.vehicle.year &&
store.getters.order?.vehicle?.make &&
store.getters.order?.vehicle?.model &&
store.getters.order?.vehicle?.style &&
store.getters.order?.vehicle?.carId
);
}

View file

@ -470,7 +470,8 @@ export default {
} }
}, },
donationValues() { donationValues() {
return [1, 3, 5]; var donationAmounts = this.getCmsContent("DonationAmountsWidget", "Text");
return donationAmounts.split(",").map(Number);
}, },
lineItemsWithoutDonation() { lineItemsWithoutDonation() {
const lineItemsClone = deepClone(this.lineItems); const lineItemsClone = deepClone(this.lineItems);
@ -531,10 +532,11 @@ export default {
window.location.assign(location.protocol + "//" + location.host); window.location.assign(location.protocol + "//" + location.host);
}, },
async addDonationToOrder() { async addDonationToOrder() {
const selectedItemNumber = this.donationValues.indexOf(Number(this.donationAmount));
const donationResponse = await baseMixin.methods.dispatchStoreActionWithLogging( const donationResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.SUBMIT_DONATION_PART_TO_SV2, storeActions.SUBMIT_DONATION_PART_TO_SV2,
{ {
donationAmount: parseInt(this.donationAmount), selectedItemNumber: selectedItemNumber,
submittedOrder: this.submittedOrder, submittedOrder: this.submittedOrder,
}, },
false false

View file

@ -89,18 +89,53 @@ describe("mobile-details.vue", () => {
// Assert // Assert
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled(); expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
}); });
test("arePagePrerequisitesValid should be true ", async () => { describe("arePagePrerequisitesValid", () => {
//Arrange test("returns true when all prerequisites are valid", () => {
const { wrapper } = setupMocks({ const { wrapper } = setupMocks({
mixins: [mockMixin], mixins: [mockMixin],
attachTo: document.body, attachTo: document.body,
});
const result = wrapper.vm.arePagePrerequisitesValid();
expect(result).toBe(true);
}); });
//Act test("returns falsy when service zip info is missing", () => {
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); store.getters.order.serviceLocation.zipCode = null;
const { wrapper } = setupMocks({
mixins: [mockMixin],
attachTo: document.body,
});
//Assert const result = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(true);
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 () => { test("if the back button is clicked, navigate back", async () => {

View file

@ -78,6 +78,7 @@ import { settleAllPromises } from "@/helpers/layout-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import textLink from "@/ux-components/text-link/text-link"; import textLink from "@/ux-components/text-link/text-link";
import analyticsMixin from "@/mixins/analytics-mixin"; import analyticsMixin from "@/mixins/analytics-mixin";
import { flushPagePrereqsLogs, hasSchedulingInfo } from "@/helpers/page-prerequisites-helper";
export default { export default {
name: "MobileDetails", name: "MobileDetails",
data() { data() {
@ -110,6 +111,9 @@ export default {
}, },
methods: { methods: {
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
const logQueue = [];
const order = store.getters.order;
const serviceLocation = store.getters.order.serviceLocation; const serviceLocation = store.getters.order.serviceLocation;
const serviceLocationPreReqs = const serviceLocationPreReqs =
serviceLocation.zipCode && serviceLocation.zipCode &&
@ -118,16 +122,12 @@ export default {
serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE; serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
// Schedule // Schedule
const schedule = store.getters.order.schedule; const scheduleInfoPreReqs = hasSchedulingInfo(order, logQueue);
const scheduleReqs = !!(
schedule.date && const preReqResult = serviceLocationPreReqs && scheduleInfoPreReqs;
schedule.startTime &&
schedule.endTime && flushPagePrereqsLogs("mobile-details.vue", preReqResult, logQueue);
schedule.jobMaxMinutes &&
schedule.jobMinMinutes
);
const preReqResult = serviceLocationPreReqs && scheduleReqs;
return preReqResult; return preReqResult;
}, },
getServiceAddressFromStore() { getServiceAddressFromStore() {

View file

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

View file

@ -83,7 +83,15 @@ import cart from "@/fmg-components/cart/cart";
import { coverageStatus } from "@/constants/insurance"; import { coverageStatus } from "@/constants/insurance";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
import store from "@/store"; 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 { export default {
name: "payment-adyen", name: "payment-adyen",
@ -136,110 +144,19 @@ export default {
}, },
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
// Service Location const order = store.getters.order;
const serviceLocation = store.getters.order.serviceLocation; const logQueue = [];
const mobileReqs = !!( const results = [
serviceLocation.address && hasServiceLocationInfo(order, logQueue),
serviceLocation.city && hasInsuranceInfo(order, logQueue),
serviceLocation.state && hasSchedulingInfo(order, logQueue),
serviceLocation.zipCode hasCustomerInfo(order, logQueue),
); hasPaymentMethodInfo(order, logQueue),
hasGlassPartsOrRepairInfo(order, logQueue),
const providerLocation = serviceLocation.provider.address; ];
const dropOffInshopReqs = !!( const result = results.every(Boolean);
providerLocation.streetAddress && flushPagePrereqsLogs("payment-adyen.vue", result, logQueue);
providerLocation.city && return result;
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;
}, },
async initializeAdyen() { async initializeAdyen() {

View file

@ -8,6 +8,7 @@ import store from "@/store";
import { experimentSettings } from "@/constants/experiments"; import { experimentSettings } from "@/constants/experiments";
import { storeMutations } from "@/constants/store-mutations"; import { storeMutations } from "@/constants/store-mutations";
import globalMethods from "@/global-methods"; import globalMethods from "@/global-methods";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
globalMethods.callHttpClient = jest.fn(); globalMethods.callHttpClient = jest.fn();
@ -29,8 +30,64 @@ jest.mock("@/helpers/pricing-helper.js", () => ({
getAmountDue: jest.fn(), getAmountDue: jest.fn(),
})); }));
jest.mock("@/helpers/debug-log-helper.js", () => ({
debugLog: jest.fn(),
}));
let piaDisabledFlag = false; 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("payment-method.vue", () => {
describe("navigation", () => { describe("navigation", () => {
test("if the back button is clicked, navigate back", async () => { test("if the back button is clicked, navigate back", async () => {
@ -54,6 +111,63 @@ describe("payment-method.vue", () => {
expect(wrapper.vm.$router.navigateWithoutSaving).toHaveBeenCalled(); 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() { function setupMocks() {
@ -65,34 +179,9 @@ function setupMocks() {
vaps: [], vaps: [],
promos: [], promos: [],
}, },
order: { order: createValidOrderForPaymentMethod(),
payment: { applicationUser: {
isPia: false, experiments: [],
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 },
}, },
policy: { policy: {
currentDeductible: 123, currentDeductible: 123,

View file

@ -53,6 +53,17 @@
v-bind:isDismissible="false" /> v-bind:isDismissible="false" />
</div> </div>
<div>
<alert
ref="UnverifiedInsuranceAlert"
class="mt-5 mb-5"
cmsWidgetName="UnverifiedInsuranceAlertWidget"
v-if="shouldDisplayUnverifiedInsuranceAlert"
:showInfoIcon="true"
:showHorizontalRow="true"
alertClass="alert-info" />
</div>
<div <div
v-if="isRecalibrationOnOrder && shouldHideRecalibration" v-if="isRecalibrationOnOrder && shouldHideRecalibration"
class="questions-about-service mt-5"> class="questions-about-service mt-5">
@ -167,7 +178,14 @@ import { containsRecalParts } from "@/helpers/recal-helper";
import { getBoolFromString } from "@/helpers/boolean-helper"; import { getBoolFromString } from "@/helpers/boolean-helper";
import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js"; import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { debugLog } from "@/helpers/debug-log-helper"; import {
flushPagePrereqsLogs,
hasServiceLocationInfo,
hasInsuranceInfo,
hasSchedulingInfo,
hasCustomerInfo,
hasGlassPartsOrRepairInfo,
} from "@/helpers/page-prerequisites-helper.js";
import { ErrorMessage } from "vee-validate"; import { ErrorMessage } from "vee-validate";
import { Field } from "vee-validate"; import { Field } from "vee-validate";
import experimentMixin from "../../mixins/experiment-mixin"; import experimentMixin from "../../mixins/experiment-mixin";
@ -311,6 +329,8 @@ export default {
serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: lineItemsForCart, pricedLineItems: lineItemsForCart,
serviceLocationAddressLine1: store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2,
}, },
"payment-method", "payment-method",
false false
@ -368,91 +388,19 @@ export default {
this.$router.navigateWithoutSaving(scenarioName, this.pageName); this.$router.navigateWithoutSaving(scenarioName, this.pageName);
}, },
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
// Service Location const order = store.getters.order;
const serviceLocation = store.getters.order.serviceLocation; const logQueue = [];
const mobileReqs = !!( const results = [
serviceLocation.address && hasServiceLocationInfo(order, logQueue),
serviceLocation.city && hasInsuranceInfo(order, logQueue),
serviceLocation.state && hasSchedulingInfo(order, logQueue),
serviceLocation.zipCode hasCustomerInfo(order, logQueue),
); hasGlassPartsOrRepairInfo(order, logQueue),
];
const providerLocation = serviceLocation.provider.address; //false if any check is false but calls all checks for logging purposes
const dropOffInshopReqs = !!( const result = results.every(Boolean);
providerLocation.streetAddress && flushPagePrereqsLogs("payment-method.vue", result, logQueue);
providerLocation.city && return result;
providerLocation.state &&
providerLocation.zipCode
);
const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
const serviceLocationReqs =
(isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs);
// Insurance
const isInsurance = store.getters.order.payment.isInsurance;
const insuranceCoverageStatus = store.getters.payment.insuranceCoverage.coverageStatus;
const currentDeductible = store.getters.policy.currentDeductible;
const isInsuranceSet = () => {
if (isInsurance !== null) {
if (
insuranceCoverageStatus === coverageStatus.VERIFIED &&
!(currentDeductible === 0 || currentDeductible > 0)
) {
return false; // if coverageStatus is verified there must be a valid deductible also
}
return true;
}
return false;
};
// Schedule
const schedule = store.getters.order.schedule;
const scheduleReqs = !!(
schedule.date &&
schedule.startTime &&
schedule.endTime &&
schedule.jobMaxMinutes &&
schedule.jobMinMinutes
);
// Customer
const customer = store.getters.order.customer;
const customerReqs = !!(
customer.firstName &&
customer.lastName &&
customer.phoneNumber &&
customer.emailAddress
);
const preReqResult =
serviceLocationReqs && isInsuranceSet() && scheduleReqs && customerReqs;
// prettier-ignore
{
debugLog("--- payment-method.vue pagePrereqs start ---", null, !preReqResult);
debugLog("serviceLocationRequs::isMobile:", isMobile, !preReqResult);
debugLog("serviceLocationReqs::mobileReqs:", mobileReqs, !preReqResult);
debugLog("serviceLocationReqs::dropOffInshopReqs:", dropOffInshopReqs, !preReqResult);
debugLog("serviceLocationReqs:: result", serviceLocationReqs, !preReqResult);
debugLog("isInsuranceSet::", isInsuranceSet(), !preReqResult);
debugLog("scheduleReqs::schedule.date:", schedule.date, !preReqResult);
debugLog("scheduleReqs::schedule.startTime:", schedule.startTime, !preReqResult);
debugLog("scheduleReqs::schedule.endTime:", schedule.endTime, !preReqResult);
debugLog("scheduleReqs::schedule.jobMaxMinutes:", schedule.jobMaxMinutes, !preReqResult);
debugLog("scheduleReqs::schedule.jobMinMinutes:", schedule.jobMinMinutes, !preReqResult);
debugLog("customerReqs::customer.firstName:", customer.firstName, !preReqResult);
debugLog("customerReqs::customer.lastName:", customer.lastName, !preReqResult);
debugLog("customerReqs::customer.phoneNumber:", customer.phoneNumber, !preReqResult);
debugLog("customerReqs::customer.emailAddress:", customer.emailAddress, !preReqResult);
debugLog("--- payment-method.vue pagePrereqs end ---", null, !preReqResult);
}
return preReqResult;
}, },
getPaymentMethodFromStore() { getPaymentMethodFromStore() {
const piaType = store.getters.order.payment.piaType; const piaType = store.getters.order.payment.piaType;
@ -515,6 +463,10 @@ export default {
serviceLocationState: this.$store.getters.order.serviceLocation.state, serviceLocationState: this.$store.getters.order.serviceLocation.state,
serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode, serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode,
pricedLineItems: this.lineItems, pricedLineItems: this.lineItems,
serviceLocationAddressLine1:
this.$store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2:
this.$store.getters.order.serviceLocation?.address2,
}, },
"payment-method", "payment-method",
false false
@ -639,6 +591,8 @@ export default {
serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: this.lineItems, pricedLineItems: this.lineItems,
serviceLocationAddressLine1: store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2,
}, },
"payment-method", "payment-method",
false false
@ -865,6 +819,13 @@ export default {
shouldDisplayPiaAlert() { shouldDisplayPiaAlert() {
return getBoolFromString(this.$route?.query?.piaError); return getBoolFromString(this.$route?.query?.piaError);
}, },
shouldDisplayUnverifiedInsuranceAlert() {
const shouldDisplayUnverifiedDeduct = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_UNVERIFIED_DEDUCT_ENTRY,
"true"
);
return shouldDisplayUnverifiedDeduct;
},
// Necessary to make the watcher of lineItems work // Necessary to make the watcher of lineItems work
// JavaScript does not keep a record of the old value, only a reference to it's location in the memory. // JavaScript does not keep a record of the old value, only a reference to it's location in the memory.
// This creates a separate location for oldValue/newValue so they can be compared // This creates a separate location for oldValue/newValue so they can be compared
@ -1033,7 +994,15 @@ export default {
} }
} }
} }
.alert.alert-info.widget-name-UnverifiedInsuranceAlertWidget {
:deep(.alert-heading) {
color: $black;
font-weight: 600;
}
}
} }
:deep(span.label), :deep(span.label),
:deep(span) { :deep(span) {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif; font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;

View file

@ -258,6 +258,52 @@ describe("payment.vue", () => {
expect(result).toBe(true); 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", () => { describe("Payment method cases", () => {
test("Returns false if pia options are not valid", () => { test("Returns false if pia options are not valid", () => {
// Arrange // Arrange

View file

@ -235,7 +235,6 @@ import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { applicationConfig } from "@/constants/application-config"; import { applicationConfig } from "@/constants/application-config";
import { paymentMethods } from "@/constants/payment-method-constants"; import { paymentMethods } from "@/constants/payment-method-constants";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js"; import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
@ -243,6 +242,15 @@ import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.
import { coverageStatus } from "@/constants/insurance"; import { coverageStatus } from "@/constants/insurance";
import { getDisplayAmountDue, getAmountDue } from "@/helpers/pricing-helper.js"; import { getDisplayAmountDue, getAmountDue } from "@/helpers/pricing-helper.js";
import { debugLog } from "@/helpers/debug-log-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 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"; import paymentMethodListButton from "@/layouts/payment-method/payment-method-question/payment-method-list-button/payment-method-list-button";
@ -435,110 +443,19 @@ export default {
}, },
methods: { methods: {
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
// Service Location const order = store.getters.order;
const serviceLocation = store.getters.order.serviceLocation; const logQueue = [];
const mobileReqs = !!( const results = [
serviceLocation.address && hasServiceLocationInfo(order, logQueue),
serviceLocation.city && hasInsuranceInfo(order, logQueue),
serviceLocation.state && hasSchedulingInfo(order, logQueue),
serviceLocation.zipCode hasCustomerInfo(order, logQueue),
); hasPaymentMethodInfo(order, logQueue),
hasGlassPartsOrRepairInfo(order, logQueue),
const providerLocation = serviceLocation.provider.address; ];
const dropOffInshopReqs = !!( const result = results.every(Boolean);
providerLocation.streetAddress && flushPagePrereqsLogs("payment.vue", result, logQueue);
providerLocation.city && return result;
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;
}, },
getAnswersNullSafe(widgetName) { getAnswersNullSafe(widgetName) {
const rawData = this.getCmsContent(widgetName, "Answers"); const rawData = this.getCmsContent(widgetName, "Answers");

View file

@ -295,6 +295,47 @@ describe("quote.vue", () => {
expect(arePagePrerequisitesValid).toBe(false); 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 () => { test("should have non-null values for necessary data members after 'beforeRouteEnter'", async () => {
//Arrange //Arrange
store.getters = { store.getters = {

View file

@ -183,6 +183,11 @@ import { routeData } from "@/router/constants/routes";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { storeMutations } from "@/constants/store-mutations"; import { storeMutations } from "@/constants/store-mutations";
import { debugLog } from "@/helpers/debug-log-helper"; 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 { savePageData } from "@/router/methods/helpers/save-page-data";
import { quotePageDiscountTable } from "../../constants/quote-page-discounts"; import { quotePageDiscountTable } from "../../constants/quote-page-discounts";
@ -728,28 +733,27 @@ export default {
this.$refs[modalName].openModal(); this.$refs[modalName].openModal();
}, },
arePagePrerequisitesValid() { 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 = const results = [
store.getters.order.serviceLocation.zipCode && hasServiceZipInfo(order, logQueue),
store.getters.order.serviceLocation.zipCodeCtu && hasGlassPartsOrRepairInfo(order, logQueue),
(store.getters.order.damage.isRepair || ];
(store.getters.order.lineItems?.glassParts != null && const preReqResult = results.every(Boolean) && isVerifiedOk;
store.getters.order.lineItems.glassParts.length > 0)) &&
(payment.insuranceCoverage?.isVerified == null ||
payment.insuranceCoverage?.isVerified === false);
// prettier-ignore logQueue.push(() => {
{ debugLog(
debugLog("--- quote.vue pagePrereqs start ---", null, !preReqResult); "payment.insuranceCoverage?.isVerified:",
debugLog("store.getters.order.serviceLocation.zipCode:", store.getters.order.serviceLocation?.zipCode, !preReqResult); order.payment?.insuranceCoverage?.isVerified,
debugLog("store.getters.order.serviceLocation.zipCodeCtu:", store.getters.order.serviceLocation?.zipCodeCtu, !preReqResult); !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);
}
flushPagePrereqsLogs("quote.vue", preReqResult, logQueue);
return preReqResult; return preReqResult;
}, },
vapsItemsSelectedAction(vapsItemsSelected) { vapsItemsSelectedAction(vapsItemsSelected) {

View file

@ -35,6 +35,11 @@ jest.mock("@/mixins/experiment-mixin.js", () => ({
return true; return true;
} }
}, },
hasSetting(settingName) {
if (settingName === mockExperimentSettings.PROMO_ON_PACKAGE) {
return false;
}
},
hasSettingEqualTo(settingName, settingValue) { hasSettingEqualTo(settingName, settingValue) {
return false; return false;
}, },

View file

@ -163,6 +163,16 @@ export default {
})); }));
this.$emit("currentNumberOfPackages", modifiedAnswers.length); this.$emit("currentNumberOfPackages", modifiedAnswers.length);
if (this.experimentPackageOrder()) {
const orderedKeys = this.experimentPackageOrder()
.split(",")
.map((key) => key.trim());
modifiedAnswers.sort(
(a, b) => orderedKeys.indexOf(a.value) - orderedKeys.indexOf(b.value)
);
}
return modifiedAnswers; return modifiedAnswers;
}, },
isDiscountOnOrder() { isDiscountOnOrder() {
@ -235,6 +245,11 @@ export default {
) )
); );
}, },
experimentPackageOrder() {
return experimentMixin.methods.hasSetting(experimentSettings.SERVICE_PACKAGE_ORDER)
? experimentMixin.methods.getSettingValue(experimentSettings.SERVICE_PACKAGE_ORDER)
: false;
},
getButtonTypeObject() { getButtonTypeObject() {
if (this.isAfterpayBreakoutDisplay()) { if (this.isAfterpayBreakoutDisplay()) {
return this.servicePackageRadioAfterpayExperiment; return this.servicePackageRadioAfterpayExperiment;

View file

@ -79,3 +79,137 @@ export function getTodayDate() {
export function getTodayDateString() { export function getTodayDateString() {
return convertDateToDateString(getTodayDate()); return convertDateToDateString(getTodayDate());
} }
export function getMonthEnd(dateStr) {
// convert string to date, do date calc, then return a string back
const date = new Date(dateStr.split("-")[0], parseInt(dateStr.split("-")[1]), 0);
return convertDateToDateString(date);
}
export function getWeekStartDate(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
// Subtract the day of the week from date to get the date of Sunday
const sunday = new Date(date);
sunday.setDate(sunday.getDate() - dayOfWeek);
return convertDateToDateString(sunday);
}
export function getWeekEndDate(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
const daysUntilSaturday = 6 - dayOfWeek; // Calculate the number of days until Saturday
// Clone the given date and add the remaining days until Saturday
const saturday = new Date(date);
saturday.setDate(date.getDate() + daysUntilSaturday);
return convertDateToDateString(saturday);
}
export function getNextWeekSunday(dateString) {
const date = convertDateStringToDate(dateString);
const dayOfWeek = date.getDay();
const daysUntilNextSunday = 7 - dayOfWeek; // Calculate the number of days until the next Sunday
// Clone the given date and add the remaining days until Sunday
const nextSunday = new Date(date);
nextSunday.setDate(date.getDate() + daysUntilNextSunday);
return convertDateToDateString(nextSunday);
}
export function getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDate) {
const weeks = [];
let weekStartDate = getWeekStartDate(todayString);
let weekEndDate = getWeekEndDate(todayString);
if (preSelectedDate) {
let preSelectedDateMonthEnd = getMonthEnd(preSelectedDate.replace("-mobile", ""));
let monthEndsByThisWeek = false;
let i = 0;
while (!monthEndsByThisWeek && i < 52) {
if (i > 0) {
weekStartDate = getNextWeekSunday(weekEndDate);
weekEndDate = getWeekEndDate(weekStartDate);
if (
(preSelectedDateMonthEnd > weekStartDate &&
preSelectedDateMonthEnd < weekEndDate) ||
preSelectedDateMonthEnd === weekStartDate ||
preSelectedDateMonthEnd === weekEndDate
) {
weekEndDate = preSelectedDateMonthEnd;
monthEndsByThisWeek = true;
} else if (preSelectedDateMonthEnd < weekEndDate) {
// Additional case: if month ends before this week (but not necessarily during it),
// still cut the loop here, but don't truncate the week.
monthEndsByThisWeek = true;
}
}
weeks.push({
weekNum: i + 1,
weekStartDate: weekStartDate,
weekEndDate: weekEndDate,
});
i++;
}
} else {
for (let i = 0; i < initialViewRowsToShow; i++) {
if (i > 0) {
weekStartDate = getNextWeekSunday(weekEndDate);
weekEndDate = getWeekEndDate(weekStartDate);
}
weeks.push({
weekNum: i + 1,
weekStartDate: weekStartDate,
weekEndDate: weekEndDate,
});
}
// If any of these weeks is split between two months, then make them 2 separate "weeks"
// (a week split between two months is considered 2 weeks per business requirements)
const hasSplitWeek = (week) => {
return week.weekStartDate.split("-")[1] !== week.weekEndDate.split("-")[1]
? true
: false;
};
const splitWeekIndex = weeks.findIndex(hasSplitWeek);
if (splitWeekIndex > -1) {
const week1 = [];
const week2 = [];
let switchToWeek2 = false;
for (let j = 0; j < 7; j++) {
const newDate = convertDateStringToDate(weeks[splitWeekIndex].weekStartDate);
newDate.setDate(newDate.getDate() + j);
if (newDate.getDate() === 1) switchToWeek2 = true;
if (switchToWeek2) {
week2.push(convertDateToDateString(newDate));
} else {
week1.push(convertDateToDateString(newDate));
}
}
const week1EndDate = week1[week1.length - 1];
const week2StartDate = week2[0];
if (week1EndDate < todayString) {
// replace week 1 with week 2
weeks[splitWeekIndex].weekStartDate = week2StartDate;
} else {
const newWeek = {
weekNum: weeks[splitWeekIndex].weekNum,
weekStartDate: week2StartDate,
weekEndDate: weeks[splitWeekIndex].weekEndDate,
};
weeks[splitWeekIndex].weekEndDate = week1EndDate;
weeks.splice(splitWeekIndex + 1, 0, newWeek);
weeks.pop();
weeks.forEach((item, index) => {
if (index > splitWeekIndex) {
item.weekNum = item.weekNum + 1;
}
});
}
}
}
return weeks;
}

View file

@ -0,0 +1,358 @@
<template>
<div id="multi-location-modal-container">
<modal
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:isFooterButtonPrimary="true"
@footer-button-event="confirmAppointment"
@isModalOpened="setIsModalOpen">
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="multiLocationRadioButton"
:buttonTypeObject="multiLocationRadioButton"
class="mt-5"
:answers="availableAppointments"
groupName="multiLocationQuestion"
textPosition="text-center"
v-model="selectedValue"
isRequired
validationRules="time-slot-required" />
<template v-slot:modal-header-slot>
<span>{{ modalSubHeaderText }}</span>
</template>
<template v-slot:modal-footer-slot>
<button
type="button"
class="btn btn-link"
id="see-more-options"
:disabled="isLoading"
@click="closeModal">
{{ buttonText }}
</button>
</template>
</modal>
</div>
</template>
<script>
// Supporting files
import store from "@/store";
import modal from "@/digital-components/modal/modal";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { get12HourTimeFormat } from "@/helpers/date-helper";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
import buttonQuestion from "@/digital-components/button-question/button-question";
import multiLocationRadioButton from "./multi-location-radio/multi-location-radio";
// Validation
import { defineRule, useField } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
const NULL_APPOINTMENT = {
estimatedServiceMinutes: {
minimum: null,
maximum: null,
},
timeSlot: null,
date: null,
};
// Validation for the modal button
defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "multi-location-modal",
props: {
cmsWidgetName: String,
modalWidgetName: String,
buttonWidgetName: String,
},
data() {
return {
isModalOpen: false,
mobileAppointmentOption: NULL_APPOINTMENT,
inshopAppointmentOption: NULL_APPOINTMENT,
confirmedAppointment: false,
multiLocationRadioButton: multiLocationRadioButton,
selectedValue: null,
};
},
methods: {
splitCopyOnCMSPlaceHolder,
get12HourTimeFormat,
openModal() {
this.modal.openModal();
},
onModalClosed() {
this.pushEvent();
this.confirmedAppointment = false;
},
closeModal() {
this.modal.closeModal();
},
confirmAppointment() {
if (!this.selectedAppointment.timeSlot.id) return;
const selectedTimeSlot = {
timeSlot: this.selectedAppointment.timeSlot,
routeCode: this.selectedAppointment.timeSlot.id,
date: this.selectedAppointment.date,
provider: this.selectedAppointment.provider,
};
this.confirmedAppointment = true;
this.$emit("confirm-appointment", {
selectedTimeSlot: selectedTimeSlot,
appointmentType: this.selectedValue,
});
this.modal.closeModal();
},
setPromotedMobileAppointment(appointment) {
this.mobileAppointmentOption = appointment;
},
setPromotedInshopAppointment(appointment) {
this.inshopAppointmentOption = appointment;
},
getIsModalOpen() {
return this.isModalOpen;
},
setIsModalOpen(isOpen) {
this.isModalOpen = isOpen;
},
selectedSlotEventValue() {
const daysUntilAppointment =
(new Date(this.selectedAppointment.date) - new Date()) / (1000 * 60 * 60 * 24);
const isAM = this.selectedAppointment.timeSlot.startTime < "12:00:00";
return (
"AM: " +
isAM +
" | " +
"PM: " +
!isAM +
" | " +
"Days: " +
Math.ceil(daysUntilAppointment)
);
},
pushEvent() {
let gaAction = this.confirmedAppointment ? this.GaActions.YES : this.GaActions.NO;
if (
this.selectedValue?.toLowerCase() === "mobile" &&
this.mobileAppointmentOption?.timeSlot
) {
this.pushEventToGA(
this.GaCategories.MOBILE_CONFIRMATION_CLICKED,
gaAction,
this.mobileAppointmentOption.timeSlot.startTime +
"-" +
this.mobileAppointmentOption.timeSlot.endTime +
" " +
this.mobileAppointmentOption.date,
true,
null,
this.selectedSlotEventValue()
);
} else if (
this.selectedValue?.toLowerCase() === "inshop" &&
this.inshopAppointmentOption?.timeSlot
) {
this.pushEventToGA(
this.GaCategories.INSHOP_CONFIRMATION_CLICKED,
gaAction,
this.inshopAppointmentOption.timeSlot.startTime +
" " +
this.inshopAppointmentOption.date,
true,
null,
this.selectedSlotEventValue()
);
}
},
},
computed: {
selectedAppointment() {
if (this.selectedValue?.toLowerCase() === "mobile") {
return this.mobileAppointmentOption;
} else if (this.selectedValue?.toLowerCase() === "inshop") {
return this.inshopAppointmentOption;
} else {
return NULL_APPOINTMENT;
}
},
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
modalSubHeaderText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
buttonText() {
return this.getCmsContent(this.modalWidgetName, "FooterText2");
},
mobileButtonLabel() {
return this.getCmsContent(this.buttonWidgetName, "HeaderText");
},
inshopButtonLabel() {
return this.getCmsContent(this.buttonWidgetName, "SubheaderText");
},
mobileButtonDate() {
if (!this.mobileAppointmentOption.date) return "";
return new Date(this.mobileAppointmentOption.date).toLocaleDateString("en-US", {
timeZone: "UTC",
weekday: "short",
month: "long",
day: "numeric",
});
},
inshopButtonDate() {
if (!this.inshopAppointmentOption.date) return "";
return new Date(this.inshopAppointmentOption.date).toLocaleDateString("en-US", {
timeZone: "UTC",
weekday: "short",
month: "long",
day: "numeric",
});
},
mobileButtonTime() {
const timeslot =
this.get12HourTimeFormat(this.mobileAppointmentOption.timeSlot?.startTime) +
" - " +
this.get12HourTimeFormat(this.mobileAppointmentOption.timeSlot?.endTime);
return timeslot;
},
inshopButtonTime() {
const timeslot = this.get12HourTimeFormat(
this.inshopAppointmentOption.timeSlot?.startTime
);
return timeslot;
},
mobileLocationLabel() {
return this.getCmsContent(this.buttonWidgetName, "BodyText");
},
inshopLocationLabel() {
return this.inshopAppointmentOption?.addressCopy;
},
mobileServiceDuration() {
const durationTime = getDisplayTextForDurationLength(
this.mobileAppointmentOption.estimatedServiceMinutes.minimum,
this.mobileAppointmentOption.estimatedServiceMinutes.maximum
);
const footerText = this.getCmsContent(this.buttonWidgetName, "FooterText").replaceAll(
"{custom:serviceLength}",
durationTime
);
return footerText;
},
inshopServiceDuration() {
const durationTime = getDisplayTextForDurationLength(
this.inshopAppointmentOption.estimatedServiceMinutes.minimum,
this.inshopAppointmentOption.estimatedServiceMinutes.maximum
);
const footerText = this.getCmsContent(this.buttonWidgetName, "FooterText").replaceAll(
"{custom:serviceLength}",
durationTime
);
return footerText;
},
availableAppointments() {
return [
{
value: "mobile",
buttonLabel: this.mobileButtonDate,
buttonLabelSubCopy: this.mobileButtonTime,
buttonBodyCopy: this.mobileLocationLabel,
buttonAuxillaryCopy: this.mobileButtonLabel,
buttonFooterCopy: this.mobileServiceDuration,
},
{
value: "inshop",
buttonLabel: this.inshopButtonDate,
buttonLabelSubCopy: this.inshopButtonTime,
buttonBodyCopy: this.inshopLocationLabel,
buttonAuxillaryCopy: this.inshopButtonLabel,
buttonFooterCopy: this.inshopServiceDuration,
},
];
},
},
components: { modal, buttonQuestion },
};
</script>
<style lang="scss">
#multi-location-modal-container {
.modal-component {
@include media-breakpoint-up(md) {
.modal-dialog {
left: 0;
align-content: center;
flex-wrap: wrap;
width: 22.063rem;
transform: translate(0, 0);
.modal-content {
border-radius: $border-radius-lg;
}
}
}
.modal-dialog {
.modal-content {
.modal-header {
flex-direction: column;
text-align: center;
padding: 0 1rem;
margin-top: 4rem;
& > span {
color: $red;
font-weight: $font-weight-600;
text-transform: uppercase;
font-family: $font-family-sans-serif-semibold;
}
.modal-title {
font-size: $font-size-20;
font-weight: $font-weight-normal;
}
}
.modal-body {
padding: 0.5rem 1rem;
.button-question {
margin-top: 0.75rem;
}
.modal-body-inner {
ul {
list-style: none;
padding: 1rem;
background-color: #f4f4f4;
font-size: $font-size-14;
}
}
}
.modal-footer {
flex-flow: wrap-reverse;
#see-more-options {
width: 100%;
text-decoration: none;
margin-top: 1.5rem;
font-weight: $font-weight-600;
}
}
}
}
}
}
</style>

View file

@ -0,0 +1,121 @@
import { mount } from "@vue/test-utils";
import multiLocationRadio from "./multi-location-radio";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
describe("multi-location-radio.vue", () => {
it("Should include buttonLabel in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabel"]));
});
it("Should include buttonLabelSubCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabelSubCopy"]));
});
it("Should include buttonFooterCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonFooterCopy"]));
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when provided with a <ul><li>...</li>(x5)</ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
it("Should get strings from getArrayOfListItemsFromRawCmsCopy without <ul> or <li> tags when provided with a <ul><li>...</li></ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
const fileredResults = results.filter((result) => {
return (
result.includes("<ul>") ||
result.includes("</ul>") ||
result.includes("<li>") ||
result.includes("</li>")
);
});
expect(fileredResults.length).toBe(0);
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when buttonBodyCopy has 6 total <li>...</li>, but one is empty ", async () => {
// Arrange
const moddedProps = mockProps;
moddedProps["buttonBodyCopy"] =
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li><li></li></ul>";
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: moddedProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
});
const mockProps = {
groupName: "group-name",
buttonLabel: "buttonLabel test copy",
buttonLabelAuxillaryCopy: "buttonLabelAuxillaryCopy test copy",
buttonLabelSubCopy: "buttonLabelSubCopy test copy",
buttonBodyCopy:
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>",
buttonFooterCopy: "buttonFooterCopy test copy",
buttonAuxillaryCopy: "buttonAuxillaryCopy test copy",
};
function setupMocks({ mountOptionsMockData = {} }) {
const wrapper = mount(multiLocationRadio, {
...mountOptionsMockData,
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -0,0 +1,312 @@
<template>
<baseInputButton v-bind="$props" @buttonClicked="handleAnswerChange" v-model="selectedValue">
<div class="radio-label" for="testradio">
<div class="location-specs">
<div class="row">
<div class="col md-6">
<p class="m-0">
<span v-html="this.buttonLabel"></span>
</p>
<span class="service-tag" v-html="this.buttonAuxillaryCopy"></span>
<p
class="sub-label m-0"
v-if="this.buttonLabelSubCopy"
v-html="this.buttonLabelSubCopy"></p>
</div>
<div class="col md-6 location-info">
<div
class="location-info-label"
v-if="this.buttonBodyCopy && this.value === 'mobile'"
v-html="this.buttonBodyCopy"></div>
<div
class="location-info-label"
v-if="this.buttonBodyCopy && this.value === 'inshop'">
<textLink
linkType="newWindowLink"
:text="this.buttonBodyCopy"
:href="inshopHref"
target="_blank" />
</div>
</div>
<div class="col md-6 time-info" v-html="this.buttonFooterCopy"></div>
</div>
</div>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import textLink from "@/ux-components/text-link/text-link";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
} from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js";
export default {
name: "multiLocationRadioButton",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
textLink,
},
computed: {
arrayOfListItemsFromBodyText() {
return this.getArrayOfListItemsFromRawCmsCopy(this.buttonBodyCopy);
},
shouldDisplayStrikeThroughPrice() {
const displayPrice = this.buttonAuxillaryCopy?.substring(
this.buttonAuxillaryCopy.indexOf("$")
);
return displayPrice != this.additionalButtonData?.strikeThroughPrice;
},
isAppleBrowser() {
return baseMixin.methods.isAppleBrowser();
},
inshopHref() {
if (this.isAppleBrowser) {
return "https://maps.apple.com/?q=" + encodeURIComponent(this.buttonBodyCopy) + '"';
}
return (
"https://www.google.com/maps/search/?api=1&query=" +
encodeURIComponent(this.buttonBodyCopy) +
'"'
);
},
},
methods: {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
stripUlTagFromCopy(copy) {
const regex = /(?:<ul(?:.*?)>)|(?:<\/ul>)/g;
return copy?.replace(regex, "");
},
getArrayOfListItemsFromRawCmsCopy(copy) {
const withoutUlTags = this.stripUlTagFromCopy(copy);
return withoutUlTags
?.split(/(?:<li(?:.*?)>)|(?:<\/li>)/g)
?.filter((lineItem) => lineItem);
},
},
};
</script>
<style lang="scss">
.multi-location-wrapper {
margin: 1rem 0;
&:first-child {
margin: 1rem 0 0 0;
}
&:last-child {
margin: 1rem 0 0 0;
}
label {
display: block;
@include media-breakpoint-up(md) {
display: flex;
height: 100%;
}
}
input[type="radio"] {
opacity: 0;
position: absolute;
left: -9999px;
+ .radio-label {
display: flex;
align-items: flex-start;
position: relative;
cursor: pointer;
width: 100%;
padding: 0.75rem;
border: 1px solid $gray-300;
box-shadow:
0px 4px 8px -4px rgba(0, 0, 0, 0.15),
0px 4px 24px -8px rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
overflow: hidden;
min-height: 52px;
max-height: 126px;
@include media-breakpoint-up(md) {
padding-bottom: 3rem;
}
transition: max-height 0.25s ease-in;
&.has-subheader {
min-height: 72px;
}
&:before {
content: "";
position: relative;
top: 5px;
margin-right: 0.5rem;
border-radius: 50%;
border: 1px solid $gray-500;
width: 16px;
height: 16px;
min-width: 16px;
}
&:after {
content: "";
position: absolute;
left: 15px;
top: 20px;
border-radius: 50%;
width: 10px;
height: 10px;
min-width: 10px;
}
}
&:hover {
+ .radio-label {
&:before {
border: 1px solid #8e9292;
box-shadow:
0px 0px 0px 4px #9fcee6,
0px 1px 4px rgba(0, 0, 0, 0.2);
}
}
}
&:checked {
+ .radio-label {
.location-specs {
.hide-when-closed {
display: block;
}
.service-tag {
background: $blue;
color: $blue-150;
}
}
}
+ .radio-label {
border: 1px solid $blue;
background-color: $blue-100;
}
+ .radio-label {
&:before {
box-shadow: 0px 0px 0px 1px $blue;
}
}
+ .radio-label {
&:after {
background: $blue;
}
}
}
&:focus {
+ .radio-label {
&:before {
border: 2px solid $blue;
}
}
}
}
.location-specs {
display: flex;
flex-direction: column;
width: 100%;
transition: all 0.5s ease;
.row {
align-items: center;
.col {
flex: 1 0 100%;
}
.service-tag {
font-size: $font-size-12;
font-family: $font-family-sans-serif-semibold;
position: absolute;
top: 0;
right: 0;
background: $blue-150;
color: $blue;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
margin: 0.5rem;
}
p {
font-size: $font-size-14;
& > span {
color: $black;
font-family: $font-family-sans-serif-semibold;
font-weight: $font-weight-bold;
font-size: $font-size-base;
}
}
.sub-label {
margin-bottom: 0.5rem !important;
border-bottom: 1px solid $gray-1000;
}
}
.location-info-label,
.time-info {
font-family: $font-family-sans-serif;
font-size: $font-size-12;
}
.location-info-label {
display: flex;
align-items: center;
line-height: 1.625rem;
&:before {
content: "";
display: inline-block;
position: relative;
top: 0;
margin-right: 0.375rem;
width: 0.75rem;
height: 1rem;
background-image: url(~@/assets/img/icons/location-pin.svg);
background-repeat: no-repeat;
background-position: 0 50%;
background-size: 0.75rem auto;
padding: 0 0.75rem 0 0;
@include blue-filter();
}
& > a.new-window-link {
margin-bottom: 0;
font-family: $font-family-sans-serif;
font-size: $font-size-12;
padding-bottom: 0;
}
}
@keyframes slideaway {
from {
display: block;
}
to {
transform: translateY(40px);
opacity: 0;
}
}
.hide-when-closed {
display: none;
@include media-breakpoint-up(md) {
display: flex;
flex-direction: column;
}
}
}
}
</style>

View file

@ -57,6 +57,40 @@ jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateToHeritageFunnel: jest.fn(), 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("schedule.vue", () => {
describe("navigation", () => { describe("navigation", () => {
test("backButtonAction => non-insurance: navigateWithoutSaving called", () => { test("backButtonAction => non-insurance: navigateWithoutSaving called", () => {
@ -149,8 +183,67 @@ describe("schedule.vue", () => {
expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled(); 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 = {}) { function setupMocks(mountOptionsMockData = {}) {
const route = { name: "schedule" }; const route = { name: "schedule" };
const defaultMountOptions = { const defaultMountOptions = {

View file

@ -5,7 +5,12 @@
<mobileFirstModal <mobileFirstModal
modalWidgetName="MobileFirstModalWidget" modalWidgetName="MobileFirstModalWidget"
ref="mobileFirstModal" ref="mobileFirstModal"
@confirm-appointment="updateTimeSlotandNavigateForward" /> @confirm-appointment="updateMobileFirstTimeSlotandNavigateForward" />
<multiLocationModal
modalWidgetName="MultiLocationModalWidget"
buttonWidgetName="MultiLocationRadioButtonWidget"
ref="multiLocationModal"
@confirm-appointment="updateMultiLocationTimeSlotandNavigateForward" />
<recalAckModal <recalAckModal
modalWidgetName="RecalAckModalWidget" modalWidgetName="RecalAckModalWidget"
agreementWidgetName="RecalAgreementQuestionWidget" agreementWidgetName="RecalAgreementQuestionWidget"
@ -14,7 +19,9 @@
ref="recalAckModal" /> ref="recalAckModal" />
<div <div
class="container page-container-grouped-styles" class="container page-container-grouped-styles"
:class="[isMobileFirstModalOpen ? 'hidden-background' : '']"> :class="[
isMobileFirstModalOpen || isMultiLocationModalOpen ? 'hidden-background' : '',
]">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">
<mobileFeeWaiverAlert <mobileFeeWaiverAlert
@ -28,7 +35,6 @@
cmsWidgetName="ScheduleYourServiceWidget" cmsWidgetName="ScheduleYourServiceWidget"
id="schedule-your-service" /> id="schedule-your-service" />
<appointmentTypeQuestion <appointmentTypeQuestion
v-if="hasSelectableDatesLoaded"
v-model="appointmentTypeFromAppointmentTypeQuestion" v-model="appointmentTypeFromAppointmentTypeQuestion"
v-show="isAppointmentTypeDisplayed" v-show="isAppointmentTypeDisplayed"
:isServiceableMobile="isServiceableMobile" :isServiceableMobile="isServiceableMobile"
@ -36,7 +42,6 @@
:isDisplayed="isAppointmentTypeDisplayed" :isDisplayed="isAppointmentTypeDisplayed"
:mobileFeeApplies="mobileFeeApplies" :mobileFeeApplies="mobileFeeApplies"
:zipCode="zipCode" :zipCode="zipCode"
ref="appointmentTypeQuestion"
groupName="appointmentTypeQuestion" groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget" cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" validationRules="option-required"
@ -76,6 +81,16 @@
cmsWidgetName="AlertNoShopsWidget" cmsWidgetName="AlertNoShopsWidget"
v-if="displayNoShopsAlert" v-if="displayNoShopsAlert"
alertClass="alert-warning" /> alertClass="alert-warning" />
<alert
ref="alertMSRNotCoveredByInsurance"
class="mt-5 mb-5"
v-if="isMSRFeeNotCoveredByInsurance"
:manualHeadline="MSRFeeAlertHeadlineText"
:manualCopy="MSRFeeAlertBodyText"
:showWarningIcon="true"
:hasBorder="true"
:showHorizontalRow="true"
alertClass="alert-warning" />
</div> </div>
</div> </div>
<div <div
@ -193,6 +208,7 @@ import shopLocation from "@/layouts/schedule/shop-location/shop-location";
import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue"; import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue";
import durationTextBlock from "@/layouts/schedule/duration-text-block/duration-text-block.vue"; import durationTextBlock from "@/layouts/schedule/duration-text-block/duration-text-block.vue";
import mobileFirstModal from "@/layouts/schedule/mobile-first-modal/mobile-first-modal.vue"; import mobileFirstModal from "@/layouts/schedule/mobile-first-modal/mobile-first-modal.vue";
import multiLocationModal from "@/layouts/schedule/multi-location-modal/multi-location-modal.vue";
import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue"; import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question"; import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
@ -223,7 +239,11 @@ import {
RECAL_ACK_YES, RECAL_ACK_YES,
} from "@/constants/schedule-constants"; } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import {
fetchCmsContentForPage,
splitCopyOnCMSPlaceHolder,
formatToUSDollar,
} from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { import {
getPricedMobileFeePart, getPricedMobileFeePart,
@ -239,13 +259,14 @@ import { partNumberStrings } from "@/constants/part-number-strings";
import store from "@/store"; import store from "@/store";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { import {
calcDaysBetweenDates, calcDaysBetweenDates,
convertDateStringToDate, convertDateStringToDate,
sumDateString, sumDateString,
isDropOffRouteCode, isDropOffRouteCode,
getTodayDate, getTodayDate,
getTodayDateString,
getInitialViewWeeks,
} from "@/layouts/schedule/helpers/schedule-helper"; } from "@/layouts/schedule/helpers/schedule-helper";
import { containsRecalParts, anyPartWithRequiresRecalFlag } from "@/helpers/recal-helper"; import { containsRecalParts, anyPartWithRequiresRecalFlag } from "@/helpers/recal-helper";
@ -255,6 +276,12 @@ import { getAmountDue, getPricingByDayPartWithPrice } from "@/helpers/pricing-he
import { getItemsWithoutRecalParts } from "@/helpers/recal-helper"; import { getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
import {
flushPagePrereqsLogs,
hasServiceZipInfo,
hasGlassPartsOrRepairInfo,
hasInsuranceInfo,
} from "@/helpers/page-prerequisites-helper.js";
import { import {
getSessionKeyValue, getSessionKeyValue,
getUserIdValue, getUserIdValue,
@ -477,11 +504,9 @@ export default {
shopListButton: shopListButton, shopListButton: shopListButton,
lastSelectedInshopOrDropoffProvider: null, lastSelectedInshopOrDropoffProvider: null,
selectedMobileFirstAppointment: false, selectedMobileFirstAppointment: false,
isShowMobileFirstAppt: experimentMixin.methods.hasSettingEqualTo( selectedMultiLocationAppointment: false,
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
),
calendarLoadingStatus: "none", calendarLoadingStatus: "none",
isLoadingMultiLocationPopup: true,
}; };
}, },
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -612,8 +637,9 @@ export default {
pricedMobileFeePart, pricedMobileFeePart,
shopProviderData.data shopProviderData.data
); );
vm.initializeDatePicker().then(() => { vm.initializeDatePicker().then(async () => {
vm.showMobileFirstModal(); // vm.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
await vm.showMultiLocationModal();
vm.hideLoadingModal(); vm.hideLoadingModal();
vm.calendarLoadingStatus = "none"; vm.calendarLoadingStatus = "none";
if (!vm.preSelectedDate) vm.setSelectedDateToFirstAvailable(); if (!vm.preSelectedDate) vm.setSelectedDateToFirstAvailable();
@ -679,8 +705,7 @@ export default {
isMobileStaticRecalibrationApplicable() { isMobileStaticRecalibrationApplicable() {
return ( return (
this.displayMSR && this.displayMSR &&
this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE && this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE
(this.isCashItacNoComp || this.mobileFeePart?.isInsurable)
); );
}, },
displayMSR() { displayMSR() {
@ -690,6 +715,28 @@ export default {
?.toLowerCase() === "true" ?.toLowerCase() === "true"
); );
}, },
isMSRFeeNotCoveredByInsurance() {
return (
this.isMobileSelected &&
this.isMobileStaticRecalibrationApplicable &&
this.mobileFeeHasPrice &&
this.isInsurance &&
!this.mobileFeePart?.isInsurable
);
},
MSRFeeAlertHeadlineText() {
return this.getCmsContent("AlertMSRNotCoveredByInsuranceWidget", "HeadlineText");
},
MSRFeeAlertBodyText() {
const cmsContentText = this.getCmsContent(
"AlertMSRNotCoveredByInsuranceWidget",
"BodyText"
);
return cmsContentText.replaceAll(
"{custom:mobileFee}",
formatToUSDollar(this.mobileFee)
);
},
isCashItacNoComp() { isCashItacNoComp() {
return !this.isInsurance || this.isITAC || this.isNoComp; return !this.isInsurance || this.isITAC || this.isNoComp;
}, },
@ -805,13 +852,6 @@ export default {
}; };
}, },
selectedShopAnswer() { selectedShopAnswer() {
const toTitleCase = (str) => {
if (!str) return "";
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};
if ( if (
this.selectedProvider && this.selectedProvider &&
this.selectedProvider.address && this.selectedProvider.address &&
@ -820,18 +860,11 @@ export default {
const provider = this.shopProviderData?.shopProviders?.find( const provider = this.shopProviderData?.shopProviders?.find(
(p) => p.providerNumber === this.selectedProvider?.providerNumber (p) => p.providerNumber === this.selectedProvider?.providerNumber
); );
const streetAddress = toTitleCase(this.selectedProvider.address.streetAddress); const distanceInMiles = provider?.distanceInMiles
const city = toTitleCase(this.selectedProvider.address.city); ? Math.round(provider.distanceInMiles * 2) / 2
const state = this.selectedProvider.address.state; : 0;
const zipCode = this.selectedProvider.address.zipCode;
const distanceInMiles = provider ? Math.round(provider.distanceInMiles * 2) / 2 : 0;
return { return this.getFormattedShopAddress(provider?.address, distanceInMiles);
city: `${city}`,
distance: `${distanceInMiles} mi`,
address1: `${streetAddress}`,
address2: `${city}, ${state} ${zipCode}`,
};
} }
return []; return [];
}, },
@ -902,6 +935,15 @@ export default {
? this.$refs.mobileFirstModal?.getIsModalOpen() ? this.$refs.mobileFirstModal?.getIsModalOpen()
: false; : false;
}, },
isMultiLocationModalOpen() {
// Only return false if we're done loading the popup
if (this.isLoadingMultiLocationPopup) {
return true; // Still loading, keep background hidden
}
return this.selectableDatesMobile.days && this.selectableDatesInshop.days
? this.$refs.multiLocationModal?.getIsModalOpen()
: false;
},
displayWaitList() { displayWaitList() {
if (!this.appointmentType) return false; if (!this.appointmentType) return false;
const firstMobileDateString = const firstMobileDateString =
@ -950,86 +992,65 @@ export default {
return false; return false;
}, },
hasSelectableDatesLoaded() { hasSelectableDatesLoaded() {
if (this.isMobileSelected) { if (this.isServiceableMobile) {
return this.selectableDatesMobile?.days?.length > 0; return this.selectableDatesMobile?.days?.length > 0;
} else { } else {
return this.selectableDatesInshop?.days?.length > 0; return this.selectableDatesInshop?.days?.length > 0;
} }
}, },
isShowMobileFirstAppt() {
return experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
);
},
isShowMultiLocationPopup() {
return experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MULTI_LOCATION_APPT,
"true"
);
},
initialViewStartDate() {
return getTodayDateString();
},
initialViewEndDate() {
const initialViewWeeks = getInitialViewWeeks(
this.initialViewStartDate,
NUMBER_OF_CALENDAR_ROWS_TO_SHOW_FOR_INITIAL_VIEW,
this.preSelectedDate
);
return initialViewWeeks[initialViewWeeks.length - 1].weekEndDate;
},
}, },
methods: { methods: {
splitCopyOnCMSPlaceHolder, splitCopyOnCMSPlaceHolder,
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
const paymentInfo = store.getters.payment.isInsurance !== null; const order = store.getters.order;
const damageInfo = const logQueue = [];
store.getters.order.damage.isRepair ||
(store.getters.order.lineItems?.glassParts != null && const serviceZip = hasServiceZipInfo(order, logQueue);
store.getters.order.lineItems.glassParts.length > 0); const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
const parentAccountNumberInfo = const insuranceInfo = hasInsuranceInfo(order, logQueue);
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;
// insurance drops the recycle fee on replace orders so it won't be in supportingItems // insurance drops the recycle fee on replace orders so it won't be in supportingItems
const insurancePreReqResult = const supportingItemsInfo = store.getters.lineItems?.supportingItems;
parentAccountNumberInfo && const supportingItemsPreReqs = order.payment?.isInsurance
policyNumberInfo && ? true
zipCodeInfo && : !!supportingItemsInfo;
paymentInfo &&
damageInfo;
const cashPreReqResult =
supportingItemsInfo && zipCodeInfo && paymentInfo && damageInfo;
const outputDebugLog = (preReqResult) => { const preReqResult =
// prettier-ignore serviceZip && insuranceInfo && glassPartsOrRepair && supportingItemsPreReqs;
debugLog("--- schedule.vue pagePrereqs start ---", null, !preReqResult);
debugLog( logQueue.push(() => {
"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
);
debugLog( debugLog(
"store.getters.lineItems.supportingItems:", "store.getters.lineItems.supportingItems:",
store.getters.lineItems.supportingItems, store.getters.lineItems?.supportingItems,
!preReqResult !supportingItemsPreReqs
); );
debugLog("--- schedule.vue pagePrereqs end ---", null, !preReqResult); });
};
if (store.getters.payment.isInsurance) { flushPagePrereqsLogs("schedule.vue", preReqResult, logQueue);
outputDebugLog(insurancePreReqResult); return preReqResult;
return insurancePreReqResult;
} else {
outputDebugLog(cashPreReqResult);
return cashPreReqResult;
}
}, },
setDataOnLoad(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) { setDataOnLoad(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) {
@ -1241,7 +1262,8 @@ export default {
} }
} }
this.initializeDatePicker().then(() => { this.initializeDatePicker().then(() => {
this.showMobileFirstModal(); // this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
this.showMultiLocationModal();
this.hideLoadingModal(); this.hideLoadingModal();
this.calendarLoadingStatus = "none"; this.calendarLoadingStatus = "none";
this.setSelectedDateToFirstAvailable(); this.setSelectedDateToFirstAvailable();
@ -1275,7 +1297,7 @@ export default {
}, },
async initializeDatePicker() { async initializeDatePicker() {
this.isShowMobileFirstAppt && this.showLoadingModal(); this.isShowMultiLocationPopup && this.showLoadingModal();
const includeMobileTimeSlots = this.isServiceableMobile; const includeMobileTimeSlots = this.isServiceableMobile;
const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff; const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff;
@ -1787,7 +1809,8 @@ export default {
this.zipCodeCtu = newZipCode.zipCodeCtu; this.zipCodeCtu = newZipCode.zipCodeCtu;
this.selectedDate = null; this.selectedDate = null;
this.initializeDatePicker().then(() => { this.initializeDatePicker().then(() => {
this.showMobileFirstModal(); // this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
this.showMultiLocationModal();
this.hideLoadingModal(); this.hideLoadingModal();
this.calendarLoadingStatus = "none"; this.calendarLoadingStatus = "none";
this.setSelectedDateToFirstAvailable(); this.setSelectedDateToFirstAvailable();
@ -1842,10 +1865,13 @@ export default {
} else { } else {
this.appointmentType = null; this.appointmentType = null;
} }
this.setSelectedDateToFirstAvailable(); // v-if on appointmentTypeQuestion ensures dates have been loaded by now this.setSelectedDateToFirstAvailable();
}, },
setSelectedDateToFirstAvailable() { setSelectedDateToFirstAvailable() {
this.selectedDate = this.getFirstAvailableDate(); if (this.hasSelectableDatesLoaded) {
// ensure dates have been loaded by now
this.selectedDate = this.getFirstAvailableDate();
}
}, },
showRecalAcknowledgementModal() { showRecalAcknowledgementModal() {
if (this.shouldShowRecalAckModal()) { if (this.shouldShowRecalAckModal()) {
@ -1899,7 +1925,7 @@ export default {
const order = this.$store.getters.order; const order = this.$store.getters.order;
const isRepair = order.damage.isRepair; const isRepair = order.damage.isRepair;
let preSelectedMobileAppointment = null; let promotedMobileAppointment = null;
const todaysDate = getTodayDate(); const todaysDate = getTodayDate();
let firstMobileAMAppt = await this.getFirstAvailableMobileApptByTOD("AM"); let firstMobileAMAppt = await this.getFirstAvailableMobileApptByTOD("AM");
let firstMobilePMAppt = await this.getFirstAvailableMobileApptByTOD("PM"); let firstMobilePMAppt = await this.getFirstAvailableMobileApptByTOD("PM");
@ -1997,7 +2023,7 @@ export default {
if (this.isShowMobileFirstAppt) { if (this.isShowMobileFirstAppt) {
if (pmMobileDays == 0) { if (pmMobileDays == 0) {
// Selects the first available time slot // Selects the first available time slot
preSelectedMobileAppointment = promotedMobileAppointment =
numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate
? firstMobileAMAppt ? firstMobileAMAppt
: firstMobilePMAppt; : firstMobilePMAppt;
@ -2006,20 +2032,20 @@ export default {
numberOfDaysToFirstMobilePMDate <= pmMobileDays numberOfDaysToFirstMobilePMDate <= pmMobileDays
) { ) {
// Selects the first available PM time slot // Selects the first available PM time slot
preSelectedMobileAppointment = firstMobilePMAppt; promotedMobileAppointment = firstMobilePMAppt;
} else if ( } else if (
firstMobilePMDate && firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= noPMMobileDays numberOfDaysToFirstMobilePMDate >= noPMMobileDays
) { ) {
// Selects the first available AM time slot. If none, selects the first available PM time slot // Selects the first available AM time slot. If none, selects the first available PM time slot
preSelectedMobileAppointment = firstMobileAMAppt promotedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt ? firstMobileAMAppt
: firstMobilePMAppt; : firstMobilePMAppt;
} }
if (preSelectedMobileAppointment) { if (promotedMobileAppointment) {
this.$refs.mobileFirstModal.setSelectedAppointment( this.$refs.mobileFirstModal.setSelectedAppointment(
preSelectedMobileAppointment promotedMobileAppointment
); );
this.$refs.mobileFirstModal.openModal(); this.$refs.mobileFirstModal.openModal();
} }
@ -2027,11 +2053,158 @@ export default {
} }
} }
}, },
async showMultiLocationModal() {
const showMultiLocationAppointment = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_MULTI_LOCATION_APPT
);
if (
!this.selectedRouteCodeData?.routeCode &&
showMultiLocationAppointment?.toLowerCase() === "true"
) {
let maxDayRangeToShowPmTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_PM_DAYS_MULTI_LOCATION
);
let maxDayRangeToShowNoPmTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_PM_DAYS_MULTI_LOCATION
);
let maxDayRangeToShowAnyTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION
);
let promotedMobileAppointment = null;
let promotedInshopAppointment = null;
const todaysDate = getTodayDate();
// Helper function to calculate days until appointment (handles null/undefined)
const calculateDaysUntilDate = (appointmentObj) => {
const dateString = appointmentObj?.date;
return dateString
? (new Date(dateString) - todaysDate) / (1000 * 60 * 60 * 24)
: null;
};
// Fetch all appointments
const [firstMobileAMAppt, firstMobilePMAppt, firstInshopAppts] = await Promise.all([
this.getFirstAvailableMobileApptByTOD("AM"),
this.getFirstAvailableMobileApptByTOD("PM"),
this.getFirstAvailableInshopApptsByTOD(["AM", "PM"]),
]);
// Extract dates
const firstMobileAMDate = firstMobileAMAppt?.date;
const firstMobilePMDate = firstMobilePMAppt?.date;
const firstInshopAMDate = firstInshopAppts[0]?.date;
const firstInshopPMDate = firstInshopAppts[1]?.date;
// Calculate days until appointments
const numberOfDaysToFirstMobileAMDate = calculateDaysUntilDate(firstMobileAMAppt);
const numberOfDaysToFirstMobilePMDate = calculateDaysUntilDate(firstMobilePMAppt);
const numberOfDaysToFirstInshopAMDate = calculateDaysUntilDate(firstInshopAppts[0]);
const numberOfDaysToFirstInshopPMDate = calculateDaysUntilDate(firstInshopAppts[1]);
const shouldExposeMultiLocationModal = () => {
const isMobileAppointmentInDateRange =
(firstMobileAMDate &&
numberOfDaysToFirstMobileAMDate <= maxDayRangeToShowAnyTimeslot) ||
(firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowAnyTimeslot);
const isInshopAppointmentInDateRange =
(firstInshopAMDate &&
numberOfDaysToFirstInshopAMDate <= maxDayRangeToShowAnyTimeslot) ||
(firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowAnyTimeslot);
return isMobileAppointmentInDateRange && isInshopAppointmentInDateRange;
};
if (shouldExposeMultiLocationModal()) {
const multiLocationPopupExperiment =
store.getters.applicationUser.experiments.find(
(e) => e.universeName === experimentUniverses.MULTI_LOCATION_POPUP
);
const hasExposedMultiLocationPopup = multiLocationPopupExperiment?.isExposed;
if (!hasExposedMultiLocationPopup && multiLocationPopupExperiment) {
baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOG_EXPERIMENT_EXPOSURE_AND_UPDATE_STORE,
{
userId: getUserIdValue(),
deviceId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: "schedule",
experiment: multiLocationPopupExperiment,
},
"schedule",
false
);
}
if (this.isShowMultiLocationPopup) {
if (maxDayRangeToShowPmTimeslot == 0) {
// Selects the first available mobile time slot
promotedMobileAppointment =
numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate
? firstMobileAMAppt
: firstMobilePMAppt;
// Selects the first available inshop time slot
promotedInshopAppointment =
numberOfDaysToFirstInshopAMDate <= numberOfDaysToFirstInshopPMDate
? firstInshopAppts[0]
: firstInshopAppts[1];
} else {
if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowPmTimeslot
) {
// Selects the first available PM time slot
promotedMobileAppointment = firstMobilePMAppt;
} else if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= maxDayRangeToShowNoPmTimeslot
) {
// Selects the first available AM time slot. If none, selects the first available PM time slot
promotedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt
: firstMobilePMAppt;
}
if (
firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowPmTimeslot
) {
// Selects the first available PM time slot
promotedInshopAppointment = firstInshopAppts[1];
} else if (
firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate >= maxDayRangeToShowNoPmTimeslot
) {
// Selects the first available AM time slot. If none, selects the first available PM time slot
promotedInshopAppointment = firstInshopAppts[0]
? firstInshopAppts[0]
: firstInshopAppts[1];
}
}
if (promotedMobileAppointment && promotedInshopAppointment) {
this.$refs.multiLocationModal.setPromotedMobileAppointment(
promotedMobileAppointment
);
this.$refs.multiLocationModal.setPromotedInshopAppointment(
promotedInshopAppointment
);
this.$refs.multiLocationModal.openModal();
}
}
}
}
this.isLoadingMultiLocationPopup = false;
},
async getFirstAvailableMobileApptByTOD(timeOfDay) { async getFirstAvailableMobileApptByTOD(timeOfDay) {
if (!this.selectableDatesMobile?.days?.length) { const selectableDays = this.selectableDatesMobile?.days;
if (!selectableDays?.length) {
return null; return null;
} else { } else {
for (const dateObj of this.selectableDatesMobile.days) { for (const dateObj of selectableDays) {
let matchingTimeSlot = { let matchingTimeSlot = {
estimatedServiceMinutes: { estimatedServiceMinutes: {
minimum: this.estimatedServiceMinutesMinimum, minimum: this.estimatedServiceMinutesMinimum,
@ -2053,7 +2226,137 @@ export default {
return null; return null;
} }
}, },
updateTimeSlotandNavigateForward(timeSlotObj) {
findMatchingInshopAppt(selectableDays, timeOfDay, provider) {
if (!selectableDays?.length) return null;
for (const dateObj of selectableDays) {
const distanceInMiles = provider?.distanceInMiles
? Math.round(provider.distanceInMiles * 2) / 2
: 0;
const providerAddress = this.getFormattedShopAddress(
provider?.address,
distanceInMiles
);
let matchingTimeSlot = {
estimatedServiceMinutes: {
minimum: this.estimatedServiceMinutesMinimum,
maximum: this.estimatedServiceMinutesMaximum,
},
timeSlot: null,
date: null,
addressCopy: providerAddress.address1 + ", " + providerAddress.address2,
provider: provider,
};
const isMatchingApptDay = dateObj.timeSlots.some(
(slot) =>
slot.id.includes(timeOfDay) &&
(matchingTimeSlot.timeSlot = slot) &&
(matchingTimeSlot.date = dateObj.date)
);
if (isMatchingApptDay && matchingTimeSlot) {
return matchingTimeSlot;
}
}
return null;
},
async getFirstAvailableInshopApptsByTOD(timeOfDayArray) {
let matchingApptNearestAM1 = this.findMatchingInshopAppt(
this.selectableDatesInshop?.days,
timeOfDayArray[0],
this.selectedProvider
);
let matchingApptNearestPM1 = this.findMatchingInshopAppt(
this.selectableDatesInshop?.days,
timeOfDayArray[1],
this.selectedProvider
);
let matchingApptNearestAM2;
let matchingApptNearestPM2;
let matchingApptNearestAM3;
let matchingApptNearestPM3;
const providerNearest2 = this.shopProviderData.shopProviders[1];
const providerNearest3 = this.shopProviderData.shopProviders[2];
if (providerNearest2?.distanceInMiles && providerNearest2.distanceInMiles < 25) {
this.calendarLoadingStatus = "more";
const selectableDaysFromProviderNearest2 = await getScheduleApiResponse({
startDateString: this.initialViewStartDate,
endDateString: this.initialViewEndDate,
inshopProviderNumber: providerNearest2.providerNumber,
zipCode: this.zipCode,
includeMobileTimeSlots: false,
includeInshopTimeSlots: true,
});
matchingApptNearestAM2 = this.findMatchingInshopAppt(
selectableDaysFromProviderNearest2?.inshopTimeSlotsData?.days,
"AM",
providerNearest2
);
matchingApptNearestPM2 = this.findMatchingInshopAppt(
selectableDaysFromProviderNearest2?.inshopTimeSlotsData?.days,
"PM",
providerNearest2
);
}
if (providerNearest3?.distanceInMiles && providerNearest3?.distanceInMiles < 25) {
this.calendarLoadingStatus = "more";
const selectableDaysFromProviderNearest3 = await getScheduleApiResponse({
startDateString: this.initialViewStartDate,
endDateString: this.initialViewEndDate,
inshopProviderNumber: providerNearest3.providerNumber,
zipCode: this.zipCode,
includeMobileTimeSlots: false,
includeInshopTimeSlots: true,
});
matchingApptNearestAM3 = this.findMatchingInshopAppt(
selectableDaysFromProviderNearest3?.inshopTimeSlotsData?.days,
"AM",
providerNearest3
);
matchingApptNearestPM3 = this.findMatchingInshopAppt(
selectableDaysFromProviderNearest3?.inshopTimeSlotsData?.days,
"PM",
providerNearest3
);
}
this.calendarLoadingStatus = "none";
// WHICH OF THE 3 INSHOP APPTS IS THE EARLIEST?
const compareAppointments = (appt1, appt2) => {
// Handle null/undefined appointments
if (!appt1?.date) return 1; // appt1 is later (or invalid)
if (!appt2?.date) return -1; // appt2 is later (or invalid)
// Compare dates first (YYYY-MM-DD format allows string comparison)
if (appt1.date < appt2.date) return -1; // appt1 is earlier
if (appt1.date > appt2.date) return 1; // appt2 is earlier
// Dates are equal, compare times (HH:MM format allows string comparison)
const time1 = appt1.timeSlot?.startTime || "23:59";
const time2 = appt2.timeSlot?.startTime || "23:59";
if (time1 < time2) return -1; // appt1 is earlier
if (time1 > time2) return 1; // appt2 is earlier
return 0; // Completely equal
};
// Find earliest AM appointment
const preferredApptAM =
[matchingApptNearestAM1, matchingApptNearestAM2, matchingApptNearestAM3]
.filter((appt) => appt?.date) // Remove null/undefined
.sort(compareAppointments)[0] || null;
// Find earliest PM appointment
const preferredApptPM =
[matchingApptNearestPM1, matchingApptNearestPM2, matchingApptNearestPM3]
.filter((appt) => appt?.date)
.sort(compareAppointments)[0] || null;
return [preferredApptAM, preferredApptPM];
},
updateMobileFirstTimeSlotandNavigateForward(timeSlotObj) {
this.selectedMobileFirstAppointment = true; this.selectedMobileFirstAppointment = true;
this.appointmentType = AppointmentTypeStrings.MOBILE; this.appointmentType = AppointmentTypeStrings.MOBILE;
this.selectedDate = timeSlotObj.date; this.selectedDate = timeSlotObj.date;
@ -2061,15 +2364,68 @@ export default {
this.updateTimeSlot(timeSlotObj); this.updateTimeSlot(timeSlotObj);
this.forwardButtonAction(); this.forwardButtonAction();
}, },
updateMultiLocationTimeSlotandNavigateForward({ selectedTimeSlot, appointmentType }) {
if (!selectedTimeSlot) return;
this.selectedMultiLocationAppointment = true;
this.selectedDate = selectedTimeSlot.date;
if (appointmentType?.toLowerCase() == "mobile") {
this.appointmentType = AppointmentTypeStrings.MOBILE;
this.updateSelectedProvider();
this.updateTimeSlot(selectedTimeSlot);
} else if (appointmentType?.toLowerCase() == "inshop") {
this.appointmentType = AppointmentTypeStrings.IN_SHOP;
this.updateSelectedProvider(selectedTimeSlot.provider);
// UPDATE SELECTED TIME SLOT INFO
this.selectedTimeSlotInfo = {
timeSlot: {
date: selectedTimeSlot.date,
routeCode: selectedTimeSlot.routeCode,
startTime: selectedTimeSlot.timeSlot.startTime,
endTime: selectedTimeSlot.timeSlot.endTime,
jobMaxMinutes: this.estimatedServiceMinutesMaximum.toString(),
jobMinMinutes: this.estimatedServiceMinutesMinimum.toString(),
},
isPremiumAppointment: selectedTimeSlot.isPremiumAppointment ? true : false,
};
// UPDATE APPT TYPE
this.appointmentType = this.getInShopOrDropOffApptType(selectedTimeSlot.routeCode);
}
this.forwardButtonAction();
},
updateRecalAcknowledgedAndNavigateForward(isAcknowledged) { updateRecalAcknowledgedAndNavigateForward(isAcknowledged) {
this.isRecalAcknowledgedForScheduling = isAcknowledged; this.isRecalAcknowledgedForScheduling = isAcknowledged;
this.forwardButtonAction(); this.forwardButtonAction();
}, },
getFormattedShopAddress(providerAddress, distanceInMiles) {
const toTitleCase = (str) => {
if (!str) return "";
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};
if (!providerAddress) return {};
const streetAddress = toTitleCase(providerAddress.streetAddress);
const city = toTitleCase(providerAddress.city);
const state = providerAddress.state;
const zipCode = providerAddress.zipCode;
return {
city: city,
distance: `${distanceInMiles} mi`,
address1: streetAddress,
address2: `${city}, ${state} ${zipCode}`,
};
},
}, },
watch: { watch: {
appointmentTypeFromAppointmentTypeQuestion: { appointmentTypeFromAppointmentTypeQuestion: {
handler(newValue, oldValue) { handler(newValue, oldValue) {
this.handleAppointmentTypeChange(newValue); // v-if on appointmentTypeQuestion ensures dates have been loaded by now this.handleAppointmentTypeChange(newValue);
}, },
}, },
}, },
@ -2084,6 +2440,7 @@ export default {
textBlock, textBlock,
timeSlotQuestion, timeSlotQuestion,
mobileFirstModal, mobileFirstModal,
multiLocationModal,
recalAckModal, recalAckModal,
alert, alert,
serviceZipModalQuestion, serviceZipModalQuestion,

View file

@ -200,6 +200,13 @@ export default {
} }
} }
baseMixin.methods.ResetExternalParamsAndHideModal(); baseMixin.methods.ResetExternalParamsAndHideModal();
//clear any validation errors for external parameter flow
const form = vm.$refs.theForm;
if (form) {
form.setFieldError("DamageLocationQuestion", undefined);
form.setFieldTouched("DamageLocationQuestion", false, false);
}
} }
} else { } else {
baseMixin.methods.ResetExternalParamsAndHideModal(); baseMixin.methods.ResetExternalParamsAndHideModal();

View file

@ -291,6 +291,7 @@ export default {
sessionData.isPia = order?.payment?.isPia ?? false; sessionData.isPia = order?.payment?.isPia ?? false;
sessionData.piaType = order?.payment?.piaType; sessionData.piaType = order?.payment?.piaType;
sessionData.parentAccountNumber = order?.payment?.parentAccountNumber; sessionData.parentAccountNumber = order?.payment?.parentAccountNumber;
sessionData.billToAccountNumber = order?.payment?.billToAccountNumber;
sessionData.settledTenderAmount = order?.settledTenderAmount; sessionData.settledTenderAmount = order?.settledTenderAmount;
sessionData.recalRequired = containsRecalParts(order?.lineItems); sessionData.recalRequired = containsRecalParts(order?.lineItems);
sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts);

View file

@ -209,6 +209,15 @@ export default {
userAgent.includes("webOS") userAgent.includes("webOS")
); );
}, },
isAppleBrowser() {
const userAgent = navigator.userAgent;
return (
userAgent.includes("iPod") ||
userAgent.includes("iPad") ||
userAgent.includes("iPhone") ||
userAgent.includes("Mac")
);
},
getSubmittedOrder() { getSubmittedOrder() {
return JSON.parse( return JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE) window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)

View file

@ -15,11 +15,14 @@ export async function consumeReferralQuerystrings() {
// If no referral number in querystring, check if heritage funnel updated last. // If no referral number in querystring, check if heritage funnel updated last.
// This would indicate a customer that went to heritage but did not return through the // This would indicate a customer that went to heritage but did not return through the
// normal route. ie: left the site in search of a discount and returned without querystrings. // normal route. ie: left the site in search of a discount and returned without querystrings.
//or if a referral number is in query string check if heritage funnel updated last
// This would indicate a customer that returned through normal route like from sfa email
const didHeritageFunnelUpdateLast = getFunnelCookie()?.DidHeritageFunnelUpdateLast; const didHeritageFunnelUpdateLast = getFunnelCookie()?.DidHeritageFunnelUpdateLast;
if (didHeritageFunnelUpdateLast && !referralNumber) { if (didHeritageFunnelUpdateLast) {
referralNumber = getFunnelCookie().ReferralNumber; referralNumber = referralNumber ?? getFunnelCookie().ReferralNumber;
parentAccount = getFunnelCookie().ReferralParentAccountNumber; parentAccount = parentAccount ?? getFunnelCookie().ReferralParentAccountNumber;
correlationId = getFunnelCookie().ReferralCorrelationId; correlationId = correlationId ?? getFunnelCookie().ReferralCorrelationId;
referralDate = getFunnelCookie().ReferralDate; referralDate = getFunnelCookie().ReferralDate;
savedSessionId = getFunnelCookie().SavedSessionId; savedSessionId = getFunnelCookie().SavedSessionId;
} }

View file

@ -3,6 +3,7 @@ import { getBoolFromString } from "@/helpers/boolean-helper";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { routeData } from "@/router/constants/routes"; import { routeData } from "@/router/constants/routes";
import { isVerifiedInsurance } from "@/router/methods/helpers/requires-verified-redirect"; import { isVerifiedInsurance } from "@/router/methods/helpers/requires-verified-redirect";
import { hasVehicleInfo } from "@/helpers/vehicle-helper";
import store from "@/store"; import store from "@/store";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import baseMixin from "@/mixins/base-mixin"; import baseMixin from "@/mixins/base-mixin";
@ -33,7 +34,7 @@ export async function vehicleBeforeEnter(to, from) {
} }
} }
if (isVerifiedInsurance()) { if (isVerifiedInsurance() && hasVehicleInfo()) {
return { return {
name: routeData.VEHICLE_DAMAGE.name, name: routeData.VEHICLE_DAMAGE.name,
replace: true, replace: true,

View file

@ -1645,6 +1645,7 @@ export const actions = {
isPia, isPia,
piaType, piaType,
parentAccountNumber, parentAccountNumber,
billToAccountNumber,
settledTenderAmount, settledTenderAmount,
recalRequired, recalRequired,
recalType, recalType,
@ -1718,6 +1719,7 @@ export const actions = {
totalPrice: totalPrice, totalPrice: totalPrice,
userAgent: userAgent, userAgent: userAgent,
cashPriceSubTotal: cashPriceSubTotal, cashPriceSubTotal: cashPriceSubTotal,
billToAccountNumber: billToAccountNumber,
}; };
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
@ -2126,14 +2128,14 @@ export const actions = {
}, },
async submitDonationPartToSV2( async submitDonationPartToSV2(
context, context,
{ payload: { donationAmount, submittedOrder }, pageNameToLog } { payload: { selectedItemNumber, submittedOrder }, pageNameToLog }
) { ) {
const state = JSON.parse( const state = JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE) window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)
); );
const order = state.order; const order = state.order;
const payload = { const payload = {
donationAmount: parseInt(donationAmount), selectedItemNumber: selectedItemNumber,
savedSessionId: state.applicationUser.savedSessionId, savedSessionId: state.applicationUser.savedSessionId,
paymentInfo: { paymentInfo: {
parentAccountNumber: order.payment.parentAccountNumber, parentAccountNumber: order.payment.parentAccountNumber,
@ -3138,6 +3140,8 @@ export const actions = {
serviceLocationState, serviceLocationState,
serviceLocationZipCode, serviceLocationZipCode,
pricedLineItems, pricedLineItems,
serviceLocationAddressLine1,
serviceLocationAddressLine2,
}, },
pageNameToLog, pageNameToLog,
} }
@ -3171,6 +3175,8 @@ export const actions = {
appointmentType: appointmentType, appointmentType: appointmentType,
pricedLineItems: lineItemsWithOnlyPriceInfo, pricedLineItems: lineItemsWithOnlyPriceInfo,
serviceLocation: { serviceLocation: {
addressLine1: appointmentType == "Mobile" ? serviceLocationAddressLine1 : null,
addressLine2: appointmentType == "Mobile" ? serviceLocationAddressLine2 : null,
city: appointmentType == "Mobile" ? serviceLocationCity : null, city: appointmentType == "Mobile" ? serviceLocationCity : null,
state: appointmentType == "Mobile" ? serviceLocationState : null, state: appointmentType == "Mobile" ? serviceLocationState : null,
zipCode: appointmentType == "Mobile" ? serviceLocationZipCode : null, zipCode: appointmentType == "Mobile" ? serviceLocationZipCode : null,

View file

@ -20,3 +20,9 @@
} }
// Usage: @include responsive-font-size-md(mobile-font-size-in-rem, desktop-font-size-in-rem); // Usage: @include responsive-font-size-md(mobile-font-size-in-rem, desktop-font-size-in-rem);
// Example: @include responsive-font-size-md(0.875rem, 1rem); // Example: @include responsive-font-size-md(0.875rem, 1rem);
// Filter to use on SVG elements that are background
@mixin blue-filter {
filter: brightness(0) saturate(100%) invert(31%) sepia(68%) saturate(1676%) hue-rotate(186deg)
brightness(93%) contrast(110%);
}

View file

@ -127,6 +127,12 @@ $body-color: $gray-600;
$font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif; $font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif;
$font-family-monospace: $font-family-monospace:
UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
$font-family-sans-serif-semibold: UrbanistSemibold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-bold: UrbanistBold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-extrabold: UrbanistExtraBold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-medium-italic: UrbanistMedium_Italic, Arial, Helvetica, sans-serif;
$font-family-sans-serif-bold-italic: UrbanistBold_Italic, Arial, Helvetica, sans-serif;
// stylelint-enable value-keyword-case // stylelint-enable value-keyword-case
$font-family-base: $font-family-sans-serif; $font-family-base: $font-family-sans-serif;
$font-family-code: $font-family-monospace; $font-family-code: $font-family-monospace;

View file

@ -7,8 +7,18 @@
isDismissible ? 'alert-dismissible' : '', isDismissible ? 'alert-dismissible' : '',
this.alertClass, this.alertClass,
this.cssClassNameForCmsWidget, this.cssClassNameForCmsWidget,
this.hasBorder ? 'bordered' : '',
]"> ]">
<p class="mx-6 my-0 fw-bold alert-heading">{{ alertHeadline }}</p> <p class="mx-6 my-0 fw-bold alert-heading">
<img v-if="showInfoIcon" :src="infoIcon" alt="Info Icon" class="info-icon" />
<img
v-if="showWarningIcon"
:src="warningIcon"
alt="Warning Icon"
class="warning-icon" />
{{ alertHeadline }}
</p>
<hr v-if="showHorizontalRow" />
<template v-for="paragraph in splitAlertCopyForParagraphTag" :key="paragraph"> <template v-for="paragraph in splitAlertCopyForParagraphTag" :key="paragraph">
<p <p
class="mx-4 mt-1 mb-0 text-body small" class="mx-4 mt-1 mb-0 text-body small"
@ -58,6 +68,10 @@ import textBlock from "@/digital-components/text-block/text-block";
export default { export default {
name: "alert", name: "alert",
props: { props: {
showInfoIcon: Boolean,
showWarningIcon: Boolean,
showHorizontalRow: Boolean,
hasBorder: Boolean,
isDismissible: Boolean, isDismissible: Boolean,
alertClass: String, alertClass: String,
/* /*
@ -87,6 +101,12 @@ export default {
}, },
}, },
computed: { computed: {
infoIcon() {
return require(`@/assets/img/icons/info-circle-blue.svg`);
},
warningIcon() {
return require(`@/assets/img/icons/alert-circle-yellow.svg`);
},
pageQueryString() { pageQueryString() {
return applicationConfig.PAGE_QUERYSTRING; return applicationConfig.PAGE_QUERYSTRING;
}, },
@ -148,6 +168,12 @@ export default {
border-radius: 0.5rem; border-radius: 0.5rem;
border-width: 0px; border-width: 0px;
hr {
border: 1px solid $blue;
margin-left: 1rem;
margin-right: 1rem;
}
button { button {
display: none; display: none;
} }
@ -196,12 +222,19 @@ export default {
background-color: $yellow-100; background-color: $yellow-100;
.alert-heading { .alert-heading {
color: $yellow-600; color: $yellow-600;
.warning-icon {
height: 1rem;
}
} }
svg { svg {
fill: $yellow-600; fill: $yellow-600;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
hr {
border-color: $yellow-800;
}
} }
&.alert-success { &.alert-success {
background-color: $green-100; background-color: $green-100;
@ -214,6 +247,9 @@ export default {
height: 1rem; height: 1rem;
} }
} }
&.bordered {
border: 1px solid;
}
& p { & p {
font-size: 0.875rem; font-size: 0.875rem;
} }