Merge remote-tracking branch 'origin/develop' into feature/CASH-1527

This commit is contained in:
scottkiener-at-safelite 2025-11-06 13:06:09 -05:00
commit 5445512816
51 changed files with 2488 additions and 1845 deletions

View file

@ -9,11 +9,11 @@ SKIP_CONTENT_SITE=false
# Base URLs by environment
# qa
BASE_URL="https://www-qa2.safelite.com/"
# BASE_URL="https://www-qa2.safelite.com/"
# local version of FMG (after running the local server)
# BASE_URL="http://localhost:8080/fmg/"
# qa with skipToInsurance Turned Off
# BASE_URL="https://fixmyglassqa.safelite.com/?cns=all&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true,NextGen_IGQSkipToInsurance=NextGen_IGQSkipToInsurance_V1=NextGen_IGQSkipToInsurance_CONTROL=true"
BASE_URL="https://www-qa2.safelite.com/?&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true"
# sys
# BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle"
# dev

View file

@ -5,4 +5,5 @@ export interface ITestData extends base {
// Put any project-specific data here. Anything useful to other Safelite projects should be submitted as a pull request to safelite-playwright-core.
paymentMethod: PaymentMethod
isOptedInForTextMessages: boolean
totalAmount?: number
}

View file

@ -3,6 +3,7 @@ import { Soft } from 'safelite-playwright-core';
import { waitUntil } from 'safelite-playwright-core';
import test, { expect, type Locator, type Page } from '@playwright/test';
import { error } from 'console';
import { ITestData } from 'framework/TestData';
/**
* Base class for all page objects.
@ -18,7 +19,7 @@ export class BasePage {
readonly hamburgerMenu: Locator;
readonly progressBar: Locator;
constructor(page: Page){
constructor(page: Page) {
this.page = page;
this.continueButton = page.locator('[id="infoBox"]').getByRole('button');
this.backButton = page.locator('[id="infoBox"]').getByRole('link');
@ -27,21 +28,21 @@ export class BasePage {
this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' });
this.progressBar = this.page.locator('.progress-bar-outer .progress-bar-inner');
}
async nextPage() {
await waitUntil(async () => {;
return (await this.continueButton.getAttribute('aria-disabled')) !== 'true'
});
await this.continueButton.click();
await waitUntil(async () => {
let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all();
return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false));
});
return (await this.continueButton.getAttribute('aria-disabled')) !== 'true'
});
await this.continueButton.click();
await waitUntil(async () => {
let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all();
return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false));
});
}
async previousPage() {
const startingUrl = this.page.url();
await expect(async () => {
@ -49,14 +50,14 @@ export class BasePage {
if (currentUrl === startingUrl) {
await this.backButton.click({ timeout: 1000 });
}
expect(currentUrl).not.toEqual(startingUrl);
expect(currentUrl).not.toEqual(startingUrl);
}).toPass({ timeout: 240_000 });
}
async fillAndValidate(element: Locator, value: string){
async fillAndValidate(element: Locator, value: string) {
await expect(async () => {
var text = await element.textContent();
if(text !== value) {
if (text !== value) {
await element.clear();
await element.fill(value);
}
@ -79,7 +80,7 @@ export class BasePage {
}
await page.waitForTimeout(100); // Small delay before retrying
}
console.log(`Failed to click the the element within ${timeout/1000} seconds` + error);
console.log(`Failed to click the the element within ${timeout / 1000} seconds` + error);
}
async logReferralNumber() {
@ -107,7 +108,7 @@ export class BasePage {
const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/schedule/api/v1/schedule/mobile-time-slots`;
await this.page.route(apiUrl, async (route) => {
const currentDate = new Date().toISOString().split('T')[0]; // e.g., "2025-07-23"
if(route.request().postDataJSON().startDate === currentDate) {
if (route.request().postDataJSON().startDate === currentDate) {
const response = await route.fetch();
const responseBody = await response.json();
@ -120,28 +121,45 @@ export class BasePage {
});
customerDetails.apptDate = responseBody.days.find((day: any) => day.timeSlots.some((slot: any) => slot.offerPremium === true)).date || undefined;
// Mock the response
await route.fulfill({
response,
body: JSON.stringify(responseBody),
});
}
});
});
}
async getRepairPartsTotal(testData: Partial<ITestData>) {
const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/price/api/v1/price/order-items`;
await this.page.on('response', async (response) => {
const orderItemRequest = response.request();
if (response.request().url() === apiUrl && orderItemRequest.postDataJSON().lineItems.some(item => item.partNumber === "WSREPAIR")) {
const responseBody = await response.json();
const targetParts = ['WSREPAIR']; // ["SUPPLIES-REPAIR", "WSREPAIR"];
testData.totalAmount = responseBody.lineItems
.filter(item => targetParts.includes(item.partNumber))
.reduce((sum: number, item: any) => {
return sum + item.laborAmount + item.sellingPrice + item.kitPrice;
}, 0).toFixed(2);
}
});
testData.totalAmount! > 0 ? console.log(`Total Amount: ${testData.totalAmount}`) : console.error('No repair parts found in the order items.');
}
async validateProgressBar(progressPercentage: string, timeout: number = 60000) {
// This section has been commented out until progress bar work is completed for parity.
await waitUntil(async () => {
let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all();
return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false));
});
let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all();
return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false));
});
// Take a screenshot of the page before validating the progress bar
await this.page.screenshot({ path: `test-results\\ortoni-data\\progress-bar-${Date.now()}.png`, fullPage: true });
// Wait until the progress bar element is attached and visible
const actualProgressPercentage = await this.progressBar.evaluate(
async (element) => {
await new Promise(resolve => setTimeout(resolve, 200));

View file

@ -30,7 +30,7 @@ export class HomePage extends BasePage {
super(page);
this.page = page;
this.letsGetStartedButton = this.page.locator('a.btn.btn-primary.ghost');
this.letsGetStartedButton = this.page.locator('.hero-content #zipCodeTextboxButton');
this.cusmodalPopup = this.page.locator('#Cusmodalpopup');
this.closePopupButton = this.page.getByRole('button', { name: '×' });

View file

@ -1,6 +1,7 @@
import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { IAlertFlags, TestSuccessAlert } from 'safelite-playwright-core';
import { IAlertFlags, TestSuccessAlert, VehicleDamage } from 'safelite-playwright-core';
import { ITestData } from 'framework/TestData';
import { VehicleLookupType } from 'safelite-playwright-core';
export class LookupPage extends BasePage {
@ -105,8 +106,9 @@ export class LookupPage extends BasePage {
}
}
async handleZipValidation(zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise<boolean> {
async handleZipValidation(testData: Partial<ITestData>, zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise<boolean> {
let { vehicleDamage } = testData;
// Only proceed with validation if alertFlags is provided
if (alertFlags) {
if (alertFlags.isUnserviceableZip) {
@ -122,6 +124,10 @@ export class LookupPage extends BasePage {
}
}
if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) {
await this.getRepairPartsTotal(testData);
};
// If no alert flags or no matching condition, just continue
await this.nextPage();

View file

@ -169,9 +169,10 @@ export class OrderConfirmationPage extends BasePage {
: appointmentDetails?.serviceLocation == ServiceLocation.InShop
? formattedExpectedAppointmentDate + `${customerDetails!.apptTime}`
: formattedExpectedAppointmentDate + "Drop off before 9:30 AM"
// : formattedExpectedAppointmentDate + "before 9:30 AM"
);
appointmentSummary.push("Add to calendar");
appointmentSummary.push(
appointmentSummary.push(
appointmentDetails?.serviceLocation == ServiceLocation.Mobile
? appointmentDetails?.serviceAddress
? ("We're coming to you at" + appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode + "to service your " + `${vehicleDetails!.year} ${vehicleDetails!.make} ${vehicleDetails!.model}`)

View file

@ -1,7 +1,7 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { IPaymentDetails, Soft, waitUntil } from 'safelite-playwright-core';
import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core';
import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core';
import { PaymentMethod, ProgressBarPercentages } from 'framework/localTypes/Enums';
import { PaymentPage } from './PaymentPage';
import { AfterpayPage } from './AfterpayPage';
@ -26,6 +26,8 @@ export class PaymentMethodPage extends BasePage {
readonly recalibrationCheckbox: Locator;
readonly paymentPage: PaymentPage;
readonly paypalPage: PaypalPage;
readonly afterPayBreakoutSection: Locator;
readonly afterPayToggle: Locator;
// Payment detail page validation locators
readonly reviewTable: Locator;
@ -61,12 +63,12 @@ export class PaymentMethodPage extends BasePage {
// Payment details validation locators
this.reviewTable = this.page.locator('div.review-table');
// Section locators - find by heading text
this.serviceLocationDateandTimeSection = this.page.locator('.review-table').locator('div .service-location');
this.vehicleDamageLocationsSection= this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]');
this.vehicleDamageLocationsSection = this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]');
this.contactDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Contact details' }).first();
// Cart panel elements
this.cartPanelDetails = this.page.locator('.cart-panel');
this.subtotalText = this.page.locator('.sub-total');
@ -75,6 +77,8 @@ export class PaymentMethodPage extends BasePage {
this.wiperBladesText = this.page.locator('div', { hasText: /^New wiper blades$/ });
this.rainDefenseText = this.page.locator('div', { hasText: /^Rain Defense™$/ });
this.deductibleText = this.page.locator('#deductible-value');
this.afterPayBreakoutSection = this.page.locator('div.alert-info:has(.after-pay)');
this.afterPayToggle = this.page.locator('.after-pay-toggle');
}
async validatePaymentDetailsPage(testData: Partial<ITestData>) {
@ -84,15 +88,15 @@ export class PaymentMethodPage extends BasePage {
isUseVehicleOnPolicy, paymentMethod, vehicleDamage } = testData;
// Wait for review table to be visible to ensure page is loaded
await this.appointmentDetailsDropdown.waitFor({state: "visible"});
await this.appointmentDetailsDropdown.waitFor({ state: "visible" });
await this.appointmentDetailsDropdown.click().then(
async () => {
await waitUntil(async () => {
return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)';
await waitUntil(async () => {
return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)';
});
}
);
let expectedAppointmentDetails = new Map<string, string[]>();
expectedAppointmentDetails = await this.getExpectedServiceLocationDateAndTimeSection(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.getExpectedVehicleDamageAndVehicle(testData, expectedAppointmentDetails);
@ -102,7 +106,7 @@ export class PaymentMethodPage extends BasePage {
let actualAppointmentDetails = await this.getActualAppointmentDetails();
for (const key in expectedAppointmentDetails) {
Soft.expect(actualAppointmentDetails[key]?.map(item => item.toLowerCase()))
.toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase()));
.toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase()));
}
// Expand cart to see all details
@ -120,16 +124,16 @@ export class PaymentMethodPage extends BasePage {
// Extract amounts for self-pay customers
const subtotalAmount = this.extractAmount(subtotalValue);
const finalAmountDueAmount = this.extractAmount(finalAmountDueValue);
// Subtotal should be greater than 0
Soft.expect(subtotalAmount).toBeGreaterThan(0);
// Final amount differs based on payment type
if (paymentDetails?.paymentType === PaymentType.PayAtService) {
Soft.expect(finalAmountDueAmount).toBeGreaterThan(0);
} else if (paymentDetails?.paymentType === PaymentType.Credit ||
paymentDetails?.paymentType === PaymentType.Paypal ||
paymentDetails?.paymentType === PaymentType.AfterPay) {
paymentDetails?.paymentType === PaymentType.Paypal ||
paymentDetails?.paymentType === PaymentType.AfterPay) {
// For payment types that charge immediately, amount due could be 0
// This logic might need adjusting based on actual business rules
}
@ -186,11 +190,11 @@ export class PaymentMethodPage extends BasePage {
if (!text) throw new Error("Subtotal text is empty");
return text;
}
async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) {
const browserContext = this.page.context();
switch(paymentDetails.paymentType) {
switch (paymentDetails.paymentType) {
case PaymentType.Credit:
await this.selectCreditCard();
await this.nextPage();
@ -228,15 +232,15 @@ export class PaymentMethodPage extends BasePage {
}
}
async selectPaypal(){
async selectPaypal() {
await this.payNowButton.click();
}
l
async selectCreditCard(){
l
async selectCreditCard() {
await this.payNowButton.click();
}
async selectPayAtService(isRecalVehicle: boolean){
async selectPayAtService(isRecalVehicle: boolean) {
if (await this.payAtServiceButton.isVisible()) {
await this.payAtServiceButton.click();
} else if (isRecalVehicle) {
@ -248,13 +252,13 @@ l
async verifyVAPS(): Promise<void> {
// Get Vuex state from localStorage
const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
// Validate the count of FRONT WIPER parts
if (vuexState.order?.lineItems?.vaps?.length > 0) {
const frontWiperPartsCount = vuexState.order.lineItems.vaps.filter(vap =>
vap.partType === "FRONT WIPER"
).length;
await expect(frontWiperPartsCount).toBe(2);
} else {
throw new Error("No VAPS found in the order");
@ -264,7 +268,7 @@ l
async getActualAppointmentDetails(): Promise<any> {
let actualAppointmentDetails = new Map<string, string[]>();
let appointmentDetailsSubSections = await this.appointmentDetailsSection.locator('.review-table .py-3').all();
for (let element of appointmentDetailsSubSections) {
let label = await element.locator('div .text-block').innerText();
//added to trim the text to remove any leading or trailing spaces (example: "expert installation " to "expert installation")
@ -282,9 +286,9 @@ l
async getExpectedVehicleDamageAndVehicle(testdata: Partial<ITestData>, expectedServicePackageDetails: Map<string, string[]>): Promise<any> {
const {vehicleDetails, vehicleDamage} = testdata;
const { vehicleDetails, vehicleDamage } = testdata;
let vehicleDamageText: string= '';
let vehicleDamageText: string = '';
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldOneChip || item == VehicleDamage.WindshieldTwoChips || item == VehicleDamage.WindshieldThreeChips)) {
vehicleDamageText = "Repair the windshield of your";
}
@ -300,17 +304,17 @@ l
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverFrontDoor)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Front Glass" : "Replace the Driver Front Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverRearDoor)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Back Glass" : "Replace the Driver Back Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverVentGlass) && vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldCrack)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Vent Glass" : "Replace the Driver Vent Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverQuarterPanel)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass";
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerVentGlass)) {
@ -320,35 +324,34 @@ l
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerFrontDoor)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Front Glass" : "Replace the Passenger Front Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerRearDoor)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Back Glass" : "Replace the Passenger Back Glass";
}
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerQuarterPanel)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass";
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass";
}
if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.RearWindow || item == VehicleDamage.RearSliding)) {
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass";
vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass";
}
// Replace last comma with " and" if there are multiple damages
if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield")
{
if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield") {
vehicleDamageText = vehicleDamageText.replace(/,([^,]*)$/, " and$1") + " of your";
}
expectedServicePackageDetails[vehicleDamageText] = [vehicleDetails?.year + " " + vehicleDetails?.make + " " + vehicleDetails?.model];
return expectedServicePackageDetails;
}
async expectedServicePackageDetails(testData: Partial<ITestData>, expectedServicePackageDetails: Map<string, string[]>): Promise<any> {
const {servicePackage} = testData;
const { servicePackage } = testData;
let localStorage= JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
let localStorage = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
let isRepair = localStorage.order.damage.isRepair as boolean;
let isInsurance = localStorage.order.payment.isInsurance as boolean;
let isInsurance = localStorage.order.payment.isInsurance as boolean;
let hasNonWindshieldGlass: boolean = false;
if (!isRepair) {
hasNonWindshieldGlass = localStorage.order.lineItems.glassParts.find((item: any) => item.partType !== "WINDSHIELD") ? true : false;
@ -362,33 +365,32 @@ l
}
let recalRequired = !isRepair && (isInsurance || isCaliforniaState) && hasRecalPart && canSafeliteRecalibrate
let stringForRepair: string[] = ["Expert windshield repair", "Exclusive resin sealant", "Nationwide lifetime guarantee"];
let stringIfRecal = isRepair
? ""
: recalRequired
? " and recalibration"
: "";
let stringIfRecal = isRepair
? ""
: recalRequired
? " and recalibration"
: "";
let stringForReplace: string[] = [hasNonWindshieldGlass ? "New replacement glass" : "New replacement windshield", "Expert installation" + `${stringIfRecal}`, "Nationwide lifetime warranty"];
switch (servicePackage)
{
case ServicePackage.GlassOnly:
expectedServicePackageDetails["Glass service only"] = isRepair
? stringForRepair
: stringForReplace;
break;
case ServicePackage.Standard:
stringForRepair.push("New wiper blades");
stringForReplace.push("New wiper blades");
expectedServicePackageDetails["Standard service"] = isRepair
? stringForRepair
: stringForReplace;
break;
case ServicePackage.Premium:
stringForRepair.push("New wiper blades", "Rain repel treatment");
stringForReplace.push("New wiper blades", "Rain repel treatment");
expectedServicePackageDetails["Premium service"] = isRepair
? stringForRepair
: stringForReplace;
break;
switch (servicePackage) {
case ServicePackage.GlassOnly:
expectedServicePackageDetails["Glass service only"] = isRepair
? stringForRepair
: stringForReplace;
break;
case ServicePackage.Standard:
stringForRepair.push("New wiper blades");
stringForReplace.push("New wiper blades");
expectedServicePackageDetails["Standard service"] = isRepair
? stringForRepair
: stringForReplace;
break;
case ServicePackage.Premium:
stringForRepair.push("New wiper blades", "Rain repel treatment");
stringForReplace.push("New wiper blades", "Rain repel treatment");
expectedServicePackageDetails["Premium service"] = isRepair
? stringForRepair
: stringForReplace;
break;
}
return expectedServicePackageDetails;
}
@ -396,8 +398,8 @@ l
async getExpectedServiceLocationDateAndTimeSection(testData: Partial<ITestData>, expectedServicePackageDetails: Map<string, string[]>): Promise<any> {
const { customerDetails, appointmentDetails } = testData;
let serviceLocationTitle = appointmentDetails?.serviceLocation == ServiceLocation.Mobile
? "We're coming to you"
: "You're coming to us";
? "We're coming to you"
: "You're coming to us";
let serviceLocationText = appointmentDetails?.serviceLocation == ServiceLocation.Mobile
? appointmentDetails?.serviceAddress
? appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode
@ -443,7 +445,7 @@ l
const { customerDetails } = testData;
let isOptedInForTextMessages = testData.isOptedInForTextMessages ?? false;
let expectedText = isOptedInForTextMessages ? customerDetails?.phoneNumber : "Not opted in";
@ -451,6 +453,40 @@ l
return expectedServicePackageDetails;
}
async ValidateAfterPayBreakOutSection() {
/*const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
const isInsurance = vuexState.order.payment.isInsurance;
const currentDeductible = vuexState.order.policy.currentDeductible;
const isVerified = vuexState.order.payment.insuranceCoverage.isVerified;*/
const amountDueText = await this.amountDueTextField.first().textContent();
const verifyAfterpayBreakout = amountDueText?.includes('$')
? Number.parseFloat(amountDueText.replace(/[^0-9.]/g, '')) > 0
: false
if (verifyAfterpayBreakout) {
const afterPayAmountString = await this.afterPayBreakoutSection.locator('.afterpay-amount').textContent();
const afterPayAmount = Number.parseFloat(afterPayAmountString!.replace(/[^0-9.]/g, ''));
Soft.expect(afterPayAmount, `AfterPayAmount (${afterPayAmount}) is > 0`).toBeGreaterThan(0);
await this.afterPayToggle.click().then(
async () => {
await waitUntil(async () => {
return (await this.page.locator('.after-pay-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)';
});
}
);
const afterPayCards = await this.page.locator('.after-pay-details .payment-card').all();
Soft.expect(afterPayCards.length, `There are 4 afterpay cards`).toBe(4);
for (const afterPayCard of afterPayCards) {
const afterPayAmountWithinCardString = await afterPayCard.locator('.amount-due').textContent();
const afterPayAmountWithinCard = Number.parseFloat(afterPayAmountWithinCardString!.replace(/[^0-9.]/g, ''));
Soft.expect(afterPayAmountWithinCard, `AfterPayAmount (${afterPayAmountWithinCard}) > 0`).toBeGreaterThan(0);
}
}
}
async getFormattedAppointmentDate(appointmentDate: string) {
// Parse the original date
@ -467,7 +503,8 @@ l
await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage);
await this.validatePaymentDetailsPage(testData);
await this.ValidateAfterPayBreakOutSection();
// Verify VAPS wipers on backend for standard and premium packages
if (servicePackage === ServicePackage.Standard || servicePackage === ServicePackage.Premium) {
await this.verifyVAPS();

View file

@ -13,19 +13,21 @@ export class PaypalPage extends BasePage {
readonly completePurchaseButton: Locator;
readonly payWithRadioButton: Locator;
readonly payButton: Locator;
readonly tryAnotherWayButton: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.usernameTextBox = page.getByPlaceholder('Email');
this.usernameTextBox = page.locator('#email');
this.nextButton = page.getByRole('button', { name: 'Next' });
this.loginWithPasswordButton = page.getByRole('link', { name: 'Log in with a password instead' })
this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use Password Instead' });
this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use password instead' });
this.passwordTextBox = page.getByPlaceholder('Password');
this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true });
this.completePurchaseButton = page.getByTestId('submit-button-initial')
this.payWithRadioButton = page.getByRole('button').filter({ hasText: 'Pay with' });
this.payButton = page.locator('#one-time-cta');
this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' });
}
async completePaypalPurchase(paymentDetails: IPaymentDetails){
@ -41,6 +43,7 @@ export class PaypalPage extends BasePage {
} else {
await this.usernameTextBox.fill(paymentDetails.username!);
await this.nextButton.click();
await this.tryAnotherWayButton.click();
await this.usePasswordInsteadButton.click();
await this.passwordTextBox.fill(paymentDetails.password!);
await this.paypalLoginButton.click();

View file

@ -212,7 +212,7 @@ export class SchedulePage extends BasePage {
let formattedTimeSlot: string = "";
if (selectedTimeSlot.toLowerCase().includes("drop"))
{
formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "drop off before 9:30 AM";
formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "before 9:30 AM" // "drop off before 9:30 AM";
}
else if (selectedTimeSlot.includes("-") || selectedTimeSlot.includes("Earlybird")) // - means Mobile time slot where service time is between 8:00 AM - 12:00 PM
{

View file

@ -1,6 +1,6 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { AppointmentTimeslot, ServicePackage, VehicleDamage } from 'safelite-playwright-core';
import { AppointmentTimeslot, ServicePackage, Soft, VehicleDamage } from 'safelite-playwright-core';
import { ProgressBarPercentages } from 'framework/localTypes/Enums';
import { PaymentMethod } from "framework/localTypes/Enums";
import { step } from 'framework/localTypes/Step';
@ -14,6 +14,8 @@ export class ServicePackagesPage extends BasePage {
readonly payOnMyOwnButton: Locator;
readonly paywithInsuranceButton: Locator;
readonly iHavePromoCodeButton: Locator;
readonly afterPayBanner: Locator;
readonly glassOnlypackagePrice: Locator;
//Your quote is almost ready modal
readonly skipQuoteEmailButton: Locator;
@ -32,16 +34,18 @@ export class ServicePackagesPage extends BasePage {
this.standardPackageButton = this.page.getByText('Standard');
this.premiumPackageButton = this.page.getByText('Premium');
this.glassOnlyButton = this.page.getByText('Glass service only', { exact: true });
this.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' }).locator('div');
this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' }).locator('div');
this.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' });
this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' });
this.iHavePromoCodeButton = this.page.getByRole('link', { name: 'I have a promo code' });
this.skipQuoteEmailButton = this.page.getByRole('button', { name: 'Skip' });
this.emailInput = this.page.getByRole('textbox', { name: 'Enter your email address' });
this.getMyQuoteButton = this.page.getByRole('button', { name: 'Send' });
this.closeButton = this.page. getByRole('dialog').locator('button').filter({ hasText: 'Close' });
this.closeButton = this.page.getByRole('dialog').locator('button').filter({ hasText: 'Close' });
this.promoCodeTextbox = this.page.getByLabel('Enter a promo code');
this.applyPromoButton = this.page.getByRole('button', { name: 'Apply promo code' });
this.repeatedClicksModalCloseButton = this.page.locator('.QSISlider').locator('img[src*=\'close\']');
this.afterPayBanner = this.page.locator('#afterpay-banner');
this.glassOnlypackagePrice = this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ hasText: 'Glass service only' }).locator('.pricing-info');
}
async selectPaymentMethod(method: PaymentMethod): Promise<void> {
@ -62,7 +66,11 @@ export class ServicePackagesPage extends BasePage {
if (servicePackage != null) {
await locators[servicePackage].click();
}
}
}
async getVuex() {
return JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
}
async handleQuotePopup(email?: string): Promise<void> {
@ -87,16 +95,16 @@ export class ServicePackagesPage extends BasePage {
await this.promoCodeTextbox.fill(promoCode);
await this.applyPromoButton.click();
}
async verifyCanNotRecal(): Promise<boolean> {
// Get Vuex state from localStorage
const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
const vuexState = await this.getVuex();
// Validate in the backend to make sure the can safelite recalibrate data is correct
if (vuexState.order?.lineItems?.glassParts?.length > 0) {
for (const glassPart of vuexState.order.lineItems.glassParts) {
await expect(glassPart.canSafeliteRecalibrate).toBe(false);
await expect(glassPart.requiresRecalibration).toBe(true);
expect(glassPart.canSafeliteRecalibrate).toBe(false);
expect(glassPart.requiresRecalibration).toBe(true);
}
return true;
}
@ -106,7 +114,7 @@ export class ServicePackagesPage extends BasePage {
async verifyDynamicRecal(): Promise<void> {
// Get Vuex state from localStorage
const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
// Validate in the backend to make sure the dynamic recalibration part is present
if (vuexState.order?.lineItems?.glassParts?.length > 0) {
const hasDynamicRecalPart = vuexState.order.lineItems.glassParts.some(glassPart =>
@ -114,7 +122,7 @@ export class ServicePackagesPage extends BasePage {
childPart.partNumber.includes("RECAL DYNAMIC")
)
);
await expect(hasDynamicRecalPart).toBe(true);
console.log("Recal part line item is verified");
} else {
@ -144,25 +152,25 @@ export class ServicePackagesPage extends BasePage {
await expect(vuexState.order.damage.isRepair).toBe(false);
}
}
async verifyVehicleParts(vehicleDamage: VehicleDamage[]): Promise<void> {
// Get Vuex state from localStorage
const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
// Validate the presence of specific parts in the glassParts array
const glassParts = vuexState.order?.lineItems?.glassParts;
const WINDSHIELD_TYPES = [
  "SINGLE WINDSHIELD",
  "DRIVER SPLIT WINDSHIELD",
  "PASSENGER SPLIT WINDSHIELD"
"SINGLE WINDSHIELD",
"DRIVER SPLIT WINDSHIELD",
"PASSENGER SPLIT WINDSHIELD"
];
if (glassParts?.length > 0) {
for (const partType of vehicleDamage) {
// Normalize part type to "WINDSHIELD" if it matches any of the defined types (Split Windshield types)
  const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType;
const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType;
const hasPartType = glassParts.some(glassPart => glassPart.partType === normalizedPartType);
@ -173,14 +181,46 @@ export class ServicePackagesPage extends BasePage {
}
}
async VerifyAfterpayBreakout() {
const isAfterPayBannerVisible = await this.afterPayBanner.isVisible({ timeout: 2000 })
await Soft.expect(isAfterPayBannerVisible, "AfterPay Banner is visible.").toBeTruthy();
const localStorage = await this.getVuex();
let isRepair = localStorage.order.damage.isRepair as boolean;
const servicePackages = await this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ visible: true }).all();
for (const servicePackage of servicePackages) {
const servicePackageButtonLabel = await servicePackage.getAttribute('buttonlabel');
if (!isRepair) {
const hasRecalPart = localStorage.order.lineItems.glassParts.find((item: any) => item.requiresRecalibration === true) ? true : false;
const canSafeliteRecalibrate = localStorage.order.lineItems.glassParts.find((item: any) => item.canSafeliteRecalibrate === true) ? true : false;
if (hasRecalPart && canSafeliteRecalibrate) {
const servicePackageTextContent = await servicePackage.textContent();
await Soft.expect(servicePackageTextContent, `${servicePackageButtonLabel} package contains "Expert installation and recalibration"`).toContain('Expert installation and recalibration');
}
}
const afterPayPricingInfo = await servicePackage.locator('.pricing-info').filter({ visible: true }).textContent();
const classAttribute = await this.payOnMyOwnButton.getAttribute('class');
const isPayOnMyOwnSelected = classAttribute?.includes('selected');
if (isPayOnMyOwnSelected) {
const regex = /^\$\d+.\d{2}in\s4\sinterest-free\spayments\sor\s(\$\d+.\d{2}){1,2}\sin\ssingle\spayment\s$/;
const priceInfoRegexMatch = regex.test(afterPayPricingInfo!)
await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy();
} else {
const regex = /^As\slittle\sas\s\$\d+.\d{2}$/;
const priceInfoRegexMatch = regex.test(afterPayPricingInfo!)
await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy();
}
}
}
async verifyOEMPart(): Promise<void> {
// Get Vuex state from localStorage
const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')'));
// Validate the presence of an OEM part
if (vuexState.order?.lineItems?.glassParts?.length > 0) {
const firstGlassPartNumber = vuexState.order.lineItems.glassParts[0].partNumber;
await expect(firstGlassPartNumber.includes("OEM")).toBe(true);
} else {
throw new Error("No glass parts found in the order");
@ -189,8 +229,8 @@ export class ServicePackagesPage extends BasePage {
@step("ServicePackagePage >> Select Payment Method and Service Type: ")
async handleServicePackagePage(testData: Partial<ITestData>) {
const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails } = testData;
const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails, totalAmount } = testData;
await this.validateProgressBar(ProgressBarPercentages.ServicePackagePage);
// Define repair damage types (vs. replacement types)
const repairTypes: VehicleDamage[] = [
@ -205,32 +245,50 @@ export class ServicePackagesPage extends BasePage {
});
await this.handleQuotePopup(customerDetails!.email!);
// validate total amount for 3 chip repair
if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) {
const priceText = await this.glassOnlypackagePrice.textContent() || '$0.00';
// Regex to match dollar amounts like $54.99 or $219.97
const regex = /\$(\d+\.\d{2})/g;
// Match all dollar amounts
const matches = priceText.match(regex);
const price = matches!.length > 1 ? matches![1] : matches![0];
const actualPrice = Number.parseFloat(price.replace('$', '')).toString();
Soft.expect(actualPrice).toEqual(totalAmount);
}
await this.selectPaymentMethod(paymentMethod!);
await this.selectServicePackage(servicePackage!);
await this.VerifyAfterpayBreakout();
// Enter promo code
if (paymentDetails?.promoCode) {
await this.enterPromo(paymentDetails.promoCode);
}
// Backend Validations
// Validate backend for can not recal if applicable
if (isCanNotRecal) {
await this.verifyCanNotRecal();
}
// Validate backend for dynamic recal if applicable
if (isDynamicRecal) {
await this.verifyDynamicRecal();
}
// Validate backend for repair info (including chip verification)
await this.verifyIsRepair(!isReplace, vehicleDamage!);
if (isReplace) {
// Validate backend for parts info
await this.verifyVehicleParts(vehicleDamage!);
}
// Validate backend for OEM endorsement
if (hasOemEndorsement) {
await this.verifyOEMPart();
@ -239,7 +297,7 @@ export class ServicePackagesPage extends BasePage {
if (appointmentDetails?.appointmentTimeSlot === AppointmentTimeslot.EarlyBird) {
await this.mockScheduleResponseForEarlyBird(customerDetails!);
}
await this.nextPage();
}
}

View file

@ -15,6 +15,6 @@ export class ServiceZipPage extends LookupPage {
const { customerDetails, vehicleDetails, alertFlags } = testData;
await this.validateProgressBar(ProgressBarPercentages.ServiceZipPage);
await this.enterZip(customerDetails!.address.postalCode!);
await this.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!);
await this.handleZipValidation(testData, customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!);
}
}

View file

@ -54,6 +54,7 @@ const jiraReportConfig: JiraReporterConfig = {
jiraProjectKey: process.env.JIRA_PROJECT_KEY || '',
jiraEpicKey: process.env.JIRA_EPIC_KEY || '',
jiraCardNumber: process.env.JIRA_CARD_NUMBER || '',
applicationName: 'FMG 2.0',
jiraApiUtilConfig: {
jiraUrl: process.env.JIRA_SERVER || '',
jiraUsername: process.env.JIRA_USERNAME || '',
@ -99,7 +100,7 @@ export default defineConfig({
headless: process.env.CI ? true : false,
screenshot: "only-on-failure",
actionTimeout: 60_000,
navigationTimeout: 60_000,
navigationTimeout: 60_000
},
/* Configure projects for major browsers */

View file

@ -32,8 +32,8 @@ const cashRepairInShopPayPalData : Partial<ITestData> = {
appointmentDetails: {
...getDefaultTestData().appointmentDetails!,
shopAddress: "6826 Sawmill Rd, Columbus, OH 43235"
},
},
// Use predefined payment data
paymentDetails: ClientData.getDefaultPaypalDetails()
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 439.8 73.6" viewBox="0 0 439.8 73.6">
<path d="M47.2 22.8c-.8 0-1.4-.6-1.4-1.4v-1.5c0-4.1-2.6-7.5-8.1-7.5-6.1 0-8.8 4.3-8.8 7.4 0 13.7 46.4 7 46.4 32.3 0 14.7-13.6 21.3-38.6 21.3C13.1 73.4 0 68.2 0 53.9v-1c0-.8.6-1.4 1.4-1.4H26c.8 0 1.4.6 1.4 1.4v1.5c0 5.9 3.8 8 9.3 8 5.8 0 9.2-4.2 9.2-8 0-13.7-44.5-6.9-44.5-31.4C1.4 9 13.1 1.4 37.1 1.4c23.8 0 34.5 6 35.2 19.8 0 .8-.6 1.5-1.4 1.5l-23.7.1zm106.4-7.1c0-9.8 6-13.4 23.8-13.4 3.3 0 7.3.1 11 .4.7.1 1.3.7 1.3 1.4v6c0 .8-.7 1.5-1.6 1.4-.8-.1-1.6-.1-2.7-.1-3.1 0-6.1 1-6.1 3.4v6.9c0 .8.6 1.4 1.4 1.4h7.6c.8 0 1.4.6 1.4 1.4v8.1c0 .8-.6 1.4-1.4 1.4h-7.6c-.8 0-1.4.6-1.4 1.4v35.2c0 .8-.6 1.4-1.4 1.4h-22.8c-.8 0-1.4-.6-1.4-1.4l-.1-54.9zM218 50.6c-.8 0-1.4.6-1.4 1.4v6.3c0 5.3 3.4 6.6 7 6.6 4.3 0 6.9-2.7 7.2-8.5 0-.7.7-1.3 1.4-1.3h20.2c.9 0 1.5.7 1.4 1.6-1 10.9-9.9 16.9-30.2 16.9-24.8 0-32.8-7.1-32.8-25.8 0-18.8 9.7-25.9 32.8-25.9 20.5 0 31 5.8 31 23.6v3.6c0 .8-.6 1.4-1.4 1.4l-35.2.1zm12.6-12.4c0-5.2-1.7-7.5-7.1-7.5-5.3 0-7 2.4-7 7.5v2.1c0 .8.6 1.4 1.4 1.4h11.3c.8 0 1.4-.6 1.4-1.4v-2.1zM265.2 0H288c.8 0 1.4.6 1.4 1.4v69.5c0 .8-.6 1.4-1.4 1.4h-22.8c-.8 0-1.4-.6-1.4-1.4V1.4c-.1-.8.6-1.4 1.4-1.4zM302 23.2h22.8c.8 0 1.4.6 1.4 1.4v46.3c0 .8-.6 1.4-1.4 1.4H302c-.8 0-1.4-.6-1.4-1.4V24.6c-.1-.8.6-1.4 1.4-1.4zM302 0h22.8c.8 0 1.4.6 1.4 1.4v12.1c0 .8-.6 1.4-1.4 1.4L302 15c-.8 0-1.4-.6-1.4-1.4V1.5c-.1-.9.6-1.5 1.4-1.5zm101.2 50.6c-.8 0-1.4.6-1.4 1.4v6.3c0 5.3 3.4 6.6 7 6.6 4.2 0 6.9-2.7 7.2-8.5 0-.7.7-1.3 1.4-1.3h20.2c.9 0 1.5.7 1.4 1.6-1 10.9-9.9 16.9-30.1 16.9-24.8 0-32.8-7.1-32.8-25.8 0-18.8 9.7-25.9 32.8-25.9 20.5 0 31 5.8 31 23.6v3.6c0 .8-.6 1.4-1.4 1.4l-35.3.1zm12.7-12.4c0-5.2-1.7-7.5-7.1-7.5-5.3 0-7 2.4-7 7.5v2.1c0 .8.6 1.4 1.4 1.4h11.3c.8 0 1.4-.6 1.4-1.4v-2.1zm-271.5-.5C144.3 25 131.8 22 113.9 22c-11.2 0-28.2 2.3-29.5 14.9-.1.8.6 1.6 1.4 1.6h19.4c.7 0 1.3-.6 1.4-1.3.5-4.3 2.8-6.5 7.2-6.5s6.5 1.5 6.5 4.5v4.4c0 .7-.5 1.3-1.2 1.4-1.2.2-2.9.4-6.3.9l-10.1 1.5c-16.4 2.4-21.4 6.7-21.4 15.8 0 8.4 6 14.4 21.4 14.4 7.3 0 13.9-2.2 17-6.2.2-.2.6-.2.6.1.3 1 .9 2.9 1.2 3.9.2.6.7.9 1.3.9h20c.8 0 1.4-.6 1.4-1.4l.2-33.2zm-32 26.3c-3.9 0-6.3-3.4-6.3-6.7 0-3.3 1.5-5.3 5.3-6.3 2.4-.7 5.5-1.1 7.4-1.4.9-.1 1.6.5 1.6 1.4v4.9c0 5.3-2.2 8.1-8 8.1zm251.8-29.7h7.3c.8 0 1.4-.6 1.4-1.4v-8.1c0-.8-.6-1.4-1.4-1.4h-7.3c-.8 0-1.4-.6-1.4-1.4l-.1-20.4v-.4c0-.6-.5-1.1-1.1-1.1-.2 0-.3 0-.4.1h-.1l-22.6 13.4c-1 .6-1.4.7-1.4 1.4v45.4c0 10.9 6.1 12.5 21.8 12.5 3.9 0 8.3-.2 12.7-.4.7-.1 1.3-.7 1.3-1.4v-6.8c0-.8-.6-1.4-1.4-1.4h-2.6c-4.1 0-6.1-1-6.1-3.8V35.8c0-.9.6-1.5 1.4-1.5z" style="fill:#dc1f26"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,299 @@
<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: Urbanist;
src: url("/fmg/static/assets/Urbanist-Regular.woff") format("woff");
}
@font-face {
font-family: UrbanistSemiBold;
src: url("/fmg/static/assets/Urbanist-SemiBold.woff") format("woff");
}
body {
font-family: 'Urbanist', Roboto, Arial, Helvetica, sans-serif;
}
.container {
max-width: 960px;
margin: auto;
padding: 1rem;
}
.header, .copy, .cta {
margin-bottom: 3rem;
}
.header .header-image {
width: 165px;
}
.subheader {
font-weight: normal;
font-size: 1.5rem;
}
.cta-button {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
padding-left: 2rem;
padding-right: 2rem;
border-radius: 50rem;
background-color: #db0020;
color: #ffffff;
font-family: inherit;
font-size: 1rem;
border: 0px;
}
.diagnostic-info {
color: #db0020;
font-family: 'UrbanistSemiBold', Roboto, Arial, Helvetica, sans-serif;
}
</style>
<title>Error - Safelite</title>
<link rel="icon" href="/fmg/favicon.ico">
</head>
<body>
<div class="container">
<div class="header">
<img class="header-image" src="/fmg/static/assets/logo.svg" />
</div>
<div class="copy">
<h2 class="subheader">
We're not able to schedule at this time. We apologize for the inconvenience
</h2>
<p>
We encountered an error while processing your appointment. You can return to our site and try scheduling again below.
</p>
</div>
<div class="cta">
<a href="/">
<button class="cta-button">
Continue to Safelite.com
</button>
</a>
</div>
<div class="diagnostic-info" id="diagnostic-container">
<hr />
<h2 class="subheader">
Bailout Information:
</h2>
<div id="bailout-info-container">
</div>
</div>
</div>
<script>
// Helpers
function getCookieValueByName(name) {
const value = "; " + document.cookie;
const parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function getCurrentEnvironmentData() {
const environmentData = [
{
name: 'Localhost',
hostName: 'localhost',
apiHostname: 'digitalapi.dev.safelite.io',
debug: true,
},
{
name: 'Dev',
hostName: 'www-dev2.safelite.com',
apiHostname: 'digitalapi.dev.safelite.io',
debug: true,
},
{
name: 'Test',
hostName: 'www-test2.safelite.com',
apiHostname: 'digitalapi.test.safelite.io',
debug: true,
},
{
name: 'QA',
hostName: 'www-qa2.safelite.com',
apiHostname: 'digitalapi.qa.safelite.io',
debug: false,
},
{
name: 'Prod',
hostName: 'www.safelite.com',
apiHostname: 'digitalapi.safelite.io',
debug: false,
},
];
const hostName = window.location.hostname;
const match = environmentData.find(
data => (data.hostName === hostName)
);
return match;
}
window.onload = async () => {
// =============== Check for session info:
const existingVuexDataJSON = window.localStorage.getItem('vuex');
const existingVuexData = existingVuexDataJSON ? JSON.parse(existingVuexDataJSON) : null;
const existingBailoutInfoJSON = window.sessionStorage.getItem('bailoutInfo');
const existingBailoutInfo = existingBailoutInfoJSON ? JSON.parse(existingBailoutInfoJSON) : null;
console.log(`================ VUEX DATA`);
console.log(existingVuexData);
console.log(`================ PRIOR BAILOUT DATA`);
console.log(existingBailoutInfo);
let bailoutInfo = null;
if(existingVuexData) {
try {
// =============== Collect diagnostic data:
bailoutInfo = [];
// App & Site Name
bailoutInfo.push({ name: 'App Name', value: 'FixMyGlass' });
bailoutInfo.push({ name: 'Site Name', value: 'SafeliteDotcom' });
// Not included (yet) from heritage:
// - Site ID
// - Code
// - Module
// - Page
bailoutInfo.push({ name: 'Timestamp', value: `${new Date()}` });
// - URL
// - Server
const sessionId = getCookieValueByName('sid');
bailoutInfo.push({ name: 'Session Id', value: sessionId });
const userAgent = window.navigator.userAgent;
bailoutInfo.push({ name: 'User Agent', value: userAgent });
// - IP
// - Session Log Sequence Number
bailoutInfo.push({ name: 'Referral Seq Number', value: existingVuexData.order?.referralSequenceNumber });
bailoutInfo.push({ name: 'Referral Number', value: existingVuexData.order?.referralNumber });
bailoutInfo.push({ name: 'Referral Date', value: existingVuexData.order?.referralDate });
bailoutInfo.push({ name: 'Referral Provider Number', value: existingVuexData.order?.serviceLocation?.provider?.providerNumber });
bailoutInfo.push({ name: 'Referral CTU', value: existingVuexData.order?.serviceLocation?.provider?.address?.zipCodeCtu });
// - Referral Insured Zip
// - Referral Insured Service Zip
bailoutInfo.push({ name: 'Referral CarID', value: existingVuexData.order?.vehicle?.carId });
const vehicle = existingVuexData.order?.vehicle;
const vehicleString = vehicle?.year
? `${vehicle.year} ${vehicle.make} ${vehicle.model} - ${vehicle.style}`
: null;
bailoutInfo.push({ name: 'Referral Vehicle', value: vehicleString });
bailoutInfo.push({ name: 'Work Order Number', value: existingVuexData.order?.workOrderNumber });
bailoutInfo.push({ name: 'Work Order ID', value: existingVuexData.order?.workOrderId });
bailoutInfo.push({ name: 'Parent Account Number', value: existingVuexData.order?.payment?.parentAccountNumber });
// - Parent Account Name
// - Client GUID
} finally {
// If any information gathered, write to session storage.
if(bailoutInfo) {
window.sessionStorage.setItem('bailoutInfo', JSON.stringify(bailoutInfo));
}
// Then *always* clear vuex data.
window.localStorage.removeItem('vuex');
}
} else if(existingBailoutInfo) {
// Proceed with prior data.
bailoutInfo = existingBailoutInfo;
}
const environmentInfo = getCurrentEnvironmentData();
const shouldShowDebugInfo = environmentInfo?.debug;
if(bailoutInfo && shouldShowDebugInfo) {
try {
// =============== Display diagnostic data:
// Create display nodes
const elements = bailoutInfo.map(
dataPoint => {
const element = document.createElement('li');
element.textContent = `${dataPoint.name}: ${dataPoint.value}`;
return element;
}
);
const fragment = new DocumentFragment();
elements.forEach(
(element) => {
fragment.append(element);
}
);
// Attach nodes to DOM and render
const attachNode = document.getElementById('bailout-info-container');
if(attachNode) {
const ul = attachNode.appendChild(document.createElement('ul'));
ul.append(fragment);
}
} catch(e) {
console.error(`=== ERROR DISPLAYING INFO`);
console.error(e);
const diagnosticContainer = document.getElementById('diagnostic-container');
diagnosticContainer.remove();
}
} else {
const diagnosticContainer = document.getElementById('diagnostic-container');
diagnosticContainer.remove();
}
const apiHostname = environmentInfo?.apiHostname;
if(apiHostname) {
try {
const endpointUrl = `https://${apiHostname}/analytics/api/v1/logging/log-error`;
const infoString = bailoutInfo?.map(
(entry) => `${entry.name}: ${entry.value}`
)?.reduce(
(prev, next) => `${prev}\n${next}`
);
const entryString = `User encountered bailout page.\n${new Date()}\n${infoString ?? 'No information recoverable'}`;
const request = new Request(endpointUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
entry: entryString,
}),
});
const response = await fetch(request);
// Don't need data from response, but could get it here with:
// const responseData = await response.json();
} catch(e) {
console.error(`=== ERROR SENDING LOGGING`);
console.error(e);
}
}
};
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -180,6 +180,10 @@ const endpoints = {
url: "/analytics/api/v1/analytics/digitalconsumer-log",
method: "POST",
},
LogFmgSessionData: {
url: "/analytics/api/v1/analytics/digitalconsumer-session-logging",
method: "POST",
},
GetExperimentsByUser: {
url: "/analytics/api/v1/analytics/get-experiments",
method: "GET",

View file

@ -4,6 +4,7 @@ const experimentUniverses = {
MSR: "MSR",
IGQ_SkipQuote: "NextGen_IGQSkipToInsurance",
AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay",
MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment",
};
const experimentSettings = {
@ -28,6 +29,10 @@ const experimentSettings = {
DISPLAY_AFTERPAY_BREAKOUT_DISPLAY: "Display_AfterpayBreakoutDisplay",
AFTERPAY_EXTENDED_PAY_OPTION_THRESHOLD: "AfterPayExtendedPayOptionThreshold",
DYNAMO_LOGGING: "DynamoLogging",
SHOW_MOBILE_FIRST_APPT: "ShowMobileFirstAppt",
SHOW_PM_MOBILE_DAYS: "Show_PMmobileDays",
SHOW_NO_PM_MOBILE_DAYS: "Show_NoPMmobileDays",
SHOW_NO_MOBILE_AVAILABLE_DAYS: "Show_NoMobileAvailableDays",
};
const experimentTriggers = {

View file

@ -64,6 +64,7 @@ const storeActions = {
INITIALIZE_SESSION: "initializeSession",
LOG_PART_QUESTIONS: "logPartQuestions",
LOG_DIGITALCONSUMER: "logDigitalConsumer",
LOG_FMG_SESSION_DATA: "logFmgSessionData",
// DEPENDENCY MUTATIONS
RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleStateAndDependencies",

View file

@ -150,6 +150,7 @@ import {
import {
convertDateToDateString,
convertDateStringToDate,
getTodayDateString,
} from "@/layouts/schedule/helpers/schedule-helper";
import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper";
@ -227,7 +228,7 @@ export default {
},
computed: {
todayString() {
return this.todayOverrideDateString || convertDateToDateString(new Date());
return this.todayOverrideDateString || getTodayDateString();
},
todayDayIndex() {
return convertDateStringToDate(this.todayString).getDay();
@ -259,6 +260,17 @@ export default {
this.dispatchStoreAction(this.storeActions.SAVE_WAITLIST_REQUESTED, false, false);
},
},
firstAvailableSelectableDate() {
const mobileFirstDate = Array.isArray(this.selectableDatesMobile)
? this.selectableDatesMobile[0]?.date
: null;
const inshopFirstDate = Array.isArray(this.selectableDatesInshop)
? this.selectableDatesInshop[0]?.date
: null;
return this.isMobileSelected && mobileFirstDate
? mobileFirstDate + "-mobile"
: inshopFirstDate;
},
},
methods: {
async initializeComponent(initialData) {
@ -414,7 +426,7 @@ export default {
} else if (config.todayOverrideDateString) {
todayDateString = config.todayOverrideDateString;
} else {
todayDateString = convertDateToDateString(new Date());
todayDateString = getTodayDateString();
}
if (config.selectableDatesSetting === "past") calendarViewDirection = "past";
if (config.selectableDatesSetting === "custom") calendarViewDirection = "future";
@ -834,6 +846,12 @@ export default {
},
},
watch: {
isLoading(newValue) {
// if done loading dates, then set to first available date
if (newValue === false && this.firstAvailableSelectableDate) {
this.selectedDate = this.firstAvailableSelectableDate;
}
},
modelValue(newValue) {
this.resetField({
value: newValue,

View file

@ -14,6 +14,7 @@
<div class="modal-dialog" :class="{ 'modal-dialog-centered': !isRecal }">
<div class="modal-content">
<div class="modal-header mb-0 mt-6">
<slot name="modal-header-slot"></slot>
<label
v-if="headerText"
class="modal-title d-flex justify-content-center pb-0 w-100">

View file

@ -309,40 +309,6 @@ describe("cart.vue", () => {
expect(found).toBe(true);
});
// Service package discount Fee Cart Item
test("only if there is service package discount fee for the package, a service package discount cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [
{
description: null,
id: "bc00294e-6baa-403e-866e-52c267187a15",
kitPrice: 0,
laborAmount: 0,
partNumber: "DISC CASHSAVE70",
partType: "SERVICE PACKAGE DISCOUNT",
salesTax: null,
sellingPrice: -70,
},
],
vaps: [],
promos: [],
};
const availableVaps = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableVaps: availableVaps,
},
});
// Assert
expect(wrapper.vm.servicePackageDiscountCartItem).toBeNull();
});
// Other Supporting Items Cart Item
test("if there are other supporting items on the order, an other supporting items cart item should be added to the cart but should not be displayed", () => {
// Arrange

View file

@ -312,10 +312,23 @@ export default {
this.lineItems[category] = this.lineItems[category].filter(
(lineItemsToKeep) => lineItemsToKeep.cartItemType != cartItemType
);
let shouldSaveSupportingItems = category == cartItemCategories.SUPPORTING_ITEMS;
if (category == cartItemCategories.VAPS || category == cartItemCategories.PROMOS) {
this.saveVaps(this.lineItems);
// Check if service package discount should be removed after vaps change
if (
this.servicePackageDiscountCartItem &&
this.discountPackageNames != this.packageLevel
) {
this.lineItems.supportingItems = this.lineItems.supportingItems.filter(
(lineItemsToKeep) =>
lineItemsToKeep.cartItemType !=
this.servicePackageDiscountCartItem.cartItemType
);
shouldSaveSupportingItems = true;
}
}
if (category == cartItemCategories.SUPPORTING_ITEMS) {
if (shouldSaveSupportingItems) {
await this.dispatchStoreAction(
storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
this.lineItems.supportingItems,
@ -471,14 +484,7 @@ export default {
}
if (this.servicePackageDiscountCartItem) {
if (this.discountPackageNames != this.packageLevel) {
this.removeItem(
this.servicePackageDiscountCartItem.cartItemType,
cartItemCategories.SUPPORTING_ITEMS
);
} else {
cartItems.push(this.servicePackageDiscountCartItem);
}
cartItems.push(this.servicePackageDiscountCartItem);
}
if (this.premiumAppointmentDiscountCartItem) {

View file

@ -250,7 +250,8 @@ export default {
}
}
.modal-disclaimer {
font-size: 0.75rem;
font-size: 0.8125rem;
color: #525656;
text-align: left;
order: 3;
margin: 0;

View file

@ -121,7 +121,7 @@ export default {
endpoint: endpoint,
};
router.bailout(errorPayload);
router.handleSoftError(errorPayload);
// do not log 404 errors from services because we return NotFound
// when a service doesn't return an object

View file

@ -38,7 +38,7 @@ it("Global Methods - Call Http Client - Should Reject Promise", () => {
isError: true,
});
analyticsMixIn.methods.pushEventToGA = jest.fn();
router.bailout = jest.fn();
router.handleSoftError = jest.fn();
//Act
globalMethods.callHttpClient(httpArgs).catch((err) => {

View file

@ -125,6 +125,16 @@ export function getSessionKeyValue() {
return 0;
}
export function getskeyValue() {
const cookieValue = getCookieValueByName(cookieNames.SESSION_KEY);
if (cookieValue) {
return cookieValue;
}
return 0;
}
export function setSessionKeyIfUnset(value) {
if (!isCookieSet(cookieNames.FUNNEL_SESSION_KEY)) {
setCookieProperties(

View file

@ -99,3 +99,25 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) {
return pricingResults[0];
}
export function addPricesToLineItems(lineItems, pricingLineItems) {
lineItems.forEach((lineItem) => {
const lineItemIndex = pricingLineItems.findIndex(
(pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber
);
if (lineItem.childParts) {
addPricesToLineItems(lineItem.childParts, pricingLineItems);
}
if (lineItemIndex !== -1) {
const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0];
lineItem.laborAmount = pricedLineItem.laborAmount;
lineItem.sellingPrice = pricedLineItem.sellingPrice;
lineItem.kitPrice = pricedLineItem.kitPrice;
lineItem.salesTax = pricedLineItem.salesTax;
}
});
return lineItems;
}

View file

@ -6,7 +6,7 @@ import baseMixin from "@/mixins/base-mixin.js";
export const promoPartNumberStrings = {
WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT",
RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN REPEL",
RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN",
GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT",
GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN",
};

View file

@ -35,7 +35,7 @@
:isNoComp="isNoComp"
:isExpandedOnLoad="false"
:isMSRFeeApplicable="isMSRFeeApplicable"
@itemRemoved="reTaxItemsOnOrder" />
@itemRemoved="evaluatePromosAndTaxItemsOnOrder" />
<afterpayBreakout
v-if="isAfterpayBreakoutDisplay"
@ -155,7 +155,6 @@ import {
getNewlyInactivatedPromos,
} from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { deepClone } from "@/helpers/object-helper";
import { Form } from "vee-validate";
@ -163,18 +162,10 @@ import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { partTypeStrings } from "@/constants/part-type-strings";
import { mapTaxedLineItemsToStoreFormat } from "../../store";
import { coverageStatus } from "@/constants/insurance";
import { containsLineItemWithPartType } from "@/helpers/service-package-helper";
import { containsRecalParts } from "@/helpers/recal-helper";
import { getBoolFromString } from "@/helpers/boolean-helper";
import {
getDisplayAmountDue,
getAmountDue,
getSubTotal,
getSalesTax,
} from "@/helpers/pricing-helper.js";
import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { debugLog } from "@/helpers/debug-log-helper";
import { ErrorMessage } from "vee-validate";
@ -183,6 +174,23 @@ import { Field } from "vee-validate";
defineRule("payment-method-required", required(errorMessages.OPTION_REQUIRED));
defineRule("recal-ack-required", required(errorMessages.RECAL_ACK_REQUIRED));
function getFlattenedArrayOfLineItemsWithChildParts(lineItems, childPartRecursiveCall = false) {
let flattenedArray = [];
lineItems?.forEach((lineItem) => {
// this assumes childparts will never be a glass part
lineItem.isChildPart = childPartRecursiveCall;
flattenedArray.push(lineItem);
if (lineItem.childParts) {
flattenedArray = [
...flattenedArray,
...getFlattenedArrayOfLineItemsWithChildParts(lineItem.childParts, true),
];
}
});
return flattenedArray;
}
export default {
name: "paymentMethod",
props: {
@ -231,22 +239,30 @@ export default {
const availableVaps = [resultMap.rainRepel, ...resultMap.wipers];
const pricedAvailableVaps = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: availableVaps,
},
"payment-method",
false
);
const lineItemsOnOrderAndAvailableVaps = [
...pricedAvailableVaps,
...availableVaps,
...glassParts,
...supportingItems,
...vaps,
];
// All line items are already priced except availableVaps
// Price everything again to ensure that serverData has all values
// Specifically this addresses an error where insurance client glass parts are not in serverData
// See CASH-1713 for details
let pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: lineItemsOnOrderAndAvailableVaps,
},
"payment-method",
false
);
pricedLineItems = getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems);
// Add prices to the availableVaps
const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems);
// Promo logic
// Populate the previous state of promos for toast message usage in "next()"
const oldActivePromos = store.getters.lineItems.promos?.slice(0);
@ -265,14 +281,23 @@ export default {
delete lineItemsForCart.serverData;
// End of promo logic
//
const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos(
lineItemsForCart.promos ?? [],
availableVaps,
lineItemsForCart
);
lineItemsForCart.vaps = lineItemsForCart.vaps ?? [];
// Add prices to all line items, sometimes they are not there when they come back from heritage
// See CASH-1713 for details
lineItemsForCart.glassParts = addPricesToLineItems(
lineItemsForCart.glassParts ?? [],
pricedLineItems
);
lineItemsForCart.supportingItems = addPricesToLineItems(
lineItemsForCart.supportingItems ?? [],
pricedLineItems
);
lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems);
lineItemsForCart.vaps.push(...vapsToAddToCart);
// Tax items on order
lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging(
@ -464,24 +489,6 @@ export default {
});
}
if (revalidatePromoResponse.promoLineItems.length > 0) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: store.getters.payment.billToAccountNumber,
providerNumber:
this.$store.getters.order.serviceLocation.provider.providerNumber,
appointmentType: this.$store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: this.$store.getters.order.serviceLocation.city,
serviceLocationState: this.$store.getters.order.serviceLocation.state,
serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode,
pricedLineItems: revalidatePromoResponse.promoLineItems,
},
"payment-method",
false
);
}
const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos(
revalidatePromoResponse.promoLineItems,
this.availableVaps,
@ -495,6 +502,24 @@ export default {
getPromoCodeWithoutBundleIdentifier(x.promoCode)
);
if (revalidatePromoResponse.promoLineItems.length > 0) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: store.getters.payment.billToAccountNumber,
providerNumber:
this.$store.getters.order.serviceLocation.provider.providerNumber,
appointmentType: this.$store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: this.$store.getters.order.serviceLocation.city,
serviceLocationState: this.$store.getters.order.serviceLocation.state,
serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode,
pricedLineItems: this.lineItems,
},
"payment-method",
false
);
}
// SAVE PROMO CHANGES TO STORE
this.dispatchStoreAction(
storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS,
@ -589,7 +614,13 @@ export default {
this.pageName
);
},
async reTaxItemsOnOrder() {
async evaluatePromosAndTaxItemsOnOrder() {
//Revalidate promos if there are any inactive, or active promos
const hasInactivePromos = this.inactivePromos.length > 0;
const hasActivePromos = this.lineItems.promos.length > 0;
if (hasInactivePromos || hasActivePromos) {
await this.revalidatePromos();
}
this.lineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
@ -663,10 +694,12 @@ export default {
: this.ServiceLocationFullAddress.zipCode;
},
AppointmentWordingText() {
return this.getCmsContent("ApptDetailsSnapshotWidget", "BodyText")?.replaceAll(
const text = this.getCmsContent("ApptDetailsSnapshotWidget", "BodyText")?.replaceAll(
"{custom:ADDRESS}",
this.ServiceLocationFullAddressText
);
return text?.replace(/\barriving between\b/gi, "<span>arriving between</span>");
},
isRecalibrationOnOrder() {
const order = baseMixin.methods.hasSubmittedOrder()
@ -793,14 +826,17 @@ export default {
},
isRecalPriceRemove() {
return (
!this.isInsurance &&
this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() ===
"true"
"true" &&
this.isRecalibrationOnOrder
);
},
isAfterpayBreakoutDisplay() {
return (
this.showInsuranceCoverageAs !== coverageStatus.PENDING &&
!this.isRecalPriceRemove &&
this.isPiaEnabled &&
this.hasSettingEqualTo(experimentSettings.DISPLAY_AFTERPAY_BREAKOUT_DISPLAY, "true")
);
},

View file

@ -319,7 +319,6 @@ describe("payment.vue", () => {
);
// Assert
expect(vmMock.availableVaps).not.toBeUndefined();
expect(vmMock.lineItems).not.toBeUndefined();
expect(vmMock.setCmsContent).toBeCalled();

View file

@ -42,7 +42,7 @@
<cart
ref="cart"
:damage="damageInfo"
:availableVaps="availableVaps"
:availableVaps="[]"
:allowItemRemoval="false"
v-model="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle"
@ -237,25 +237,11 @@ import { applicationConfig } from "@/constants/application-config";
import { paymentMethods } from "@/constants/payment-method-constants";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import baseMixin from "@/mixins/base-mixin.js";
import { mapTaxedLineItemsToStoreFormat } from "../../store";
import {
revalidatePromosAndValidateQueryStringPromo,
getAddableVapsFromAvailableLineItems,
getVapsThatNeedToBeAddedToSatisfyPromos,
} from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { deepClone } from "@/helpers/object-helper";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.js";
import { coverageStatus } from "@/constants/insurance";
import { getBoolFromString } from "@/helpers/boolean-helper.js";
import {
getDisplayAmountDue,
getAmountDue,
getSubTotal,
getSalesTax,
} from "@/helpers/pricing-helper.js";
import { getDisplayAmountDue, getAmountDue } from "@/helpers/pricing-helper.js";
import { debugLog } from "@/helpers/debug-log-helper.js";
import buttonQuestion from "@/digital-components/button-question/button-question";
import paymentMethodListButton from "@/layouts/payment-method/payment-method-question/payment-method-list-button/payment-method-list-button";
@ -290,7 +276,6 @@ export default {
displayAmount: this.getDisplayAmountDue(),
piaLineItems: "",
lineItems: [],
availableVaps: [],
shouldBlockInteraction: false,
paymentMethodListButton: paymentMethodListButton,
showSwitchPaymentMethod: !this.isAfterpay(),
@ -321,21 +306,6 @@ export default {
"payment"
);
const wipersPromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_WIPERS,
{
serviceZipCode: store.getters.order.serviceLocation.zipCode,
carId: store.getters.vehicle.carId,
},
"payment"
);
const rainRepelPromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_RAIN_REPEL,
null,
"payment"
);
const promiseResultMap = [
{
resultKey: "cmsContent",
@ -345,92 +315,14 @@ export default {
resultKey: "signature",
promise: signaturePromise,
},
{
resultKey: "wipers",
promise: wipersPromise,
},
{
resultKey: "rainRepel",
promise: rainRepelPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
const lineItemsFromStore = deepClone(store.getters.order.lineItems);
const glassParts = lineItemsFromStore.glassParts ?? [];
const supportingItems = lineItemsFromStore.supportingItems ?? [];
const lineItemsToTax = [
resultMap.rainRepel,
...supportingItems,
...resultMap.wipers,
...glassParts,
];
const availableVaps = [resultMap.rainRepel, ...resultMap.wipers];
const pricedLineItemsToTax = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: lineItemsToTax,
},
"payment",
false
);
// Promo logic
const promoCodeFromQueryString = getQuerystringParameter(queryStrings.PROMO);
const { validatePromoResponse, revalidatePromoResponse } =
await revalidatePromosAndValidateQueryStringPromo(
promoCodeFromQueryString,
pricedLineItemsToTax,
"payment"
);
const newValidatedPromos = validatePromoResponse?.orderPromos ?? [];
newValidatedPromos.push(
...(revalidatePromoResponse ? revalidatePromoResponse.promoLineItems : [])
);
pricedLineItemsToTax.push(...newValidatedPromos);
// End of promo logic
const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: store.getters.payment.billToAccountNumber,
providerNumber: store.getters.order.serviceLocation.provider.providerNumber,
appointmentType: store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: store.getters.order.serviceLocation.city,
serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: pricedLineItemsToTax,
},
"payment",
false
);
// Match all line items to the line items as they are in the store
// and rebuild the original structure.
const lineItems = mapTaxedLineItemsToStoreFormat(taxedLineItems, lineItemsFromStore);
const taxedVaps = mapTaxedLineItemsToStoreFormat(taxedLineItems, availableVaps);
// Add items that were not in the store yet but added via query string promo validation
//newValidatedPromos contains both validated and revalidated promos.
lineItems.promos = Array.from(newValidatedPromos);
const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos(
newValidatedPromos,
taxedVaps,
lineItems
);
lineItems.vaps = lineItems.vaps ?? [];
lineItems.vaps.push(...vapsToAddToCart);
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.availableVaps = taxedVaps;
vm.lineItems = lineItems;
vm.lineItems = deepClone(store.getters.order.lineItems);
vm.$nextTick(() => {
if (vm.$refs.cart) {

View file

@ -71,3 +71,11 @@ export function isDropOffRouteCode(routeCode) {
routeCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)
);
}
export function getTodayDate(routeCode) {
return new Date();
}
export function getTodayDateString(routeCode) {
return convertDateToDateString(getTodayDate());
}

View file

@ -0,0 +1,222 @@
<template>
<div id="mobile-first-modal-container">
<modal
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:isFooterButtonPrimary="true"
@footer-button-event="confirmAppointment"
@isModalOpened="setIsModalOpen">
<p class="modal-body-inner" v-html="modalBodyText"></p>
<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";
const INLINE_SERVICETYPE_TOKEN = "custom:serviceType";
const INLINE_DAY_TOKEN = "custom:day";
const INLINE_DATE_TOKEN = "custom:date";
const INLINE_TIMESLOT_TOKEN = "custom:timeslot";
const INLINE_SERVICE_LENGTH_TOKEN = "custom:serviceLength";
export default {
name: "mobile-first-modal",
props: {
cmsWidgetName: String,
modalWidgetName: String,
},
data() {
return {
isModalOpen: false,
selectedAppontment: {
estimatedServiceMinutes: {
minimum: null,
maximum: null,
},
timeSlot: null,
date: null,
},
};
},
methods: {
splitCopyOnCMSPlaceHolder,
get12HourTimeFormat,
openModal() {
this.modal.openModal();
},
onModalClosed() {
this.$emit("close-mobile-first-modal");
},
closeModal() {
this.modal.closeModal();
},
confirmAppointment() {
let selectedTimeSlot = {
timeSlot: this.selectedAppontment.timeSlot,
routeCode: this.selectedAppontment.timeSlot.id,
};
this.$emit("confirm-appointment", selectedTimeSlot);
this.modal.closeModal();
},
setSelectedAppointment(appointment) {
this.selectedAppontment = appointment;
},
getIsModalOpen() {
return this.isModalOpen;
},
setIsModalOpen(isOpen) {
this.isModalOpen = isOpen;
},
},
computed: {
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
modalSubHeaderText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
modalBodyText() {
var tokens = this.splitCopyOnCMSPlaceHolder(
this.getCmsContent(this.modalWidgetName, "BodyText")
);
tokens.forEach((token) => {
if (token.includes(INLINE_SERVICETYPE_TOKEN)) {
const serviceType = "<strong>" + this.getServiceType + "</strong>";
tokens.splice(tokens.indexOf(token), 1, serviceType);
} else if (token.includes(INLINE_DAY_TOKEN) && this.selectedAppontment.date) {
const day =
"<strong>" +
new Date(this.selectedAppontment.date).toLocaleDateString("en-US", {
weekday: "long",
timeZone: "UTC",
}) +
"</strong>";
tokens.splice(tokens.indexOf(token), 1, day);
} else if (token.includes(INLINE_DATE_TOKEN) && this.selectedAppontment.date) {
const date = new Date(this.selectedAppontment.date).toLocaleDateString(
"en-US",
{ timeZone: "UTC", weekday: "long", month: "long", day: "numeric" }
);
tokens.splice(tokens.indexOf(token), 1, date);
} else if (
token.includes(INLINE_TIMESLOT_TOKEN) &&
this.selectedAppontment.timeSlot
) {
const timeslot =
this.get12HourTimeFormat(this.selectedAppontment.timeSlot.startTime) +
" - " +
this.get12HourTimeFormat(this.selectedAppontment.timeSlot.endTime);
tokens.splice(tokens.indexOf(token), 1, timeslot);
} else if (
token.includes(INLINE_SERVICE_LENGTH_TOKEN) &&
this.selectedAppontment.estimatedServiceMinutes
) {
const inshopDurationTime = getDisplayTextForDurationLength(
this.selectedAppontment.estimatedServiceMinutes.minimum,
this.selectedAppontment.estimatedServiceMinutes.maximum
);
tokens.splice(tokens.indexOf(token), 1, inshopDurationTime);
}
});
return tokens.join("");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
buttonText() {
return this.getCmsContent(this.modalWidgetName, "FooterText2");
},
getServiceType() {
return store.getters.order.damage.isRepair ? "repair" : "replace";
},
},
components: { modal },
};
</script>
<style lang="scss">
#mobile-first-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;
& > span {
color: $red;
font-size: $font-size-14;
font-weight: $font-weight-600;
text-transform: uppercase;
}
.modal-title {
font-size: $font-size-20;
font-weight: $font-weight-normal;
}
}
.modal-body {
padding: 0.5rem 1rem;
.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

@ -1,782 +0,0 @@
<!-- OLD SCHEDULE BEGINS -->
<!-- TODO: REMOVE THIS FILE ONCE CASH-803 (SERVICE-LOCATION AND SCHEDULE PAGE COMBINATION) HAS BEEN VETTED -->
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles page-schedule">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4" />
<template v-if="ChangeShopLink.length">
<textBlock
cmsWidgetName="ChangeShopLink"
justifyText="center"
class="mb-5 text-link-small change-location"
marginTopSizeOverride="1" />
</template>
<locationAlerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" />
<datePicker
customComponentId="dateQuestion"
selectableDatesSetting="custom"
ref="datePicker"
v-model="selectedDate"
class="text-link-small"
:customSelectableDatesCallback="getAvailableDatesMethod"
validationRules="date-required"
@date-clicked="handleDateClicked"
:pricingByDayBasePrice="pricingByDayBasePrice"
:pricingByDayUpcharge="pricingByDayUpcharge"
:showPricingByDay="showPricingByDay"
:isPricingByDayExperiment="isPricingByDayExperiment"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:estimatedServiceMinutesMinimum="
selectableDatesData.estimatedServiceMinutesMinimum
"
:estimatedServiceMinutesMaximum="
selectableDatesData.estimatedServiceMinutesMaximum
"
@TimeSlotSelected="updateTimeSlot"
:displayWaitList="displayWaitList"
@waitListRequested="handleWaitListRequested" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form, defineRule } from "vee-validate";
import datePicker from "@/digital-components/date-picker/date-picker";
import locationAlerts from "@/layouts/schedule/location-alerts/location-alerts";
import textBlock from "@/digital-components/text-block/text-block";
// Supporting files
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
import { settleAllPromises } from "@/helpers/layout-helper";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import {
calcDaysBetweenDates,
convertDateStringToDate,
sumDateString,
} from "@/layouts/schedule/helpers/schedule-helper";
import {
AppointmentTypeStrings,
PREMIUM_FEE_PART_TYPE,
PRICING_BY_DAY_PART_TYPE,
} from "@/constants/schedule-constants";
import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
import store from "@/store";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { getAmountDue, getPricingByDayPartWithPrice } from "@/helpers/pricing-helper.js";
import { getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import { partNumberStrings } from "@/constants/part-number-strings";
import { deepClone } from "@/helpers/object-helper";
import { debugLog } from "@/helpers/debug-log-helper";
// DEFINE VALIDATION RULES
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const apiEndDateLimit = sumDateString(startDateString, TIME_SLOTS_CALL_DAYS_LIMIT);
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
const storeActionConfigs = [];
const timeSlotsData = {};
timeSlotsData.days = [];
let apiStartDate = startDateString;
let apiEndDate = endDateString;
for (let i = 1; i <= apiCallsCount; i++) {
let storeActionConfig;
if (i > 1) {
apiStartDate = sumDateString(apiEndDate, 1);
apiEndDate = sumDateString(apiStartDate, TIME_SLOTS_CALL_DAYS_LIMIT);
if (i === apiCallsCount) {
apiEndDate = endDateString;
}
} else {
if (apiEndDate > apiEndDateLimit) {
apiEndDate = apiEndDateLimit;
}
}
if (appointmentType === AppointmentTypeStrings.MOBILE) {
storeActionConfig = {
storeAction: storeActions.GET_MOBILE_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
},
};
} else {
storeActionConfig = {
storeAction: storeActions.GET_SHOP_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
shopAppointmentType: appointmentType,
providerNumber: providerNumber,
},
};
}
if (apiStartDate < apiEndDate) storeActionConfigs.push(storeActionConfig);
}
const timeSlotsResponsesData = {
days: [],
};
function compareDayStrings(a, b) {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
}
const makeParallelCalls = async () => {
await Promise.all(
storeActionConfigs.map(async (storeAction) => {
const timeSlotsResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeAction.storeAction,
storeAction.payload,
"schedule",
false
);
timeSlotsResponsesData.estimatedServiceMinutesMinimum =
timeSlotsResponse.data.estimatedServiceMinutesMinimum;
timeSlotsResponsesData.estimatedServiceMinutesMaximum =
timeSlotsResponse.data.estimatedServiceMinutesMaximum;
timeSlotsResponsesData.days = [
...timeSlotsResponsesData.days,
...timeSlotsResponse.data.days,
];
})
);
};
return makeParallelCalls().then(() => {
// sort days chronologically
timeSlotsResponsesData.days.sort(compareDayStrings);
return timeSlotsResponsesData;
});
};
export default {
name: "schedule",
data() {
return {
selectedDate: this.getSelectedDate(),
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesData: [],
mobilePremiumAppointmentFee: null,
waitListRequested: null,
displayWaitList: null,
pricingByDayUpchargeLineItem: null,
includePricingByDayUpcharge: null,
isPricingByDayExperiment: null,
pricingByDayBasePrice: null,
pricingByDayUpcharge: null,
showPricingByDay: null,
};
},
async beforeRouteEnter(to, from, next) {
const isPricingByDayExperiment = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.PRICING_BY_DAY,
"true"
);
const showPricingByDay = !store.getters.payment.isInsurance && isPricingByDayExperiment;
// Get pricingByDayBasePrice needed for Pricing By Day
const lineItems = deepClone(store.getters.order.lineItems);
const isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder;
const shouldHideRecalibration =
experimentMixin.methods.hasSettingEqualTo(
experimentSettings.RECAL_PRICE_REMOVE,
"true"
) && isRecalibrationOnOrder;
const glassParts =
isRecalibrationOnOrder && shouldHideRecalibration
? getItemsWithoutRecalParts(lineItems.glassParts)
: (lineItems.glassParts ?? []);
const supportingItemsFromStore = lineItems?.supportingItems;
const supportingItemsWithoutFees = baseMixin.methods.filterOutCertainPartTypesOrNumbers(
lineItems.supportingItems,
{
partNumbersToRemove: [
partNumberStrings.RECYCLE_FEE,
partNumberStrings.PRICING_BY_DAY_UPCHARGE,
],
}
);
const lineItemsToBePriced = {
glassParts: glassParts,
supportingItems: supportingItemsWithoutFees,
vaps: lineItems.vaps ?? [],
promos: lineItems.promos ?? [],
};
const priceString = getAmountDue(lineItemsToBePriced, false); // pass the IncludeTax param as false
const priceStringIntegerRoundedDown = priceString?.split(".")[0]; // same method used as getDisplayPrice() in service-package-radio used on /quote
const pricingByDayBasePrice = parseInt(priceStringIntegerRoundedDown);
let includePricingByDayUpcharge = false;
// Check to see if date should be pre-selected
let preSelectedSlot = await store.getters.order.schedule;
if (!preSelectedSlot.date || preSelectedSlot?.date?.length < 1) {
preSelectedSlot = null;
} else {
// Check to see if pre-selected date should have pricing by day upcharge
if (showPricingByDay) {
// is this preSelectedDate a higher priced pricingByDay day?
const dayIndex = convertDateStringToDate(preSelectedSlot?.date).getDay();
const dayObject = DAYS_OF_WEEK[dayIndex];
if (dayObject.isPricingByDayUpchargeDay) {
includePricingByDayUpcharge = true;
}
}
}
// Set up promises
const cmsContentPromise = fetchCmsContentForPage(to.name);
const alertReasonsPromise = locationAlerts.methods.loadInitialData(
store.getters.order.serviceLocation.zipCodeCtu,
store.getters.order.serviceLocation.provider?.address?.zipCodeCtu
);
// While Pricing By Day Experiment is active, using the updated datePicker
const datePickerInitialDataPromise = await datePicker.methods.loadInitialData({
// setup config options for date-picker
selectableDatesSetting: "custom",
initialViewRowsToShow: 2,
customSelectableDatesCallback: getAvailableDates,
preSelectedDate: preSelectedSlot ? preSelectedSlot.date : preSelectedSlot,
});
// Get pricingByDayUpcharge needed for Pricing By Day
const pricingByDayUpchargePartPromise = showPricingByDay
? getPricingByDayPartWithPrice()
: null;
const premiumFeePromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_MOBILE_PREMIUM_FEE,
null,
"schedule"
);
const premiumFeeWithPricePromise = premiumFeePromise.then((result) => {
if (result.data) {
return baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: [result.data],
},
"schedule",
false
);
} else {
return result.data;
}
});
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "alertReasons",
promise: alertReasonsPromise,
},
{
resultKey: "datePickerInitialData",
promise: datePickerInitialDataPromise,
},
{
resultKey: "pricingByDayUpchargePart",
promise: pricingByDayUpchargePartPromise,
},
{
resultKey: "premiumFeeWithPrice",
promise: premiumFeeWithPricePromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
const pricingByDayUpcharge = showPricingByDay
? await baseMixin.methods.getTotalLineItemPrice(
resultMap.pricingByDayUpchargePart,
false
)
: null;
const datePickerInitialData = resultMap.datePickerInitialData;
datePickerInitialData.pricingByDayBasePrice = pricingByDayBasePrice;
datePickerInitialData.pricingByDayUpcharge = pricingByDayUpcharge;
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.datePicker.initializeComponent(datePickerInitialData);
vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons);
vm.selectableDatesData = datePickerInitialData.initialShopTimeSlotsResponse;
vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice
? resultMap.premiumFeeWithPrice[0]
: null;
vm.updateFooterButtonText(vm.selectedTimeSlotInfo);
vm.setDisplayWaitList();
vm.pricingByDayUpchargeLineItem = resultMap.pricingByDayUpchargePart;
vm.includePricingByDayUpcharge = includePricingByDayUpcharge;
vm.isPricingByDayExperiment = isPricingByDayExperiment;
vm.pricingByDayBasePrice = pricingByDayBasePrice;
vm.pricingByDayUpcharge = pricingByDayUpcharge;
vm.showPricingByDay = showPricingByDay;
});
},
mounted() {
this.$nextTick(() => {
const selectableDatesData = this.selectableDatesData;
// if no date selected on load
if (!this.selectedDate) {
if (selectableDatesData?.days?.length > 0) {
this.selectedDate = selectableDatesData.days[0].date;
} else {
setTimeout(() => {
this.$refs.datePicker.showAnotherMonth().then((moreSelectableDatesData) => {
this.selectableDatesData = moreSelectableDatesData;
if (selectableDatesData?.days?.length > 0) {
this.selectedDate = selectableDatesData.days[0].date;
}
this.setDisplayWaitList();
});
}, 50);
}
}
});
},
computed: {
ChangeShopLinkText() {
return this.getCmsContent("ChangeShopLink", "Text");
},
ChangeShopLink() {
// Splits content when brackets are found in text so that text can be looped through and router-link can be injected when needed
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
},
timeSlotsForSelectedDate() {
if (!this.selectedDate) return null;
return this.selectableDatesData.days?.find(
(selectableDate) => selectableDate.date === this.selectedDate
);
},
},
methods: {
splitCopyOnCMSPlaceHolder,
arePagePrerequisitesValid() {
const serviceLocation = store.getters.order.serviceLocation;
const serviceLocationPreReqs =
serviceLocation.zipCode &&
serviceLocation.zipCodeCtu &&
serviceLocation.appointmentType &&
(serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE ||
serviceLocation.provider.providerNumber);
const paymentInfo = store.getters.payment.isInsurance !== null;
const damageInfo =
store.getters.order.damage.isRepair ||
(store.getters.order.lineItems?.glassParts != null &&
store.getters.order.lineItems.glassParts.length > 0);
const preReqResult = serviceLocationPreReqs && paymentInfo && damageInfo;
// prettier-ignore
{
debugLog("--- schedule.vue pagePrereqs start ---", null, !preReqResult);
debugLog("store.getters.order.serviceLocation.zipCode:", serviceLocation.zipCode, !preReqResult);
debugLog("store.getters.order.serviceLocation.zipCodeCtu:", serviceLocation.zipCodeCtu, !preReqResult);
debugLog("store.getters.order.serviceLocation.appointmentType:", serviceLocation.appointmentType, !preReqResult);
debugLog("store.getters.order.serviceLocation.provider.providerNumber:", serviceLocation.provider?.providerNumber, !preReqResult);
debugLog("store.getters.payment.isInsurance:", store.getters.payment?.isInsurance, !preReqResult);
debugLog("store.getters.order.damage.isRepair:", store.getters.order.damage?.isRepair, !preReqResult);
debugLog("store.getters.order.lineItems.glassParts:", store.getters.order.lineItems?.glassParts, !preReqResult);
debugLog("--- schedule.vue pagePrereqs end ---", null, !preReqResult);
}
return preReqResult;
},
async getAvailableDatesMethod(startDate, endDate) {
const newShopTimeSlots = await getAvailableDates(
startDate,
endDate,
this.appointmentType,
this.$store.getters.order.serviceLocation.provider.providerNumber
);
// ADD API CALL RESULTS TO EXISTING DATE DATA
this.selectableDatesData.days = this.selectableDatesData.days.concat(
newShopTimeSlots.days
);
return newShopTimeSlots;
},
getAvailableDates,
getServiceZipCtuCodeFromStore() {
return store.getters.order.serviceLocation.zipCodeCtu;
},
getSelectedDate() {
return store.getters.order.schedule.date;
},
getSelectedTimeSlotInfo() {
const supportingItems = this.getSupportingItems();
var isPremiumAppointment = false;
if (supportingItems) {
isPremiumAppointment =
!!supportingItems.filter(
(lineItem) => lineItem.partType === PREMIUM_FEE_PART_TYPE
).length > 0;
}
const selectedTimeSlotInfo = {
timeSlot: store.getters.order.schedule,
isPremiumAppointment: isPremiumAppointment,
};
return selectedTimeSlotInfo;
},
getSupportingItems() {
return store.getters.lineItems.supportingItems;
},
updateFooterButtonText(timeSlotInfo) {
let navbarButtonText;
if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) {
navbarButtonText = "Continue";
} else {
navbarButtonText = `Select ${this.convertSelectedDateToShortMonthAndDay(
timeSlotInfo.timeSlot.date
)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime
)}`;
} else if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
!timeSlotInfo.isPremiumAppointment
) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime,
true
)} - ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.endTime,
true
)}`;
}
}
this.$refs.navbar.updateButtonText(navbarButtonText);
},
convertSelectedDateToShortMonthAndDay(selectedDate) {
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(selectedDate);
// Ex: April 25
return dateObject.toLocaleDateString("en-us", { month: "short", day: "numeric" });
},
getDisplayTextForMilitaryTime(militaryTimeInput, shouldTrimMinutesIfEmpty = false) {
// Expected input: "HH:MM"
let hours = parseInt(militaryTimeInput.split(":")[0]);
const minutes = militaryTimeInput.split(":")[1];
const meridianNotation = hours > 11 ? "PM" : "AM";
if (hours > 12) {
hours -= 12;
}
if (shouldTrimMinutesIfEmpty && minutes === "00") {
return `${hours} ${meridianNotation}`;
} else {
return `${hours}:${minutes} ${meridianNotation}`;
}
},
backButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
},
forwardButtonAction() {
this.updateSupportingItems();
if (this.displayWaitList) {
var gaLabel = "";
const status = this.waitListRequested ? "checked" : "unchecked";
const type = store.getters.isMobileAppointment ? "mobile" : "inshop";
gaLabel = `${status}_${type}`;
const currentDate = new Date();
const dateString = this.selectableDatesData.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const appointmentDate = new Date(year, month - 1, day);
const timeDifference = appointmentDate - currentDate;
// Convert the time difference from milliseconds to days
const daysUntilAppointment = Math.ceil(timeDifference / (1000 * 60 * 60 * 24));
gaLabel +=
"_" +
daysUntilAppointment.toString() +
"_" +
store.getters.order.serviceLocation.zipCode +
"_" +
store.getters.order.serviceLocation.zipCodeCtu;
this.pushEventToGA("waitlist", "add_to_waitlist_check_box_status", gaLabel, true);
}
if (this.selectedTimeSlotInfo.timeSlot.jobMinMinutes == null) {
this.selectedTimeSlotInfo.timeSlot.jobMinMinutes =
this.selectableDatesData?.estimatedServiceMinutesMinimum?.toString();
this.selectedTimeSlotInfo.timeSlot.jobMaxMinutes =
this.selectableDatesData?.estimatedServiceMinutesMaximum?.toString();
}
this.dispatchStoreAction(
this.storeActions.SAVE_SCHEDULE,
this.selectedTimeSlotInfo.timeSlot,
false
);
if (this.waitListRequested !== null && this.waitListRequested !== undefined) {
this.dispatchStoreAction(
this.storeActions.SAVE_WAITLIST_REQUESTED,
this.waitListRequested,
false
);
}
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
},
setDisplayWaitList() {
if (
experimentMixin.methods.hasSettingEqualTo(
experimentSettings.DISPLAY_WAITLIST,
"true"
) &&
this.selectableDatesData.days[0]
) {
const dateString = this.selectableDatesData.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const targetDate = new Date(year, month - 1, day);
const currentDate = new Date();
const futureDate = new Date(currentDate);
const experimentThresholdDays = experimentMixin.methods.hasSetting(
experimentSettings.WAITLIST_THRESHOLD_DAYS
)
? parseInt(
experimentMixin.methods.getSettingValue(
experimentSettings.WAITLIST_THRESHOLD_DAYS
)
)
: 0;
futureDate.setDate(currentDate.getDate() + experimentThresholdDays);
if (targetDate >= futureDate) {
this.displayWaitList = true;
} else {
this.displayWaitList = false;
}
}
},
updateSupportingItems() {
const supportingItems = this.getSupportingItems();
// if we have a pricing by day upcharge, then save/update supporting items with it
const pricingByDayUpchargeFeeIndex = supportingItems?.findIndex(
(item) => item.partType == PRICING_BY_DAY_PART_TYPE
);
if (this.includePricingByDayUpcharge && this.showPricingByDay) {
if (pricingByDayUpchargeFeeIndex && pricingByDayUpchargeFeeIndex > -1) {
supportingItems[pricingByDayUpchargeFeeIndex].laborAmount =
this.pricingByDayUpchargeLineItem.laborAmount;
supportingItems[pricingByDayUpchargeFeeIndex].sellingPrice =
this.pricingByDayUpchargeLineItem.sellingPrice;
supportingItems[pricingByDayUpchargeFeeIndex].kitPrice =
this.pricingByDayUpchargeLineItem.kitPrice;
} else {
supportingItems.push(this.pricingByDayUpchargeLineItem);
}
} else {
if (pricingByDayUpchargeFeeIndex >= 0) {
// remove pricing by day upcharge if it already was in store
supportingItems.splice(pricingByDayUpchargeFeeIndex, 1);
}
}
// if we have a premium fee(early bird), then save/update supporting items
if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
this.selectedTimeSlotInfo?.isPremiumAppointment
) {
const premiumFeeIndex = supportingItems.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
);
if (premiumFeeIndex > -1) {
supportingItems[premiumFeeIndex].laborAmount =
this.mobilePremiumAppointmentFee.laborAmount;
supportingItems[premiumFeeIndex].sellingPrice =
this.mobilePremiumAppointmentFee.sellingPrice;
supportingItems[premiumFeeIndex].kitPrice =
this.mobilePremiumAppointmentFee.kitPrice;
} else {
supportingItems.push(this.mobilePremiumAppointmentFee);
}
} else {
if (!supportingItems) {
return;
}
// if it's not a mobile and/or premium early bird, then make sure we remove any that may have been added
const removePremiumFeeIndex = supportingItems.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
);
if (removePremiumFeeIndex >= 0) {
supportingItems.splice(removePremiumFeeIndex, 1);
}
}
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
},
handleWaitListRequested(value) {
this.waitListRequested = value;
},
handleDateClicked(date) {
// do something to mark this as upcharge day or not...
if (date.isPricingByDayUpchargeDay) {
this.includePricingByDayUpcharge = true;
} else {
this.includePricingByDayUpcharge = false;
}
},
updateTimeSlot(timeSlot) {
this.selectedTimeSlotInfo = timeSlot;
},
},
watch: {
selectedDate(newValue, oldValue) {
// Clear time slot selection if date selected changes
if (newValue !== oldValue) {
this.selectedTimeSlotInfo = {
timeSlot: {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
isPremiumAppointment: null,
};
}
},
selectedTimeSlotInfo(newValue) {
this.updateFooterButtonText(newValue);
},
},
components: {
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
datePicker,
locationAlerts,
textBlock,
},
};
</script>
<style lang="scss">
.container-fluid {
&.page-schedule {
padding: 0 1rem;
.text-link-small {
a,
.btn-link {
width: auto;
margin: 0 auto;
height: auto;
font-size: 0.875rem;
line-height: 1.75;
padding: 0;
border-radius: 0;
&:focus {
outline: 1px solid $blue;
}
@include media-breakpoint-up(md) {
font-size: 1rem;
}
}
}
.funnel-sub-header {
h5.dark-header {
margin-bottom: 0.25rem;
}
}
.change-location a {
font-family: AvertaSemibold;
}
.time-slots-question {
padding: 0 0.75rem;
}
}
}
</style>
<!-- OLD SCHEDULE ENDS -->

View file

@ -1,616 +0,0 @@
// Components
import schedule from "@/layouts/schedule/schedule.vue";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
import router from "@/router";
import baseMixin from "../../mixins/base-mixin";
// Mock basemixin
jest.mock("@/mixins/base-mixin.js", () => ({
methods: {
dispatchStoreAction: jest.fn().mockImplementation((storeAction) => {
if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") {
return {
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: [
{
date: "2023-12-01",
timeSlots: [
{
id: "06747-01820-S-B*20424*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
};
}
if (storeAction === "getMobilePremiumFee") {
return Promise.resolve({
data: {
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
});
}
if (storeAction === "priceOrderItemsAndSaveServerData") {
return Promise.resolve([
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
]);
}
if (storeAction === "saveSupportingItemsSuppressingStateResetting") {
return Promise.resolve([
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
]);
}
}),
dispatchStoreActionWithLogging: jest.fn().mockImplementation((storeAction) => {
if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") {
return {
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: [
{
date: "2023-12-01",
timeSlots: [
{
id: "06747-01820-S-B*20424*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
};
}
if (storeAction === "getMobilePremiumFee") {
return Promise.resolve({
data: {
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
});
}
if (storeAction === "priceOrderItemsAndSaveServerData") {
return Promise.resolve([
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
]);
}
if (storeAction === "saveSupportingItemsSuppressingStateResetting") {
return Promise.resolve([
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 14.99,
kitPrice: 0,
},
]);
}
}),
filterOutCertainPartTypesOrNumbers: jest.fn(),
hasSubmittedOrder: jest.fn(),
getTotalPriceOfAllLineItemsAndChildParts: jest.fn(),
getTotalLineItemPrice: jest.fn(),
},
}));
// Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: () => Promise.resolve("content"),
splitCopyOnCMSPlaceHolder: jest.fn(() => ["A", "B"]),
}));
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
store.getters = {
applicationUser: {
experiments: [],
},
order: {
schedule: {
date: "2019-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: "000",
},
lineItems: {
glassParts: [
{
partNumber: "ABC123",
},
],
supportingItems: [],
},
serviceLocation: {
appointmentType: "Inshop",
zipCode: "12345",
zipCodeCtu: "01234",
provider: {
providerNumber: "123",
},
},
damage: {
isRepair: false,
},
referralNumber: "1234567",
policy: {
policyNumber: "123",
},
},
payment: {
isInsurance: true,
},
lineItems: {
glassParts: [],
supportingItems: [],
},
experimentSettings: {},
vehicle: {
carId: "123",
},
};
});
afterEach(() => {
store.getters = {};
jest.restoreAllMocks();
jest.clearAllMocks();
});
describe("schedule.vue...", () => {
describe("initial load", () => {
test("should pass arePagePrerequisitesValid with a mobile CASH order and no providerNumber", () => {
// Arrange
const { wrapper } = setupMocks({});
store.getters.order.serviceLocation.appointmentType = "Mobile";
store.getters.order.serviceLocation.provider.policyNumber = null;
store.getters.payment.isInsurance = false;
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBe(true);
});
test("should pass arePagePrerequisitesValid with an inshop order and providerNumber", () => {
// Arrange
const { wrapper } = setupMocks({});
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBe(true);
});
test("should fail arePagePrerequisitesValid with a replace with no glass parts", async () => {
// Arrange
const { wrapper } = setupMocks({});
store.getters.order.lineItems.glassParts = [];
// Act
const arePagePrerequisitesValid2 = await wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid2).toBe(false);
});
test("should fail arePagePrerequisitesValid without isInsurance", () => {
// Arrange
const { wrapper } = setupMocks({});
store.getters.payment.isInsurance = null;
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBe(false);
});
test("should return timeslots when getMoreScheduleData is called", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.selectableDatesMobile = {
days: [],
};
// Act
const newShopTimeSlots = await wrapper.vm.getMoreScheduleData(
"2023-01-01",
"2023-01-31"
);
// Assert
expect(newShopTimeSlots).toStrictEqual({
inshopTimeSlotsData: {
days: [
{
date: "2023-12-01",
timeSlots: [
{
endTime: "08:00",
id: "06747-01820-S-B*20424*7 AM",
offerPremium: false,
startTime: "07:00",
},
],
},
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
mobileTimeSlotsData: {
days: [
{
date: "2023-12-01",
timeSlots: [
{
endTime: "08:00",
id: "06747-01820-S-B*20424*7 AM",
offerPremium: false,
startTime: "07:00",
},
],
},
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
});
});
test("should call API service in day ranges of 34 or less when getMoreScheduleData is called with large date ranges", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.selectableDatesMobile = {
days: [],
};
// Act
await wrapper.vm.getMoreScheduleData.call(
wrapper.vm,
"2023-01-01",
"2023-03-31",
"Inshop",
"123"
);
// Assert
expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledTimes(6);
expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledWith(
"getShopTimeSlots",
expect.anything(),
expect.anything(),
expect.anything()
);
});
describe("beforeRouteEnter function... ", () => {
// TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back)
xtest("should call next() and call all functions within next", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.updateFooterButtonText = jest.fn();
wrapper.vm.setDisplayWaitList = jest.fn();
const nextFunction = jest.fn((c) => {
c(wrapper.vm);
});
// Act
await schedule.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "schedule" } },
undefined,
nextFunction
);
// Assert
expect(nextFunction).toHaveBeenCalled();
expect(wrapper.vm.setCmsContent).toHaveBeenCalledWith("content");
expect(wrapper.vm.$refs.datePicker.initializeComponent).toHaveBeenCalledWith(
expect.objectContaining({
calendarViewDirection: "future",
})
);
expect(wrapper.vm.$refs.locationAlerts.initializeComponent).toHaveBeenCalled();
expect(wrapper.vm.selectableDatesInshop).toStrictEqual(
expect.objectContaining({
days: expect.any(Array),
estimatedServiceMinutesMaximum: expect.any(Number),
estimatedServiceMinutesMinimum: expect.any(Number),
})
);
expect(wrapper.vm.mobilePremiumAppointmentFee).toStrictEqual(
expect.objectContaining({
partNumber: expect.any(String),
})
);
expect(wrapper.vm.updateFooterButtonText).toHaveBeenCalled();
expect(wrapper.vm.setDisplayWaitList).toHaveBeenCalled();
});
});
describe("computed properties...", () => {
test("timeSlotsForSelectedDate should return timeslots if selected date is available", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [
{
date: "2022-11-11",
timeSlots: [
{
id: "1820I-01820-M-I*20425*AM",
startTime: "08:00",
endTime: "12:00",
offerPremium: true,
},
{
id: "1820I-01820-M-I*20425*PM",
startTime: "12:00",
endTime: "17:00",
offerPremium: false,
},
],
},
],
};
wrapper.setData({
selectedDate: "2022-11-11",
});
// Act
const testValue = wrapper.vm.timeSlotsForSelectedDate;
// Assert
expect(testValue).toStrictEqual(
expect.objectContaining({
date: "2022-11-11",
})
);
});
test("timeSlotsForSelectedDate should be null if no date has been selected", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [
{
date: "2022-11-11",
timeSlots: [
{
id: "1820I-01820-M-I*20425*AM",
startTime: "08:00",
endTime: "12:00",
offerPremium: true,
},
{
id: "1820I-01820-M-I*20425*PM",
startTime: "12:00",
endTime: "17:00",
offerPremium: false,
},
],
},
],
};
wrapper.setData({
selectedDate: undefined,
});
// Act
const testValue = wrapper.vm.timeSlotsForSelectedDate;
// Assert
expect(testValue).toBe(null);
});
});
});
describe("schedule page methods...", () => {
test("getServiceZipCtuCodeFromStore should return zipCodeCtu", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
// Act
const testValue = wrapper.vm.getServiceZipCtuCodeFromStore();
// Assert
expect(testValue).toStrictEqual("01234");
});
test("getDisplayTextForMilitaryTime should return the correctly formatted string", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
const timeInput1 = "15:00";
const timeInput2 = "15:30";
// Act
const testOutput1 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput1);
const testOutput2 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput2);
const testOutput3 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput1, true);
const testOutput4 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput2, true);
// Assert
expect(testOutput1).toBe("3:00 PM");
expect(testOutput2).toBe("3:30 PM");
expect(testOutput3).toBe("3 PM");
expect(testOutput4).toBe("3:30 PM");
});
test("Clicking back should fire correct navigation", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.$router.navigateWithoutSaving = jest.fn();
// Act
wrapper.vm.backButtonAction();
// Assert
expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith(
"CLICKED_BACK",
"schedule"
);
});
});
// TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back)
xtest("forwardButtonAction should call route method navigateWithoutSaving", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.$router.navigateWithSaving = jest.fn(() => {
return {};
});
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
});
test("for Inshop appts, updateSupportingItems should call store action to save supporting items WITHOUT the EARLY BIRD supporting item", async () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Inshop";
store.getters.lineItems.supportingItems = [
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 0,
kitPrice: 0,
},
];
const { wrapper } = setupMocks({});
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.mobilePremiumAppointmentFee = 14.99;
wrapper.setData({
selectedTimeSlot: {
date: "2019-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: null,
isPremiumAppointment: true,
},
});
// Act
await wrapper.vm.updateSupportingItems();
// Assert
expect(wrapper.vm.dispatchStoreAction).toBeCalledWith(
"saveSupportingItemsSuppressingStateResetting",
expect.not.arrayContaining([
expect.objectContaining({
partType: "EARLY BIRD",
}),
]),
expect.anything()
);
});
});
const mockCmsContent = {};
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,
route: { name: "schedule" },
});
mountOptions.global.mocks["$store"] = store;
mountOptions.global.mocks["$router"] = router;
mountOptions["attachTo"] = document.body;
mountOptions.mixins = [
{
methods: {
getCmsContent: jest.fn().mockImplementation((widgetName, fieldName) => {
if (mockCmsContent[widgetName] && mockCmsContent[widgetName][fieldName])
return mockCmsContent[widgetName][fieldName];
}),
},
},
];
mountOptions.global.mocks.pageName = "schedule";
const wrapper = shallowMount(schedule, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
wrapper.vm.$refs.datePicker.initializeComponent = jest.fn();
wrapper.vm.$refs.datePicker.loadInitialData = jest.fn();
wrapper.vm.$refs.locationAlerts.initializeComponent = jest.fn();
wrapper.vm.$refs.navbar.updateButtonText = jest.fn();
return { wrapper };
}

View file

@ -2,7 +2,13 @@
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<loadingModal ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<div class="container page-container-grouped-styles">
<mobileFirstModal
modalWidgetName="MobileFirstModalWidget"
ref="mobileFirstModal"
@confirm-appointment="updateTimeSlotandNavigateForward" />
<div
class="container page-container-grouped-styles"
:class="[isMobileFirstModalOpen ? 'hidden-background' : '']">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<mobileFeeWaiverAlert
@ -183,6 +189,7 @@ import shopListButton from "@/layouts/service-location/shop-question/shop-list-b
import shopLocation from "@/layouts/schedule/shop-location/shop-location";
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 mobileFirstModal from "@/layouts/schedule/mobile-first-modal/mobile-first-modal.vue";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
import shopQuestionPopup from "@/layouts/service-location/shop-question/shop-question-popup";
@ -203,7 +210,7 @@ import mobileFeeWaiverAlert from "./mobile-fee-waiver-alert/mobile-fee-waiver-al
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { experimentUniverses, experimentSettings } from "@/constants/experiments";
import {
AppointmentTypeStrings,
RouteCodeFlags,
@ -233,6 +240,7 @@ import {
convertDateStringToDate,
sumDateString,
isDropOffRouteCode,
getTodayDate,
} from "@/layouts/schedule/helpers/schedule-helper";
import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants";
@ -241,6 +249,11 @@ import { getAmountDue, getPricingByDayPartWithPrice } from "@/helpers/pricing-he
import { getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import { deepClone } from "@/helpers/object-helper";
import { debugLog } from "@/helpers/debug-log-helper";
import {
getSessionKeyValue,
getUserIdValue,
getDeviceIdValue,
} from "@/helpers/heritage-integration/cookie-helper";
// DEFINE VALIDATION RULES
defineRule("mobile-location-required", (value) => {
@ -331,12 +344,16 @@ const getScheduleApiResponse = async ({
const mobileTimeSlotsData = {
days: [],
};
function compareDayStrings(a, b) {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
}
function removePastDates(array, todaysDate) {
return array.filter(function (a) {
return !(a.date < todaysDate);
});
}
const makeParallelCalls = async () => {
await Promise.all(
@ -389,6 +406,10 @@ const getScheduleApiResponse = async ({
inshopTimeSlotsData.days.sort(compareDayStrings);
mobileTimeSlotsData.days.sort(compareDayStrings);
const todaysDate = getTodayDate().toISOString().split("T")[0];
inshopTimeSlotsData.days = removePastDates(inshopTimeSlotsData.days, todaysDate);
mobileTimeSlotsData.days = removePastDates(mobileTimeSlotsData.days, todaysDate);
return {
inshopTimeSlotsData: inshopTimeSlotsData,
mobileTimeSlotsData: mobileTimeSlotsData,
@ -422,7 +443,6 @@ export default {
selectableDatesInshop: [],
selectableDatesMobile: [],
preSelectedDate: null,
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(),
carId: this.getCarIdfromStore(),
@ -571,7 +591,7 @@ export default {
vm.showPricingByDay = false; // Pricing By Day is not used in this version
vm.preSelectedDate = preSelectedDate;
vm.appointmentType = appointmentType;
vm.setData(
vm.setDataOnLoad(
zipCodeData,
resultMap.serviceabilityDetails,
pricedMobileFeePart,
@ -612,7 +632,7 @@ export default {
this.selectedDate.includes("mobile") &&
!this.isMobileSelected
) {
this.getNewSelectedDate();
this.setSelectedDateToFirstAvailable();
}
return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion;
},
@ -826,7 +846,7 @@ export default {
}
},
isSameDay() {
const todaysDate = new Date().toISOString().split("T")[0];
const todaysDate = getTodayDate().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
isOvernightDropoff() {
@ -837,6 +857,9 @@ export default {
)
);
},
isMobileFirstModalOpen() {
return this.isMobileSelected ? this.$refs.mobileFirstModal?.getIsModalOpen() : false;
},
},
methods: {
splitCopyOnCMSPlaceHolder,
@ -913,7 +936,7 @@ export default {
}
},
setData(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) {
setDataOnLoad(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) {
if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
this.zipCodeCtu = zipCodeData.zipCodeCtu;
@ -1100,7 +1123,7 @@ export default {
},
getTimeSlotInfo() {
// Get the current date
const currentDate = new Date();
const currentDate = getTodayDate();
// Add 10 days to the current date
currentDate.setDate(currentDate.getDate() + 10);
@ -1125,6 +1148,7 @@ export default {
this.resetWaitlist();
this.preSelectedDate = null;
this.shopProviderData = shopQuestionPopUpData.shopProviderData;
const oldProvider = this.selectedProvider;
const selectedProvider = this.shopProviderData.shopProviders.find((shopProvider) => {
return shopProvider.providerNumber === shopQuestionPopUpData.selectedProviderNumber;
});
@ -1136,7 +1160,12 @@ export default {
selectedProvider
);
} else {
this.updateSelectedProvider(selectedProvider);
const didProviderNumberChange =
oldProvider?.providerNumber !== selectedProvider.providerNumber;
if (didProviderNumberChange) {
this.updateSelectedProvider(selectedProvider);
this.initializeDatePicker();
}
}
},
async getMoreScheduleData(startDate, endDate) {
@ -1158,12 +1187,16 @@ export default {
moreShopTimeSlots.mobileTimeSlotsData.days
);
this.showMobileFirstModal();
return moreShopTimeSlots;
},
async initializeDatePicker() {
this.selectedDate = null;
const includeMobileTimeSlots = this.isServiceableMobile;
const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff;
const datePickerInitialData = await this.$refs.datePicker.loadInitialData({
// setup config options for date-picker
selectableDatesSetting: "custom",
@ -1172,52 +1205,30 @@ export default {
preSelectedDate: this.preSelectedDate,
providerNumber: this.selectedProvider?.providerNumber,
zipCode: this.zipCode,
includeMobileTimeSlots: this.isServiceableMobile,
includeInshopTimeSlots: this.isServiceableInshop || this.isServiceableDropoff,
includeMobileTimeSlots: includeMobileTimeSlots,
includeInshopTimeSlots: includeInshopTimeSlots,
});
datePickerInitialData.pricingByDayBasePrice = this.pricingByDayBasePrice;
datePickerInitialData.pricingByDayUpcharge = this.pricingByDayUpcharge;
await this.$refs.datePicker.initializeComponent(datePickerInitialData);
this.selectableDatesInshop =
datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData;
await datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData;
this.selectableDatesMobile =
datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData;
await datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData;
this.setDisplayWaitList();
if (this.preSelectedDate) this.selectedDate = this.preSelectedDate;
if (!this.preSelectedDate) {
// if no date is preselected on load, then select the first available
let selectedDateMobile = this.getSelectedDateForMobile();
let selectedDateInshop = this.getSelectedDateForInshop();
// if there is still no selected date, then load more and try again
if (this.preSelectedDate) {
this.selectedDate = this.preSelectedDate;
} else {
// if no date is preselected on load, make sure there are some dates available
if (
(this.isServiceableMobile && !selectedDateMobile) ||
(this.isServiceableInshop && !selectedDateInshop) ||
(this.isServiceableDropoff && !selectedDateInshop)
(includeInshopTimeSlots && this.selectableDatesInshop.days.length < 1) ||
(includeMobileTimeSlots && this.selectableDatesMobile.days.length < 1)
) {
await this.$nextTick();
await this.$refs.datePicker.showAnotherMonth();
// update all dates
// > CHLOE HERD 7/22 -- CASH-1207
// > Do not update the available dates again here;
// > they have already been updated by `showAnotherMonth`.
// > Doing so will likely add or remove dates,
// > desyncing the schedule page and the date-picker.
this.setDisplayWaitList();
}
await this.$nextTick();
if (this.isMobileSelected) {
this.selectedDate = this.getSelectedDateForMobile();
} else {
this.selectedDate = this.getSelectedDateForInshop();
}
}
},
getScheduleApiResponse,
@ -1403,7 +1414,7 @@ export default {
const type = store.getters.isMobileAppointment ? "mobile" : "inshop";
gaLabel = `${status}_${type}`;
const currentDate = new Date();
const currentDate = getTodayDate();
const dateString = this.isMobileSelected
? this.selectableDatesMobile.days[0].date
: this.selectableDatesInshop.days[0].date;
@ -1477,7 +1488,7 @@ export default {
) {
const [year, month, day] = dateString.split("-").map(Number);
const targetDate = new Date(year, month - 1, day);
const currentDate = new Date();
const currentDate = getTodayDate();
const futureDate = new Date(currentDate);
const experimentThresholdDays = experimentMixin.methods.hasSetting(
experimentSettings.WAITLIST_THRESHOLD_DAYS
@ -1622,7 +1633,7 @@ export default {
? AppointmentTypeStrings.DROP_OFF
: AppointmentTypeStrings.IN_SHOP;
},
getSelectedDate() {
getFirstAvailableDate() {
let dateToSelect;
if (this.isMobileSelected) {
dateToSelect = returnFirstDate(this.selectableDatesMobile);
@ -1632,29 +1643,13 @@ export default {
if (!dateToSelect) return null;
return this.isMobileSelected ? dateToSelect + "-mobile" : dateToSelect;
},
getSelectedDateForMobile() {
let dateToSelect = returnFirstDate(this.selectableDatesMobile);
if (!dateToSelect) return null;
return dateToSelect + "-mobile";
},
getSelectedDateForInshop() {
let dateToSelect = returnFirstDate(this.selectableDatesInshop);
if (!dateToSelect) return null;
return dateToSelect;
},
resetSelectedProvider() {
this.selectedProvider = new Provider();
this.updateSelectedProvider();
},
updateSelectedProvider(newProvider) {
if (newProvider) {
const didProviderNumberChange =
this.selectedProvider.providerNumber !== newProvider.providerNumber;
this.selectedProvider = newProvider;
if (didProviderNumberChange) {
this.initializeDatePicker();
}
updateSelectedProvider(newShopProvider) {
if (newShopProvider) {
this.selectedProvider = newShopProvider;
} else if (this.appointmentType === this.appointmentTypeStrings.MOBILE) {
this.selectedProvider = {
providerNumber: this.shopProviderData.mobileProviderNumber.toString(),
@ -1721,6 +1716,7 @@ export default {
}
this.appointmentType = AppointmentTypeStrings.MOBILE;
this.updateSelectedProvider();
this.showMobileFirstModal();
} else if (newAppointmentType) {
if (this.appointmentType != AppointmentTypeStrings.MOBILE) {
// Clear last shop selected if appointment type was changed in any manner other than from Mobile
@ -1734,17 +1730,131 @@ export default {
} else {
this.appointmentType = AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF;
}
// make sure a selectedProvider exists
this.updateSelectedProvider(this.lastSelectedInshopOrDropoffProvider);
} else {
this.appointmentType = null;
}
this.selectedDate = this.getSelectedDate();
this.setSelectedDateToFirstAvailable();
this.setDisplayWaitList();
},
getNewSelectedDate() {
this.selectedDate = this.getSelectedDate();
setSelectedDateToFirstAvailable() {
this.selectedDate = this.getFirstAvailableDate();
},
async showMobileFirstModal() {
if (this.appointmentType == AppointmentTypeStrings.MOBILE) {
const isShowMobileFirstAppt = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
);
const pmMobileDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_PM_MOBILE_DAYS
);
const noPMMobileDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_PM_MOBILE_DAYS
);
const noMobileAvailableDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_MOBILE_AVAILABLE_DAYS
);
let preSelectedMobileAppointment = null;
const todaysDate = getTodayDate();
let firstMobileAMAppt = await this.getFirstAvailableMobileApptByTOD("AM");
let firstMobilePMAppt = await this.getFirstAvailableMobileApptByTOD("PM");
if (!firstMobileAMAppt && !firstMobilePMAppt) {
return;
}
let firstMobileAMDate = firstMobileAMAppt ? firstMobileAMAppt.date : null;
let firstMobilePMDate = firstMobilePMAppt ? firstMobilePMAppt.date : null;
let numberOfDaysToFirstMobileAMDate =
(new Date(firstMobileAMDate) - todaysDate) / (1000 * 60 * 60 * 24);
let numberOfDaysToFirstMobilePMDate =
(new Date(firstMobilePMDate) - todaysDate) / (1000 * 60 * 60 * 24);
const shouldExposeMobileFirstAppointment = () => {
return (
(firstMobileAMDate &&
numberOfDaysToFirstMobileAMDate <= noMobileAvailableDays) ||
(firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= noMobileAvailableDays)
);
};
if (shouldExposeMobileFirstAppointment()) {
const mobileFirstExperiment = store.getters.applicationUser.experiments.find(
(e) => e.universeName === experimentUniverses.MOBILE_FIRST_APPOINTMENT
);
const hasExposedMobileFirst = mobileFirstExperiment?.isExposed;
if (!hasExposedMobileFirst && mobileFirstExperiment) {
baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOG_EXPERIMENT_EXPOSURE_AND_UPDATE_STORE,
{
userId: getUserIdValue(),
deviceId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: "schedule",
experiment: mobileFirstExperiment,
},
"schedule",
false
);
}
if (isShowMobileFirstAppt) {
if (firstMobilePMDate && numberOfDaysToFirstMobilePMDate <= pmMobileDays) {
preSelectedMobileAppointment = firstMobilePMAppt;
} else if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= noPMMobileDays
) {
preSelectedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt
: null;
!preSelectedMobileAppointment &&
(preSelectedMobileAppointment = firstMobilePMAppt);
}
this.$refs.mobileFirstModal.setSelectedAppointment(
preSelectedMobileAppointment
);
this.$refs.mobileFirstModal.openModal();
}
}
}
},
async getFirstAvailableMobileApptByTOD(timeOfDay) {
if (!this.selectableDatesMobile.days) {
await this.initializeDatePicker();
}
if (!this.selectableDatesMobile?.days?.length) {
return null;
} else {
for (const dateObj of this.selectableDatesMobile.days) {
let matchingTimeSlot = {
estimatedServiceMinutes: {
minimum: this.estimatedServiceMinutesMinimum,
maximum: this.estimatedServiceMinutesMaximum,
},
timeSlot: null,
date: null,
};
const isMatchingApptDay = dateObj.timeSlots.some(
(slot) =>
slot.id.includes(timeOfDay) &&
(matchingTimeSlot.timeSlot = slot) &&
(matchingTimeSlot.date = dateObj.date)
);
if (isMatchingApptDay && matchingTimeSlot) {
return matchingTimeSlot;
}
}
return null;
}
},
updateTimeSlotandNavigateForward(timeSlotObj) {
this.updateTimeSlot(timeSlotObj);
this.forwardButtonAction();
},
},
watch: {
@ -1790,6 +1900,7 @@ export default {
locationAlerts,
textBlock,
timeSlotQuestion,
mobileFirstModal,
alert,
serviceZipModalQuestion,
@ -1813,6 +1924,9 @@ export default {
margin-bottom: 0.25rem;
}
}
&.hidden-background {
display: none;
}
}
.alert.alert-warning {
.alert-heading {

View file

@ -509,8 +509,12 @@ export default {
// this.availableTimeSlots only returns mobile/inshop slots so we know
// the only available slot is not dropOFf
this.selectedAnswerForTimeSlots = this.availableTimeSlots[0].value;
// emit up to parent that the time slot has been selected (when mobile or inshop)
this.timeSlotSelectionChanged(this.availableTimeSlots[0].value);
} else {
this.selectedAnswerForDropOffOrInshop = this.answersForDropOffQuestion[0].value;
// emit up to parent that the time slot has been selected (when only dropoff)
this.dropOffSelectionChanged(this.answersForDropOffQuestion[0].value);
}
}
},

View file

@ -10,6 +10,7 @@ import {
areAllSessionCookiesSet,
setSessionIdIfUnset,
setSessionKeyIfUnset,
getskeyValue,
} from "@/helpers/heritage-integration/cookie-helper";
import { queryStrings } from "@/constants/query-strings";
import { experimentSettings, experimentUniverses } from "@/constants/experiments";
@ -31,6 +32,9 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { routeData } from "@/router/constants/routes";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { Variables } from "../constants/analytics";
import { containsRecalParts, getRecalPartNumbers } from "@/helpers/recal-helper";
import { getAmountDue, getSubTotal, getSalesTax } from "@/helpers/pricing-helper.js";
import { partTypeStrings } from "@/constants/part-type-strings";
import router from "@/router";
export default {
@ -197,6 +201,101 @@ export default {
await this.logPageView(analyticsPageEvents.ENTRY);
},
// This sends session data to the session logging endpoint on the analytics service.
// From there, it uses aws kinesis data stream, DigitalConsumer-Session-Data-Stream,
// and aws FireHose stream, DigitalConsumer-Session-Firehose, to push data to an
// S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1.
// This bucket data is then picked up by snowflake for analytics use.
async pushFmgSessionData() {
const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder();
const submittedOrder = baseMixin.methods.getSubmittedOrder();
const order = hasSubmittedOrder ? submittedOrder : store.getters.order;
const hasSubmittedApplicationUser = baseMixin.methods.hasSubmittedApplicationUser();
const submittedApplicationUser = baseMixin.methods.getSubmittedApplicationUser();
const applicationUser = hasSubmittedApplicationUser
? submittedApplicationUser
: store.getters.applicationUser;
const isEarlyBird = order?.lineItems?.supportingItems?.find(
(lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD
);
const promoCodes = Array.isArray(order?.lineItems?.promos)
? order?.lineItems?.promos
.map((item) => item.promoCode)
.filter((code) => code)
.join(", ")
: null;
const glassProducts = order?.damage?.glassToReplace?.map(
(part) => `${part.glassLocation}-${part.glassName}`
);
var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`;
var sessionData = {};
sessionData.currentPage = getPageNameFromRouter();
sessionData.sid = getSessionIdValue();
sessionData.deviceId = getDeviceIdValue();
sessionData.fmgSessionId = applicationUser?.savedSessionId;
sessionData.skey = getskeyValue().toString();
sessionData.userId = getUserIdValue();
sessionData.carId = order?.vehicle?.carId;
sessionData.vehicleYear = order?.vehicle?.year;
sessionData.vehicleMake = order?.vehicle?.make;
sessionData.vehicleModel = order?.vehicle?.model;
sessionData.vehicleStyle = order?.vehicle?.style;
sessionData.hasVin = order?.vehicle?.vin ? true : false;
sessionData.cashOrInsuranceAccountType = order?.payment?.isInsurance
? "Insurance"
: "Cash";
sessionData.damageType = order?.damage?.isRepair ? "Repair" : "Replace";
sessionData.productType = glassProducts;
sessionData.eon = order?.eon;
sessionData.referralNumber = order?.referralNumber;
sessionData.referralSequenceNumber = order?.referralSequenceNumber;
sessionData.referralDate = order?.referralDate;
sessionData.workOrderNumber = order?.workOrderNumber;
sessionData.workOrderId = order?.workOrderId;
sessionData.isPia = order?.payment?.isPia ?? false;
sessionData.piaType = order?.payment?.piaType;
sessionData.parentAccountNumber = order?.payment?.parentAccountNumber;
sessionData.settledTenderAmount = order?.settledTenderAmount;
sessionData.recalRequired = containsRecalParts(order?.lineItems);
sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts);
sessionData.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode
? order?.serviceLocation?.provider?.address?.zipCode
: order?.serviceLocation?.zipCode;
sessionData.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu
? order?.serviceLocation?.provider?.address?.zipCodeCtu
: order?.serviceLocation?.zipCodeCtu;
sessionData.appointmentDate = appointment;
sessionData.serviceType = order?.serviceLocation?.appointmentType;
sessionData.promoCodes = promoCodes;
sessionData.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false;
sessionData.paymentMethod = order?.payment?.piaType;
sessionData.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false;
sessionData.isEarlyBird = isEarlyBird ? true : false;
sessionData.insuranceCo = order?.policy?.insuranceCompanyName;
sessionData.deductible = order?.policy?.currentDeductible;
sessionData.isVerified = order?.payment?.insuranceCoverage?.isVerified ?? false;
sessionData.coverageStatus = order?.payment?.insuranceCoverage?.coverageStatus;
sessionData.coverageSubStatus = order?.payment?.insuranceCoverage?.coverageSubStatus;
sessionData.isNoComp = order?.policy?.isNoComp;
sessionData.isItac = order?.policy?.isItac;
sessionData.subTotalPrice = getSubTotal(order?.lineItems);
sessionData.totalPrice = getAmountDue(order?.lineItems, true);
sessionData.userAgent = navigator.userAgent;
sessionData.cashPriceSubTotal = order?.cashPriceSubTotal;
await baseMixin.methods.dispatchStoreAction(
storeActions.LOG_FMG_SESSION_DATA,
sessionData,
false
);
},
pushOrderToDataLayer() {
// helper check for if an object is defined (but maybe falsey)
const isDefined = (x) => x !== null && x !== undefined;
@ -372,7 +471,7 @@ export default {
}
// Cash Quote or Cash Price Sub Total
payload.cashPriceSubTotal = store.getters.order?.cashPriceSubTotal ?? "";
payload.cashPriceSubTotal = order?.cashPriceSubTotal ?? "";
//unverified (in scenarios we dont display the price)
if (

View file

@ -112,7 +112,6 @@ export const routeData = {
path: "/virtual/auto-route",
virtual: true,
},
// TODO: RENAME FROM BAILOUT
ERROR: {
name: "error",
path: "/virtual/error",

View file

@ -1,7 +1,7 @@
import { routes } from "@/router/methods/routes";
import { afterEach } from "@/router/methods/after-each";
import { beforeEach } from "@/router/methods/before-each";
import { bailout } from "@/router/methods/error";
import { handleSoftError, handleHardError } from "@/router/methods/error";
import {
navigateWithoutSaving,
navigateWithSaving,
@ -32,7 +32,8 @@ router.navigateWithoutSaving = navigateWithoutSaving;
router.navigateWithPageData = navigateWithPageData;
router.navigateAndForceTopLevelNavigation = navigateAndForceTopLevelNavigation;
router.bailout = bailout;
router.handleSoftError = handleSoftError;
router.handleHardError = handleHardError;
router.navigateToExternalUrl = navigateToExternalUrl;

View file

@ -4,6 +4,9 @@ export async function afterEach(to, from) {
// digital consumer logging
analyticsMixin.methods.logDigitalConsumer();
// digital consumer fmg session logging to snowflake
analyticsMixin.methods.pushFmgSessionData();
// Push page view to GA
analyticsMixin.methods.pushPageViewToGA();

View file

@ -8,7 +8,7 @@ import analyticsMixin from "@/mixins/analytics-mixin";
import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments";
import { bailout } from "@/router/methods/error";
import { handleSoftError, handleHardError } from "@/router/methods/error";
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
import { checkLogParam } from "@/helpers/debug-log-helper";
import { debugLog } from "@/helpers/debug-log-helper";
@ -41,7 +41,7 @@ export async function beforeEach(to, from) {
nextPage: to?.name,
};
bailout(errorPayload, true);
await handleSoftError(errorPayload, true);
return false;
}
@ -88,7 +88,7 @@ export async function beforeEach(to, from) {
nextPage: to?.name,
};
bailout(errorPayload);
await handleSoftError(errorPayload);
return;
}
@ -107,7 +107,8 @@ export async function beforeEach(to, from) {
console.log(error);
bailout(errorPayload);
// Eject user from Vue app in this scenario and clear localstorage.
await handleHardError(errorPayload);
return;
}
}

View file

@ -4,8 +4,9 @@ import router from "@/router";
import store from "@/store";
import { storeActions } from "@/constants/store-actions";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import experimentMixin from "../../mixins/experiment-mixin";
export async function bailout(errorPayload, forceRestart = false) {
export async function handleSoftError(errorPayload, forceRestart = false) {
if (forceRestart) {
await store.dispatch(storeActions.RESET_STATE);
deleteFunnelCookie();
@ -17,3 +18,23 @@ export async function bailout(errorPayload, forceRestart = false) {
name: routeData.ERROR.name,
});
}
export async function handleHardError(errorPayload) {
try {
const isInStaticErrorExperiment = experimentMixin.methods.hasSettingEqualTo(
"UseStaticErrorPage",
"true"
);
if (isInStaticErrorExperiment) {
analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload);
window.top.location = "/fmg/static/error";
return;
} else {
await handleSoftError(errorPayload, true);
return;
}
} catch (exception) {
await handleSoftError(errorPayload, true);
}
}

View file

@ -11,7 +11,7 @@ export function createRoute(routeDatum, beforeEnter = async (to, from) => undefi
return await beforeEnter(to, from);
} catch {
return {
name: routeData.BAILOUT.name,
name: routeData.ERROR.name,
replace: true,
};
}
@ -29,7 +29,7 @@ export function createVirtualRoute(routeDatum, beforeEnter = async (to, from) =>
} catch (error) {
console.log(error);
return {
name: routeData.BAILOUT.name,
name: routeData.ERROR.name,
replace: true,
};
}

View file

@ -5,7 +5,7 @@ import { savePageData } from "@/router/methods/helpers/save-page-data";
import router from "@/router";
import store from "@/store";
import { bailout } from "@/router/methods/error";
import { handleSoftError } from "@/router/methods/error";
async function navigate(scenario, currentPageName, withSaving = false, forceTopLevelNav = false) {
// Check to see if calling page is same as current page.
@ -30,7 +30,7 @@ async function navigate(scenario, currentPageName, withSaving = false, forceTopL
nextPage: nextPage?.name,
};
bailout(errorPayload);
await handleSoftError(errorPayload);
return;
}

View file

@ -5,15 +5,34 @@ import baseMixin from "@/mixins/base-mixin";
import router from "@/router";
import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import store from "@/store";
import { handleHardError } from "@/router/methods/error";
export async function errorBeforeEnter(to, from) {
// If we've already encountered one error, clear session to avoid more.
// Otherwise mark that we encoutnered an error here.
if (store.getters.applicationUser.hasAlreadyTriggeredError) {
return {
name: routeData.RESTART.name,
replace: true,
// Check if `hasAlreadyTriggeredErrror` is available.
// If we cannot check it (store, getters, or applicationUser is null-ish),
// act as if the flag is set, as we are in unstable state.
if (!store?.getters?.applicationUser) {
const errorPayload = {
cause: "Cannot access store during error process.",
currentPage: from?.name,
nextPage: to?.name,
};
await handleHardError(errorPayload);
return;
}
// If we've already encountered one error, clear session to avoid more.
// Otherwise mark that we encountered an error here.
if (store.getters.applicationUser.hasAlreadyTriggeredError) {
const errorPayload = {
cause: "Successive errors triggered.",
currentPage: from?.name,
nextPage: to?.name,
};
await handleHardError(errorPayload);
return;
} else {
await store.dispatch(storeActions.UPDATE_HAS_TRIGGERED_ERROR, true);
}

View file

@ -59,6 +59,7 @@ import {
} from "@/helpers/recal-helper";
import { externalParameterStatus } from "@/constants/external-parameters";
import { experimentSettings } from "@/constants/experiments";
import { addPricesToLineItems } from "@/helpers/pricing-helper";
// Export State
const getDefaultState = () => {
@ -1594,6 +1595,115 @@ export const actions = {
});
},
logFmgSessionData(
context,
{
currentPage,
sid,
deviceId,
fmgSessionId,
skey,
userId,
carId,
vehicleYear,
vehicleMake,
vehicleModel,
vehicleStyle,
hasVin,
cashOrInsuranceAccountType,
damageType,
productType,
eon,
referralNumber,
referralSequenceNumber,
referralDate,
workOrderNumber,
workOrderId,
isPia,
piaType,
parentAccountNumber,
settledTenderAmount,
recalRequired,
recalType,
serviceZipCode,
providerCtu,
appointmentDate,
serviceType,
promoCodes,
hasTechnicianNotes,
paymentMethod,
isTextingOptedIn,
isEarlyBird,
insuranceCo,
deductible,
isVerified,
coverageStatus,
coverageSubStatus,
isNoComp,
isItac,
subTotalPrice,
totalPrice,
userAgent,
cashPriceSubTotal,
}
) {
var payload = {
currentPage: currentPage,
sid: sid,
deviceId: deviceId,
fmgSessionId: fmgSessionId,
skey: skey,
userId: userId,
carId: carId,
vehicleYear: vehicleYear,
vehicleMake: vehicleMake,
vehicleModel: vehicleModel,
vehicleStyle: vehicleStyle,
hasVin: hasVin,
cashOrInsuranceAccountType: cashOrInsuranceAccountType,
isVerified: isVerified,
coverageStatus: coverageStatus,
coverageSubStatus: coverageSubStatus,
damageType: damageType,
productType: productType,
eon: eon,
referralNumber: referralNumber,
referralSequenceNumber: referralSequenceNumber,
referralDate: referralDate,
workOrderNumber: workOrderNumber,
workOrderId: workOrderId,
isPia: isPia,
piaType: piaType,
parentAccountNumber: parentAccountNumber,
settledTenderAmount: settledTenderAmount,
recalRequired: recalRequired,
recalType: recalType,
serviceZipCode: serviceZipCode,
providerCtu: providerCtu,
appointmentDate: appointmentDate,
serviceType: serviceType,
promoCodes: promoCodes,
hasTechnicianNotes: hasTechnicianNotes,
paymentMethod: paymentMethod,
isTextingOptedIn: isTextingOptedIn,
isEarlyBird: isEarlyBird,
insuranceCo: insuranceCo,
deductible: deductible,
isNoComp: isNoComp,
isItac: isItac,
subTotalPrice: subTotalPrice,
totalPrice: totalPrice,
userAgent: userAgent,
cashPriceSubTotal: cashPriceSubTotal,
};
return globalMethods.callHttpClient({
method: endpoints.LogFmgSessionData.method,
endpoint: endpoints.LogFmgSessionData.url,
payload: payload,
});
},
// Misc Actions
setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) {
context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber);
@ -2960,9 +3070,9 @@ export const actions = {
) {
const arrayOfLineItems = [
...(pricedLineItems.glassParts ?? []),
...pricedLineItems.promos,
...pricedLineItems.supportingItems,
...pricedLineItems.vaps,
...(pricedLineItems.promos ?? []),
...(pricedLineItems.supportingItems ?? []),
...(pricedLineItems.vaps ?? []),
];
const flattenedLineItemsWithChildParts =
getFlattenedArrayOfLineItemsWithChildParts(arrayOfLineItems);
@ -3701,26 +3811,6 @@ function convertGlassPieceNamingFromApi(glassArray) {
return glassArray;
}
function addPricesToLineItems(lineItems, pricingLineItems) {
lineItems.forEach((lineItem) => {
const lineItemIndex = pricingLineItems.findIndex(
(pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber
);
if (lineItem.childParts) {
addPricesToLineItems(lineItem.childParts, pricingLineItems);
}
const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0];
lineItem.laborAmount = pricedLineItem.laborAmount;
lineItem.sellingPrice = pricedLineItem.sellingPrice;
lineItem.kitPrice = pricedLineItem.kitPrice;
lineItem.salesTax = pricedLineItem.salesTax;
});
return lineItems;
}
function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) {
pricedLineItems.forEach((pricedLineItem) => {
const lineItemIndex = taxingLineItems.findIndex(

View file

@ -131,6 +131,7 @@ $font-family-code: $font-family-monospace;
$font-size-base: 1rem; // Assumes the browser default, typically `16px`
$font-size-12: $font-size-base * 0.75; // 12px
$font-size-14: $font-size-base * 0.875; // 14px
$font-size-20: $font-size-base * 1.25; // 20px
//Custom Font size (extra small)