Merge branch 'release/2025.09.25' into feature/CASH-DeviceID-Fix

This commit is contained in:
CarlNation 2025-09-16 20:57:15 -04:00 committed by GitHub
commit e941181410
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 1794 additions and 648 deletions

View file

@ -33,6 +33,7 @@ stages:
# TODO: Change to ADO Playwright template
- template: temp/playwright-test.yml
parameters:
applicationType: 'vue'
totalShards: ${{ variables.totalShards }}
targetUrl: $(BASE_URL)
dockerFileName: 'Dockerfile.playwright'

View file

@ -51,19 +51,11 @@ stages:
displayName: Run Prettier check
- stage: TestPr
displayName: Run Unit Tests For PullRequest
jobs:
- template: templates/digital/vue-jest-run-unit-tests.yml@AzureDevOps
parameters:
nodeContainer: node
npmLocation: $(Build.SourcesDirectory)
testResultsFile: junit.xml
summaryFileLocation: $(Build.SourcesDirectory)/coverage/cobertura-coverage.xml
- stage: TestPrPlaywright
displayName: Run Playwright Tests For PullRequest
displayName: Run Tests For PullRequest
jobs:
- template: temp/playwright-test.yml
parameters:
applicationType: 'vue'
totalShards: 2
targetUrl: $(BASE_URL)
dockerFileName: 'Dockerfile.playwright'
@ -75,6 +67,12 @@ stages:
secrets:
CCIS_API_AUTH: $(CCIS_API_AUTH)
JIRA_API_KEY: $(JIRA_API_KEY)
- template: templates/digital/vue-jest-run-unit-tests.yml@AzureDevOps
parameters:
nodeContainer: node
npmLocation: $(Build.SourcesDirectory)
testResultsFile: junit.xml
summaryFileLocation: $(Build.SourcesDirectory)/coverage/cobertura-coverage.xml
- ${{ else }}:
# Dev Build/Deploy
- stage: Dev

View file

@ -2,7 +2,7 @@
# Environment configuration
# Environment type
NODE_ENV="qa"
PLAYWRIGHT_ENV="qa"
# Skip content site
SKIP_CONTENT_SITE=false

View file

@ -2,7 +2,7 @@
# Environment configuration
# Environment type
NODE_ENV="qa"
PLAYWRIGHT_ENV="qa"
# Skip content site
SKIP_CONTENT_SITE=false

View file

@ -2,7 +2,7 @@
# Environment configuration
# Environment type
NODE_ENV="qa"
PLAYWRIGHT_ENV="qa"
# Skip content site
SKIP_CONTENT_SITE=false

View file

@ -260,7 +260,6 @@ ADMIN_SERVICE_API_URL= <br>
SAUCE_USERNAME= <br>
SAUCE_ACCESS_KEY= <br>
## CI/CD Integration
The project uses Azure Pipelines for continuous integration with configurations defined in azure-pipelines.yml.

View file

@ -5,23 +5,23 @@ export enum PaymentMethod {
}
export enum ProgressBarPercentages {
VehicleSelectionPage = '4%',
VehicleDamagePage = '16%',
EstimatePage = '28%',
ServiceZipPage = '32%',
VehicleLookupAddressPage = '32%',
VehicleLookupLicensePage = '32%',
VinLookupPage = '32%',
PartQuestionsPage = '40%',
MoldingQuestionsPage = '40%',
CapabilityQuestionsPage = '40%',
VehiclePartsPage = '40%',
ServicePackagePage = '48%',
VehicleSelectionPage = '8%',
VehicleDamagePage = '11%',
EstimatePage = '18%',
ServiceZipPage = '22%',
VehicleLookupAddressPage = '22%',
VehicleLookupLicensePage = '22%',
VinLookupPage = '22%',
PartQuestionsPage = '30%',
MoldingQuestionsPage = '30%',
CapabilityQuestionsPage = '30%',
VehiclePartsPage = '30%',
ServicePackagePage = '62%',
InsuranceCompanyPage = '52%',
ServiceLocationPage = '60%',
SchedulePage = '64%',
ServiceLocationPage = '76%',
SchedulePage = '76%',
MobileDetailsPage = '76%',
ContactDetailsPage = '84%',
PaymentMethodPage = '92%',
PaymentMethodPage = '97%',
OrderConfirmationPage = '100%'
}

View file

@ -22,7 +22,7 @@
"eslint": "^9.28.0",
"luxon": "^3.6.1",
"ortoni-report": "^3.0.2",
"playwright-jira-reporter": "^1.0.4",
"playwright-jira-reporter": "^1.0.9",
"safelite-playwright-core": "^1.0.26",
"typescript": "^5.8.3"
}
@ -3006,9 +3006,9 @@
}
},
"node_modules/playwright-jira-reporter": {
"version": "1.0.4",
"resolved": "https://pkgs.dev.azure.com/Safelite/Digital/_packaging/DigitalQA/npm/registry/playwright-jira-reporter/-/playwright-jira-reporter-1.0.4.tgz",
"integrity": "sha1-uwnLFtj3Pnq5XXMFotlfHItucAw=",
"version": "1.0.9",
"resolved": "https://pkgs.dev.azure.com/Safelite/Digital/_packaging/DigitalQA/npm/registry/playwright-jira-reporter/-/playwright-jira-reporter-1.0.9.tgz",
"integrity": "sha1-o6+EHQlQ/jY4BtWKA6gSJ5mwK6A=",
"dev": true,
"license": "ISC",
"dependencies": {

View file

@ -30,7 +30,7 @@
"eslint": "^9.28.0",
"luxon": "^3.6.1",
"ortoni-report": "^3.0.2",
"playwright-jira-reporter": "^1.0.4",
"playwright-jira-reporter": "^1.0.9",
"safelite-playwright-core": "^1.0.26",
"typescript": "^5.8.3"
},

View file

@ -10,6 +10,8 @@ export class AfterpayPage extends BasePage {
readonly passwordTextBox: Locator;
// Card details
readonly paymentOptionsButton: Locator;
readonly continueButtonAfterpay: Locator;
readonly cardholderNameTextBox: Locator;
readonly cardNumberTextBox: Locator;
readonly expirationDateTextBox: Locator;
@ -23,6 +25,8 @@ export class AfterpayPage extends BasePage {
this.passwordTextBox = page.getByTestId('login-password-input');
this.submitButton = page.getByRole('button', { name: 'Continue' });
this.paymentOptionsButton = page.locator('div:has(>input[id*=\'payment-types\']) label').nth(1);
this.continueButtonAfterpay = page.getByRole('button').filter({hasText: 'Continue'});
this.cardholderNameTextBox = page.getByTestId('payment-method-cardHolderName-input');
this.cardNumberTextBox = page.getByTestId('payment-method-cardNumber-input');
this.expirationDateTextBox = page.getByTestId('payment-method-cardExpiry-input');
@ -46,7 +50,12 @@ export class AfterpayPage extends BasePage {
async executeAfterpayPayment(paymentDetails: IPaymentDetails) {
await this.login(paymentDetails.password!);
await this.page.locator('div[data-testid=\'loading-icon-svg\']').filter({ visible: true}).first().waitFor({ state: 'hidden' });
if (await this.paymentOptionsButton.isVisible())
{
await this.paymentOptionsButton.click();
await this.continueButtonAfterpay.click();
}
await this.confirmButton.click();
}

View file

@ -25,7 +25,7 @@ export class BasePage {
this.pageSpinner = page.getByRole('status');
this.buttonLoadSpin = page.getByRole('alert');
this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' });
this.progressBar = this.page.locator('.progress-bar-inner');
this.progressBar = this.page.locator('.progress-bar-outer.progress-bar-inner');
}
async nextPage() {
@ -104,7 +104,7 @@ export class BasePage {
async mockScheduleResponseForEarlyBird(customerDetails: ICustomerDetails) {
const apiUrl = `https://digitalapi.${process.env['NODE_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/schedule/api/v1/schedule/mobile-time-slots`;
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) {

View file

@ -56,7 +56,7 @@ export class PaymentMethodPage extends BasePage {
this.payNowButton = this.page.locator('[buttonlabel="Pay now"]');
this.payInFourButton = this.page.locator('[buttonlabel="Pay in 4 installments"]');
this.submitButton = this.page.locator('[data-test-id="nav-bar-main-button"]');
this.recalibrationCheckbox = this.page.getByLabel('I understand after windshield');
this.recalibrationCheckbox = this.page.locator('label:has(>input[name=\'recalAckOptIn\'])');
// this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1);
this.paymentPage = new PaymentPage(page);
this.paypalPage = new PaypalPage(page);
@ -101,7 +101,7 @@ export class PaymentMethodPage extends BasePage {
let expectedAppointmentDetails = new Map<string, string[]>();
expectedAppointmentDetails = await this.getExpectedVehicleDetails(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.getExpectedVehicleDamage(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.expectedServicePackageDetails(testData, expectedAppointmentDetails);
// expectedAppointmentDetails = await this.expectedServicePackageDetails(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.getExpectedServiceLocation(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.getExpectedAppointmentDate(testData, expectedAppointmentDetails);
expectedAppointmentDetails = await this.getExpectedCustomerDetails(testData, expectedAppointmentDetails);
@ -403,7 +403,7 @@ l
const { appointmentDetails } = testData;
let serviceLocationTitle = appointmentDetails?.serviceLocation == ServiceLocation.Mobile
? "We're coming to you"
: "You're going to a Safelite shop";
: "You're coming to us";
let serviceLocation: string[] = [];
serviceLocation.push(
@ -443,7 +443,7 @@ l
customerDetails?.apptDuration ? "Estimated appointment length: " + customerDetails.apptDuration : ""
);
expectedServicePackageDetails["Appointment date + time"] = appointmentDateText;
expectedServicePackageDetails["Appointment Date + Time"] = appointmentDateText;
return expectedServicePackageDetails;
}
@ -452,7 +452,7 @@ l
let customerDetailsText: string[] = [];
customerDetailsText.push(
customerDetails?.firstName.toUpperCase() + " " + customerDetails?.lastName.toUpperCase(),
// customerDetails?.firstName.toUpperCase() + " " + customerDetails?.lastName.toUpperCase(),
customerDetails?.email ? customerDetails?.email.toUpperCase() : "",
customerDetails?.phoneNumber ? customerDetails?.phoneNumber : "",
"Opted out of text message updates"

View file

@ -24,8 +24,8 @@ export class PaypalPage extends BasePage {
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.locator('.py-4').first();
this.payButton = page.getByRole('button', { name: 'Pay $' });
this.payWithRadioButton = page.getByRole('button').filter({ hasText: 'Pay with' });
this.payButton = page.locator('#one-time-cta');
}
async completePaypalPurchase(paymentDetails: IPaymentDetails){
@ -45,7 +45,8 @@ export class PaypalPage extends BasePage {
await this.passwordTextBox.fill(paymentDetails.password!);
await this.paypalLoginButton.click();
await this.payWithRadioButton.click();
await this.payButton.click();
await this.page.waitForTimeout(2000); // wait for 2 seconds to ensure the Pay button is clickable
await this.payButton.dblclick();
}
}
}

View file

@ -17,8 +17,11 @@ export class SchedulePage extends BasePage {
readonly militaryWarningMessage: Locator;
readonly changeZipButton: Locator;
readonly updateZipTextBox: Locator;
readonly safeliteShopsList: Locator;
readonly searchButton: Locator;
readonly storeAddressText: Locator;
readonly saveLocationButton: Locator;
readonly changeShopLocationLink: Locator;
readonly moreLocationsButton: Locator;
readonly selectAShopOptions: Locator;
readonly yourSafeliteShop: Locator;
@ -44,12 +47,15 @@ export class SchedulePage extends BasePage {
this.militaryWarningMessage = this.page.locator('[class*="widget-name-AlertMilitaryBaseZipWidget"]');
this.changeZipButton = this.page.locator('a:has(span.sr-only:has-text("edit zip code"))');
this.updateZipTextBox = this.page.locator('#serviceZipCode').filter({ visible: true });
this.safeliteShopsList = this.page.locator('fieldset:has(#chooseShop) label');
this.searchButton = this.page.locator('.search-icon-button');
this.storeAddressText = this.page.locator('#shop-location label:not(:first-child)');
this.saveLocationButton = this.page.getByText('Save location', { exact: true });
this.changeShopLocationLink = this.page.locator(".shop-question a").filter({ hasText: "Change shop location " });
this.moreLocationsButton = this.page.locator(".shop-question a").filter({ hasText: "More locations " });
this.selectAShopOptions = this.page.locator('[class="shop-question"]');
this.yourSafeliteShop = this.page.locator("fieldset:has(#chooseShop) label");
this.allDayDropOffButton = this.page.locator("label[buttonlabel='Drop off all day']");
this.allDayDropOffButton = this.page.locator("label[buttonlabel*='Drop']");
this.pickATimeButton = this.page.locator("label[buttonlabel='Pick a time']");
this.firstAvailableDate = this.page.locator('.selectable-day').filter({ visible: true}).locator('nth=0');
this.firstAvailableTime = this.page.locator('label').filter({ hasText: /AM|PM/ }).locator('div').locator('nth=0');
@ -58,7 +64,7 @@ export class SchedulePage extends BasePage {
this.dateText = this.page.locator('label.modal-title');
this.viewMoreDatesLink = this.page.getByText(/View more dates/).first();
this.appointmentDuration = this.page.locator('.duration-text-block');
this.timeSlots = this.page.locator('fieldset[aria-labelledby=\'chooseTimeSlot\'] label');
this.timeSlots = this.page.locator('fieldset:has(>legend#chooseTimeSlot) label').filter({ visible: true});
}
async selectLocation(testData: Partial<ITestData>) {
@ -76,25 +82,36 @@ export class SchedulePage extends BasePage {
}
async scheduleInShop(appointmentDetails?: IAppointmentDetails) {
await this.inShopButton.click();
await this.inShopButton.isVisible() ? await this.inShopButton.click() : null;
if (appointmentDetails && appointmentDetails.shopAddress) {
const zipCodeMatch = appointmentDetails.shopAddress.match(/\b\d{5}$/);
if (zipCodeMatch) {
const zipCode = zipCodeMatch[0];
// Enter the ZIP code into the updateZipTextBox
await this.changeShopLocationLink.click();
await this.moreLocationsButton.click();
await this.page.waitForTimeout(500);
await this.updateZipTextBox.fill(zipCode);
await this.searchButton.click().then( async() => await this.page.locator('loader-wrapper').waitFor({state: 'hidden'}));
await this.safeliteShopsList.first().click();
// Click the save location button
await this.saveLocationButton.click();
}
await this.inShopButton.click();
// await this.inShopButton.click();
// await this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).scrollIntoViewIfNeeded().then(() => this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).click());
}
if (appointmentDetails && (appointmentDetails.shopAddress === "" || appointmentDetails.shopAddress === undefined )) {
appointmentDetails.shopAddress = await this.yourSafeliteShop.getAttribute("buttonbodycopy") || "";
appointmentDetails.shopAddress = '';
await this.storeAddressText.first().waitFor({ state: 'visible' });
const elements = await this.storeAddressText.all();
for (let element of elements) {
let addressLine = await element.textContent() || "";
appointmentDetails.shopAddress += addressLine + ", "
};
appointmentDetails.shopAddress = appointmentDetails.shopAddress?.slice(0, -2);
}
}
@ -146,6 +163,7 @@ export class SchedulePage extends BasePage {
const inshopAvailableDates = this.page.locator('.selectable-day').filter({ visible: true}).all();
for (const inshopAvailableDate of await inshopAvailableDates) {
await inshopAvailableDate.click();
await this.page.waitForTimeout(500);
if (await this.allDayDropOffButton.isVisible()) {
customerDetails!.apptDate = `${await inshopAvailableDate.getAttribute("id")}`;
break;
@ -186,13 +204,13 @@ export class SchedulePage extends BasePage {
}
// appointmentmentDetails.serviceLocation === ServiceLocation.DropOff ? await this.dropOffButton.click() : await this.firstAvailableTime.click();
customerDetails!.apptDuration = (await this.appointmentDuration.innerText()).replace("Duration: ", "");
customerDetails!.apptDuration = (await this.appointmentDuration.innerText()).replace("Your service will take approximately ", "").replace("Duration: ", "");
}
async getFormattedTimeSlot(timeSlot: Locator) {
async getFormattedTimeSlot(timeSlot: Locator) {
const selectedTimeSlot = await timeSlot.innerText();
let formattedTimeSlot: string = "";
if (selectedTimeSlot.toLowerCase().includes("drop off"))
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";
}

View file

@ -37,7 +37,7 @@ export class ServicePackagesPage extends BasePage {
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: 'Get my quote' });
this.getMyQuoteButton = this.page.getByRole('button', { name: 'Send' });
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' });

View file

@ -39,9 +39,9 @@ export class VehicleDamagePage extends BasePage {
super(page);
this.page = page;
this.windshieldChkBox = this.page.locator('[buttonlabel="Windshield"]');
this.crackButton = this.page.locator('[buttonlabel="Crack"]');
this.chipButton = this.page.locator('[buttonlabel="Chip(s)"]');
this.sideDoorButton = this.page.locator('[buttonlabel="Side door"]');
this.crackButton = this.page.locator('[buttonlabelsubcopy="Replace my windshield"]');
this.chipButton = this.page.locator('[buttonlabelsubcopy="Repair my windshield"]');
this.sideDoorButton = this.page.locator('[buttonlabel="Side window"]');
this.driverSideButton = this.page.locator('[buttonlabel="Driver side"]');
this.passengerSideButton = this.page.locator('[buttonlabel="Passenger side"]');
this.driverQuarterPanelChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Quarter panel"]');

View file

@ -9,7 +9,7 @@ if (!process.env.CI) {
// Environment variables are present in CI environment, no need to read from file
const basePath = __dirname; // This gets the directory where the config file is located
if (process.env.NODE_ENV == 'undefined' || process.env.NODE_ENV == null) {
if (process.env.PLAYWRIGHT_ENV == 'undefined' || process.env.PLAYWRIGHT_ENV == null) {
dotenv.config({
path: path.join(basePath, '.env.dev'),
example: path.join(basePath, '.env.example')
@ -17,7 +17,7 @@ if (!process.env.CI) {
}
else {
dotenv.config({
path: path.join(basePath, `.env.${process.env.NODE_ENV}`),
path: path.join(basePath, `.env.${process.env.PLAYWRIGHT_ENV}`),
example: path.join(basePath, '.env.example')
});
}
@ -42,7 +42,7 @@ const ortoniReportConfig: OrtoniReportConfig = {
filename: `ortoni_report_${formatDateForFilename(new Date())}.html`,
showProject: false,
projectName: "FMG-Nextgen-Playwright-Report",
testType: `E2E- Environment: ${process.env.NODE_ENV} `,
testType: `E2E- Environment: ${process.env.PLAYWRIGHT_ENV} `,
preferredTheme: "light",
base64Image: true,
}

View file

@ -291,7 +291,6 @@ async function runWorkflow(page: Page, testCase: TestCase) {
// let serviceLocationPage = testCase.pages.serviceLocationPage;
// await serviceLocationPage.handleServiceLocationPage(testCase.testData);
// Schedule appointment
let schedulePage = testCase.pages.schedulePage;
await schedulePage.handleSchedulePage(testCase.testData);

View file

@ -55,7 +55,7 @@ const cashRepairMobileCCTests: ITestCase[] = [];
const tc = {
name: `CashRepairMobileCreditCard`,
tags: ['@E2E','@CashRepairMobileCreditCard', '@test_report', '@CASH'],
tags: ['@E2E','@CashRepairMobileCreditCard', '@test_report', '@CASH', '@CASH-848'],
testData: cashRepairMobileCCData
};
cashRepairMobileCCTests.push(tc);

View file

@ -42,14 +42,6 @@ const cashReplaceGlassAddressLookupInshopAfterPayData: Partial<ITestData> = {
vehicleLookupType: VehicleLookupType.Address
},
// No need to override vehicleDamage as it already defaults to WindshieldCrack
// Override appointment details
appointmentDetails: {
...getDefaultTestData().appointmentDetails!,
shopAddress: "6826 Sawmill Rd, Columbus, OH 43235"
},
// Override payment details
paymentDetails: ClientData.getDefaultAfterpayDetails()
}
@ -58,7 +50,7 @@ const cashReplaceGlassAddressLookupInshopAfterPayTests: ITestCase[] = [];
const tc = {
name: `CashReplaceGlassAddressLookupInshopAfterPay`,
tags: ['@E2E','@CashReplaceGlassAddressLookupInshopAfterPay', '@test_report', '@CASH'],
tags: ['@E2E','@CashReplaceGlassAddressLookupInshopAfterPay', '@test_report', '@CASH', '@CASH-848'],
testData: cashReplaceGlassAddressLookupInshopAfterPayData
};
cashReplaceGlassAddressLookupInshopAfterPayTests.push(tc);

View file

@ -35,7 +35,7 @@ const cashReplaceMultiSlidingGlassDropoffData: Partial<ITestData> = {
appointmentDetails: {
serviceLocation: ServiceLocation.DropOff,
appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate,
shopAddress: "6826 Sawmill Rd, Columbus, OH 43235"
shopAddress: "6826 sawmill rd, Columbus, OH 43235"
},
// Override vehicle details

View file

@ -66,7 +66,7 @@ const CashReplaceSplitWindshieldTests: ITestCase[] = [];
const tc = {
name: `CashReplaceSplitWindshield`,
tags: ['@E2E','@CashReplaceSplitWindshield', '@test_report', '@CASH'],
tags: ['@E2E','@CashReplaceSplitWindshield', '@test_report', '@CASH', '@CASH-848'],
testData: CashReplaceSplitWindshieldData
};
CashReplaceSplitWindshieldTests.push(tc);

View file

@ -20,7 +20,7 @@ const cashReplaceWiperDropoffData: Partial<ITestData> = {
servicePackage: ServicePackage.Standard,
// Special flags
isSkipEstimatePage: true,
isSkipEstimatePage: false,
isRecalVehicle: true,
// Override customer postal code
@ -28,7 +28,7 @@ const cashReplaceWiperDropoffData: Partial<ITestData> = {
...getDefaultTestData().customerDetails!,
address: {
...getDefaultTestData().customerDetails!.address,
postalCode: '43085'
postalCode: '43235'
}
},

View file

@ -90,7 +90,7 @@ const insuranceAcuityPaypalTests: ITestCase[] = [];
const tc = {
name: `InsuranceAcuityPaypal`,
tags: ['@E2E','@InsuranceAcuityPaypal', '@test_report', '@Insurance', '@CASH-1187'],
tags: ['@E2E','@InsuranceAcuityPaypal', '@test_report', '@Insurance', '@CASH-1187', '@CASH-848'],
testData: insuranceAcuityPaypalData
};
insuranceAcuityPaypalTests.push(tc);

View file

@ -53,6 +53,7 @@ body select {
background-color: #fff;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 400;
box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
}
body input:focus, body input:focus-visible,
body select:focus,

View file

@ -63,6 +63,7 @@ body {
background-color: #fff;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 400;
box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
&:focus,
&:focus-visible {
box-shadow: 0 0 0 2.5px #1574a1;

View file

@ -16,6 +16,8 @@ const damageLocationsSelected = {
PASSENGERSIDE: "PassengerSide",
STATIONARY: "Stationary",
SLIDER: "Slider",
DOOR: "Door",
GLASS: "Glass",
};
export { damageLocationsSelected };

View file

@ -2,16 +2,6 @@
<div
class="date-picker text-center"
:class="`${calendarViewDirection} calendar-2025 ${showPricingByDayClass}`">
<div class="date-picker-header">
<funnelSubHeader cmsWidgetName="DatePickerSubHeaderWidget" class="mt-5" />
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
typeStyle="small"
marginTopSizeOverride="0"
class="duration-text-block" />
</div>
<fieldset id="date-picker-fieldset" ref="datePickerFieldset">
<legend class="sr-only">Select a day and time</legend>
<div
@ -139,36 +129,11 @@
@click="showAnotherMonth">
View more dates
</button>
<timeSlotQuestion
ref="timeSlotModalQuestion"
v-model="selectedTimeSlotInfo"
cmsWidgetName="TimeSlotModalQuestion"
waitListLabelWidget="WaitListLabelWidget"
waitListQuestionWidget="WaitListQuestionWidget"
mobilePremiumCmsWidgetName="MobilePremiumTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
dropOffOrPickATimeQuestionCmsWidgetName="DropOffOrPickATimeQuestionWidget"
:selectedDate="selectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="premiumAppointmentFee"
:displayWaitList="displayWaitList"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:estimatedServiceMinutesMinimum="estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="estimatedServiceMinutesMaximum"
@waitListRequested="handleWaitListRequested"
@time-slot-modal-closed="timeSlotModalClosed" />
</fieldset>
</div>
</template>
<script>
import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue";
import textBlock from "@/digital-components/text-block/text-block";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
// Supporting files
import loader from "@/ux-components/loader/loader";
import store from "@/store";
@ -178,12 +143,6 @@ import {
MONTHS_OF_YEAR,
DAYS_OF_WEEK,
} from "@/digital-components/date-picker/mixins/constants";
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_FEE_PART_TYPE,
cmsWidgetFieldMappings,
} from "@/constants/schedule-constants";
import {
selectableDaysOptions,
requiredParameter,
@ -195,7 +154,6 @@ import {
import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
export default {
name: "datePicker",
@ -205,10 +163,8 @@ export default {
months: null,
disableViewMoreDatesButton: false,
hideSomeDaysForInitialView: null,
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesInshop: [], // NOTE: this and the mobile version below use monthNum (1-based), NOT monthIndex (0-based)
selectableDatesMobile: [],
durationTextBlockCopyForInshopOrDropoff: null,
};
},
props: {
@ -242,12 +198,6 @@ export default {
pricingByDayBasePrice: Number,
pricingByDayUpcharge: Number,
isPricingByDayExperiment: Boolean,
timeSlotsForSelectedDate: Object,
appointmentType: String,
premiumAppointmentFee: Object,
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
displayWaitList: Boolean,
isMobileSelected: Boolean,
},
setup(props) {
@ -309,80 +259,10 @@ export default {
this.dispatchStoreAction(this.storeActions.SAVE_WAITLIST_REQUESTED, false, false);
},
},
dropOffDurationText() {
return this.getCmsContent("DropOffTimeSlotModal", cmsWidgetFieldMappings.DURATION);
},
sameDayDropoffDurationText() {
return this.getCmsContent(
"SameDayDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
overnightDropoffDurationText() {
return this.getCmsContent(
"OvernightDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
"TimeSlotModalQuestion",
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
if (this.estimatedServiceMinutesMinimum && this.estimatedServiceMinutesMaximum) {
return `${inshopDurationTextWithoutTime} <b>${inshopDurationTime}</b>`;
}
return null;
},
mobileDurationText() {
const mobileDurationTextWithoutTime = this.getCmsContent(
"TimeSlotModalQuestion",
cmsWidgetFieldMappings.DURATION
);
const mobileDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
if (this.estimatedServiceMinutesMinimum && this.estimatedServiceMinutesMaximum) {
return `${mobileDurationTextWithoutTime} <b>${mobileDurationTime}</b>`;
}
return null;
},
durationTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return this.mobileDurationText;
} else if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return this.inshopDurationText;
} else if (
this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF ||
this.appointmentType === AppointmentTypeStrings.DROP_OFF
) {
return this.durationTextBlockCopyForInshopOrDropoff ?? this.inshopDurationText;
}
return null;
},
isSameDay() {
if (!this.timeSlotsForSelectedDate) {
return false;
}
const todaysDate = new Date().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
},
methods: {
async initializeComponent(initialData) {
await this.setCalendarData(initialData);
this.$refs.timeSlotModalQuestion.initializeComponent();
},
fireDateSelectedEvent(event, date) {
// Ignore if arrow key selected radioButton
@ -949,42 +829,9 @@ export default {
};
window.requestAnimationFrame(step);
},
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;
},
handleWaitListRequested(value) {
this.$emit("waitListRequested", value);
},
getDurationTextBlockCopyForInshopOrDropoff(selectedRouteCode) {
if (selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropoffDurationText;
} else if (selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropoffDurationText;
} else {
return this.dropOffDurationText;
}
}
return this.inshopDurationText;
},
},
watch: {
modelValue(newValue) {
@ -998,19 +845,10 @@ export default {
this.scrollToElement("date-of-month-error");
}
},
selectedTimeSlotInfo(newValue) {
const routeCode = newValue?.timeSlot?.routeCode;
this.durationTextBlockCopyForInshopOrDropoff =
this.getDurationTextBlockCopyForInshopOrDropoff(routeCode);
this.$emit("TimeSlotSelected", newValue); // needed to update footer button text on Schedule page and to save to Store correctly
},
},
components: {
loader,
ErrorMessage,
timeSlotQuestion,
textBlock,
funnelSubHeader,
},
};
</script>
@ -1028,14 +866,6 @@ export default {
align-items: flex-start;
text-align: left;
.duration-text-block {
text-align: left;
}
.date-picker-header {
margin-bottom: 1.5rem;
}
fieldset {
flex-grow: 1;
position: relative;

View file

@ -2,7 +2,7 @@
<!-- Modal -->
<div
class="modal fade modal-component"
:class="modalId"
:class="[modalId, { 'recall-show': isRecal }]"
v-on="{ 'hidden.bs.modal': onModalClosed, 'shown.bs.modal': onModalOpened }"
:data-bs-backdrop="backdropSetting"
:id="modalId"
@ -11,7 +11,7 @@
@keypress.enter="onEnter"
aria-hidden="true">
<!--Close modal when clicking other than close button-->
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog" :class="{ 'modal-dialog-centered': !isRecal }">
<div class="modal-content">
<div class="modal-header mb-0 mt-6">
<label
@ -32,7 +32,7 @@
<div class="modal-footer">
<slot name="modal-footer-slot"></slot>
<modalButtonMain
isPrimary
:isPrimary="isFooterButtonPrimary"
class="w-100 modal-footer-button"
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
@ -65,12 +65,17 @@ export default {
suppressPageScroll: Boolean,
staticBackdrop: Boolean,
footerButtonDisabled: Boolean,
isFooterButtonPrimary: Boolean,
onModalOpenedCallback: {
type: Function,
},
onModalClosedCallback: {
type: Function,
},
isRecal: {
type: Boolean,
default: false,
},
},
setup() {
const uuid = uuidv4();
@ -195,6 +200,19 @@ export default {
background-color: $white;
}
&.modal-component {
&.recall-show {
.modal-dialog {
transform: translateX(-50%);
left: 50%;
right: auto;
width: auto;
max-width: 600px;
@include media-breakpoint-up(md) {
top: 10%;
}
}
}
.modal-dialog {
max-width: 767px;
margin: 0 auto;
@ -250,6 +268,9 @@ export default {
button {
margin: 0;
border-radius: 50rem;
&.btn-secondary {
font-weight: 600;
}
}
}
}

View file

@ -115,14 +115,16 @@ export default {
&.btn-secondary {
position: relative;
background: transparent;
border: 1px solid $red;
border: 1px solid $blue;
color: $blue;
font-weight: 500;
transition: all 150ms linear;
height: 3rem;
&:hover {
&:hover,
&:active {
color: $white;
@include blue-gradient;
background: $blue;
border: none;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
@ -130,16 +132,18 @@ export default {
outline: none;
box-shadow:
0 0 0 3px $white,
0 0 0 5.5px $red;
color: $white;
@include blue-gradient;
0 0 0 5.5px $blue;
color: $blue;
&:active {
color: $white;
}
}
&:disabled {
background: transparent;
color: $gray-550 !important;
font-weight: 400;
height: 48px;
border: 1px solid $red;
border: 1px solid $blue;
cursor: pointer;
pointer-events: all;
}

View file

@ -6,6 +6,7 @@
:max-length="12"
v-model="selectedValue"
:mask="mask"
placeholderText="###-###-####"
:isRequired="isRequired"
:validationRules="validationRulesForTextBoxQuestion"
:cmsWidgetName="cmsWidgetName" />

View file

@ -214,6 +214,47 @@ export default {
&.is-insurance {
padding-bottom: 3rem;
@include media-breakpoint-down(md) {
padding-bottom: 0.75rem;
.pricing-info {
top: 0.75rem;
right: 0.75rem;
bottom: auto;
left: auto;
width: auto;
margin: 0;
text-align: end;
max-width: 60%;
}
@media (max-width: 320px) {
min-height: 80px;
max-height: 120px;
.pricing-info {
top: 2.1rem;
right: auto;
left: 2.3rem;
bottom: auto;
position: absolute;
width: calc(100% - 2.5rem);
text-align: left;
max-width: none;
}
}
&.has-subheader {
@media (max-width: 320px) {
min-height: 100px;
max-height: 160px;
.pricing-info {
top: 3.9rem;
}
}
}
}
}
&.has-subheader {

View file

@ -3,13 +3,15 @@
<div class="px-0" v-if="isCartReadyToLoad">
<div
class="row vin-toggle flex align-items-center pt-4"
:class="[isExpanded ? 'expanded' : '']"
:class="[
isCollapsible ? (isExpanded ? 'expanded' : '') : 'expanded non-collapsible',
]"
@click="toggleIsExpanded()">
<a
aria-label="expand cart"
href="javascript:void(0)"
class="col d-flex justify-content-between py-0">
<span class="label">{{ OrderDetailsText }}</span>
<span class="label">{{ cartHeaderText }}</span>
<span class="label amount-due larger">
{{ getLineItemAmount(amountDue, showCoverageAsPending) }}
</span>
@ -208,6 +210,7 @@ export default {
isItac: Boolean,
isNoComp: Boolean,
isExpandedOnLoad: Boolean,
isCollapsible: { type: Boolean, default: true },
isMSRFeeApplicable: Boolean,
donationCartItem: Object,
},
@ -1094,9 +1097,19 @@ export default {
amountDueText() {
return this.getCmsContent("AmountDueTextWidget", "Text");
},
OrderDetailsText() {
orderDetailsText() {
return this.getCmsContent("OrderDetailsTextWidget", "Text");
},
estimateDetailsText() {
return this.getCmsContent("EstimateDetailsTextWidget", "Text");
},
cartHeaderText() {
if (!this.isInsurance && this.shouldHideRecalibration) {
return this.estimateDetailsText;
} else {
return this.orderDetailsText;
}
},
amountPaidText() {
return this.getCmsContent("AmountPaidTextWidget", "Text");
},
@ -1182,6 +1195,11 @@ export default {
font-weight: 500;
line-height: 1.625;
}
.non-collapsible {
.amount-due {
display: none;
}
}
.amount-due {
color: $green;
@ -1283,23 +1301,26 @@ export default {
margin-top: 0.5rem;
}
.vin-toggle {
&:after {
content: "";
transition: all 0.5s ease;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath fill='%230070D1' d='M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm6.113 11.28-5.52 5.52a.84.84 0 0 1-1.186 0l-5.52-5.52a.843.843 0 1 1 1.186-1.2L12 15.012l4.927-4.932a.843.843 0 1 1 1.186 1.2Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right center;
width: 24px;
height: 24px;
display: inline-flex;
position: relative;
right: 0.75rem;
margin: 0.5rem 0 0.5rem 1rem;
cursor: pointer;
}
&.expanded:after {
transform: rotate(180deg);
&:not(.non-collapsible) {
&:after {
content: "";
transition: all 0.5s ease;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath fill='%230070D1' d='M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm6.113 11.28-5.52 5.52a.84.84 0 0 1-1.186 0l-5.52-5.52a.843.843 0 1 1 1.186-1.2L12 15.012l4.927-4.932a.843.843 0 1 1 1.186 1.2Z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right center;
width: 24px;
height: 24px;
display: inline-flex;
position: relative;
right: 0.75rem;
margin: 0.5rem 0 0.5rem 1rem;
cursor: pointer;
}
&.expanded:after {
transform: rotate(180deg);
}
}
&.expanded + .cart-panel {
max-height: 650px;
transition: all 150ms ease-in;
@ -1307,6 +1328,13 @@ export default {
padding: 1rem 0 0.5rem;
visibility: visible;
}
&.non-collapsible {
& > a {
cursor: default;
}
}
a {
font-family: UrbanistSemibold;
text-decoration: none;

View file

@ -3,7 +3,8 @@
:ref="ModalName"
:modalId="ModalName"
:footerButtonText="ModalCloseButtonText"
@footer-button-event="footerButtonClick">
@footer-button-event="footerButtonClick"
:isRecal="isRecal">
<div :class="[isRecal ? 'recal-modal' : '']">
<img :src="ModalImage" class="mw-100 d-flex mx-auto mb-4" alt="" />
<h5 class="mb-4" v-html="ModalHeadline"></h5>

View file

@ -1,5 +1,5 @@
<template>
<div class="funnel-header" v-if="imageSrc">
<div class="funnel-header" :class="{ scrolled: isScrolled }" v-if="imageSrc">
<div class="d-flex w-100">
<progress-bar :page="$route.name" />
</div>
@ -61,6 +61,7 @@ export default {
sierraChatOpen: false,
salesforceChatOpen: false,
isSalesforceTransferInProgress: false,
isScrolled: false,
};
},
props: {
@ -108,6 +109,15 @@ export default {
}
this.globalAlertMessages.push(alertToPush);
},
removeGlobalAlert(alertId) {
const alertIndex = this.globalAlertMessages.findIndex((alert) => alert.id === alertId);
if (alertIndex !== -1) {
this.globalAlertMessages.splice(alertIndex, 1);
}
},
getGlobalAlerts() {
return this.globalAlertMessages;
},
webchatClicked(event) {
event.preventDefault();
this.launchWebchat();
@ -185,6 +195,12 @@ export default {
}
};
window.addEventListener("salesforce-chat-visibility", this._handleSalesforceChatOpened);
// Add scroll listener for header shadow effect
this._handleScroll = () => {
this.isScrolled = window.scrollY > 0;
};
window.addEventListener("scroll", this._handleScroll);
},
beforeUnmount() {
window.removeEventListener("sierra-chat-closed", this._handleSierraChatClosed);
@ -195,6 +211,7 @@ export default {
);
window.removeEventListener("sierra-chat-transfer", this._handleSierraToSalesforceTransfer);
window.removeEventListener("salesforce-chat-visibility", this._handleSalesforceChatOpened);
window.removeEventListener("scroll", this._handleScroll);
},
};
</script>
@ -209,9 +226,18 @@ export default {
padding: 2rem 0.75rem;
}
}
position: relative;
position: sticky;
top: 0;
z-index: 1000;
background-color: white;
box-shadow: none;
transition: box-shadow 0.3s ease-in-out;
padding: 0;
&.scrolled {
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
}
.button-container {
&.webchat {
margin-right: 0.875rem;

View file

@ -68,6 +68,9 @@ export default {
<style lang="scss" scoped>
:deep(.bolded-words) {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
color: $black;
font-weight: 600;
font-size: 16px;
}
h5 {
line-height: 32px;
@ -87,7 +90,7 @@ h5 {
}
p.small {
color: $gray-550;
color: $gray-600;
}
.sub-text {
margin: 0;

View file

@ -16,6 +16,7 @@
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
:isFooterButtonPrimary="true"
@footer-button-event="saveProgress">
<p class="modal-body-inner" v-html="modalBodyText"></p>
<saveProgressQuestion

View file

@ -6,6 +6,7 @@
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
:isFooterButtonPrimary="true"
@footer-button-event="saveProgress">
<p class="modal-body-inner">{{ modalBodyText }}</p>
<saveProgressQuestion

View file

@ -82,3 +82,56 @@ export async function isGlassAvailableForCarId(carId, pageNameToLog) {
return true;
}
export function getSideDoorGlassString(glassLocation, glassName) {
let glassNameString;
switch (glassName) {
case glassLocations.BACK:
glassNameString = glassLocations.REAR + " " + glassLocations.DOOR; // "Back Door"
break;
case glassLocations.FRONT:
glassNameString = glassName + " " + glassLocations.DOOR; // "Front Door"
break;
case glassLocations.VENT:
case glassLocations.QUARTER:
glassNameString = glassName;
break;
}
return glassLocation + " " + glassName + " " + glassLocations.GLASS;
}
export function getDamageInfoWordingText(damageInfo) {
const glassTextArray = [];
damageInfo.glassToReplace.forEach((item) => {
let string = "";
if (item.glassLocation === glassLocations.WINDSHIELD) {
string = item.glassLocation;
}
if (item.glassLocation === glassLocations.REAR) {
string = glassLocations.BACK + " " + glassLocations.GLASS;
}
if (item.glassLocation === glassLocations.DRIVER) {
string = getSideDoorGlassString(item.glassLocation, item.glassName);
// ONLY INCLUDE "DOOR" IF IT'S REAR or FRONT (vent or quarter should NOT)
}
if (item.glassLocation === glassLocations.PASSENGER) {
string = getSideDoorGlassString(item.glassLocation, item.glassName);
}
glassTextArray.push(string);
});
if (glassTextArray.length === 1) {
return glassTextArray[0];
}
let damageInfoWordingText = glassTextArray.join(", "); // string
let lastCommaIndex = damageInfoWordingText.lastIndexOf(", ");
if (lastCommaIndex > -1) {
return (
damageInfoWordingText.slice(0, lastCommaIndex) +
" and " +
damageInfoWordingText.slice(lastCommaIndex + 2, damageInfoWordingText.length)
);
}
return "";
}

View file

@ -757,7 +757,7 @@ function setupMocks({
wrapper.vm.$refs.navbar.removeLoader = jest.fn();
const closestShops = {
data: {
providers: [
inShopProviders: [
{ id: 1, name: "Shop 1" },
{ id: 2, name: "Shop 2" },
],

View file

@ -308,7 +308,7 @@ export default {
this.serviceZipCode,
this.carId
);
this.displayNoServiceAlert = !closestShops?.data?.providers?.length;
this.displayNoServiceAlert = !closestShops?.data?.inShopProviders?.length;
if (this.displayNoServiceAlert) {
return this.$refs.navbar.removeLoader();
}

View file

@ -62,7 +62,7 @@ export default {
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border-radius: $border-radius-pill;
border: 1px solid $gray-500;
width: 100%;
outline: none;

View file

@ -61,14 +61,15 @@
:donationCartItem="donationLineItem"
:showAsPaid="isPia"
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal"
:isInsurance="isInsurance"
:insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName"
:showInsuranceCoverageAs="showInsuranceCoverageAs"
:shouldHideRecalibration="shouldHideRecalibration"
:isExpandedOnLoad="false"
:isItac="isItac"
:isNoComp="isNoComp"
:isExpandedOnLoad="false"
:isMSRFeeApplicable="isMSRFeeApplicable" />
<hr class="mb-5" />

View file

@ -4,7 +4,10 @@
<div class="container page-container-grouped-styles">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4 mb-5" />
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
class="mt-4 mb-5"
alignLeft />
<textboxQuestion
isRequired
class="mb-4"
@ -215,3 +218,8 @@ export default {
},
};
</script>
<style lang="scss">
.form-check {
margin-bottom: 0.5rem;
}
</style>

View file

@ -27,7 +27,7 @@
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
<div class="text-center" v-html="this.VinLookupQuestionFooterText2"></div>
<div class="text-center mb-5" v-html="this.VinLookupQuestionFooterText2"></div>
</div>
</div>
</div>
@ -268,6 +268,15 @@ export default {
.header-sub-copy {
font-size: 0.875rem;
}
.funnel-sub-header {
padding-bottom: 0;
h5.dark-header {
margin-bottom: 1rem;
}
.sub-text strong {
text-transform: uppercase;
}
}
.select-an-option {
font-family: UrbanistSemibold;
@ -277,5 +286,6 @@ export default {
.select-option {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
font-weight: 600;
color: $black;
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<textboxQuestion
v-model="selectedAccountName"
ref="insuranceCoQuestion"
customInputId="autocomplete"
class="mb-4 mt-5"
cmsWidgetName="InsuranceCoQuestionWidget"
includeSearchIcon
:validationRules="validationRules" />
</template>

View file

@ -5,7 +5,7 @@
<div class="container page-container-grouped-styles">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4" alignLeft />
<div v-if="originalList.length > 0">
<insurance-company-question

View file

@ -722,7 +722,7 @@ function setupMocks({
wrapper.vm.$refs.navbar.removeLoader = jest.fn();
const closestShops = {
data: {
providers: [
inShopProviders: [
{ id: 1, name: "Shop 1" },
{ id: 2, name: "Shop 2" },
],

View file

@ -274,7 +274,7 @@ export default {
this.serviceZipCode,
this.carId
);
this.displayNoServiceAlert = !closestShops?.data?.providers?.length;
this.displayNoServiceAlert = !closestShops?.data?.inShopProviders?.length;
if (this.displayNoServiceAlert) {
return this.$refs.navbar.removeLoader();
}

View file

@ -43,8 +43,8 @@
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" aria-live="polite">
<div class="col-8">
<div class="mb-4" aria-live="polite">
<div class="col mb-4">
<dropdownQuestion
customDropdownId="state"
cmsWidgetName="StateQuestionWidget"
@ -56,7 +56,7 @@
:labelBold="labelBold"
:isDisabled="this.isStateDisabled" />
</div>
<div class="col-4">
<div class="col">
<textboxQuestion
customInputId="zipCode"
cmsWidgetName="ZipQuestionWidget"

View file

@ -67,7 +67,7 @@ beforeEach(() => {
describe("mobile-details.vue", () => {
test("if the continue button is clicked, navigate forward", async () => {
// Arrange
const { wrapper } = setupMocks(mobileDetails, {
const { wrapper } = setupMocks({
mixins: [mockMixin],
attachTo: document.body,
});
@ -91,7 +91,7 @@ describe("mobile-details.vue", () => {
});
test("arePagePrerequisitesValid should be true ", async () => {
//Arrange
const { wrapper } = setupMocks(mobileDetails, {
const { wrapper } = setupMocks({
mixins: [mockMixin],
attachTo: document.body,
});
@ -105,7 +105,7 @@ describe("mobile-details.vue", () => {
test("if the back button is clicked, navigate back", async () => {
// Arrange
const { wrapper } = setupMocks(mobileDetails, {
const { wrapper } = setupMocks({
mixins: [mockMixin],
attachTo: document.body,
});

View file

@ -7,11 +7,26 @@
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<hr class="my-3" />
<textBlock cmsWidgetName="AppointmentTimeOfService" class="time-header" />
<div class="date-time-edit">
<textBlock cmsWidgetName="AppointmentTimeOfService" class="time-header" />
<textLink
linkType="navigation"
:text="editLink"
@click-event="linkClick"
href="javascript:void(0)"
data-bs-target="#footerModal"
data-bs-dismiss="modal" />
</div>
<textBlock cmsWidgetName="AppointmentDateAndTime" />
<hr class="my-3" />
<div class="mobile-location-questions">
<div class="address-questions-container">
<textBlock
cmsWidgetName="PleaseProvideAddressWidget"
class="provide-address-header" />
<textBlock
cmsWidgetName="VehicleKeysMessageWidget"
class="keys-message" />
<mobileAddressQuestions
ref="addressQuestions"
v-model="this.addressQuestions"
@ -56,6 +71,8 @@ import store from "@/store";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import textLink from "@/ux-components/text-link/text-link";
import analyticsMixin from "@/mixins/analytics-mixin";
export default {
name: "MobileDetails",
data() {
@ -142,6 +159,17 @@ export default {
this.pageName
);
},
linkClick() {
// check session expired and initSession to recreate cookies
if (analyticsMixin.methods.sessionExpired()) {
this.routeReturnUser();
} else {
this.backButtonAction();
}
},
routeReturnUser() {
this.$router.sendToReturnUser();
},
backButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
@ -156,6 +184,9 @@ export default {
AppointmentTimeOfService() {
return this.getCmsContent("AppointmentDateAndTime", "Text");
},
editLink() {
return this.getCmsContent("EditLinkWidget", "Text");
},
},
components: {
mobileAddressQuestions,
@ -166,6 +197,7 @@ export default {
funnelSubHeader,
Form,
loadingModal,
textLink,
},
};
</script>
@ -175,5 +207,40 @@ export default {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
font-weight: 600;
color: $black;
margin-top: 0;
}
.provide-address-header {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
font-weight: 600;
color: $black;
line-height: 1.625rem;
letter-spacing: 0.03rem;
}
.keys-message {
background-color: #fff5eb;
color: #4d5151;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 0.5rem 1rem 0.5rem 1rem;
min-height: 3.656rem;
line-height: 1.625rem;
margin-top: 1.25rem;
margin-bottom: 1.25rem;
border-radius: 5px;
letter-spacing: 0;
}
.date-time-edit {
display: flex;
justify-content: space-between;
}
:deep(span.unbolded-text) {
font-weight: 400;
color: $gray-600;
font-family: UrbanistRegular;
}
:deep(.caption) {
font-size: 0.875rem;
}
</style>

View file

@ -95,6 +95,7 @@ export default {
.question-text span {
text-align: left;
line-height: 24px;
font-weight: 600;
}
}
</style>

View file

@ -106,7 +106,8 @@
<contentGroupModal
ref="RecalModal"
cmsWidgetName="RecalModal"
class="recal-modal" />
class="recal-modal"
isRecal />
</div>
</div>
</div>
@ -222,7 +223,8 @@ export default {
"payment-method"
);
const reviewDropdownPromise = reviewDropdown.methods.loadInitialData();
// const reviewDropdownPromise = reviewDropdown.methods.loadInitialData();
// (removed temporarily for Heritage parity effort)
const promiseResultMap = [
{
@ -237,10 +239,11 @@ export default {
resultKey: "rainDefense",
promise: rainDefensePromise,
},
{
resultKey: "reviewDropdownData",
promise: reviewDropdownPromise,
},
// {
// resultKey: "reviewDropdownData",
// promise: reviewDropdownPromise,
// },
// (removed temporarily for Heritage parity effort)
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -345,7 +348,8 @@ export default {
);
vm.updateFooterButtonText(vm.customCtaCopy);
vm.$refs.reviewDropdown.initializeComponent(resultMap.reviewDropdownData);
// vm.$refs.reviewDropdown.initializeComponent(resultMap.reviewDropdownData);
// (removed temporarily for Heritage parity effort)
if (revalidatePromoResponse) {
const revalidateAlerts = buildToastMessagesFromRevalidateOrValidatePromoResponse(
@ -751,15 +755,15 @@ export default {
customCtaCopy() {
switch (this.paymentMethod) {
case paymentMethods.PAY_NOW:
return "Continue to checkout";
return "Continue"; // Before Heritage Parity it was "Continue to checkout"
case paymentMethods.INSURANCE:
return "Continue to insurance";
return "Continue"; // Before Heritage Parity it was "Continue to insurance"
case paymentMethods.CREDIT_CARD:
return "Continue to checkout";
return "Continue"; // Before Heritage Parity it was "Continue to checkout"
case paymentMethods.PAYPAL:
return "Continue to Paypal";
return "Continue"; // Before Heritage Parity it was "Continue to Paypal"
case paymentMethods.AFTERPAY:
return "Continue to Afterpay";
return "Continue"; // Before Heritage Parity it was "Continue to Afterpay"
default:
return null;
}
@ -846,6 +850,18 @@ export default {
(newPromo) => !oldPromoCodes.includes(newPromo.promoCode)
);
// Remove any existing promo error alerts for this promo code
const existingAlerts = this.$refs.funnelHeader.getGlobalAlerts();
if (existingAlerts.length > 0) {
const errorPromoAlertToRemove = existingAlerts.find(
(alert) =>
alert.messageHeadline === "Promo code error" &&
alert.messageCopy.includes(newlyActivatedPromoCodes[0].promoCode)
);
errorPromoAlertToRemove &&
this.$refs.funnelHeader.removeGlobalAlert(errorPromoAlertToRemove.id);
}
const alert = createPromoSuccessAlert(newlyActivatedPromoCodes[0].promoCode);
this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade);
} else if (
@ -891,6 +907,9 @@ export default {
text-transform: lowercase;
}
}
:deep(.review-table p) {
margin: 0.25rem 0 0 0;
}
}
.checkbox-group {
align-items: start;
@ -932,17 +951,31 @@ export default {
:deep(.modal-body) {
display: flex;
flex-direction: column;
overflow: visible !important;
p.subheader-text {
color: $red;
order: 1 !important;
margin-top: -2.25rem;
}
h5 {
text-align: center;
order: 1;
text-align: left !important;
order: 2 !important;
}
p {
order: 3;
order: 4;
}
img {
order: 2;
order: 3 !important;
}
}
}
}
:deep(span.label) {
font-weight: 600;
}
:deep(div.service-questions span) {
color: $black;
font-family: UrbanistSemibold;
}
</style>

View file

@ -15,6 +15,28 @@
<div v-html="apptWordingText" v-show="!isExpanded"></div>
<div class="review-table px-4">
<appointmentReview
class="service-location"
cmsWidgetName="AppointmentWidget"
:apptWordingText="apptWordingText" />
<hr class="my-0" />
<parityDamageReview
cmsWidgetName="DamageReviewWidget"
damageLocationsWidgetName="DamageLocationsWidget"
:damage="damageInfo"
:vehicle="vehicleInfo" />
<hr class="my-0" />
<customerReview cmsWidgetName="CustomerReviewWidget" :customer="customerInfo" />
<hr class="my-0" />
<optInReview cmsWidgetName="OptInReviewWidget" :customer="customerInfo" />
<!-- the following was the order prior to Heritage parity
<vehicleReview cmsWidgetName="VehicleReviewWidget" :vehicle="vehicleInfo" />
<hr class="my-0" />
@ -48,6 +70,7 @@
<hr class="my-0" />
<customerReview cmsWidgetName="CustomerReviewWidget" :customer="customerInfo" />
-->
</div>
</div>
</template>
@ -56,8 +79,11 @@
import textBlock from "@/digital-components/text-block/text-block";
import customerReview from "@/layouts/payment-method/review-dropdown/review-sections/customer-review/customer-review";
import optInReview from "@/layouts/payment-method/review-dropdown/review-sections/opt-in-review/opt-in-review";
import damageReview from "@/layouts/payment-method/review-dropdown/review-sections/damage-review/damage-review";
import parityDamageReview from "@/layouts/payment-method/review-dropdown/review-sections/parity-damage-review/parity-damage-review";
import scheduleReview from "@/layouts/payment-method/review-dropdown/review-sections/schedule-review/schedule-review";
import appointmentReview from "@/layouts/payment-method/review-dropdown/review-sections/appointment-review/appointment-review";
import serviceLocationReview from "@/layouts/payment-method/review-dropdown/review-sections/service-location-review/service-location-review";
import servicePackageReview from "@/layouts/payment-method/review-dropdown/review-sections/service-package-review/service-package-review";
import vehicleReview from "@/layouts/payment-method/review-dropdown/review-sections/vehicle-review/vehicle-review";
@ -69,6 +95,7 @@ export default {
props: {
apptWordingText: String,
serviceLocationFullAddress: String,
damageInfoHeader: String,
},
data() {
return {
@ -76,21 +103,22 @@ export default {
};
},
methods: {
async loadInitialData() {
const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData();
// the following was used prior to Heritage parity
// async loadInitialData() {
// const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData();
const promiseResultMap = [
{
resultKey: "servicePackageData",
promise: servicePackageInitialDataPromise,
},
];
// const promiseResultMap = [
// {
// resultKey: "servicePackageData",
// promise: servicePackageInitialDataPromise,
// },
// ];
return settleAllPromises(promiseResultMap);
},
initializeComponent(apiResponses) {
this.$refs.servicePackageReview.initializeComponent(apiResponses.servicePackageData);
},
// return settleAllPromises(promiseResultMap);
// },
// initializeComponent(apiResponses) {
// this.$refs.servicePackageReview.initializeComponent(apiResponses.servicePackageData);
// },
toggleIsExpanded() {
this.isExpanded = !this.isExpanded;
},
@ -117,11 +145,9 @@ export default {
},
components: {
customerReview,
damageReview,
scheduleReview,
serviceLocationReview,
servicePackageReview,
vehicleReview,
optInReview,
appointmentReview,
parityDamageReview,
},
};
</script>
@ -138,6 +164,10 @@ export default {
overflow: hidden;
visibility: hidden;
& > :first-child {
margin-top: -0.75rem;
}
:deep(.service-location) {
.review-block-content {
text-transform: capitalize;

View file

@ -0,0 +1,103 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import appointmentReview from "@/layouts/payment-method/review-dropdown/review-sections/appointment-review/appointment-review";
const testConstants = {
cms: {
header: {
text: "Appointment Details",
},
},
appointment: {
apptWordingText:
"September 10, 2025 arriving between 9:00 AM — 11:00 AM at 123 Main St, anytown, OH 43215",
},
displayContent: {
apptWordingText:
"September 10, 2025 arriving between 9:00 AM — 11:00 AM at 123 Main St, anytown, OH 43215",
},
};
let cmsContent;
describe("Appointment Review Block", () => {
beforeEach(() => {
cmsContent = {
AppointmentWidget: {
HeaderText: testConstants.cms.header.text,
},
};
});
test("Should display header text from cms", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.headerText).toEqual(testConstants.cms.header.text);
});
test("Should render correct display content", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayContent).toEqual([testConstants.displayContent.apptWordingText]);
});
test("Should emit edit-clicked when editClicked method is called", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
wrapper.vm.editClicked();
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted("edit-clicked")).toBeTruthy();
});
});
function generateDefaultProps() {
return {
cmsWidgetName: "AppointmentWidget",
apptWordingText: testConstants.appointment.apptWordingText,
};
}
function setupMocks(customMountOptions) {
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return cmsContent?.[widgetName]?.[cmsFieldName] ?? "";
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(appointmentReview, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,38 @@
<template>
<reviewBlock
:customHeaderText="headerText"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
export default {
name: "appointment-review",
props: {
cmsWidgetName: String,
apptWordingText: String,
},
data() {
return {};
},
methods: {
editClicked() {
this.$emit("edit-clicked");
},
},
computed: {
displayContent() {
return [this.apptWordingText];
},
headerText() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
},
components: {
reviewBlock,
},
};
</script>

View file

@ -13,17 +13,17 @@ const testConstants = {
},
},
customer: {
firstName: "First",
lastName: "Last",
// firstName: "First",
// lastName: "Last",
phoneNumber: "111-111-1111",
emailAddress: "builddigitaltest@safelite.com",
isSmsOptIn: false,
// isSmsOptIn: false,
},
displayContent: {
fullName: "First Last",
// fullName: "First Last",
phoneNumber: "111-111-1111",
emailAddress: "builddigitaltest@safelite.com",
smsOptIn: "Sms",
// smsOptIn: "Sms",
},
};
@ -54,20 +54,20 @@ describe("Customer Review Block", () => {
expect(wrapper.vm.header).toEqual(testConstants.cms.header.text);
});
test("Should display sms text from cms", async () => {
// Arrange
let props = generateDefaultProps();
// test("Should display sms text from cms", async () => {
// // Arrange
// let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// const { wrapper } = setupMocks({
// propsData: props,
// });
// Act
await wrapper.vm.$nextTick();
// // Act
// await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.smsOptIn).toEqual(testConstants.cms.sms.text);
});
// // Assert
// expect(wrapper.vm.smsOptIn).toEqual(testConstants.cms.sms.text);
// });
test("Should render correct display content", async () => {
// Arrange
@ -82,10 +82,9 @@ describe("Customer Review Block", () => {
// Assert
expect(wrapper.vm.displayContent).toEqual([
testConstants.displayContent.fullName,
testConstants.displayContent.emailAddress,
testConstants.displayContent.phoneNumber,
testConstants.displayContent.smsOptIn,
// testConstants.displayContent.smsOptIn,
]);
});
});
@ -94,11 +93,11 @@ function generateDefaultProps() {
return {
cmsWidgetName: "CustomerWidget",
customer: {
firstName: testConstants.customer.firstName,
lastName: testConstants.customer.lastName,
// firstName: testConstants.customer.firstName,
// lastName: testConstants.customer.lastName,
phoneNumber: testConstants.customer.phoneNumber,
emailAddress: testConstants.customer.emailAddress,
isSmsOptIn: testConstants.customer.isSmsOptIn,
// isSmsOptIn: testConstants.customer.isSmsOptIn,
},
};
}

View file

@ -21,7 +21,8 @@ export default {
},
computed: {
displayContent() {
return [this.fullName, this.email, this.phoneNumber, this.smsOptIn];
return [this.email, this.phoneNumber];
// return [this.fullName, this.email, this.phoneNumber, this.smsOptIn]; // prior to Heritage parity
},
header() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");

View file

@ -0,0 +1,141 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import optInReview from "@/layouts/payment-method/review-dropdown/review-sections/opt-in-review/opt-in-review";
const testConstants = {
cms: {
header: {
text: "Text Message Updates",
},
bodyText: {
text: "We'll send appointment updates to {custom:SMSNUMBER}",
},
processedBodyText: {
text: "We'll send appointment updates to 111-111-1111",
},
},
customer: {
phoneNumber: "111-111-1111",
},
displayContent: {
processedBodyText: "We'll send appointment updates to 111-111-1111",
},
};
let cmsContent;
describe("Opt-In Review Block", () => {
beforeEach(() => {
cmsContent = {
OptInWidget: {
HeaderText: testConstants.cms.header.text,
BodyText: testConstants.cms.bodyText.text,
},
};
});
test("Should display header text from cms", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.header).toEqual(testConstants.cms.header.text);
});
test("Should render correct display content with phone number replacement", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayContent).toEqual([testConstants.displayContent.processedBodyText]);
});
test("Should return customer phone number", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.phoneNumber).toEqual(testConstants.customer.phoneNumber);
});
test("Should emit edit-clicked when editClicked method is called", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
wrapper.vm.editClicked();
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted("edit-clicked")).toBeTruthy();
});
test("Should handle missing customer gracefully", async () => {
// Arrange
let props = generateDefaultProps();
props.customer = null;
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.phoneNumber).toBeUndefined();
});
});
function generateDefaultProps() {
return {
cmsWidgetName: "OptInWidget",
customer: {
phoneNumber: testConstants.customer.phoneNumber,
},
};
}
function setupMocks(customMountOptions) {
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return cmsContent?.[widgetName]?.[cmsFieldName] ?? "";
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(optInReview, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,42 @@
<template>
<reviewBlock :customHeaderText="header" :content="displayContent" @edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
export default {
name: "opt-in-review",
props: {
cmsWidgetName: String,
customer: Object,
},
data() {
return {};
},
methods: {
editClicked() {
this.$emit("edit-clicked");
},
},
computed: {
displayContent() {
return [
this.getCmsContent(this.cmsWidgetName, "BodyText")?.replaceAll(
"{custom:SMSNUMBER}",
this.phoneNumber
),
];
},
header() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
phoneNumber() {
return this.customer?.phoneNumber;
},
},
components: {
reviewBlock,
},
};
</script>

View file

@ -0,0 +1,149 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { getDamageInfoWordingText } from "@/helpers/damage-helper";
import parityDamageReview from "@/layouts/payment-method/review-dropdown/review-sections/parity-damage-review/parity-damage-review";
// Mock the damage helper
jest.mock("@/helpers/damage-helper", () => ({
getDamageInfoWordingText: jest.fn(),
}));
const testConstants = {
cms: {
damageInfoHeader: {
text: "We'll replace your {custom:GLASSPARTS}",
},
processedHeader: {
text: "We'll replace your windshield and passenger side window",
},
},
vehicle: {
year: "2020",
make: "Honda",
model: "Civic",
},
damage: {
glassType: "windshield",
location: "passenger",
},
damageInfoWordingText: "windshield and passenger side window",
displayContent: {
vehicleInfo: "2020 Honda Civic",
},
};
let cmsContent;
describe("Parity Damage Review Block", () => {
beforeEach(() => {
cmsContent = {
DamageInfoHeaderWidget: {
Text: testConstants.cms.damageInfoHeader.text,
},
};
// Mock the damage helper function
getDamageInfoWordingText.mockReturnValue(testConstants.damageInfoWordingText);
});
afterEach(() => {
jest.clearAllMocks();
});
test("Should display processed damage info header text with custom replacement", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.damageInfoHeader).toEqual(testConstants.cms.processedHeader.text);
});
test("Should render correct display content with vehicle information", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayContent).toEqual([testConstants.displayContent.vehicleInfo]);
});
test("Should call getDamageInfoWordingText with damage prop", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
await wrapper.vm.$nextTick();
const result = wrapper.vm.damageInfoWordingText;
// Assert
expect(getDamageInfoWordingText).toHaveBeenCalledWith(testConstants.damage);
expect(result).toEqual(testConstants.damageInfoWordingText);
});
test("Should emit edit-clicked when editClicked method is called", async () => {
// Arrange
let props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
wrapper.vm.editClicked();
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted("edit-clicked")).toBeTruthy();
});
});
function generateDefaultProps() {
return {
cmsWidgetName: "DamageReviewWidget",
vehicle: {
year: testConstants.vehicle.year,
make: testConstants.vehicle.make,
model: testConstants.vehicle.model,
},
damage: {
glassType: testConstants.damage.glassType,
location: testConstants.damage.location,
},
};
}
function setupMocks(customMountOptions) {
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return cmsContent?.[widgetName]?.[cmsFieldName] ?? "";
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(parityDamageReview, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,46 @@
<template>
<reviewBlock
:headerCmsWidgetName="cmsWidgetName"
:content="displayContent"
:customHeaderText="damageInfoHeader"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
import { getDamageInfoWordingText } from "@/helpers/damage-helper";
export default {
name: "vehicle-review",
props: {
cmsWidgetName: String,
vehicle: Object,
damage: Object,
},
data() {
return {};
},
methods: {
editClicked() {
this.$emit("edit-clicked");
},
},
computed: {
displayContent() {
return [`${this.vehicle.year} ${this.vehicle.make} ${this.vehicle.model}`];
},
damageInfoWordingText() {
return getDamageInfoWordingText(this.damage);
},
damageInfoHeader() {
return this.getCmsContent("DamageInfoHeaderWidget", "Text")?.replaceAll(
"{custom:GLASSPARTS}",
this.damageInfoWordingText
);
},
},
components: {
reviewBlock,
},
};
</script>

View file

@ -53,7 +53,7 @@
:showInsuranceCoverageAs="showInsuranceCoverageAs"
:isItac="isItac"
:isNoComp="isNoComp"
:isExpandedOnLoad="true" />
:isCollapsible="false" />
<button
v-if="showSwitchPaymentMethod"
@ -919,10 +919,6 @@ export default {
width: 100%;
min-height: 650px;
border: 0 none;
@include media-breakpoint-down(lg) {
min-height: 1688px;
}
}
#pageLoadingModal {
background-color: red;
@ -936,16 +932,18 @@ export default {
}
& > #cart-container {
flex-basis: 36%;
margin-top: 2rem;
}
@include media-breakpoint-down(lg) {
flex-direction: column;
& > iframe {
flex-basis: 100%;
flex-basis: auto;
margin-right: 0;
}
& > #cart-container {
flex-basis: 100%;
flex-basis: auto;
margin: 0 0.5rem;
& > button#submit-payment {
display: none;

View file

@ -20,8 +20,8 @@
<span v-else>{{ getInlineAltText(token) }}</span>
</span>
<span v-else v-html="token"></span>
<span class="nbsp">&nbsp;</span>
</span>
&nbsp;
<a
id="afterpay-learnmore"
href="#"
@ -139,7 +139,7 @@ export default {
line-height: 2rem;
@include media-breakpoint-down(md) {
border-bottom: 1px solid;
padding-bottom: 1rem;
padding-bottom: 0.5rem;
width: 100%;
justify-content: center;
@ -170,4 +170,8 @@ export default {
font-size: 0.875rem;
}
}
#afterpay-learnmore {
font-weight: 600;
font-size: 0.875rem;
}
</style>

View file

@ -57,7 +57,7 @@
alertClass="alert-info" />
<promoModalQuestion
class="mt-6 d-flex"
class="mt-4 d-flex"
v-model="lineItems"
:addableVaps="addableVaps"
pageName="quote"
@ -67,11 +67,11 @@
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-8 col-xl-6 nav-container">
<div class="col-md-8 col-xl-6">
<contentGroupModal ref="RainDefenseModal" cmsWidgetName="RainDefenseModal" />
<contentGroupModal ref="FrontWiperModal" cmsWidgetName="FrontWiperModal" />
<contentGroupModal ref="RearWiperModal" cmsWidgetName="RearWiperModal" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" isRecal />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
<navbar
cmsWidgetName="FunnelFooterWidget"
@ -902,21 +902,19 @@ export default {
.quote {
padding-bottom: 2rem;
.nav-container {
/* Create correct positioning point for .nav-bar a.navigation-link */
position: relative;
}
:deep(.nav-bar) {
position: initial;
button.btn {
width: 100%;
justify-content: center;
@media only screen and (min-width: 340px) {
.col button {
width: 100%;
justify-content: center;
}
}
a.navigation-link {
position: absolute;
left: 1rem;
bottom: -1rem;
bottom: 1rem;
}
.col-auto {
width: -webkit-fill-available;

View file

@ -105,6 +105,7 @@ export default {
<style lang="scss" scoped>
.start-over {
text-align: center;
margin-bottom: 1.5rem;
a {
font-weight: 600;
color: $black;
@ -114,7 +115,7 @@ export default {
}
.return-user {
.return-user-spacing {
margin-top: 14rem;
margin-top: 8rem;
}
}
:deep(.menu-modal-container) {

View file

@ -0,0 +1,114 @@
<template>
<textBlock
:customText="durationTextBlockCopy"
typeStyle="small"
marginTopSizeOverride="0"
class="duration-text-block" />
</template>
<script>
// Components
import textBlock from "@/digital-components/text-block/text-block";
// Constants
import { AppointmentTypeStrings, cmsWidgetFieldMappings } from "@/constants/schedule-constants";
// Helpers
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
export default {
name: "duration-text-block",
props: {
isSameDay: Boolean,
appointmentType: String,
estimatedServiceMinutesMinimum: String,
estimatedServiceMinutesMaximum: String,
isOvernightDropoff: Boolean,
},
data() {
return {
selectedRouteCode: null,
selectedAnswerForDropOffOrInshop: null,
selectedAnswerForTimeSlots: null,
};
},
computed: {
dropOffDurationText() {
return this.getCmsContent("DropOffTimeSlotModal", cmsWidgetFieldMappings.DURATION);
},
sameDayDropoffDurationText() {
return this.getCmsContent(
"SameDayDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
overnightDropoffDurationText() {
return this.getCmsContent(
"OvernightDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
"TimeSlotModalQuestion",
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
if (this.estimatedServiceMinutesMinimum && this.estimatedServiceMinutesMaximum) {
return `${inshopDurationTextWithoutTime} <b>${inshopDurationTime}</b>`;
}
return null;
},
mobileDurationText() {
const mobileDurationTextWithoutTime = this.getCmsContent(
"TimeSlotModalQuestion",
cmsWidgetFieldMappings.DURATION
);
const mobileDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
if (this.estimatedServiceMinutesMinimum && this.estimatedServiceMinutesMaximum) {
return `${mobileDurationTextWithoutTime} ${mobileDurationTime}`;
}
return null;
},
durationTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return this.mobileDurationText;
} else if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
return this.getDurationTextBlockCopyForDropoff();
} else {
return this.inshopDurationText;
}
},
},
methods: {
getDurationTextBlockCopyForDropoff() {
if (this.isOvernightDropoff) {
return this.overnightDropoffDurationText;
} else {
if (this.isSameDay) {
return this.sameDayDropoffDurationText;
} else {
return this.dropOffDurationText;
}
}
},
},
components: {
textBlock,
},
};
</script>
<style></style>

View file

@ -0,0 +1,141 @@
<template>
<div
class="mobile-fee-waiver-alert-container py-3 px-4 my-2"
:class="[isExpanded ? 'expanded' : '']">
<div
class="mobile-fee-waiver-header d-flex align-items-center justify-content-between"
@click="toggleIsExpanded">
<span>
{{ headerText }}
</span>
</div>
<div class="mobile-fee-waiver-body">
<div class="mobile-fee-waiver-copy">
{{ subheaderText }}
</div>
<div class="mobile-fee-waiver-price-comp">
<span class="mobile-fee-waiver-strikethrough-price">
{{ priceStrikethroughText }}
</span>
<span class="mobile-fee-waiver-free-price">{{ freePriceText }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "mobile-fee-waiver-alert",
props: {
cmsWidgetName: String,
},
data() {
return {
isExpanded: true,
};
},
computed: {
headerText() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
subheaderText() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
priceStrikethroughText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
freePriceText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText2");
},
},
methods: {
toggleIsExpanded() {
this.setIsExpanded(!this.isExpanded);
},
setIsExpanded(newVal) {
this.isExpanded = newVal;
},
},
components: {},
};
</script>
<style lang="scss">
.mobile-fee-waiver-alert-container {
background: $green-100;
border-radius: 0.25em;
border: 1px solid $green-400;
.mobile-fee-waiver-header {
font-weight: bold;
span {
flex-grow: 1;
color: $black;
}
&::before {
content: "";
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath fill='%230C7E47' d='M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Zm3.7 6.171-4.449 4.45a.559.559 0 0 1-.8 0l-2.16-2.16a.563.563 0 0 1 .792-.8l1.76 1.76 4.066-4.042a.563.563 0 1 1 .792.8v-.008Z'/%3E%3C/svg%3E");
width: 1rem;
height: 1rem;
display: inline-block;
margin-right: 0.5rem;
}
&::after {
content: "";
transition: all 0.5s ease;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 9'%3E%3Cpath d='M7.99282 0.117702C7.75716 0.116049 7.53044 0.207797 7.3623 0.37286L0.255553 7.46878C0.0862976 7.63796 -0.00878906 7.86742 -0.00878906 8.10667C-0.00878906 8.34593 0.0862976 8.57539 0.255553 8.74457C0.424808 8.91374 0.654368 9.00879 0.893731 9.00879C1.13309 9.00879 1.36265 8.91374 1.53191 8.74457L7.99282 2.27378L14.4614 8.74457C14.6307 8.91205 14.8595 9.00547 15.0977 9.00428C15.3359 9.00308 15.5638 8.90737 15.7314 8.73819C15.8989 8.56901 15.9924 8.34022 15.9912 8.10216C15.99 7.8641 15.8942 7.63627 15.725 7.46878L8.6259 0.377963C8.45765 0.21088 8.22999 0.117289 7.99282 0.117702Z' fill='%230C7E47'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center center;
width: 16px;
height: 16px;
display: inline-flex;
position: relative;
}
}
.mobile-fee-waiver-body {
transition: all 500ms ease-out;
visibility: hidden;
overflow: hidden;
max-height: 0;
.mobile-fee-waiver-price-comp {
margin-top: 0.5em;
.mobile-fee-waiver-strikethrough-price {
font-size: smaller;
text-decoration: line-through;
}
.mobile-fee-waiver-free-price {
display: inline-block;
font-weight: bolder;
border-radius: 1em;
background-color: $green-300;
padding: 0 0.5em;
margin-inline-start: 0.5em;
}
}
}
&.expanded {
.mobile-fee-waiver-header {
&::after {
transform: rotate(180deg);
}
}
.mobile-fee-waiver-body {
visibility: visible;
max-height: none;
border-top: 1px solid $green-400;
padding-top: 0.5em;
margin-top: 0.5em;
}
}
}
</style>

View file

@ -5,17 +5,14 @@
<div class="container page-container-grouped-styles">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<alert
ref="alertMobileFeeFree"
class="mb-5"
cmsWidgetName="AlertMobileFeeFreeWidget"
v-if="showMobileFreeAlert"
alertClass="alert-success" />
<mobileFeeWaiverAlert cmsWidgetName="MobileFeeFreeDropdownWidget" />
</div>
</div>
<div class="row appointment-type">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="ScheduleYourServiceWidget" class="mb-5" />
<funnelSubHeader
cmsWidgetName="ScheduleYourServiceWidget"
id="schedule-your-service" />
<appointmentTypeQuestion
v-model="appointmentTypeFromAppointmentTypeQuestion"
v-show="isAppointmentTypeDisplayed"
@ -118,6 +115,15 @@
<div class="row date-picker-wrapper">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<locationAlerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" />
<div v-show="appointmentType" class="date-picker-header fw-normal mt-5 mb-5">
<textBlock :customText="datePickerHeaderText" typeStyle="button-question" />
<durationTextBlock
:isSameDay="isSameDay"
:appointmentType="appointmentType"
:estimatedServiceMinutesMinimum="estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="estimatedServiceMinutesMaximum"
:isOvernightDropoff="isOvernightDropoff" />
</div>
<datePicker
:currentZip="zipCode"
:currentProviderNumber="selectedProvider?.providerNumber"
@ -134,14 +140,25 @@
:pricingByDayBasePrice="pricingByDayBasePrice"
:pricingByDayUpcharge="pricingByDayUpcharge"
:showPricingByDay="showPricingByDay"
:isPricingByDayExperiment="isPricingByDayExperiment"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:isPricingByDayExperiment="isPricingByDayExperiment" />
<timeSlotQuestion
ref="timeSlotModalQuestion"
v-if="showTimeSlotQuestion"
@time-slot-selection-changed="updateTimeSlot"
waitListLabelWidget="WaitListLabelWidget"
waitListQuestionWidget="WaitListQuestionWidget"
mobilePremiumCmsWidgetName="MobilePremiumTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
dropOffOrPickATimeQuestionCmsWidgetName="DropOffOrPickATimeQuestionWidget"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:estimatedServiceMinutesMinimum="getServiceMinutesMin"
:estimatedServiceMinutesMaximum="getServiceMinutesMax"
@timeSlotSelected="updateTimeSlot"
:displayWaitList="displayWaitList"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:isSameDay="isSameDay"
:selectedRouteCodeData="selectedRouteCodeData"
@waitListRequested="handleWaitListRequested" />
<navbar
cmsWidgetName="FunnelFooterWidget"
@ -160,9 +177,10 @@
import alert from "@/ux-components/alert/alert";
import serviceZipModalQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-modal-question";
import appointmentTypeQuestion from "@/layouts/service-location/appointment-type-question/appointment-type-question";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
import shopListButton from "@/layouts/service-location/shop-question/shop-list-button/shop-list-button";
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 funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
@ -174,6 +192,7 @@ import datePicker from "@/digital-components/date-picker/date-picker";
import locationAlerts from "@/layouts/schedule/location-alerts/location-alerts";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import textBlock from "@/digital-components/text-block/text-block";
import mobileFeeWaiverAlert from "./mobile-fee-waiver-alert/mobile-fee-waiver-alert.vue";
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
@ -626,6 +645,9 @@ export default {
return this.isGlassServiceableMobile;
}
},
showTimeSlotQuestion() {
return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion;
},
isMobileStaticRecalibrationApplicable() {
return (
this.displayMSR &&
@ -785,6 +807,21 @@ export default {
}
return [];
},
selectedRouteCodeData() {
if (!this.selectedTimeSlotInfo?.timeSlot?.routeCode) {
return null;
}
return {
routeCode: this.selectedTimeSlotInfo.timeSlot.routeCode,
isPremiumAppointment: this.selectedTimeSlotInfo.isPremiumAppointment,
};
},
questionText() {
return this.getCmsContent("YourSafeliteShopWidget", "QuestionText");
},
datePickerHeaderText() {
return this.getCmsContent("DatePickerSubHeaderWidget", "HeaderText");
},
appointmentTypeStrings() {
return AppointmentTypeStrings;
},
@ -795,12 +832,12 @@ export default {
// 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);
},
getServiceMinutesMin() {
estimatedServiceMinutesMinimum() {
return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMinimum
: this.selectableDatesInshop.estimatedServiceMinutesMinimum;
},
getServiceMinutesMax() {
estimatedServiceMinutesMaximum() {
return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMaximum
: this.selectableDatesInshop.estimatedServiceMinutesMaximum;
@ -820,6 +857,18 @@ export default {
);
}
},
isSameDay() {
const todaysDate = new Date().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
isOvernightDropoff() {
return (
this.selectedTimeSlotInfo?.timeSlot?.routeCode &&
this.selectedTimeSlotInfo.timeSlot.routeCode.includes(
RouteCodeFlags.OVERNIGHT_DROP_OFF
)
);
},
},
methods: {
splitCopyOnCMSPlaceHolder,
@ -1238,7 +1287,9 @@ export default {
return store.getters.lineItems.supportingItems;
},
updateFooterButtonText(timeSlotInfo) {
let navbarButtonText;
let navbarButtonText = "Continue";
// Archiving the following in case we revert back from Heritage parity
/*
if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) {
navbarButtonText = "Continue";
} else {
@ -1273,6 +1324,7 @@ export default {
)}`;
}
}
*/
this.$refs.navbar.updateButtonText(navbarButtonText);
},
convertSelectedDateToShortMonthAndDay(selectedDate) {
@ -1555,22 +1607,46 @@ export default {
}
},
updateTimeSlot(timeSlotObj) {
if (this.preSelectedDate && !timeSlotObj?.timeSlot?.date) return;
this.selectedTimeSlotInfo = timeSlotObj;
if (!timeSlotObj?.routeCode) {
this.appointmentType = AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF;
this.selectedTimeSlotInfo = {
timeSlot: {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
isPremiumAppointment: null,
};
return;
}
const timeSlot = this.timeSlotsForSelectedDate?.timeSlots?.find(
(slot) => slot.id == timeSlotObj.routeCode
);
this.selectedTimeSlotInfo = {
timeSlot: {
date: this.timeSlotsForSelectedDate.date,
routeCode: timeSlotObj.routeCode,
startTime: timeSlot.startTime,
endTime: timeSlot.endTime,
jobMaxMinutes: this.estimatedServiceMinutesMaximum.toString(),
jobMinMinutes: this.estimatedServiceMinutesMinimum.toString(),
},
isPremiumAppointment: timeSlotObj.isPremiumAppointment ? true : false,
};
if (
this.appointmentType !== AppointmentTypeStrings.MOBILE &&
this.appointmentType !== null
) {
// update apptType based on routeCode to determine if it should be dropoff or inshop
debugLog(`Updating appointment type based on route code.`);
debugLog(`Route code =`, timeSlotObj.timeSlot.routeCode);
debugLog(
`Parsed type = `,
this.getInShopOrDropOffApptType(timeSlotObj.timeSlot.routeCode)
);
this.appointmentType = this.getInShopOrDropOffApptType(
timeSlotObj.timeSlot.routeCode
);
debugLog(`Route code =`, timeSlotObj.routeCode);
debugLog(`Parsed type = `, this.getInShopOrDropOffApptType(timeSlotObj.routeCode));
this.appointmentType = this.getInShopOrDropOffApptType(timeSlotObj.routeCode);
}
},
getInShopOrDropOffApptType(routeCode) {
@ -1618,7 +1694,7 @@ export default {
},
};
} else if (
!this.selectedProvider?.address?.streetAddress &&
(!this.selectedProvider?.address?.streetAddress || this.isVehicleHeavyTruck) &&
this.shopProviderData?.shopProviders?.length
) {
this.selectedProvider = this.shopProviderData.shopProviders[0];
@ -1737,12 +1813,15 @@ export default {
datePicker,
locationAlerts,
textBlock,
timeSlotQuestion,
alert,
serviceZipModalQuestion,
appointmentTypeQuestion,
contentGroupModal,
durationTextBlock,
shopLocation,
mobileFeeWaiverAlert,
},
};
</script>
@ -1777,7 +1856,7 @@ export default {
font-weight: 600;
}
:deep(.funnel-sub-header:not(.mb-5)) {
:deep(.funnel-sub-header:not(#schedule-your-service)) {
h5.dark-header {
font-weight: 600;
font-size: 1rem;
@ -1791,7 +1870,7 @@ export default {
text-underline-offset: 4px;
}
.text-block.duration-text-block b {
:deep(.text-block.duration-text-block b) {
font-family: UrbanistSemibold;
}
:deep(.alert-warning.widget-name-AlertRecalNoMobileWidget .alert-heading),

View file

@ -3,6 +3,7 @@
<buttonQuestion
v-if="isDropOffAppointmentAvailable"
v-model="selectedAnswerForDropOffOrInshop"
@update:modelValue="dropOffSelectionChanged"
:questionText="dropOffOrPickATimeQuestionLabelText"
:questionTextDescription="dropOffOrPickATimeQuestionDescription"
:answers="answersForDropOffQuestion"
@ -15,7 +16,7 @@
<alert
v-if="shouldDisplayDropOffAlert"
ref="alertDropoffInformation"
class="mb-4 drop-off-alert"
class="mt-4 mb-4 drop-off-alert"
:cmsWidgetName="dropOffTimeSlotAlertCMSWidgetName"
alertClass="alert-info"
v-bind:isDismissible="false" />
@ -24,6 +25,7 @@
<buttonQuestion
ref="buttonQuestion"
v-if="shouldDisplayTimeSlotQuestion"
@update:modelValue="timeSlotSelectionChanged"
buttonTypeString="timeSlotModalListButton"
:buttonTypeObject="timeSlotModalListButton"
class="mt-4 timeslots"
@ -44,7 +46,7 @@
v-if="supplementalInformationBlock"
v-html="supplementalInformationBlock" />
<div
v-if="hasWaitListExperiment && displayWaitListFeature && selectedDate"
v-if="hasWaitListExperiment && displayWaitListFeature"
class="mt-5 bg-light rounded waitlist">
<textBlock
cmsWidgetName="WaitListLabelWidget"
@ -59,7 +61,7 @@
@click="waitListChecked" />
</div>
<div
v-if="waitListRequested && displayWaitList && selectedDate"
v-if="waitListRequested && displayWaitList"
class="rounded waitlist-success"
ref="waitlistSuccessMessage">
<img :src="waitListSuccessImage" class="success-image" />
@ -92,7 +94,6 @@ import {
isDropOffRouteCode,
militaryToTwelveHourTime,
} from "@/layouts/schedule/helpers/schedule-helper";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
// Validation
import { defineRule, useField } from "vee-validate";
@ -116,23 +117,8 @@ defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "time-slot-question",
emits: ["update:modelValue", "TimeSlotSelected", "click-event"],
emits: ["update:modelValue", "time-slot-selection-changed"],
props: {
modelValue: {
type: Object,
default: () => ({
timeSlot: {
routeCode: null,
date: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
isPremiumAppointment: null,
}),
},
cmsWidgetName: String,
mobileCmsWidgetName: String,
mobilePremiumCmsWidgetName: String,
dropoffCmsWidgetName: String,
@ -142,21 +128,23 @@ export default {
appointmentType: String,
timeSlotsForSelectedDate: Object,
premiumAppointmentFee: Object,
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
validationRules: String,
customComponentId: String,
selectedDate: String,
isSameDay: Boolean,
displayWaitList: Boolean,
selectedRouteCodeData: Object,
},
data() {
return {
selectedRouteCode: null,
timeSlotModalListButton: timeSlotModalListButton,
selectedAnswerForDropOffOrInshop: null,
selectedAnswerForTimeSlots: null,
selectedAnswerForDropOffOrInshop: null,
};
},
mounted() {
this.updateDropoffAndTimeSlotAnswersFromSelectedRouteCodeData(this.selectedRouteCodeData);
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
},
setup(props) {
const uuid = uuidv4();
const componentId = !props.customComponentId
@ -186,20 +174,33 @@ export default {
errors,
};
},
watch: {
waitListRequested(newVal) {
if (newVal) {
this.$nextTick(() => {
this.scrollToSuccessMessage();
});
}
},
selectedRouteCodeData(newValue) {
this.updateDropoffAndTimeSlotAnswersFromSelectedRouteCodeData(newValue);
},
timeSlotsForSelectedDate() {
this.selectedAnswerForDropOffOrInshop = null;
this.selectedAnswerForTimeSlots = null;
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
},
},
computed: {
supplementalInformationBlock() {
let appointmentTypeCmsWidgetName;
if (
this.selectedDate == null ||
this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF ||
this.appointmentType === AppointmentTypeStrings.IN_SHOP ||
this.appointmentType === AppointmentTypeStrings.DROP_OFF ||
!this.availableTimeSlots
) {
if (!this.selectedAnswerForTimeSlots && !this.selectedAnswerForDropOffOrInshop) {
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
}
return null;
// This is planned to be used again for drop-off
// Wrote this to be ready for that eventuality, is untested and no HTML work done yet
@ -214,11 +215,7 @@ export default {
// );
// }
} else {
if (!this.selectedAnswerForTimeSlots && this.selectedRouteCode) {
// Clearing selectedRouteCode if no time slot is selected
this.resetSelectedTimeSlot();
}
appointmentTypeCmsWidgetName = this.selectedRouteCode?.includes(
appointmentTypeCmsWidgetName = this.selectedRouteCodeData?.routeCode?.includes(
PREMIUM_TIME_SLOT_ID_FLAG
)
? this.mobilePremiumCmsWidgetName
@ -299,13 +296,6 @@ export default {
// return null;
// }
// },
isSameDay() {
if (!this.timeSlotsForSelectedDate) {
return false;
}
const todaysDate = new Date().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
availableTimeSlots() {
if (!this.timeSlotsForSelectedDate) {
return null;
@ -407,29 +397,60 @@ export default {
},
},
methods: {
initializeComponent() {
this.setSelectedRouteCodeFromParent();
},
async setSelectedTimeSlot() {
this.selectedRouteCode =
this.selectedAnswerForTimeSlots || this.selectedAnswerForDropOffOrInshop;
this.$emit(
"update:modelValue",
this.getSelectedTimeSlotInfoObject(this.selectedRouteCode)
);
},
getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
selectedRouteCode,
isSameDayRelevant = false
) {
if (selectedRouteCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffCmsWidgetName;
} else if (selectedRouteCode.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
return this.isSameDay && isSameDayRelevant
? this.sameDayDropOffCmsWidgetName
: this.dropoffCmsWidgetName;
updateDropoffAndTimeSlotAnswersFromSelectedRouteCodeData(selectedRouteCodeData) {
if (selectedRouteCodeData?.routeCode) {
let routeCode = selectedRouteCodeData.routeCode;
if (isDropOffRouteCode(routeCode)) {
this.selectedAnswerForDropOffOrInshop = routeCode;
} else {
this.selectedAnswerForDropOffOrInshop = PICK_A_TIME_BUTTON_VALUE;
if (this.selectedRouteCodeData.isPremiumAppointment) {
routeCode = this.addPremiumFlagToInput(routeCode);
}
this.selectedAnswerForTimeSlots = routeCode;
}
} else {
this.selectedAnswerForDropOffOrInshop = null;
this.selectedAnswerForTimeSlots = null;
}
},
dropOffSelectionChanged(newValue) {
let newSelectedRouteCodeData = null;
if (newValue && newValue != PICK_A_TIME_BUTTON_VALUE) {
newSelectedRouteCodeData = {
routeCode: newValue,
isPremiumAppointment: false,
};
}
this.selectedAnswerForTimeSlots = null;
this.$emit("time-slot-selection-changed", newSelectedRouteCodeData);
},
timeSlotSelectionChanged(newValue) {
if (newValue) {
let routeCode = newValue;
let isPremiumAppointment = routeCode.includes(PREMIUM_TIME_SLOT_ID_FLAG);
if (isPremiumAppointment) {
routeCode = this.removePremiumFlagFromInput(routeCode);
}
this.$emit("time-slot-selection-changed", {
routeCode: routeCode,
isPremiumAppointment: isPremiumAppointment,
});
}
},
// This is unused but planned to be used again
// getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
// selectedRouteCode,
// isSameDayRelevant = false
// ) {
// if (selectedRouteCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
// return this.overnightDropOffCmsWidgetName;
// } else if (selectedRouteCode.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
// return this.isSameDay && isSameDayRelevant
// ? this.sameDayDropOffCmsWidgetName
// : this.dropoffCmsWidgetName;
// }
// },
getAvailableTimeSlotsForInshop(timeSlotsForSelectedDate) {
return timeSlotsForSelectedDate
.filter((timeSlot) => {
@ -463,8 +484,6 @@ export default {
);
}
this.resetSelectedTimeSlotIfDateChange(timeSlotsForSelectedDate);
return availableTimeSlots;
},
getPremiumAppointmentTimeSlot(timeSlotData) {
@ -480,35 +499,19 @@ export default {
},
};
},
setSelectedRouteCodeFromParent() {
if (!this.modelValue?.timeSlot?.routeCode) {
return;
}
const routeCodeFromParent = this.modelValue.timeSlot.routeCode;
if (this.modelValue?.isPremiumAppointment) {
// Mobile Only
this.selectedAnswerForTimeSlots = this.addPremiumFlagToInput(routeCodeFromParent);
} else {
if (isDropOffRouteCode(routeCodeFromParent)) {
this.selectedAnswerForDropOffOrInshop = routeCodeFromParent;
} else {
this.selectedAnswerForDropOffOrInshop = PICK_A_TIME_BUTTON_VALUE;
this.selectedAnswerForTimeSlots = routeCodeFromParent;
}
}
},
getWaitListRequestedFromStore() {
return store.getters.order.customer?.waitListRequested;
},
autoSelectTimeSlotIfOnlyOneIsAvailable() {
const numberOfOptions = this.timeSlotsForSelectedDate?.timeSlots?.length;
if (this.appointmentType && numberOfOptions === 1) {
if (numberOfOptions === 1) {
if (this.availableTimeSlots.length > 0) {
// this.availableTimeSlots only returns mobile/inshop slots so we know
// the only available slot is not dropOFf
this.selectedAnswerForTimeSlots = this.availableTimeSlots[0].value;
} else {
this.selectedAnswerForDropOffOrInshop = this.answersForDropOffQuestion[0].value;
}
this.setSelectedTimeSlot();
}
},
addPremiumFlagToInput(routeCode) {
@ -517,41 +520,6 @@ export default {
removePremiumFlagFromInput(routeCode) {
return routeCode.replace(PREMIUM_TIME_SLOT_ID_FLAG, "");
},
getSelectedTimeSlotInfoObject(routeCode) {
const routeCodeIncludesPremium = routeCode?.includes(PREMIUM_TIME_SLOT_ID_FLAG);
if (routeCodeIncludesPremium) {
routeCode = this.removePremiumFlagFromInput(routeCode);
}
const timeSlot = this.timeSlotsForSelectedDate?.timeSlots?.find(
(slot) => slot.id == routeCode
);
if (timeSlot) {
return {
timeSlot: {
date: this.timeSlotsForSelectedDate.date,
routeCode: timeSlot.id,
startTime: timeSlot.startTime,
endTime: timeSlot.endTime,
jobMaxMinutes: this.estimatedServiceMinutesMaximum?.toString() || null,
jobMinMinutes: this.estimatedServiceMinutesMinimum?.toString() || null,
},
isPremiumAppointment: routeCodeIncludesPremium ? true : false,
};
}
return {
timeSlot: {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
isPremiumAppointment: null,
};
},
waitListChecked(event) {
this.$emit("waitListRequested", event.target.checked);
},
@ -561,65 +529,6 @@ export default {
successMessageElement.scrollIntoView({ behavior: "smooth" });
}
},
resetSelectedTimeSlotIfDateChange(timeSlotsForSelectedDate) {
if (this.selectedRouteCode) {
const selectedRouteCodeString = this.selectedRouteCode.replace(
PREMIUM_TIME_SLOT_ID_FLAG,
""
);
const matchingTimeSlot = timeSlotsForSelectedDate.find((slot) => {
return slot.id === selectedRouteCodeString;
});
if (!matchingTimeSlot) {
this.resetSelectedTimeSlot();
}
}
},
resetSelectedTimeSlot() {
this.selectedRouteCode = null;
},
},
watch: {
waitListRequested(newVal) {
if (newVal) {
this.$nextTick(() => {
this.scrollToSuccessMessage();
});
}
},
modelValue: {
handler(newValue) {
this.handleChange(newValue);
},
deep: true,
},
selectedDate: {
handler() {
this.resetSelectedTimeSlot();
this.selectedAnswerForDropOffOrInshop = null;
this.selectedAnswerForTimeSlots = null;
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
},
},
selectedRouteCode(newValue) {
if (newValue) {
this.setSelectedTimeSlot();
}
},
selectedAnswerForDropOffOrInshop(newValue) {
if (newValue == PICK_A_TIME_BUTTON_VALUE) {
this.selectedRouteCode = null;
this.setSelectedTimeSlot();
} else {
this.selectedAnswerForTimeSlots = null;
this.selectedRouteCode = newValue;
}
},
selectedAnswerForTimeSlots(newValue) {
if (newValue) {
this.selectedRouteCode = newValue;
}
},
},
components: {
textBlock,

View file

@ -178,7 +178,7 @@ export default {
"service-location"
);
if (!closestShops || closestShops.providers?.length === 0) {
if (!closestShops || closestShops.inShopProviders?.length === 0) {
this.displayNoServiceAlert = true;
this.focusOnZipInput();
this.resetModalButtonStyle();

View file

@ -12,6 +12,7 @@
</transition>
<modal
ref="shopQuestionModal"
class="shop-question-popup"
:headerText="modalHeaderText"
:onModalOpenedCallback="updateSelectedAnswer"
:onModalClosedCallback="resetZipCodeData"
@ -290,7 +291,7 @@ export default {
this.$store.getters.order.vehicle.carId,
"service-location"
);
if (!closestShops || closestShops.providers?.length === 0) {
if (!closestShops || closestShops.inShopProviders?.length === 0) {
this.isLoading = false;
this.displayNoServiceAlert = true;
return null;
@ -392,6 +393,25 @@ export default {
.modal.modal-component .modal-dialog .modal-content .modal-body .textbox-question {
padding: 0;
}
.modal.shop-question-popup .btn.btn-primary {
background: $blue;
&:focus,
&:focus-visible {
box-shadow:
0 0 0 3px,
0 0 0 5.5px $blue;
}
&.form-test-invalid {
background: #d4d6d8;
&:focus,
&:focus-visible {
box-shadow: none;
}
}
}
.shop-question-zipcode {
display: flex;
flex-direction: row;

View file

@ -265,7 +265,7 @@ export default {
this.serviceZipCode,
this.carId
);
this.displayNoServiceAlert = !closestShops?.data?.providers?.length;
this.displayNoServiceAlert = !closestShops?.data?.inShopProviders?.length;
if (this.displayNoServiceAlert) {
return this.$refs.navbar.removeLoader();
}

View file

@ -238,6 +238,9 @@ export default {
</script>
<style lang="scss">
html .has-error.list-card:hover {
border-radius: 50rem;
}
.vehicle-parts {
label {
&.list-card {

View file

@ -496,7 +496,10 @@ export default {
{ zip: this.serviceZipCode, carId: this.carId },
"vehicle"
);
if (!closestShops?.data?.providers || closestShops.data.providers.length === 0) {
if (
!closestShops?.data?.inShopProviders ||
closestShops.data.inShopProviders.length === 0
) {
this.displayNoServiceAlert = true;
if (store.getters.externalParameterState?.isExternalParameter) {
return baseMixin.methods.ResetExternalParamsAndHideModal();

View file

@ -341,7 +341,7 @@ export default {
this.serviceZipCode,
this.carId
);
this.displayNoServiceAlert = !closestShops?.data?.providers?.length;
this.displayNoServiceAlert = !closestShops?.data?.inShopProviders?.length;
if (this.displayNoServiceAlert) {
return this.$refs.navbar.removeLoader();
}

View file

@ -695,6 +695,7 @@ export const mutations = {
vehicleSubType: sessionInformation.order.vehicle?.vehicleSubType,
vehicleSpecialClass: sessionInformation.order.vehicle?.vehicleSpecialClass,
isBigTruck: sessionInformation.order.vehicle?.isBigTruck,
canSafeliteService: sessionInformation.order.vehicle?.canSafeliteService,
});
state.order.damage.glassToReplace = sessionInformation.order.damage.glassToReplace;

View file

@ -63,9 +63,6 @@ export default {
<style lang="scss">
.btn {
&:first-child:active {
background-color: $red;
}
&.btn-primary {
position: relative;
background: $red;
@ -117,8 +114,8 @@ export default {
&.btn-secondary {
position: relative;
background: transparent;
border: 1px solid $red;
color: $red;
border: 1px solid $blue;
color: $blue;
font-weight: 500;
transition: all 150ms linear;
height: 3rem;
@ -131,8 +128,11 @@ export default {
outline: none;
box-shadow:
0 0 0 3px $white,
0 0 0 5.5px $red;
color: $white;
0 0 0 5.5px $blue;
color: $blue;
&:hover {
color: $white;
}
}
&:disabled {
background: transparent;

View file

@ -79,7 +79,7 @@ export default {
color: $black;
font-weight: 500;
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
border: 1px solid $blue;
}
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;

View file

@ -32,11 +32,23 @@ parameters:
- name: imageTag
type: string
default: '$(Build.BuildId)'
- name: dockerBuildArgs
type: string
default: ''
- name: envFileName
type: string
default: '.env.ci'
- name: applicationType
type: string
default: 'vue'
values: ['vue', 'dotnet']
- name: readinessEndpoint
type: string
default: '/healthcheck'
- name: serverMaxAttempts
type: number
default: 60
jobs:
- job: playwright_tests
continueOnError: true
@ -103,6 +115,14 @@ jobs:
echo "Docker cleanup completed!"
displayName: "Docker Cleanup"
- bash: |
printenv > "${{ parameters.envFileName }}"
if [ -z "$(GITHUB_TOKEN)" ]; then
echo "Printed env, but GITHUB_TOKEN was undefined"
fi
env: ${{ parameters.secrets }}
displayName: "Make Azure Pipeline Variables Available to Docker"
- task: Docker@2
displayName: 'Build Docker Image'
inputs:
@ -110,20 +130,23 @@ jobs:
dockerfile: ${{ parameters.dockerFileName }}
repository: ${{ parameters.dockerImageName }}
tags: ${{ parameters.imageTag }}
arguments: '--no-cache --pull'
arguments: |
--no-cache --pull ${{ parameters.dockerBuildArgs }}
- script: |
printenv > "${{ parameters.envFileName }}"
# Determine JIRA card number and filters
JIRA_CARD_NUMBER=""
IS_REGRESSION="${{ parameters.isRegression }}"
echo "isRegression: $IS_REGRESSION";
echo "isRegression: $IS_REGRESSION"
if [[ "${{ parameters.isRegression }}" == "False" ]]; then
branch_name=$(System.PullRequest.SourceBranch)
branch_name="$(System.PullRequest.SourceBranch)"
echo "Retrieved branch name: '$branch_name'"
JIRA_CARD_NUMBER="${branch_name##*/}"
echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'"
fi
# Build test filter
FILTER=""
if [[ -n "${{ parameters.filterTags }}" && -n "$JIRA_CARD_NUMBER" ]]; then
echo "Filtering by filterTags and Jira Card Number..."
@ -140,57 +163,213 @@ jobs:
fi
echo "Filter value: $FILTER"
TARGET_URL=${{ parameters.targetUrl }}
echo "BASE_URL value: $BASE_URL"
TARGET_URL="${{ parameters.targetUrl }}"
echo "Target URL: $TARGET_URL"
echo "Application Type: ${{ parameters.applicationType }}"
# Create container and run tests
# Set common variables
PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}"
SERVE_PATH="${{ parameters.npmServePath }}"
SHARD_NUMBER=$(shardNumber)
TOTAL_SHARDS=${{ parameters.totalShards }}
APPLICATION_TYPE="${{ parameters.applicationType }}"
ENV_FILE="${{ parameters.envFileName }}"
# Handle localhost URLs
if [[ "$TARGET_URL" == *localhost* ]]; then
echo 'Npx version:'
npx --version
port="$TARGET_URL"
echo "URL: '$port'"
echo "URL '$port' contains 'localhost'. Extracting port... "
port=$(echo "$port" | sed -E 's/.*:([0-9]+).*/\1/')
echo "Port is '$port'"
TARGET_URL="$TARGET_URL"
JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER"
FILTER="$FILTER"
PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}"
SERVE_PATH="${{ parameters.npmServePath }}"
SHARD_NUMBER=$(shardNumber)
TOTAL_SHARDS=${{ parameters.totalShards }}
echo "Localhost URL detected, extracting port..."
port=$(echo "$TARGET_URL" | sed -E 's/.*:([0-9]+).*/\1/')
echo "Port extracted: $port"
# Create readiness check URL based on application type
if [[ "$APPLICATION_TYPE" == "dotnet" ]]; then
# For .NET APIs, append the readiness endpoint to the target URL
READINESS_URL="${TARGET_URL}${{ parameters.readinessEndpoint }}"
echo "Using .NET readiness check URL: $READINESS_URL"
else
# For Vue apps, use the target URL as-is
READINESS_URL="$TARGET_URL"
echo "Using Vue app URL for readiness check: $READINESS_URL"
fi
echo "Starting tests for shard $SHARD_NUMBER of $TOTAL_SHARDS..."
container_id=$(docker create \
--ipc=host \
--env CI=true \
--env TARGET_URL="$TARGET_URL" \
--env READINESS_URL="$READINESS_URL" \
--env PLAYWRIGHT_PATH="$PLAYWRIGHT_PATH" \
--env SERVE_PATH="$SERVE_PATH" \
--env SHARD_NUMBER="$SHARD_NUMBER" \
--env TOTAL_SHARDS="$TOTAL_SHARDS" \
--env FILTER="$FILTER" \
--env APPLICATION_TYPE="$APPLICATION_TYPE" \
--env SERVER_MAX_ATTEMPTS="${{ parameters.serverMaxAttempts }}" \
--env ASPNETCORE_URLS="https://localhost:$port" \
--env ENV_FILE="$ENV_FILE" \
--env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
npx concurrently -k -n 'server,playwright' \
"npm --prefix $SERVE_PATH run serve -- --port=$port" \
"npx --prefix $PLAYWRIGHT_PATH wait-on $TARGET_URL && PLAYWRIGHT_BLOB_OUTPUT_DIR='/app/blob-report' npx --prefix $PLAYWRIGHT_PATH playwright test $PLAYWRIGHT_PATH --config=$PLAYWRIGHT_PATH/playwright.config.ts --shard=$SHARD_NUMBER/$TOTAL_SHARDS --reporter=list,blob $FILTER" \
bash -c '
echo "=== Starting Application Server ==="
echo "Application Type: $APPLICATION_TYPE"
echo "Target URL: $TARGET_URL"
echo "Readiness Check URL: $READINESS_URL"
echo "Server Max Attempts: $SERVER_MAX_ATTEMPTS seconds"
# Function to wait for server readiness
wait_for_server() {
local url=$1
local max_attempts=$SERVER_MAX_ATTEMPTS
local attempt=1
echo "Waiting for server at: $url"
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts: Checking server health..."
if [[ "$APPLICATION_TYPE" == "vue" ]]; then
# For Vue apps, use a simple HTTP check
if curl -s -f "$url" --max-time 10 > /dev/null 2>&1; then
echo "Vue server is ready!"
return 0
fi
else
# For .NET APIs, use healthcheck endpoint
if curl -k -s -f "$url" --max-time 10 > /dev/null 2>&1; then
echo ".NET server is ready!"
return 0
fi
fi
if [ $attempt -eq $max_attempts ]; then
echo "Server failed to become ready after $max_attempts attempts"
return 1
fi
echo "Server not ready yet, waiting 2 seconds..."
sleep 2
attempt=$((attempt + 1))
done
}
# Install curl if not available
if ! command -v curl &> /dev/null; then
echo "Installing curl..."
apk add --no-cache curl 2>/dev/null || apt-get update && apt-get install -y curl 2>/dev/null || true
fi
# Start server and run tests based on application type
if [[ "$APPLICATION_TYPE" == "vue" ]]; then
echo "=== Using Vue Mode with wait-on and concurrently ==="
# Extract port for Vue server
port=$(echo "$TARGET_URL" | sed -E "s/.*:([0-9]+).*/\1/")
echo "Starting Vue server on port: $port"
# Create blob report directory
mkdir -p /app/blob-report
# Use concurrently for Vue apps (original approach)
if [ -n "$FILTER" ]; then
echo "Running Vue tests with filter: $FILTER"
npx concurrently -k -n "server,playwright" \
"npm --prefix $SERVE_PATH run serve -- --port=$port" \
"npx --prefix $PLAYWRIGHT_PATH wait-on $TARGET_URL && cd $PLAYWRIGHT_PATH && PLAYWRIGHT_BLOB_OUTPUT_DIR=\"/app/blob-report\" npx dotenv-cli -e \"../$ENV_FILE\" -- playwright test --config=./playwright.config.ts --shard=$SHARD_NUMBER/$TOTAL_SHARDS --reporter=list,blob $FILTER"
else
echo "Running all Vue tests"
npx concurrently -k -n "server,playwright" \
"npm --prefix $SERVE_PATH run serve -- --port=$port" \
"npx --prefix $PLAYWRIGHT_PATH wait-on $TARGET_URL && cd $PLAYWRIGHT_PATH && PLAYWRIGHT_BLOB_OUTPUT_DIR=\"/app/blob-report\" npx dotenv-cli -e \"../$ENV_FILE\" -- playwright test --config=./playwright.config.ts --shard=$SHARD_NUMBER/$TOTAL_SHARDS --reporter=list,blob"
fi
else
echo "=== Using .NET Mode with healthcheck ==="
echo "Starting .NET server..."
echo "ASPNETCORE_URLS: $ASPNETCORE_URLS"
npm --prefix "$SERVE_PATH" run serve &
SERVER_PID=$!
echo ".NET server started with PID: $SERVER_PID"
# Wait for .NET server readiness check
if ! wait_for_server "$READINESS_URL"; then
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
echo "=== Starting Playwright Tests ==="
# Create blob report directory
mkdir -p /app/blob-report
cd "$PLAYWRIGHT_PATH"
# Run Playwright tests
if [ -n "$FILTER" ]; then
echo "Running .NET tests with filter: $FILTER"
PLAYWRIGHT_BLOB_OUTPUT_DIR="/app/blob-report" npx dotenv-cli -e "../$ENV_FILE" -- playwright test \
--config="./playwright.config.ts" \
--shard="$SHARD_NUMBER/$TOTAL_SHARDS" \
--reporter=list,blob \
$FILTER || true
else
echo "Running all .NET tests"
PLAYWRIGHT_BLOB_OUTPUT_DIR="/app/blob-report" npx dotenv-cli -e "../$ENV_FILE" -- playwright test \
--config="./playwright.config.ts" \
--shard="$SHARD_NUMBER/$TOTAL_SHARDS" \
--reporter=list,blob || true
fi
TEST_EXIT_CODE=$?
echo "Tests completed with exit code: $TEST_EXIT_CODE"
# Clean up server
echo "Stopping server..."
kill $SERVER_PID 2>/dev/null || true
exit $TEST_EXIT_CODE
fi
'
)
else
export TARGET_URL="$TARGET_URL"
export JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER"
export FILTER="$FILTER"
export PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}"
export SERVE_PATH="${{ parameters.npmServePath }}"
export SHARD_NUMBER=$(shardNumber)
export TOTAL_SHARDS=${{ parameters.totalShards }}
export ENV_FILE="${{ parameters.envFileName }}"
# Remote URL testing
echo "Remote URL testing mode"
container_id=$(docker create \
--ipc=host \
--env CI=true \
--env TARGET_URL="$TARGET_URL" \
--env PLAYWRIGHT_PATH="$PLAYWRIGHT_PATH" \
--env SHARD_NUMBER="$SHARD_NUMBER" \
--env TOTAL_SHARDS="$TOTAL_SHARDS" \
--env FILTER="$FILTER" \
--env ENV_FILE="$ENV_FILE" \
--env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
bash -c "
set -a
source $ENV_FILE
set +a
PLAYWRIGHT_BLOB_OUTPUT_DIR='/app/blob-report' npx --prefix $PLAYWRIGHT_PATH playwright test $PLAYWRIGHT_PATH --config=$PLAYWRIGHT_PATH/playwright.config.ts --shard=$SHARD_NUMBER/$TOTAL_SHARDS --reporter=list,blob $FILTER
" \
echo '=== Running Tests Against Remote URL ==='
echo 'Target URL: $TARGET_URL'
mkdir -p /app/blob-report
cd \$PLAYWRIGHT_PATH
if [ -n \"\$FILTER\" ]; then
echo 'Running tests with filter: \$FILTER'
PLAYWRIGHT_BLOB_OUTPUT_DIR='/app/blob-report' npx dotenv-cli -e \"../\$ENV_FILE\" -- playwright test \
--config=./playwright.config.ts \
--shard=\$SHARD_NUMBER/\$TOTAL_SHARDS \
--reporter=list,blob \
\$FILTER || true
else
echo 'Running all tests'
PLAYWRIGHT_BLOB_OUTPUT_DIR='/app/blob-report' npx dotenv-cli -e \"../\$ENV_FILE\" -- playwright test \
--config=./playwright.config.ts \
--shard=\$SHARD_NUMBER/\$TOTAL_SHARDS \
--reporter=list,blob || true
fi
"
)
fi
# Start container and stream logs
echo "Starting tests for shard $(shardNumber)..."
docker start -a $container_id
@ -206,12 +385,6 @@ jobs:
# Remove container
echo "Cleaning up container..."
docker rm $container_id
# Check if tests failed
if [ $? -ne 0 ]; then
echo "Tests failed in shard $(shardNumber) or tests don't exist for this shard number!"
exit 0 # Suppress error. It will be visible in report.
fi
displayName: 'Run Playwright Tests - Shard $(shardNumber)'
env: ${{ parameters.secrets }}
@ -227,6 +400,7 @@ jobs:
docker rmi ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} -f
displayName: 'Cleanup Docker Image'
condition: always()
- job: download_and_merge_reports
dependsOn: playwright_tests
timeoutInMinutes: 8
@ -238,6 +412,10 @@ jobs:
- task: DownloadPipelineArtifact@2
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports'
- bash: |
printenv > "${{ parameters.envFileName }}"
env: ${{ parameters.secrets }}
displayName: "Make Azure Pipeline Variables Available to Docker"
- task: Docker@2
displayName: 'Build Docker Image'
inputs:
@ -245,16 +423,15 @@ jobs:
dockerfile: ${{ parameters.dockerFileName }}
repository: ${{ parameters.dockerImageName }}
tags: ${{ parameters.imageTag }}
arguments: '--no-cache --pull'
arguments: |
--no-cache --pull ${{ parameters.dockerBuildArgs }}
- bash: |
printenv > "${{ parameters.envFileName }}"
branch_name=$(System.PullRequest.SourceBranch)
echo "Retrieved branch name: '$branch_name'"
JIRA_CARD_NUMBER="${branch_name##*/}"
echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'"
export PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}"
export JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER"
export ENV_FILE="${{ parameters.envFileName }}"
container_id=$(docker create \
@ -263,14 +440,12 @@ jobs:
--env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
bash -c "
set -a
source $ENV_FILE
set +a
echo 'Moving Playwright reports out of subfolders...'
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \;
echo 'Value of JIRA_CARD_NUMBER in bash -c command: $JIRA_CARD_NUMBER'
echo 'Merging reports...'
JIRA_CARD_NUMBER='$JIRA_CARD_NUMBER' PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx --prefix $PLAYWRIGHT_PATH playwright merge-reports --config=$PLAYWRIGHT_PATH/playwright.config.ts ./playwright-reports
cd $PLAYWRIGHT_PATH
JIRA_CARD_NUMBER='$JIRA_CARD_NUMBER' PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx dotenv-cli -e ../$ENV_FILE -- playwright merge-reports --config=./playwright.config.ts ../playwright-reports
"
)