diff --git a/jest.config.js b/jest.config.js
index 6c0addf5f..a8063f324 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -15,6 +15,7 @@ module.exports = {
"!src/main.js",
"!src/constants/*.js",
"!src/router/**/*.js",
+ "src/router/methods/error.js", // Re-include after the exclusion above (Jest applies later positive globs as overrides).
"!src/helpers/unit-test-helper.js",
"!src/helpers/logger.js",
"!src/layouts/vehicle-damage/windshield-options/windshield-options.vue",
diff --git a/src/App.vue b/src/App.vue
index cae31dd24..2b9bc5a33 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -64,6 +64,16 @@ window.onerror = (msg, url, line, col, error) => {
var suppressErrorAlert = true;
return suppressErrorAlert;
};
+
+// Log Promise rejections that are never handled (.catch / await try/catch), e.g. fire-and-forget async.
+window.addEventListener("unhandledrejection", (event) => {
+ const reason = event.reason;
+ const message = reason instanceof Error ? reason.message : String(reason);
+ global.$logger.logError(`unhandledrejection: ${message}`, {
+ stack: reason instanceof Error ? reason.stack : undefined,
+ reason: reason instanceof Error ? undefined : reason,
+ });
+});
diff --git a/src/fmg-components/content-group-modal/content-group-modal.vue b/src/fmg-components/content-group-modal/content-group-modal.vue
index 25c50b2df..e4a5241c4 100644
--- a/src/fmg-components/content-group-modal/content-group-modal.vue
+++ b/src/fmg-components/content-group-modal/content-group-modal.vue
@@ -26,6 +26,7 @@ export default {
name: "content-group-modal",
props: {
cmsWidgetName: String,
+ customBodyText: String,
footerButtonActionName: {
type: String,
default: null,
@@ -43,6 +44,9 @@ export default {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
ModalBodyText() {
+ if (this.customBodyText) {
+ return this.customBodyText;
+ }
return this.getCmsContent(this.cmsWidgetName, "BodyText");
},
ModalSubBodyText() {
diff --git a/src/global-methods.js b/src/global-methods.js
index 4f1dc601b..f408230f8 100644
--- a/src/global-methods.js
+++ b/src/global-methods.js
@@ -102,7 +102,6 @@ export default {
},
(error) => {
if (
- endpoint.toLowerCase().includes(endpoints.LogDigitalConsumer.url) ||
endpoint.toLowerCase().includes(endpoints.LogFmgSessionData.url) ||
endpoint.toLowerCase().includes(endpoints.LogPageView.url) ||
endpoint.toLowerCase().includes(endpoints.LogCustomEvent.url) ||
diff --git a/src/helpers/heritage-integration/navigation-helper.js b/src/helpers/heritage-integration/navigation-helper.js
index 539d36648..a2b4452d5 100644
--- a/src/helpers/heritage-integration/navigation-helper.js
+++ b/src/helpers/heritage-integration/navigation-helper.js
@@ -27,7 +27,7 @@ export async function navigateToHeritageFunnel({ shouldSaveSession, pageNameToLo
var heritageParms = {
corid: store.getters.order.referralCorrelationId,
src: "concept-funnel",
- conceptsqid: store.getters.applicationUser.savedSessionId,
+ conceptsqid: store.getters.applicationUser.savedSessionId ?? "",
isInsurance: store.getters.payment.isInsurance,
};
diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js
index 68de04515..9564b5c1f 100644
--- a/src/helpers/pricing-helper.js
+++ b/src/helpers/pricing-helper.js
@@ -2,6 +2,7 @@ import store from "@/store";
import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js";
import { deepClone } from "@/helpers/object-helper";
+import { partNumberStrings } from "@/constants/part-number-strings";
export function getDisplayAmountDue(lineItemsObject, includeTax = true) {
return getAmountDue(lineItemsObject, includeTax).toLocaleString("en-US", {
@@ -32,6 +33,10 @@ export function getAmountDue(lineItemsObject, includeTax = true) {
if (store.getters.coverageIsVerified && !order.policy.isNoComp && !order.policy.isItac) {
amountDue = order.policy.currentDeductible;
+
+ if (order.isMSRFeeApplicable && !order.isMSRFeeCoveredByInsurance) {
+ amountDue += getMSRFeePartPrice(lineItemsObject?.supportingItems, includeTax);
+ }
}
if (lineItemsObject?.vaps) {
@@ -100,6 +105,20 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) {
return pricingResults[0];
}
+export function getMSRFeePartPrice(supportingItems, includeTax) {
+ let msrFeePrice = 0;
+ const msrFeeLineItem = supportingItems?.find(
+ (lineItem) => lineItem.partNumber === partNumberStrings.MOBILE_STATIC_RECAL_FEE
+ );
+ if (msrFeeLineItem) {
+ msrFeePrice = baseMixin?.methods?.getTotalPriceOfAllLineItemsAndChildParts(
+ [msrFeeLineItem],
+ includeTax
+ );
+ }
+ return msrFeePrice;
+}
+
export function addPricesToLineItems(lineItems, pricingLineItems) {
lineItems.forEach((lineItem) => {
const lineItemIndex = pricingLineItems.findIndex(
diff --git a/src/layouts/confirmation/confirmation.vue b/src/layouts/confirmation/confirmation.vue
index fa0e53425..e26c5f9b1 100644
--- a/src/layouts/confirmation/confirmation.vue
+++ b/src/layouts/confirmation/confirmation.vue
@@ -62,6 +62,7 @@
:showAsPaid="isPia"
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal"
+ msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance"
:insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName"
@@ -70,7 +71,8 @@
:isItac="isItac"
:isNoComp="isNoComp"
:isExpandedOnLoad="false"
- :isMSRFeeApplicable="isMSRFeeApplicable" />
+ :isMSRFeeApplicable="isMSRFeeApplicable"
+ :IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance" />
@@ -419,6 +421,9 @@ export default {
isMSRFeeApplicable() {
return this.submittedOrder?.isMSRFeeApplicable;
},
+ isMSRFeeCoveredByInsurance() {
+ return this.submittedOrder?.isMSRFeeCoveredByInsurance;
+ },
isInsurance() {
return this.submittedOrder?.payment?.isInsurance;
},
diff --git a/src/layouts/estimate/estimate.vue b/src/layouts/estimate/estimate.vue
index 60d050d73..36e27127a 100644
--- a/src/layouts/estimate/estimate.vue
+++ b/src/layouts/estimate/estimate.vue
@@ -62,6 +62,7 @@ import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings";
import { nextTick } from "vue";
import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash";
+import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@@ -169,7 +170,7 @@ export default {
baseMixin.methods.ResetExternalParamsAndHideModal();
}
} else {
- baseMixin.methods.ResetExternalParamsAndHideModal();
+ showFmgLoadingModal(false);
}
} else {
baseMixin.methods.ResetExternalParamsAndHideModal();
diff --git a/src/layouts/payment-adyen/payment-adyen.spec.js b/src/layouts/payment-adyen/payment-adyen.spec.js
index 26d3a6aec..1a506b860 100644
--- a/src/layouts/payment-adyen/payment-adyen.spec.js
+++ b/src/layouts/payment-adyen/payment-adyen.spec.js
@@ -187,6 +187,118 @@ describe("payment-adyen.vue", () => {
expect(result).toBe(true);
});
});
+
+ describe("splitStreetAddress", () => {
+ const setInShopStreet = (streetAddress) => {
+ store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
+ store.getters.order.serviceLocation.provider.address.streetAddress = streetAddress;
+ };
+
+ test("splits typical address into first token as number and remainder as street", () => {
+ setInShopStreet("123 West Elm St");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "123",
+ street: "West Elm St",
+ });
+ });
+
+ test("two tokens: number and single-word street", () => {
+ setInShopStreet("456 Oak");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "456",
+ street: "Oak",
+ });
+ });
+
+ test("single token with no spaces: full string in number, empty street", () => {
+ setInShopStreet("123OakStreet");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "123OakStreet",
+ street: "",
+ });
+ });
+
+ test("collapses multiple spaces between words", () => {
+ setInShopStreet("123 West Elm St");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "123",
+ street: "West Elm St",
+ });
+ });
+
+ test("trims leading and trailing whitespace on full address", () => {
+ setInShopStreet(" 99 Maple Ave ");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "99",
+ street: "Maple Ave",
+ });
+ });
+
+ test("empty string yields empty number and street", () => {
+ setInShopStreet("");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "",
+ street: "",
+ });
+ });
+
+ test("whitespace-only address yields empty number and street", () => {
+ setInShopStreet(" \t ");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "",
+ street: "",
+ });
+ });
+
+ test("null address yields empty number and street", () => {
+ setInShopStreet(null);
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "",
+ street: "",
+ });
+ });
+
+ test("uses mobile service address when appointment is mobile", () => {
+ store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
+ store.getters.order.serviceLocation.address = "700 Broadway Blvd";
+ store.getters.order.serviceLocation.city = "Columbus";
+ store.getters.order.serviceLocation.state = "OH";
+ store.getters.order.serviceLocation.zipCode = "43235";
+
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "700",
+ street: "Broadway Blvd",
+ });
+ });
+
+ test("splits on tabs and other whitespace runs", () => {
+ setInShopStreet("10\tPine\tLane");
+ const wrapper = setupMocks({});
+
+ expect(wrapper.vm.splitStreetAddress).toEqual({
+ number: "10",
+ street: "Pine Lane",
+ });
+ });
+ });
});
function setupMocks({ customMountOptions } = {}) {
diff --git a/src/layouts/payment-adyen/payment-adyen.vue b/src/layouts/payment-adyen/payment-adyen.vue
index 4a4936a7f..aa75487da 100644
--- a/src/layouts/payment-adyen/payment-adyen.vue
+++ b/src/layouts/payment-adyen/payment-adyen.vue
@@ -29,10 +29,12 @@
v-model="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal"
+ msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance"
:insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName"
:showInsuranceCoverageAs="showInsuranceCoverageAs"
+ :isMSRFeeApplicable="isMSRFeeApplicable"
:isItac="isItac"
:isNoComp="isNoComp"
:isCollapsible="false" />
@@ -514,15 +516,14 @@ export default {
},
splitStreetAddress() {
- const address = this.locationInfo.address;
- const tokens = address.split(" ") ?? [""];
- const number = tokens[0];
-
- const street = tokens.slice(1).reduce((prev, next) => `${prev} ${next}`);
+ const address = (this.locationInfo?.address ?? "").trim();
+ const tokens = address.length ? address.split(/\s+/) : [""];
+ const number = tokens[0] ?? "";
+ const street = tokens.length > 1 ? tokens.slice(1).join(" ") : "";
return {
- number: number ?? "",
- street: street ?? "",
+ number,
+ street,
};
},
@@ -568,6 +569,9 @@ export default {
insuranceCompanyName() {
return this.$store.getters.policy.insuranceCompanyName;
},
+ isMSRFeeApplicable() {
+ return this.$store.getters.order.isMSRFeeApplicable;
+ },
lineItems() {
return deepClone(this.$store.getters.lineItems);
},
diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue
index 48f076f84..e5b08bdbc 100644
--- a/src/layouts/payment-method/payment-method.vue
+++ b/src/layouts/payment-method/payment-method.vue
@@ -26,6 +26,7 @@
pageName="payment-method"
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal"
+ msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance"
:insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName"
@@ -35,7 +36,9 @@
:isNoComp="isNoComp"
:isExpandedOnLoad="false"
:isMSRFeeApplicable="isMSRFeeApplicable"
- @itemRemoved="evaluatePromosAndTaxItemsOnOrder" />
+ :IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance"
+ @itemRemoved="evaluatePromosAndTaxItemsOnOrder"
+ @switchToInshop="navigateToSchedulePage" />
@@ -410,6 +412,9 @@ export default {
insuranceCompanyName() {
return this.$store.getters.policy.insuranceCompanyName;
},
+ isMSRFeeApplicable() {
+ return this.$store.getters.order.isMSRFeeApplicable;
+ },
isPaypal() {
if (this.paymentType == "pp") {
return true;
diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue
index b428ab905..106c04bfa 100644
--- a/src/layouts/quote/quote.vue
+++ b/src/layouts/quote/quote.vue
@@ -590,12 +590,6 @@ export default {
}
// there are no active or inactive external parameters; use internal threshold
if (internalThreshold) thresholdToUse = internalThreshold;
-
- vm.isInsuranceSelected = getIsInsuranceSelectedValue(
- vm.availableLineItems,
- thresholdToUse
- );
- baseMixin.methods.ResetExternalParamsAndHideModal();
} else {
// user came from an external source, however, the externalParms may have been reset on service-zip, property-questions, etc...
if (getBoolFromString(store.getters.externalParameterQuote?.isInsurance)) {
@@ -613,8 +607,6 @@ export default {
) {
await vm.skip(insuranceSelection, servicePackage);
}
-
- baseMixin.methods.ResetExternalParamsAndHideModal();
} else {
if (externalThreshold) thresholdToUse = externalThreshold;
@@ -622,15 +614,17 @@ export default {
{
debugLog("quote.vue tab select NOT EXTERNAL");
}
-
- vm.isInsuranceSelected = getIsInsuranceSelectedValue(
- vm.availableLineItems,
- thresholdToUse
- );
- baseMixin.methods.ResetExternalParamsAndHideModal();
}
}
+ if (vm.isInsuranceSelected == null) {
+ vm.isInsuranceSelected = getIsInsuranceSelectedValue(
+ vm.availableLineItems,
+ thresholdToUse
+ );
+ }
+ baseMixin.methods.ResetExternalParamsAndHideModal();
+
if (vm.skipToInsurance && !vm.showSaveProgressPopup) {
// skip ahead only if no email popup.
await vm.skip(true, externalParamPackageLabels.GLASS_ONLY);
diff --git a/src/layouts/schedule/schedule.vue b/src/layouts/schedule/schedule.vue
index ec8051cc4..c483c90df 100644
--- a/src/layouts/schedule/schedule.vue
+++ b/src/layouts/schedule/schedule.vue
@@ -85,6 +85,7 @@
ref="alertMSRNotCoveredByInsurance"
class="mt-5 mb-5"
v-if="isMSRFeeNotCoveredByInsurance"
+ :leftAlignCopy="true"
:manualHeadline="MSRFeeAlertHeadlineText"
:manualCopy="MSRFeeAlertBodyText"
:showWarningIcon="true"
@@ -705,7 +706,8 @@ export default {
isMobileStaticRecalibrationApplicable() {
return (
this.displayMSR &&
- this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE
+ this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE &&
+ (this.enableMSRSplitPay || this.isCashItacNoComp || this.mobileFeePart?.isInsurable)
);
},
displayMSR() {
@@ -715,6 +717,13 @@ export default {
?.toLowerCase() === "true"
);
},
+ enableMSRSplitPay() {
+ return (
+ experimentMixin.methods
+ .getSettingValue(experimentSettings.ENABLE_MSR_SPLIT_PAY)
+ ?.toLowerCase() === "true"
+ );
+ },
isMSRFeeNotCoveredByInsurance() {
return (
this.isMobileSelected &&
@@ -1173,6 +1182,13 @@ export default {
isMSRFeeApplicable
);
},
+ updateAndSaveIsMSRFeeCoveredByInsurance() {
+ const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable;
+ this.dispatchStoreAction(
+ this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE,
+ isMSRFeeCoveredByInsurance
+ );
+ },
updateAndSaveSupportingItems() {
let supportingItems = store.getters.lineItems.supportingItems;
let shouldSaveSupportingItems = false;
@@ -1528,6 +1544,7 @@ export default {
this.updateAndSaveSupportingItems();
this.updateAndSaveIsMSRFeeApplicable();
+ this.updateAndSaveIsMSRFeeCoveredByInsurance();
this.updateSupportingItems();
diff --git a/src/layouts/service-zip/service-zip.vue b/src/layouts/service-zip/service-zip.vue
index ab99adc6f..c82a0a0e3 100644
--- a/src/layouts/service-zip/service-zip.vue
+++ b/src/layouts/service-zip/service-zip.vue
@@ -80,6 +80,7 @@ import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings";
import { routeData } from "@/router/constants/routes";
+import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@@ -137,6 +138,13 @@ export default {
},
false
);
+ vm.serviceZipCode = store.getters.externalParameterServiceZip.zipCode;
+ const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm);
+ if (isValid) {
+ showFmgLoadingModal(true);
+ vm.forwardButtonAction();
+ return;
+ }
}
if (store.getters.externalParameterCustomer?.phoneNumber) {
diff --git a/src/layouts/vehicle-damage/vehicle-damage.spec.js b/src/layouts/vehicle-damage/vehicle-damage.spec.js
index 7ea899fda..3b8e46d22 100644
--- a/src/layouts/vehicle-damage/vehicle-damage.spec.js
+++ b/src/layouts/vehicle-damage/vehicle-damage.spec.js
@@ -17,6 +17,7 @@ import store from "@/store";
import { validate } from "vee-validate";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import * as cookieHelper from "@/helpers/heritage-integration/cookie-helper";
+import { externalParameterStatus } from "@/constants/external-parameters";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
@@ -817,7 +818,11 @@ describe("vehicle-damage.vue", () => {
describe("vehicle-damage.vue", () => {
test("should call forwardButtonAction if isExternalParameter is true and form is valid", async () => {
// Set up the store with isExternalParameter as true
- store.getters.externalParameterState = { isExternalParameter: true };
+ store.getters.externalParameterState = {
+ isExternalParameter: true,
+ isExternalParameter: externalParameterStatus.ACTIVE,
+ vehicleDamage: { damageType: "windshieldReplace" },
+ };
store.getters.externalParameterDamage = {
damageType: "windshieldReplace",
isRepair: false,
diff --git a/src/layouts/vehicle-damage/vehicle-damage.vue b/src/layouts/vehicle-damage/vehicle-damage.vue
index 5e128e867..e68598bd4 100644
--- a/src/layouts/vehicle-damage/vehicle-damage.vue
+++ b/src/layouts/vehicle-damage/vehicle-damage.vue
@@ -93,12 +93,14 @@ import { errorMessages } from "@/constants/error-messages";
import { damageLocationsCms } from "@/constants/damage-locations-cms.js";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
+import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
import { debugLog } from "@/helpers/debug-log-helper";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
import { nextTick } from "vue";
import { getBoolFromString } from "@/helpers/boolean-helper";
+import { externalParameterStatus } from "@/constants/external-parameters";
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED));
@@ -142,7 +144,12 @@ export default {
vm.$refs.backGlassOptions.initializeComponent(
resultMap.damageOptions.backGlassOptions.availableReplacementOptions
);
- if (store.getters.externalParameterState?.isExternalParameter) {
+ if (
+ store.getters.externalParameterState?.isExternalParameter &&
+ store.getters.externalParameterState?.isExternalParameter ===
+ externalParameterStatus.ACTIVE &&
+ store.getters.externalParameterState.vehicleDamage?.damageType
+ ) {
await nextTick();
const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm);
debugLog(`**** vehicle-damage is form valid: ${isValid} ****`);
@@ -199,7 +206,7 @@ export default {
return vm.forwardButtonAction();
}
}
- baseMixin.methods.ResetExternalParamsAndHideModal();
+ showFmgLoadingModal(false);
//clear any validation errors for external parameter flow
const form = vm.$refs.theForm;
@@ -209,7 +216,7 @@ export default {
}
}
} else {
- baseMixin.methods.ResetExternalParamsAndHideModal();
+ showFmgLoadingModal(false);
}
});
},
@@ -354,7 +361,12 @@ export default {
) {
windShieldOptions.selectedWindshieldDamageType =
damageLocationsSelected.REPLACE;
- if (!store.getters.externalParameterState.isExternalParameter) {
+ if (
+ !store.getters.externalParameterState.isExternalParameter ||
+ store.getters.externalParameterState?.isExternalParameter !==
+ externalParameterStatus.ACTIVE ||
+ !store.getters.externalParameterState.vehicleDamage?.damageType
+ ) {
windShieldOptions.selectedWindshieldReplaceOptions.push(
damageLocationsSelected.SINGLE
);
@@ -487,6 +499,14 @@ export default {
policy.policyNumber !== "";
const skipVin = await skipVinLookup();
+
+ this.pushEventToGA(
+ this.GaCategories.VIN_ELIGIBLE,
+ skipVin ? this.GaActions.FALSE : this.GaActions.TRUE,
+ this.$store.getters.vehicle.carId,
+ true
+ );
+
if (vehicleChangedDuringPolicyLookupInHeritage) {
if (skipVin) {
this.$router.navigateWithSaving(
@@ -665,12 +685,21 @@ export default {
return this.$store.getters.requiresVerifiedRedirecting;
},
isContinueEnabled() {
- return (
+ var isEnabled =
this.isAnyDamageSelected &&
this.isWindshieldValid &&
this.isRearGlassValid &&
- this.isSideGlassValid
- );
+ this.isSideGlassValid;
+ if (!isEnabled) {
+ debugLog(
+ `************************ isContinueEnabled: ${isEnabled} *****************************`
+ );
+ debugLog(`**** this.isAnyDamageSelected: ${this.isAnyDamageSelected} ****`);
+ debugLog(`**** this.isWindshieldValid: ${this.isWindshieldValid} ****`);
+ debugLog(`**** this.isRearGlassValid: ${this.isRearGlassValid} ****`);
+ debugLog(`**** this.isSideGlassValid: ${this.isSideGlassValid} ****`);
+ }
+ return isEnabled;
},
isAnyDamageSelected() {
return this.selectedDamageLocations != null && this.selectedDamageLocations.length != 0;
diff --git a/src/layouts/vehicle/vehicle.spec.js b/src/layouts/vehicle/vehicle.spec.js
index d4bc1f726..ba1c4c621 100644
--- a/src/layouts/vehicle/vehicle.spec.js
+++ b/src/layouts/vehicle/vehicle.spec.js
@@ -93,8 +93,11 @@ describe("vehicle.vue", () => {
});
describe("vehicle.vue", () => {
- test("should call forwardButtonAction if isExternalParameter is true and form is valid", async () => {
+ test("should call forwardButtonAction if isExternalParameter is true, has a year, make and model, and form is valid", async () => {
store.getters.externalParameterState.isExternalParameter = true;
+ store.getters.externalParameterVehicle.year = "1886";
+ store.getters.externalParameterVehicle.make = "Benz";
+ store.getters.externalParameterVehicle.model = "Patent-Motorwagen";
// Create a shallow mount of MyComponent
const { wrapper } = setupMocks();
// Set displayNoServiceAlert to false
diff --git a/src/layouts/vehicle/vehicle.vue b/src/layouts/vehicle/vehicle.vue
index 4521a6e2f..5c80694bf 100644
--- a/src/layouts/vehicle/vehicle.vue
+++ b/src/layouts/vehicle/vehicle.vue
@@ -109,6 +109,7 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { applicationConfig } from "../../constants/application-config";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
+import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
import { debugLog } from "@/helpers/debug-log-helper";
//define validation rules
@@ -140,6 +141,8 @@ export default {
styleOptions: [],
displayNoServiceAlert: false,
canSafeliteService: this.canSafeliteServiceFromStore(),
+ skipVINLookup: this.getSkipVINLookupfromStore(),
+ skipPartQuestions: this.getSkipPartQuestionsfromStore(),
};
},
@@ -252,17 +255,17 @@ export default {
const matchingMake = resultMap.makeQuestionInitialData?.filter(
(item) =>
item.toLowerCase() ===
- store.getters.externalParameterVehicle.make.toLowerCase()
+ store.getters.externalParameterVehicle.make?.toLowerCase()
);
const matchingModel = resultMap.modelQuestionInitialData?.filter(
(item) =>
item.toLowerCase() ===
- store.getters.externalParameterVehicle.model.toLowerCase()
+ store.getters.externalParameterVehicle.model?.toLowerCase()
);
const matchingStyle = resultMap.styleQuestionInitialData?.filter(
(item) =>
item.toLowerCase() ===
- store.getters.externalParameterVehicle.style.toLowerCase()
+ store.getters.externalParameterVehicle.style?.toLowerCase()
);
baseMixin.methods.dispatchStoreAction(
storeActions.UPDATE_EXTERNAL_PARAMETER_MMS,
@@ -291,7 +294,12 @@ export default {
resultMap.modelQuestionInitialData,
resultMap.styleQuestionInitialData
);
- if (store.getters.externalParameterState?.isExternalParameter) {
+ if (
+ store.getters.externalParameterState?.isExternalParameter &&
+ store.getters.externalParameterVehicle.year &&
+ store.getters.externalParameterVehicle.make &&
+ store.getters.externalParameterVehicle.model
+ ) {
await vm.getVehicleDetails();
if (!vm.displayNoServiceAlert) {
const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm);
@@ -304,7 +312,7 @@ export default {
baseMixin.methods.ResetExternalParamsAndHideModal();
}
} else {
- baseMixin.methods.ResetExternalParamsAndHideModal();
+ showFmgLoadingModal(false);
}
});
},
@@ -480,6 +488,8 @@ export default {
this.vehicleSubType = result?.data.vehicleSubType;
this.vehicleSpecialClass = result?.data.vehicleSpecialClass;
this.isBigTruck = result?.data.isBigTruck;
+ this.skipVINLookup = result?.data.skipVINLookup;
+ this.skipPartQuestions = result?.data.skipPartQuestions;
},
resetAlert() {
this.displayNoServiceAlert = false;
@@ -536,6 +546,8 @@ export default {
imageUrl: this.imageUrl,
imageVifNumber: this.imageVifNumber,
imageVifColor: this.imageVifColor,
+ skipVINLookup: this.skipVINLookup,
+ skipPartQuestions: this.skipPartQuestions,
},
false
);
@@ -621,6 +633,12 @@ export default {
getIsBigTruckfromStore() {
return store.getters.vehicle.isBigTruck;
},
+ getSkipVINLookupfromStore() {
+ return store.getters.vehicle.skipVINLookup;
+ },
+ getSkipPartQuestionsfromStore() {
+ return store.getters.vehicle.skipPartQuestions;
+ },
},
components: {
diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js
index 781c0cb34..3ab575f58 100644
--- a/src/mixins/analytics-mixin.js
+++ b/src/mixins/analytics-mixin.js
@@ -132,51 +132,6 @@ export default {
}
},
- async logDigitalConsumer() {
- const currentPageName = getPageNameFromRouter();
- const universes = store.getters.applicationUser.experiments;
-
- const variationNames = universes
- .filter((item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL)
- .map((item) => item.variationName)
- .filter(Boolean); // removes undefined/null
-
- const conceptVariation = variationNames.length > 0 ? variationNames[0] : "";
-
- const isConceptExposed = universes.find(
- (item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL
- )?.isExposed;
-
- const submittedOrder = baseMixin.methods.getSubmittedOrder();
- const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder();
- const hasSubmittedOrderAtConfirmationPage =
- hasSubmittedOrder && currentPageName?.toLowerCase() == routeData.CONFIRMATION.name;
-
- var payload = {
- actionName: `Browser page:${currentPageName}`,
- referralSequenceNumber: hasSubmittedOrderAtConfirmationPage
- ? submittedOrder.referralSequenceNumber
- : store.getters.order.referralSequenceNumber,
- referralNumber: hasSubmittedOrderAtConfirmationPage
- ? submittedOrder.referralNumber
- : store.getters.order.referralNumber,
- workOrderId: hasSubmittedOrderAtConfirmationPage
- ? submittedOrder.workOrderId
- : store.getters.order.workOrderId,
- workOrderNumber: hasSubmittedOrderAtConfirmationPage
- ? submittedOrder.workOrderNumber
- : store.getters.order.workOrderNumber,
- conceptVariation: conceptVariation,
- isConceptExposed: isConceptExposed,
- };
-
- await baseMixin.methods.dispatchStoreAction(
- storeActions.LOG_DIGITALCONSUMER,
- payload,
- false
- );
- },
-
async pushEventForChatsToGA(category, action, label, pushToLogApp = false) {
const currentPageName = getPageNameFromRouter();
const value = `2.0_${currentPageName}`;
@@ -206,6 +161,11 @@ export default {
// 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() {
+ var currentPageName = getPageNameFromRouter(true);
+ if (!currentPageName) {
+ return;
+ }
+
const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder();
const submittedOrder = baseMixin.methods.getSubmittedOrder();
const order = hasSubmittedOrder ? submittedOrder : store.getters.order;
@@ -247,22 +207,6 @@ export default {
}
var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`;
- var currentPageName = getPageNameFromRouter();
-
- // add query strings to the page name for debugging. on the vehicle page, if from an external link, pull it from the stash
- if (currentPageName === "vehicle") {
- if (!window.location.search) {
- if (store.getters.externalParameterState?.qsStash) {
- currentPageName += `${store.getters.externalParameterState.qsStash}`;
- }
- } else {
- currentPageName += `${window.location.search}`;
- }
- } else {
- if (window.location.search) {
- currentPageName += `${window.location.search}`;
- }
- }
var sessionData = {};
sessionData.currentPage = currentPageName;
@@ -911,6 +855,9 @@ export default {
setSessionIdIfUnset(response.data.sessionId);
}
}
+
+ // logging on session init so we capture complete query string data
+ await this.pushFmgSessionData();
},
noSession() {
@@ -977,7 +924,7 @@ function pushToDataLayerIfDefined(data) {
}
}
-function getPageNameFromRouter() {
+function getPageNameFromRouter(useDefaultUrl = false) {
if (
router &&
router.currentRoute &&
@@ -987,7 +934,9 @@ function getPageNameFromRouter() {
return router.currentRoute.value.name;
}
- return window.location.href.replace(/\/$/, "").split("/").pop();
+ return useDefaultUrl
+ ? window.location.search
+ : window.location.href.replace(/\/$/, "").split("/").pop();
}
function getValueToLog(value, valueToLogType) {
diff --git a/src/mixins/vehicle-questions-mixin.js b/src/mixins/vehicle-questions-mixin.js
index c8d6a554f..2f64df7b0 100644
--- a/src/mixins/vehicle-questions-mixin.js
+++ b/src/mixins/vehicle-questions-mixin.js
@@ -5,6 +5,8 @@ import { storeActions } from "@/constants/store-actions";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store";
+import { getIsWindshieldOnly } from "@/helpers/damage-helper";
+import { partTypeStrings } from "@/constants/part-type-strings";
export default {
methods: {
@@ -367,8 +369,13 @@ export default {
const hasChildPartQuestions = this.hasChildPartQuestions(partsOrQuestions);
const hasCapabilityQuestions = this.hasCapabilityQuestions(partsOrQuestions);
+ const isWindshieldOnly =
+ getIsWindshieldOnly().toUpperCase() === partTypeStrings.WINDSHIELD;
+ const skipPartQuestions = store.getters.vehicle?.skipPartQuestions && isWindshieldOnly;
+
if (
hasPartQuestions &&
+ !skipPartQuestions &&
this.currentPageComesBeforePage(currentPage, routeData.PART_QUESTIONS.name)
) {
self.$router.navigateWithPageData(
diff --git a/src/router/constants/navigation-scenarios.js b/src/router/constants/navigation-scenarios.js
index 0d0ee836f..21bcc46f3 100644
--- a/src/router/constants/navigation-scenarios.js
+++ b/src/router/constants/navigation-scenarios.js
@@ -64,6 +64,7 @@ const navigationScenarios = {
CLICKED_PAY_NOW: "CLICKED_PAY_NOW",
CLICKED_PAY_NOW_ADYEN: "CLICKED_PAY_NOW_ADYEN",
CLICKED_PAY_LATER: "CLICKED_PAY_LATER",
+ CLICKED_SWITCH_TO_INSHOP: "CLICKED_SWITCH_TO_INSHOP",
PIA_ERROR: "PIA_ERROR",
PIA_CC_ERROR: "PIA_CC_ERROR",
PIA_APPLE_ERROR: "PIA_APPLE_ERROR",
diff --git a/src/router/constants/routing-table.js b/src/router/constants/routing-table.js
index 2d2f49a25..a7be5f387 100644
--- a/src/router/constants/routing-table.js
+++ b/src/router/constants/routing-table.js
@@ -553,6 +553,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_INSURANCE,
destinationPageData: routeData.INSURANCE_COMPANY,
},
+ {
+ scenario: navigationScenarios.CLICKED_SWITCH_TO_INSHOP,
+ destinationPageData: routeData.SCHEDULE,
+ },
],
},
{
diff --git a/src/router/methods/after-each.js b/src/router/methods/after-each.js
index 608119399..43ed0d4b5 100644
--- a/src/router/methods/after-each.js
+++ b/src/router/methods/after-each.js
@@ -5,9 +5,6 @@ import { storeMutations } from "@/constants/store-mutations";
export async function afterEach(to, from) {
store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name);
- // digital consumer logging
- analyticsMixin.methods.logDigitalConsumer();
-
// digital consumer fmg session logging to snowflake
analyticsMixin.methods.pushFmgSessionData();
diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js
index 15eae5d8c..237014ee6 100644
--- a/src/router/methods/before-each.js
+++ b/src/router/methods/before-each.js
@@ -7,6 +7,7 @@ import { isSavedSessionStillActive } from "@/helpers/heritage-integration/sessio
import analyticsMixin from "@/mixins/analytics-mixin";
import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments";
+import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { handleSoftError, handleHardError } from "@/router/methods/error";
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
@@ -16,6 +17,8 @@ import { handleHeritageReturn } from "@/router/methods/helpers/handle-heritage-r
import { isVirtualRoute } from "@/router/methods/helpers/is-virtual-route";
import router from "@/router";
+const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
+
export async function beforeEach(to, from) {
try {
await analyticsMixin.methods.validateSession();
@@ -27,10 +30,37 @@ export async function beforeEach(to, from) {
}
if (getFunnelCookie()?.SuppressConceptFunnel) {
- // Redirect to heritage.
- return {
- name: routeData.HERITAGE.name,
- };
+ const redirectKey = sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT;
+ const redirectCount = Number(window.sessionStorage.getItem(redirectKey) ?? 0);
+
+ if (redirectCount < MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION) {
+ window.sessionStorage.setItem(redirectKey, String(redirectCount + 1));
+ navigateToHeritageFunnel({
+ shouldSaveSession: false,
+ pageNameToLog: to.name,
+ });
+ return false;
+ } else {
+ // If the redirect count is greater than the max allowed, we need to handle the error.
+
+ // Reset the redirect count to 0.
+ window.sessionStorage.setItem(redirectKey, "0");
+
+ debugLog(
+ "SuppressConceptFunnel: skipped heritage redirect after max attempts this session",
+ { redirectCount, to: to.name }
+ );
+
+ const errorPayload = {
+ cause: "Handling heritage redirect after max attempts this session.",
+ currentPage: from?.name,
+ nextPage: to?.name,
+ };
+
+ // Eject user from Vue app in this scenario and clear localstorage.
+ await handleSoftError(errorPayload, true);
+ return false;
+ }
}
// Ensure session has not expired
diff --git a/src/router/methods/error.js b/src/router/methods/error.js
index aa845d452..15cabb9a0 100644
--- a/src/router/methods/error.js
+++ b/src/router/methods/error.js
@@ -12,7 +12,7 @@ export async function handleSoftError(errorPayload, forceRestart = false) {
deleteFunnelCookie();
}
- analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload);
+ logErrorToDataLayerAndLogger("handleSoftError", errorPayload, forceRestart);
router.push({
name: routeData.ERROR.name,
@@ -27,7 +27,7 @@ export async function handleHardError(errorPayload) {
);
if (isInStaticErrorExperiment) {
- analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload);
+ logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
window.top.location = "/fmg/static/error";
return;
} else {
@@ -38,3 +38,10 @@ export async function handleHardError(errorPayload) {
await handleSoftError(errorPayload, true);
}
}
+
+async function logErrorToDataLayerAndLogger(context, errorPayload, forceRestart = null) {
+ const forceRestartSegment = forceRestart === true ? " (force restart)" : "";
+ const message = `${context}${forceRestartSegment}: ${errorPayload?.cause ?? "unknown cause"}`;
+ analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload);
+ global.$logger.logError(message, errorPayload);
+}
diff --git a/src/router/methods/error.spec.js b/src/router/methods/error.spec.js
new file mode 100644
index 000000000..c48d63fda
--- /dev/null
+++ b/src/router/methods/error.spec.js
@@ -0,0 +1,168 @@
+import { handleSoftError, handleHardError } from "./error";
+import router from "@/router";
+import store from "@/store";
+import analyticsMixin from "@/mixins/analytics-mixin";
+import experimentMixin from "../../mixins/experiment-mixin";
+import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
+import { storeActions } from "@/constants/store-actions";
+import { routeData } from "@/router/constants/routes";
+
+jest.mock("@/router", () => ({
+ __esModule: true,
+ default: {
+ push: jest.fn(),
+ },
+}));
+
+jest.mock("@/store", () => ({
+ __esModule: true,
+ default: {
+ dispatch: jest.fn(() => Promise.resolve()),
+ },
+}));
+
+jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
+ deleteFunnelCookie: jest.fn(),
+}));
+
+jest.mock("@/mixins/analytics-mixin", () => ({
+ __esModule: true,
+ default: {
+ methods: {
+ pushPageErrorToDataLayer: jest.fn(),
+ },
+ },
+}));
+
+jest.mock("../../mixins/experiment-mixin", () => ({
+ __esModule: true,
+ default: {
+ methods: {
+ hasSettingEqualTo: jest.fn(),
+ },
+ },
+}));
+
+describe("router/methods/error", () => {
+ const errorPayload = { cause: "test cause", currentPage: "from", nextPage: "to" };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ store.dispatch.mockResolvedValue(undefined);
+ global.$logger = {
+ logInformation: jest.fn(),
+ logWarning: jest.fn(),
+ logError: jest.fn(),
+ logCritical: jest.fn(),
+ };
+ });
+
+ describe("handleSoftError", () => {
+ it("logs and navigates to the error route without reset when forceRestart is false", async () => {
+ await handleSoftError(errorPayload, false);
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ expect(deleteFunnelCookie).not.toHaveBeenCalled();
+ expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
+ errorPayload
+ );
+ expect(global.$logger.logError).toHaveBeenCalledWith(
+ "handleSoftError: test cause",
+ errorPayload
+ );
+ expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
+ });
+
+ it("dispatches reset, deletes funnel cookie, logs, and navigates when forceRestart is true", async () => {
+ await handleSoftError(errorPayload, true);
+
+ expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
+ expect(deleteFunnelCookie).toHaveBeenCalled();
+ expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
+ errorPayload
+ );
+ expect(global.$logger.logError).toHaveBeenCalledWith(
+ "handleSoftError (force restart): test cause",
+ errorPayload
+ );
+ expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
+ });
+
+ it("uses unknown cause in the log message when payload has no cause", async () => {
+ await handleSoftError({}, false);
+
+ expect(global.$logger.logError).toHaveBeenCalledWith(
+ "handleSoftError: unknown cause",
+ {}
+ );
+ });
+ });
+
+ describe("handleHardError", () => {
+ let topLocationValue;
+
+ beforeEach(() => {
+ topLocationValue = undefined;
+ Object.defineProperty(window, "top", {
+ configurable: true,
+ value: {},
+ });
+ Object.defineProperty(window.top, "location", {
+ configurable: true,
+ set(value) {
+ topLocationValue = value;
+ },
+ get() {
+ return topLocationValue;
+ },
+ });
+ });
+
+ it("logs and sets static error location when UseStaticErrorPage experiment is on", async () => {
+ experimentMixin.methods.hasSettingEqualTo.mockReturnValue(true);
+
+ await handleHardError(errorPayload);
+
+ expect(experimentMixin.methods.hasSettingEqualTo).toHaveBeenCalledWith(
+ "UseStaticErrorPage",
+ "true"
+ );
+ expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
+ errorPayload
+ );
+ expect(global.$logger.logError).toHaveBeenCalledWith(
+ "handleHardError: test cause",
+ errorPayload
+ );
+ expect(topLocationValue).toBe("/fmg/static/error");
+ expect(router.push).not.toHaveBeenCalled();
+ });
+
+ it("delegates to handleSoftError with force restart when static error page is off", async () => {
+ experimentMixin.methods.hasSettingEqualTo.mockReturnValue(false);
+
+ await handleHardError(errorPayload);
+
+ expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
+ expect(deleteFunnelCookie).toHaveBeenCalled();
+ expect(global.$logger.logError).toHaveBeenCalledWith(
+ "handleSoftError (force restart): test cause",
+ errorPayload
+ );
+ expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
+ expect(topLocationValue).toBeUndefined();
+ });
+
+ it("delegates to handleSoftError with force restart when experiment check throws", async () => {
+ experimentMixin.methods.hasSettingEqualTo.mockImplementation(() => {
+ throw new Error("experiment failure");
+ });
+
+ await handleHardError(errorPayload);
+
+ expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
+ expect(deleteFunnelCookie).toHaveBeenCalled();
+ expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
+ });
+ });
+});
diff --git a/src/router/methods/helpers/initialize-from-querystrings.js b/src/router/methods/helpers/initialize-from-querystrings.js
index 8dff4423d..4d342cdc0 100644
--- a/src/router/methods/helpers/initialize-from-querystrings.js
+++ b/src/router/methods/helpers/initialize-from-querystrings.js
@@ -9,6 +9,7 @@ import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { queryStrings } from "@/constants/query-strings";
import { externalParameterStatus } from "@/constants/external-parameters";
import { debugLog } from "@/helpers/debug-log-helper";
+import { runExperiments, forceExperiments } from "@/router/methods/helpers/run-experiments";
export async function initializeFromQueryStrings() {
// Get advertiser info
@@ -16,6 +17,17 @@ export async function initializeFromQueryStrings() {
// Read all external paramter queries and write to store.
updateExternalParameterState();
+
+ forceExperimentsFromQuerystrings();
+}
+
+async function forceExperimentsFromQuerystrings() {
+ const experimentsFromQueryString = getQuerystringParameter(queryStrings.EXPERIMENTS);
+ if (experimentsFromQueryString) {
+ var qs = decodeURIComponent(window.location.search);
+ let forcedExperiments = qs.substring(qs.indexOf(queryStrings.EXPERIMENTS));
+ await forceExperiments(forcedExperiments);
+ }
}
function setupAdvertisersAndAffiliates() {
@@ -39,7 +51,6 @@ function updateExternalParameterState() {
const externalParameterVinSelection = getQuerystringParameter(queryStrings.VIN_SELECTION);
const externalParameterServicePackage = getQuerystringParameter(queryStrings.SERVICE_PACKAGE);
const externalParameterNumberOfChips = getQuerystringParameter(queryStrings.NUMBER_OF_CHIPS);
- const externalParameterSource = getQuerystringParameter(queryStrings.EXPERIMENTS);
const externalParameterPhoneNumber = getQuerystringParameter(queryStrings.PHONE_NUMBER);
if (!externalParameterZipCode) {
@@ -58,7 +69,6 @@ function updateExternalParameterState() {
debugLog("externalParameterVinSelection:", externalParameterVinSelection);
debugLog("externalParameterServicePackage:", externalParameterServicePackage);
debugLog("externalParameterNumberOfChips:", externalParameterNumberOfChips);
- debugLog("externalParameterSource:", externalParameterSource);
debugLog("externalPhoneNumber:", externalParameterPhoneNumber);
}
@@ -95,6 +105,7 @@ function updateExternalParameterState() {
}
if (externalParameterZipCode) {
store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_ZIP_CODE, externalParameterZipCode);
+ store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.ACTIVE);
}
if (externalParameterEmail) {
store.commit(
@@ -115,9 +126,6 @@ function updateExternalParameterState() {
externalParameterVinSelection
);
}
- if (externalParameterSource) {
- store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_SOURCE, externalParameterSource);
- }
if (externalParameterPhoneNumber) {
store.commit(
storeMutations.UPDATE_EXTERNAL_PARAMETER_PHONE_NUMBER,
diff --git a/src/router/methods/helpers/run-experiments.js b/src/router/methods/helpers/run-experiments.js
index 97472ad0c..edcfddeb4 100644
--- a/src/router/methods/helpers/run-experiments.js
+++ b/src/router/methods/helpers/run-experiments.js
@@ -28,3 +28,15 @@ export async function runExperiments(nextPageName) {
nextPageName
);
}
+
+export async function forceExperiments(forceExperimentQueryStringValues) {
+ await baseMixin.methods.dispatchStoreActionWithLogging(
+ storeActions.FORCE_EXPERIMENTS,
+ {
+ deviceId: getDeviceIdValue(),
+ queryStringExperiments: forceExperimentQueryStringValues,
+ },
+ "/",
+ false
+ );
+}
diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js
index fb9dd2cbc..162e5b914 100644
--- a/src/router/methods/route-logic/error.js
+++ b/src/router/methods/route-logic/error.js
@@ -19,7 +19,6 @@ export async function errorBeforeEnter(to, from) {
nextPage: to?.name,
};
- analyticsMixin.methods.logDigitalConsumer();
await handleHardError(errorPayload);
return;
}
@@ -33,7 +32,6 @@ export async function errorBeforeEnter(to, from) {
nextPage: to?.name,
};
- analyticsMixin.methods.logDigitalConsumer();
await handleHardError(errorPayload);
return;
} else {
diff --git a/src/router/methods/route-logic/heritage.js b/src/router/methods/route-logic/heritage.js
index d1a4c41c4..509c135eb 100644
--- a/src/router/methods/route-logic/heritage.js
+++ b/src/router/methods/route-logic/heritage.js
@@ -1,5 +1,6 @@
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
+// Unsure if this is needed. I don't think its used anywhere
export async function heritageBeforeEnter(to, from) {
await navigateToHeritageFunnel({ shouldSaveSession: false, pageNameToLog: to.name });
}
diff --git a/src/store/index.js b/src/store/index.js
index e740bf0d3..da45f3a61 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -9,7 +9,6 @@ import { storeActions } from "@/constants/store-actions";
import { applicationConfig } from "@/constants/application-config";
import { experimentTriggers } from "@/constants/experiments";
import { damageLocationsSelected } from "@/constants/damage-locations-selected";
-import { singleWindshieldCarIds } from "@/constants/single-windshield-carids";
import { routeData } from "@/router/constants/routes";
import {
deleteFunnelCookie,
@@ -83,6 +82,8 @@ const getDefaultState = () => {
registration: {
licensePlate: null,
},
+ skipVINLookup: false,
+ skipPartQuestions: false,
},
serviceLocation: {
address: null,
@@ -192,6 +193,7 @@ const getDefaultState = () => {
isRecalAckOptIn: false,
isRecalAcknowledgedForScheduling: "",
isMSRFeeApplicable: false,
+ isMSRFeeCoveredByInsurance: false,
},
applicationUser: {
eventBus: [],
@@ -251,6 +253,15 @@ export const mutations = {
updateVehicleCanSafeliteService(state, canSafeliteService) {
state.order.vehicle.canSafeliteService = canSafeliteService;
},
+ updateSkipVINLookup(state, skipVINLookup) {
+ state.order.vehicle.skipVINLookup = skipVINLookup;
+ },
+ updateSkipPartQuestions(state, skipPartQuestions) {
+ state.order.vehicle.skipPartQuestions = skipPartQuestions;
+ },
+ updateSkipCapabilitiesQuestions(state, skipCapabilitiesQuestions) {
+ state.order.vehicle.skipCapabilitiesQuestions = skipCapabilitiesQuestions;
+ },
updateVehicleImageUrl(state, imageUrl) {
state.order.vehicle.imageUrl = imageUrl;
},
@@ -359,6 +370,9 @@ export const mutations = {
updateIsMSRFeeApplicable(state, isMSRFeeApplicable) {
state.order.isMSRFeeApplicable = isMSRFeeApplicable;
},
+ updateIsMSRFeeCoveredByInsurance(state, isMSRFeeCoveredByInsurance) {
+ state.order.isMSRFeeCoveredByInsurance = isMSRFeeCoveredByInsurance;
+ },
updateCashPriceSubTotal(state, cashPriceSubTotal) {
state.order.cashPriceSubTotal =
cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString();
@@ -1593,6 +1607,7 @@ export const actions = {
operatorId: "WEB",
userName: "SafeliteConceptFunnel",
referrer: referrer,
+ clientData: window.location.search,
};
return globalMethods
@@ -1630,38 +1645,6 @@ export const actions = {
});
},
- logDigitalConsumer(
- context,
- {
- actionName,
- referralSequenceNumber,
- referralNumber,
- workOrderId,
- workOrderNumber,
- conceptVariation,
- isConceptExposed,
- }
- ) {
- var payload = {
- sessionId: getSessionIdValue(),
- deviceId: getDeviceIdValue(),
- actionName: actionName ?? "",
- referralSequenceNumber: referralSequenceNumber ?? "",
- referralNumber: referralNumber ?? "",
- applicationName: baseMixin.methods.isMobileDevice() ? "2.0 Mobile" : "2.0",
- workOrderId: workOrderId ?? "",
- workOrderNumber: workOrderNumber ?? "",
- conceptVariation: conceptVariation,
- isConceptExposed: isConceptExposed,
- };
-
- return globalMethods.callHttpClient({
- method: endpoints.LogDigitalConsumer.method,
- endpoint: endpoints.LogDigitalConsumer.url,
- payload: payload,
- });
- },
-
logFmgSessionData(
context,
{
@@ -1770,6 +1753,7 @@ export const actions = {
method: endpoints.LogFmgSessionData.method,
endpoint: endpoints.LogFmgSessionData.url,
payload: payload,
+ logApiCall: false,
});
},
@@ -1816,6 +1800,25 @@ export const actions = {
context.commit(storeMutations.UPDATE_EXPERIMENTS, response.data.experiments);
},
+ async forceExperiments(
+ context,
+ { payload: { deviceId, queryStringExperiments }, pageNameToLog }
+ ) {
+ var payload = {
+ applicationName: applicationConfig.APPLICATION_NAME,
+ deviceId: deviceId,
+ queryStringExperiments: queryStringExperiments,
+ };
+
+ const response = await globalMethods.callHttpClient({
+ method: endpoints.ForceExperiments.method,
+ endpoint: endpoints.ForceExperiments.url,
+ payload: payload,
+ logApiCall: true,
+ pageNameToLog: pageNameToLog,
+ });
+ },
+
getEvoxImage(context, { relativeUrl }) {
return globalMethods.callHttpClient({
method: endpoints.GetPageData.method,
@@ -2058,52 +2061,27 @@ export const actions = {
},
getServiceabilityDetails(context, { payload: { serviceZipCode }, pageNameToLog }) {
- const escapeRecalibrationType = (rt) => rt.split("&").join("%26");
-
- const partialLineItemsObjects = context.getters.order.lineItems.glassParts?.map(
- (lineItem) => ({
- partNumber: lineItem.partNumber,
- recalibrationType:
- lineItem.recalibrationType && isRecalPartOrHasChildRecalPart(lineItem)
- ? escapeRecalibrationType(lineItem.recalibrationType)
- : undefined,
- })
+ const flattenedLineItemsWithChildParts = getFlattenedArrayOfLineItemsWithChildParts(
+ context.getters.order.lineItems.glassParts
);
- var lineItems = null;
- if (partialLineItemsObjects) {
- lineItems = buildQueryStringParameterFromArrayOfComplexObjects(
- partialLineItemsObjects,
- "lineItems"
- );
- }
+ const lineItemsList = flattenedLineItemsWithChildParts
+ .map((item) => item.partNumber)
+ .join(",");
const vehicle = context.getters.vehicle;
const carId = vehicle.carId;
const damage = context.getters.damage;
- const glassArray = convertGlassPieceNamingForApi(damage.glassToReplace);
-
- const glassPieces = buildQueryStringParameterFromArrayOfComplexObjects(
- glassArray,
- "glassPieces"
- );
const parentAccountNumber =
context.getters.order.payment.parentAccountNumber ??
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER;
- const referralSequenceNumber = context.getters.order.referralSequenceNumber;
- var endPoint = `${endpoints.GetServiceabilityDetails.url}?zip=${serviceZipCode}&carId=${carId}&parentAccountNumber=${parentAccountNumber}&referralSequenceNumber=${referralSequenceNumber}&applicationName=${applicationConfig.ANALYTICS_APPLICATION_NAME}`;
- if (lineItems) {
- endPoint += `&${lineItems}`;
+ var endPoint = `${endpoints.GetServiceabilityDetails.url}?zip=${serviceZipCode}&carId=${carId}&parentAccountNumber=${parentAccountNumber}&isRepair=${damage.isRepair}`;
+ if (lineItemsList) {
+ endPoint += `&lineItems=${lineItemsList}`;
}
- if (glassPieces) {
- endPoint += `&${glassPieces}`;
- }
-
- endPoint += `&isHeavyTruckVehicle=${context.getters.order.vehicle.isBigTruck}`;
-
return globalMethods.callHttpClient({
method: endpoints.GetServiceabilityDetails.method,
endpoint: endPoint,
@@ -2146,6 +2124,7 @@ export const actions = {
const carId = context.getters.vehicle.carId;
const isRepair = context.getters.damage.isRepair;
const numberOfChips = context.getters.damage.numberOfChips;
+ const apptType = context.getters.order?.serviceLocation?.appointmentType;
return globalMethods.callHttpClient({
method: endpoints.GetSupportingItems.method,
@@ -2156,6 +2135,9 @@ export const actions = {
parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
parts: glassPartsArray,
numberOfRepairChips: isRepair ? numberOfChips : 0,
+ providerNumber: context.getters.order.serviceLocation.zipCodeCtu,
+ facilityType:
+ apptType && apptType.trim() !== "" ? apptType : AppointmentTypeStrings.IN_SHOP,
},
logApiCall: true,
pageNameToLog: pageNameToLog,
@@ -2611,6 +2593,7 @@ export const actions = {
lockToken: order.lockToken,
isRecalAckOptIn: order.isRecalAckOptIn,
isMSRFeeApplicable: order.isMSRFeeApplicable,
+ isMSRFeeCoveredByInsurance: order.isMSRFeeCoveredByInsurance,
cashPriceSubTotal: order.cashPriceSubTotal,
},
},
@@ -2723,6 +2706,8 @@ export const actions = {
imageUrl,
imageVifNumber,
imageVifColor,
+ skipVINLookup,
+ skipPartQuestions,
}
) {
if (
@@ -2748,6 +2733,8 @@ export const actions = {
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, imageUrl);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, imageVifNumber);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, imageVifColor);
+ context.commit(storeMutations.UPDATE_SKIP_VIN_LOOKUP, skipVINLookup);
+ context.commit(storeMutations.UPDATE_SKIP_PART_QUESTIONS, skipPartQuestions);
}
},
@@ -3043,6 +3030,13 @@ export const actions = {
context.commit(storeMutations.UPDATE_IS_MSR_FEE_APPLICABLE, isMSRFeeApplicable);
},
+ saveIsMSRFeeCoveredByInsurance(context, isMSRFeeCoveredByInsurance) {
+ context.commit(
+ storeMutations.UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE,
+ isMSRFeeCoveredByInsurance
+ );
+ },
+
saveParentAccountNumber(context, parentAccountNumber) {
context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber);
},
@@ -3535,25 +3529,7 @@ export const actions = {
},
isVinOptionalVehicle(context) {
- //Optional for carIds with only a single windshield
- if (singleWindshieldCarIds.find((item) => item === context.state.order.vehicle.carId)) {
- return true;
- }
- //Optional for specific YMMSs
- switch (context.state.order.vehicle.make.toLowerCase()) {
- case "porsche":
- return true;
- default:
- }
- if (
- (context.state.order.vehicle.make.toLowerCase() === "bmw" &&
- context.state.order.vehicle.year < 2010) ||
- (context.state.order.vehicle.make.toLowerCase() === "volkswagen" &&
- context.state.order.vehicle.year < 2010) ||
- (context.state.order.vehicle.make.toLowerCase() === "audi" &&
- context.state.order.vehicle.year < 2010)
- )
- return true;
+ if (context.state.order.vehicle.skipVINLookup) return true;
return false;
},
diff --git a/src/store/store.spec.js b/src/store/store.spec.js
index 71e2ec894..ea896116d 100644
--- a/src/store/store.spec.js
+++ b/src/store/store.spec.js
@@ -4261,6 +4261,8 @@ describe("isVinOptionalVehicle", () => {
},
};
+ mutations.updateSkipVINLookup(context.state, expectedVinSkip);
+
var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip);
}
@@ -4291,6 +4293,8 @@ describe("isVinOptionalVehicle", () => {
},
};
+ mutations.updateSkipVINLookup(context.state, expectedVinSkip);
+
var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip);
}
diff --git a/src/ux-components/alert/alert.vue b/src/ux-components/alert/alert.vue
index deb7bca8b..448917c1d 100644
--- a/src/ux-components/alert/alert.vue
+++ b/src/ux-components/alert/alert.vue
@@ -1,6 +1,6 @@
-
+
"Mock CMS Content"),
+ },
+};
+
+function setupMocks(mountOptionsMockData = {}) {
+ const defaultMountOptions = {
+ propsData: {
+ cmsWidgetName: "testWidget",
+ },
+ mixins: [mockMixin],
+ };
+ return { ...defaultMountOptions, ...mountOptionsMockData };
+}
+
+describe("toolTip.vue", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should not show tooltip initially", () => {
+ // Arrange
+ const wrapper = shallowMount(toolTip, setupMocks());
+
+ // Assert
+ expect(wrapper.vm.isVisible).toBe(false);
+ expect(wrapper.find(".tooltip").exists()).toBe(false);
+ });
+
+ it("should show tooltip when icon is clicked", async () => {
+ // Arrange
+ const wrapper = shallowMount(toolTip, setupMocks());
+ const icon = wrapper.find(".tooltip-icon");
+
+ // Act
+ await icon.trigger("click");
+
+ // Assert
+ expect(wrapper.vm.isVisible).toBe(true);
+ expect(wrapper.find(".tooltip").exists()).toBe(true);
+ });
+
+ it("should hide tooltip when clicked outside", async () => {
+ // Arrange
+ const wrapper = shallowMount(toolTip, setupMocks());
+ const icon = wrapper.find(".tooltip-icon");
+
+ // Show tooltip first
+ await icon.trigger("click");
+ expect(wrapper.vm.isVisible).toBe(true);
+
+ // Act - simulate click outside
+ const clickEvent = new Event("click");
+ Object.defineProperty(clickEvent, "target", { value: document.body });
+ document.dispatchEvent(clickEvent);
+
+ // Wait for next tick to allow event handling
+ await wrapper.vm.$nextTick();
+
+ // Assert
+ expect(wrapper.vm.isVisible).toBe(false);
+ expect(wrapper.find(".tooltip").exists()).toBe(false);
+ });
+
+ it("should toggle tooltip visibility when icon is clicked multiple times", async () => {
+ // Arrange
+ const wrapper = shallowMount(toolTip, setupMocks());
+ const icon = wrapper.find(".tooltip-icon");
+
+ // Act & Assert - First click shows
+ await icon.trigger("click");
+ expect(wrapper.vm.isVisible).toBe(true);
+
+ // Second click hides
+ await icon.trigger("click");
+ expect(wrapper.vm.isVisible).toBe(false);
+
+ // Third click shows again
+ await icon.trigger("click");
+ expect(wrapper.vm.isVisible).toBe(true);
+ });
+
+ it("should not hide tooltip when clicking inside the tooltip container", async () => {
+ // Arrange
+ const wrapper = shallowMount(toolTip, setupMocks());
+ const icon = wrapper.find(".tooltip-icon");
+
+ // Show tooltip first
+ await icon.trigger("click");
+ expect(wrapper.vm.isVisible).toBe(true);
+
+ // Act - simulate click inside container
+ const container = wrapper.find(".tooltip-container").element;
+ const clickEvent = new Event("click");
+ Object.defineProperty(clickEvent, "target", { value: container });
+ document.dispatchEvent(clickEvent);
+
+ // Wait for next tick
+ await wrapper.vm.$nextTick();
+
+ // Assert - tooltip should still be visible
+ expect(wrapper.vm.isVisible).toBe(true);
+ });
+
+ it("should calculate lineItemContainerLength based on parent element width", () => {
+ // Arrange
+ const wrapper = mount(toolTip, setupMocks());
+ const parentDiv = document.createElement("div");
+ Object.defineProperty(parentDiv, "offsetWidth", { value: 300, writable: true });
+
+ const container = wrapper.vm.$refs.container;
+ Object.defineProperty(container, "parentElement", { value: parentDiv, writable: true });
+
+ // Act & Assert
+ expect(wrapper.vm.lineItemContainerLength).toBe(-300);
+ });
+});
diff --git a/src/ux-components/tool-tip/tool-tip.vue b/src/ux-components/tool-tip/tool-tip.vue
new file mode 100644
index 000000000..648c5adbd
--- /dev/null
+++ b/src/ux-components/tool-tip/tool-tip.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+