From 4d07bff4b8a4a7c309e79cfc1c3096cd61c17148 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Thu, 2 Oct 2025 12:35:43 -0400 Subject: [PATCH 01/94] Update font size & coloration --- .../save-progress-popup-question.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue index 0e1b07b8e..19b1b3629 100644 --- a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue @@ -250,7 +250,8 @@ export default { } } .modal-disclaimer { - font-size: 0.75rem; + font-size: 0.8125rem; + color: #525656; text-align: left; order: 3; margin: 0; From ac64aa0ed7b29e82e7bfe89bb8f1b471c730730e Mon Sep 17 00:00:00 2001 From: Chris Redelinghuys Date: Fri, 3 Oct 2025 08:54:45 -0400 Subject: [PATCH 02/94] CASH-1072: Offer PIA for $0 deductible with VAPS. Only show afterpay breakout with PIA --- src/layouts/payment-method/payment-method.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index f1bb25810..3b213891d 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -805,7 +805,10 @@ export default { const piaInsurance = this.getSettingValue(experimentSettings.PIA_INSURANCE); if (this.isInsurance) { - return piaInsurance === "true" && this.currentDeductible > 0; + return ( + piaInsurance === "true" && + (this.currentDeductible > 0 || +this.totalAmountDue > 0) + ); } else { return piaExperience === "PIA Optional" || piaExperience === "PIA Required"; } @@ -837,6 +840,7 @@ export default { return ( this.showInsuranceCoverageAs !== coverageStatus.PENDING && !this.isRecalPriceRemove && + this.isPiaEnabled && this.hasSettingEqualTo(experimentSettings.DISPLAY_AFTERPAY_BREAKOUT_DISPLAY, "true") ); }, From 6587f78dced3e353608f7613b75bcffe4cfa8191 Mon Sep 17 00:00:00 2001 From: Johnny shultz Date: Fri, 3 Oct 2025 13:41:04 -0400 Subject: [PATCH 03/94] CASH-1514 CASH-1514 changed cms to arriving between, and put text into span to not get overridden by text-transform: capitalize css property --- src/layouts/payment-method/payment-method.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index f1bb25810..b5a0b159b 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -699,10 +699,12 @@ export default { : this.ServiceLocationFullAddress.zipCode; }, AppointmentWordingText() { - return this.getCmsContent("ApptDetailsSnapshotWidget", "BodyText")?.replaceAll( + const text = this.getCmsContent("ApptDetailsSnapshotWidget", "BodyText")?.replaceAll( "{custom:ADDRESS}", this.ServiceLocationFullAddressText ); + + return text?.replace(/\barriving between\b/gi, "arriving between"); }, isRecalibrationOnOrder() { const order = baseMixin.methods.hasSubmittedOrder() From 8ab2a25fe4121acacc61d4aab61b352b1ba2de35 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Mon, 6 Oct 2025 15:38:18 -0400 Subject: [PATCH 04/94] CASH-1637 CASH-1637 - added 2 new analytics events to capture funnel data --- src/constants/analytics.js | 3 ++ src/mixins/analytics-mixin.js | 63 ++++++++++++++++++++++++++++++++ src/router/methods/after-each.js | 3 ++ src/store/index.js | 6 +-- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/constants/analytics.js b/src/constants/analytics.js index fe43be06d..77bb78a3e 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -7,6 +7,8 @@ const analyticsPageEvents = { const GaEvents = { GENERIC_EVENT: "event", PAGE_VIEW_EVENT: "logPageview", + FMG_DATA_EVENT_1: "logEvent1", + FMG_DATA_EVENT_2: "logEvent2", }; const GaCategories = { @@ -14,6 +16,7 @@ const GaCategories = { EVOX: "Evox", APPOINTMENT: "Appointment", SERVICE_LOCATION: "service-location", + FMG_SESSION_DATA: "fmg_session_data", }; const GaActions = { diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index a3322a0a8..fe61ddeef 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -31,6 +31,9 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { routeData } from "@/router/constants/routes"; import { getQuerystringParameter } from "@/helpers/querystring-helper"; import { Variables } from "../constants/analytics"; +import { containsRecalParts, getRecalPartNumbers } from "@/helpers/recal-helper"; +import { getAmountDue, getSubTotal, getSalesTax } from "@/helpers/pricing-helper.js"; +import { partTypeStrings } from "@/constants/part-type-strings"; import router from "@/router"; export default { @@ -197,6 +200,66 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, + async pushFmgDataToGA() { + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + const order = hasSubmittedOrder ? submittedOrder : store.getters.order; + + //event 1 + var label = {}; + label.carId = order?.vehicle?.carId; + label.hasVin = order?.vehicle ? true : false; + label.cashOrInsuranceAccountType = order?.payment?.isInsurance ? "Insurance" : "Cash"; + label.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; + label.productType = order?.damage?.glassToReplace; + label.recalRequired = containsRecalParts(order?.lineItems); + label.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); + label.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode + ? order?.serviceLocation?.provider?.address?.zipCode + : order?.serviceLocation?.zipCode; + label.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu + ? order?.serviceLocation?.provider?.address?.zipCodeCtu + : order?.serviceLocation?.zipCodeCtu; + label.subTotalPrice = getSubTotal(order?.lineItems); + label.totalPrice = getAmountDue(order?.lineItems); + + await this.pushEventToGA( + GaCategories.FMG_SESSION_DATA, + GaEvents.FMG_DATA_EVENT_1, + JSON.stringify(label), + true, + null, + null + ); + + //event 2 + const isEarlyBird = order?.lineItems?.supportingItems?.find( + (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD + ); + + label = {}; + label.serviceType = order?.serviceLocation?.appointmentType; + label.promoCodes = order?.lineItems?.promos; + label.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; + label.paymentMethod = order?.payment?.piaType; + label.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; + label.isEarlyBird = isEarlyBird; + label.insuranceCo = order?.policy?.insuranceCompanyName; + label.deductible = order?.policy?.currentDeductible; + label.isVerified = order?.payment?.insuranceCoverage?.isVerified; + label.isNoComp = order?.policy?.isNoComp; + label.isItac = order?.policy?.isNoComp; + + await this.pushEventToGA( + GaCategories.FMG_SESSION_DATA, + GaEvents.FMG_DATA_EVENT_2, + JSON.stringify(label), + true, + null, + null + ); + }, + pushOrderToDataLayer() { // helper check for if an object is defined (but maybe falsey) const isDefined = (x) => x !== null && x !== undefined; diff --git a/src/router/methods/after-each.js b/src/router/methods/after-each.js index 27cecffde..7be0e5ce0 100644 --- a/src/router/methods/after-each.js +++ b/src/router/methods/after-each.js @@ -12,4 +12,7 @@ export async function afterEach(to, from) { // Push current order status to Data Layer analyticsMixin.methods.pushOrderToDataLayer(); + + // Push session data GA events for Analytics + analyticsMixin.methods.pushFmgDataToGA(); } diff --git a/src/store/index.js b/src/store/index.js index ed84cc636..1fa9b4f14 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2960,9 +2960,9 @@ export const actions = { ) { const arrayOfLineItems = [ ...(pricedLineItems.glassParts ?? []), - ...pricedLineItems.promos, - ...pricedLineItems.supportingItems, - ...pricedLineItems.vaps, + ...(pricedLineItems.promos ?? []), + ...(pricedLineItems.supportingItems ?? []), + ...(pricedLineItems.vaps ?? []), ]; const flattenedLineItemsWithChildParts = getFlattenedArrayOfLineItemsWithChildParts(arrayOfLineItems); From 2ed7af2c7ad0eac5e97f625d8107e2838ff498eb Mon Sep 17 00:00:00 2001 From: CarlNation Date: Mon, 6 Oct 2025 15:44:51 -0400 Subject: [PATCH 05/94] CASH-1637 CASH-1637 bug cleanup --- src/mixins/analytics-mixin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index fe61ddeef..281515c3a 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -208,7 +208,7 @@ export default { //event 1 var label = {}; label.carId = order?.vehicle?.carId; - label.hasVin = order?.vehicle ? true : false; + label.hasVin = order?.vehicle?.vin ? true : false; label.cashOrInsuranceAccountType = order?.payment?.isInsurance ? "Insurance" : "Cash"; label.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; label.productType = order?.damage?.glassToReplace; @@ -248,7 +248,7 @@ export default { label.deductible = order?.policy?.currentDeductible; label.isVerified = order?.payment?.insuranceCoverage?.isVerified; label.isNoComp = order?.policy?.isNoComp; - label.isItac = order?.policy?.isNoComp; + label.isItac = order?.policy?.isItac; await this.pushEventToGA( GaCategories.FMG_SESSION_DATA, From 6f5344c58331e14027e4b28a32a1953d3e4e6724 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Mon, 6 Oct 2025 15:50:28 -0400 Subject: [PATCH 06/94] CASH-1637 CASH-1637 correct earlybird --- src/mixins/analytics-mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 281515c3a..74b74283b 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -243,7 +243,7 @@ export default { label.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; label.paymentMethod = order?.payment?.piaType; label.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; - label.isEarlyBird = isEarlyBird; + label.isEarlyBird = isEarlyBird ? true : false; label.insuranceCo = order?.policy?.insuranceCompanyName; label.deductible = order?.policy?.currentDeductible; label.isVerified = order?.payment?.insuranceCoverage?.isVerified; From 85cad5a6ce041657a6340ac4240aff58ca076275 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Tue, 7 Oct 2025 14:57:12 -0400 Subject: [PATCH 07/94] CASH-1637 CASH-1637 correcting fields iteratively since dev environment is unstable --- src/mixins/analytics-mixin.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 74b74283b..50115bbf9 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -221,7 +221,7 @@ export default { ? order?.serviceLocation?.provider?.address?.zipCodeCtu : order?.serviceLocation?.zipCodeCtu; label.subTotalPrice = getSubTotal(order?.lineItems); - label.totalPrice = getAmountDue(order?.lineItems); + label.totalPrice = getAmountDue(order?.lineItems, true); await this.pushEventToGA( GaCategories.FMG_SESSION_DATA, @@ -237,9 +237,14 @@ export default { (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD ); + const promoCodes = order?.lineItems?.promos + ?.map((item) => item.promoCode) + .filter((code) => code) + .join(", "); + label = {}; label.serviceType = order?.serviceLocation?.appointmentType; - label.promoCodes = order?.lineItems?.promos; + label.promoCodes = promoCodes; label.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; label.paymentMethod = order?.payment?.piaType; label.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; From 58e2ec0addacd163c7467c8516e6b9e8abec8e44 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Tue, 7 Oct 2025 15:06:41 -0400 Subject: [PATCH 08/94] CASH-1637 CASH-1637 check for array to prevent exception when not --- src/mixins/analytics-mixin.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 50115bbf9..3ff326e1c 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -237,10 +237,12 @@ export default { (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD ); - const promoCodes = order?.lineItems?.promos - ?.map((item) => item.promoCode) - .filter((code) => code) - .join(", "); + const promoCodes = Array.isArray(order?.lineItems?.promos) + ? order?.lineItems?.promos + ?.map((item) => item.promoCode) + .filter((code) => code) + .join(", ") + : ""; label = {}; label.serviceType = order?.serviceLocation?.appointmentType; From 7f28449d1f479c98a078ecceaed2a196ea40d9c8 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Tue, 7 Oct 2025 15:10:18 -0400 Subject: [PATCH 09/94] CASH-1637 CASH-1637 optional chaining ? not necessary since checking for an array already --- src/mixins/analytics-mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 3ff326e1c..f5a7d10e0 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -239,7 +239,7 @@ export default { const promoCodes = Array.isArray(order?.lineItems?.promos) ? order?.lineItems?.promos - ?.map((item) => item.promoCode) + .map((item) => item.promoCode) .filter((code) => code) .join(", ") : ""; From ceca1fd1c79ef76c7fde371c13f2637226e0dea1 Mon Sep 17 00:00:00 2001 From: Chris Redelinghuys Date: Thu, 9 Oct 2025 14:49:08 -0400 Subject: [PATCH 10/94] CASH-1072: Revert offering PIA on $0 deductible with VAPS --- src/layouts/payment-method/payment-method.vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 04ecc27f4..f9d765c32 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -807,10 +807,7 @@ export default { const piaInsurance = this.getSettingValue(experimentSettings.PIA_INSURANCE); if (this.isInsurance) { - return ( - piaInsurance === "true" && - (this.currentDeductible > 0 || +this.totalAmountDue > 0) - ); + return piaInsurance === "true" && this.currentDeductible > 0; } else { return piaExperience === "PIA Optional" || piaExperience === "PIA Required"; } From 1e19d6abe637276e5862d5d016384304409c089c Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Thu, 9 Oct 2025 14:55:08 -0400 Subject: [PATCH 11/94] CASH-1640 | Remove pricing and taxing from payment --- src/layouts/payment/payment.spec.js | 1 - src/layouts/payment/payment.vue | 114 +--------------------------- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/src/layouts/payment/payment.spec.js b/src/layouts/payment/payment.spec.js index ad98814d2..1b36d0644 100644 --- a/src/layouts/payment/payment.spec.js +++ b/src/layouts/payment/payment.spec.js @@ -319,7 +319,6 @@ describe("payment.vue", () => { ); // Assert - expect(vmMock.availableVaps).not.toBeUndefined(); expect(vmMock.lineItems).not.toBeUndefined(); expect(vmMock.setCmsContent).toBeCalled(); diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index 992795321..b8e481174 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -42,7 +42,7 @@ { vm.setCmsContent(resultMap.cmsContent); - vm.availableVaps = taxedVaps; - vm.lineItems = lineItems; + vm.lineItems = deepClone(store.getters.order.lineItems); vm.$nextTick(() => { if (vm.$refs.cart) { From a5d3cdb197814602aef25379538865f3599e6c1b Mon Sep 17 00:00:00 2001 From: Chris Redelinghuys Date: Fri, 10 Oct 2025 14:45:59 -0400 Subject: [PATCH 12/94] CASH-1550: Afterpaybreakout display fix for glass repair --- src/layouts/payment-method/payment-method.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index f9d765c32..a3e9a2745 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -832,7 +832,7 @@ export default { isRecalPriceRemove() { return ( this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() === - "true" + "true" && this.isRecalibrationOnOrder ); }, isAfterpayBreakoutDisplay() { From d88ea8a59908d0b076ad16d6eb6c945a30864707 Mon Sep 17 00:00:00 2001 From: Chris Redelinghuys Date: Mon, 13 Oct 2025 09:56:48 -0400 Subject: [PATCH 13/94] CASH-1550: IGQ is cash only --- src/layouts/payment-method/payment-method.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index a3e9a2745..691c8ee5c 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -831,8 +831,10 @@ export default { }, isRecalPriceRemove() { return ( + !this.isInsurance && this.getSettingValue(experimentSettings.RECAL_PRICE_REMOVE)?.toLowerCase() === - "true" && this.isRecalibrationOnOrder + "true" && + this.isRecalibrationOnOrder ); }, isAfterpayBreakoutDisplay() { From 1d603abbe26c67e793f17fc8b578cdc22325ebb8 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Tue, 14 Oct 2025 08:13:05 -0400 Subject: [PATCH 14/94] CASH-1640 | Fix promo staying on order before tax call is made --- src/layouts/payment-method/payment-method.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 1042e30fe..f96e0d5cf 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -35,7 +35,7 @@ :isNoComp="isNoComp" :isExpandedOnLoad="false" :isMSRFeeApplicable="isMSRFeeApplicable" - @itemRemoved="reTaxItemsOnOrder" /> + @itemRemoved="evaluatePromosAndTaxItemsOnOrder" /> 0; + const hasActivePromos = this.lineItems.promos.length > 0; + if (hasInactivePromos || hasActivePromos) { + await this.revalidatePromos(); + } this.lineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { From 2730b0650ee7fd41ff7fcf86860bf96a7c4a5c27 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Tue, 14 Oct 2025 08:37:10 -0400 Subject: [PATCH 15/94] CASH-1426 CASH-1426 use a part number (DISCOUNT) that exists on mainframe IPF file so that work order submission does not fail in ESL. --- src/helpers/promotions-helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/promotions-helper.js b/src/helpers/promotions-helper.js index 2921fbfc8..e386660ac 100644 --- a/src/helpers/promotions-helper.js +++ b/src/helpers/promotions-helper.js @@ -6,7 +6,7 @@ import baseMixin from "@/mixins/base-mixin.js"; export const promoPartNumberStrings = { WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT", - RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN REPEL", + RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN", }; From b195d308234b3c223286013242a1754fb886e7ee Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 14 Oct 2025 13:08:57 -0400 Subject: [PATCH 16/94] CASH-1677 add emit to force selection change if only one --- .../schedule/time-slot-question/time-slot-question.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/layouts/schedule/time-slot-question/time-slot-question.vue b/src/layouts/schedule/time-slot-question/time-slot-question.vue index 18f3b77d2..1b8a2cb2e 100644 --- a/src/layouts/schedule/time-slot-question/time-slot-question.vue +++ b/src/layouts/schedule/time-slot-question/time-slot-question.vue @@ -509,8 +509,12 @@ export default { // this.availableTimeSlots only returns mobile/inshop slots so we know // the only available slot is not dropOFf this.selectedAnswerForTimeSlots = this.availableTimeSlots[0].value; + // emit up to parent that the time slot has been selected (when mobile or inshop) + this.timeSlotSelectionChanged(this.availableTimeSlots[0].value); } else { this.selectedAnswerForDropOffOrInshop = this.answersForDropOffQuestion[0].value; + // emit up to parent that the time slot has been selected (when only dropoff) + this.dropOffSelectionChanged(this.answersForDropOffQuestion[0].value); } } }, From a6df3a6ff6b95070e583d878227d40d54e25ec22 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Wed, 15 Oct 2025 13:23:22 -0400 Subject: [PATCH 17/94] CASH-1637 CASH-1637 call fmg session data logging endpoint --- src/constants/analytics.js | 3 - src/constants/endpoints.js | 4 ++ src/constants/store-actions.js | 1 + src/mixins/analytics-mixin.js | 117 ++++++++++++++++++------------- src/router/methods/after-each.js | 6 +- src/store/index.js | 101 ++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 54 deletions(-) diff --git a/src/constants/analytics.js b/src/constants/analytics.js index 77bb78a3e..fe43be06d 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -7,8 +7,6 @@ const analyticsPageEvents = { const GaEvents = { GENERIC_EVENT: "event", PAGE_VIEW_EVENT: "logPageview", - FMG_DATA_EVENT_1: "logEvent1", - FMG_DATA_EVENT_2: "logEvent2", }; const GaCategories = { @@ -16,7 +14,6 @@ const GaCategories = { EVOX: "Evox", APPOINTMENT: "Appointment", SERVICE_LOCATION: "service-location", - FMG_SESSION_DATA: "fmg_session_data", }; const GaActions = { diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js index eb55495f5..17a8f9a08 100644 --- a/src/constants/endpoints.js +++ b/src/constants/endpoints.js @@ -180,6 +180,10 @@ const endpoints = { url: "/analytics/api/v1/analytics/digitalconsumer-log", method: "POST", }, + LogFmgSessionData: { + url: "/analytics/api/v1/analytics/digitalconsumer-session-logging", + method: "POST", + }, GetExperimentsByUser: { url: "/analytics/api/v1/analytics/get-experiments", method: "GET", diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 209266131..513891ea1 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -64,6 +64,7 @@ const storeActions = { INITIALIZE_SESSION: "initializeSession", LOG_PART_QUESTIONS: "logPartQuestions", LOG_DIGITALCONSUMER: "logDigitalConsumer", + LOG_FMG_SESSION_DATA: "logFmgSessionData", // DEPENDENCY MUTATIONS RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleStateAndDependencies", diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index f5a7d10e0..fbaccb113 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -200,39 +200,22 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, - async pushFmgDataToGA() { + // This sends session data to the session logging endpoint on the analytics service. + // From there, it uses aws kinesis data stream, DigitalConsumer-Session-Data-Stream, + // and aws FireHose stream, DigitalConsumer-Session-Firehose, to push data to an + // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. + // This bucket data is then picked up by snowflake for analytics use. + async pushFmgSessionData() { const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); const submittedOrder = baseMixin.methods.getSubmittedOrder(); const order = hasSubmittedOrder ? submittedOrder : store.getters.order; - //event 1 - var label = {}; - label.carId = order?.vehicle?.carId; - label.hasVin = order?.vehicle?.vin ? true : false; - label.cashOrInsuranceAccountType = order?.payment?.isInsurance ? "Insurance" : "Cash"; - label.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; - label.productType = order?.damage?.glassToReplace; - label.recalRequired = containsRecalParts(order?.lineItems); - label.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); - label.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode - ? order?.serviceLocation?.provider?.address?.zipCode - : order?.serviceLocation?.zipCode; - label.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu - ? order?.serviceLocation?.provider?.address?.zipCodeCtu - : order?.serviceLocation?.zipCodeCtu; - label.subTotalPrice = getSubTotal(order?.lineItems); - label.totalPrice = getAmountDue(order?.lineItems, true); + const hasSubmittedApplicationUser = baseMixin.methods.hasSubmittedApplicationUser(); + const submittedApplicationUser = baseMixin.methods.getSubmittedApplicationUser(); + const applicationUser = hasSubmittedApplicationUser + ? submittedApplicationUser + : store.getters.applicationUser; - await this.pushEventToGA( - GaCategories.FMG_SESSION_DATA, - GaEvents.FMG_DATA_EVENT_1, - JSON.stringify(label), - true, - null, - null - ); - - //event 2 const isEarlyBird = order?.lineItems?.supportingItems?.find( (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD ); @@ -242,28 +225,66 @@ export default { .map((item) => item.promoCode) .filter((code) => code) .join(", ") - : ""; + : null; - label = {}; - label.serviceType = order?.serviceLocation?.appointmentType; - label.promoCodes = promoCodes; - label.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; - label.paymentMethod = order?.payment?.piaType; - label.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; - label.isEarlyBird = isEarlyBird ? true : false; - label.insuranceCo = order?.policy?.insuranceCompanyName; - label.deductible = order?.policy?.currentDeductible; - label.isVerified = order?.payment?.insuranceCoverage?.isVerified; - label.isNoComp = order?.policy?.isNoComp; - label.isItac = order?.policy?.isItac; + const glassProducts = order?.damage?.glassToReplace?.map( + (part) => `${part.glassLocation}-${part.glassName}` + ); - await this.pushEventToGA( - GaCategories.FMG_SESSION_DATA, - GaEvents.FMG_DATA_EVENT_2, - JSON.stringify(label), - true, - null, - null + var sessionData = {}; + sessionData.currentPage = getPageNameFromRouter(); + sessionData.sid = getSessionIdValue(); + sessionData.deviceId = getDeviceIdValue(); + sessionData.fmgSessionId = applicationUser?.savedSessionId; + sessionData.userId = getUserIdValue(); + sessionData.carId = order?.vehicle?.carId; + sessionData.vehicleYear = order?.vehicle?.year; + sessionData.vehicleMake = order?.vehicle?.make; + sessionData.vehicleModel = order?.vehicle?.model; + sessionData.vehicleStyle = order?.vehicle?.style; + sessionData.hasVin = order?.vehicle?.vin ? true : false; + sessionData.cashOrInsuranceAccountType = order?.payment?.isInsurance + ? "Insurance" + : "Cash"; + sessionData.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; + sessionData.productType = glassProducts; + sessionData.eon = order?.eon; + sessionData.referralNumber = order?.referralNumber; + sessionData.referralSequenceNumber = order?.referralSequenceNumber; + sessionData.referralDate = order?.referralDate; + sessionData.workOrderNumber = order?.workOrderNumber; + sessionData.workOrderId = order?.workOrderId; + sessionData.isPia = order?.payment?.isPia ?? false; + sessionData.piaType = order?.payment?.piaType; + sessionData.parentAccountNumber = order?.payment?.parentAccountNumber; + sessionData.settledTenderAmount = order?.settledTenderAmount; + sessionData.recalRequired = containsRecalParts(order?.lineItems); + sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); + sessionData.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode + ? order?.serviceLocation?.provider?.address?.zipCode + : order?.serviceLocation?.zipCode; + sessionData.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu + ? order?.serviceLocation?.provider?.address?.zipCodeCtu + : order?.serviceLocation?.zipCodeCtu; + sessionData.appointmentDate = `${order?.schedule?.date} ${order?.schedule?.startTime}`; + sessionData.serviceType = order?.serviceLocation?.appointmentType; + sessionData.promoCodes = promoCodes; + sessionData.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; + sessionData.paymentMethod = order?.payment?.piaType; + sessionData.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; + sessionData.isEarlyBird = isEarlyBird ? true : false; + sessionData.insuranceCo = order?.policy?.insuranceCompanyName; + sessionData.deductible = order?.policy?.currentDeductible; + sessionData.isVerified = order?.payment?.insuranceCoverage?.isVerified ?? false; + sessionData.isNoComp = order?.policy?.isNoComp; + sessionData.isItac = order?.policy?.isItac; + sessionData.subTotalPrice = getSubTotal(order?.lineItems); + sessionData.totalPrice = getAmountDue(order?.lineItems, true); + + await baseMixin.methods.dispatchStoreAction( + storeActions.LOG_FMG_SESSION_DATA, + sessionData, + false ); }, diff --git a/src/router/methods/after-each.js b/src/router/methods/after-each.js index 7be0e5ce0..1e1f80134 100644 --- a/src/router/methods/after-each.js +++ b/src/router/methods/after-each.js @@ -4,6 +4,9 @@ export async function afterEach(to, from) { // digital consumer logging analyticsMixin.methods.logDigitalConsumer(); + // digital consumer fmg session logging to snowflake + analyticsMixin.methods.pushFmgSessionData(); + // Push page view to GA analyticsMixin.methods.pushPageViewToGA(); @@ -12,7 +15,4 @@ export async function afterEach(to, from) { // Push current order status to Data Layer analyticsMixin.methods.pushOrderToDataLayer(); - - // Push session data GA events for Analytics - analyticsMixin.methods.pushFmgDataToGA(); } diff --git a/src/store/index.js b/src/store/index.js index 1fa9b4f14..cf2326d53 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1594,6 +1594,107 @@ export const actions = { }); }, + logFmgSessionData( + context, + { + currentPage, + sid, + deviceId, + fmgSessionId, + userId, + carId, + vehicleYear, + vehicleMake, + vehicleModel, + vehicleStyle, + hasVin, + cashOrInsuranceAccountType, + currentDeductible, + damageType, + productType, + eon, + referralNumber, + referralSequenceNumber, + referralDate, + workOrderNumber, + workOrderId, + isPia, + piaType, + parentAccountNumber, + settledTenderAmount, + recalRequired, + recalType, + serviceZipCode, + providerCtu, + appointmentDate, + serviceType, + promoCodes, + hasTechnicianNotes, + paymentMethod, + isTextingOptedIn, + isEarlyBird, + insuranceCo, + deductible, + isVerified, + isNoComp, + isItac, + subTotalPrice, + totalPrice, + } + ) { + var payload = { + currentPage: currentPage, + sid: sid, + deviceId: deviceId, + fmgSessionId: fmgSessionId, + userId: userId, + carId: carId, + vehicleYear: vehicleYear, + vehicleMake: vehicleMake, + vehicleModel: vehicleModel, + vehicleStyle: vehicleStyle, + hasVin: hasVin, + cashOrInsuranceAccountType: cashOrInsuranceAccountType, + isVerified: isVerified, + currentDeductible: currentDeductible, + damageType: damageType, + productType: productType, + eon: eon, + referralNumber: referralNumber, + referralSequenceNumber: referralSequenceNumber, + referralDate: referralDate, + workOrderNumber: workOrderNumber, + workOrderId: workOrderId, + isPia: isPia, + piaType: piaType, + parentAccountNumber: parentAccountNumber, + settledTenderAmount: settledTenderAmount, + recalRequired: recalRequired, + recalType: recalType, + serviceZipCode: serviceZipCode, + providerCtu: providerCtu, + appointmentDate: appointmentDate, + serviceType: serviceType, + promoCodes: promoCodes, + hasTechnicianNotes: hasTechnicianNotes, + paymentMethod: paymentMethod, + isTextingOptedIn: isTextingOptedIn, + isEarlyBird: isEarlyBird, + insuranceCo: insuranceCo, + deductible: deductible, + isNoComp: isNoComp, + isItac: isItac, + subTotalPrice: subTotalPrice, + totalPrice: totalPrice, + }; + + return globalMethods.callHttpClient({ + method: endpoints.LogFmgSessionData.method, + endpoint: endpoints.LogFmgSessionData.url, + payload: payload, + }); + }, + // Misc Actions setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) { context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber); From 5bf467dfffe388af0c5399a2c212aefcb04919d2 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Wed, 15 Oct 2025 13:37:57 -0400 Subject: [PATCH 18/94] CASH-1637 CASH-1637 dupe deductible field --- src/store/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/store/index.js b/src/store/index.js index cf2326d53..991391e38 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1609,7 +1609,6 @@ export const actions = { vehicleStyle, hasVin, cashOrInsuranceAccountType, - currentDeductible, damageType, productType, eon, @@ -1656,7 +1655,6 @@ export const actions = { hasVin: hasVin, cashOrInsuranceAccountType: cashOrInsuranceAccountType, isVerified: isVerified, - currentDeductible: currentDeductible, damageType: damageType, productType: productType, eon: eon, From 56d9ecbc03c37250a537276c6ed70426c31bc598 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sat, 18 Oct 2025 10:04:30 -0400 Subject: [PATCH 19/94] CASH-1637 CASH-1637 this is no longer a GA event and will be implemented with kinesis/snowflake next sprint --- src/constants/analytics.js | 3 -- src/mixins/analytics-mixin.js | 67 -------------------------------- src/router/methods/after-each.js | 3 -- 3 files changed, 73 deletions(-) diff --git a/src/constants/analytics.js b/src/constants/analytics.js index 77bb78a3e..fe43be06d 100644 --- a/src/constants/analytics.js +++ b/src/constants/analytics.js @@ -7,8 +7,6 @@ const analyticsPageEvents = { const GaEvents = { GENERIC_EVENT: "event", PAGE_VIEW_EVENT: "logPageview", - FMG_DATA_EVENT_1: "logEvent1", - FMG_DATA_EVENT_2: "logEvent2", }; const GaCategories = { @@ -16,7 +14,6 @@ const GaCategories = { EVOX: "Evox", APPOINTMENT: "Appointment", SERVICE_LOCATION: "service-location", - FMG_SESSION_DATA: "fmg_session_data", }; const GaActions = { diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index f5a7d10e0..ef5735bc3 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -200,73 +200,6 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, - async pushFmgDataToGA() { - const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); - const submittedOrder = baseMixin.methods.getSubmittedOrder(); - const order = hasSubmittedOrder ? submittedOrder : store.getters.order; - - //event 1 - var label = {}; - label.carId = order?.vehicle?.carId; - label.hasVin = order?.vehicle?.vin ? true : false; - label.cashOrInsuranceAccountType = order?.payment?.isInsurance ? "Insurance" : "Cash"; - label.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; - label.productType = order?.damage?.glassToReplace; - label.recalRequired = containsRecalParts(order?.lineItems); - label.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); - label.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode - ? order?.serviceLocation?.provider?.address?.zipCode - : order?.serviceLocation?.zipCode; - label.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu - ? order?.serviceLocation?.provider?.address?.zipCodeCtu - : order?.serviceLocation?.zipCodeCtu; - label.subTotalPrice = getSubTotal(order?.lineItems); - label.totalPrice = getAmountDue(order?.lineItems, true); - - await this.pushEventToGA( - GaCategories.FMG_SESSION_DATA, - GaEvents.FMG_DATA_EVENT_1, - JSON.stringify(label), - true, - null, - null - ); - - //event 2 - const isEarlyBird = order?.lineItems?.supportingItems?.find( - (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD - ); - - const promoCodes = Array.isArray(order?.lineItems?.promos) - ? order?.lineItems?.promos - .map((item) => item.promoCode) - .filter((code) => code) - .join(", ") - : ""; - - label = {}; - label.serviceType = order?.serviceLocation?.appointmentType; - label.promoCodes = promoCodes; - label.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; - label.paymentMethod = order?.payment?.piaType; - label.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; - label.isEarlyBird = isEarlyBird ? true : false; - label.insuranceCo = order?.policy?.insuranceCompanyName; - label.deductible = order?.policy?.currentDeductible; - label.isVerified = order?.payment?.insuranceCoverage?.isVerified; - label.isNoComp = order?.policy?.isNoComp; - label.isItac = order?.policy?.isItac; - - await this.pushEventToGA( - GaCategories.FMG_SESSION_DATA, - GaEvents.FMG_DATA_EVENT_2, - JSON.stringify(label), - true, - null, - null - ); - }, - pushOrderToDataLayer() { // helper check for if an object is defined (but maybe falsey) const isDefined = (x) => x !== null && x !== undefined; diff --git a/src/router/methods/after-each.js b/src/router/methods/after-each.js index 7be0e5ce0..27cecffde 100644 --- a/src/router/methods/after-each.js +++ b/src/router/methods/after-each.js @@ -12,7 +12,4 @@ export async function afterEach(to, from) { // Push current order status to Data Layer analyticsMixin.methods.pushOrderToDataLayer(); - - // Push session data GA events for Analytics - analyticsMixin.methods.pushFmgDataToGA(); } From b49a66b5991aa884df8dd1863273806ddd9f2261 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sat, 18 Oct 2025 10:30:27 -0400 Subject: [PATCH 20/94] CASH-1637 CASH-1637 function that used GA got reverted out but needed added back with the new endpoint that logs via kinesis --- src/mixins/analytics-mixin.js | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index ef5735bc3..f87f72ec9 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -200,6 +200,94 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, + // This sends session data to the session logging endpoint on the analytics service. + // From there, it uses aws kinesis data stream, DigitalConsumer-Session-Data-Stream, + // and aws FireHose stream, DigitalConsumer-Session-Firehose, to push data to an + // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. + // This bucket data is then picked up by snowflake for analytics use. + async pushFmgSessionData() { + const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); + const submittedOrder = baseMixin.methods.getSubmittedOrder(); + const order = hasSubmittedOrder ? submittedOrder : store.getters.order; + + const hasSubmittedApplicationUser = baseMixin.methods.hasSubmittedApplicationUser(); + const submittedApplicationUser = baseMixin.methods.getSubmittedApplicationUser(); + const applicationUser = hasSubmittedApplicationUser + ? submittedApplicationUser + : store.getters.applicationUser; + + const isEarlyBird = order?.lineItems?.supportingItems?.find( + (lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD + ); + + const promoCodes = Array.isArray(order?.lineItems?.promos) + ? order?.lineItems?.promos + .map((item) => item.promoCode) + .filter((code) => code) + .join(", ") + : null; + + const glassProducts = order?.damage?.glassToReplace?.map( + (part) => `${part.glassLocation}-${part.glassName}` + ); + + var sessionData = {}; + sessionData.currentPage = getPageNameFromRouter(); + sessionData.sid = getSessionIdValue(); + sessionData.deviceId = getDeviceIdValue(); + sessionData.fmgSessionId = applicationUser?.savedSessionId; + sessionData.userId = getUserIdValue(); + sessionData.carId = order?.vehicle?.carId; + sessionData.vehicleYear = order?.vehicle?.year; + sessionData.vehicleMake = order?.vehicle?.make; + sessionData.vehicleModel = order?.vehicle?.model; + sessionData.vehicleStyle = order?.vehicle?.style; + sessionData.hasVin = order?.vehicle?.vin ? true : false; + sessionData.cashOrInsuranceAccountType = order?.payment?.isInsurance + ? "Insurance" + : "Cash"; + sessionData.damageType = order?.damage?.isRepair ? "Repair" : "Replace"; + sessionData.productType = glassProducts; + sessionData.eon = order?.eon; + sessionData.referralNumber = order?.referralNumber; + sessionData.referralSequenceNumber = order?.referralSequenceNumber; + sessionData.referralDate = order?.referralDate; + sessionData.workOrderNumber = order?.workOrderNumber; + sessionData.workOrderId = order?.workOrderId; + sessionData.isPia = order?.payment?.isPia ?? false; + sessionData.piaType = order?.payment?.piaType; + sessionData.parentAccountNumber = order?.payment?.parentAccountNumber; + sessionData.settledTenderAmount = order?.settledTenderAmount; + sessionData.recalRequired = containsRecalParts(order?.lineItems); + sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts); + sessionData.serviceZipCode = order?.serviceLocation?.provider?.address?.zipCode + ? order?.serviceLocation?.provider?.address?.zipCode + : order?.serviceLocation?.zipCode; + sessionData.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu + ? order?.serviceLocation?.provider?.address?.zipCodeCtu + : order?.serviceLocation?.zipCodeCtu; + sessionData.appointmentDate = `${order?.schedule?.date} ${order?.schedule?.startTime}`; + sessionData.serviceType = order?.serviceLocation?.appointmentType; + sessionData.promoCodes = promoCodes; + sessionData.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; + sessionData.paymentMethod = order?.payment?.piaType; + sessionData.isTextingOptedIn = order?.customer?.isSmsOptIn ? true : false; + sessionData.isEarlyBird = isEarlyBird ? true : false; + sessionData.insuranceCo = order?.policy?.insuranceCompanyName; + sessionData.deductible = order?.policy?.currentDeductible; + sessionData.isVerified = order?.payment?.insuranceCoverage?.isVerified ?? false; + sessionData.isNoComp = order?.policy?.isNoComp; + sessionData.isItac = order?.policy?.isItac; + sessionData.subTotalPrice = getSubTotal(order?.lineItems); + sessionData.totalPrice = getAmountDue(order?.lineItems, true); + + await baseMixin.methods.dispatchStoreAction( + storeActions.LOG_FMG_SESSION_DATA, + sessionData, + false + ); + }, + pushOrderToDataLayer() { // helper check for if an object is defined (but maybe falsey) const isDefined = (x) => x !== null && x !== undefined; From f6aca0046c1163867b8145bb2cf2fdf6bcc64c73 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sat, 18 Oct 2025 10:38:19 -0400 Subject: [PATCH 21/94] CASH-1637 pretier --- src/mixins/analytics-mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index f87f72ec9..fbaccb113 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -200,7 +200,7 @@ export default { await this.logPageView(analyticsPageEvents.ENTRY); }, - // This sends session data to the session logging endpoint on the analytics service. + // This sends session data to the session logging endpoint on the analytics service. // From there, it uses aws kinesis data stream, DigitalConsumer-Session-Data-Stream, // and aws FireHose stream, DigitalConsumer-Session-Firehose, to push data to an // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. From e278bcc878b7e4869ace5fa1568a4a622221160e Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sat, 18 Oct 2025 20:53:41 -0400 Subject: [PATCH 22/94] CASH-1637 CASH-1637 add useragent --- src/helpers/heritage-integration/cookie-helper.js | 11 +++++++++++ src/mixins/analytics-mixin.js | 7 ++++++- src/store/index.js | 4 ++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js index 6e78d1304..c9c0f4ff3 100644 --- a/src/helpers/heritage-integration/cookie-helper.js +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -125,6 +125,17 @@ export function getSessionKeyValue() { return 0; } +export function getskeyValue() { + const cookieValue = getCookieValueByName(cookieNames.SESSION_KEY); + + if (cookieValue) { + return cookieValue; + } + + return 0; +} + + export function setSessionKeyIfUnset(value) { if (!isCookieSet(cookieNames.FUNNEL_SESSION_KEY)) { setCookieProperties( diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index fbaccb113..14ddd14c6 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -10,6 +10,7 @@ import { areAllSessionCookiesSet, setSessionIdIfUnset, setSessionKeyIfUnset, + getskeyValue, } from "@/helpers/heritage-integration/cookie-helper"; import { queryStrings } from "@/constants/query-strings"; import { experimentSettings, experimentUniverses } from "@/constants/experiments"; @@ -231,11 +232,14 @@ export default { (part) => `${part.glassLocation}-${part.glassName}` ); + var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`; + var sessionData = {}; sessionData.currentPage = getPageNameFromRouter(); sessionData.sid = getSessionIdValue(); sessionData.deviceId = getDeviceIdValue(); sessionData.fmgSessionId = applicationUser?.savedSessionId; + sessionData.skey = getskeyValue().toString(); sessionData.userId = getUserIdValue(); sessionData.carId = order?.vehicle?.carId; sessionData.vehicleYear = order?.vehicle?.year; @@ -266,7 +270,7 @@ export default { sessionData.providerCtu = order?.serviceLocation?.provider?.address?.zipCodeCtu ? order?.serviceLocation?.provider?.address?.zipCodeCtu : order?.serviceLocation?.zipCodeCtu; - sessionData.appointmentDate = `${order?.schedule?.date} ${order?.schedule?.startTime}`; + sessionData.appointmentDate = appointment; sessionData.serviceType = order?.serviceLocation?.appointmentType; sessionData.promoCodes = promoCodes; sessionData.hasTechnicianNotes = order?.serviceLocation?.techNotes ? true : false; @@ -280,6 +284,7 @@ export default { sessionData.isItac = order?.policy?.isItac; sessionData.subTotalPrice = getSubTotal(order?.lineItems); sessionData.totalPrice = getAmountDue(order?.lineItems, true); + sessionData.userAgent = navigator.userAgent; await baseMixin.methods.dispatchStoreAction( storeActions.LOG_FMG_SESSION_DATA, diff --git a/src/store/index.js b/src/store/index.js index 991391e38..e50a33765 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1601,6 +1601,7 @@ export const actions = { sid, deviceId, fmgSessionId, + skey, userId, carId, vehicleYear, @@ -1639,6 +1640,7 @@ export const actions = { isItac, subTotalPrice, totalPrice, + userAgent, } ) { var payload = { @@ -1646,6 +1648,7 @@ export const actions = { sid: sid, deviceId: deviceId, fmgSessionId: fmgSessionId, + skey: skey, userId: userId, carId: carId, vehicleYear: vehicleYear, @@ -1684,6 +1687,7 @@ export const actions = { isItac: isItac, subTotalPrice: subTotalPrice, totalPrice: totalPrice, + userAgent, }; return globalMethods.callHttpClient({ From 2038b5b339c56b15f62b80895037428f2f2790a8 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sat, 18 Oct 2025 20:57:19 -0400 Subject: [PATCH 23/94] prettier --- src/helpers/heritage-integration/cookie-helper.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js index c9c0f4ff3..d2f7046b6 100644 --- a/src/helpers/heritage-integration/cookie-helper.js +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -135,7 +135,6 @@ export function getskeyValue() { return 0; } - export function setSessionKeyIfUnset(value) { if (!isCookieSet(cookieNames.FUNNEL_SESSION_KEY)) { setCookieProperties( From 3b171fe57c31bd4979a7ac68431e5b45974edf8d Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sun, 19 Oct 2025 10:31:00 -0400 Subject: [PATCH 24/94] CASH-1426 CASH-1426 use correct part number for rain repel discount. rain defen is the actual part number on the part file. --- src/helpers/promotions-helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/promotions-helper.js b/src/helpers/promotions-helper.js index e386660ac..b9e3327b6 100644 --- a/src/helpers/promotions-helper.js +++ b/src/helpers/promotions-helper.js @@ -6,7 +6,7 @@ import baseMixin from "@/mixins/base-mixin.js"; export const promoPartNumberStrings = { WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT", - RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISCOUNT", + RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN", }; From 9c4d2daa3ecc90cda38995353739ca1eac03dd39 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 17:04:33 -0500 Subject: [PATCH 25/94] CASH-1315: move all today getters into helper --- src/digital-components/date-picker/date-picker.vue | 5 +++-- src/layouts/schedule/helpers/schedule-helper.js | 8 ++++++++ src/layouts/schedule/schedule.vue | 9 +++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index cbaba9193..b77e90e3e 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -150,6 +150,7 @@ import { import { convertDateToDateString, convertDateStringToDate, + getTodayDateString, } from "@/layouts/schedule/helpers/schedule-helper"; import { useField, ErrorMessage } from "vee-validate"; import { deepClone } from "@/helpers/object-helper"; @@ -227,7 +228,7 @@ export default { }, computed: { todayString() { - return this.todayOverrideDateString || convertDateToDateString(new Date()); + return this.todayOverrideDateString || getTodayDateString(); }, todayDayIndex() { return convertDateStringToDate(this.todayString).getDay(); @@ -414,7 +415,7 @@ export default { } else if (config.todayOverrideDateString) { todayDateString = config.todayOverrideDateString; } else { - todayDateString = convertDateToDateString(new Date()); + todayDateString = getTodayDateString(); } if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; if (config.selectableDatesSetting === "custom") calendarViewDirection = "future"; diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 41a1cd66a..85df8aa43 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -71,3 +71,11 @@ export function isDropOffRouteCode(routeCode) { routeCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF) ); } + +export function getTodayDate(routeCode) { + return new Date(); +} + +export function getTodayDateString(routeCode) { + return convertDateToDateString(getTodayDate()); +} diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index af7e67bd0..01b9841c0 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -233,6 +233,7 @@ import { convertDateStringToDate, sumDateString, isDropOffRouteCode, + getTodayDate, } from "@/layouts/schedule/helpers/schedule-helper"; import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants"; @@ -826,7 +827,7 @@ export default { } }, isSameDay() { - const todaysDate = new Date().toISOString().split("T")[0]; + const todaysDate = getTodayDate().toISOString().split("T")[0]; return this.selectedDate === todaysDate; }, isOvernightDropoff() { @@ -1100,7 +1101,7 @@ export default { }, getTimeSlotInfo() { // Get the current date - const currentDate = new Date(); + const currentDate = getTodayDate(); // Add 10 days to the current date currentDate.setDate(currentDate.getDate() + 10); @@ -1403,7 +1404,7 @@ export default { const type = store.getters.isMobileAppointment ? "mobile" : "inshop"; gaLabel = `${status}_${type}`; - const currentDate = new Date(); + const currentDate = getTodayDate(); const dateString = this.isMobileSelected ? this.selectableDatesMobile.days[0].date : this.selectableDatesInshop.days[0].date; @@ -1477,7 +1478,7 @@ export default { ) { const [year, month, day] = dateString.split("-").map(Number); const targetDate = new Date(year, month - 1, day); - const currentDate = new Date(); + const currentDate = getTodayDate(); const futureDate = new Date(currentDate); const experimentThresholdDays = experimentMixin.methods.hasSetting( experimentSettings.WAITLIST_THRESHOLD_DAYS From f2322450781b679a753dc5be8177de75ee04b568 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 17:13:20 -0500 Subject: [PATCH 26/94] CASH-1315: ensure that date picker selects first available date once loaded --- .../date-picker/date-picker.vue | 17 +++++ src/layouts/schedule/schedule.vue | 72 +++++++------------ 2 files changed, 42 insertions(+), 47 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index b77e90e3e..3f9b1f668 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -200,6 +200,7 @@ export default { pricingByDayUpcharge: Number, isPricingByDayExperiment: Boolean, isMobileSelected: Boolean, + isLoadingDates: Boolean, }, setup(props) { const uuid = uuidv4(); @@ -260,6 +261,15 @@ export default { this.dispatchStoreAction(this.storeActions.SAVE_WAITLIST_REQUESTED, false, false); }, }, + firstAvailableSelectableDate() { + const mobileFirstDate = Array.isArray(this.selectableDatesMobile) + ? this.selectableDatesMobile[0]?.date + : null; + const inshopFirstDate = Array.isArray(this.selectableDatesInshop) + ? this.selectableDatesInshop[0]?.date + : null; + return this.isMobileSelected ? mobileFirstDate + "-mobile" : inshopFirstDate; + }, }, methods: { async initializeComponent(initialData) { @@ -835,6 +845,13 @@ export default { }, }, watch: { + isLoadingDates(newValue) { + // if done loading dates, then set to first available date + if (newValue === false && this.firstAvailableSelectableDate) { + this.selectedDate = this.firstAvailableSelectableDate; + } + }, + modelValue(newValue) { this.resetField({ value: newValue, diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 01b9841c0..178a15d7b 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -134,6 +134,7 @@ selectableDatesSetting="custom" ref="datePicker" v-model="selectedDate" + :isLoadingDates="isLoadingDates" :isMobileSelected="isMobileSelected" class="text-link-small" :getMoreDatesCallback="getMoreScheduleData" @@ -332,12 +333,16 @@ const getScheduleApiResponse = async ({ const mobileTimeSlotsData = { days: [], }; - function compareDayStrings(a, b) { if (a.date < b.date) return -1; if (a.date > b.date) return 1; return 0; } + function removePastDates(array, todaysDate) { + return array.filter(function (a) { + return !(a.date < todaysDate); + }); + } const makeParallelCalls = async () => { await Promise.all( @@ -390,6 +395,10 @@ const getScheduleApiResponse = async ({ inshopTimeSlotsData.days.sort(compareDayStrings); mobileTimeSlotsData.days.sort(compareDayStrings); + const todaysDate = getTodayDate().toISOString().split("T")[0]; + inshopTimeSlotsData.days = removePastDates(inshopTimeSlotsData.days, todaysDate); + mobileTimeSlotsData.days = removePastDates(mobileTimeSlotsData.days, todaysDate); + return { inshopTimeSlotsData: inshopTimeSlotsData, mobileTimeSlotsData: mobileTimeSlotsData, @@ -423,7 +432,7 @@ export default { selectableDatesInshop: [], selectableDatesMobile: [], preSelectedDate: null, - + isLoadingDates: true, streetAddress: this.getServiceAddressFromStore(), apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), carId: this.getCarIdfromStore(), @@ -613,7 +622,7 @@ export default { this.selectedDate.includes("mobile") && !this.isMobileSelected ) { - this.getNewSelectedDate(); + this.setSelectedDateToFirstAvailable(); } return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion; }, @@ -1180,45 +1189,24 @@ export default { datePickerInitialData.pricingByDayUpcharge = this.pricingByDayUpcharge; await this.$refs.datePicker.initializeComponent(datePickerInitialData); this.selectableDatesInshop = - datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; + await datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; this.selectableDatesMobile = - datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; + await datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; this.setDisplayWaitList(); - if (this.preSelectedDate) this.selectedDate = this.preSelectedDate; - - if (!this.preSelectedDate) { - // if no date is preselected on load, then select the first available - let selectedDateMobile = this.getSelectedDateForMobile(); - let selectedDateInshop = this.getSelectedDateForInshop(); - - // if there is still no selected date, then load more and try again + if (this.preSelectedDate) { + this.selectedDate = this.preSelectedDate; + } else { + // if no date is preselected on load, make sure there are some dates available if ( - (this.isServiceableMobile && !selectedDateMobile) || - (this.isServiceableInshop && !selectedDateInshop) || - (this.isServiceableDropoff && !selectedDateInshop) + this.selectableDatesInshop.days.length < 1 || + this.selectableDatesMobile.days.length < 1 ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); - - // update all dates - - // > CHLOE HERD 7/22 -- CASH-1207 - // > Do not update the available dates again here; - // > they have already been updated by `showAnotherMonth`. - // > Doing so will likely add or remove dates, - // > desyncing the schedule page and the date-picker. - + this.isLoadingDates = false; this.setDisplayWaitList(); } - - await this.$nextTick(); - - if (this.isMobileSelected) { - this.selectedDate = this.getSelectedDateForMobile(); - } else { - this.selectedDate = this.getSelectedDateForInshop(); - } } }, getScheduleApiResponse, @@ -1623,7 +1611,7 @@ export default { ? AppointmentTypeStrings.DROP_OFF : AppointmentTypeStrings.IN_SHOP; }, - getSelectedDate() { + getFirstAvailableDate() { let dateToSelect; if (this.isMobileSelected) { dateToSelect = returnFirstDate(this.selectableDatesMobile); @@ -1633,16 +1621,6 @@ export default { if (!dateToSelect) return null; return this.isMobileSelected ? dateToSelect + "-mobile" : dateToSelect; }, - getSelectedDateForMobile() { - let dateToSelect = returnFirstDate(this.selectableDatesMobile); - if (!dateToSelect) return null; - return dateToSelect + "-mobile"; - }, - getSelectedDateForInshop() { - let dateToSelect = returnFirstDate(this.selectableDatesInshop); - if (!dateToSelect) return null; - return dateToSelect; - }, resetSelectedProvider() { this.selectedProvider = new Provider(); this.updateSelectedProvider(); @@ -1741,11 +1719,11 @@ export default { } else { this.appointmentType = null; } - this.selectedDate = this.getSelectedDate(); + this.setSelectedDateToFirstAvailable(); this.setDisplayWaitList(); }, - getNewSelectedDate() { - this.selectedDate = this.getSelectedDate(); + setSelectedDateToFirstAvailable() { + this.selectedDate = this.getFirstAvailableDate(); }, }, watch: { From cfc36ca247c4d4c2e88c48941c4e76f304e32665 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 18:31:32 -0500 Subject: [PATCH 27/94] CASH-1315: remove redundent isLoading check --- src/digital-components/date-picker/date-picker.vue | 4 +--- src/layouts/schedule/schedule.vue | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 3f9b1f668..a531244a5 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -200,7 +200,6 @@ export default { pricingByDayUpcharge: Number, isPricingByDayExperiment: Boolean, isMobileSelected: Boolean, - isLoadingDates: Boolean, }, setup(props) { const uuid = uuidv4(); @@ -845,13 +844,12 @@ export default { }, }, watch: { - isLoadingDates(newValue) { + isLoading(newValue) { // if done loading dates, then set to first available date if (newValue === false && this.firstAvailableSelectableDate) { this.selectedDate = this.firstAvailableSelectableDate; } }, - modelValue(newValue) { this.resetField({ value: newValue, diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 178a15d7b..7c9d9f444 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -134,7 +134,6 @@ selectableDatesSetting="custom" ref="datePicker" v-model="selectedDate" - :isLoadingDates="isLoadingDates" :isMobileSelected="isMobileSelected" class="text-link-small" :getMoreDatesCallback="getMoreScheduleData" @@ -432,7 +431,6 @@ export default { selectableDatesInshop: [], selectableDatesMobile: [], preSelectedDate: null, - isLoadingDates: true, streetAddress: this.getServiceAddressFromStore(), apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), carId: this.getCarIdfromStore(), @@ -1204,7 +1202,6 @@ export default { ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); - this.isLoadingDates = false; this.setDisplayWaitList(); } } From 4b65a9ddb3119b9785c95a03727cf6964693a0c3 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Tue, 14 Oct 2025 08:37:10 -0400 Subject: [PATCH 28/94] CASH-1426 CASH-1426 use a part number (DISCOUNT) that exists on mainframe IPF file so that work order submission does not fail in ESL. --- src/helpers/promotions-helper.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/helpers/promotions-helper.js b/src/helpers/promotions-helper.js index b9e3327b6..57e3dc7e7 100644 --- a/src/helpers/promotions-helper.js +++ b/src/helpers/promotions-helper.js @@ -6,7 +6,11 @@ import baseMixin from "@/mixins/base-mixin.js"; export const promoPartNumberStrings = { WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT", +<<<<<<< HEAD RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", +======= + RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISCOUNT", +>>>>>>> 2730b0650 (CASH-1426) GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN", }; From 63b3ef642f8b4ef0ce89317f619d26eae9cbe2a2 Mon Sep 17 00:00:00 2001 From: CarlNation Date: Sun, 19 Oct 2025 10:31:00 -0400 Subject: [PATCH 29/94] CASH-1426 CASH-1426 use correct part number for rain repel discount. rain defen is the actual part number on the part file. --- src/helpers/promotions-helper.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/helpers/promotions-helper.js b/src/helpers/promotions-helper.js index 57e3dc7e7..af93af3c9 100644 --- a/src/helpers/promotions-helper.js +++ b/src/helpers/promotions-helper.js @@ -6,11 +6,15 @@ import baseMixin from "@/mixins/base-mixin.js"; export const promoPartNumberStrings = { WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT", +<<<<<<< HEAD <<<<<<< HEAD RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", ======= RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISCOUNT", >>>>>>> 2730b0650 (CASH-1426) +======= + RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", +>>>>>>> 3b171fe57 (CASH-1426) GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN", }; From 222f27ee75350156a417af44cbc788e994fa74e1 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Mon, 20 Oct 2025 08:25:16 -0400 Subject: [PATCH 30/94] CASH-1315 - fix accidental merge crud --- src/helpers/promotions-helper.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/helpers/promotions-helper.js b/src/helpers/promotions-helper.js index af93af3c9..b9e3327b6 100644 --- a/src/helpers/promotions-helper.js +++ b/src/helpers/promotions-helper.js @@ -6,15 +6,7 @@ import baseMixin from "@/mixins/base-mixin.js"; export const promoPartNumberStrings = { WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT", -<<<<<<< HEAD -<<<<<<< HEAD RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", -======= - RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISCOUNT", ->>>>>>> 2730b0650 (CASH-1426) -======= - RAIN_REPEL_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN", ->>>>>>> 3b171fe57 (CASH-1426) GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT", GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN", }; From 504a774863e0c7f6411d11b8aa21b448ce4e6a10 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Mon, 20 Oct 2025 08:48:44 -0400 Subject: [PATCH 31/94] CASH-1315 refactor to avoid null being coerced into a string --- src/digital-components/date-picker/date-picker.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index a531244a5..63449320c 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -267,7 +267,7 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return this.isMobileSelected ? mobileFirstDate + "-mobile" : inshopFirstDate; + return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; }, }, methods: { From efee6aa70aff4c9170acacf1c3dc625f8ef8ffc8 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 09:37:39 -0500 Subject: [PATCH 32/94] CASH-1315: refactoring from PR review - remove watch --- .../date-picker/date-picker.vue | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 63449320c..32fe7f5f3 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -267,7 +267,9 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; + return this.isMobileSelected && mobileFirstDate + ? mobileFirstDate + "-mobile" + : inshopFirstDate; }, }, methods: { @@ -540,6 +542,8 @@ export default { } this.months = months; this.isLoading = false; + // if done loading dates, then set to first available date + if (!this.selectedDate) this.selectedDate = this.firstAvailableSelectableDate; if (config.preSelectedDate) { this.$nextTick(() => { @@ -773,6 +777,9 @@ export default { ); this.isLoading = false; + // if done loading dates, then set to first available date + if (!this.selectedDate) this.selectedDate = this.firstAvailableSelectableDate; + this.hideSomeDaysForInitialView = false; // if hid days on initial partial view, this will reveal those days monthToShow.monthClass = monthToShow.monthClass.replace(" month-hidden", ""); this.scrollToElement(monthToShow.monthString); @@ -844,12 +851,6 @@ export default { }, }, watch: { - isLoading(newValue) { - // if done loading dates, then set to first available date - if (newValue === false && this.firstAvailableSelectableDate) { - this.selectedDate = this.firstAvailableSelectableDate; - } - }, modelValue(newValue) { this.resetField({ value: newValue, From dfc70d19bc22cf22260d42bb5daa0393de779173 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 30 Dec 2025 10:54:01 -0500 Subject: [PATCH 33/94] CASH-1315 undo removal of watch --- src/digital-components/date-picker/date-picker.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 32fe7f5f3..65ada843e 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -267,9 +267,7 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return this.isMobileSelected && mobileFirstDate - ? mobileFirstDate + "-mobile" - : inshopFirstDate; + return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; }, }, methods: { From 5bf73e43bb909785cf53a76a459a3d9a45d95353 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 30 Dec 2025 10:59:14 -0500 Subject: [PATCH 34/94] CASH-1315 prettier updates: --- src/digital-components/date-picker/date-picker.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 65ada843e..32fe7f5f3 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -267,7 +267,9 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; + return this.isMobileSelected && mobileFirstDate + ? mobileFirstDate + "-mobile" + : inshopFirstDate; }, }, methods: { From e8ab9d5625cc0ea023e69b10e62e700a39e6b4b0 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 30 Dec 2025 11:16:32 -0500 Subject: [PATCH 35/94] Revert "CASH-1315: refactoring from PR review - remove watch" This reverts commit efee6aa70aff4c9170acacf1c3dc625f8ef8ffc8. --- .../date-picker/date-picker.vue | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 32fe7f5f3..63449320c 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -267,9 +267,7 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return this.isMobileSelected && mobileFirstDate - ? mobileFirstDate + "-mobile" - : inshopFirstDate; + return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; }, }, methods: { @@ -542,8 +540,6 @@ export default { } this.months = months; this.isLoading = false; - // if done loading dates, then set to first available date - if (!this.selectedDate) this.selectedDate = this.firstAvailableSelectableDate; if (config.preSelectedDate) { this.$nextTick(() => { @@ -777,9 +773,6 @@ export default { ); this.isLoading = false; - // if done loading dates, then set to first available date - if (!this.selectedDate) this.selectedDate = this.firstAvailableSelectableDate; - this.hideSomeDaysForInitialView = false; // if hid days on initial partial view, this will reveal those days monthToShow.monthClass = monthToShow.monthClass.replace(" month-hidden", ""); this.scrollToElement(monthToShow.monthString); @@ -851,6 +844,12 @@ export default { }, }, watch: { + isLoading(newValue) { + // if done loading dates, then set to first available date + if (newValue === false && this.firstAvailableSelectableDate) { + this.selectedDate = this.firstAvailableSelectableDate; + } + }, modelValue(newValue) { this.resetField({ value: newValue, From 21c247f91606c4ae1baba7b341d153210d50606b Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 30 Dec 2025 11:25:57 -0500 Subject: [PATCH 36/94] Revert "Merge pull request #2897 from Safelite/feature/CASH-1315-v4" This reverts commit 78273792b04a1a26a73c48cb7a72c179a56f3fa1, reversing changes made to b2478f4e9ea187fd568f3c12c202ff5262405e45. --- .../date-picker/date-picker.vue | 5 +- .../schedule/helpers/schedule-helper.js | 8 -- src/layouts/schedule/schedule.vue | 78 ++++++++++++------- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 63449320c..737a9e715 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -150,7 +150,6 @@ import { import { convertDateToDateString, convertDateStringToDate, - getTodayDateString, } from "@/layouts/schedule/helpers/schedule-helper"; import { useField, ErrorMessage } from "vee-validate"; import { deepClone } from "@/helpers/object-helper"; @@ -228,7 +227,7 @@ export default { }, computed: { todayString() { - return this.todayOverrideDateString || getTodayDateString(); + return this.todayOverrideDateString || convertDateToDateString(new Date()); }, todayDayIndex() { return convertDateStringToDate(this.todayString).getDay(); @@ -424,7 +423,7 @@ export default { } else if (config.todayOverrideDateString) { todayDateString = config.todayOverrideDateString; } else { - todayDateString = getTodayDateString(); + todayDateString = convertDateToDateString(new Date()); } if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; if (config.selectableDatesSetting === "custom") calendarViewDirection = "future"; diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 85df8aa43..41a1cd66a 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -71,11 +71,3 @@ export function isDropOffRouteCode(routeCode) { routeCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF) ); } - -export function getTodayDate(routeCode) { - return new Date(); -} - -export function getTodayDateString(routeCode) { - return convertDateToDateString(getTodayDate()); -} diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 7c9d9f444..af7e67bd0 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -233,7 +233,6 @@ import { convertDateStringToDate, sumDateString, isDropOffRouteCode, - getTodayDate, } from "@/layouts/schedule/helpers/schedule-helper"; import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants"; @@ -332,16 +331,12 @@ const getScheduleApiResponse = async ({ const mobileTimeSlotsData = { days: [], }; + function compareDayStrings(a, b) { if (a.date < b.date) return -1; if (a.date > b.date) return 1; return 0; } - function removePastDates(array, todaysDate) { - return array.filter(function (a) { - return !(a.date < todaysDate); - }); - } const makeParallelCalls = async () => { await Promise.all( @@ -394,10 +389,6 @@ const getScheduleApiResponse = async ({ inshopTimeSlotsData.days.sort(compareDayStrings); mobileTimeSlotsData.days.sort(compareDayStrings); - const todaysDate = getTodayDate().toISOString().split("T")[0]; - inshopTimeSlotsData.days = removePastDates(inshopTimeSlotsData.days, todaysDate); - mobileTimeSlotsData.days = removePastDates(mobileTimeSlotsData.days, todaysDate); - return { inshopTimeSlotsData: inshopTimeSlotsData, mobileTimeSlotsData: mobileTimeSlotsData, @@ -431,6 +422,7 @@ export default { selectableDatesInshop: [], selectableDatesMobile: [], preSelectedDate: null, + streetAddress: this.getServiceAddressFromStore(), apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), carId: this.getCarIdfromStore(), @@ -620,7 +612,7 @@ export default { this.selectedDate.includes("mobile") && !this.isMobileSelected ) { - this.setSelectedDateToFirstAvailable(); + this.getNewSelectedDate(); } return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion; }, @@ -834,7 +826,7 @@ export default { } }, isSameDay() { - const todaysDate = getTodayDate().toISOString().split("T")[0]; + const todaysDate = new Date().toISOString().split("T")[0]; return this.selectedDate === todaysDate; }, isOvernightDropoff() { @@ -1108,7 +1100,7 @@ export default { }, getTimeSlotInfo() { // Get the current date - const currentDate = getTodayDate(); + const currentDate = new Date(); // Add 10 days to the current date currentDate.setDate(currentDate.getDate() + 10); @@ -1187,23 +1179,45 @@ export default { datePickerInitialData.pricingByDayUpcharge = this.pricingByDayUpcharge; await this.$refs.datePicker.initializeComponent(datePickerInitialData); this.selectableDatesInshop = - await datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; + datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; this.selectableDatesMobile = - await datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; + datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; this.setDisplayWaitList(); - if (this.preSelectedDate) { - this.selectedDate = this.preSelectedDate; - } else { - // if no date is preselected on load, make sure there are some dates available + if (this.preSelectedDate) this.selectedDate = this.preSelectedDate; + + if (!this.preSelectedDate) { + // if no date is preselected on load, then select the first available + let selectedDateMobile = this.getSelectedDateForMobile(); + let selectedDateInshop = this.getSelectedDateForInshop(); + + // if there is still no selected date, then load more and try again if ( - this.selectableDatesInshop.days.length < 1 || - this.selectableDatesMobile.days.length < 1 + (this.isServiceableMobile && !selectedDateMobile) || + (this.isServiceableInshop && !selectedDateInshop) || + (this.isServiceableDropoff && !selectedDateInshop) ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); + + // update all dates + + // > CHLOE HERD 7/22 -- CASH-1207 + // > Do not update the available dates again here; + // > they have already been updated by `showAnotherMonth`. + // > Doing so will likely add or remove dates, + // > desyncing the schedule page and the date-picker. + this.setDisplayWaitList(); } + + await this.$nextTick(); + + if (this.isMobileSelected) { + this.selectedDate = this.getSelectedDateForMobile(); + } else { + this.selectedDate = this.getSelectedDateForInshop(); + } } }, getScheduleApiResponse, @@ -1389,7 +1403,7 @@ export default { const type = store.getters.isMobileAppointment ? "mobile" : "inshop"; gaLabel = `${status}_${type}`; - const currentDate = getTodayDate(); + const currentDate = new Date(); const dateString = this.isMobileSelected ? this.selectableDatesMobile.days[0].date : this.selectableDatesInshop.days[0].date; @@ -1463,7 +1477,7 @@ export default { ) { const [year, month, day] = dateString.split("-").map(Number); const targetDate = new Date(year, month - 1, day); - const currentDate = getTodayDate(); + const currentDate = new Date(); const futureDate = new Date(currentDate); const experimentThresholdDays = experimentMixin.methods.hasSetting( experimentSettings.WAITLIST_THRESHOLD_DAYS @@ -1608,7 +1622,7 @@ export default { ? AppointmentTypeStrings.DROP_OFF : AppointmentTypeStrings.IN_SHOP; }, - getFirstAvailableDate() { + getSelectedDate() { let dateToSelect; if (this.isMobileSelected) { dateToSelect = returnFirstDate(this.selectableDatesMobile); @@ -1618,6 +1632,16 @@ export default { if (!dateToSelect) return null; return this.isMobileSelected ? dateToSelect + "-mobile" : dateToSelect; }, + getSelectedDateForMobile() { + let dateToSelect = returnFirstDate(this.selectableDatesMobile); + if (!dateToSelect) return null; + return dateToSelect + "-mobile"; + }, + getSelectedDateForInshop() { + let dateToSelect = returnFirstDate(this.selectableDatesInshop); + if (!dateToSelect) return null; + return dateToSelect; + }, resetSelectedProvider() { this.selectedProvider = new Provider(); this.updateSelectedProvider(); @@ -1716,11 +1740,11 @@ export default { } else { this.appointmentType = null; } - this.setSelectedDateToFirstAvailable(); + this.selectedDate = this.getSelectedDate(); this.setDisplayWaitList(); }, - setSelectedDateToFirstAvailable() { - this.selectedDate = this.getFirstAvailableDate(); + getNewSelectedDate() { + this.selectedDate = this.getSelectedDate(); }, }, watch: { From fa0f09b46db3b7beb503228e5cd71e2e667517eb Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 30 Dec 2025 11:29:14 -0500 Subject: [PATCH 37/94] CASH-1315 prettier revert --- src/digital-components/date-picker/date-picker.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 737a9e715..6f635d0bc 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -266,7 +266,9 @@ export default { const inshopFirstDate = Array.isArray(this.selectableDatesInshop) ? this.selectableDatesInshop[0]?.date : null; - return (this.isMobileSelected && mobileFirstDate) ? mobileFirstDate + "-mobile" : inshopFirstDate; + return this.isMobileSelected && mobileFirstDate + ? mobileFirstDate + "-mobile" + : inshopFirstDate; }, }, methods: { From ad1990d8b4fc68d85f57b7f00e6fcb28eb239c14 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 17:04:33 -0500 Subject: [PATCH 38/94] CASH-1315: move all today getters into helper --- src/digital-components/date-picker/date-picker.vue | 5 +++-- src/layouts/schedule/helpers/schedule-helper.js | 8 ++++++++ src/layouts/schedule/schedule.vue | 9 +++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index 6f635d0bc..d586ff839 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -150,6 +150,7 @@ import { import { convertDateToDateString, convertDateStringToDate, + getTodayDateString, } from "@/layouts/schedule/helpers/schedule-helper"; import { useField, ErrorMessage } from "vee-validate"; import { deepClone } from "@/helpers/object-helper"; @@ -227,7 +228,7 @@ export default { }, computed: { todayString() { - return this.todayOverrideDateString || convertDateToDateString(new Date()); + return this.todayOverrideDateString || getTodayDateString(); }, todayDayIndex() { return convertDateStringToDate(this.todayString).getDay(); @@ -425,7 +426,7 @@ export default { } else if (config.todayOverrideDateString) { todayDateString = config.todayOverrideDateString; } else { - todayDateString = convertDateToDateString(new Date()); + todayDateString = getTodayDateString(); } if (config.selectableDatesSetting === "past") calendarViewDirection = "past"; if (config.selectableDatesSetting === "custom") calendarViewDirection = "future"; diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 41a1cd66a..85df8aa43 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -71,3 +71,11 @@ export function isDropOffRouteCode(routeCode) { routeCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF) ); } + +export function getTodayDate(routeCode) { + return new Date(); +} + +export function getTodayDateString(routeCode) { + return convertDateToDateString(getTodayDate()); +} diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index af7e67bd0..01b9841c0 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -233,6 +233,7 @@ import { convertDateStringToDate, sumDateString, isDropOffRouteCode, + getTodayDate, } from "@/layouts/schedule/helpers/schedule-helper"; import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants"; @@ -826,7 +827,7 @@ export default { } }, isSameDay() { - const todaysDate = new Date().toISOString().split("T")[0]; + const todaysDate = getTodayDate().toISOString().split("T")[0]; return this.selectedDate === todaysDate; }, isOvernightDropoff() { @@ -1100,7 +1101,7 @@ export default { }, getTimeSlotInfo() { // Get the current date - const currentDate = new Date(); + const currentDate = getTodayDate(); // Add 10 days to the current date currentDate.setDate(currentDate.getDate() + 10); @@ -1403,7 +1404,7 @@ export default { const type = store.getters.isMobileAppointment ? "mobile" : "inshop"; gaLabel = `${status}_${type}`; - const currentDate = new Date(); + const currentDate = getTodayDate(); const dateString = this.isMobileSelected ? this.selectableDatesMobile.days[0].date : this.selectableDatesInshop.days[0].date; @@ -1477,7 +1478,7 @@ export default { ) { const [year, month, day] = dateString.split("-").map(Number); const targetDate = new Date(year, month - 1, day); - const currentDate = new Date(); + const currentDate = getTodayDate(); const futureDate = new Date(currentDate); const experimentThresholdDays = experimentMixin.methods.hasSetting( experimentSettings.WAITLIST_THRESHOLD_DAYS From 3b3f8cd800eb3e8468e84e8fe027c24f5dfd5ef6 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 17:13:20 -0500 Subject: [PATCH 39/94] CASH-1315: ensure that date picker selects first available date once loaded (cherry picked from commit f2322450781b679a753dc5be8177de75ee04b568) --- .../date-picker/date-picker.vue | 3 +- src/layouts/schedule/schedule.vue | 72 +++++++------------ 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index d586ff839..e12c835e9 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -200,6 +200,7 @@ export default { pricingByDayUpcharge: Number, isPricingByDayExperiment: Boolean, isMobileSelected: Boolean, + isLoadingDates: Boolean, }, setup(props) { const uuid = uuidv4(); @@ -846,7 +847,7 @@ export default { }, }, watch: { - isLoading(newValue) { + isLoadingDates(newValue) { // if done loading dates, then set to first available date if (newValue === false && this.firstAvailableSelectableDate) { this.selectedDate = this.firstAvailableSelectableDate; diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 01b9841c0..178a15d7b 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -134,6 +134,7 @@ selectableDatesSetting="custom" ref="datePicker" v-model="selectedDate" + :isLoadingDates="isLoadingDates" :isMobileSelected="isMobileSelected" class="text-link-small" :getMoreDatesCallback="getMoreScheduleData" @@ -332,12 +333,16 @@ const getScheduleApiResponse = async ({ const mobileTimeSlotsData = { days: [], }; - function compareDayStrings(a, b) { if (a.date < b.date) return -1; if (a.date > b.date) return 1; return 0; } + function removePastDates(array, todaysDate) { + return array.filter(function (a) { + return !(a.date < todaysDate); + }); + } const makeParallelCalls = async () => { await Promise.all( @@ -390,6 +395,10 @@ const getScheduleApiResponse = async ({ inshopTimeSlotsData.days.sort(compareDayStrings); mobileTimeSlotsData.days.sort(compareDayStrings); + const todaysDate = getTodayDate().toISOString().split("T")[0]; + inshopTimeSlotsData.days = removePastDates(inshopTimeSlotsData.days, todaysDate); + mobileTimeSlotsData.days = removePastDates(mobileTimeSlotsData.days, todaysDate); + return { inshopTimeSlotsData: inshopTimeSlotsData, mobileTimeSlotsData: mobileTimeSlotsData, @@ -423,7 +432,7 @@ export default { selectableDatesInshop: [], selectableDatesMobile: [], preSelectedDate: null, - + isLoadingDates: true, streetAddress: this.getServiceAddressFromStore(), apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), carId: this.getCarIdfromStore(), @@ -613,7 +622,7 @@ export default { this.selectedDate.includes("mobile") && !this.isMobileSelected ) { - this.getNewSelectedDate(); + this.setSelectedDateToFirstAvailable(); } return this.selectedDate && this.appointmentTypeFromAppointmentTypeQuestion; }, @@ -1180,45 +1189,24 @@ export default { datePickerInitialData.pricingByDayUpcharge = this.pricingByDayUpcharge; await this.$refs.datePicker.initializeComponent(datePickerInitialData); this.selectableDatesInshop = - datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; + await datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData; this.selectableDatesMobile = - datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; + await datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData; this.setDisplayWaitList(); - if (this.preSelectedDate) this.selectedDate = this.preSelectedDate; - - if (!this.preSelectedDate) { - // if no date is preselected on load, then select the first available - let selectedDateMobile = this.getSelectedDateForMobile(); - let selectedDateInshop = this.getSelectedDateForInshop(); - - // if there is still no selected date, then load more and try again + if (this.preSelectedDate) { + this.selectedDate = this.preSelectedDate; + } else { + // if no date is preselected on load, make sure there are some dates available if ( - (this.isServiceableMobile && !selectedDateMobile) || - (this.isServiceableInshop && !selectedDateInshop) || - (this.isServiceableDropoff && !selectedDateInshop) + this.selectableDatesInshop.days.length < 1 || + this.selectableDatesMobile.days.length < 1 ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); - - // update all dates - - // > CHLOE HERD 7/22 -- CASH-1207 - // > Do not update the available dates again here; - // > they have already been updated by `showAnotherMonth`. - // > Doing so will likely add or remove dates, - // > desyncing the schedule page and the date-picker. - + this.isLoadingDates = false; this.setDisplayWaitList(); } - - await this.$nextTick(); - - if (this.isMobileSelected) { - this.selectedDate = this.getSelectedDateForMobile(); - } else { - this.selectedDate = this.getSelectedDateForInshop(); - } } }, getScheduleApiResponse, @@ -1623,7 +1611,7 @@ export default { ? AppointmentTypeStrings.DROP_OFF : AppointmentTypeStrings.IN_SHOP; }, - getSelectedDate() { + getFirstAvailableDate() { let dateToSelect; if (this.isMobileSelected) { dateToSelect = returnFirstDate(this.selectableDatesMobile); @@ -1633,16 +1621,6 @@ export default { if (!dateToSelect) return null; return this.isMobileSelected ? dateToSelect + "-mobile" : dateToSelect; }, - getSelectedDateForMobile() { - let dateToSelect = returnFirstDate(this.selectableDatesMobile); - if (!dateToSelect) return null; - return dateToSelect + "-mobile"; - }, - getSelectedDateForInshop() { - let dateToSelect = returnFirstDate(this.selectableDatesInshop); - if (!dateToSelect) return null; - return dateToSelect; - }, resetSelectedProvider() { this.selectedProvider = new Provider(); this.updateSelectedProvider(); @@ -1741,11 +1719,11 @@ export default { } else { this.appointmentType = null; } - this.selectedDate = this.getSelectedDate(); + this.setSelectedDateToFirstAvailable(); this.setDisplayWaitList(); }, - getNewSelectedDate() { - this.selectedDate = this.getSelectedDate(); + setSelectedDateToFirstAvailable() { + this.selectedDate = this.getFirstAvailableDate(); }, }, watch: { From 3af1a9af4c2c903d52b0c132f9484cc6e3e81401 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 31 Dec 2025 18:31:32 -0500 Subject: [PATCH 40/94] CASH-1315: remove redundent isLoading check --- src/digital-components/date-picker/date-picker.vue | 3 +-- src/layouts/schedule/schedule.vue | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/digital-components/date-picker/date-picker.vue b/src/digital-components/date-picker/date-picker.vue index e12c835e9..d586ff839 100644 --- a/src/digital-components/date-picker/date-picker.vue +++ b/src/digital-components/date-picker/date-picker.vue @@ -200,7 +200,6 @@ export default { pricingByDayUpcharge: Number, isPricingByDayExperiment: Boolean, isMobileSelected: Boolean, - isLoadingDates: Boolean, }, setup(props) { const uuid = uuidv4(); @@ -847,7 +846,7 @@ export default { }, }, watch: { - isLoadingDates(newValue) { + isLoading(newValue) { // if done loading dates, then set to first available date if (newValue === false && this.firstAvailableSelectableDate) { this.selectedDate = this.firstAvailableSelectableDate; diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 178a15d7b..7c9d9f444 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -134,7 +134,6 @@ selectableDatesSetting="custom" ref="datePicker" v-model="selectedDate" - :isLoadingDates="isLoadingDates" :isMobileSelected="isMobileSelected" class="text-link-small" :getMoreDatesCallback="getMoreScheduleData" @@ -432,7 +431,6 @@ export default { selectableDatesInshop: [], selectableDatesMobile: [], preSelectedDate: null, - isLoadingDates: true, streetAddress: this.getServiceAddressFromStore(), apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), carId: this.getCarIdfromStore(), @@ -1204,7 +1202,6 @@ export default { ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); - this.isLoadingDates = false; this.setDisplayWaitList(); } } From e22f66eb83efc8dd0ef0fe24403a7a31c6611ddb Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Mon, 13 Oct 2025 14:00:53 -0400 Subject: [PATCH 41/94] Stub error page --- public/static/error/index.html | 128 +++++++++++++++++++++ src/global-methods.js | 2 +- src/router/constants/routes.js | 1 - src/router/index.js | 5 +- src/router/methods/before-each.js | 9 +- src/router/methods/error.js | 10 +- src/router/methods/helpers/create-route.js | 4 +- src/router/methods/navigate.js | 4 +- src/router/methods/route-logic/error.js | 25 +++- 9 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 public/static/error/index.html diff --git a/public/static/error/index.html b/public/static/error/index.html new file mode 100644 index 000000000..539a7fb88 --- /dev/null +++ b/public/static/error/index.html @@ -0,0 +1,128 @@ + + + + + + +

+ EXCEPTION PAGE +

+
+

+ BAILOUT INFO: +

+
+ +
+ + + \ No newline at end of file diff --git a/src/global-methods.js b/src/global-methods.js index 0bd0549ea..fc0b3cb41 100644 --- a/src/global-methods.js +++ b/src/global-methods.js @@ -121,7 +121,7 @@ export default { endpoint: endpoint, }; - router.bailout(errorPayload); + router.handleSoftError(errorPayload); // do not log 404 errors from services because we return NotFound // when a service doesn't return an object diff --git a/src/router/constants/routes.js b/src/router/constants/routes.js index 5632fe357..98d6c503a 100644 --- a/src/router/constants/routes.js +++ b/src/router/constants/routes.js @@ -112,7 +112,6 @@ export const routeData = { path: "/virtual/auto-route", virtual: true, }, - // TODO: RENAME FROM BAILOUT ERROR: { name: "error", path: "/virtual/error", diff --git a/src/router/index.js b/src/router/index.js index cd842c5cb..aa07a7a0b 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,7 +1,7 @@ import { routes } from "@/router/methods/routes"; import { afterEach } from "@/router/methods/after-each"; import { beforeEach } from "@/router/methods/before-each"; -import { bailout } from "@/router/methods/error"; +import { handleSoftError, handleHardError } from "@/router/methods/error"; import { navigateWithoutSaving, navigateWithSaving, @@ -32,7 +32,8 @@ router.navigateWithoutSaving = navigateWithoutSaving; router.navigateWithPageData = navigateWithPageData; router.navigateAndForceTopLevelNavigation = navigateAndForceTopLevelNavigation; -router.bailout = bailout; +router.handleSoftError = handleSoftError; +router.handleHardError = handleHardError; router.navigateToExternalUrl = navigateToExternalUrl; diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js index e742f89ff..1796b40d7 100644 --- a/src/router/methods/before-each.js +++ b/src/router/methods/before-each.js @@ -8,7 +8,7 @@ import analyticsMixin from "@/mixins/analytics-mixin"; import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes"; import { runExperiments } from "@/router/methods/helpers/run-experiments"; -import { bailout } from "@/router/methods/error"; +import { handleSoftError, handleHardError } from "@/router/methods/error"; import { checkPagePrerequisites } from "@/router/methods/page-prerequisites"; import { checkLogParam } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper"; @@ -41,7 +41,7 @@ export async function beforeEach(to, from) { nextPage: to?.name, }; - bailout(errorPayload, true); + handleSoftError(errorPayload, true); return false; } @@ -88,7 +88,7 @@ export async function beforeEach(to, from) { nextPage: to?.name, }; - bailout(errorPayload); + handleSoftError(errorPayload); return; } @@ -107,7 +107,8 @@ export async function beforeEach(to, from) { console.log(error); - bailout(errorPayload); + // Eject user from Vue app in this scenario and clear localstorage. + handleHardError(errorPayload); return; } } diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 06eea69b0..c9f4e30a5 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -5,7 +5,7 @@ import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; -export async function bailout(errorPayload, forceRestart = false) { +export async function handleSoftError(errorPayload, forceRestart = false) { if (forceRestart) { await store.dispatch(storeActions.RESET_STATE); deleteFunnelCookie(); @@ -17,3 +17,11 @@ export async function bailout(errorPayload, forceRestart = false) { name: routeData.ERROR.name, }); } + +export async function handleHardError(errorPayload) { + try { + analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); + } finally { + window.top.location = "/fmg/static/error/"; + } +} diff --git a/src/router/methods/helpers/create-route.js b/src/router/methods/helpers/create-route.js index 32410e3b3..828777603 100644 --- a/src/router/methods/helpers/create-route.js +++ b/src/router/methods/helpers/create-route.js @@ -11,7 +11,7 @@ export function createRoute(routeDatum, beforeEnter = async (to, from) => undefi return await beforeEnter(to, from); } catch { return { - name: routeData.BAILOUT.name, + name: routeData.ERROR.name, replace: true, }; } @@ -29,7 +29,7 @@ export function createVirtualRoute(routeDatum, beforeEnter = async (to, from) => } catch (error) { console.log(error); return { - name: routeData.BAILOUT.name, + name: routeData.ERROR.name, replace: true, }; } diff --git a/src/router/methods/navigate.js b/src/router/methods/navigate.js index b15c955e9..25c58e7e5 100644 --- a/src/router/methods/navigate.js +++ b/src/router/methods/navigate.js @@ -5,7 +5,7 @@ import { savePageData } from "@/router/methods/helpers/save-page-data"; import router from "@/router"; import store from "@/store"; -import { bailout } from "@/router/methods/error"; +import { handleSoftError } from "@/router/methods/error"; async function navigate(scenario, currentPageName, withSaving = false, forceTopLevelNav = false) { // Check to see if calling page is same as current page. @@ -30,7 +30,7 @@ async function navigate(scenario, currentPageName, withSaving = false, forceTopL nextPage: nextPage?.name, }; - bailout(errorPayload); + handleSoftError(errorPayload); return; } diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js index 8707a65d9..9f86f7acf 100644 --- a/src/router/methods/route-logic/error.js +++ b/src/router/methods/route-logic/error.js @@ -5,15 +5,34 @@ import baseMixin from "@/mixins/base-mixin"; import router from "@/router"; import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes"; import store from "@/store"; +import { handleHardError } from "@/router/methods/error"; export async function errorBeforeEnter(to, from) { + // Check if `hasAlreadyTriggeredErrror` is available. + // If we cannot check it (store, getters, or applicationUser is null-ish), + // act as if the flag is set, as we are in unstable state. + if (!store?.getters?.applicationUser) { + const errorPayload = { + cause: "Cannot access store during error process.", + currentPage: from?.name, + nextPage: to?.name, + }; + + handleHardError(errorPayload); + return; + } + // If we've already encountered one error, clear session to avoid more. // Otherwise mark that we encoutnered an error here. if (store.getters.applicationUser.hasAlreadyTriggeredError) { - return { - name: routeData.RESTART.name, - replace: true, + const errorPayload = { + cause: "Successive errors triggered.", + currentPage: from?.name, + nextPage: to?.name, }; + + handleHardError(errorPayload); + return; } else { await store.dispatch(storeActions.UPDATE_HAS_TRIGGERED_ERROR, true); } From 9375b6b2a6cf2ddc5d023a33871686cc8062ccc4 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Wed, 15 Oct 2025 09:47:03 -0400 Subject: [PATCH 42/94] Add styling to page --- public/static/assets/Urbanist-Regular.woff | Bin 0 -> 24404 bytes public/static/assets/Urbanist-SemiBold.woff | Bin 0 -> 24604 bytes public/static/assets/logo.svg | 3 + public/static/error/index.html | 91 ++++++++++++++++++-- 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 public/static/assets/Urbanist-Regular.woff create mode 100644 public/static/assets/Urbanist-SemiBold.woff create mode 100644 public/static/assets/logo.svg diff --git a/public/static/assets/Urbanist-Regular.woff b/public/static/assets/Urbanist-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..7efe9bc4272c4eea02db12582856dfc8d8f37eab GIT binary patch literal 24404 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� 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 literal 0 HcmV?d00001 diff --git a/public/static/assets/Urbanist-SemiBold.woff b/public/static/assets/Urbanist-SemiBold.woff new file mode 100644 index 0000000000000000000000000000000000000000..92949770b0ba8593c0573e8808d29809596bfd77 GIT binary patch literal 24604 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 literal 0 HcmV?d00001 diff --git a/public/static/assets/logo.svg b/public/static/assets/logo.svg new file mode 100644 index 000000000..8f739ca15 --- /dev/null +++ b/public/static/assets/logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/static/error/index.html b/public/static/error/index.html index 539a7fb88..057d33f1e 100644 --- a/public/static/error/index.html +++ b/public/static/error/index.html @@ -1,18 +1,91 @@ + + Error - Safelite + -

- EXCEPTION PAGE -

-
-

- BAILOUT INFO: -

-
+
+
+ +
+
+

+ We're not able to schedule at this time. We apologize for the inconvenience +

+

+ We encountered an error while processing your appointment. You can return to our site and try scheduling again below. +

+
+ +
+
+

+ Bailout Information: +

+
+
+
From 487f380c1e4556da7e28fc15681d0c90bd8890a8 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Thu, 16 Oct 2025 13:25:19 -0400 Subject: [PATCH 45/94] Change behavior by environment, and send api logging call. --- public/static/error/index.html | 137 ++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 29 deletions(-) diff --git a/public/static/error/index.html b/public/static/error/index.html index 8ea99c6ed..451c6301e 100644 --- a/public/static/error/index.html +++ b/public/static/error/index.html @@ -98,8 +98,51 @@ body { } return ""; } + + function getCurrentEnvironmentData() { + const environmentData = [ + { + name: 'Localhost', + hostName: 'localhost', + apiHostname: 'digitalapi.dev.safelite.io', + debug: true, + }, + { + name: 'Dev', + hostName: 'www-dev2.safelite.com', + apiHostname: 'digitalapi.dev.safelite.io', + debug: true, + }, + { + name: 'Test', + hostName: 'www-test2.safelite.com', + apiHostname: 'digitalapi.test.safelite.io', + debug: true, + }, + { + name: 'QA', + hostName: 'www-qa2.safelite.com', + apiHostname: 'digitalapi.qa.safelite.io', + debug: false, + }, + { + name: 'Prod', + hostName: 'www.safelite.com', + apiHostname: 'digitalapi.safelite.io', + debug: false, + }, + ]; - window.onload = () => { + const hostName = window.location.hostname; + + const match = environmentData.find( + data => (data.hostName === hostName) + ); + + return match; + } + + window.onload = async () => { // =============== Check for session info: const existingVuexDataJSON = window.localStorage.getItem('vuex'); const existingVuexData = existingVuexDataJSON ? JSON.parse(existingVuexDataJSON) : null; @@ -176,43 +219,79 @@ body { } else if(existingBailoutInfo) { // Proceed with prior data. bailoutInfo = existingBailoutInfo; + } + + const environmentInfo = getCurrentEnvironmentData(); + const shouldShowDebugInfo = environmentInfo?.debug; + + if(bailoutInfo && shouldShowDebugInfo) { + try { + // =============== Display diagnostic data: + // Create display nodes + const elements = bailoutInfo.map( + dataPoint => { + const element = document.createElement('li'); + element.textContent = `${dataPoint.name}: ${dataPoint.value}`; + + return element; + } + ); + + const fragment = new DocumentFragment(); + + elements.forEach( + (element) => { + fragment.append(element); + } + ); + + // Attach nodes to DOM and render + const attachNode = document.getElementById('bailout-info-container'); + if(attachNode) { + const ul = attachNode.appendChild(document.createElement('ul')); + ul.append(fragment); + } + } catch(e) { + console.error(`=== ERROR DISPLAYING INFO`); + console.error(e); + + const diagnosticContainer = document.getElementById('diagnostic-container'); + diagnosticContainer.remove(); + } } else { - // If neither fresh nor prior data exists, remove diagnostic section. const diagnosticContainer = document.getElementById('diagnostic-container'); diagnosticContainer.remove(); - return; } - try { - // =============== Display diagnostic data: - // Create display nodes - const elements = bailoutInfo.map( - dataPoint => { - const element = document.createElement('li'); - element.textContent = `${dataPoint.name}: ${dataPoint.value}`; + const apiHostname = environmentInfo?.apiHostname; + if(apiHostname) { + try { + const endpointUrl = `https://${apiHostname}/analytics/api/v1/logging/log-error`; - return element; - } - ); + const infoString = bailoutInfo.map( + (entry) => `${entry.name}: ${entry.value}` + ).reduce( + (prev, next) => `${prev}\n${next}` + ); + const entryString = `User encountered bailout page.\n${new Date()}\n${infoString}`; - const fragment = new DocumentFragment(); + const request = new Request(endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + entry: entryString, + }), + }); - elements.forEach( - (element) => { - fragment.append(element); - } - ); - - // Attach nodes to DOM and render - const attachNode = document.getElementById('bailout-info-container'); - if(attachNode) { - const ul = attachNode.appendChild(document.createElement('ul')); - ul.append(fragment); + const response = await fetch(request); + // Don't need data from response, but could get it here with: + // const responseData = await response.json(); + } catch(e) { + console.error(`=== ERROR SENDING LOGGING`); + console.error(e); } - } finally { - // =============== Clear User Data - // ======== To avoid reproducing the same issue due to invalid states. - window.localStorage.removeItem('vuex'); } }; From 3fa55f6dea8363e518fcec20eef17fc1cf4e7ddd Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Oct 2025 10:48:46 -0400 Subject: [PATCH 46/94] Integrate experiment toggle --- src/router/methods/error.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/router/methods/error.js b/src/router/methods/error.js index c9f4e30a5..08bd182df 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -4,6 +4,7 @@ import router from "@/router"; import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import experimentMixin from "../../mixins/experiment-mixin"; export async function handleSoftError(errorPayload, forceRestart = false) { if (forceRestart) { @@ -20,8 +21,18 @@ export async function handleSoftError(errorPayload, forceRestart = false) { export async function handleHardError(errorPayload) { try { - analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); + const isInStaticErrorExperiment = experimentMixin.methods.hasSettingEqualTo( + 'UseStaticErrorPage', + 'true' + ); + + if(isInStaticErrorExperiment) { + analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); + window.top.location = "/fmg/static/error"; + } else { + handleSoftError(errorPayload, true); + } } finally { - window.top.location = "/fmg/static/error/"; + handleSoftError(errorPayload, true); } } From 6fe34a4797adf377cf3c8aca03707b276161a093 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Oct 2025 13:24:42 -0400 Subject: [PATCH 47/94] Return after successful error handling. --- src/router/methods/error.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 08bd182df..1d0428ed7 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -29,8 +29,10 @@ export async function handleHardError(errorPayload) { if(isInStaticErrorExperiment) { analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); window.top.location = "/fmg/static/error"; + return; } else { handleSoftError(errorPayload, true); + return; } } finally { handleSoftError(errorPayload, true); From 7aecf03856acefa87d54ca72ed11fbf8ac67c715 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Oct 2025 13:33:35 -0400 Subject: [PATCH 48/94] Handle no info scenario better + styling --- public/static/error/index.html | 6 +++--- src/router/methods/error.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/static/error/index.html b/public/static/error/index.html index 451c6301e..a4c141f85 100644 --- a/public/static/error/index.html +++ b/public/static/error/index.html @@ -268,12 +268,12 @@ body { try { const endpointUrl = `https://${apiHostname}/analytics/api/v1/logging/log-error`; - const infoString = bailoutInfo.map( + const infoString = bailoutInfo?.map( (entry) => `${entry.name}: ${entry.value}` - ).reduce( + )?.reduce( (prev, next) => `${prev}\n${next}` ); - const entryString = `User encountered bailout page.\n${new Date()}\n${infoString}`; + const entryString = `User encountered bailout page.\n${new Date()}\n${infoString ?? 'No information recoverable'}`; const request = new Request(endpointUrl, { method: 'POST', diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 1d0428ed7..551df4aed 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -22,11 +22,11 @@ export async function handleSoftError(errorPayload, forceRestart = false) { export async function handleHardError(errorPayload) { try { const isInStaticErrorExperiment = experimentMixin.methods.hasSettingEqualTo( - 'UseStaticErrorPage', - 'true' + "UseStaticErrorPage", + "true" ); - if(isInStaticErrorExperiment) { + if (isInStaticErrorExperiment) { analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); window.top.location = "/fmg/static/error"; return; From a868c05ebeb04486c5ef05ee8d699e798c70f8cf Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Oct 2025 15:24:58 -0400 Subject: [PATCH 49/94] Stabilize unit tests --- src/global-methods.spec.js | 2 +- src/router/methods/before-each.js | 2 +- src/router/methods/error.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/global-methods.spec.js b/src/global-methods.spec.js index c09bf8e97..c8c4d6da9 100644 --- a/src/global-methods.spec.js +++ b/src/global-methods.spec.js @@ -38,7 +38,7 @@ it("Global Methods - Call Http Client - Should Reject Promise", () => { isError: true, }); analyticsMixIn.methods.pushEventToGA = jest.fn(); - router.bailout = jest.fn(); + router.handleSoftError = jest.fn(); //Act globalMethods.callHttpClient(httpArgs).catch((err) => { diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js index 1796b40d7..67a6ecf84 100644 --- a/src/router/methods/before-each.js +++ b/src/router/methods/before-each.js @@ -108,7 +108,7 @@ export async function beforeEach(to, from) { console.log(error); // Eject user from Vue app in this scenario and clear localstorage. - handleHardError(errorPayload); + await handleHardError(errorPayload); return; } } diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 551df4aed..42807ce04 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -31,10 +31,10 @@ export async function handleHardError(errorPayload) { window.top.location = "/fmg/static/error"; return; } else { - handleSoftError(errorPayload, true); + await handleSoftError(errorPayload, true); return; } } finally { - handleSoftError(errorPayload, true); + await handleSoftError(errorPayload, true); } } From bb293cf0fc9bc62210d75f3df8f76604eda27752 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 09:09:29 -0400 Subject: [PATCH 50/94] CASH-1713 | Insurance pricing error (tax refactor) Moved helper function to a helper file Price all line items on payment-method just in case something isn't in serverData yet --- src/helpers/pricing-helper.js | 20 ++++++++++++ src/layouts/payment-method/payment-method.vue | 32 +++++++++---------- src/store/index.js | 21 +----------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index 9aa317353..1216836d5 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -99,3 +99,23 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) { return pricingResults[0]; } + +export function addPricesToLineItems(lineItems, pricingLineItems) { + lineItems.forEach((lineItem) => { + const lineItemIndex = pricingLineItems.findIndex( + (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber + ); + + if (lineItem.childParts) { + addPricesToLineItems(lineItem.childParts, pricingLineItems); + } + + const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; + lineItem.laborAmount = pricedLineItem.laborAmount; + lineItem.sellingPrice = pricedLineItem.sellingPrice; + lineItem.kitPrice = pricedLineItem.kitPrice; + lineItem.salesTax = pricedLineItem.salesTax; + }); + + return lineItems; +} \ No newline at end of file diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index ffb3d2353..6767777c8 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -155,7 +155,6 @@ import { getNewlyInactivatedPromos, } from "@/helpers/promotions-helper"; import { queryStrings } from "@/constants/query-strings"; -import { getQuerystringParameter } from "@/helpers/querystring-helper"; import { deepClone } from "@/helpers/object-helper"; import { Form } from "vee-validate"; @@ -163,17 +162,12 @@ import { defineRule } from "vee-validate"; import { required } from "@/helpers/validation-rules"; import { errorMessages } from "@/constants/error-messages"; import { AppointmentTypeStrings } from "@/constants/schedule-constants"; -import { partTypeStrings } from "@/constants/part-type-strings"; -import { mapTaxedLineItemsToStoreFormat } from "../../store"; import { coverageStatus } from "@/constants/insurance"; -import { containsLineItemWithPartType } from "@/helpers/service-package-helper"; import { containsRecalParts } from "@/helpers/recal-helper"; import { getBoolFromString } from "@/helpers/boolean-helper"; import { - getDisplayAmountDue, getAmountDue, - getSubTotal, - getSalesTax, + addPricesToLineItems } from "@/helpers/pricing-helper.js"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { debugLog } from "@/helpers/debug-log-helper"; @@ -231,21 +225,28 @@ export default { const availableVaps = [resultMap.rainRepel, ...resultMap.wipers]; - const pricedAvailableVaps = await baseMixin.methods.dispatchStoreActionWithLogging( + const lineItemsOnOrderAndAvailableVaps = [ + ...availableVaps, + ...glassParts, + ...supportingItems, + ...vaps, + ]; + + // All line items are already priced except availableVaps + // Price everything again to ensure that serverData has all values + // Specifically this addresses an error where insurance client glass parts are not in serverData + // See CASH-1713 for details + const pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { - availableLineItems: availableVaps, + availableLineItems: lineItemsOnOrderAndAvailableVaps, }, "payment-method", false ); - const lineItemsOnOrderAndAvailableVaps = [ - ...pricedAvailableVaps, - ...glassParts, - ...supportingItems, - ...vaps, - ]; + // Add prices to the availableVaps + const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems); // Promo logic // Populate the previous state of promos for toast message usage in "next()" @@ -265,7 +266,6 @@ export default { delete lineItemsForCart.serverData; // End of promo logic - // const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( lineItemsForCart.promos ?? [], diff --git a/src/store/index.js b/src/store/index.js index 1fa9b4f14..ef67b84a0 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -59,6 +59,7 @@ import { } from "@/helpers/recal-helper"; import { externalParameterStatus } from "@/constants/external-parameters"; import { experimentSettings } from "@/constants/experiments"; +import { addPricesToLineItems } from "@/helpers/pricing-helper"; // Export State const getDefaultState = () => { @@ -3701,26 +3702,6 @@ function convertGlassPieceNamingFromApi(glassArray) { return glassArray; } -function addPricesToLineItems(lineItems, pricingLineItems) { - lineItems.forEach((lineItem) => { - const lineItemIndex = pricingLineItems.findIndex( - (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber - ); - - if (lineItem.childParts) { - addPricesToLineItems(lineItem.childParts, pricingLineItems); - } - - const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; - lineItem.laborAmount = pricedLineItem.laborAmount; - lineItem.sellingPrice = pricedLineItem.sellingPrice; - lineItem.kitPrice = pricedLineItem.kitPrice; - lineItem.salesTax = pricedLineItem.salesTax; - }); - - return lineItems; -} - function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) { pricedLineItems.forEach((pricedLineItem) => { const lineItemIndex = taxingLineItems.findIndex( From 1e6d1c0bfceb80ce7bd53d01c50c32a9131f6667 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 09:11:42 -0400 Subject: [PATCH 51/94] CASH-1713 | Formatting --- src/helpers/pricing-helper.js | 2 +- src/layouts/payment-method/payment-method.vue | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index 1216836d5..0a8b85d7e 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -118,4 +118,4 @@ export function addPricesToLineItems(lineItems, pricingLineItems) { }); return lineItems; -} \ No newline at end of file +} diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 6767777c8..abee992bf 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -165,10 +165,7 @@ import { AppointmentTypeStrings } from "@/constants/schedule-constants"; import { coverageStatus } from "@/constants/insurance"; import { containsRecalParts } from "@/helpers/recal-helper"; import { getBoolFromString } from "@/helpers/boolean-helper"; -import { - getAmountDue, - addPricesToLineItems -} from "@/helpers/pricing-helper.js"; +import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { debugLog } from "@/helpers/debug-log-helper"; import { ErrorMessage } from "vee-validate"; From 9dc65e8df5175567aa2f09ebb5e73bff39c848b7 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 10:28:08 -0400 Subject: [PATCH 52/94] CASH-1713 | Add prices to wipers again --- src/layouts/payment-method/payment-method.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index abee992bf..502321d2e 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,7 +269,7 @@ export default { availableVaps, lineItemsForCart ); - lineItemsForCart.vaps = lineItemsForCart.vaps ?? []; + lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps, pricedLineItems) ?? []; lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( From 60163cef33d5d8424825ffd2436caf803afbcfd5 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 10:35:14 -0400 Subject: [PATCH 53/94] Null check --- src/layouts/payment-method/payment-method.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 502321d2e..f903d0c16 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,7 +269,7 @@ export default { availableVaps, lineItemsForCart ); - lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps, pricedLineItems) ?? []; + lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems); lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( From 84768ef658e8f8e206ca6d0a85dc55d0a7f57b2f Mon Sep 17 00:00:00 2001 From: kpatel8hs4io <31411746+kpatel8hs4io@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:14:24 -0400 Subject: [PATCH 54/94] adding afterpay breakout changes --- playwright-tests/.env.dev | 4 +- playwright-tests/framework/TestData.ts | 1 + playwright-tests/pages/BasePage.ts | 69 ++++--- playwright-tests/pages/HomePage.ts | 2 +- playwright-tests/pages/LookupPage.ts | 10 +- .../pages/OrderConfirmationPage.ts | 3 +- playwright-tests/pages/PaymentMethodPage.ts | 171 +++++++++++------- playwright-tests/pages/PaypalPage.ts | 7 +- playwright-tests/pages/SchedulePage.ts | 2 +- playwright-tests/pages/ServicePackagesPage.ts | 112 +++++++++--- playwright-tests/pages/ServiceZipPage.ts | 2 +- playwright-tests/playwright.config.ts | 3 +- .../tests/CashRepairInShopPayPal.ts | 4 +- 13 files changed, 258 insertions(+), 132 deletions(-) diff --git a/playwright-tests/.env.dev b/playwright-tests/.env.dev index 1dad92994..f02f656d0 100644 --- a/playwright-tests/.env.dev +++ b/playwright-tests/.env.dev @@ -9,11 +9,11 @@ SKIP_CONTENT_SITE=false # Base URLs by environment # qa -BASE_URL="https://www-qa2.safelite.com/" +# BASE_URL="https://www-qa2.safelite.com/" # local version of FMG (after running the local server) # BASE_URL="http://localhost:8080/fmg/" # qa with skipToInsurance Turned Off -# BASE_URL="https://fixmyglassqa.safelite.com/?cns=all&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true,NextGen_IGQSkipToInsurance=NextGen_IGQSkipToInsurance_V1=NextGen_IGQSkipToInsurance_CONTROL=true" +BASE_URL="https://www-qa2.safelite.com/?&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true" # sys # BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" # dev diff --git a/playwright-tests/framework/TestData.ts b/playwright-tests/framework/TestData.ts index cc9e44c23..9ed648426 100644 --- a/playwright-tests/framework/TestData.ts +++ b/playwright-tests/framework/TestData.ts @@ -5,4 +5,5 @@ export interface ITestData extends base { // Put any project-specific data here. Anything useful to other Safelite projects should be submitted as a pull request to safelite-playwright-core. paymentMethod: PaymentMethod isOptedInForTextMessages: boolean + totalAmount?: number } \ No newline at end of file diff --git a/playwright-tests/pages/BasePage.ts b/playwright-tests/pages/BasePage.ts index 1de8c0b7f..0239ecd3d 100644 --- a/playwright-tests/pages/BasePage.ts +++ b/playwright-tests/pages/BasePage.ts @@ -3,6 +3,7 @@ import { Soft } from 'safelite-playwright-core'; import { waitUntil } from 'safelite-playwright-core'; import test, { expect, type Locator, type Page } from '@playwright/test'; import { error } from 'console'; +import { ITestData } from 'framework/TestData'; /** * Base class for all page objects. @@ -18,7 +19,7 @@ export class BasePage { readonly hamburgerMenu: Locator; readonly progressBar: Locator; - constructor(page: Page){ + constructor(page: Page) { this.page = page; this.continueButton = page.locator('[id="infoBox"]').getByRole('button'); this.backButton = page.locator('[id="infoBox"]').getByRole('link'); @@ -27,21 +28,22 @@ export class BasePage { this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' }); this.progressBar = this.page.locator('.progress-bar-outer .progress-bar-inner'); } - + async nextPage() { - await waitUntil(async () => {; - return (await this.continueButton.getAttribute('aria-disabled')) !== 'true' - }); - - await this.continueButton.click(); - await waitUntil(async () => { - let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); - return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); - }); + ; + return (await this.continueButton.getAttribute('aria-disabled')) !== 'true' + }); + + await this.continueButton.click(); + + await waitUntil(async () => { + let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); + return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); + }); } - - + + async previousPage() { const startingUrl = this.page.url(); await expect(async () => { @@ -49,14 +51,14 @@ export class BasePage { if (currentUrl === startingUrl) { await this.backButton.click({ timeout: 1000 }); } - expect(currentUrl).not.toEqual(startingUrl); + expect(currentUrl).not.toEqual(startingUrl); }).toPass({ timeout: 240_000 }); } - async fillAndValidate(element: Locator, value: string){ + async fillAndValidate(element: Locator, value: string) { await expect(async () => { var text = await element.textContent(); - if(text !== value) { + if (text !== value) { await element.clear(); await element.fill(value); } @@ -79,7 +81,7 @@ export class BasePage { } await page.waitForTimeout(100); // Small delay before retrying } - console.log(`Failed to click the the element within ${timeout/1000} seconds` + error); + console.log(`Failed to click the the element within ${timeout / 1000} seconds` + error); } async logReferralNumber() { @@ -107,7 +109,7 @@ export class BasePage { const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/schedule/api/v1/schedule/mobile-time-slots`; await this.page.route(apiUrl, async (route) => { const currentDate = new Date().toISOString().split('T')[0]; // e.g., "2025-07-23" - if(route.request().postDataJSON().startDate === currentDate) { + if (route.request().postDataJSON().startDate === currentDate) { const response = await route.fetch(); const responseBody = await response.json(); @@ -120,28 +122,45 @@ export class BasePage { }); customerDetails.apptDate = responseBody.days.find((day: any) => day.timeSlots.some((slot: any) => slot.offerPremium === true)).date || undefined; - + // Mock the response await route.fulfill({ response, body: JSON.stringify(responseBody), }); } - }); + }); + } + + async getRepairPartsTotal(testData: Partial) { + const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/price/api/v1/price/order-items`; + await this.page.on('response', async (response) => { + const orderItemRequest = response.request(); + if (response.request().url() === apiUrl && orderItemRequest.postDataJSON().lineItems.some(item => item.partNumber === "WSREPAIR")) { + const responseBody = await response.json(); + const targetParts = ['WSREPAIR']; // ["SUPPLIES-REPAIR", "WSREPAIR"]; + testData.totalAmount = responseBody.lineItems + .filter(item => targetParts.includes(item.partNumber)) + .reduce((sum: number, item: any) => { + return sum + item.laborAmount + item.sellingPrice + item.kitPrice; + }, 0).toFixed(2); + } + }); + testData.totalAmount! > 0 ? console.log(`Total Amount: ${testData.totalAmount}`) : console.error('No repair parts found in the order items.'); } async validateProgressBar(progressPercentage: string, timeout: number = 60000) { // This section has been commented out until progress bar work is completed for parity. - + await waitUntil(async () => { - let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); - return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); - }); + let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); + return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); + }); // Take a screenshot of the page before validating the progress bar await this.page.screenshot({ path: `test-results\\ortoni-data\\progress-bar-${Date.now()}.png`, fullPage: true }); // Wait until the progress bar element is attached and visible - + const actualProgressPercentage = await this.progressBar.evaluate( async (element) => { await new Promise(resolve => setTimeout(resolve, 200)); diff --git a/playwright-tests/pages/HomePage.ts b/playwright-tests/pages/HomePage.ts index 3f934f9c9..b28dd75a4 100644 --- a/playwright-tests/pages/HomePage.ts +++ b/playwright-tests/pages/HomePage.ts @@ -30,7 +30,7 @@ export class HomePage extends BasePage { super(page); this.page = page; - this.letsGetStartedButton = this.page.locator('a.btn.btn-primary.ghost'); + this.letsGetStartedButton = this.page.locator('.hero-content #zipCodeTextboxButton'); this.cusmodalPopup = this.page.locator('#Cusmodalpopup'); this.closePopupButton = this.page.getByRole('button', { name: '×' }); diff --git a/playwright-tests/pages/LookupPage.ts b/playwright-tests/pages/LookupPage.ts index b964b3452..e5f2fca04 100644 --- a/playwright-tests/pages/LookupPage.ts +++ b/playwright-tests/pages/LookupPage.ts @@ -1,6 +1,7 @@ import { type Locator, type Page, expect } from '@playwright/test'; import { BasePage } from './BasePage'; -import { IAlertFlags, TestSuccessAlert } from 'safelite-playwright-core'; +import { IAlertFlags, TestSuccessAlert, VehicleDamage } from 'safelite-playwright-core'; +import { ITestData } from 'framework/TestData'; import { VehicleLookupType } from 'safelite-playwright-core'; export class LookupPage extends BasePage { @@ -105,8 +106,9 @@ export class LookupPage extends BasePage { } } - async handleZipValidation(zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise { + async handleZipValidation(testData: Partial, zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise { + let { vehicleDamage } = testData; // Only proceed with validation if alertFlags is provided if (alertFlags) { if (alertFlags.isUnserviceableZip) { @@ -122,6 +124,10 @@ export class LookupPage extends BasePage { } } + + if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) { + await this.getRepairPartsTotal(testData); + }; // If no alert flags or no matching condition, just continue await this.nextPage(); diff --git a/playwright-tests/pages/OrderConfirmationPage.ts b/playwright-tests/pages/OrderConfirmationPage.ts index e227479fd..02951bdd2 100644 --- a/playwright-tests/pages/OrderConfirmationPage.ts +++ b/playwright-tests/pages/OrderConfirmationPage.ts @@ -169,9 +169,10 @@ export class OrderConfirmationPage extends BasePage { : appointmentDetails?.serviceLocation == ServiceLocation.InShop ? formattedExpectedAppointmentDate + `${customerDetails!.apptTime}` : formattedExpectedAppointmentDate + "Drop off before 9:30 AM" + // : formattedExpectedAppointmentDate + "before 9:30 AM" ); appointmentSummary.push("Add to calendar"); - appointmentSummary.push( + appointmentSummary.push( appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? appointmentDetails?.serviceAddress ? ("We're coming to you at" + appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode + "to service your " + `${vehicleDetails!.year} ${vehicleDetails!.make} ${vehicleDetails!.model}`) diff --git a/playwright-tests/pages/PaymentMethodPage.ts b/playwright-tests/pages/PaymentMethodPage.ts index 00bb9c2e1..4ea1a8b82 100644 --- a/playwright-tests/pages/PaymentMethodPage.ts +++ b/playwright-tests/pages/PaymentMethodPage.ts @@ -1,7 +1,7 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; import { IPaymentDetails, Soft, waitUntil } from 'safelite-playwright-core'; -import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core'; +import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core'; import { PaymentMethod, ProgressBarPercentages } from 'framework/localTypes/Enums'; import { PaymentPage } from './PaymentPage'; import { AfterpayPage } from './AfterpayPage'; @@ -26,6 +26,8 @@ export class PaymentMethodPage extends BasePage { readonly recalibrationCheckbox: Locator; readonly paymentPage: PaymentPage; readonly paypalPage: PaypalPage; + readonly afterPayBreakoutSection: Locator; + readonly afterPayToggle: Locator; // Payment detail page validation locators readonly reviewTable: Locator; @@ -61,12 +63,12 @@ export class PaymentMethodPage extends BasePage { // Payment details validation locators this.reviewTable = this.page.locator('div.review-table'); - + // Section locators - find by heading text this.serviceLocationDateandTimeSection = this.page.locator('.review-table').locator('div .service-location'); - this.vehicleDamageLocationsSection= this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]'); + this.vehicleDamageLocationsSection = this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]'); this.contactDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Contact details' }).first(); - + // Cart panel elements this.cartPanelDetails = this.page.locator('.cart-panel'); this.subtotalText = this.page.locator('.sub-total'); @@ -75,6 +77,8 @@ export class PaymentMethodPage extends BasePage { this.wiperBladesText = this.page.locator('div', { hasText: /^New wiper blades$/ }); this.rainDefenseText = this.page.locator('div', { hasText: /^Rain Defense™$/ }); this.deductibleText = this.page.locator('#deductible-value'); + this.afterPayBreakoutSection = this.page.locator('div.alert-info:has(.after-pay)'); + this.afterPayToggle = this.page.locator('.after-pay-toggle'); } async validatePaymentDetailsPage(testData: Partial) { @@ -84,15 +88,15 @@ export class PaymentMethodPage extends BasePage { isUseVehicleOnPolicy, paymentMethod, vehicleDamage } = testData; // Wait for review table to be visible to ensure page is loaded - await this.appointmentDetailsDropdown.waitFor({state: "visible"}); + await this.appointmentDetailsDropdown.waitFor({ state: "visible" }); await this.appointmentDetailsDropdown.click().then( async () => { - await waitUntil(async () => { - return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; + await waitUntil(async () => { + return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; }); } ); - + let expectedAppointmentDetails = new Map(); expectedAppointmentDetails = await this.getExpectedServiceLocationDateAndTimeSection(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedVehicleDamageAndVehicle(testData, expectedAppointmentDetails); @@ -102,7 +106,7 @@ export class PaymentMethodPage extends BasePage { let actualAppointmentDetails = await this.getActualAppointmentDetails(); for (const key in expectedAppointmentDetails) { Soft.expect(actualAppointmentDetails[key]?.map(item => item.toLowerCase())) - .toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase())); + .toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase())); } // Expand cart to see all details @@ -120,16 +124,16 @@ export class PaymentMethodPage extends BasePage { // Extract amounts for self-pay customers const subtotalAmount = this.extractAmount(subtotalValue); const finalAmountDueAmount = this.extractAmount(finalAmountDueValue); - + // Subtotal should be greater than 0 Soft.expect(subtotalAmount).toBeGreaterThan(0); - + // Final amount differs based on payment type if (paymentDetails?.paymentType === PaymentType.PayAtService) { Soft.expect(finalAmountDueAmount).toBeGreaterThan(0); } else if (paymentDetails?.paymentType === PaymentType.Credit || - paymentDetails?.paymentType === PaymentType.Paypal || - paymentDetails?.paymentType === PaymentType.AfterPay) { + paymentDetails?.paymentType === PaymentType.Paypal || + paymentDetails?.paymentType === PaymentType.AfterPay) { // For payment types that charge immediately, amount due could be 0 // This logic might need adjusting based on actual business rules } @@ -186,11 +190,11 @@ export class PaymentMethodPage extends BasePage { if (!text) throw new Error("Subtotal text is empty"); return text; } - + async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) { const browserContext = this.page.context(); - switch(paymentDetails.paymentType) { + switch (paymentDetails.paymentType) { case PaymentType.Credit: await this.selectCreditCard(); await this.nextPage(); @@ -228,15 +232,15 @@ export class PaymentMethodPage extends BasePage { } } - async selectPaypal(){ + async selectPaypal() { await this.payNowButton.click(); } -l - async selectCreditCard(){ + l + async selectCreditCard() { await this.payNowButton.click(); } - async selectPayAtService(isRecalVehicle: boolean){ + async selectPayAtService(isRecalVehicle: boolean) { if (await this.payAtServiceButton.isVisible()) { await this.payAtServiceButton.click(); } else if (isRecalVehicle) { @@ -248,13 +252,13 @@ l async verifyVAPS(): Promise { // Get Vuex state from localStorage const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); - + // Validate the count of FRONT WIPER parts if (vuexState.order?.lineItems?.vaps?.length > 0) { const frontWiperPartsCount = vuexState.order.lineItems.vaps.filter(vap => vap.partType === "FRONT WIPER" ).length; - + await expect(frontWiperPartsCount).toBe(2); } else { throw new Error("No VAPS found in the order"); @@ -264,7 +268,7 @@ l async getActualAppointmentDetails(): Promise { let actualAppointmentDetails = new Map(); let appointmentDetailsSubSections = await this.appointmentDetailsSection.locator('.review-table .py-3').all(); - + for (let element of appointmentDetailsSubSections) { let label = await element.locator('div .text-block').innerText(); //added to trim the text to remove any leading or trailing spaces (example: "expert installation " to "expert installation") @@ -282,9 +286,9 @@ l async getExpectedVehicleDamageAndVehicle(testdata: Partial, expectedServicePackageDetails: Map): Promise { - const {vehicleDetails, vehicleDamage} = testdata; + const { vehicleDetails, vehicleDamage } = testdata; - let vehicleDamageText: string= ''; + let vehicleDamageText: string = ''; if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldOneChip || item == VehicleDamage.WindshieldTwoChips || item == VehicleDamage.WindshieldThreeChips)) { vehicleDamageText = "Repair the windshield of your"; } @@ -300,17 +304,17 @@ l if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverFrontDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Front Glass" : "Replace the Driver Front Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverRearDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Back Glass" : "Replace the Driver Back Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverVentGlass) && vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldCrack)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Vent Glass" : "Replace the Driver Vent Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverQuarterPanel)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerVentGlass)) { @@ -320,35 +324,34 @@ l if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerFrontDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Front Glass" : "Replace the Passenger Front Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerRearDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Back Glass" : "Replace the Passenger Back Glass"; - } + } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerQuarterPanel)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.RearWindow || item == VehicleDamage.RearSliding)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass"; } // Replace last comma with " and" if there are multiple damages - if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield") - { + if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield") { vehicleDamageText = vehicleDamageText.replace(/,([^,]*)$/, " and$1") + " of your"; } - + expectedServicePackageDetails[vehicleDamageText] = [vehicleDetails?.year + " " + vehicleDetails?.make + " " + vehicleDetails?.model]; return expectedServicePackageDetails; } async expectedServicePackageDetails(testData: Partial, expectedServicePackageDetails: Map): Promise { - const {servicePackage} = testData; + const { servicePackage } = testData; - let localStorage= JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + let localStorage = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); let isRepair = localStorage.order.damage.isRepair as boolean; - let isInsurance = localStorage.order.payment.isInsurance as boolean; + let isInsurance = localStorage.order.payment.isInsurance as boolean; let hasNonWindshieldGlass: boolean = false; if (!isRepair) { hasNonWindshieldGlass = localStorage.order.lineItems.glassParts.find((item: any) => item.partType !== "WINDSHIELD") ? true : false; @@ -362,33 +365,32 @@ l } let recalRequired = !isRepair && (isInsurance || isCaliforniaState) && hasRecalPart && canSafeliteRecalibrate let stringForRepair: string[] = ["Expert windshield repair", "Exclusive resin sealant", "Nationwide lifetime guarantee"]; - let stringIfRecal = isRepair - ? "" - : recalRequired - ? " and recalibration" - : ""; + let stringIfRecal = isRepair + ? "" + : recalRequired + ? " and recalibration" + : ""; let stringForReplace: string[] = [hasNonWindshieldGlass ? "New replacement glass" : "New replacement windshield", "Expert installation" + `${stringIfRecal}`, "Nationwide lifetime warranty"]; - switch (servicePackage) - { - case ServicePackage.GlassOnly: - expectedServicePackageDetails["Glass service only"] = isRepair - ? stringForRepair - : stringForReplace; - break; - case ServicePackage.Standard: - stringForRepair.push("New wiper blades"); - stringForReplace.push("New wiper blades"); - expectedServicePackageDetails["Standard service"] = isRepair - ? stringForRepair - : stringForReplace; - break; - case ServicePackage.Premium: - stringForRepair.push("New wiper blades", "Rain repel treatment"); - stringForReplace.push("New wiper blades", "Rain repel treatment"); - expectedServicePackageDetails["Premium service"] = isRepair - ? stringForRepair - : stringForReplace; - break; + switch (servicePackage) { + case ServicePackage.GlassOnly: + expectedServicePackageDetails["Glass service only"] = isRepair + ? stringForRepair + : stringForReplace; + break; + case ServicePackage.Standard: + stringForRepair.push("New wiper blades"); + stringForReplace.push("New wiper blades"); + expectedServicePackageDetails["Standard service"] = isRepair + ? stringForRepair + : stringForReplace; + break; + case ServicePackage.Premium: + stringForRepair.push("New wiper blades", "Rain repel treatment"); + stringForReplace.push("New wiper blades", "Rain repel treatment"); + expectedServicePackageDetails["Premium service"] = isRepair + ? stringForRepair + : stringForReplace; + break; } return expectedServicePackageDetails; } @@ -396,8 +398,8 @@ l async getExpectedServiceLocationDateAndTimeSection(testData: Partial, expectedServicePackageDetails: Map): Promise { const { customerDetails, appointmentDetails } = testData; let serviceLocationTitle = appointmentDetails?.serviceLocation == ServiceLocation.Mobile - ? "We're coming to you" - : "You're coming to us"; + ? "We're coming to you" + : "You're coming to us"; let serviceLocationText = appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? appointmentDetails?.serviceAddress ? appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode @@ -443,7 +445,7 @@ l const { customerDetails } = testData; let isOptedInForTextMessages = testData.isOptedInForTextMessages ?? false; - + let expectedText = isOptedInForTextMessages ? customerDetails?.phoneNumber : "Not opted in"; @@ -451,6 +453,40 @@ l return expectedServicePackageDetails; } + async ValidateAfterPayBreakOutSection() { + /*const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + const isInsurance = vuexState.order.payment.isInsurance; + const currentDeductible = vuexState.order.policy.currentDeductible; + const isVerified = vuexState.order.payment.insuranceCoverage.isVerified;*/ + + const amountDueText = await this.amountDueTextField.first().textContent(); + + const verifyAfterpayBreakout = amountDueText?.includes('$') + ? Number.parseFloat(amountDueText.replace(/[^0-9.]/g, '')) > 0 + : false + + if (verifyAfterpayBreakout) { + const afterPayAmountString = await this.afterPayBreakoutSection.locator('.afterpay-amount').textContent(); + const afterPayAmount = Number.parseFloat(afterPayAmountString!.replace(/[^0-9.]/g, '')); + Soft.expect(afterPayAmount, `AfterPayAmount (${afterPayAmount}) is > 0`).toBeGreaterThan(0); + await this.afterPayToggle.click().then( + async () => { + await waitUntil(async () => { + return (await this.page.locator('.after-pay-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; + }); + } + ); + const afterPayCards = await this.page.locator('.after-pay-details .payment-card').all(); + Soft.expect(afterPayCards.length, `There are 4 afterpay cards`).toBe(4); + for (const afterPayCard of afterPayCards) { + const afterPayAmountWithinCardString = await afterPayCard.locator('.amount-due').textContent(); + const afterPayAmountWithinCard = Number.parseFloat(afterPayAmountWithinCardString!.replace(/[^0-9.]/g, '')); + Soft.expect(afterPayAmountWithinCard, `AfterPayAmount (${afterPayAmountWithinCard}) > 0`).toBeGreaterThan(0); + } + } + } + async getFormattedAppointmentDate(appointmentDate: string) { // Parse the original date @@ -467,7 +503,8 @@ l await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage); await this.validatePaymentDetailsPage(testData); - + await this.ValidateAfterPayBreakOutSection(); + // Verify VAPS wipers on backend for standard and premium packages if (servicePackage === ServicePackage.Standard || servicePackage === ServicePackage.Premium) { await this.verifyVAPS(); diff --git a/playwright-tests/pages/PaypalPage.ts b/playwright-tests/pages/PaypalPage.ts index ff2bbd548..ce5547f3c 100644 --- a/playwright-tests/pages/PaypalPage.ts +++ b/playwright-tests/pages/PaypalPage.ts @@ -13,19 +13,21 @@ export class PaypalPage extends BasePage { readonly completePurchaseButton: Locator; readonly payWithRadioButton: Locator; readonly payButton: Locator; + readonly tryAnotherWayButton: Locator; constructor(page: Page) { super(page); this.page = page; - this.usernameTextBox = page.getByPlaceholder('Email'); + this.usernameTextBox = page.locator('#email'); this.nextButton = page.getByRole('button', { name: 'Next' }); this.loginWithPasswordButton = page.getByRole('link', { name: 'Log in with a password instead' }) - this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use Password Instead' }); + this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use password instead' }); this.passwordTextBox = page.getByPlaceholder('Password'); this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true }); this.completePurchaseButton = page.getByTestId('submit-button-initial') this.payWithRadioButton = page.getByRole('button').filter({ hasText: 'Pay with' }); this.payButton = page.locator('#one-time-cta'); + this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' }); } async completePaypalPurchase(paymentDetails: IPaymentDetails){ @@ -41,6 +43,7 @@ export class PaypalPage extends BasePage { } else { await this.usernameTextBox.fill(paymentDetails.username!); await this.nextButton.click(); + await this.tryAnotherWayButton.click(); await this.usePasswordInsteadButton.click(); await this.passwordTextBox.fill(paymentDetails.password!); await this.paypalLoginButton.click(); diff --git a/playwright-tests/pages/SchedulePage.ts b/playwright-tests/pages/SchedulePage.ts index bad1e4a08..77b330aff 100644 --- a/playwright-tests/pages/SchedulePage.ts +++ b/playwright-tests/pages/SchedulePage.ts @@ -212,7 +212,7 @@ export class SchedulePage extends BasePage { let formattedTimeSlot: string = ""; if (selectedTimeSlot.toLowerCase().includes("drop")) { - formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "drop off before 9:30 AM"; + formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "before 9:30 AM" // "drop off before 9:30 AM"; } else if (selectedTimeSlot.includes("-") || selectedTimeSlot.includes("Earlybird")) // - means Mobile time slot where service time is between 8:00 AM - 12:00 PM { diff --git a/playwright-tests/pages/ServicePackagesPage.ts b/playwright-tests/pages/ServicePackagesPage.ts index 95e31a746..63a60a6e6 100644 --- a/playwright-tests/pages/ServicePackagesPage.ts +++ b/playwright-tests/pages/ServicePackagesPage.ts @@ -1,6 +1,6 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; -import { AppointmentTimeslot, ServicePackage, VehicleDamage } from 'safelite-playwright-core'; +import { AppointmentTimeslot, ServicePackage, Soft, VehicleDamage } from 'safelite-playwright-core'; import { ProgressBarPercentages } from 'framework/localTypes/Enums'; import { PaymentMethod } from "framework/localTypes/Enums"; import { step } from 'framework/localTypes/Step'; @@ -14,6 +14,8 @@ export class ServicePackagesPage extends BasePage { readonly payOnMyOwnButton: Locator; readonly paywithInsuranceButton: Locator; readonly iHavePromoCodeButton: Locator; + readonly afterPayBanner: Locator; + readonly glassOnlypackagePrice: Locator; //Your quote is almost ready modal readonly skipQuoteEmailButton: Locator; @@ -32,16 +34,18 @@ export class ServicePackagesPage extends BasePage { this.standardPackageButton = this.page.getByText('Standard'); this.premiumPackageButton = this.page.getByText('Premium'); this.glassOnlyButton = this.page.getByText('Glass service only', { exact: true }); - this.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' }).locator('div'); - this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' }).locator('div'); + this.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' }); + this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' }); this.iHavePromoCodeButton = this.page.getByRole('link', { name: 'I have a promo code' }); this.skipQuoteEmailButton = this.page.getByRole('button', { name: 'Skip' }); this.emailInput = this.page.getByRole('textbox', { name: 'Enter your email address' }); this.getMyQuoteButton = this.page.getByRole('button', { name: 'Send' }); - this.closeButton = this.page. getByRole('dialog').locator('button').filter({ hasText: 'Close' }); + this.closeButton = this.page.getByRole('dialog').locator('button').filter({ hasText: 'Close' }); this.promoCodeTextbox = this.page.getByLabel('Enter a promo code'); this.applyPromoButton = this.page.getByRole('button', { name: 'Apply promo code' }); this.repeatedClicksModalCloseButton = this.page.locator('.QSISlider').locator('img[src*=\'close\']'); + this.afterPayBanner = this.page.locator('#afterpay-banner'); + this.glassOnlypackagePrice = this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ hasText: 'Glass service only' }).locator('.pricing-info'); } async selectPaymentMethod(method: PaymentMethod): Promise { @@ -62,7 +66,11 @@ export class ServicePackagesPage extends BasePage { if (servicePackage != null) { await locators[servicePackage].click(); - } + } + } + + async getVuex() { + return JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); } async handleQuotePopup(email?: string): Promise { @@ -87,16 +95,16 @@ export class ServicePackagesPage extends BasePage { await this.promoCodeTextbox.fill(promoCode); await this.applyPromoButton.click(); } - + async verifyCanNotRecal(): Promise { // Get Vuex state from localStorage - const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + const vuexState = await this.getVuex(); // Validate in the backend to make sure the can safelite recalibrate data is correct if (vuexState.order?.lineItems?.glassParts?.length > 0) { for (const glassPart of vuexState.order.lineItems.glassParts) { - await expect(glassPart.canSafeliteRecalibrate).toBe(false); - await expect(glassPart.requiresRecalibration).toBe(true); + expect(glassPart.canSafeliteRecalibrate).toBe(false); + expect(glassPart.requiresRecalibration).toBe(true); } return true; } @@ -106,7 +114,7 @@ export class ServicePackagesPage extends BasePage { async verifyDynamicRecal(): Promise { // Get Vuex state from localStorage const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); - + // Validate in the backend to make sure the dynamic recalibration part is present if (vuexState.order?.lineItems?.glassParts?.length > 0) { const hasDynamicRecalPart = vuexState.order.lineItems.glassParts.some(glassPart => @@ -114,7 +122,7 @@ export class ServicePackagesPage extends BasePage { childPart.partNumber.includes("RECAL DYNAMIC") ) ); - + await expect(hasDynamicRecalPart).toBe(true); console.log("Recal part line item is verified"); } else { @@ -144,25 +152,25 @@ export class ServicePackagesPage extends BasePage { await expect(vuexState.order.damage.isRepair).toBe(false); } } - + async verifyVehicleParts(vehicleDamage: VehicleDamage[]): Promise { // Get Vuex state from localStorage const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); - + // Validate the presence of specific parts in the glassParts array const glassParts = vuexState.order?.lineItems?.glassParts; const WINDSHIELD_TYPES = [ -   "SINGLE WINDSHIELD", -   "DRIVER SPLIT WINDSHIELD", -   "PASSENGER SPLIT WINDSHIELD" + "SINGLE WINDSHIELD", + "DRIVER SPLIT WINDSHIELD", + "PASSENGER SPLIT WINDSHIELD" ]; if (glassParts?.length > 0) { for (const partType of vehicleDamage) { // Normalize part type to "WINDSHIELD" if it matches any of the defined types (Split Windshield types) -   const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType; + const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType; const hasPartType = glassParts.some(glassPart => glassPart.partType === normalizedPartType); @@ -173,14 +181,46 @@ export class ServicePackagesPage extends BasePage { } } + async VerifyAfterpayBreakout() { + const isAfterPayBannerVisible = await this.afterPayBanner.isVisible({ timeout: 2000 }) + await Soft.expect(isAfterPayBannerVisible, "AfterPay Banner is visible.").toBeTruthy(); + const localStorage = await this.getVuex(); + let isRepair = localStorage.order.damage.isRepair as boolean; + + const servicePackages = await this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ visible: true }).all(); + for (const servicePackage of servicePackages) { + const servicePackageButtonLabel = await servicePackage.getAttribute('buttonlabel'); + if (!isRepair) { + const hasRecalPart = localStorage.order.lineItems.glassParts.find((item: any) => item.requiresRecalibration === true) ? true : false; + const canSafeliteRecalibrate = localStorage.order.lineItems.glassParts.find((item: any) => item.canSafeliteRecalibrate === true) ? true : false; + if (hasRecalPart && canSafeliteRecalibrate) { + const servicePackageTextContent = await servicePackage.textContent(); + await Soft.expect(servicePackageTextContent, `${servicePackageButtonLabel} package contains "Expert installation and recalibration"`).toContain('Expert installation and recalibration'); + } + } + const afterPayPricingInfo = await servicePackage.locator('.pricing-info').filter({ visible: true }).textContent(); + const classAttribute = await this.payOnMyOwnButton.getAttribute('class'); + const isPayOnMyOwnSelected = classAttribute?.includes('selected'); + if (isPayOnMyOwnSelected) { + const regex = /^\$\d+.\d{2}in\s4\sinterest-free\spayments\sor\s(\$\d+.\d{2}){1,2}\sin\ssingle\spayment\s$/; + const priceInfoRegexMatch = regex.test(afterPayPricingInfo!) + await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy(); + } else { + const regex = /^As\slittle\sas\s\$\d+.\d{2}$/; + const priceInfoRegexMatch = regex.test(afterPayPricingInfo!) + await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy(); + } + } + } + async verifyOEMPart(): Promise { // Get Vuex state from localStorage const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); - + // Validate the presence of an OEM part if (vuexState.order?.lineItems?.glassParts?.length > 0) { const firstGlassPartNumber = vuexState.order.lineItems.glassParts[0].partNumber; - + await expect(firstGlassPartNumber.includes("OEM")).toBe(true); } else { throw new Error("No glass parts found in the order"); @@ -189,8 +229,8 @@ export class ServicePackagesPage extends BasePage { @step("ServicePackagePage >> Select Payment Method and Service Type: ") async handleServicePackagePage(testData: Partial) { - const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails } = testData; - + const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails, totalAmount } = testData; + await this.validateProgressBar(ProgressBarPercentages.ServicePackagePage); // Define repair damage types (vs. replacement types) const repairTypes: VehicleDamage[] = [ @@ -205,32 +245,50 @@ export class ServicePackagesPage extends BasePage { }); await this.handleQuotePopup(customerDetails!.email!); + + // validate total amount for 3 chip repair + if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) { + const priceText = await this.glassOnlypackagePrice.textContent() || '$0.00'; + + // Regex to match dollar amounts like $54.99 or $219.97 + const regex = /\$(\d+\.\d{2})/g; + + // Match all dollar amounts + const matches = priceText.match(regex); + const price = matches!.length > 1 ? matches![1] : matches![0]; + + const actualPrice = Number.parseFloat(price.replace('$', '')).toString(); + Soft.expect(actualPrice).toEqual(totalAmount); + } + await this.selectPaymentMethod(paymentMethod!); await this.selectServicePackage(servicePackage!); - + + await this.VerifyAfterpayBreakout(); + // Enter promo code if (paymentDetails?.promoCode) { await this.enterPromo(paymentDetails.promoCode); } - + // Backend Validations // Validate backend for can not recal if applicable if (isCanNotRecal) { await this.verifyCanNotRecal(); } - + // Validate backend for dynamic recal if applicable if (isDynamicRecal) { await this.verifyDynamicRecal(); } - + // Validate backend for repair info (including chip verification) await this.verifyIsRepair(!isReplace, vehicleDamage!); if (isReplace) { // Validate backend for parts info await this.verifyVehicleParts(vehicleDamage!); } - + // Validate backend for OEM endorsement if (hasOemEndorsement) { await this.verifyOEMPart(); @@ -239,7 +297,7 @@ export class ServicePackagesPage extends BasePage { if (appointmentDetails?.appointmentTimeSlot === AppointmentTimeslot.EarlyBird) { await this.mockScheduleResponseForEarlyBird(customerDetails!); } - + await this.nextPage(); } } \ No newline at end of file diff --git a/playwright-tests/pages/ServiceZipPage.ts b/playwright-tests/pages/ServiceZipPage.ts index 2aa446eca..91393f892 100644 --- a/playwright-tests/pages/ServiceZipPage.ts +++ b/playwright-tests/pages/ServiceZipPage.ts @@ -15,6 +15,6 @@ export class ServiceZipPage extends LookupPage { const { customerDetails, vehicleDetails, alertFlags } = testData; await this.validateProgressBar(ProgressBarPercentages.ServiceZipPage); await this.enterZip(customerDetails!.address.postalCode!); - await this.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + await this.handleZipValidation(testData, customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); } } \ No newline at end of file diff --git a/playwright-tests/playwright.config.ts b/playwright-tests/playwright.config.ts index 40b1ddb01..016977e3c 100644 --- a/playwright-tests/playwright.config.ts +++ b/playwright-tests/playwright.config.ts @@ -54,6 +54,7 @@ const jiraReportConfig: JiraReporterConfig = { jiraProjectKey: process.env.JIRA_PROJECT_KEY || '', jiraEpicKey: process.env.JIRA_EPIC_KEY || '', jiraCardNumber: process.env.JIRA_CARD_NUMBER || '', + applicationName: 'FMG 2.0', jiraApiUtilConfig: { jiraUrl: process.env.JIRA_SERVER || '', jiraUsername: process.env.JIRA_USERNAME || '', @@ -99,7 +100,7 @@ export default defineConfig({ headless: process.env.CI ? true : false, screenshot: "only-on-failure", actionTimeout: 60_000, - navigationTimeout: 60_000, + navigationTimeout: 60_000 }, /* Configure projects for major browsers */ diff --git a/playwright-tests/tests/CashRepairInShopPayPal.ts b/playwright-tests/tests/CashRepairInShopPayPal.ts index 2a36127f4..0d559f03b 100644 --- a/playwright-tests/tests/CashRepairInShopPayPal.ts +++ b/playwright-tests/tests/CashRepairInShopPayPal.ts @@ -32,8 +32,8 @@ const cashRepairInShopPayPalData : Partial = { appointmentDetails: { ...getDefaultTestData().appointmentDetails!, shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" - }, - + }, + // Use predefined payment data paymentDetails: ClientData.getDefaultPaypalDetails() } From cc459ec7eedbb3c2886d09ec7065582fb2ef0cf7 Mon Sep 17 00:00:00 2001 From: kpatel8hs4io <31411746+kpatel8hs4io@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:25:05 -0400 Subject: [PATCH 55/94] minor change --- playwright-tests/pages/BasePage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/playwright-tests/pages/BasePage.ts b/playwright-tests/pages/BasePage.ts index 0239ecd3d..6596986df 100644 --- a/playwright-tests/pages/BasePage.ts +++ b/playwright-tests/pages/BasePage.ts @@ -31,7 +31,6 @@ export class BasePage { async nextPage() { await waitUntil(async () => { - ; return (await this.continueButton.getAttribute('aria-disabled')) !== 'true' }); From 30b1cfd787eb25fd333053f623e5fb805467abd4 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Wed, 22 Oct 2025 11:44:11 -0400 Subject: [PATCH 56/94] Tie up possible race conditions --- src/router/methods/before-each.js | 4 ++-- src/router/methods/navigate.js | 2 +- src/router/methods/route-logic/error.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js index 67a6ecf84..15eae5d8c 100644 --- a/src/router/methods/before-each.js +++ b/src/router/methods/before-each.js @@ -41,7 +41,7 @@ export async function beforeEach(to, from) { nextPage: to?.name, }; - handleSoftError(errorPayload, true); + await handleSoftError(errorPayload, true); return false; } @@ -88,7 +88,7 @@ export async function beforeEach(to, from) { nextPage: to?.name, }; - handleSoftError(errorPayload); + await handleSoftError(errorPayload); return; } diff --git a/src/router/methods/navigate.js b/src/router/methods/navigate.js index 25c58e7e5..b80d31769 100644 --- a/src/router/methods/navigate.js +++ b/src/router/methods/navigate.js @@ -30,7 +30,7 @@ async function navigate(scenario, currentPageName, withSaving = false, forceTopL nextPage: nextPage?.name, }; - handleSoftError(errorPayload); + await handleSoftError(errorPayload); return; } diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js index 573d3cddd..d82080f88 100644 --- a/src/router/methods/route-logic/error.js +++ b/src/router/methods/route-logic/error.js @@ -18,7 +18,7 @@ export async function errorBeforeEnter(to, from) { nextPage: to?.name, }; - handleHardError(errorPayload); + await handleHardError(errorPayload); return; } @@ -31,7 +31,7 @@ export async function errorBeforeEnter(to, from) { nextPage: to?.name, }; - handleHardError(errorPayload); + await handleHardError(errorPayload); return; } else { await store.dispatch(storeActions.UPDATE_HAS_TRIGGERED_ERROR, true); From 15321506ea34c0620a184aa6b50ca01ea6ad4a86 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 12:02:21 -0400 Subject: [PATCH 57/94] CASH-1713 | Add prices to all items from store --- src/layouts/payment-method/payment-method.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index f903d0c16..d22ea1534 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,6 +269,16 @@ export default { availableVaps, lineItemsForCart ); + // Add prices to all line items, sometimes they are not there when they come back from heritage + // See CASH-1713 for details + lineItemsForCart.glassParts = addPricesToLineItems( + lineItemsForCart.glassParts ?? [], + pricedLineItems + ); + lineItemsForCart.supportingItems = addPricesToLineItems( + lineItemsForCart.supportingItems ?? [], + pricedLineItems + ); lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems); lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order From 147db94b1f43635e36e0c159fd4bf06b9c1e69ff Mon Sep 17 00:00:00 2001 From: CarlNation Date: Wed, 22 Oct 2025 12:44:37 -0400 Subject: [PATCH 58/94] CASH-1637 CASH-1637 send cash price for insurance orders --- src/mixins/analytics-mixin.js | 1 + src/store/index.js | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 14ddd14c6..d6b25b8dc 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -285,6 +285,7 @@ export default { sessionData.subTotalPrice = getSubTotal(order?.lineItems); sessionData.totalPrice = getAmountDue(order?.lineItems, true); sessionData.userAgent = navigator.userAgent; + sessionData.cashPriceSubTotal = order?.cashPriceSubTotal; await baseMixin.methods.dispatchStoreAction( storeActions.LOG_FMG_SESSION_DATA, diff --git a/src/store/index.js b/src/store/index.js index ce71d6f28..dbee4f357 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1642,6 +1642,7 @@ export const actions = { subTotalPrice, totalPrice, userAgent, + cashPriceSubTotal, } ) { var payload = { @@ -1688,7 +1689,8 @@ export const actions = { isItac: isItac, subTotalPrice: subTotalPrice, totalPrice: totalPrice, - userAgent, + userAgent: userAgent, + cashPriceSubTotal: cashPriceSubTotal, }; return globalMethods.callHttpClient({ From 7c59a8d0e5a326ac8a4c39c3bdcf95fb09d9dc26 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 13:47:51 -0400 Subject: [PATCH 59/94] CASH-1713 | Send in flattened priced items --- src/layouts/payment-method/payment-method.vue | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index d22ea1534..680d060dc 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -174,6 +174,23 @@ import { Field } from "vee-validate"; defineRule("payment-method-required", required(errorMessages.OPTION_REQUIRED)); defineRule("recal-ack-required", required(errorMessages.RECAL_ACK_REQUIRED)); +function getFlattenedArrayOfLineItemsWithChildParts(lineItems, childPartRecursiveCall = false) { + let flattenedArray = []; + lineItems?.forEach((lineItem) => { + // this assumes childparts will never be a glass part + lineItem.isChildPart = childPartRecursiveCall; + flattenedArray.push(lineItem); + if (lineItem.childParts) { + flattenedArray = [ + ...flattenedArray, + ...getFlattenedArrayOfLineItemsWithChildParts(lineItem.childParts, true), + ]; + } + }); + + return flattenedArray; +} + export default { name: "paymentMethod", props: { @@ -233,7 +250,7 @@ export default { // Price everything again to ensure that serverData has all values // Specifically this addresses an error where insurance client glass parts are not in serverData // See CASH-1713 for details - const pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + let pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { availableLineItems: lineItemsOnOrderAndAvailableVaps, @@ -241,6 +258,7 @@ export default { "payment-method", false ); + pricedLineItems = getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems); // Add prices to the availableVaps const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems); From 052305bba7badbcfef4416bc2bcbb55ccd7333cd Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Wed, 22 Oct 2025 14:20:18 -0400 Subject: [PATCH 60/94] Prevent premature data clearing --- src/router/methods/error.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 42807ce04..aa845d452 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -34,7 +34,7 @@ export async function handleHardError(errorPayload) { await handleSoftError(errorPayload, true); return; } - } finally { + } catch (exception) { await handleSoftError(errorPayload, true); } } From b5933955d94fe11d041b9f4eedcbc1106c3845d9 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:50:38 -0400 Subject: [PATCH 61/94] Revert "Merge pull request #2911 from Safelite/feature/CASH-1713" This reverts commit 71c7625631b187b85cf8b7dc938f4242c91edec1, reversing changes made to 9ffd1ec2be56b070057757b505d14ec41da7234c. --- src/layouts/payment-method/payment-method.vue | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 680d060dc..d22ea1534 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -174,23 +174,6 @@ import { Field } from "vee-validate"; defineRule("payment-method-required", required(errorMessages.OPTION_REQUIRED)); defineRule("recal-ack-required", required(errorMessages.RECAL_ACK_REQUIRED)); -function getFlattenedArrayOfLineItemsWithChildParts(lineItems, childPartRecursiveCall = false) { - let flattenedArray = []; - lineItems?.forEach((lineItem) => { - // this assumes childparts will never be a glass part - lineItem.isChildPart = childPartRecursiveCall; - flattenedArray.push(lineItem); - if (lineItem.childParts) { - flattenedArray = [ - ...flattenedArray, - ...getFlattenedArrayOfLineItemsWithChildParts(lineItem.childParts, true), - ]; - } - }); - - return flattenedArray; -} - export default { name: "paymentMethod", props: { @@ -250,7 +233,7 @@ export default { // Price everything again to ensure that serverData has all values // Specifically this addresses an error where insurance client glass parts are not in serverData // See CASH-1713 for details - let pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + const pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { availableLineItems: lineItemsOnOrderAndAvailableVaps, @@ -258,7 +241,6 @@ export default { "payment-method", false ); - pricedLineItems = getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems); // Add prices to the availableVaps const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems); From 33906bd83a7916e3cd27b5480d3f2f5cad7ae7e1 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:51:13 -0400 Subject: [PATCH 62/94] Revert "Merge pull request #2909 from Safelite/feature/CASH-1713" This reverts commit 9ffd1ec2be56b070057757b505d14ec41da7234c, reversing changes made to e798eef8c5007f2985e5143946851299068e8424. --- src/layouts/payment-method/payment-method.vue | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index d22ea1534..f903d0c16 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,16 +269,6 @@ export default { availableVaps, lineItemsForCart ); - // Add prices to all line items, sometimes they are not there when they come back from heritage - // See CASH-1713 for details - lineItemsForCart.glassParts = addPricesToLineItems( - lineItemsForCart.glassParts ?? [], - pricedLineItems - ); - lineItemsForCart.supportingItems = addPricesToLineItems( - lineItemsForCart.supportingItems ?? [], - pricedLineItems - ); lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems); lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order From 75a806d124509c83b9d66a3c2793eadf94f7dd99 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:51:35 -0400 Subject: [PATCH 63/94] Revert "Merge pull request #2906 from Safelite/feature/CASH-1713" This reverts commit e798eef8c5007f2985e5143946851299068e8424, reversing changes made to 7b33d8eeb22df1831b4f4e8d15d3c651d9b3feeb. --- src/layouts/payment-method/payment-method.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index f903d0c16..502321d2e 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,7 +269,7 @@ export default { availableVaps, lineItemsForCart ); - lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems); + lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps, pricedLineItems) ?? []; lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( From 7e4e1ba1a746400f214a3814d63f585359a987fe Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:51:57 -0400 Subject: [PATCH 64/94] Revert "Merge pull request #2905 from Safelite/feature/CASH-1713" This reverts commit 7b33d8eeb22df1831b4f4e8d15d3c651d9b3feeb, reversing changes made to 2a2e3383d265e4ce9d61381207f257b8441b2864. --- src/layouts/payment-method/payment-method.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 502321d2e..abee992bf 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -269,7 +269,7 @@ export default { availableVaps, lineItemsForCart ); - lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps, pricedLineItems) ?? []; + lineItemsForCart.vaps = lineItemsForCart.vaps ?? []; lineItemsForCart.vaps.push(...vapsToAddToCart); // Tax items on order lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( From 44939c508e435ff4f090886b334c1d48c0073f56 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:52:19 -0400 Subject: [PATCH 65/94] Revert "Merge pull request #2903 from Safelite/feature/CASH-1713" This reverts commit 2a2e3383d265e4ce9d61381207f257b8441b2864, reversing changes made to dbcf5351aefced01a4535eef1df999a364cf4193. --- src/helpers/pricing-helper.js | 20 ----------- src/layouts/payment-method/payment-method.vue | 35 ++++++++++--------- src/store/index.js | 21 ++++++++++- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index 0a8b85d7e..9aa317353 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -99,23 +99,3 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) { return pricingResults[0]; } - -export function addPricesToLineItems(lineItems, pricingLineItems) { - lineItems.forEach((lineItem) => { - const lineItemIndex = pricingLineItems.findIndex( - (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber - ); - - if (lineItem.childParts) { - addPricesToLineItems(lineItem.childParts, pricingLineItems); - } - - const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; - lineItem.laborAmount = pricedLineItem.laborAmount; - lineItem.sellingPrice = pricedLineItem.sellingPrice; - lineItem.kitPrice = pricedLineItem.kitPrice; - lineItem.salesTax = pricedLineItem.salesTax; - }); - - return lineItems; -} diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index abee992bf..ffb3d2353 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -155,6 +155,7 @@ import { getNewlyInactivatedPromos, } from "@/helpers/promotions-helper"; import { queryStrings } from "@/constants/query-strings"; +import { getQuerystringParameter } from "@/helpers/querystring-helper"; import { deepClone } from "@/helpers/object-helper"; import { Form } from "vee-validate"; @@ -162,10 +163,18 @@ import { defineRule } from "vee-validate"; import { required } from "@/helpers/validation-rules"; import { errorMessages } from "@/constants/error-messages"; import { AppointmentTypeStrings } from "@/constants/schedule-constants"; +import { partTypeStrings } from "@/constants/part-type-strings"; +import { mapTaxedLineItemsToStoreFormat } from "../../store"; import { coverageStatus } from "@/constants/insurance"; +import { containsLineItemWithPartType } from "@/helpers/service-package-helper"; import { containsRecalParts } from "@/helpers/recal-helper"; import { getBoolFromString } from "@/helpers/boolean-helper"; -import { getAmountDue, addPricesToLineItems } from "@/helpers/pricing-helper.js"; +import { + getDisplayAmountDue, + getAmountDue, + getSubTotal, + getSalesTax, +} from "@/helpers/pricing-helper.js"; import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { debugLog } from "@/helpers/debug-log-helper"; import { ErrorMessage } from "vee-validate"; @@ -222,28 +231,21 @@ export default { const availableVaps = [resultMap.rainRepel, ...resultMap.wipers]; - const lineItemsOnOrderAndAvailableVaps = [ - ...availableVaps, - ...glassParts, - ...supportingItems, - ...vaps, - ]; - - // All line items are already priced except availableVaps - // Price everything again to ensure that serverData has all values - // Specifically this addresses an error where insurance client glass parts are not in serverData - // See CASH-1713 for details - const pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + const pricedAvailableVaps = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { - availableLineItems: lineItemsOnOrderAndAvailableVaps, + availableLineItems: availableVaps, }, "payment-method", false ); - // Add prices to the availableVaps - const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems); + const lineItemsOnOrderAndAvailableVaps = [ + ...pricedAvailableVaps, + ...glassParts, + ...supportingItems, + ...vaps, + ]; // Promo logic // Populate the previous state of promos for toast message usage in "next()" @@ -263,6 +265,7 @@ export default { delete lineItemsForCart.serverData; // End of promo logic + // const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( lineItemsForCart.promos ?? [], diff --git a/src/store/index.js b/src/store/index.js index ef67b84a0..1fa9b4f14 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -59,7 +59,6 @@ import { } from "@/helpers/recal-helper"; import { externalParameterStatus } from "@/constants/external-parameters"; import { experimentSettings } from "@/constants/experiments"; -import { addPricesToLineItems } from "@/helpers/pricing-helper"; // Export State const getDefaultState = () => { @@ -3702,6 +3701,26 @@ function convertGlassPieceNamingFromApi(glassArray) { return glassArray; } +function addPricesToLineItems(lineItems, pricingLineItems) { + lineItems.forEach((lineItem) => { + const lineItemIndex = pricingLineItems.findIndex( + (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber + ); + + if (lineItem.childParts) { + addPricesToLineItems(lineItem.childParts, pricingLineItems); + } + + const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; + lineItem.laborAmount = pricedLineItem.laborAmount; + lineItem.sellingPrice = pricedLineItem.sellingPrice; + lineItem.kitPrice = pricedLineItem.kitPrice; + lineItem.salesTax = pricedLineItem.salesTax; + }); + + return lineItems; +} + function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) { pricedLineItems.forEach((pricedLineItem) => { const lineItemIndex = taxingLineItems.findIndex( From 8909bbe49c60869d658bd42f7cb584fa789a7737 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:52:42 -0400 Subject: [PATCH 66/94] Revert "Merge pull request #2888 from Safelite/feature/CASH-1640" This reverts commit 396e15451f665ccb8dc65a541f604bb857599810, reversing changes made to a73410fc276a6053d32017c135177c4417cdf60e. --- src/layouts/payment-method/payment-method.vue | 10 +- src/layouts/payment/payment.spec.js | 1 + src/layouts/payment/payment.vue | 114 +++++++++++++++++- 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index ffb3d2353..214b6111e 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -35,7 +35,7 @@ :isNoComp="isNoComp" :isExpandedOnLoad="false" :isMSRFeeApplicable="isMSRFeeApplicable" - @itemRemoved="evaluatePromosAndTaxItemsOnOrder" /> + @itemRemoved="reTaxItemsOnOrder" /> 0; - const hasActivePromos = this.lineItems.promos.length > 0; - if (hasInactivePromos || hasActivePromos) { - await this.revalidatePromos(); - } + async reTaxItemsOnOrder() { this.lineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { diff --git a/src/layouts/payment/payment.spec.js b/src/layouts/payment/payment.spec.js index 1b36d0644..ad98814d2 100644 --- a/src/layouts/payment/payment.spec.js +++ b/src/layouts/payment/payment.spec.js @@ -319,6 +319,7 @@ describe("payment.vue", () => { ); // Assert + expect(vmMock.availableVaps).not.toBeUndefined(); expect(vmMock.lineItems).not.toBeUndefined(); expect(vmMock.setCmsContent).toBeCalled(); diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index b8e481174..992795321 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -42,7 +42,7 @@ { vm.setCmsContent(resultMap.cmsContent); - vm.lineItems = deepClone(store.getters.order.lineItems); + vm.availableVaps = taxedVaps; + vm.lineItems = lineItems; vm.$nextTick(() => { if (vm.$refs.cart) { From a7b46c3e17547108ad6f40e681016a76893cc0b9 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:56:47 -0400 Subject: [PATCH 67/94] Revert "Merge pull request #2874 from Safelite/feature/CASH-1529" This reverts commit 49726644053e027f2bc2bb792118bb8e13ccd150, reversing changes made to 9eacf1d04d392b497d6405cf969f4a0e5ba2f0e3. --- src/fmg-components/cart/cart.vue | 1 - .../promo-modal-question.vue | 35 +-- src/layouts/payment-method/payment-method.vue | 153 +++++++++----- src/layouts/quote/quote.vue | 32 ++- src/layouts/schedule/schedule.vue | 199 +++++++++++------- src/store/index.js | 16 +- 6 files changed, 268 insertions(+), 168 deletions(-) diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index e9152dd9d..4da3982cd 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -322,7 +322,6 @@ export default { false ); } - this.$emit("itemRemoved"); }, async saveVaps(lineItems) { diff --git a/src/fmg-components/promo-modal-question/promo-modal-question.vue b/src/fmg-components/promo-modal-question/promo-modal-question.vue index 1e87d5bb5..8efad6f3e 100644 --- a/src/fmg-components/promo-modal-question/promo-modal-question.vue +++ b/src/fmg-components/promo-modal-question/promo-modal-question.vue @@ -290,18 +290,8 @@ export default { ); if (promoCodeData.isValid) { if (this.taxPromos) { - const vapsToAdd = getVapsThatNeedToBeAddedToSatisfyPromos( - promoCodeData.promoCode, - this.addableVaps, - this.lineItems - ); - const pricedLineItemsToTax = { - glassParts: this.lineItems.glassParts, - promos: promoCodeData.promoCode, - supportingItems: this.lineItems.supportingItems, - vaps: [...this.lineItems.vaps, ...vapsToAdd], - }; - + const pricedLineItemsToTax = []; + pricedLineItemsToTax.push(...promoCodeData.promoCode); // promoCodeData.promoCode should be an array const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, @@ -324,10 +314,25 @@ export default { // Match all line items to the line items as they are in the store // and rebuild the original structure. - this.lineItems = taxedLineItems; - } else { - this.lineItems?.promos.push(...promoCodeData.promoCode); + this.lineItems = mapTaxedLineItemsToStoreFormat( + taxedLineItems, + this.lineItems + ); + const taxedVaps = mapTaxedLineItemsToStoreFormat( + taxedLineItems, + this.addableVaps + ); + + const getVaps = getVapsThatNeedToBeAddedToSatisfyPromos( + promoCodeData.promoCode, + taxedVaps, + this.lineItems + ); + + this.lineItems.vaps?.push(...getVaps); } + this.lineItems?.promos.push(...promoCodeData.promoCode); + this.$emit("promoAdded", this.lineItems); this.closeModal(); diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 214b6111e..3f7124f84 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -34,8 +34,7 @@ :isItac="isItac" :isNoComp="isNoComp" :isExpandedOnLoad="false" - :isMSRFeeApplicable="isMSRFeeApplicable" - @itemRemoved="reTaxItemsOnOrder" /> + :isMSRFeeApplicable="isMSRFeeApplicable" /> wiper.partType == partTypeStrings.FRONT_WIPER + ) ?? []; + + const rearWipersOnOrder = + lineItemsFromStore.vaps.filter( + (wiper) => wiper.partType == partTypeStrings.REAR_WIPER + ) ?? []; + + const orderHasFrontWipers = frontWipersOnOrder.length > 0; + const orderHasRearWipers = rearWipersOnOrder.length > 0; + + const wipersPromise = + !orderHasFrontWipers || !orderHasRearWipers + ? baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_WIPERS, + { + serviceZipCode: store.getters.order.serviceLocation.zipCode, + carId: store.getters.vehicle.carId, + }, + "payment-method" + ) + : Promise.resolve([]); + + const rainDefensePromise = baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_RAIN_DEFENSE, null, "payment-method" ); + // const reviewDropdownPromise = reviewDropdown.methods.loadInitialData(); + // (removed temporarily for Heritage parity effort) + const promiseResultMap = [ { resultKey: "cmsContent", @@ -220,62 +240,80 @@ export default { resultKey: "rainRepel", promise: rainRepelPromise, }, + // { + // resultKey: "reviewDropdownData", + // promise: reviewDropdownPromise, + // }, + // (removed temporarily for Heritage parity effort) ]; const resultMap = await settleAllPromises(promiseResultMap); - let lineItemsFromStore = deepClone(store.getters.order.lineItems); const glassParts = lineItemsFromStore.glassParts ?? []; const supportingItems = lineItemsFromStore.supportingItems ?? []; const vaps = lineItemsFromStore.vaps ?? []; - const availableVaps = [resultMap.rainRepel, ...resultMap.wipers]; + // if the order already has wipers on it from the quote page, use those as the available wipers + // instead of what comes from the backend. This is to prevent issues with part interchange. + const availableFrontWipers = orderHasFrontWipers + ? frontWipersOnOrder + : (resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.FRONT_WIPER) ?? + []); + const availableRearWipers = orderHasRearWipers + ? rearWipersOnOrder + : (resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.REAR_WIPER) ?? + []); - const pricedAvailableVaps = await baseMixin.methods.dispatchStoreActionWithLogging( + const allLineItems = [ + resultMap.rainDefense, + ...supportingItems, + ...availableFrontWipers, + ...availableRearWipers, + ...glassParts, + ...vaps, + ]; + + const lineItemsToTax = Array.from( + new Map(allLineItems.map((item) => [item.partNumber, item])).values() + ); + + const availableVaps = [ + resultMap.rainDefense, + ...availableFrontWipers, + ...availableRearWipers, + ]; + + const pricedLineItemsToTax = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { - availableLineItems: availableVaps, + availableLineItems: lineItemsToTax, }, "payment-method", false ); - const lineItemsOnOrderAndAvailableVaps = [ - ...pricedAvailableVaps, - ...glassParts, - ...supportingItems, - ...vaps, - ]; - // Promo logic // Populate the previous state of promos for toast message usage in "next()" const oldActivePromos = store.getters.lineItems.promos?.slice(0); const oldInactivePromos = store.getters.order.payment.inactivePromos?.slice(0); const promoCodeFromQueryString = consumeQueryFromStash(queryStrings.PROMO); - // New promos are saved to store with this const { validatePromoResponse, revalidatePromoResponse } = await revalidatePromosAndValidateQueryStringPromo( promoCodeFromQueryString, - lineItemsOnOrderAndAvailableVaps, + pricedLineItemsToTax, "payment-method" ); - // update lineItemsFromStore with newly added promos - let lineItemsForCart = deepClone(store.getters.order.lineItems); - delete lineItemsForCart.serverData; - // End of promo logic - // - - const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( - lineItemsForCart.promos ?? [], - availableVaps, - lineItemsForCart + // Add newly validated promos to the array to get taxed + const newValidatedPromos = validatePromoResponse?.orderPromos ?? []; + newValidatedPromos.push( + ...(revalidatePromoResponse ? revalidatePromoResponse.promoLineItems : []) ); - lineItemsForCart.vaps = lineItemsForCart.vaps ?? []; - lineItemsForCart.vaps.push(...vapsToAddToCart); - // Tax items on order - lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( + pricedLineItemsToTax.push(...newValidatedPromos); + // End of promo logic + + const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { billToAccountNumber: store.getters.payment.billToAccountNumber, @@ -284,17 +322,31 @@ export default { serviceLocationCity: store.getters.order.serviceLocation.city, serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, - pricedLineItems: lineItemsForCart, + pricedLineItems: pricedLineItemsToTax, }, "payment-method", false ); + // Match all line items to the line items as they are in the store + // and rebuild the original structure. + const lineItems = mapTaxedLineItemsToStoreFormat(taxedLineItems, lineItemsFromStore); + const taxedVaps = mapTaxedLineItemsToStoreFormat(taxedLineItems, availableVaps); + + lineItems.promos = newValidatedPromos ?? []; + const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( + newValidatedPromos, + taxedVaps, + lineItems + ); + lineItems.vaps = lineItems.vaps ?? []; + lineItems.vaps.push(...vapsToAddToCart); + // Call the "next" function to complete the transition to this page. next((vm) => { vm.setCmsContent(resultMap.cmsContent); - vm.availableVaps = pricedAvailableVaps; - vm.lineItems = lineItemsForCart; + vm.availableVaps = taxedVaps; + vm.lineItems = lineItems; vm.inactivePromos = removeCurrentlyActivePromoCodesFromInactivePromos( vm.lineItems.promos, vm.inactivePromos @@ -589,22 +641,6 @@ export default { this.pageName ); }, - async reTaxItemsOnOrder() { - this.lineItems = await baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, - { - billToAccountNumber: store.getters.payment.billToAccountNumber, - providerNumber: store.getters.order.serviceLocation.provider.providerNumber, - appointmentType: store.getters.order.serviceLocation.appointmentType, - serviceLocationCity: store.getters.order.serviceLocation.city, - serviceLocationState: store.getters.order.serviceLocation.state, - serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, - pricedLineItems: this.lineItems, - }, - "payment-method", - false - ); - }, hasSubmittedOrder() { return baseMixin.methods.hasSubmittedOrder(); }, @@ -825,6 +861,7 @@ export default { ) { return; } + if (oldValue.promos.length < newValue.promos.length) { const oldPromoCodes = oldValue.promos.map( (promoObject) => promoObject.promoCode diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 8bab96d5f..c76222cac 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -65,7 +65,6 @@ pageName="quote" :taxPromos="false" :useDefaultCashParentAccount="true" - @promoAdded="promoAdded" modalWidgetName="PromoModalWidget" />
@@ -577,6 +576,9 @@ export default { isInsuranceContinueButtonText() { return this.getCmsContent("isInsuranceContinueButtonText", "Text"); }, + lineItemsCloneForWatcher() { + return Object.assign({}, this.lineItems); + }, isRecalibrationOnOrder() { return store.getters.isRecalibrationOnOrder; }, @@ -852,9 +854,31 @@ export default { await this.forwardButtonAction(); } }, - promoAdded(lineItems) { - const alert = createPromoSuccessAlert(lineItems.promos[0].promoCode); - this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade); + }, + watch: { + lineItemsCloneForWatcher: { + handler(newValue, oldValue) { + if ( + !oldValue || + oldValue.length == 0 || + !oldValue.vaps || + !newValue || + newValue.length == 0 + ) { + return; + } + if (oldValue.promos.length < newValue.promos.length) { + const oldPromoCodes = oldValue.promos.map( + (promoObject) => promoObject.promoCode + ); + const newlyActivatedPromoCodes = newValue.promos.filter( + (newPromo) => !oldPromoCodes.includes(newPromo.promoCode) + ); + const alert = createPromoSuccessAlert(newlyActivatedPromoCodes[0].promoCode); + this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade); + } + }, + deep: true, }, }, components: { diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 7c9d9f444..716c819e2 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -460,47 +460,111 @@ export default { }; }, async beforeRouteEnter(to, from, next) { - const serviceZipCode = store.getters.order.serviceLocation.zipCode; + const isPricingByDayExperiment = experimentMixin.methods.hasSettingEqualTo( + experimentSettings.PRICING_BY_DAY, + "true" + ); + const showPricingByDay = !store.getters.payment.isInsurance && isPricingByDayExperiment; + // Get pricingByDayBasePrice needed for Pricing By Day + const lineItems = deepClone(store.getters.order.lineItems); + const isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder; + const shouldHideRecalibration = + experimentMixin.methods.hasSettingEqualTo( + experimentSettings.RECAL_PRICE_REMOVE, + "true" + ) && isRecalibrationOnOrder; + const glassParts = + isRecalibrationOnOrder && shouldHideRecalibration + ? getItemsWithoutRecalParts(lineItems.glassParts) + : (lineItems.glassParts ?? []); + const supportingItemsWithoutFees = baseMixin.methods.filterOutCertainPartTypesOrNumbers( + lineItems.supportingItems, + { + partNumbersToRemove: [ + partNumberStrings.RECYCLE_FEE, + partNumberStrings.PRICING_BY_DAY_UPCHARGE, + ], + } + ); + const lineItemsToBePriced = { + glassParts: glassParts, + supportingItems: supportingItemsWithoutFees, + vaps: lineItems.vaps ?? [], + promos: lineItems.promos ?? [], + }; + const priceString = getAmountDue(lineItemsToBePriced, false); // pass the IncludeTax param as false + const priceStringIntegerRoundedDown = priceString?.split(".")[0]; // same method used as getDisplayPrice() in service-package-radio used on /quote + const pricingByDayBasePrice = parseInt(priceStringIntegerRoundedDown); + let includePricingByDayUpcharge = false; + let appointmentType = store.getters.order.serviceLocation.appointmentType; + + // Check to see if date should be pre-selected + let scheduleFromStore = await store.getters.order.schedule; + let preSelectedDate; + + if (scheduleFromStore.date && scheduleFromStore.date.length > 0) { + preSelectedDate = scheduleFromStore.date; + + if (appointmentType === AppointmentTypeStrings.MOBILE) { + preSelectedDate += "-mobile"; + } + + // Check to see if pre-selected date should have pricing by day upcharge + if (showPricingByDay) { + // is this preSelectedDate a higher priced pricingByDay day? + const dayIndex = convertDateStringToDate(scheduleFromStore?.date).getDay(); + const dayObject = DAYS_OF_WEEK[dayIndex]; + if (dayObject.isPricingByDayUpchargeDay) { + includePricingByDayUpcharge = true; + } + } + } + + // Set up promises const cmsContentPromise = fetchCmsContentForPage(to.name); + const alertReasonsPromise = locationAlerts.methods.loadInitialData( store.getters.order.serviceLocation.zipCodeCtu, store.getters.order.serviceLocation.provider?.address?.zipCodeCtu ); + + const serviceZipCode = store.getters.order.serviceLocation.zipCode; + const zipCodeDataPromise = getZipCodeData(serviceZipCode, to.name); const serviceabilityDetailsPromise = getServiceabilityDetails( serviceZipCode, null, - "schedule" - ); - let zipCodeData; - const zipCodeDataPromise = getZipCodeData(serviceZipCode, to.name); - let shopProviderData; - const shopProviderDataPromise = getShopProviderData(serviceZipCode, to.name); - const zipCodeDataAndShopProviderDataPromise = Promise.all([ - zipCodeDataPromise, - shopProviderDataPromise, - ]); - const mobileFeePartPromise = zipCodeDataAndShopProviderDataPromise.then( - ([zipCodeDataResult, shopProviderDataResult]) => { - zipCodeData = zipCodeDataResult; - shopProviderData = shopProviderDataResult; - return baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.GET_MOBILE_FEE_PART, - { - serviceZipCode: serviceZipCode, - serviceZipCodeCtu: zipCodeData.zipCodeCtu, - mobileProviderNumber: shopProviderData.data.mobileProviderNumber, - }, - to.name, - false - ); - } + to.name ); + const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode, to.name); + const shopProviderData = await getShopProviderData(serviceZipCode, to.name); + const providerNumber = shopProviderData?.data?.shopProviders[0]?.providerNumber; + + // Get pricingByDayUpcharge needed for Pricing By Day + const pricingByDayUpchargePartPromise = showPricingByDay + ? getPricingByDayPartWithPrice() + : null; + const premiumFeePromise = baseMixin.methods.dispatchStoreActionWithLogging( storeActions.GET_MOBILE_PREMIUM_FEE, null, to.name ); + const premiumFeeWithPricePromise = premiumFeePromise.then((result) => { + if (result.data) { + return baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + availableLineItems: [result.data], + }, + to.name, + false + ); + } else { + return result.data; + } + }); + // Settle promises and get results const promiseResultMap = [ { @@ -511,6 +575,18 @@ export default { resultKey: "alertReasons", promise: alertReasonsPromise, }, + { + resultKey: "pricingByDayUpchargePart", + promise: pricingByDayUpchargePartPromise, + }, + { + resultKey: "premiumFeeWithPrice", + promise: premiumFeeWithPricePromise, + }, + { + resultKey: "zipCodeData", + promise: zipCodeDataPromise, + }, { resultKey: "mobileFeePart", promise: mobileFeePartPromise, @@ -519,70 +595,39 @@ export default { resultKey: "serviceabilityDetails", promise: serviceabilityDetailsPromise, }, - { - resultKey: "premiumFee", - promise: premiumFeePromise, - }, ]; + const resultMap = await settleAllPromises(promiseResultMap); - const itemsToPrice = []; - if (resultMap.mobileFeePart) { - itemsToPrice.push(resultMap.mobileFeePart); - } - if (resultMap.premiumFee) { - itemsToPrice.push(resultMap.premiumFee); - } - let pricedMobileFeePart = null; - let pricedPremiumFee = null; - - if (itemsToPrice.length) { - const pricedItems = await baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, - { - availableLineItems: itemsToPrice, - }, - to.name, - false - ); - pricedMobileFeePart = pricedItems.find( - (item) => item.partNumber === resultMap.mobileFeePart.partNumber - ); - pricedPremiumFee = pricedItems.find( - (item) => item.partNumber === resultMap.premiumFee.partNumber - ); - } - - let appointmentType = store.getters.order.serviceLocation.appointmentType; - // Check to see if date should be pre-selected - let scheduleFromStore = await store.getters.order.schedule; - let preSelectedDate; - if (scheduleFromStore.date && scheduleFromStore.date.length > 0) { - preSelectedDate = scheduleFromStore.date; - - if (appointmentType === AppointmentTypeStrings.MOBILE) { - preSelectedDate += "-mobile"; - } - } + // add pricing by day data + const pricingByDayUpcharge = + showPricingByDay && resultMap.pricingByDayUpchargePart + ? await baseMixin.methods.getTotalLineItemPrice( + resultMap.pricingByDayUpchargePart, + false + ) + : null; // Call the "next" function to complete the transition to this page. next((vm) => { vm.setCmsContent(resultMap.cmsContent); vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons); - vm.mobilePremiumAppointmentFee = pricedPremiumFee; + vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice + ? resultMap.premiumFeeWithPrice[0] + : null; vm.updateFooterButtonText(vm.selectedTimeSlotInfo); - vm.pricingByDayUpchargeLineItem = null; // Pricing By Day Upcharge Line Item is not used in this version - vm.includePricingByDayUpcharge = false; // Pricing By Day Upcharge is not used in this version - vm.isPricingByDayExperiment = false; // Pricing By Day Experiment is not used in this version - vm.pricingByDayBasePrice = null; // Pricing By Day Base Price is not used in this version - vm.pricingByDayUpcharge = null; // Pricing By Day Upcharge is not used in this version - vm.showPricingByDay = false; // Pricing By Day is not used in this version + vm.pricingByDayUpchargeLineItem = resultMap.pricingByDayUpchargePart; + vm.includePricingByDayUpcharge = includePricingByDayUpcharge; + vm.isPricingByDayExperiment = isPricingByDayExperiment; + vm.pricingByDayBasePrice = pricingByDayBasePrice; + vm.pricingByDayUpcharge = pricingByDayUpcharge; + vm.showPricingByDay = showPricingByDay; vm.preSelectedDate = preSelectedDate; vm.appointmentType = appointmentType; vm.setData( - zipCodeData, + resultMap.zipCodeData, resultMap.serviceabilityDetails, - pricedMobileFeePart, + resultMap.mobileFeePart, shopProviderData.data ); vm.initializeDatePicker(); diff --git a/src/store/index.js b/src/store/index.js index 1fa9b4f14..402ff212f 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2958,14 +2958,8 @@ export const actions = { pageNameToLog, } ) { - const arrayOfLineItems = [ - ...(pricedLineItems.glassParts ?? []), - ...(pricedLineItems.promos ?? []), - ...(pricedLineItems.supportingItems ?? []), - ...(pricedLineItems.vaps ?? []), - ]; const flattenedLineItemsWithChildParts = - getFlattenedArrayOfLineItemsWithChildParts(arrayOfLineItems); + getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems); const lineItemsWithOnlyPriceInfo = flattenedLineItemsWithChildParts.map((lineItem) => ({ partNumber: lineItem.partNumber, @@ -3014,12 +3008,8 @@ export const actions = { }); context.commit(storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, response.data.serverData); - Object.keys(pricedLineItems).forEach((key) => { - pricedLineItems[key] = addTaxesToPricedLineItems( - pricedLineItems[key] ?? [], - response.data.taxedLineItems - ); - }); + + pricedLineItems = addTaxesToPricedLineItems(pricedLineItems, response.data.taxedLineItems); return pricedLineItems; }, From 5ecd1e5c42ed0df90c060abf6d8545d7d05a8963 Mon Sep 17 00:00:00 2001 From: Scott Kiener Date: Wed, 22 Oct 2025 14:58:36 -0400 Subject: [PATCH 68/94] Redo rain repel changes --- src/layouts/payment-method/payment-method.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 3f7124f84..2c51f42a1 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -218,8 +218,8 @@ export default { ) : Promise.resolve([]); - const rainDefensePromise = baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.GET_RAIN_DEFENSE, + const rainRepelPromise = baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_RAIN_REPEL, null, "payment-method" ); @@ -265,7 +265,7 @@ export default { []); const allLineItems = [ - resultMap.rainDefense, + resultMap.rainRepel, ...supportingItems, ...availableFrontWipers, ...availableRearWipers, @@ -278,7 +278,7 @@ export default { ); const availableVaps = [ - resultMap.rainDefense, + resultMap.rainRepel, ...availableFrontWipers, ...availableRearWipers, ]; From 36b8ed0ac1c8f2ad8fc708786400ffef56d24e1e Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Fri, 24 Oct 2025 16:11:05 -0400 Subject: [PATCH 69/94] CASH-1712: rename for clarity --- src/layouts/schedule/schedule.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 716c819e2..81d4315b6 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -624,7 +624,7 @@ export default { vm.showPricingByDay = showPricingByDay; vm.preSelectedDate = preSelectedDate; vm.appointmentType = appointmentType; - vm.setData( + vm.setDataOnLoad( resultMap.zipCodeData, resultMap.serviceabilityDetails, resultMap.mobileFeePart, @@ -966,7 +966,7 @@ export default { } }, - setData(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) { + setDataOnLoad(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) { if (zipCodeData) { this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase; this.zipCodeCtu = zipCodeData.zipCodeCtu; From 4405c1c3f0323ca01ef0c76c13ee221c0ebc6287 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Fri, 24 Oct 2025 16:18:11 -0400 Subject: [PATCH 70/94] CASH-1712 - restrict showAnotherMonth logic to be more specific to avoid loading next month if only one appointment type is available --- src/layouts/schedule/schedule.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 81d4315b6..c5c697d35 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -1217,6 +1217,8 @@ export default { async initializeDatePicker() { this.selectedDate = null; + const includeMobileTimeSlots = this.isServiceableMobile; + const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff; const datePickerInitialData = await this.$refs.datePicker.loadInitialData({ // setup config options for date-picker selectableDatesSetting: "custom", @@ -1225,8 +1227,8 @@ export default { preSelectedDate: this.preSelectedDate, providerNumber: this.selectedProvider?.providerNumber, zipCode: this.zipCode, - includeMobileTimeSlots: this.isServiceableMobile, - includeInshopTimeSlots: this.isServiceableInshop || this.isServiceableDropoff, + includeMobileTimeSlots: includeMobileTimeSlots, + includeInshopTimeSlots: includeInshopTimeSlots, }); datePickerInitialData.pricingByDayBasePrice = this.pricingByDayBasePrice; datePickerInitialData.pricingByDayUpcharge = this.pricingByDayUpcharge; @@ -1242,8 +1244,8 @@ export default { } else { // if no date is preselected on load, make sure there are some dates available if ( - this.selectableDatesInshop.days.length < 1 || - this.selectableDatesMobile.days.length < 1 + (includeInshopTimeSlots && this.selectableDatesInshop.days.length < 1) || + (includeMobileTimeSlots && this.selectableDatesMobile.days.length < 1) ) { await this.$nextTick(); await this.$refs.datePicker.showAnotherMonth(); From 55d0ae9a7e2ec3ce78d0d096c01af7679b3bd1ae Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Fri, 24 Oct 2025 16:22:17 -0400 Subject: [PATCH 71/94] CASH-1712 - attempt to prevent repetitive api calls and initDatePicker when toggling appt types --- src/layouts/schedule/schedule.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index c5c697d35..e882a2652 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -1743,7 +1743,6 @@ export default { this.lastSelectedInshopOrDropoffProvider = this.selectedProvider; } this.appointmentType = AppointmentTypeStrings.MOBILE; - this.updateSelectedProvider(); } else if (newAppointmentType) { if (this.appointmentType != AppointmentTypeStrings.MOBILE) { // Clear last shop selected if appointment type was changed in any manner other than from Mobile @@ -1757,9 +1756,6 @@ export default { } else { this.appointmentType = AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF; } - - // make sure a selectedProvider exists - this.updateSelectedProvider(this.lastSelectedInshopOrDropoffProvider); } else { this.appointmentType = null; } From 3969eb4f352123b55c0030f36d13fdd434c68bf7 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Mon, 27 Oct 2025 10:56:26 -0400 Subject: [PATCH 72/94] Revert "Merge pull request #2914 from Safelite/feature/CASH-1529-revert" This reverts commit ea7262594b424d3f0fcbe0740f2a9b05fabe7054, reversing changes made to 71c7625631b187b85cf8b7dc938f4242c91edec1. --- src/fmg-components/cart/cart.vue | 1 + .../promo-modal-question.vue | 35 ++- src/helpers/pricing-helper.js | 20 ++ src/layouts/payment-method/payment-method.vue | 190 ++++++++--------- src/layouts/payment/payment.spec.js | 1 - src/layouts/payment/payment.vue | 114 +--------- src/layouts/quote/quote.vue | 32 +-- src/layouts/schedule/schedule.vue | 199 +++++++----------- src/store/index.js | 37 ++-- 9 files changed, 226 insertions(+), 403 deletions(-) diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index 4da3982cd..e9152dd9d 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -322,6 +322,7 @@ export default { false ); } + this.$emit("itemRemoved"); }, async saveVaps(lineItems) { diff --git a/src/fmg-components/promo-modal-question/promo-modal-question.vue b/src/fmg-components/promo-modal-question/promo-modal-question.vue index 8efad6f3e..1e87d5bb5 100644 --- a/src/fmg-components/promo-modal-question/promo-modal-question.vue +++ b/src/fmg-components/promo-modal-question/promo-modal-question.vue @@ -290,8 +290,18 @@ export default { ); if (promoCodeData.isValid) { if (this.taxPromos) { - const pricedLineItemsToTax = []; - pricedLineItemsToTax.push(...promoCodeData.promoCode); // promoCodeData.promoCode should be an array + const vapsToAdd = getVapsThatNeedToBeAddedToSatisfyPromos( + promoCodeData.promoCode, + this.addableVaps, + this.lineItems + ); + const pricedLineItemsToTax = { + glassParts: this.lineItems.glassParts, + promos: promoCodeData.promoCode, + supportingItems: this.lineItems.supportingItems, + vaps: [...this.lineItems.vaps, ...vapsToAdd], + }; + const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, @@ -314,25 +324,10 @@ export default { // Match all line items to the line items as they are in the store // and rebuild the original structure. - this.lineItems = mapTaxedLineItemsToStoreFormat( - taxedLineItems, - this.lineItems - ); - const taxedVaps = mapTaxedLineItemsToStoreFormat( - taxedLineItems, - this.addableVaps - ); - - const getVaps = getVapsThatNeedToBeAddedToSatisfyPromos( - promoCodeData.promoCode, - taxedVaps, - this.lineItems - ); - - this.lineItems.vaps?.push(...getVaps); + this.lineItems = taxedLineItems; + } else { + this.lineItems?.promos.push(...promoCodeData.promoCode); } - this.lineItems?.promos.push(...promoCodeData.promoCode); - this.$emit("promoAdded", this.lineItems); this.closeModal(); diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index 9aa317353..0a8b85d7e 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -99,3 +99,23 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) { return pricingResults[0]; } + +export function addPricesToLineItems(lineItems, pricingLineItems) { + lineItems.forEach((lineItem) => { + const lineItemIndex = pricingLineItems.findIndex( + (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber + ); + + if (lineItem.childParts) { + addPricesToLineItems(lineItem.childParts, pricingLineItems); + } + + const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; + lineItem.laborAmount = pricedLineItem.laborAmount; + lineItem.sellingPrice = pricedLineItem.sellingPrice; + lineItem.kitPrice = pricedLineItem.kitPrice; + lineItem.salesTax = pricedLineItem.salesTax; + }); + + return lineItems; +} diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 2c51f42a1..680d060dc 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -34,7 +34,8 @@ :isItac="isItac" :isNoComp="isNoComp" :isExpandedOnLoad="false" - :isMSRFeeApplicable="isMSRFeeApplicable" /> + :isMSRFeeApplicable="isMSRFeeApplicable" + @itemRemoved="evaluatePromosAndTaxItemsOnOrder" /> { + // this assumes childparts will never be a glass part + lineItem.isChildPart = childPartRecursiveCall; + flattenedArray.push(lineItem); + if (lineItem.childParts) { + flattenedArray = [ + ...flattenedArray, + ...getFlattenedArrayOfLineItemsWithChildParts(lineItem.childParts, true), + ]; + } + }); + + return flattenedArray; +} + export default { name: "paymentMethod", props: { @@ -191,32 +200,14 @@ export default { // Call APIs const cmsContentPromise = fetchCmsContentForPage(to.name); - const lineItemsFromStore = deepClone(store.getters.order.lineItems); - - const frontWipersOnOrder = - lineItemsFromStore.vaps.filter( - (wiper) => wiper.partType == partTypeStrings.FRONT_WIPER - ) ?? []; - - const rearWipersOnOrder = - lineItemsFromStore.vaps.filter( - (wiper) => wiper.partType == partTypeStrings.REAR_WIPER - ) ?? []; - - const orderHasFrontWipers = frontWipersOnOrder.length > 0; - const orderHasRearWipers = rearWipersOnOrder.length > 0; - - const wipersPromise = - !orderHasFrontWipers || !orderHasRearWipers - ? baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.GET_WIPERS, - { - serviceZipCode: store.getters.order.serviceLocation.zipCode, - carId: store.getters.vehicle.carId, - }, - "payment-method" - ) - : Promise.resolve([]); + const wipersPromise = baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_WIPERS, + { + serviceZipCode: store.getters.order.serviceLocation.zipCode, + carId: store.getters.vehicle.carId, + }, + "payment-method" + ); const rainRepelPromise = baseMixin.methods.dispatchStoreActionWithLogging( storeActions.GET_RAIN_REPEL, @@ -224,9 +215,6 @@ export default { "payment-method" ); - // const reviewDropdownPromise = reviewDropdown.methods.loadInitialData(); - // (removed temporarily for Heritage parity effort) - const promiseResultMap = [ { resultKey: "cmsContent", @@ -240,57 +228,40 @@ export default { resultKey: "rainRepel", promise: rainRepelPromise, }, - // { - // resultKey: "reviewDropdownData", - // promise: reviewDropdownPromise, - // }, - // (removed temporarily for Heritage parity effort) ]; const resultMap = await settleAllPromises(promiseResultMap); + let lineItemsFromStore = deepClone(store.getters.order.lineItems); const glassParts = lineItemsFromStore.glassParts ?? []; const supportingItems = lineItemsFromStore.supportingItems ?? []; const vaps = lineItemsFromStore.vaps ?? []; - // if the order already has wipers on it from the quote page, use those as the available wipers - // instead of what comes from the backend. This is to prevent issues with part interchange. - const availableFrontWipers = orderHasFrontWipers - ? frontWipersOnOrder - : (resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.FRONT_WIPER) ?? - []); - const availableRearWipers = orderHasRearWipers - ? rearWipersOnOrder - : (resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.REAR_WIPER) ?? - []); + const availableVaps = [resultMap.rainRepel, ...resultMap.wipers]; - const allLineItems = [ - resultMap.rainRepel, - ...supportingItems, - ...availableFrontWipers, - ...availableRearWipers, + const lineItemsOnOrderAndAvailableVaps = [ + ...availableVaps, ...glassParts, + ...supportingItems, ...vaps, ]; - const lineItemsToTax = Array.from( - new Map(allLineItems.map((item) => [item.partNumber, item])).values() - ); - - const availableVaps = [ - resultMap.rainRepel, - ...availableFrontWipers, - ...availableRearWipers, - ]; - - const pricedLineItemsToTax = await baseMixin.methods.dispatchStoreActionWithLogging( + // All line items are already priced except availableVaps + // Price everything again to ensure that serverData has all values + // Specifically this addresses an error where insurance client glass parts are not in serverData + // See CASH-1713 for details + let pricedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { - availableLineItems: lineItemsToTax, + availableLineItems: lineItemsOnOrderAndAvailableVaps, }, "payment-method", false ); + pricedLineItems = getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems); + + // Add prices to the availableVaps + const pricedAvailableVaps = addPricesToLineItems(availableVaps, pricedLineItems); // Promo logic // Populate the previous state of promos for toast message usage in "next()" @@ -298,22 +269,38 @@ export default { const oldInactivePromos = store.getters.order.payment.inactivePromos?.slice(0); const promoCodeFromQueryString = consumeQueryFromStash(queryStrings.PROMO); + // New promos are saved to store with this const { validatePromoResponse, revalidatePromoResponse } = await revalidatePromosAndValidateQueryStringPromo( promoCodeFromQueryString, - pricedLineItemsToTax, + lineItemsOnOrderAndAvailableVaps, "payment-method" ); + // update lineItemsFromStore with newly added promos + let lineItemsForCart = deepClone(store.getters.order.lineItems); + delete lineItemsForCart.serverData; - // Add newly validated promos to the array to get taxed - const newValidatedPromos = validatePromoResponse?.orderPromos ?? []; - newValidatedPromos.push( - ...(revalidatePromoResponse ? revalidatePromoResponse.promoLineItems : []) - ); - pricedLineItemsToTax.push(...newValidatedPromos); // End of promo logic - const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( + lineItemsForCart.promos ?? [], + availableVaps, + lineItemsForCart + ); + // Add prices to all line items, sometimes they are not there when they come back from heritage + // See CASH-1713 for details + lineItemsForCart.glassParts = addPricesToLineItems( + lineItemsForCart.glassParts ?? [], + pricedLineItems + ); + lineItemsForCart.supportingItems = addPricesToLineItems( + lineItemsForCart.supportingItems ?? [], + pricedLineItems + ); + lineItemsForCart.vaps = addPricesToLineItems(lineItemsForCart.vaps ?? [], pricedLineItems); + lineItemsForCart.vaps.push(...vapsToAddToCart); + // Tax items on order + lineItemsForCart = await baseMixin.methods.dispatchStoreActionWithLogging( storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, { billToAccountNumber: store.getters.payment.billToAccountNumber, @@ -322,31 +309,17 @@ export default { serviceLocationCity: store.getters.order.serviceLocation.city, serviceLocationState: store.getters.order.serviceLocation.state, serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, - pricedLineItems: pricedLineItemsToTax, + pricedLineItems: lineItemsForCart, }, "payment-method", false ); - // Match all line items to the line items as they are in the store - // and rebuild the original structure. - const lineItems = mapTaxedLineItemsToStoreFormat(taxedLineItems, lineItemsFromStore); - const taxedVaps = mapTaxedLineItemsToStoreFormat(taxedLineItems, availableVaps); - - lineItems.promos = newValidatedPromos ?? []; - const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( - newValidatedPromos, - taxedVaps, - lineItems - ); - lineItems.vaps = lineItems.vaps ?? []; - lineItems.vaps.push(...vapsToAddToCart); - // Call the "next" function to complete the transition to this page. next((vm) => { vm.setCmsContent(resultMap.cmsContent); - vm.availableVaps = taxedVaps; - vm.lineItems = lineItems; + vm.availableVaps = pricedAvailableVaps; + vm.lineItems = lineItemsForCart; vm.inactivePromos = removeCurrentlyActivePromoCodesFromInactivePromos( vm.lineItems.promos, vm.inactivePromos @@ -641,6 +614,28 @@ export default { this.pageName ); }, + async evaluatePromosAndTaxItemsOnOrder() { + //Revalidate promos if there are any inactive, or active promos + const hasInactivePromos = this.inactivePromos.length > 0; + const hasActivePromos = this.lineItems.promos.length > 0; + if (hasInactivePromos || hasActivePromos) { + await this.revalidatePromos(); + } + this.lineItems = await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + billToAccountNumber: store.getters.payment.billToAccountNumber, + providerNumber: store.getters.order.serviceLocation.provider.providerNumber, + appointmentType: store.getters.order.serviceLocation.appointmentType, + serviceLocationCity: store.getters.order.serviceLocation.city, + serviceLocationState: store.getters.order.serviceLocation.state, + serviceLocationZipCode: store.getters.order.serviceLocation.zipCode, + pricedLineItems: this.lineItems, + }, + "payment-method", + false + ); + }, hasSubmittedOrder() { return baseMixin.methods.hasSubmittedOrder(); }, @@ -861,7 +856,6 @@ export default { ) { return; } - if (oldValue.promos.length < newValue.promos.length) { const oldPromoCodes = oldValue.promos.map( (promoObject) => promoObject.promoCode diff --git a/src/layouts/payment/payment.spec.js b/src/layouts/payment/payment.spec.js index ad98814d2..1b36d0644 100644 --- a/src/layouts/payment/payment.spec.js +++ b/src/layouts/payment/payment.spec.js @@ -319,7 +319,6 @@ describe("payment.vue", () => { ); // Assert - expect(vmMock.availableVaps).not.toBeUndefined(); expect(vmMock.lineItems).not.toBeUndefined(); expect(vmMock.setCmsContent).toBeCalled(); diff --git a/src/layouts/payment/payment.vue b/src/layouts/payment/payment.vue index 992795321..b8e481174 100644 --- a/src/layouts/payment/payment.vue +++ b/src/layouts/payment/payment.vue @@ -42,7 +42,7 @@ { vm.setCmsContent(resultMap.cmsContent); - vm.availableVaps = taxedVaps; - vm.lineItems = lineItems; + vm.lineItems = deepClone(store.getters.order.lineItems); vm.$nextTick(() => { if (vm.$refs.cart) { diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index c76222cac..8bab96d5f 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -65,6 +65,7 @@ pageName="quote" :taxPromos="false" :useDefaultCashParentAccount="true" + @promoAdded="promoAdded" modalWidgetName="PromoModalWidget" /> @@ -576,9 +577,6 @@ export default { isInsuranceContinueButtonText() { return this.getCmsContent("isInsuranceContinueButtonText", "Text"); }, - lineItemsCloneForWatcher() { - return Object.assign({}, this.lineItems); - }, isRecalibrationOnOrder() { return store.getters.isRecalibrationOnOrder; }, @@ -854,31 +852,9 @@ export default { await this.forwardButtonAction(); } }, - }, - watch: { - lineItemsCloneForWatcher: { - handler(newValue, oldValue) { - if ( - !oldValue || - oldValue.length == 0 || - !oldValue.vaps || - !newValue || - newValue.length == 0 - ) { - return; - } - if (oldValue.promos.length < newValue.promos.length) { - const oldPromoCodes = oldValue.promos.map( - (promoObject) => promoObject.promoCode - ); - const newlyActivatedPromoCodes = newValue.promos.filter( - (newPromo) => !oldPromoCodes.includes(newPromo.promoCode) - ); - const alert = createPromoSuccessAlert(newlyActivatedPromoCodes[0].promoCode); - this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade); - } - }, - deep: true, + promoAdded(lineItems) { + const alert = createPromoSuccessAlert(lineItems.promos[0].promoCode); + this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade); }, }, components: { diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 716c819e2..7c9d9f444 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -460,111 +460,47 @@ export default { }; }, async beforeRouteEnter(to, from, next) { - const isPricingByDayExperiment = experimentMixin.methods.hasSettingEqualTo( - experimentSettings.PRICING_BY_DAY, - "true" - ); - const showPricingByDay = !store.getters.payment.isInsurance && isPricingByDayExperiment; + const serviceZipCode = store.getters.order.serviceLocation.zipCode; - // Get pricingByDayBasePrice needed for Pricing By Day - const lineItems = deepClone(store.getters.order.lineItems); - const isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder; - const shouldHideRecalibration = - experimentMixin.methods.hasSettingEqualTo( - experimentSettings.RECAL_PRICE_REMOVE, - "true" - ) && isRecalibrationOnOrder; - const glassParts = - isRecalibrationOnOrder && shouldHideRecalibration - ? getItemsWithoutRecalParts(lineItems.glassParts) - : (lineItems.glassParts ?? []); - const supportingItemsWithoutFees = baseMixin.methods.filterOutCertainPartTypesOrNumbers( - lineItems.supportingItems, - { - partNumbersToRemove: [ - partNumberStrings.RECYCLE_FEE, - partNumberStrings.PRICING_BY_DAY_UPCHARGE, - ], - } - ); - const lineItemsToBePriced = { - glassParts: glassParts, - supportingItems: supportingItemsWithoutFees, - vaps: lineItems.vaps ?? [], - promos: lineItems.promos ?? [], - }; - const priceString = getAmountDue(lineItemsToBePriced, false); // pass the IncludeTax param as false - const priceStringIntegerRoundedDown = priceString?.split(".")[0]; // same method used as getDisplayPrice() in service-package-radio used on /quote - const pricingByDayBasePrice = parseInt(priceStringIntegerRoundedDown); - let includePricingByDayUpcharge = false; - let appointmentType = store.getters.order.serviceLocation.appointmentType; - - // Check to see if date should be pre-selected - let scheduleFromStore = await store.getters.order.schedule; - let preSelectedDate; - - if (scheduleFromStore.date && scheduleFromStore.date.length > 0) { - preSelectedDate = scheduleFromStore.date; - - if (appointmentType === AppointmentTypeStrings.MOBILE) { - preSelectedDate += "-mobile"; - } - - // Check to see if pre-selected date should have pricing by day upcharge - if (showPricingByDay) { - // is this preSelectedDate a higher priced pricingByDay day? - const dayIndex = convertDateStringToDate(scheduleFromStore?.date).getDay(); - const dayObject = DAYS_OF_WEEK[dayIndex]; - if (dayObject.isPricingByDayUpchargeDay) { - includePricingByDayUpcharge = true; - } - } - } - - // Set up promises const cmsContentPromise = fetchCmsContentForPage(to.name); - const alertReasonsPromise = locationAlerts.methods.loadInitialData( store.getters.order.serviceLocation.zipCodeCtu, store.getters.order.serviceLocation.provider?.address?.zipCodeCtu ); - - const serviceZipCode = store.getters.order.serviceLocation.zipCode; - const zipCodeDataPromise = getZipCodeData(serviceZipCode, to.name); const serviceabilityDetailsPromise = getServiceabilityDetails( serviceZipCode, null, - to.name + "schedule" + ); + let zipCodeData; + const zipCodeDataPromise = getZipCodeData(serviceZipCode, to.name); + let shopProviderData; + const shopProviderDataPromise = getShopProviderData(serviceZipCode, to.name); + const zipCodeDataAndShopProviderDataPromise = Promise.all([ + zipCodeDataPromise, + shopProviderDataPromise, + ]); + const mobileFeePartPromise = zipCodeDataAndShopProviderDataPromise.then( + ([zipCodeDataResult, shopProviderDataResult]) => { + zipCodeData = zipCodeDataResult; + shopProviderData = shopProviderDataResult; + return baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.GET_MOBILE_FEE_PART, + { + serviceZipCode: serviceZipCode, + serviceZipCodeCtu: zipCodeData.zipCodeCtu, + mobileProviderNumber: shopProviderData.data.mobileProviderNumber, + }, + to.name, + false + ); + } ); - const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode, to.name); - const shopProviderData = await getShopProviderData(serviceZipCode, to.name); - const providerNumber = shopProviderData?.data?.shopProviders[0]?.providerNumber; - - // Get pricingByDayUpcharge needed for Pricing By Day - const pricingByDayUpchargePartPromise = showPricingByDay - ? getPricingByDayPartWithPrice() - : null; - const premiumFeePromise = baseMixin.methods.dispatchStoreActionWithLogging( storeActions.GET_MOBILE_PREMIUM_FEE, null, to.name ); - const premiumFeeWithPricePromise = premiumFeePromise.then((result) => { - if (result.data) { - return baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, - { - availableLineItems: [result.data], - }, - to.name, - false - ); - } else { - return result.data; - } - }); - // Settle promises and get results const promiseResultMap = [ { @@ -575,18 +511,6 @@ export default { resultKey: "alertReasons", promise: alertReasonsPromise, }, - { - resultKey: "pricingByDayUpchargePart", - promise: pricingByDayUpchargePartPromise, - }, - { - resultKey: "premiumFeeWithPrice", - promise: premiumFeeWithPricePromise, - }, - { - resultKey: "zipCodeData", - promise: zipCodeDataPromise, - }, { resultKey: "mobileFeePart", promise: mobileFeePartPromise, @@ -595,39 +519,70 @@ export default { resultKey: "serviceabilityDetails", promise: serviceabilityDetailsPromise, }, + { + resultKey: "premiumFee", + promise: premiumFeePromise, + }, ]; - const resultMap = await settleAllPromises(promiseResultMap); + const itemsToPrice = []; + if (resultMap.mobileFeePart) { + itemsToPrice.push(resultMap.mobileFeePart); + } + if (resultMap.premiumFee) { + itemsToPrice.push(resultMap.premiumFee); + } - // add pricing by day data - const pricingByDayUpcharge = - showPricingByDay && resultMap.pricingByDayUpchargePart - ? await baseMixin.methods.getTotalLineItemPrice( - resultMap.pricingByDayUpchargePart, - false - ) - : null; + let pricedMobileFeePart = null; + let pricedPremiumFee = null; + + if (itemsToPrice.length) { + const pricedItems = await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + availableLineItems: itemsToPrice, + }, + to.name, + false + ); + pricedMobileFeePart = pricedItems.find( + (item) => item.partNumber === resultMap.mobileFeePart.partNumber + ); + pricedPremiumFee = pricedItems.find( + (item) => item.partNumber === resultMap.premiumFee.partNumber + ); + } + + let appointmentType = store.getters.order.serviceLocation.appointmentType; + // Check to see if date should be pre-selected + let scheduleFromStore = await store.getters.order.schedule; + let preSelectedDate; + if (scheduleFromStore.date && scheduleFromStore.date.length > 0) { + preSelectedDate = scheduleFromStore.date; + + if (appointmentType === AppointmentTypeStrings.MOBILE) { + preSelectedDate += "-mobile"; + } + } // Call the "next" function to complete the transition to this page. next((vm) => { vm.setCmsContent(resultMap.cmsContent); vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons); - vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice - ? resultMap.premiumFeeWithPrice[0] - : null; + vm.mobilePremiumAppointmentFee = pricedPremiumFee; vm.updateFooterButtonText(vm.selectedTimeSlotInfo); - vm.pricingByDayUpchargeLineItem = resultMap.pricingByDayUpchargePart; - vm.includePricingByDayUpcharge = includePricingByDayUpcharge; - vm.isPricingByDayExperiment = isPricingByDayExperiment; - vm.pricingByDayBasePrice = pricingByDayBasePrice; - vm.pricingByDayUpcharge = pricingByDayUpcharge; - vm.showPricingByDay = showPricingByDay; + vm.pricingByDayUpchargeLineItem = null; // Pricing By Day Upcharge Line Item is not used in this version + vm.includePricingByDayUpcharge = false; // Pricing By Day Upcharge is not used in this version + vm.isPricingByDayExperiment = false; // Pricing By Day Experiment is not used in this version + vm.pricingByDayBasePrice = null; // Pricing By Day Base Price is not used in this version + vm.pricingByDayUpcharge = null; // Pricing By Day Upcharge is not used in this version + vm.showPricingByDay = false; // Pricing By Day is not used in this version vm.preSelectedDate = preSelectedDate; vm.appointmentType = appointmentType; vm.setData( - resultMap.zipCodeData, + zipCodeData, resultMap.serviceabilityDetails, - resultMap.mobileFeePart, + pricedMobileFeePart, shopProviderData.data ); vm.initializeDatePicker(); diff --git a/src/store/index.js b/src/store/index.js index 475076c86..dbee4f357 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -59,6 +59,7 @@ import { } from "@/helpers/recal-helper"; import { externalParameterStatus } from "@/constants/external-parameters"; import { experimentSettings } from "@/constants/experiments"; +import { addPricesToLineItems } from "@/helpers/pricing-helper"; // Export State const getDefaultState = () => { @@ -3063,8 +3064,14 @@ export const actions = { pageNameToLog, } ) { + const arrayOfLineItems = [ + ...(pricedLineItems.glassParts ?? []), + ...(pricedLineItems.promos ?? []), + ...(pricedLineItems.supportingItems ?? []), + ...(pricedLineItems.vaps ?? []), + ]; const flattenedLineItemsWithChildParts = - getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems); + getFlattenedArrayOfLineItemsWithChildParts(arrayOfLineItems); const lineItemsWithOnlyPriceInfo = flattenedLineItemsWithChildParts.map((lineItem) => ({ partNumber: lineItem.partNumber, @@ -3113,8 +3120,12 @@ export const actions = { }); context.commit(storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, response.data.serverData); - - pricedLineItems = addTaxesToPricedLineItems(pricedLineItems, response.data.taxedLineItems); + Object.keys(pricedLineItems).forEach((key) => { + pricedLineItems[key] = addTaxesToPricedLineItems( + pricedLineItems[key] ?? [], + response.data.taxedLineItems + ); + }); return pricedLineItems; }, @@ -3796,26 +3807,6 @@ function convertGlassPieceNamingFromApi(glassArray) { return glassArray; } -function addPricesToLineItems(lineItems, pricingLineItems) { - lineItems.forEach((lineItem) => { - const lineItemIndex = pricingLineItems.findIndex( - (pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber - ); - - if (lineItem.childParts) { - addPricesToLineItems(lineItem.childParts, pricingLineItems); - } - - const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; - lineItem.laborAmount = pricedLineItem.laborAmount; - lineItem.sellingPrice = pricedLineItem.sellingPrice; - lineItem.kitPrice = pricedLineItem.kitPrice; - lineItem.salesTax = pricedLineItem.salesTax; - }); - - return lineItems; -} - function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) { pricedLineItems.forEach((pricedLineItem) => { const lineItemIndex = taxingLineItems.findIndex( From 87d83ee21b4bb1a6f17c9f8753b4ac48f2887a11 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Mon, 27 Oct 2025 11:37:43 -0400 Subject: [PATCH 73/94] Make lineItems pricing lineup safer --- src/helpers/pricing-helper.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index 0a8b85d7e..68de04515 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -110,11 +110,13 @@ export function addPricesToLineItems(lineItems, pricingLineItems) { addPricesToLineItems(lineItem.childParts, pricingLineItems); } - const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; - lineItem.laborAmount = pricedLineItem.laborAmount; - lineItem.sellingPrice = pricedLineItem.sellingPrice; - lineItem.kitPrice = pricedLineItem.kitPrice; - lineItem.salesTax = pricedLineItem.salesTax; + if (lineItemIndex !== -1) { + const pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0]; + lineItem.laborAmount = pricedLineItem.laborAmount; + lineItem.sellingPrice = pricedLineItem.sellingPrice; + lineItem.kitPrice = pricedLineItem.kitPrice; + lineItem.salesTax = pricedLineItem.salesTax; + } }); return lineItems; From e29a2a00aef3e6062fee84511e71831dfce84792 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Mon, 27 Oct 2025 16:33:41 -0400 Subject: [PATCH 74/94] Fix cart modification in getter Move it to the removeItem method where it's easier to follow the code flow --- src/fmg-components/cart/cart.vue | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index e9152dd9d..3961c4d4f 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -312,10 +312,23 @@ export default { this.lineItems[category] = this.lineItems[category].filter( (lineItemsToKeep) => lineItemsToKeep.cartItemType != cartItemType ); + let shouldSaveSupportingItems = category == cartItemCategories.SUPPORTING_ITEMS; if (category == cartItemCategories.VAPS || category == cartItemCategories.PROMOS) { this.saveVaps(this.lineItems); + // Check if service package discount should be removed after vaps change + if (this.servicePackageDiscountCartItem && this.discountPackageNames != this.packageLevel) { + this.lineItems.supportingItems = this.lineItems.supportingItems.filter( + (lineItemsToKeep) => lineItemsToKeep.cartItemType != this.servicePackageDiscountCartItem.cartItemType + ); + await this.dispatchStoreAction( + storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, + this.lineItems.supportingItems, + false + ); + shouldSaveSupportingItems = true; + } } - if (category == cartItemCategories.SUPPORTING_ITEMS) { + if (shouldSaveSupportingItems) { await this.dispatchStoreAction( storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, this.lineItems.supportingItems, @@ -471,14 +484,7 @@ export default { } if (this.servicePackageDiscountCartItem) { - if (this.discountPackageNames != this.packageLevel) { - this.removeItem( - this.servicePackageDiscountCartItem.cartItemType, - cartItemCategories.SUPPORTING_ITEMS - ); - } else { - cartItems.push(this.servicePackageDiscountCartItem); - } + cartItems.push(this.servicePackageDiscountCartItem); } if (this.premiumAppointmentDiscountCartItem) { From 78aa17361499b07df5c86c3c9d2cfdc2189b52b6 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Tue, 28 Oct 2025 08:12:39 -0400 Subject: [PATCH 75/94] Formatting --- src/fmg-components/cart/cart.vue | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index 3961c4d4f..9fb5bba92 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -316,9 +316,14 @@ export default { if (category == cartItemCategories.VAPS || category == cartItemCategories.PROMOS) { this.saveVaps(this.lineItems); // Check if service package discount should be removed after vaps change - if (this.servicePackageDiscountCartItem && this.discountPackageNames != this.packageLevel) { + if ( + this.servicePackageDiscountCartItem && + this.discountPackageNames != this.packageLevel + ) { this.lineItems.supportingItems = this.lineItems.supportingItems.filter( - (lineItemsToKeep) => lineItemsToKeep.cartItemType != this.servicePackageDiscountCartItem.cartItemType + (lineItemsToKeep) => + lineItemsToKeep.cartItemType != + this.servicePackageDiscountCartItem.cartItemType ); await this.dispatchStoreAction( storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, From 66b2efdbb4eb2c151eeaac71f2560721e22b52d2 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Tue, 28 Oct 2025 09:26:51 -0400 Subject: [PATCH 76/94] Remove redundant save call --- src/fmg-components/cart/cart.vue | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/fmg-components/cart/cart.vue b/src/fmg-components/cart/cart.vue index 9fb5bba92..d1bd2a939 100644 --- a/src/fmg-components/cart/cart.vue +++ b/src/fmg-components/cart/cart.vue @@ -325,11 +325,6 @@ export default { lineItemsToKeep.cartItemType != this.servicePackageDiscountCartItem.cartItemType ); - await this.dispatchStoreAction( - storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING, - this.lineItems.supportingItems, - false - ); shouldSaveSupportingItems = true; } } From 90370f8a96ec8432fb6ad65077e32b4251ec9e2a Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Tue, 28 Oct 2025 13:31:30 -0400 Subject: [PATCH 77/94] Remove outdated unit test There shouldn't be logic that modifies the cartItems inside the getter --- src/fmg-components/cart/cart.spec.js | 34 ---------------------------- 1 file changed, 34 deletions(-) diff --git a/src/fmg-components/cart/cart.spec.js b/src/fmg-components/cart/cart.spec.js index 6cec9ed3b..f06a49a90 100644 --- a/src/fmg-components/cart/cart.spec.js +++ b/src/fmg-components/cart/cart.spec.js @@ -309,40 +309,6 @@ describe("cart.vue", () => { expect(found).toBe(true); }); - // Service package discount Fee Cart Item - test("only if there is service package discount fee for the package, a service package discount cart item should be added to the cart", () => { - // Arrange - const lineItems = { - glassParts: [], - supportingItems: [ - { - description: null, - id: "bc00294e-6baa-403e-866e-52c267187a15", - kitPrice: 0, - laborAmount: 0, - partNumber: "DISC CASHSAVE70", - partType: "SERVICE PACKAGE DISCOUNT", - salesTax: null, - sellingPrice: -70, - }, - ], - vaps: [], - promos: [], - }; - - const availableVaps = []; - - // Act - const { wrapper } = setupMocks({ - props: { - modelValue: lineItems, - availableVaps: availableVaps, - }, - }); - // Assert - expect(wrapper.vm.servicePackageDiscountCartItem).toBeNull(); - }); - // Other Supporting Items Cart Item test("if there are other supporting items on the order, an other supporting items cart item should be added to the cart but should not be displayed", () => { // Arrange From e693f9e449f42e29639be073701d292031cc3f12 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 28 Oct 2025 15:52:07 -0400 Subject: [PATCH 78/94] CASH-1712: more precise approach to fixing forced refresh of date picker --- src/layouts/schedule/schedule.vue | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index e882a2652..a670d7c5d 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -1669,15 +1669,10 @@ export default { this.selectedProvider = new Provider(); this.updateSelectedProvider(); }, - updateSelectedProvider(newProvider) { - if (newProvider) { - const didProviderNumberChange = - this.selectedProvider.providerNumber !== newProvider.providerNumber; - this.selectedProvider = newProvider; + updateSelectedProvider(newShopProvider) { - if (didProviderNumberChange) { - this.initializeDatePicker(); - } + if (newShopProvider) { + this.selectedProvider = newShopProvider; } else if (this.appointmentType === this.appointmentTypeStrings.MOBILE) { this.selectedProvider = { providerNumber: this.shopProviderData.mobileProviderNumber.toString(), @@ -1743,6 +1738,7 @@ export default { this.lastSelectedInshopOrDropoffProvider = this.selectedProvider; } this.appointmentType = AppointmentTypeStrings.MOBILE; + this.updateSelectedProvider(); } else if (newAppointmentType) { if (this.appointmentType != AppointmentTypeStrings.MOBILE) { // Clear last shop selected if appointment type was changed in any manner other than from Mobile @@ -1756,6 +1752,8 @@ export default { } else { this.appointmentType = AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF; } + // make sure a selectedProvider exists + this.updateSelectedProvider(this.lastSelectedInshopOrDropoffProvider); } else { this.appointmentType = null; } From cdeede1ed68124b059aa5288e6ad55fc8de63d82 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 28 Oct 2025 16:05:50 -0400 Subject: [PATCH 79/94] CASH-1712: missed save for merge conflict; committing conflict resolve --- src/layouts/schedule/schedule.vue | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 1690d831d..106602bdd 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -579,13 +579,8 @@ export default { vm.showPricingByDay = false; // Pricing By Day is not used in this version vm.preSelectedDate = preSelectedDate; vm.appointmentType = appointmentType; -<<<<<<< HEAD vm.setDataOnLoad( resultMap.zipCodeData, -======= - vm.setData( - zipCodeData, ->>>>>>> release/2025.11.06 resultMap.serviceabilityDetails, pricedMobileFeePart, shopProviderData.data @@ -1630,7 +1625,6 @@ export default { this.updateSelectedProvider(); }, updateSelectedProvider(newShopProvider) { - if (newShopProvider) { this.selectedProvider = newShopProvider; } else if (this.appointmentType === this.appointmentTypeStrings.MOBILE) { From c32494309ccd95a2db524424e0593413e8a19486 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Tue, 28 Oct 2025 17:41:27 -0400 Subject: [PATCH 80/94] CASH-1712: correct misunderstood use of zipCodeData var (not part of resultMap) --- src/layouts/schedule/schedule.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 106602bdd..8b275f924 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -580,7 +580,7 @@ export default { vm.preSelectedDate = preSelectedDate; vm.appointmentType = appointmentType; vm.setDataOnLoad( - resultMap.zipCodeData, + zipCodeData, resultMap.serviceabilityDetails, pricedMobileFeePart, shopProviderData.data From cf0816dca33757a5a1cf5f445d60e345d586b815 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 29 Oct 2025 09:23:08 -0400 Subject: [PATCH 81/94] CASH-1712: remove old obsolete files that never should've been committed --- src/layouts/schedule/schedule-june-2025.vue | 782 ------------------ src/layouts/schedule/schedule.specDISABLED.js | 616 -------------- 2 files changed, 1398 deletions(-) delete mode 100644 src/layouts/schedule/schedule-june-2025.vue delete mode 100644 src/layouts/schedule/schedule.specDISABLED.js diff --git a/src/layouts/schedule/schedule-june-2025.vue b/src/layouts/schedule/schedule-june-2025.vue deleted file mode 100644 index 0464b2914..000000000 --- a/src/layouts/schedule/schedule-june-2025.vue +++ /dev/null @@ -1,782 +0,0 @@ - - - - - - - - - - - diff --git a/src/layouts/schedule/schedule.specDISABLED.js b/src/layouts/schedule/schedule.specDISABLED.js deleted file mode 100644 index 9d25152c8..000000000 --- a/src/layouts/schedule/schedule.specDISABLED.js +++ /dev/null @@ -1,616 +0,0 @@ -// Components -import schedule from "@/layouts/schedule/schedule.vue"; - -// Supporting Files -import { shallowMount } from "@vue/test-utils"; -import { getMountOptions } from "@/helpers/unit-test-helper.js"; -import store from "@/store"; -import router from "@/router"; -import baseMixin from "../../mixins/base-mixin"; - -// Mock basemixin -jest.mock("@/mixins/base-mixin.js", () => ({ - methods: { - dispatchStoreAction: jest.fn().mockImplementation((storeAction) => { - if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") { - return { - data: { - estimatedServiceMinutesMinimum: 90, - estimatedServiceMinutesMaximum: 120, - days: [ - { - date: "2023-12-01", - timeSlots: [ - { - id: "06747-01820-S-B*20424*7 AM", - startTime: "07:00", - endTime: "08:00", - offerPremium: false, - }, - ], - }, - ], - }, - }; - } - if (storeAction === "getMobilePremiumFee") { - return Promise.resolve({ - data: { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - }); - } - if (storeAction === "priceOrderItemsAndSaveServerData") { - return Promise.resolve([ - { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - ]); - } - if (storeAction === "saveSupportingItemsSuppressingStateResetting") { - return Promise.resolve([ - { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - ]); - } - }), - dispatchStoreActionWithLogging: jest.fn().mockImplementation((storeAction) => { - if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") { - return { - data: { - estimatedServiceMinutesMinimum: 90, - estimatedServiceMinutesMaximum: 120, - days: [ - { - date: "2023-12-01", - timeSlots: [ - { - id: "06747-01820-S-B*20424*7 AM", - startTime: "07:00", - endTime: "08:00", - offerPremium: false, - }, - ], - }, - ], - }, - }; - } - if (storeAction === "getMobilePremiumFee") { - return Promise.resolve({ - data: { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - }); - } - if (storeAction === "priceOrderItemsAndSaveServerData") { - return Promise.resolve([ - { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - ]); - } - if (storeAction === "saveSupportingItemsSuppressingStateResetting") { - return Promise.resolve([ - { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 14.99, - kitPrice: 0, - }, - ]); - } - }), - filterOutCertainPartTypesOrNumbers: jest.fn(), - hasSubmittedOrder: jest.fn(), - getTotalPriceOfAllLineItemsAndChildParts: jest.fn(), - getTotalLineItemPrice: jest.fn(), - }, -})); - -// Mock fetchCmsContentForPage -jest.mock("@/helpers/cms-content-helper", () => ({ - fetchCmsContentForPage: () => Promise.resolve("content"), - splitCopyOnCMSPlaceHolder: jest.fn(() => ["A", "B"]), -})); - -beforeEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - store.getters = { - applicationUser: { - experiments: [], - }, - order: { - schedule: { - date: "2019-01-01", - startTime: "09:00", - endTime: "10:00", - routeCode: "000", - }, - lineItems: { - glassParts: [ - { - partNumber: "ABC123", - }, - ], - supportingItems: [], - }, - serviceLocation: { - appointmentType: "Inshop", - zipCode: "12345", - zipCodeCtu: "01234", - provider: { - providerNumber: "123", - }, - }, - damage: { - isRepair: false, - }, - referralNumber: "1234567", - policy: { - policyNumber: "123", - }, - }, - payment: { - isInsurance: true, - }, - lineItems: { - glassParts: [], - supportingItems: [], - }, - experimentSettings: {}, - vehicle: { - carId: "123", - }, - }; -}); -afterEach(() => { - store.getters = {}; - jest.restoreAllMocks(); - jest.clearAllMocks(); -}); - -describe("schedule.vue...", () => { - describe("initial load", () => { - test("should pass arePagePrerequisitesValid with a mobile CASH order and no providerNumber", () => { - // Arrange - const { wrapper } = setupMocks({}); - store.getters.order.serviceLocation.appointmentType = "Mobile"; - store.getters.order.serviceLocation.provider.policyNumber = null; - store.getters.payment.isInsurance = false; - - // Act - const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); - - // Assert - expect(arePagePrerequisitesValid).toBe(true); - }); - - test("should pass arePagePrerequisitesValid with an inshop order and providerNumber", () => { - // Arrange - const { wrapper } = setupMocks({}); - - // Act - const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); - - // Assert - expect(arePagePrerequisitesValid).toBe(true); - }); - - test("should fail arePagePrerequisitesValid with a replace with no glass parts", async () => { - // Arrange - const { wrapper } = setupMocks({}); - store.getters.order.lineItems.glassParts = []; - - // Act - const arePagePrerequisitesValid2 = await wrapper.vm.arePagePrerequisitesValid(); - - // Assert - expect(arePagePrerequisitesValid2).toBe(false); - }); - - test("should fail arePagePrerequisitesValid without isInsurance", () => { - // Arrange - const { wrapper } = setupMocks({}); - store.getters.payment.isInsurance = null; - - // Act - const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid(); - - // Assert - expect(arePagePrerequisitesValid).toBe(false); - }); - - test("should return timeslots when getMoreScheduleData is called", async () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - wrapper.vm.selectableDatesMobile = { - days: [], - }; - - // Act - const newShopTimeSlots = await wrapper.vm.getMoreScheduleData( - "2023-01-01", - "2023-01-31" - ); - - // Assert - expect(newShopTimeSlots).toStrictEqual({ - inshopTimeSlotsData: { - days: [ - { - date: "2023-12-01", - timeSlots: [ - { - endTime: "08:00", - id: "06747-01820-S-B*20424*7 AM", - offerPremium: false, - startTime: "07:00", - }, - ], - }, - ], - estimatedServiceMinutesMinimum: 90, - estimatedServiceMinutesMaximum: 120, - }, - mobileTimeSlotsData: { - days: [ - { - date: "2023-12-01", - timeSlots: [ - { - endTime: "08:00", - id: "06747-01820-S-B*20424*7 AM", - offerPremium: false, - startTime: "07:00", - }, - ], - }, - ], - estimatedServiceMinutesMinimum: 90, - estimatedServiceMinutesMaximum: 120, - }, - }); - }); - - test("should call API service in day ranges of 34 or less when getMoreScheduleData is called with large date ranges", async () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - wrapper.vm.selectableDatesMobile = { - days: [], - }; - - // Act - await wrapper.vm.getMoreScheduleData.call( - wrapper.vm, - "2023-01-01", - "2023-03-31", - "Inshop", - "123" - ); - - // Assert - expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledTimes(6); - expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledWith( - "getShopTimeSlots", - expect.anything(), - expect.anything(), - expect.anything() - ); - }); - - describe("beforeRouteEnter function... ", () => { - // TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back) - xtest("should call next() and call all functions within next", async () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - wrapper.vm.updateFooterButtonText = jest.fn(); - wrapper.vm.setDisplayWaitList = jest.fn(); - const nextFunction = jest.fn((c) => { - c(wrapper.vm); - }); - - // Act - await schedule.beforeRouteEnter.call( - wrapper.vm, - { query: { fmgPage: "schedule" } }, - undefined, - nextFunction - ); - - // Assert - expect(nextFunction).toHaveBeenCalled(); - expect(wrapper.vm.setCmsContent).toHaveBeenCalledWith("content"); - expect(wrapper.vm.$refs.datePicker.initializeComponent).toHaveBeenCalledWith( - expect.objectContaining({ - calendarViewDirection: "future", - }) - ); - expect(wrapper.vm.$refs.locationAlerts.initializeComponent).toHaveBeenCalled(); - expect(wrapper.vm.selectableDatesInshop).toStrictEqual( - expect.objectContaining({ - days: expect.any(Array), - estimatedServiceMinutesMaximum: expect.any(Number), - estimatedServiceMinutesMinimum: expect.any(Number), - }) - ); - expect(wrapper.vm.mobilePremiumAppointmentFee).toStrictEqual( - expect.objectContaining({ - partNumber: expect.any(String), - }) - ); - expect(wrapper.vm.updateFooterButtonText).toHaveBeenCalled(); - expect(wrapper.vm.setDisplayWaitList).toHaveBeenCalled(); - }); - }); - - describe("computed properties...", () => { - test("timeSlotsForSelectedDate should return timeslots if selected date is available", () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [ - { - date: "2022-11-11", - timeSlots: [ - { - id: "1820I-01820-M-I*20425*AM", - startTime: "08:00", - endTime: "12:00", - offerPremium: true, - }, - { - id: "1820I-01820-M-I*20425*PM", - startTime: "12:00", - endTime: "17:00", - offerPremium: false, - }, - ], - }, - ], - }; - wrapper.setData({ - selectedDate: "2022-11-11", - }); - - // Act - const testValue = wrapper.vm.timeSlotsForSelectedDate; - - // Assert - expect(testValue).toStrictEqual( - expect.objectContaining({ - date: "2022-11-11", - }) - ); - }); - - test("timeSlotsForSelectedDate should be null if no date has been selected", () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [ - { - date: "2022-11-11", - timeSlots: [ - { - id: "1820I-01820-M-I*20425*AM", - startTime: "08:00", - endTime: "12:00", - offerPremium: true, - }, - { - id: "1820I-01820-M-I*20425*PM", - startTime: "12:00", - endTime: "17:00", - offerPremium: false, - }, - ], - }, - ], - }; - wrapper.setData({ - selectedDate: undefined, - }); - - // Act - const testValue = wrapper.vm.timeSlotsForSelectedDate; - - // Assert - expect(testValue).toBe(null); - }); - }); - }); - - describe("schedule page methods...", () => { - test("getServiceZipCtuCodeFromStore should return zipCodeCtu", () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - - // Act - const testValue = wrapper.vm.getServiceZipCtuCodeFromStore(); - - // Assert - expect(testValue).toStrictEqual("01234"); - }); - - test("getDisplayTextForMilitaryTime should return the correctly formatted string", () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - const timeInput1 = "15:00"; - const timeInput2 = "15:30"; - - // Act - const testOutput1 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput1); - const testOutput2 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput2); - const testOutput3 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput1, true); - const testOutput4 = wrapper.vm.getDisplayTextForMilitaryTime(timeInput2, true); - - // Assert - expect(testOutput1).toBe("3:00 PM"); - expect(testOutput2).toBe("3:30 PM"); - expect(testOutput3).toBe("3 PM"); - expect(testOutput4).toBe("3:30 PM"); - }); - - test("Clicking back should fire correct navigation", () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.selectableDatesInshop = { - days: [], - }; - wrapper.vm.$router.navigateWithoutSaving = jest.fn(); - - // Act - wrapper.vm.backButtonAction(); - - // Assert - expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith( - "CLICKED_BACK", - "schedule" - ); - }); - }); - - // TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back) - xtest("forwardButtonAction should call route method navigateWithoutSaving", async () => { - // Arrange - const { wrapper } = setupMocks({}); - wrapper.vm.dispatchStoreAction = jest.fn(() => { - return { - data: [], - }; - }); - wrapper.vm.$router.navigateWithSaving = jest.fn(() => { - return {}; - }); - - // Act - await wrapper.vm.forwardButtonAction(); - - // Assert - expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled(); - }); - - test("for Inshop appts, updateSupportingItems should call store action to save supporting items WITHOUT the EARLY BIRD supporting item", async () => { - // Arrange - store.getters.order.serviceLocation.appointmentType = "Inshop"; - store.getters.lineItems.supportingItems = [ - { - partNumber: "EARLY BIRD", - description: null, - partType: "EARLY BIRD", - laborAmount: 0, - sellingPrice: 0, - kitPrice: 0, - }, - ]; - const { wrapper } = setupMocks({}); - wrapper.vm.dispatchStoreAction = jest.fn(() => { - return { - data: [], - }; - }); - wrapper.vm.mobilePremiumAppointmentFee = 14.99; - wrapper.setData({ - selectedTimeSlot: { - date: "2019-01-01", - startTime: "09:00", - endTime: "10:00", - routeCode: null, - isPremiumAppointment: true, - }, - }); - - // Act - await wrapper.vm.updateSupportingItems(); - - // Assert - expect(wrapper.vm.dispatchStoreAction).toBeCalledWith( - "saveSupportingItemsSuppressingStateResetting", - expect.not.arrayContaining([ - expect.objectContaining({ - partType: "EARLY BIRD", - }), - ]), - expect.anything() - ); - }); -}); - -const mockCmsContent = {}; - -function setupMocks({ customMountOptions }) { - const mountOptions = getMountOptions({ - ...customMountOptions, - route: { name: "schedule" }, - }); - - mountOptions.global.mocks["$store"] = store; - mountOptions.global.mocks["$router"] = router; - mountOptions["attachTo"] = document.body; - mountOptions.mixins = [ - { - methods: { - getCmsContent: jest.fn().mockImplementation((widgetName, fieldName) => { - if (mockCmsContent[widgetName] && mockCmsContent[widgetName][fieldName]) - return mockCmsContent[widgetName][fieldName]; - }), - }, - }, - ]; - mountOptions.global.mocks.pageName = "schedule"; - - const wrapper = shallowMount(schedule, mountOptions); - - wrapper.vm.setCmsContent = jest.fn(); - wrapper.vm.$refs.datePicker.initializeComponent = jest.fn(); - wrapper.vm.$refs.datePicker.loadInitialData = jest.fn(); - wrapper.vm.$refs.locationAlerts.initializeComponent = jest.fn(); - wrapper.vm.$refs.navbar.updateButtonText = jest.fn(); - - return { wrapper }; -} From fb7204d986dce34d3b3a20de7b2ef57359defb2a Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 29 Oct 2025 09:55:32 -0400 Subject: [PATCH 82/94] CASH-1712: move the providedNumberChange check into the specific condition where we need to reinit --- src/layouts/schedule/schedule.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 8b275f924..40968a4e3 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -1133,6 +1133,7 @@ export default { this.resetWaitlist(); this.preSelectedDate = null; this.shopProviderData = shopQuestionPopUpData.shopProviderData; + const oldProvider = this.selectedProvider; const selectedProvider = this.shopProviderData.shopProviders.find((shopProvider) => { return shopProvider.providerNumber === shopQuestionPopUpData.selectedProviderNumber; }); @@ -1144,7 +1145,12 @@ export default { selectedProvider ); } else { - this.updateSelectedProvider(selectedProvider); + const didProviderNumberChange = + oldProvider.providerNumber !== selectedProvider.providerNumber; + if (didProviderNumberChange) { + this.updateSelectedProvider(selectedProvider); + this.initializeDatePicker(); + } } }, async getMoreScheduleData(startDate, endDate) { From d09a30ae4f1af8c42e58eebf5b6e7b3c05e02bd2 Mon Sep 17 00:00:00 2001 From: AdamCaouetteSafelite Date: Wed, 29 Oct 2025 10:15:58 -0400 Subject: [PATCH 83/94] CASH-1712: add null check --- src/layouts/schedule/schedule.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue index 40968a4e3..0a0a78d68 100644 --- a/src/layouts/schedule/schedule.vue +++ b/src/layouts/schedule/schedule.vue @@ -1146,7 +1146,7 @@ export default { ); } else { const didProviderNumberChange = - oldProvider.providerNumber !== selectedProvider.providerNumber; + oldProvider?.providerNumber !== selectedProvider.providerNumber; if (didProviderNumberChange) { this.updateSelectedProvider(selectedProvider); this.initializeDatePicker(); From 0f207d8aa92aa60aa032ae7843b5c8588244ea04 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Wed, 29 Oct 2025 16:27:01 -0400 Subject: [PATCH 84/94] Grab from correct order rather than going around. --- src/mixins/analytics-mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index d6b25b8dc..78b76caea 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -469,7 +469,7 @@ export default { } // Cash Quote or Cash Price Sub Total - payload.cashPriceSubTotal = store.getters.order?.cashPriceSubTotal ?? ""; + payload.cashPriceSubTotal = order?.cashPriceSubTotal ?? ""; //unverified (in scenarios we don’t display the price) if ( From cd03722642c01d7a7320dd4c19a0b2809c2d8677 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Thu, 30 Oct 2025 08:09:56 -0400 Subject: [PATCH 85/94] CASH-1760 | Fix revalidate tax call It wasn't sending all items --- src/layouts/payment-method/payment-method.vue | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue index 680d060dc..21c16d80b 100644 --- a/src/layouts/payment-method/payment-method.vue +++ b/src/layouts/payment-method/payment-method.vue @@ -489,24 +489,6 @@ export default { }); } - if (revalidatePromoResponse.promoLineItems.length > 0) { - await baseMixin.methods.dispatchStoreActionWithLogging( - storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, - { - billToAccountNumber: store.getters.payment.billToAccountNumber, - providerNumber: - this.$store.getters.order.serviceLocation.provider.providerNumber, - appointmentType: this.$store.getters.order.serviceLocation.appointmentType, - serviceLocationCity: this.$store.getters.order.serviceLocation.city, - serviceLocationState: this.$store.getters.order.serviceLocation.state, - serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode, - pricedLineItems: revalidatePromoResponse.promoLineItems, - }, - "payment-method", - false - ); - } - const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos( revalidatePromoResponse.promoLineItems, this.availableVaps, @@ -520,6 +502,24 @@ export default { getPromoCodeWithoutBundleIdentifier(x.promoCode) ); + if (revalidatePromoResponse.promoLineItems.length > 0) { + await baseMixin.methods.dispatchStoreActionWithLogging( + storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA, + { + billToAccountNumber: store.getters.payment.billToAccountNumber, + providerNumber: + this.$store.getters.order.serviceLocation.provider.providerNumber, + appointmentType: this.$store.getters.order.serviceLocation.appointmentType, + serviceLocationCity: this.$store.getters.order.serviceLocation.city, + serviceLocationState: this.$store.getters.order.serviceLocation.state, + serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode, + pricedLineItems: this.lineItems, + }, + "payment-method", + false + ); + } + // SAVE PROMO CHANGES TO STORE this.dispatchStoreAction( storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS, From 387906baa8bd824fc5a0437cdd47b0422abe757c Mon Sep 17 00:00:00 2001 From: Chris Redelinghuys Date: Thu, 30 Oct 2025 10:08:30 -0400 Subject: [PATCH 86/94] CASH-1535: Mobile First Appointment --- src/constants/experiments.js | 5 + src/digital-components/modal/modal.vue | 1 + .../mobile-first-modal/mobile-first-modal.vue | 214 ++++++++++++++++++ src/layouts/schedule/schedule.vue | 126 ++++++++++- src/styles/ux-variables.scss | 5 +- 5 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 src/layouts/schedule/mobile-first-modal/mobile-first-modal.vue diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 6a2796da9..f358fc89f 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -4,6 +4,7 @@ const experimentUniverses = { MSR: "MSR", IGQ_SkipQuote: "NextGen_IGQSkipToInsurance", AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay", + MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment", }; const experimentSettings = { @@ -28,6 +29,10 @@ const experimentSettings = { DISPLAY_AFTERPAY_BREAKOUT_DISPLAY: "Display_AfterpayBreakoutDisplay", AFTERPAY_EXTENDED_PAY_OPTION_THRESHOLD: "AfterPayExtendedPayOptionThreshold", DYNAMO_LOGGING: "DynamoLogging", + SHOW_MOBILE_FIRST_APPT: "ShowMobileFirstAppt", + SHOW_PM_MOBILE_DAYS: "Show_PMmobileDays", + SHOW_NO_PM_MOBILE_DAYS: "Show_NoPMmobileDays", + SHOW_NO_MOBILE_AVAILABLE_DAYS: "Show_NoMobileAvailableDays", }; const experimentTriggers = { diff --git a/src/digital-components/modal/modal.vue b/src/digital-components/modal/modal.vue index 9167c03b3..867e73c75 100644 --- a/src/digital-components/modal/modal.vue +++ b/src/digital-components/modal/modal.vue @@ -14,6 +14,7 @@