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 # TODO: Change to ADO Playwright template
- template: temp/playwright-test.yml - template: temp/playwright-test.yml
parameters: parameters:
applicationType: 'vue'
totalShards: ${{ variables.totalShards }} totalShards: ${{ variables.totalShards }}
targetUrl: $(BASE_URL) targetUrl: $(BASE_URL)
dockerFileName: 'Dockerfile.playwright' dockerFileName: 'Dockerfile.playwright'

View file

@ -51,19 +51,11 @@ stages:
displayName: Run Prettier check displayName: Run Prettier check
- stage: TestPr - stage: TestPr
displayName: Run Unit Tests For PullRequest displayName: Run 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
jobs: jobs:
- template: temp/playwright-test.yml - template: temp/playwright-test.yml
parameters: parameters:
applicationType: 'vue'
totalShards: 2 totalShards: 2
targetUrl: $(BASE_URL) targetUrl: $(BASE_URL)
dockerFileName: 'Dockerfile.playwright' dockerFileName: 'Dockerfile.playwright'
@ -75,6 +67,12 @@ stages:
secrets: secrets:
CCIS_API_AUTH: $(CCIS_API_AUTH) CCIS_API_AUTH: $(CCIS_API_AUTH)
JIRA_API_KEY: $(JIRA_API_KEY) 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 }}: - ${{ else }}:
# Dev Build/Deploy # Dev Build/Deploy
- stage: Dev - stage: Dev

View file

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

View file

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

View file

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

View file

@ -260,7 +260,6 @@ ADMIN_SERVICE_API_URL= <br>
SAUCE_USERNAME= <br> SAUCE_USERNAME= <br>
SAUCE_ACCESS_KEY= <br> SAUCE_ACCESS_KEY= <br>
## CI/CD Integration ## CI/CD Integration
The project uses Azure Pipelines for continuous integration with configurations defined in azure-pipelines.yml. 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 { export enum ProgressBarPercentages {
VehicleSelectionPage = '4%', VehicleSelectionPage = '8%',
VehicleDamagePage = '16%', VehicleDamagePage = '11%',
EstimatePage = '28%', EstimatePage = '18%',
ServiceZipPage = '32%', ServiceZipPage = '22%',
VehicleLookupAddressPage = '32%', VehicleLookupAddressPage = '22%',
VehicleLookupLicensePage = '32%', VehicleLookupLicensePage = '22%',
VinLookupPage = '32%', VinLookupPage = '22%',
PartQuestionsPage = '40%', PartQuestionsPage = '30%',
MoldingQuestionsPage = '40%', MoldingQuestionsPage = '30%',
CapabilityQuestionsPage = '40%', CapabilityQuestionsPage = '30%',
VehiclePartsPage = '40%', VehiclePartsPage = '30%',
ServicePackagePage = '48%', ServicePackagePage = '62%',
InsuranceCompanyPage = '52%', InsuranceCompanyPage = '52%',
ServiceLocationPage = '60%', ServiceLocationPage = '76%',
SchedulePage = '64%', SchedulePage = '76%',
MobileDetailsPage = '76%', MobileDetailsPage = '76%',
ContactDetailsPage = '84%', ContactDetailsPage = '84%',
PaymentMethodPage = '92%', PaymentMethodPage = '97%',
OrderConfirmationPage = '100%' OrderConfirmationPage = '100%'
} }

View file

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

View file

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

View file

@ -10,6 +10,8 @@ export class AfterpayPage extends BasePage {
readonly passwordTextBox: Locator; readonly passwordTextBox: Locator;
// Card details // Card details
readonly paymentOptionsButton: Locator;
readonly continueButtonAfterpay: Locator;
readonly cardholderNameTextBox: Locator; readonly cardholderNameTextBox: Locator;
readonly cardNumberTextBox: Locator; readonly cardNumberTextBox: Locator;
readonly expirationDateTextBox: Locator; readonly expirationDateTextBox: Locator;
@ -23,6 +25,8 @@ export class AfterpayPage extends BasePage {
this.passwordTextBox = page.getByTestId('login-password-input'); this.passwordTextBox = page.getByTestId('login-password-input');
this.submitButton = page.getByRole('button', { name: 'Continue' }); 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.cardholderNameTextBox = page.getByTestId('payment-method-cardHolderName-input');
this.cardNumberTextBox = page.getByTestId('payment-method-cardNumber-input'); this.cardNumberTextBox = page.getByTestId('payment-method-cardNumber-input');
this.expirationDateTextBox = page.getByTestId('payment-method-cardExpiry-input'); this.expirationDateTextBox = page.getByTestId('payment-method-cardExpiry-input');
@ -46,7 +50,12 @@ export class AfterpayPage extends BasePage {
async executeAfterpayPayment(paymentDetails: IPaymentDetails) { async executeAfterpayPayment(paymentDetails: IPaymentDetails) {
await this.login(paymentDetails.password!); 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(); await this.confirmButton.click();
} }

View file

@ -25,7 +25,7 @@ export class BasePage {
this.pageSpinner = page.getByRole('status'); this.pageSpinner = page.getByRole('status');
this.buttonLoadSpin = page.getByRole('alert'); this.buttonLoadSpin = page.getByRole('alert');
this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' }); 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() { async nextPage() {
@ -104,7 +104,7 @@ export class BasePage {
async mockScheduleResponseForEarlyBird(customerDetails: ICustomerDetails) { 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) => { await this.page.route(apiUrl, async (route) => {
const currentDate = new Date().toISOString().split('T')[0]; // e.g., "2025-07-23" const currentDate = new Date().toISOString().split('T')[0]; // e.g., "2025-07-23"
if(route.request().postDataJSON().startDate === currentDate) { if(route.request().postDataJSON().startDate === currentDate) {

View file

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

View file

@ -24,8 +24,8 @@ export class PaypalPage extends BasePage {
this.passwordTextBox = page.getByPlaceholder('Password'); this.passwordTextBox = page.getByPlaceholder('Password');
this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true }); this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true });
this.completePurchaseButton = page.getByTestId('submit-button-initial') this.completePurchaseButton = page.getByTestId('submit-button-initial')
this.payWithRadioButton = page.locator('.py-4').first(); this.payWithRadioButton = page.getByRole('button').filter({ hasText: 'Pay with' });
this.payButton = page.getByRole('button', { name: 'Pay $' }); this.payButton = page.locator('#one-time-cta');
} }
async completePaypalPurchase(paymentDetails: IPaymentDetails){ async completePaypalPurchase(paymentDetails: IPaymentDetails){
@ -45,7 +45,8 @@ export class PaypalPage extends BasePage {
await this.passwordTextBox.fill(paymentDetails.password!); await this.passwordTextBox.fill(paymentDetails.password!);
await this.paypalLoginButton.click(); await this.paypalLoginButton.click();
await this.payWithRadioButton.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 militaryWarningMessage: Locator;
readonly changeZipButton: Locator; readonly changeZipButton: Locator;
readonly updateZipTextBox: Locator; readonly updateZipTextBox: Locator;
readonly safeliteShopsList: Locator;
readonly searchButton: Locator;
readonly storeAddressText: Locator;
readonly saveLocationButton: Locator; readonly saveLocationButton: Locator;
readonly changeShopLocationLink: Locator; readonly moreLocationsButton: Locator;
readonly selectAShopOptions: Locator; readonly selectAShopOptions: Locator;
readonly yourSafeliteShop: Locator; readonly yourSafeliteShop: Locator;
@ -44,12 +47,15 @@ export class SchedulePage extends BasePage {
this.militaryWarningMessage = this.page.locator('[class*="widget-name-AlertMilitaryBaseZipWidget"]'); 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.changeZipButton = this.page.locator('a:has(span.sr-only:has-text("edit zip code"))');
this.updateZipTextBox = this.page.locator('#serviceZipCode').filter({ visible: true }); 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.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.selectAShopOptions = this.page.locator('[class="shop-question"]');
this.yourSafeliteShop = this.page.locator("fieldset:has(#chooseShop) label"); 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.pickATimeButton = this.page.locator("label[buttonlabel='Pick a time']");
this.firstAvailableDate = this.page.locator('.selectable-day').filter({ visible: true}).locator('nth=0'); 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'); 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.dateText = this.page.locator('label.modal-title');
this.viewMoreDatesLink = this.page.getByText(/View more dates/).first(); this.viewMoreDatesLink = this.page.getByText(/View more dates/).first();
this.appointmentDuration = this.page.locator('.duration-text-block'); 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>) { async selectLocation(testData: Partial<ITestData>) {
@ -76,25 +82,36 @@ export class SchedulePage extends BasePage {
} }
async scheduleInShop(appointmentDetails?: IAppointmentDetails) { async scheduleInShop(appointmentDetails?: IAppointmentDetails) {
await this.inShopButton.click();
await this.inShopButton.isVisible() ? await this.inShopButton.click() : null;
if (appointmentDetails && appointmentDetails.shopAddress) { if (appointmentDetails && appointmentDetails.shopAddress) {
const zipCodeMatch = appointmentDetails.shopAddress.match(/\b\d{5}$/); const zipCodeMatch = appointmentDetails.shopAddress.match(/\b\d{5}$/);
if (zipCodeMatch) { if (zipCodeMatch) {
const zipCode = zipCodeMatch[0]; const zipCode = zipCodeMatch[0];
// Enter the ZIP code into the updateZipTextBox // Enter the ZIP code into the updateZipTextBox
await this.changeShopLocationLink.click(); await this.moreLocationsButton.click();
await this.page.waitForTimeout(500); await this.page.waitForTimeout(500);
await this.updateZipTextBox.fill(zipCode); 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 // Click the save location button
await this.saveLocationButton.click(); 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()); // await this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).scrollIntoViewIfNeeded().then(() => this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).click());
} }
if (appointmentDetails && (appointmentDetails.shopAddress === "" || appointmentDetails.shopAddress === undefined )) { 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(); const inshopAvailableDates = this.page.locator('.selectable-day').filter({ visible: true}).all();
for (const inshopAvailableDate of await inshopAvailableDates) { for (const inshopAvailableDate of await inshopAvailableDates) {
await inshopAvailableDate.click(); await inshopAvailableDate.click();
await this.page.waitForTimeout(500);
if (await this.allDayDropOffButton.isVisible()) { if (await this.allDayDropOffButton.isVisible()) {
customerDetails!.apptDate = `${await inshopAvailableDate.getAttribute("id")}`; customerDetails!.apptDate = `${await inshopAvailableDate.getAttribute("id")}`;
break; break;
@ -186,13 +204,13 @@ export class SchedulePage extends BasePage {
} }
// appointmentmentDetails.serviceLocation === ServiceLocation.DropOff ? await this.dropOffButton.click() : await this.firstAvailableTime.click(); // 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(); const selectedTimeSlot = await timeSlot.innerText();
let formattedTimeSlot: string = ""; 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"; 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.iHavePromoCodeButton = this.page.getByRole('link', { name: 'I have a promo code' });
this.skipQuoteEmailButton = this.page.getByRole('button', { name: 'Skip' }); this.skipQuoteEmailButton = this.page.getByRole('button', { name: 'Skip' });
this.emailInput = this.page.getByRole('textbox', { name: 'Enter your email address' }); 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.closeButton = this.page. getByRole('dialog').locator('button').filter({ hasText: 'Close' });
this.promoCodeTextbox = this.page.getByLabel('Enter a promo code'); this.promoCodeTextbox = this.page.getByLabel('Enter a promo code');
this.applyPromoButton = this.page.getByRole('button', { name: 'Apply 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); super(page);
this.page = page; this.page = page;
this.windshieldChkBox = this.page.locator('[buttonlabel="Windshield"]'); this.windshieldChkBox = this.page.locator('[buttonlabel="Windshield"]');
this.crackButton = this.page.locator('[buttonlabel="Crack"]'); this.crackButton = this.page.locator('[buttonlabelsubcopy="Replace my windshield"]');
this.chipButton = this.page.locator('[buttonlabel="Chip(s)"]'); this.chipButton = this.page.locator('[buttonlabelsubcopy="Repair my windshield"]');
this.sideDoorButton = this.page.locator('[buttonlabel="Side door"]'); this.sideDoorButton = this.page.locator('[buttonlabel="Side window"]');
this.driverSideButton = this.page.locator('[buttonlabel="Driver side"]'); this.driverSideButton = this.page.locator('[buttonlabel="Driver side"]');
this.passengerSideButton = this.page.locator('[buttonlabel="Passenger side"]'); this.passengerSideButton = this.page.locator('[buttonlabel="Passenger side"]');
this.driverQuarterPanelChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Quarter panel"]'); 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 // 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 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({ dotenv.config({
path: path.join(basePath, '.env.dev'), path: path.join(basePath, '.env.dev'),
example: path.join(basePath, '.env.example') example: path.join(basePath, '.env.example')
@ -17,7 +17,7 @@ if (!process.env.CI) {
} }
else { else {
dotenv.config({ 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') example: path.join(basePath, '.env.example')
}); });
} }
@ -42,7 +42,7 @@ const ortoniReportConfig: OrtoniReportConfig = {
filename: `ortoni_report_${formatDateForFilename(new Date())}.html`, filename: `ortoni_report_${formatDateForFilename(new Date())}.html`,
showProject: false, showProject: false,
projectName: "FMG-Nextgen-Playwright-Report", projectName: "FMG-Nextgen-Playwright-Report",
testType: `E2E- Environment: ${process.env.NODE_ENV} `, testType: `E2E- Environment: ${process.env.PLAYWRIGHT_ENV} `,
preferredTheme: "light", preferredTheme: "light",
base64Image: true, base64Image: true,
} }

View file

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

View file

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

View file

@ -42,14 +42,6 @@ const cashReplaceGlassAddressLookupInshopAfterPayData: Partial<ITestData> = {
vehicleLookupType: VehicleLookupType.Address 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 // Override payment details
paymentDetails: ClientData.getDefaultAfterpayDetails() paymentDetails: ClientData.getDefaultAfterpayDetails()
} }
@ -58,7 +50,7 @@ const cashReplaceGlassAddressLookupInshopAfterPayTests: ITestCase[] = [];
const tc = { const tc = {
name: `CashReplaceGlassAddressLookupInshopAfterPay`, name: `CashReplaceGlassAddressLookupInshopAfterPay`,
tags: ['@E2E','@CashReplaceGlassAddressLookupInshopAfterPay', '@test_report', '@CASH'], tags: ['@E2E','@CashReplaceGlassAddressLookupInshopAfterPay', '@test_report', '@CASH', '@CASH-848'],
testData: cashReplaceGlassAddressLookupInshopAfterPayData testData: cashReplaceGlassAddressLookupInshopAfterPayData
}; };
cashReplaceGlassAddressLookupInshopAfterPayTests.push(tc); cashReplaceGlassAddressLookupInshopAfterPayTests.push(tc);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,6 @@
<div <div
class="date-picker text-center" class="date-picker text-center"
:class="`${calendarViewDirection} calendar-2025 ${showPricingByDayClass}`"> :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"> <fieldset id="date-picker-fieldset" ref="datePickerFieldset">
<legend class="sr-only">Select a day and time</legend> <legend class="sr-only">Select a day and time</legend>
<div <div
@ -139,36 +129,11 @@
@click="showAnotherMonth"> @click="showAnotherMonth">
View more dates View more dates
</button> </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> </fieldset>
</div> </div>
</template> </template>
<script> <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 // Supporting files
import loader from "@/ux-components/loader/loader"; import loader from "@/ux-components/loader/loader";
import store from "@/store"; import store from "@/store";
@ -178,12 +143,6 @@ import {
MONTHS_OF_YEAR, MONTHS_OF_YEAR,
DAYS_OF_WEEK, DAYS_OF_WEEK,
} from "@/digital-components/date-picker/mixins/constants"; } from "@/digital-components/date-picker/mixins/constants";
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_FEE_PART_TYPE,
cmsWidgetFieldMappings,
} from "@/constants/schedule-constants";
import { import {
selectableDaysOptions, selectableDaysOptions,
requiredParameter, requiredParameter,
@ -195,7 +154,6 @@ import {
import { useField, ErrorMessage } from "vee-validate"; import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
export default { export default {
name: "datePicker", name: "datePicker",
@ -205,10 +163,8 @@ export default {
months: null, months: null,
disableViewMoreDatesButton: false, disableViewMoreDatesButton: false,
hideSomeDaysForInitialView: null, hideSomeDaysForInitialView: null,
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesInshop: [], // NOTE: this and the mobile version below use monthNum (1-based), NOT monthIndex (0-based) selectableDatesInshop: [], // NOTE: this and the mobile version below use monthNum (1-based), NOT monthIndex (0-based)
selectableDatesMobile: [], selectableDatesMobile: [],
durationTextBlockCopyForInshopOrDropoff: null,
}; };
}, },
props: { props: {
@ -242,12 +198,6 @@ export default {
pricingByDayBasePrice: Number, pricingByDayBasePrice: Number,
pricingByDayUpcharge: Number, pricingByDayUpcharge: Number,
isPricingByDayExperiment: Boolean, isPricingByDayExperiment: Boolean,
timeSlotsForSelectedDate: Object,
appointmentType: String,
premiumAppointmentFee: Object,
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
displayWaitList: Boolean,
isMobileSelected: Boolean, isMobileSelected: Boolean,
}, },
setup(props) { setup(props) {
@ -309,80 +259,10 @@ export default {
this.dispatchStoreAction(this.storeActions.SAVE_WAITLIST_REQUESTED, false, false); 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: { methods: {
async initializeComponent(initialData) { async initializeComponent(initialData) {
await this.setCalendarData(initialData); await this.setCalendarData(initialData);
this.$refs.timeSlotModalQuestion.initializeComponent();
}, },
fireDateSelectedEvent(event, date) { fireDateSelectedEvent(event, date) {
// Ignore if arrow key selected radioButton // Ignore if arrow key selected radioButton
@ -949,42 +829,9 @@ export default {
}; };
window.requestAnimationFrame(step); 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() { getSupportingItems() {
return store.getters.lineItems.supportingItems; 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: { watch: {
modelValue(newValue) { modelValue(newValue) {
@ -998,19 +845,10 @@ export default {
this.scrollToElement("date-of-month-error"); 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: { components: {
loader, loader,
ErrorMessage, ErrorMessage,
timeSlotQuestion,
textBlock,
funnelSubHeader,
}, },
}; };
</script> </script>
@ -1028,14 +866,6 @@ export default {
align-items: flex-start; align-items: flex-start;
text-align: left; text-align: left;
.duration-text-block {
text-align: left;
}
.date-picker-header {
margin-bottom: 1.5rem;
}
fieldset { fieldset {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;

View file

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

View file

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

View file

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

View file

@ -214,6 +214,47 @@ export default {
&.is-insurance { &.is-insurance {
padding-bottom: 3rem; 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 { &.has-subheader {

View file

@ -3,13 +3,15 @@
<div class="px-0" v-if="isCartReadyToLoad"> <div class="px-0" v-if="isCartReadyToLoad">
<div <div
class="row vin-toggle flex align-items-center pt-4" class="row vin-toggle flex align-items-center pt-4"
:class="[isExpanded ? 'expanded' : '']" :class="[
isCollapsible ? (isExpanded ? 'expanded' : '') : 'expanded non-collapsible',
]"
@click="toggleIsExpanded()"> @click="toggleIsExpanded()">
<a <a
aria-label="expand cart" aria-label="expand cart"
href="javascript:void(0)" href="javascript:void(0)"
class="col d-flex justify-content-between py-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"> <span class="label amount-due larger">
{{ getLineItemAmount(amountDue, showCoverageAsPending) }} {{ getLineItemAmount(amountDue, showCoverageAsPending) }}
</span> </span>
@ -208,6 +210,7 @@ export default {
isItac: Boolean, isItac: Boolean,
isNoComp: Boolean, isNoComp: Boolean,
isExpandedOnLoad: Boolean, isExpandedOnLoad: Boolean,
isCollapsible: { type: Boolean, default: true },
isMSRFeeApplicable: Boolean, isMSRFeeApplicable: Boolean,
donationCartItem: Object, donationCartItem: Object,
}, },
@ -1094,9 +1097,19 @@ export default {
amountDueText() { amountDueText() {
return this.getCmsContent("AmountDueTextWidget", "Text"); return this.getCmsContent("AmountDueTextWidget", "Text");
}, },
OrderDetailsText() { orderDetailsText() {
return this.getCmsContent("OrderDetailsTextWidget", "Text"); 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() { amountPaidText() {
return this.getCmsContent("AmountPaidTextWidget", "Text"); return this.getCmsContent("AmountPaidTextWidget", "Text");
}, },
@ -1182,6 +1195,11 @@ export default {
font-weight: 500; font-weight: 500;
line-height: 1.625; line-height: 1.625;
} }
.non-collapsible {
.amount-due {
display: none;
}
}
.amount-due { .amount-due {
color: $green; color: $green;
@ -1283,23 +1301,26 @@ export default {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.vin-toggle { .vin-toggle {
&:after { &:not(.non-collapsible) {
content: ""; &:after {
transition: all 0.5s ease; content: "";
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"); transition: all 0.5s ease;
background-repeat: no-repeat; 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-position: right center; background-repeat: no-repeat;
width: 24px; background-position: right center;
height: 24px; width: 24px;
display: inline-flex; height: 24px;
position: relative; display: inline-flex;
right: 0.75rem; position: relative;
margin: 0.5rem 0 0.5rem 1rem; right: 0.75rem;
cursor: pointer; margin: 0.5rem 0 0.5rem 1rem;
} cursor: pointer;
&.expanded:after { }
transform: rotate(180deg); &.expanded:after {
transform: rotate(180deg);
}
} }
&.expanded + .cart-panel { &.expanded + .cart-panel {
max-height: 650px; max-height: 650px;
transition: all 150ms ease-in; transition: all 150ms ease-in;
@ -1307,6 +1328,13 @@ export default {
padding: 1rem 0 0.5rem; padding: 1rem 0 0.5rem;
visibility: visible; visibility: visible;
} }
&.non-collapsible {
& > a {
cursor: default;
}
}
a { a {
font-family: UrbanistSemibold; font-family: UrbanistSemibold;
text-decoration: none; text-decoration: none;

View file

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

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="funnel-header" v-if="imageSrc"> <div class="funnel-header" :class="{ scrolled: isScrolled }" v-if="imageSrc">
<div class="d-flex w-100"> <div class="d-flex w-100">
<progress-bar :page="$route.name" /> <progress-bar :page="$route.name" />
</div> </div>
@ -61,6 +61,7 @@ export default {
sierraChatOpen: false, sierraChatOpen: false,
salesforceChatOpen: false, salesforceChatOpen: false,
isSalesforceTransferInProgress: false, isSalesforceTransferInProgress: false,
isScrolled: false,
}; };
}, },
props: { props: {
@ -108,6 +109,15 @@ export default {
} }
this.globalAlertMessages.push(alertToPush); 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) { webchatClicked(event) {
event.preventDefault(); event.preventDefault();
this.launchWebchat(); this.launchWebchat();
@ -185,6 +195,12 @@ export default {
} }
}; };
window.addEventListener("salesforce-chat-visibility", this._handleSalesforceChatOpened); 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() { beforeUnmount() {
window.removeEventListener("sierra-chat-closed", this._handleSierraChatClosed); window.removeEventListener("sierra-chat-closed", this._handleSierraChatClosed);
@ -195,6 +211,7 @@ export default {
); );
window.removeEventListener("sierra-chat-transfer", this._handleSierraToSalesforceTransfer); window.removeEventListener("sierra-chat-transfer", this._handleSierraToSalesforceTransfer);
window.removeEventListener("salesforce-chat-visibility", this._handleSalesforceChatOpened); window.removeEventListener("salesforce-chat-visibility", this._handleSalesforceChatOpened);
window.removeEventListener("scroll", this._handleScroll);
}, },
}; };
</script> </script>
@ -209,9 +226,18 @@ export default {
padding: 2rem 0.75rem; 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; padding: 0;
&.scrolled {
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
}
.button-container { .button-container {
&.webchat { &.webchat {
margin-right: 0.875rem; margin-right: 0.875rem;

View file

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

View file

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

View file

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

View file

@ -82,3 +82,56 @@ export async function isGlassAvailableForCarId(carId, pageNameToLog) {
return true; 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(); wrapper.vm.$refs.navbar.removeLoader = jest.fn();
const closestShops = { const closestShops = {
data: { data: {
providers: [ inShopProviders: [
{ id: 1, name: "Shop 1" }, { id: 1, name: "Shop 1" },
{ id: 2, name: "Shop 2" }, { id: 2, name: "Shop 2" },
], ],

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@
@back-clicked="backButtonAction" @back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" /> @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> </div>
</div> </div>
@ -268,6 +268,15 @@ export default {
.header-sub-copy { .header-sub-copy {
font-size: 0.875rem; 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 { .select-an-option {
font-family: UrbanistSemibold; font-family: UrbanistSemibold;
@ -277,5 +286,6 @@ export default {
.select-option { .select-option {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif; font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
font-weight: 600; font-weight: 600;
color: $black;
} }
</style> </style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,11 +7,26 @@
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" /> <funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<hr class="my-3" /> <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" /> <textBlock cmsWidgetName="AppointmentDateAndTime" />
<hr class="my-3" /> <hr class="my-3" />
<div class="mobile-location-questions"> <div class="mobile-location-questions">
<div class="address-questions-container"> <div class="address-questions-container">
<textBlock
cmsWidgetName="PleaseProvideAddressWidget"
class="provide-address-header" />
<textBlock
cmsWidgetName="VehicleKeysMessageWidget"
class="keys-message" />
<mobileAddressQuestions <mobileAddressQuestions
ref="addressQuestions" ref="addressQuestions"
v-model="this.addressQuestions" v-model="this.addressQuestions"
@ -56,6 +71,8 @@ import store from "@/store";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper"; import { settleAllPromises } from "@/helpers/layout-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import textLink from "@/ux-components/text-link/text-link";
import analyticsMixin from "@/mixins/analytics-mixin";
export default { export default {
name: "MobileDetails", name: "MobileDetails",
data() { data() {
@ -142,6 +159,17 @@ export default {
this.pageName 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() { backButtonAction() {
this.$router.navigateWithoutSaving( this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK, this.navigationScenarios.CLICKED_BACK,
@ -156,6 +184,9 @@ export default {
AppointmentTimeOfService() { AppointmentTimeOfService() {
return this.getCmsContent("AppointmentDateAndTime", "Text"); return this.getCmsContent("AppointmentDateAndTime", "Text");
}, },
editLink() {
return this.getCmsContent("EditLinkWidget", "Text");
},
}, },
components: { components: {
mobileAddressQuestions, mobileAddressQuestions,
@ -166,6 +197,7 @@ export default {
funnelSubHeader, funnelSubHeader,
Form, Form,
loadingModal, loadingModal,
textLink,
}, },
}; };
</script> </script>
@ -175,5 +207,40 @@ export default {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif; font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
font-weight: 600; font-weight: 600;
color: $black; 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> </style>

View file

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

View file

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

View file

@ -15,6 +15,28 @@
<div v-html="apptWordingText" v-show="!isExpanded"></div> <div v-html="apptWordingText" v-show="!isExpanded"></div>
<div class="review-table px-4"> <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" /> <vehicleReview cmsWidgetName="VehicleReviewWidget" :vehicle="vehicleInfo" />
<hr class="my-0" /> <hr class="my-0" />
@ -48,6 +70,7 @@
<hr class="my-0" /> <hr class="my-0" />
<customerReview cmsWidgetName="CustomerReviewWidget" :customer="customerInfo" /> <customerReview cmsWidgetName="CustomerReviewWidget" :customer="customerInfo" />
-->
</div> </div>
</div> </div>
</template> </template>
@ -56,8 +79,11 @@
import textBlock from "@/digital-components/text-block/text-block"; import textBlock from "@/digital-components/text-block/text-block";
import customerReview from "@/layouts/payment-method/review-dropdown/review-sections/customer-review/customer-review"; 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 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 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 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 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"; import vehicleReview from "@/layouts/payment-method/review-dropdown/review-sections/vehicle-review/vehicle-review";
@ -69,6 +95,7 @@ export default {
props: { props: {
apptWordingText: String, apptWordingText: String,
serviceLocationFullAddress: String, serviceLocationFullAddress: String,
damageInfoHeader: String,
}, },
data() { data() {
return { return {
@ -76,21 +103,22 @@ export default {
}; };
}, },
methods: { methods: {
async loadInitialData() { // the following was used prior to Heritage parity
const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData(); // async loadInitialData() {
// const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData();
const promiseResultMap = [ // const promiseResultMap = [
{ // {
resultKey: "servicePackageData", // resultKey: "servicePackageData",
promise: servicePackageInitialDataPromise, // promise: servicePackageInitialDataPromise,
}, // },
]; // ];
return settleAllPromises(promiseResultMap); // return settleAllPromises(promiseResultMap);
}, // },
initializeComponent(apiResponses) { // initializeComponent(apiResponses) {
this.$refs.servicePackageReview.initializeComponent(apiResponses.servicePackageData); // this.$refs.servicePackageReview.initializeComponent(apiResponses.servicePackageData);
}, // },
toggleIsExpanded() { toggleIsExpanded() {
this.isExpanded = !this.isExpanded; this.isExpanded = !this.isExpanded;
}, },
@ -117,11 +145,9 @@ export default {
}, },
components: { components: {
customerReview, customerReview,
damageReview, optInReview,
scheduleReview, appointmentReview,
serviceLocationReview, parityDamageReview,
servicePackageReview,
vehicleReview,
}, },
}; };
</script> </script>
@ -138,6 +164,10 @@ export default {
overflow: hidden; overflow: hidden;
visibility: hidden; visibility: hidden;
& > :first-child {
margin-top: -0.75rem;
}
:deep(.service-location) { :deep(.service-location) {
.review-block-content { .review-block-content {
text-transform: capitalize; 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: { customer: {
firstName: "First", // firstName: "First",
lastName: "Last", // lastName: "Last",
phoneNumber: "111-111-1111", phoneNumber: "111-111-1111",
emailAddress: "builddigitaltest@safelite.com", emailAddress: "builddigitaltest@safelite.com",
isSmsOptIn: false, // isSmsOptIn: false,
}, },
displayContent: { displayContent: {
fullName: "First Last", // fullName: "First Last",
phoneNumber: "111-111-1111", phoneNumber: "111-111-1111",
emailAddress: "builddigitaltest@safelite.com", 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); expect(wrapper.vm.header).toEqual(testConstants.cms.header.text);
}); });
test("Should display sms text from cms", async () => { // test("Should display sms text from cms", async () => {
// Arrange // // Arrange
let props = generateDefaultProps(); // let props = generateDefaultProps();
const { wrapper } = setupMocks({ // const { wrapper } = setupMocks({
propsData: props, // propsData: props,
}); // });
// Act // // Act
await wrapper.vm.$nextTick(); // await wrapper.vm.$nextTick();
// Assert // // Assert
expect(wrapper.vm.smsOptIn).toEqual(testConstants.cms.sms.text); // expect(wrapper.vm.smsOptIn).toEqual(testConstants.cms.sms.text);
}); // });
test("Should render correct display content", async () => { test("Should render correct display content", async () => {
// Arrange // Arrange
@ -82,10 +82,9 @@ describe("Customer Review Block", () => {
// Assert // Assert
expect(wrapper.vm.displayContent).toEqual([ expect(wrapper.vm.displayContent).toEqual([
testConstants.displayContent.fullName,
testConstants.displayContent.emailAddress, testConstants.displayContent.emailAddress,
testConstants.displayContent.phoneNumber, testConstants.displayContent.phoneNumber,
testConstants.displayContent.smsOptIn, // testConstants.displayContent.smsOptIn,
]); ]);
}); });
}); });
@ -94,11 +93,11 @@ function generateDefaultProps() {
return { return {
cmsWidgetName: "CustomerWidget", cmsWidgetName: "CustomerWidget",
customer: { customer: {
firstName: testConstants.customer.firstName, // firstName: testConstants.customer.firstName,
lastName: testConstants.customer.lastName, // lastName: testConstants.customer.lastName,
phoneNumber: testConstants.customer.phoneNumber, phoneNumber: testConstants.customer.phoneNumber,
emailAddress: testConstants.customer.emailAddress, emailAddress: testConstants.customer.emailAddress,
isSmsOptIn: testConstants.customer.isSmsOptIn, // isSmsOptIn: testConstants.customer.isSmsOptIn,
}, },
}; };
} }

View file

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

View file

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

View file

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

View file

@ -105,6 +105,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.start-over { .start-over {
text-align: center; text-align: center;
margin-bottom: 1.5rem;
a { a {
font-weight: 600; font-weight: 600;
color: $black; color: $black;
@ -114,7 +115,7 @@ export default {
} }
.return-user { .return-user {
.return-user-spacing { .return-user-spacing {
margin-top: 14rem; margin-top: 8rem;
} }
} }
:deep(.menu-modal-container) { :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="container page-container-grouped-styles">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">
<alert <mobileFeeWaiverAlert cmsWidgetName="MobileFeeFreeDropdownWidget" />
ref="alertMobileFeeFree"
class="mb-5"
cmsWidgetName="AlertMobileFeeFreeWidget"
v-if="showMobileFreeAlert"
alertClass="alert-success" />
</div> </div>
</div> </div>
<div class="row appointment-type"> <div class="row appointment-type">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader cmsWidgetName="ScheduleYourServiceWidget" class="mb-5" /> <funnelSubHeader
cmsWidgetName="ScheduleYourServiceWidget"
id="schedule-your-service" />
<appointmentTypeQuestion <appointmentTypeQuestion
v-model="appointmentTypeFromAppointmentTypeQuestion" v-model="appointmentTypeFromAppointmentTypeQuestion"
v-show="isAppointmentTypeDisplayed" v-show="isAppointmentTypeDisplayed"
@ -118,6 +115,15 @@
<div class="row date-picker-wrapper"> <div class="row date-picker-wrapper">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">
<locationAlerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" /> <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 <datePicker
:currentZip="zipCode" :currentZip="zipCode"
:currentProviderNumber="selectedProvider?.providerNumber" :currentProviderNumber="selectedProvider?.providerNumber"
@ -134,14 +140,25 @@
:pricingByDayBasePrice="pricingByDayBasePrice" :pricingByDayBasePrice="pricingByDayBasePrice"
:pricingByDayUpcharge="pricingByDayUpcharge" :pricingByDayUpcharge="pricingByDayUpcharge"
:showPricingByDay="showPricingByDay" :showPricingByDay="showPricingByDay"
:isPricingByDayExperiment="isPricingByDayExperiment" :isPricingByDayExperiment="isPricingByDayExperiment" />
:timeSlotsForSelectedDate="timeSlotsForSelectedDate" <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" :appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee" :premiumAppointmentFee="mobilePremiumAppointmentFee"
:estimatedServiceMinutesMinimum="getServiceMinutesMin"
:estimatedServiceMinutesMaximum="getServiceMinutesMax"
@timeSlotSelected="updateTimeSlot"
:displayWaitList="displayWaitList" :displayWaitList="displayWaitList"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:isSameDay="isSameDay"
:selectedRouteCodeData="selectedRouteCodeData"
@waitListRequested="handleWaitListRequested" /> @waitListRequested="handleWaitListRequested" />
<navbar <navbar
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
@ -160,9 +177,10 @@
import alert from "@/ux-components/alert/alert"; import alert from "@/ux-components/alert/alert";
import serviceZipModalQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-modal-question"; 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 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 shopListButton from "@/layouts/service-location/shop-question/shop-list-button/shop-list-button";
import shopLocation from "@/layouts/schedule/shop-location/shop-location"; 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 funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar"; 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 locationAlerts from "@/layouts/schedule/location-alerts/location-alerts";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal"; import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import textBlock from "@/digital-components/text-block/text-block"; import textBlock from "@/digital-components/text-block/text-block";
import mobileFeeWaiverAlert from "./mobile-fee-waiver-alert/mobile-fee-waiver-alert.vue";
// Supporting files // Supporting files
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
@ -626,6 +645,9 @@ export default {
return this.isGlassServiceableMobile; return this.isGlassServiceableMobile;
} }
}, },
showTimeSlotQuestion() {
return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion;
},
isMobileStaticRecalibrationApplicable() { isMobileStaticRecalibrationApplicable() {
return ( return (
this.displayMSR && this.displayMSR &&
@ -785,6 +807,21 @@ export default {
} }
return []; 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() { appointmentTypeStrings() {
return 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 // 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); return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
}, },
getServiceMinutesMin() { estimatedServiceMinutesMinimum() {
return this.isMobileSelected return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMinimum ? this.selectableDatesMobile.estimatedServiceMinutesMinimum
: this.selectableDatesInshop.estimatedServiceMinutesMinimum; : this.selectableDatesInshop.estimatedServiceMinutesMinimum;
}, },
getServiceMinutesMax() { estimatedServiceMinutesMaximum() {
return this.isMobileSelected return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMaximum ? this.selectableDatesMobile.estimatedServiceMinutesMaximum
: this.selectableDatesInshop.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: { methods: {
splitCopyOnCMSPlaceHolder, splitCopyOnCMSPlaceHolder,
@ -1238,7 +1287,9 @@ export default {
return store.getters.lineItems.supportingItems; return store.getters.lineItems.supportingItems;
}, },
updateFooterButtonText(timeSlotInfo) { updateFooterButtonText(timeSlotInfo) {
let navbarButtonText; let navbarButtonText = "Continue";
// Archiving the following in case we revert back from Heritage parity
/*
if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) { if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) {
navbarButtonText = "Continue"; navbarButtonText = "Continue";
} else { } else {
@ -1273,6 +1324,7 @@ export default {
)}`; )}`;
} }
} }
*/
this.$refs.navbar.updateButtonText(navbarButtonText); this.$refs.navbar.updateButtonText(navbarButtonText);
}, },
convertSelectedDateToShortMonthAndDay(selectedDate) { convertSelectedDateToShortMonthAndDay(selectedDate) {
@ -1555,22 +1607,46 @@ export default {
} }
}, },
updateTimeSlot(timeSlotObj) { updateTimeSlot(timeSlotObj) {
if (this.preSelectedDate && !timeSlotObj?.timeSlot?.date) return; if (!timeSlotObj?.routeCode) {
this.selectedTimeSlotInfo = timeSlotObj; 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 ( if (
this.appointmentType !== AppointmentTypeStrings.MOBILE && this.appointmentType !== AppointmentTypeStrings.MOBILE &&
this.appointmentType !== null this.appointmentType !== null
) { ) {
// update apptType based on routeCode to determine if it should be dropoff or inshop // update apptType based on routeCode to determine if it should be dropoff or inshop
debugLog(`Updating appointment type based on route code.`); debugLog(`Updating appointment type based on route code.`);
debugLog(`Route code =`, timeSlotObj.timeSlot.routeCode); debugLog(`Route code =`, timeSlotObj.routeCode);
debugLog( debugLog(`Parsed type = `, this.getInShopOrDropOffApptType(timeSlotObj.routeCode));
`Parsed type = `, this.appointmentType = this.getInShopOrDropOffApptType(timeSlotObj.routeCode);
this.getInShopOrDropOffApptType(timeSlotObj.timeSlot.routeCode)
);
this.appointmentType = this.getInShopOrDropOffApptType(
timeSlotObj.timeSlot.routeCode
);
} }
}, },
getInShopOrDropOffApptType(routeCode) { getInShopOrDropOffApptType(routeCode) {
@ -1618,7 +1694,7 @@ export default {
}, },
}; };
} else if ( } else if (
!this.selectedProvider?.address?.streetAddress && (!this.selectedProvider?.address?.streetAddress || this.isVehicleHeavyTruck) &&
this.shopProviderData?.shopProviders?.length this.shopProviderData?.shopProviders?.length
) { ) {
this.selectedProvider = this.shopProviderData.shopProviders[0]; this.selectedProvider = this.shopProviderData.shopProviders[0];
@ -1737,12 +1813,15 @@ export default {
datePicker, datePicker,
locationAlerts, locationAlerts,
textBlock, textBlock,
timeSlotQuestion,
alert, alert,
serviceZipModalQuestion, serviceZipModalQuestion,
appointmentTypeQuestion, appointmentTypeQuestion,
contentGroupModal, contentGroupModal,
durationTextBlock,
shopLocation, shopLocation,
mobileFeeWaiverAlert,
}, },
}; };
</script> </script>
@ -1777,7 +1856,7 @@ export default {
font-weight: 600; font-weight: 600;
} }
:deep(.funnel-sub-header:not(.mb-5)) { :deep(.funnel-sub-header:not(#schedule-your-service)) {
h5.dark-header { h5.dark-header {
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
@ -1791,7 +1870,7 @@ export default {
text-underline-offset: 4px; text-underline-offset: 4px;
} }
.text-block.duration-text-block b { :deep(.text-block.duration-text-block b) {
font-family: UrbanistSemibold; font-family: UrbanistSemibold;
} }
:deep(.alert-warning.widget-name-AlertRecalNoMobileWidget .alert-heading), :deep(.alert-warning.widget-name-AlertRecalNoMobileWidget .alert-heading),

View file

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

View file

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

View file

@ -12,6 +12,7 @@
</transition> </transition>
<modal <modal
ref="shopQuestionModal" ref="shopQuestionModal"
class="shop-question-popup"
:headerText="modalHeaderText" :headerText="modalHeaderText"
:onModalOpenedCallback="updateSelectedAnswer" :onModalOpenedCallback="updateSelectedAnswer"
:onModalClosedCallback="resetZipCodeData" :onModalClosedCallback="resetZipCodeData"
@ -290,7 +291,7 @@ export default {
this.$store.getters.order.vehicle.carId, this.$store.getters.order.vehicle.carId,
"service-location" "service-location"
); );
if (!closestShops || closestShops.providers?.length === 0) { if (!closestShops || closestShops.inShopProviders?.length === 0) {
this.isLoading = false; this.isLoading = false;
this.displayNoServiceAlert = true; this.displayNoServiceAlert = true;
return null; return null;
@ -392,6 +393,25 @@ export default {
.modal.modal-component .modal-dialog .modal-content .modal-body .textbox-question { .modal.modal-component .modal-dialog .modal-content .modal-body .textbox-question {
padding: 0; 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 { .shop-question-zipcode {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,11 +32,23 @@ parameters:
- name: imageTag - name: imageTag
type: string type: string
default: '$(Build.BuildId)' default: '$(Build.BuildId)'
- name: dockerBuildArgs
type: string
default: ''
- name: envFileName - name: envFileName
type: string type: string
default: '.env.ci' 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: jobs:
- job: playwright_tests - job: playwright_tests
continueOnError: true continueOnError: true
@ -103,6 +115,14 @@ jobs:
echo "Docker cleanup completed!" echo "Docker cleanup completed!"
displayName: "Docker Cleanup" 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 - task: Docker@2
displayName: 'Build Docker Image' displayName: 'Build Docker Image'
inputs: inputs:
@ -110,20 +130,23 @@ jobs:
dockerfile: ${{ parameters.dockerFileName }} dockerfile: ${{ parameters.dockerFileName }}
repository: ${{ parameters.dockerImageName }} repository: ${{ parameters.dockerImageName }}
tags: ${{ parameters.imageTag }} tags: ${{ parameters.imageTag }}
arguments: '--no-cache --pull' arguments: |
--no-cache --pull ${{ parameters.dockerBuildArgs }}
- script: | - script: |
printenv > "${{ parameters.envFileName }}" # Determine JIRA card number and filters
JIRA_CARD_NUMBER="" JIRA_CARD_NUMBER=""
IS_REGRESSION="${{ parameters.isRegression }}" IS_REGRESSION="${{ parameters.isRegression }}"
echo "isRegression: $IS_REGRESSION"; echo "isRegression: $IS_REGRESSION"
if [[ "${{ parameters.isRegression }}" == "False" ]]; then if [[ "${{ parameters.isRegression }}" == "False" ]]; then
branch_name=$(System.PullRequest.SourceBranch) branch_name="$(System.PullRequest.SourceBranch)"
echo "Retrieved branch name: '$branch_name'" echo "Retrieved branch name: '$branch_name'"
JIRA_CARD_NUMBER="${branch_name##*/}" JIRA_CARD_NUMBER="${branch_name##*/}"
echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'" echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'"
fi fi
# Build test filter
FILTER="" FILTER=""
if [[ -n "${{ parameters.filterTags }}" && -n "$JIRA_CARD_NUMBER" ]]; then if [[ -n "${{ parameters.filterTags }}" && -n "$JIRA_CARD_NUMBER" ]]; then
echo "Filtering by filterTags and Jira Card Number..." echo "Filtering by filterTags and Jira Card Number..."
@ -140,57 +163,213 @@ jobs:
fi fi
echo "Filter value: $FILTER" echo "Filter value: $FILTER"
TARGET_URL=${{ parameters.targetUrl }} TARGET_URL="${{ parameters.targetUrl }}"
echo "BASE_URL value: $BASE_URL" 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 if [[ "$TARGET_URL" == *localhost* ]]; then
echo 'Npx version:' echo "Localhost URL detected, extracting port..."
npx --version port=$(echo "$TARGET_URL" | sed -E 's/.*:([0-9]+).*/\1/')
port="$TARGET_URL" echo "Port extracted: $port"
echo "URL: '$port'"
echo "URL '$port' contains 'localhost'. Extracting port... " # Create readiness check URL based on application type
port=$(echo "$port" | sed -E 's/.*:([0-9]+).*/\1/') if [[ "$APPLICATION_TYPE" == "dotnet" ]]; then
echo "Port is '$port'" # For .NET APIs, append the readiness endpoint to the target URL
TARGET_URL="$TARGET_URL" READINESS_URL="${TARGET_URL}${{ parameters.readinessEndpoint }}"
JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" echo "Using .NET readiness check URL: $READINESS_URL"
FILTER="$FILTER" else
PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}" # For Vue apps, use the target URL as-is
SERVE_PATH="${{ parameters.npmServePath }}" READINESS_URL="$TARGET_URL"
SHARD_NUMBER=$(shardNumber) echo "Using Vue app URL for readiness check: $READINESS_URL"
TOTAL_SHARDS=${{ parameters.totalShards }} fi
echo "Starting tests for shard $SHARD_NUMBER of $TOTAL_SHARDS..."
container_id=$(docker create \ container_id=$(docker create \
--ipc=host \ --ipc=host \
--env CI=true \ --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 }}" \ --env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \ ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
npx concurrently -k -n 'server,playwright' \ bash -c '
"npm --prefix $SERVE_PATH run serve -- --port=$port" \ echo "=== Starting Application Server ==="
"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" \ 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 else
export TARGET_URL="$TARGET_URL" # Remote URL testing
export JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" echo "Remote URL testing mode"
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 }}"
container_id=$(docker create \ container_id=$(docker create \
--ipc=host \ --ipc=host \
--env CI=true \ --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 }}" \ --env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \ ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
bash -c " bash -c "
set -a echo '=== Running Tests Against Remote URL ==='
source $ENV_FILE echo 'Target URL: $TARGET_URL'
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 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 fi
# Start container and stream logs # Start container and stream logs
echo "Starting tests for shard $(shardNumber)..." echo "Starting tests for shard $(shardNumber)..."
docker start -a $container_id docker start -a $container_id
@ -206,12 +385,6 @@ jobs:
# Remove container # Remove container
echo "Cleaning up container..." echo "Cleaning up container..."
docker rm $container_id 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)' displayName: 'Run Playwright Tests - Shard $(shardNumber)'
env: ${{ parameters.secrets }} env: ${{ parameters.secrets }}
@ -227,6 +400,7 @@ jobs:
docker rmi ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} -f docker rmi ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} -f
displayName: 'Cleanup Docker Image' displayName: 'Cleanup Docker Image'
condition: always() condition: always()
- job: download_and_merge_reports - job: download_and_merge_reports
dependsOn: playwright_tests dependsOn: playwright_tests
timeoutInMinutes: 8 timeoutInMinutes: 8
@ -238,6 +412,10 @@ jobs:
- task: DownloadPipelineArtifact@2 - task: DownloadPipelineArtifact@2
inputs: inputs:
targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports' targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports'
- bash: |
printenv > "${{ parameters.envFileName }}"
env: ${{ parameters.secrets }}
displayName: "Make Azure Pipeline Variables Available to Docker"
- task: Docker@2 - task: Docker@2
displayName: 'Build Docker Image' displayName: 'Build Docker Image'
inputs: inputs:
@ -245,16 +423,15 @@ jobs:
dockerfile: ${{ parameters.dockerFileName }} dockerfile: ${{ parameters.dockerFileName }}
repository: ${{ parameters.dockerImageName }} repository: ${{ parameters.dockerImageName }}
tags: ${{ parameters.imageTag }} tags: ${{ parameters.imageTag }}
arguments: '--no-cache --pull' arguments: |
--no-cache --pull ${{ parameters.dockerBuildArgs }}
- bash: | - bash: |
printenv > "${{ parameters.envFileName }}"
branch_name=$(System.PullRequest.SourceBranch) branch_name=$(System.PullRequest.SourceBranch)
echo "Retrieved branch name: '$branch_name'" echo "Retrieved branch name: '$branch_name'"
JIRA_CARD_NUMBER="${branch_name##*/}" JIRA_CARD_NUMBER="${branch_name##*/}"
echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'" echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'"
export PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}" export PLAYWRIGHT_PATH="${{ parameters.playwrightTestsPath }}"
export JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" export JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER"
export ENV_FILE="${{ parameters.envFileName }}" export ENV_FILE="${{ parameters.envFileName }}"
container_id=$(docker create \ container_id=$(docker create \
@ -263,14 +440,12 @@ jobs:
--env-file "${{ parameters.envFileName }}" \ --env-file "${{ parameters.envFileName }}" \
${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \ ${{ parameters.dockerImageName }}:${{ parameters.imageTag }} \
bash -c " bash -c "
set -a
source $ENV_FILE
set +a
echo 'Moving Playwright reports out of subfolders...' echo 'Moving Playwright reports out of subfolders...'
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; 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 'Value of JIRA_CARD_NUMBER in bash -c command: $JIRA_CARD_NUMBER'
echo 'Merging reports...' 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
" "
) )