Merge branch 'develop' into feature/CASH-1252

This commit is contained in:
kpatel8hs4io 2026-05-03 12:15:31 -04:00
commit b82bda7d01
45 changed files with 1276 additions and 19130 deletions

View file

@ -15,6 +15,7 @@ module.exports = {
"!src/main.js", "!src/main.js",
"!src/constants/*.js", "!src/constants/*.js",
"!src/router/**/*.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/unit-test-helper.js",
"!src/helpers/logger.js", "!src/helpers/logger.js",
"!src/layouts/vehicle-damage/windshield-options/windshield-options.vue", "!src/layouts/vehicle-damage/windshield-options/windshield-options.vue",

View file

@ -64,6 +64,16 @@ window.onerror = (msg, url, line, col, error) => {
var suppressErrorAlert = true; var suppressErrorAlert = true;
return suppressErrorAlert; 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,
});
});
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -18,6 +18,7 @@ const GaCategories = {
INSHOP_CONFIRMATION_CLICKED: "inshop confirmation clicked", INSHOP_CONFIRMATION_CLICKED: "inshop confirmation clicked",
MOBILE_CONFIRMATION_CLICKED: "mobile confirmation clicked", MOBILE_CONFIRMATION_CLICKED: "mobile confirmation clicked",
RECAL_DISCLAIMER: "Recalibration disclaimer", RECAL_DISCLAIMER: "Recalibration disclaimer",
VIN_ELIGIBLE: "VIN eligible",
}; };
const GaActions = { const GaActions = {
@ -32,6 +33,8 @@ const GaActions = {
MORE_LOCATIONS_CLICKED: "more_locations_clicked", MORE_LOCATIONS_CLICKED: "more_locations_clicked",
YES: "yes", YES: "yes",
NO: "no", NO: "no",
TRUE: "true",
FALSE: "false",
ACKNOWLEDGED: "Acknowledged", ACKNOWLEDGED: "Acknowledged",
}; };

View file

@ -81,7 +81,7 @@ const endpoints = {
method: "GET", method: "GET",
}, },
GetSupportingItems: { GetSupportingItems: {
url: "/parts/api/v1/parts/supporting-items", url: "/parts/api/v2/parts/supporting-items",
method: "POST", method: "POST",
}, },
GetRecalPart: { GetRecalPart: {
@ -180,10 +180,6 @@ const endpoints = {
url: "/analytics/api/v1/analytics/log-part-questions", url: "/analytics/api/v1/analytics/log-part-questions",
method: "POST", method: "POST",
}, },
LogDigitalConsumer: {
url: "/analytics/api/v1/analytics/digitalconsumer-log",
method: "POST",
},
LogFmgSessionData: { LogFmgSessionData: {
url: "/analytics/api/v1/analytics/digitalconsumer-session-logging", url: "/analytics/api/v1/analytics/digitalconsumer-session-logging",
method: "POST", method: "POST",
@ -196,6 +192,10 @@ const endpoints = {
url: "/experiments/api/v1/experiments/run", url: "/experiments/api/v1/experiments/run",
method: "POST", method: "POST",
}, },
ForceExperiments: {
url: "/experiments/api/v1/experiments/forceVariations",
method: "POST",
},
GetInsuranceCompanyList: { GetInsuranceCompanyList: {
url: "/account/api/v1/account/insurance-companies", url: "/account/api/v1/account/insurance-companies",
method: "GET", method: "GET",

View file

@ -23,6 +23,7 @@ const experimentSettings = {
INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold",
INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold", INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold",
DISPLAY_MSR: "DisplayMSR", DISPLAY_MSR: "DisplayMSR",
ENABLE_MSR_SPLIT_PAY: "EnableMSRSplitPay",
DISPLAY_WAITLIST: "DisplayWaitlist2.0", DISPLAY_WAITLIST: "DisplayWaitlist2.0",
WAITLIST_THRESHOLD_DAYS: "Waitlist_Threshold_Days", WAITLIST_THRESHOLD_DAYS: "Waitlist_Threshold_Days",
PRICING_BY_DAY: "DisplayPricingByDay", PRICING_BY_DAY: "DisplayPricingByDay",

View file

@ -1,3 +1,5 @@
export const sessionStorageKeyConstants = { export const sessionStorageKeyConstants = {
SUBMITTED_STATE: "submittedState", SUBMITTED_STATE: "submittedState",
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
}; };

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,7 @@ const storeActions = {
TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "taxOrderItemsAndSaveServerData", TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "taxOrderItemsAndSaveServerData",
GET_EXPERIMENTS_BY_USER: "GetExperimentsByUser", GET_EXPERIMENTS_BY_USER: "GetExperimentsByUser",
RUN_EXPERIMENTS_FOR_TRIGGER: "runExperimentsForTrigger", RUN_EXPERIMENTS_FOR_TRIGGER: "runExperimentsForTrigger",
FORCE_EXPERIMENTS: "forceExperiments",
CLEAR_VIN: "clearVin", CLEAR_VIN: "clearVin",
RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise", RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise",
GET_SIGNATURE: "getSignature", GET_SIGNATURE: "getSignature",
@ -63,7 +64,6 @@ const storeActions = {
LOG_CUSTOM_EVENT: "logCustomEvent", LOG_CUSTOM_EVENT: "logCustomEvent",
INITIALIZE_SESSION: "initializeSession", INITIALIZE_SESSION: "initializeSession",
LOG_PART_QUESTIONS: "logPartQuestions", LOG_PART_QUESTIONS: "logPartQuestions",
LOG_DIGITALCONSUMER: "logDigitalConsumer",
LOG_FMG_SESSION_DATA: "logFmgSessionData", LOG_FMG_SESSION_DATA: "logFmgSessionData",
// DEPENDENCY MUTATIONS // DEPENDENCY MUTATIONS
@ -114,6 +114,7 @@ const storeActions = {
SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "saveIsRecalAcknowledgedForScheduling", SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "saveIsRecalAcknowledgedForScheduling",
SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected", SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected",
SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable", SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable",
SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance",
CREATE_SUBMITTED_STATE: "createSubmittedState", CREATE_SUBMITTED_STATE: "createSubmittedState",
RESET_SUBMITTED_STATE: "resetSubmittedState", RESET_SUBMITTED_STATE: "resetSubmittedState",

View file

@ -20,6 +20,8 @@ const storeMutations = {
UPDATE_VEHICLE_VIN: "updateVehicleVin", UPDATE_VEHICLE_VIN: "updateVehicleVin",
UPDATE_VEHICLE: "updateVehicle", UPDATE_VEHICLE: "updateVehicle",
UPDATE_VEHICLE_CAN_SAFELITE_SERVICE: "updateVehicleCanSafeliteService", UPDATE_VEHICLE_CAN_SAFELITE_SERVICE: "updateVehicleCanSafeliteService",
UPDATE_SKIP_VIN_LOOKUP: "updateSkipVINLookup",
UPDATE_SKIP_PART_QUESTIONS: "updateSkipPartQuestions",
UPDATE_IS_REPAIR: "updateIsRepair", UPDATE_IS_REPAIR: "updateIsRepair",
UPDATE_NUMBER_OF_CHIPS: "updateNumberOfChips", UPDATE_NUMBER_OF_CHIPS: "updateNumberOfChips",
@ -72,6 +74,7 @@ const storeMutations = {
UPDATE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "updateIsRecalAcknowledgedForScheduling", UPDATE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "updateIsRecalAcknowledgedForScheduling",
UPDATE_IS_OEM_GLASS_SELECTED: "updateIsOemGlassSelected", UPDATE_IS_OEM_GLASS_SELECTED: "updateIsOemGlassSelected",
UPDATE_IS_MSR_FEE_APPLICABLE: "updateIsMSRFeeApplicable", UPDATE_IS_MSR_FEE_APPLICABLE: "updateIsMSRFeeApplicable",
UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE: "updateIsMSRFeeCoveredByInsurance",
UPDATE_CASH_PRICE_SUBTOTAL: "updateCashPriceSubTotal", UPDATE_CASH_PRICE_SUBTOTAL: "updateCashPriceSubTotal",
// EVENT BUS MUTATIONS // EVENT BUS MUTATIONS
@ -118,7 +121,6 @@ const storeMutations = {
UPDATE_EXTERNAL_PARAMETER_IS_INSURANCE: "updateExternalParameterIsInsurance", UPDATE_EXTERNAL_PARAMETER_IS_INSURANCE: "updateExternalParameterIsInsurance",
UPDATE_EXTERNAL_PARAMETER_VIN_SELECTION: "updateExternalParameterVinSelection", UPDATE_EXTERNAL_PARAMETER_VIN_SELECTION: "updateExternalParameterVinSelection",
UPDATE_EXTERNAL_PARAMETER_SERVICE_PACKAGE: "updateExternalParameterServicePackage", UPDATE_EXTERNAL_PARAMETER_SERVICE_PACKAGE: "updateExternalParameterServicePackage",
UPDATE_EXTERNAL_PARAMETER_SOURCE: "updateExternalParameterSource",
UPDATE_EXTERNAL_PARAMETER_PHONE_NUMBER: "updateExternalParameterPhoneNumber", UPDATE_EXTERNAL_PARAMETER_PHONE_NUMBER: "updateExternalParameterPhoneNumber",
UPDATE_EXTERNAL_PARAMETER_QS_STASH: "updateExternalParameterQsStash", UPDATE_EXTERNAL_PARAMETER_QS_STASH: "updateExternalParameterQsStash",

View file

@ -77,7 +77,7 @@
i % 2 == 0 ? 'even' : 'odd', i % 2 == 0 ? 'even' : 'odd',
cartItem.isRemovable ? 'removable-cart-item' : '', cartItem.isRemovable ? 'removable-cart-item' : '',
]"> ]">
<span v-if="cartItem != recycleFeeCartItem">{{ cartItem.name }}</span> <span v-if="!this.shouldShowInfoIcon(cartItem)">{{ cartItem.name }}</span>
<textLink <textLink
v-if="allowItemRemoval && cartItem.isRemovable" v-if="allowItemRemoval && cartItem.isRemovable"
ref="removeLink" ref="removeLink"
@ -87,11 +87,15 @@
@click-event="removeItem(cartItem.cartItemType, cartItem.category)" /> @click-event="removeItem(cartItem.cartItemType, cartItem.category)" />
<span v-else-if="cartItem == recycleFeeCartItem"> <span v-else-if="cartItem == recycleFeeCartItem">
{{ recycleFeeCartItem.name }} {{ recycleFeeCartItem.name }}
<a <toolTip
href="javascript:void(0)" :cmsWidgetName="recyclingModalCmsWidgetName"
@click="openModal(recyclingModalCmsWidgetName)"> :customBodyText="recyclingToolTipBodyText" />
<img :src="infoIcon" alt="Info Icon" class="info-icon" /> </span>
</a> <span v-if="cartItem == msrFeeCartItem">
{{ msrFeeCartItem.name }}
<toolTip
:cmsWidgetName="msrModalCmsWidgetName"
:customBodyText="msrToolTipBodyText" />
</span> </span>
<span <span
v-if=" v-if="
@ -138,6 +142,10 @@
:pageName="pageName" :pageName="pageName"
modalWidgetName="PromoModalWidget" modalWidgetName="PromoModalWidget"
@promoAdded="saveVaps" /> @promoAdded="saveVaps" />
<removeMsrFeeModal
modalWidgetName="RemoveMsrFeeModalWidget"
ref="removeMsrFeeModal"
@switchToInshop="switchToInshop" />
</div> </div>
</div> </div>
<div <div
@ -150,9 +158,6 @@
applied applied
</span> </span>
</div> </div>
<contentGroupModal ref="RecycleModal" cmsWidgetName="RecycleModal">
<textBlock cmsWidgetName="RecycleTextBlock" />
</contentGroupModal>
</div> </div>
</template> </template>
@ -160,8 +165,9 @@
// Components // Components
import textLink from "@/ux-components/text-link/text-link"; import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block"; import textBlock from "@/digital-components/text-block/text-block";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question"; import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question";
import removeMsrFeeModal from "./remove-msr-fee-modal/remove-msr-fee-modal.vue";
import toolTip from "@/ux-components/tool-tip/tool-tip.vue";
// Mixins // Mixins
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
@ -183,6 +189,7 @@ import {
getSalesTax, getSalesTax,
getAmountDueWithDonation, getAmountDueWithDonation,
} from "@/helpers/pricing-helper.js"; } from "@/helpers/pricing-helper.js";
import { formatToUSDollar } from "@/helpers/cms-content-helper";
// Constants // Constants
import { partTypeStrings } from "@/constants/part-type-strings"; import { partTypeStrings } from "@/constants/part-type-strings";
@ -191,6 +198,7 @@ import { cartItemTypes } from "@/constants/cart-item-types";
import { coverageStatus, cartItemTypesCoveredByInsurance } from "@/constants/insurance"; import { coverageStatus, cartItemTypesCoveredByInsurance } from "@/constants/insurance";
import { experimentSettings } from "@/constants/experiments"; import { experimentSettings } from "@/constants/experiments";
import { quotePageDiscountTable } from "@/constants/quote-page-discounts"; import { quotePageDiscountTable } from "@/constants/quote-page-discounts";
import { partNumberStrings } from "@/constants/part-number-strings";
export default { export default {
name: "cart", name: "cart",
@ -203,6 +211,7 @@ export default {
pageName: String, pageName: String,
allowItemRemoval: Boolean, allowItemRemoval: Boolean,
recyclingModalCmsWidgetName: String, recyclingModalCmsWidgetName: String,
msrModalCmsWidgetName: String,
showAsPaid: Boolean, showAsPaid: Boolean,
isInsurance: Boolean, isInsurance: Boolean,
insuranceDeductible: Number, insuranceDeductible: Number,
@ -214,6 +223,7 @@ export default {
isExpandedOnLoad: Boolean, isExpandedOnLoad: Boolean,
isCollapsible: { type: Boolean, default: true }, isCollapsible: { type: Boolean, default: true },
isMSRFeeApplicable: Boolean, isMSRFeeApplicable: Boolean,
IsMSRFeeCoveredByInsurance: Boolean,
donationCartItem: Object, donationCartItem: Object,
}, },
data() { data() {
@ -311,6 +321,10 @@ export default {
}, },
async removeItem(cartItemType, category) { async removeItem(cartItemType, category) {
if (cartItemType == cartItemTypes.MOBILE_FEE && this.isMSRFeeApplicable) {
this.$refs.removeMsrFeeModal.openModal();
return false;
}
this.lineItems[category] = this.lineItems[category].filter( this.lineItems[category] = this.lineItems[category].filter(
(lineItemsToKeep) => lineItemsToKeep.cartItemType != cartItemType (lineItemsToKeep) => lineItemsToKeep.cartItemType != cartItemType
); );
@ -375,6 +389,12 @@ export default {
} }
return []; return [];
}, },
shouldShowInfoIcon(cartItem) {
return cartItem == this.recycleFeeCartItem || cartItem == this.msrFeeCartItem;
},
switchToInshop() {
this.$emit("switchToInshop");
},
}, },
computed: { computed: {
isCartReadyToLoad() { isCartReadyToLoad() {
@ -490,6 +510,10 @@ export default {
cartItems.push(this.mobileFeeCartItem); cartItems.push(this.mobileFeeCartItem);
} }
if (this.msrFeeCartItem) {
cartItems.push(this.msrFeeCartItem);
}
if (this.quotePageDiscountCartItem) { if (this.quotePageDiscountCartItem) {
cartItems.push(this.quotePageDiscountCartItem); cartItems.push(this.quotePageDiscountCartItem);
} }
@ -892,7 +916,9 @@ export default {
let cartItem = null; let cartItem = null;
let showMobileFeeCartItem = false; let showMobileFeeCartItem = false;
const mobileFeeLineItem = this.supportingItems.find( const mobileFeeLineItem = this.supportingItems.find(
(lineItem) => lineItem.partType == partTypeStrings.MOBILE_FEE (lineItem) =>
lineItem.partType == partTypeStrings.MOBILE_FEE &&
lineItem.partNumber == partTypeStrings.MOBILE_FEE
); );
var mobileTotal = var mobileTotal =
@ -929,6 +955,72 @@ export default {
} }
return cartItem; return cartItem;
}, },
msrFeeCartItemName() {
return this.getCmsContent("MSRFeeTextWidget", "Text");
},
getMsrFeeLineItem() {
return this.supportingItems.find(
(lineItem) =>
lineItem.partType == partTypeStrings.MOBILE_FEE &&
lineItem.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE
);
},
msrFeeCartItem() {
let cartItem = null;
const msrFeeLineItem = this.getMsrFeeLineItem;
const shouldCreateMsrFeeCartItem =
this.isMSRFeeApplicable &&
(!this.isInsurance || !this.IsMSRFeeCoveredByInsurance) &&
msrFeeLineItem &&
this.msrFeeAmount > 0;
if (shouldCreateMsrFeeCartItem) {
cartItem = {
name: this.msrFeeCartItemName,
category: cartItemCategories.SUPPORTING_ITEMS,
cartItemType: cartItemTypes.MOBILE_FEE,
isDisplayed: true,
isRemovable: true,
subTotal: 0,
salesTax: 0,
lineItems: [],
isCoveredByInsurance: this.IsMSRFeeCoveredByInsurance,
};
msrFeeLineItem.cartItemType = cartItem.cartItemType;
cartItem.lineItems.push(msrFeeLineItem);
cartItem.subTotal += this.msrFeeAmount;
cartItem.salesTax += msrFeeLineItem.salesTax ?? 0;
}
return cartItem;
},
msrFeeAmount() {
const msrFeeLineItem = this.getMsrFeeLineItem;
if (!msrFeeLineItem) {
return 0;
}
return (
msrFeeLineItem.laborAmount + msrFeeLineItem.sellingPrice + msrFeeLineItem.kitPrice
);
},
recyclingToolTipBodyText() {
return this.getCmsContent("RecycleTextBlock", "Text");
},
msrToolTipBodyText() {
let cmsContentText = this.isInsurance
? this.getCmsContent("MSRModal", "BodyText")
: this.getCmsContent("MSRModal", "BodyText2");
if (this.isInsurance && cmsContentText) {
cmsContentText = cmsContentText.replaceAll(
"{custom:mobileFee}",
formatToUSDollar(this.msrFeeAmount)
);
cmsContentText = cmsContentText.replaceAll(
"{custom:submittedOrder.policy.insuranceCompanyName}",
this.insuranceCompanyName
);
}
return cmsContentText;
},
quotePageDiscountCartItemName() { quotePageDiscountCartItemName() {
return this.getCmsContent("ServicePackageDiscountTextWidget", "Text"); return this.getCmsContent("ServicePackageDiscountTextWidget", "Text");
}, },
@ -1187,8 +1279,9 @@ export default {
components: { components: {
textBlock, textBlock,
textLink, textLink,
contentGroupModal,
promoModalQuestion, promoModalQuestion,
removeMsrFeeModal,
toolTip,
}, },
}; };
</script> </script>
@ -1289,6 +1382,10 @@ export default {
order: 3; order: 3;
margin-right: 100%; margin-right: 100%;
text-decoration: none; text-decoration: none;
&#msr-fee-cartItem {
margin: 0 0 0 0.2rem;
}
} }
} }
.package-type, .package-type,

View file

@ -0,0 +1,153 @@
<template>
<div id="msr-fee-modal-container">
<modal
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:isFooterButtonPrimary="true"
@footer-button-event="switchToInshop"
@isModalOpened="setIsModalOpen">
<p class="modal-body-inner" v-html="modalBodyText"></p>
<template v-slot:modal-header-slot>
<span>{{ modalSubHeaderText }}</span>
</template>
<template v-slot:modal-footer-slot>
<button
type="button"
class="btn btn-link"
id="cancel-button"
:disabled="isLoading"
@click="closeModal">
{{ buttonText }}
</button>
</template>
</modal>
</div>
</template>
<script>
// Supporting files
import modal from "@/digital-components/modal/modal";
export default {
name: "remove-msr-fee-modal",
props: {
cmsWidgetName: String,
modalWidgetName: String,
},
data() {
return {
isModalOpen: false,
switchedToInshop: false,
};
},
methods: {
openModal() {
this.modal.openModal();
},
onModalClosed() {
this.$emit("close-remove-msr-fee-modal");
this.switchedToInshop = false;
},
closeModal() {
this.modal.closeModal();
},
getIsModalOpen() {
return this.isModalOpen;
},
setIsModalOpen(isOpen) {
this.isModalOpen = isOpen;
},
switchToInshop() {
this.switchedToInshop = true;
this.$emit("switchToInshop");
this.modal.closeModal();
},
},
computed: {
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
modalSubHeaderText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
modalBodyText() {
return this.getCmsContent(this.modalWidgetName, "BodyText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
buttonText() {
return this.getCmsContent(this.modalWidgetName, "FooterText2");
},
},
components: { modal },
};
</script>
<style lang="scss">
#msr-fee-modal-container {
.modal-component {
@include media-breakpoint-up(md) {
.modal-dialog {
left: 0;
align-content: center;
flex-wrap: wrap;
width: 22.063rem;
transform: translate(0, 0);
.modal-content {
border-radius: $border-radius-lg;
}
}
}
.modal-dialog {
.modal-content {
.modal-header {
flex-direction: column;
& > span {
color: $red;
font-size: $font-size-14;
font-weight: $font-weight-600;
text-transform: uppercase;
}
.modal-title {
font-size: $font-size-20;
font-weight: $font-weight-normal;
}
}
.modal-body {
padding: 0.5rem 1rem;
.modal-body-inner {
ul {
list-style: none;
padding: 1rem;
background-color: #f4f4f4;
font-size: $font-size-14;
}
}
}
.modal-footer {
flex-flow: wrap-reverse;
#cancel-button {
width: 100%;
text-decoration: none;
margin-top: 1.5rem;
font-weight: $font-weight-600;
}
}
}
}
}
}
</style>

View file

@ -26,6 +26,7 @@ export default {
name: "content-group-modal", name: "content-group-modal",
props: { props: {
cmsWidgetName: String, cmsWidgetName: String,
customBodyText: String,
footerButtonActionName: { footerButtonActionName: {
type: String, type: String,
default: null, default: null,
@ -43,6 +44,9 @@ export default {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText"); return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
}, },
ModalBodyText() { ModalBodyText() {
if (this.customBodyText) {
return this.customBodyText;
}
return this.getCmsContent(this.cmsWidgetName, "BodyText"); return this.getCmsContent(this.cmsWidgetName, "BodyText");
}, },
ModalSubBodyText() { ModalSubBodyText() {

View file

@ -102,7 +102,6 @@ export default {
}, },
(error) => { (error) => {
if ( if (
endpoint.toLowerCase().includes(endpoints.LogDigitalConsumer.url) ||
endpoint.toLowerCase().includes(endpoints.LogFmgSessionData.url) || endpoint.toLowerCase().includes(endpoints.LogFmgSessionData.url) ||
endpoint.toLowerCase().includes(endpoints.LogPageView.url) || endpoint.toLowerCase().includes(endpoints.LogPageView.url) ||
endpoint.toLowerCase().includes(endpoints.LogCustomEvent.url) || endpoint.toLowerCase().includes(endpoints.LogCustomEvent.url) ||

View file

@ -27,7 +27,7 @@ export async function navigateToHeritageFunnel({ shouldSaveSession, pageNameToLo
var heritageParms = { var heritageParms = {
corid: store.getters.order.referralCorrelationId, corid: store.getters.order.referralCorrelationId,
src: "concept-funnel", src: "concept-funnel",
conceptsqid: store.getters.applicationUser.savedSessionId, conceptsqid: store.getters.applicationUser.savedSessionId ?? "",
isInsurance: store.getters.payment.isInsurance, isInsurance: store.getters.payment.isInsurance,
}; };

View file

@ -2,6 +2,7 @@ import store from "@/store";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
import { deepClone } from "@/helpers/object-helper"; import { deepClone } from "@/helpers/object-helper";
import { partNumberStrings } from "@/constants/part-number-strings";
export function getDisplayAmountDue(lineItemsObject, includeTax = true) { export function getDisplayAmountDue(lineItemsObject, includeTax = true) {
return getAmountDue(lineItemsObject, includeTax).toLocaleString("en-US", { 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) { if (store.getters.coverageIsVerified && !order.policy.isNoComp && !order.policy.isItac) {
amountDue = order.policy.currentDeductible; amountDue = order.policy.currentDeductible;
if (order.isMSRFeeApplicable && !order.isMSRFeeCoveredByInsurance) {
amountDue += getMSRFeePartPrice(lineItemsObject?.supportingItems, includeTax);
}
} }
if (lineItemsObject?.vaps) { if (lineItemsObject?.vaps) {
@ -100,6 +105,20 @@ export async function getPricingByDayPartWithPrice(pageNameToLog) {
return pricingResults[0]; 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) { export function addPricesToLineItems(lineItems, pricingLineItems) {
lineItems.forEach((lineItem) => { lineItems.forEach((lineItem) => {
const lineItemIndex = pricingLineItems.findIndex( const lineItemIndex = pricingLineItems.findIndex(

View file

@ -62,6 +62,7 @@
:showAsPaid="isPia" :showAsPaid="isPia"
servicePackageOptionsCmsName="ServicePackageTitle" servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" recyclingModalCmsWidgetName="RecycleModal"
msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance" :isInsurance="isInsurance"
:insuranceDeductible="currentDeductible" :insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName" :insuranceCompanyName="insuranceCompanyName"
@ -70,7 +71,8 @@
:isItac="isItac" :isItac="isItac"
:isNoComp="isNoComp" :isNoComp="isNoComp"
:isExpandedOnLoad="false" :isExpandedOnLoad="false"
:isMSRFeeApplicable="isMSRFeeApplicable" /> :isMSRFeeApplicable="isMSRFeeApplicable"
:IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance" />
<hr class="mb-5" /> <hr class="mb-5" />
</div> </div>
@ -419,6 +421,9 @@ export default {
isMSRFeeApplicable() { isMSRFeeApplicable() {
return this.submittedOrder?.isMSRFeeApplicable; return this.submittedOrder?.isMSRFeeApplicable;
}, },
isMSRFeeCoveredByInsurance() {
return this.submittedOrder?.isMSRFeeCoveredByInsurance;
},
isInsurance() { isInsurance() {
return this.submittedOrder?.payment?.isInsurance; return this.submittedOrder?.payment?.isInsurance;
}, },

View file

@ -62,6 +62,7 @@ import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import { nextTick } from "vue"; import { nextTick } from "vue";
import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash"; import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
// Define Validation Rules // Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -169,7 +170,7 @@ export default {
baseMixin.methods.ResetExternalParamsAndHideModal(); baseMixin.methods.ResetExternalParamsAndHideModal();
} }
} else { } else {
baseMixin.methods.ResetExternalParamsAndHideModal(); showFmgLoadingModal(false);
} }
} else { } else {
baseMixin.methods.ResetExternalParamsAndHideModal(); baseMixin.methods.ResetExternalParamsAndHideModal();

View file

@ -187,6 +187,118 @@ describe("payment-adyen.vue", () => {
expect(result).toBe(true); 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 } = {}) { function setupMocks({ customMountOptions } = {}) {

View file

@ -29,10 +29,12 @@
v-model="lineItems" v-model="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle" servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" recyclingModalCmsWidgetName="RecycleModal"
msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance" :isInsurance="isInsurance"
:insuranceDeductible="currentDeductible" :insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName" :insuranceCompanyName="insuranceCompanyName"
:showInsuranceCoverageAs="showInsuranceCoverageAs" :showInsuranceCoverageAs="showInsuranceCoverageAs"
:isMSRFeeApplicable="isMSRFeeApplicable"
:isItac="isItac" :isItac="isItac"
:isNoComp="isNoComp" :isNoComp="isNoComp"
:isCollapsible="false" /> :isCollapsible="false" />
@ -514,15 +516,14 @@ export default {
}, },
splitStreetAddress() { splitStreetAddress() {
const address = this.locationInfo.address; const address = (this.locationInfo?.address ?? "").trim();
const tokens = address.split(" ") ?? [""]; const tokens = address.length ? address.split(/\s+/) : [""];
const number = tokens[0]; const number = tokens[0] ?? "";
const street = tokens.length > 1 ? tokens.slice(1).join(" ") : "";
const street = tokens.slice(1).reduce((prev, next) => `${prev} ${next}`);
return { return {
number: number ?? "", number,
street: street ?? "", street,
}; };
}, },
@ -568,6 +569,9 @@ export default {
insuranceCompanyName() { insuranceCompanyName() {
return this.$store.getters.policy.insuranceCompanyName; return this.$store.getters.policy.insuranceCompanyName;
}, },
isMSRFeeApplicable() {
return this.$store.getters.order.isMSRFeeApplicable;
},
lineItems() { lineItems() {
return deepClone(this.$store.getters.lineItems); return deepClone(this.$store.getters.lineItems);
}, },

View file

@ -26,6 +26,7 @@
pageName="payment-method" pageName="payment-method"
servicePackageOptionsCmsName="ServicePackageTitle" servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" recyclingModalCmsWidgetName="RecycleModal"
msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance" :isInsurance="isInsurance"
:insuranceDeductible="currentDeductible" :insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName" :insuranceCompanyName="insuranceCompanyName"
@ -35,7 +36,9 @@
:isNoComp="isNoComp" :isNoComp="isNoComp"
:isExpandedOnLoad="false" :isExpandedOnLoad="false"
:isMSRFeeApplicable="isMSRFeeApplicable" :isMSRFeeApplicable="isMSRFeeApplicable"
@itemRemoved="evaluatePromosAndTaxItemsOnOrder" /> :IsMSRFeeCoveredByInsurance="isMSRFeeCoveredByInsurance"
@itemRemoved="evaluatePromosAndTaxItemsOnOrder"
@switchToInshop="navigateToSchedulePage" />
<afterpayBreakout <afterpayBreakout
v-if="isAfterpayBreakoutDisplay" v-if="isAfterpayBreakoutDisplay"
@ -214,6 +217,7 @@ export default {
name: "paymentMethod", name: "paymentMethod",
props: { props: {
recyclingModalCmsWidgetName: String, recyclingModalCmsWidgetName: String,
msrModalCmsWidgetName: String,
}, },
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
// Call APIs // Call APIs
@ -607,6 +611,13 @@ export default {
openModal(modalName) { openModal(modalName) {
this.$refs[modalName].openModal(); this.$refs[modalName].openModal();
}, },
async navigateToSchedulePage() {
this.$refs.loadingModal.showModal();
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_SWITCH_TO_INSHOP,
this.pageName
);
},
}, },
computed: { computed: {
AppointmentType() { AppointmentType() {
@ -682,6 +693,9 @@ export default {
isMSRFeeApplicable() { isMSRFeeApplicable() {
return this.$store.getters.order.isMSRFeeApplicable; return this.$store.getters.order.isMSRFeeApplicable;
}, },
isMSRFeeCoveredByInsurance() {
return this.$store.getters.order.isMSRFeeCoveredByInsurance;
},
isInsurance() { isInsurance() {
return this.hasSubmittedOrder() return this.hasSubmittedOrder()
? this.getSubmittedOrder()?.payment.isInsurance ? this.getSubmittedOrder()?.payment.isInsurance

View file

@ -47,10 +47,12 @@
v-model="lineItems" v-model="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle" servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" recyclingModalCmsWidgetName="RecycleModal"
msrModalCmsWidgetName="MSRModal"
:isInsurance="isInsurance" :isInsurance="isInsurance"
:insuranceDeductible="currentDeductible" :insuranceDeductible="currentDeductible"
:insuranceCompanyName="insuranceCompanyName" :insuranceCompanyName="insuranceCompanyName"
:showInsuranceCoverageAs="showInsuranceCoverageAs" :showInsuranceCoverageAs="showInsuranceCoverageAs"
:isMSRFeeApplicable="isMSRFeeApplicable"
:isItac="isItac" :isItac="isItac"
:isNoComp="isNoComp" :isNoComp="isNoComp"
:isCollapsible="false" /> :isCollapsible="false" />
@ -410,6 +412,9 @@ export default {
insuranceCompanyName() { insuranceCompanyName() {
return this.$store.getters.policy.insuranceCompanyName; return this.$store.getters.policy.insuranceCompanyName;
}, },
isMSRFeeApplicable() {
return this.$store.getters.order.isMSRFeeApplicable;
},
isPaypal() { isPaypal() {
if (this.paymentType == "pp") { if (this.paymentType == "pp") {
return true; return true;

View file

@ -590,12 +590,6 @@ export default {
} }
// there are no active or inactive external parameters; use internal threshold // there are no active or inactive external parameters; use internal threshold
if (internalThreshold) thresholdToUse = internalThreshold; if (internalThreshold) thresholdToUse = internalThreshold;
vm.isInsuranceSelected = getIsInsuranceSelectedValue(
vm.availableLineItems,
thresholdToUse
);
baseMixin.methods.ResetExternalParamsAndHideModal();
} else { } else {
// user came from an external source, however, the externalParms may have been reset on service-zip, property-questions, etc... // 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)) { if (getBoolFromString(store.getters.externalParameterQuote?.isInsurance)) {
@ -613,8 +607,6 @@ export default {
) { ) {
await vm.skip(insuranceSelection, servicePackage); await vm.skip(insuranceSelection, servicePackage);
} }
baseMixin.methods.ResetExternalParamsAndHideModal();
} else { } else {
if (externalThreshold) thresholdToUse = externalThreshold; if (externalThreshold) thresholdToUse = externalThreshold;
@ -622,15 +614,17 @@ export default {
{ {
debugLog("quote.vue tab select NOT EXTERNAL"); 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) { if (vm.skipToInsurance && !vm.showSaveProgressPopup) {
// skip ahead only if no email popup. // skip ahead only if no email popup.
await vm.skip(true, externalParamPackageLabels.GLASS_ONLY); await vm.skip(true, externalParamPackageLabels.GLASS_ONLY);

View file

@ -85,6 +85,7 @@
ref="alertMSRNotCoveredByInsurance" ref="alertMSRNotCoveredByInsurance"
class="mt-5 mb-5" class="mt-5 mb-5"
v-if="isMSRFeeNotCoveredByInsurance" v-if="isMSRFeeNotCoveredByInsurance"
:leftAlignCopy="true"
:manualHeadline="MSRFeeAlertHeadlineText" :manualHeadline="MSRFeeAlertHeadlineText"
:manualCopy="MSRFeeAlertBodyText" :manualCopy="MSRFeeAlertBodyText"
:showWarningIcon="true" :showWarningIcon="true"
@ -705,7 +706,8 @@ export default {
isMobileStaticRecalibrationApplicable() { isMobileStaticRecalibrationApplicable() {
return ( return (
this.displayMSR && 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() { displayMSR() {
@ -715,6 +717,13 @@ export default {
?.toLowerCase() === "true" ?.toLowerCase() === "true"
); );
}, },
enableMSRSplitPay() {
return (
experimentMixin.methods
.getSettingValue(experimentSettings.ENABLE_MSR_SPLIT_PAY)
?.toLowerCase() === "true"
);
},
isMSRFeeNotCoveredByInsurance() { isMSRFeeNotCoveredByInsurance() {
return ( return (
this.isMobileSelected && this.isMobileSelected &&
@ -1173,6 +1182,13 @@ export default {
isMSRFeeApplicable isMSRFeeApplicable
); );
}, },
updateAndSaveIsMSRFeeCoveredByInsurance() {
const isMSRFeeCoveredByInsurance = this.mobileFeePart?.isInsurable;
this.dispatchStoreAction(
this.storeActions.SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE,
isMSRFeeCoveredByInsurance
);
},
updateAndSaveSupportingItems() { updateAndSaveSupportingItems() {
let supportingItems = store.getters.lineItems.supportingItems; let supportingItems = store.getters.lineItems.supportingItems;
let shouldSaveSupportingItems = false; let shouldSaveSupportingItems = false;
@ -1528,6 +1544,7 @@ export default {
this.updateAndSaveSupportingItems(); this.updateAndSaveSupportingItems();
this.updateAndSaveIsMSRFeeApplicable(); this.updateAndSaveIsMSRFeeApplicable();
this.updateAndSaveIsMSRFeeCoveredByInsurance();
this.updateSupportingItems(); this.updateSupportingItems();

View file

@ -80,6 +80,7 @@ import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import { routeData } from "@/router/constants/routes"; import { routeData } from "@/router/constants/routes";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
// Define Validation Rules // Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -137,6 +138,13 @@ export default {
}, },
false 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) { if (store.getters.externalParameterCustomer?.phoneNumber) {

View file

@ -17,6 +17,7 @@ import store from "@/store";
import { validate } from "vee-validate"; import { validate } from "vee-validate";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js"; import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import * as cookieHelper from "@/helpers/heritage-integration/cookie-helper"; import * as cookieHelper from "@/helpers/heritage-integration/cookie-helper";
import { externalParameterStatus } from "@/constants/external-parameters";
// Mock our module for promises. // Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({ jest.mock("@/helpers/layout-helper.js", () => ({
@ -817,7 +818,11 @@ describe("vehicle-damage.vue", () => {
describe("vehicle-damage.vue", () => { describe("vehicle-damage.vue", () => {
test("should call forwardButtonAction if isExternalParameter is true and form is valid", async () => { test("should call forwardButtonAction if isExternalParameter is true and form is valid", async () => {
// Set up the store with isExternalParameter as true // 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 = { store.getters.externalParameterDamage = {
damageType: "windshieldReplace", damageType: "windshieldReplace",
isRepair: false, isRepair: false,

View file

@ -93,12 +93,14 @@ import { errorMessages } from "@/constants/error-messages";
import { damageLocationsCms } from "@/constants/damage-locations-cms.js"; import { damageLocationsCms } from "@/constants/damage-locations-cms.js";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js"; import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
import store from "@/store"; import store from "@/store";
import baseMixin from "@/mixins/base-mixin"; import baseMixin from "@/mixins/base-mixin";
import { nextTick } from "vue"; import { nextTick } from "vue";
import { getBoolFromString } from "@/helpers/boolean-helper"; import { getBoolFromString } from "@/helpers/boolean-helper";
import { externalParameterStatus } from "@/constants/external-parameters";
// DEFINE VALIDATION RULES // DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED)); defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED));
@ -142,7 +144,12 @@ export default {
vm.$refs.backGlassOptions.initializeComponent( vm.$refs.backGlassOptions.initializeComponent(
resultMap.damageOptions.backGlassOptions.availableReplacementOptions 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(); await nextTick();
const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm); const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm);
debugLog(`**** vehicle-damage is form valid: ${isValid} ****`); debugLog(`**** vehicle-damage is form valid: ${isValid} ****`);
@ -199,7 +206,7 @@ export default {
return vm.forwardButtonAction(); return vm.forwardButtonAction();
} }
} }
baseMixin.methods.ResetExternalParamsAndHideModal(); showFmgLoadingModal(false);
//clear any validation errors for external parameter flow //clear any validation errors for external parameter flow
const form = vm.$refs.theForm; const form = vm.$refs.theForm;
@ -209,7 +216,7 @@ export default {
} }
} }
} else { } else {
baseMixin.methods.ResetExternalParamsAndHideModal(); showFmgLoadingModal(false);
} }
}); });
}, },
@ -354,7 +361,12 @@ export default {
) { ) {
windShieldOptions.selectedWindshieldDamageType = windShieldOptions.selectedWindshieldDamageType =
damageLocationsSelected.REPLACE; 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( windShieldOptions.selectedWindshieldReplaceOptions.push(
damageLocationsSelected.SINGLE damageLocationsSelected.SINGLE
); );
@ -487,6 +499,14 @@ export default {
policy.policyNumber !== ""; policy.policyNumber !== "";
const skipVin = await skipVinLookup(); 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 (vehicleChangedDuringPolicyLookupInHeritage) {
if (skipVin) { if (skipVin) {
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
@ -665,12 +685,21 @@ export default {
return this.$store.getters.requiresVerifiedRedirecting; return this.$store.getters.requiresVerifiedRedirecting;
}, },
isContinueEnabled() { isContinueEnabled() {
return ( var isEnabled =
this.isAnyDamageSelected && this.isAnyDamageSelected &&
this.isWindshieldValid && this.isWindshieldValid &&
this.isRearGlassValid && 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() { isAnyDamageSelected() {
return this.selectedDamageLocations != null && this.selectedDamageLocations.length != 0; return this.selectedDamageLocations != null && this.selectedDamageLocations.length != 0;

View file

@ -93,8 +93,11 @@ describe("vehicle.vue", () => {
}); });
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.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 // Create a shallow mount of MyComponent
const { wrapper } = setupMocks(); const { wrapper } = setupMocks();
// Set displayNoServiceAlert to false // Set displayNoServiceAlert to false

View file

@ -109,6 +109,7 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { applicationConfig } from "../../constants/application-config"; import { applicationConfig } from "../../constants/application-config";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper"; import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
//define validation rules //define validation rules
@ -140,6 +141,8 @@ export default {
styleOptions: [], styleOptions: [],
displayNoServiceAlert: false, displayNoServiceAlert: false,
canSafeliteService: this.canSafeliteServiceFromStore(), canSafeliteService: this.canSafeliteServiceFromStore(),
skipVINLookup: this.getSkipVINLookupfromStore(),
skipPartQuestions: this.getSkipPartQuestionsfromStore(),
}; };
}, },
@ -252,17 +255,17 @@ export default {
const matchingMake = resultMap.makeQuestionInitialData?.filter( const matchingMake = resultMap.makeQuestionInitialData?.filter(
(item) => (item) =>
item.toLowerCase() === item.toLowerCase() ===
store.getters.externalParameterVehicle.make.toLowerCase() store.getters.externalParameterVehicle.make?.toLowerCase()
); );
const matchingModel = resultMap.modelQuestionInitialData?.filter( const matchingModel = resultMap.modelQuestionInitialData?.filter(
(item) => (item) =>
item.toLowerCase() === item.toLowerCase() ===
store.getters.externalParameterVehicle.model.toLowerCase() store.getters.externalParameterVehicle.model?.toLowerCase()
); );
const matchingStyle = resultMap.styleQuestionInitialData?.filter( const matchingStyle = resultMap.styleQuestionInitialData?.filter(
(item) => (item) =>
item.toLowerCase() === item.toLowerCase() ===
store.getters.externalParameterVehicle.style.toLowerCase() store.getters.externalParameterVehicle.style?.toLowerCase()
); );
baseMixin.methods.dispatchStoreAction( baseMixin.methods.dispatchStoreAction(
storeActions.UPDATE_EXTERNAL_PARAMETER_MMS, storeActions.UPDATE_EXTERNAL_PARAMETER_MMS,
@ -291,7 +294,12 @@ export default {
resultMap.modelQuestionInitialData, resultMap.modelQuestionInitialData,
resultMap.styleQuestionInitialData 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(); await vm.getVehicleDetails();
if (!vm.displayNoServiceAlert) { if (!vm.displayNoServiceAlert) {
const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm); const isValid = await baseMixin.methods.isFormValid(vm.$refs.theForm);
@ -304,7 +312,7 @@ export default {
baseMixin.methods.ResetExternalParamsAndHideModal(); baseMixin.methods.ResetExternalParamsAndHideModal();
} }
} else { } else {
baseMixin.methods.ResetExternalParamsAndHideModal(); showFmgLoadingModal(false);
} }
}); });
}, },
@ -480,6 +488,8 @@ export default {
this.vehicleSubType = result?.data.vehicleSubType; this.vehicleSubType = result?.data.vehicleSubType;
this.vehicleSpecialClass = result?.data.vehicleSpecialClass; this.vehicleSpecialClass = result?.data.vehicleSpecialClass;
this.isBigTruck = result?.data.isBigTruck; this.isBigTruck = result?.data.isBigTruck;
this.skipVINLookup = result?.data.skipVINLookup;
this.skipPartQuestions = result?.data.skipPartQuestions;
}, },
resetAlert() { resetAlert() {
this.displayNoServiceAlert = false; this.displayNoServiceAlert = false;
@ -536,6 +546,8 @@ export default {
imageUrl: this.imageUrl, imageUrl: this.imageUrl,
imageVifNumber: this.imageVifNumber, imageVifNumber: this.imageVifNumber,
imageVifColor: this.imageVifColor, imageVifColor: this.imageVifColor,
skipVINLookup: this.skipVINLookup,
skipPartQuestions: this.skipPartQuestions,
}, },
false false
); );
@ -621,6 +633,12 @@ export default {
getIsBigTruckfromStore() { getIsBigTruckfromStore() {
return store.getters.vehicle.isBigTruck; return store.getters.vehicle.isBigTruck;
}, },
getSkipVINLookupfromStore() {
return store.getters.vehicle.skipVINLookup;
},
getSkipPartQuestionsfromStore() {
return store.getters.vehicle.skipPartQuestions;
},
}, },
components: { components: {

View file

@ -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) { async pushEventForChatsToGA(category, action, label, pushToLogApp = false) {
const currentPageName = getPageNameFromRouter(); const currentPageName = getPageNameFromRouter();
const value = `2.0_${currentPageName}`; const value = `2.0_${currentPageName}`;
@ -206,6 +161,11 @@ export default {
// S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1. // S3 bucket, safelite-dev-digitalconsumer-session-data-us-east-2/1.
// This bucket data is then picked up by snowflake for analytics use. // This bucket data is then picked up by snowflake for analytics use.
async pushFmgSessionData() { async pushFmgSessionData() {
var currentPageName = getPageNameFromRouter(true);
if (!currentPageName) {
return;
}
const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder(); const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder();
const submittedOrder = baseMixin.methods.getSubmittedOrder(); const submittedOrder = baseMixin.methods.getSubmittedOrder();
const order = hasSubmittedOrder ? submittedOrder : store.getters.order; const order = hasSubmittedOrder ? submittedOrder : store.getters.order;
@ -247,22 +207,6 @@ export default {
} }
var appointment = `${order?.schedule?.date ?? ""} ${order?.schedule?.startTime ?? ""}`; 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 = {}; var sessionData = {};
sessionData.currentPage = currentPageName; sessionData.currentPage = currentPageName;
@ -911,6 +855,9 @@ export default {
setSessionIdIfUnset(response.data.sessionId); setSessionIdIfUnset(response.data.sessionId);
} }
} }
// logging on session init so we capture complete query string data
await this.pushFmgSessionData();
}, },
noSession() { noSession() {
@ -977,7 +924,7 @@ function pushToDataLayerIfDefined(data) {
} }
} }
function getPageNameFromRouter() { function getPageNameFromRouter(useDefaultUrl = false) {
if ( if (
router && router &&
router.currentRoute && router.currentRoute &&
@ -987,7 +934,9 @@ function getPageNameFromRouter() {
return router.currentRoute.value.name; 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) { function getValueToLog(value, valueToLogType) {

View file

@ -5,6 +5,8 @@ import { storeActions } from "@/constants/store-actions";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store"; import store from "@/store";
import { getIsWindshieldOnly } from "@/helpers/damage-helper";
import { partTypeStrings } from "@/constants/part-type-strings";
export default { export default {
methods: { methods: {
@ -367,8 +369,13 @@ export default {
const hasChildPartQuestions = this.hasChildPartQuestions(partsOrQuestions); const hasChildPartQuestions = this.hasChildPartQuestions(partsOrQuestions);
const hasCapabilityQuestions = this.hasCapabilityQuestions(partsOrQuestions); const hasCapabilityQuestions = this.hasCapabilityQuestions(partsOrQuestions);
const isWindshieldOnly =
getIsWindshieldOnly().toUpperCase() === partTypeStrings.WINDSHIELD;
const skipPartQuestions = store.getters.vehicle?.skipPartQuestions && isWindshieldOnly;
if ( if (
hasPartQuestions && hasPartQuestions &&
!skipPartQuestions &&
this.currentPageComesBeforePage(currentPage, routeData.PART_QUESTIONS.name) this.currentPageComesBeforePage(currentPage, routeData.PART_QUESTIONS.name)
) { ) {
self.$router.navigateWithPageData( self.$router.navigateWithPageData(

View file

@ -64,6 +64,7 @@ const navigationScenarios = {
CLICKED_PAY_NOW: "CLICKED_PAY_NOW", CLICKED_PAY_NOW: "CLICKED_PAY_NOW",
CLICKED_PAY_NOW_ADYEN: "CLICKED_PAY_NOW_ADYEN", CLICKED_PAY_NOW_ADYEN: "CLICKED_PAY_NOW_ADYEN",
CLICKED_PAY_LATER: "CLICKED_PAY_LATER", CLICKED_PAY_LATER: "CLICKED_PAY_LATER",
CLICKED_SWITCH_TO_INSHOP: "CLICKED_SWITCH_TO_INSHOP",
PIA_ERROR: "PIA_ERROR", PIA_ERROR: "PIA_ERROR",
PIA_CC_ERROR: "PIA_CC_ERROR", PIA_CC_ERROR: "PIA_CC_ERROR",
PIA_APPLE_ERROR: "PIA_APPLE_ERROR", PIA_APPLE_ERROR: "PIA_APPLE_ERROR",

View file

@ -553,6 +553,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_INSURANCE, scenario: navigationScenarios.CLICKED_INSURANCE,
destinationPageData: routeData.INSURANCE_COMPANY, destinationPageData: routeData.INSURANCE_COMPANY,
}, },
{
scenario: navigationScenarios.CLICKED_SWITCH_TO_INSHOP,
destinationPageData: routeData.SCHEDULE,
},
], ],
}, },
{ {

View file

@ -5,9 +5,6 @@ import { storeMutations } from "@/constants/store-mutations";
export async function afterEach(to, from) { export async function afterEach(to, from) {
store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name); store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name);
// digital consumer logging
analyticsMixin.methods.logDigitalConsumer();
// digital consumer fmg session logging to snowflake // digital consumer fmg session logging to snowflake
analyticsMixin.methods.pushFmgSessionData(); analyticsMixin.methods.pushFmgSessionData();

View file

@ -7,6 +7,7 @@ import { isSavedSessionStillActive } from "@/helpers/heritage-integration/sessio
import analyticsMixin from "@/mixins/analytics-mixin"; import analyticsMixin from "@/mixins/analytics-mixin";
import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes"; import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments"; import { runExperiments } from "@/router/methods/helpers/run-experiments";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { handleSoftError, handleHardError } from "@/router/methods/error"; import { handleSoftError, handleHardError } from "@/router/methods/error";
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites"; 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 { isVirtualRoute } from "@/router/methods/helpers/is-virtual-route";
import router from "@/router"; import router from "@/router";
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
export async function beforeEach(to, from) { export async function beforeEach(to, from) {
try { try {
await analyticsMixin.methods.validateSession(); await analyticsMixin.methods.validateSession();
@ -27,10 +30,37 @@ export async function beforeEach(to, from) {
} }
if (getFunnelCookie()?.SuppressConceptFunnel) { if (getFunnelCookie()?.SuppressConceptFunnel) {
// Redirect to heritage. const redirectKey = sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT;
return { const redirectCount = Number(window.sessionStorage.getItem(redirectKey) ?? 0);
name: routeData.HERITAGE.name,
}; 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 // Ensure session has not expired

View file

@ -12,7 +12,7 @@ export async function handleSoftError(errorPayload, forceRestart = false) {
deleteFunnelCookie(); deleteFunnelCookie();
} }
analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload); logErrorToDataLayerAndLogger("handleSoftError", errorPayload, forceRestart);
router.push({ router.push({
name: routeData.ERROR.name, name: routeData.ERROR.name,
@ -27,7 +27,7 @@ export async function handleHardError(errorPayload) {
); );
if (isInStaticErrorExperiment) { if (isInStaticErrorExperiment) {
analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload); logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
window.top.location = "/fmg/static/error"; window.top.location = "/fmg/static/error";
return; return;
} else { } else {
@ -38,3 +38,10 @@ export async function handleHardError(errorPayload) {
await handleSoftError(errorPayload, true); 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);
}

View file

@ -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 });
});
});
});

View file

@ -9,6 +9,7 @@ import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { queryStrings } from "@/constants/query-strings"; import { queryStrings } from "@/constants/query-strings";
import { externalParameterStatus } from "@/constants/external-parameters"; import { externalParameterStatus } from "@/constants/external-parameters";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
import { runExperiments, forceExperiments } from "@/router/methods/helpers/run-experiments";
export async function initializeFromQueryStrings() { export async function initializeFromQueryStrings() {
// Get advertiser info // Get advertiser info
@ -16,6 +17,17 @@ export async function initializeFromQueryStrings() {
// Read all external paramter queries and write to store. // Read all external paramter queries and write to store.
updateExternalParameterState(); 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() { function setupAdvertisersAndAffiliates() {
@ -39,7 +51,6 @@ function updateExternalParameterState() {
const externalParameterVinSelection = getQuerystringParameter(queryStrings.VIN_SELECTION); const externalParameterVinSelection = getQuerystringParameter(queryStrings.VIN_SELECTION);
const externalParameterServicePackage = getQuerystringParameter(queryStrings.SERVICE_PACKAGE); const externalParameterServicePackage = getQuerystringParameter(queryStrings.SERVICE_PACKAGE);
const externalParameterNumberOfChips = getQuerystringParameter(queryStrings.NUMBER_OF_CHIPS); const externalParameterNumberOfChips = getQuerystringParameter(queryStrings.NUMBER_OF_CHIPS);
const externalParameterSource = getQuerystringParameter(queryStrings.EXPERIMENTS);
const externalParameterPhoneNumber = getQuerystringParameter(queryStrings.PHONE_NUMBER); const externalParameterPhoneNumber = getQuerystringParameter(queryStrings.PHONE_NUMBER);
if (!externalParameterZipCode) { if (!externalParameterZipCode) {
@ -58,7 +69,6 @@ function updateExternalParameterState() {
debugLog("externalParameterVinSelection:", externalParameterVinSelection); debugLog("externalParameterVinSelection:", externalParameterVinSelection);
debugLog("externalParameterServicePackage:", externalParameterServicePackage); debugLog("externalParameterServicePackage:", externalParameterServicePackage);
debugLog("externalParameterNumberOfChips:", externalParameterNumberOfChips); debugLog("externalParameterNumberOfChips:", externalParameterNumberOfChips);
debugLog("externalParameterSource:", externalParameterSource);
debugLog("externalPhoneNumber:", externalParameterPhoneNumber); debugLog("externalPhoneNumber:", externalParameterPhoneNumber);
} }
@ -95,6 +105,7 @@ function updateExternalParameterState() {
} }
if (externalParameterZipCode) { if (externalParameterZipCode) {
store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_ZIP_CODE, externalParameterZipCode); store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_ZIP_CODE, externalParameterZipCode);
store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.ACTIVE);
} }
if (externalParameterEmail) { if (externalParameterEmail) {
store.commit( store.commit(
@ -115,9 +126,6 @@ function updateExternalParameterState() {
externalParameterVinSelection externalParameterVinSelection
); );
} }
if (externalParameterSource) {
store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_SOURCE, externalParameterSource);
}
if (externalParameterPhoneNumber) { if (externalParameterPhoneNumber) {
store.commit( store.commit(
storeMutations.UPDATE_EXTERNAL_PARAMETER_PHONE_NUMBER, storeMutations.UPDATE_EXTERNAL_PARAMETER_PHONE_NUMBER,

View file

@ -28,3 +28,15 @@ export async function runExperiments(nextPageName) {
nextPageName nextPageName
); );
} }
export async function forceExperiments(forceExperimentQueryStringValues) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.FORCE_EXPERIMENTS,
{
deviceId: getDeviceIdValue(),
queryStringExperiments: forceExperimentQueryStringValues,
},
"/",
false
);
}

View file

@ -19,7 +19,6 @@ export async function errorBeforeEnter(to, from) {
nextPage: to?.name, nextPage: to?.name,
}; };
analyticsMixin.methods.logDigitalConsumer();
await handleHardError(errorPayload); await handleHardError(errorPayload);
return; return;
} }
@ -33,7 +32,6 @@ export async function errorBeforeEnter(to, from) {
nextPage: to?.name, nextPage: to?.name,
}; };
analyticsMixin.methods.logDigitalConsumer();
await handleHardError(errorPayload); await handleHardError(errorPayload);
return; return;
} else { } else {

View file

@ -1,5 +1,6 @@
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; 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) { export async function heritageBeforeEnter(to, from) {
await navigateToHeritageFunnel({ shouldSaveSession: false, pageNameToLog: to.name }); await navigateToHeritageFunnel({ shouldSaveSession: false, pageNameToLog: to.name });
} }

View file

@ -9,7 +9,6 @@ import { storeActions } from "@/constants/store-actions";
import { applicationConfig } from "@/constants/application-config"; import { applicationConfig } from "@/constants/application-config";
import { experimentTriggers } from "@/constants/experiments"; import { experimentTriggers } from "@/constants/experiments";
import { damageLocationsSelected } from "@/constants/damage-locations-selected"; import { damageLocationsSelected } from "@/constants/damage-locations-selected";
import { singleWindshieldCarIds } from "@/constants/single-windshield-carids";
import { routeData } from "@/router/constants/routes"; import { routeData } from "@/router/constants/routes";
import { import {
deleteFunnelCookie, deleteFunnelCookie,
@ -83,6 +82,8 @@ const getDefaultState = () => {
registration: { registration: {
licensePlate: null, licensePlate: null,
}, },
skipVINLookup: false,
skipPartQuestions: false,
}, },
serviceLocation: { serviceLocation: {
address: null, address: null,
@ -192,6 +193,7 @@ const getDefaultState = () => {
isRecalAckOptIn: false, isRecalAckOptIn: false,
isRecalAcknowledgedForScheduling: "", isRecalAcknowledgedForScheduling: "",
isMSRFeeApplicable: false, isMSRFeeApplicable: false,
isMSRFeeCoveredByInsurance: false,
}, },
applicationUser: { applicationUser: {
eventBus: [], eventBus: [],
@ -251,6 +253,15 @@ export const mutations = {
updateVehicleCanSafeliteService(state, canSafeliteService) { updateVehicleCanSafeliteService(state, canSafeliteService) {
state.order.vehicle.canSafeliteService = 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) { updateVehicleImageUrl(state, imageUrl) {
state.order.vehicle.imageUrl = imageUrl; state.order.vehicle.imageUrl = imageUrl;
}, },
@ -359,6 +370,9 @@ export const mutations = {
updateIsMSRFeeApplicable(state, isMSRFeeApplicable) { updateIsMSRFeeApplicable(state, isMSRFeeApplicable) {
state.order.isMSRFeeApplicable = isMSRFeeApplicable; state.order.isMSRFeeApplicable = isMSRFeeApplicable;
}, },
updateIsMSRFeeCoveredByInsurance(state, isMSRFeeCoveredByInsurance) {
state.order.isMSRFeeCoveredByInsurance = isMSRFeeCoveredByInsurance;
},
updateCashPriceSubTotal(state, cashPriceSubTotal) { updateCashPriceSubTotal(state, cashPriceSubTotal) {
state.order.cashPriceSubTotal = state.order.cashPriceSubTotal =
cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString(); cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString();
@ -1593,6 +1607,7 @@ export const actions = {
operatorId: "WEB", operatorId: "WEB",
userName: "SafeliteConceptFunnel", userName: "SafeliteConceptFunnel",
referrer: referrer, referrer: referrer,
clientData: window.location.search,
}; };
return globalMethods 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( logFmgSessionData(
context, context,
{ {
@ -1770,6 +1753,7 @@ export const actions = {
method: endpoints.LogFmgSessionData.method, method: endpoints.LogFmgSessionData.method,
endpoint: endpoints.LogFmgSessionData.url, endpoint: endpoints.LogFmgSessionData.url,
payload: payload, payload: payload,
logApiCall: false,
}); });
}, },
@ -1816,6 +1800,25 @@ export const actions = {
context.commit(storeMutations.UPDATE_EXPERIMENTS, response.data.experiments); 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 }) { getEvoxImage(context, { relativeUrl }) {
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetPageData.method, method: endpoints.GetPageData.method,
@ -2058,52 +2061,27 @@ export const actions = {
}, },
getServiceabilityDetails(context, { payload: { serviceZipCode }, pageNameToLog }) { getServiceabilityDetails(context, { payload: { serviceZipCode }, pageNameToLog }) {
const escapeRecalibrationType = (rt) => rt.split("&").join("%26"); const flattenedLineItemsWithChildParts = getFlattenedArrayOfLineItemsWithChildParts(
context.getters.order.lineItems.glassParts
const partialLineItemsObjects = context.getters.order.lineItems.glassParts?.map(
(lineItem) => ({
partNumber: lineItem.partNumber,
recalibrationType:
lineItem.recalibrationType && isRecalPartOrHasChildRecalPart(lineItem)
? escapeRecalibrationType(lineItem.recalibrationType)
: undefined,
})
); );
var lineItems = null; const lineItemsList = flattenedLineItemsWithChildParts
if (partialLineItemsObjects) { .map((item) => item.partNumber)
lineItems = buildQueryStringParameterFromArrayOfComplexObjects( .join(",");
partialLineItemsObjects,
"lineItems"
);
}
const vehicle = context.getters.vehicle; const vehicle = context.getters.vehicle;
const carId = vehicle.carId; const carId = vehicle.carId;
const damage = context.getters.damage; const damage = context.getters.damage;
const glassArray = convertGlassPieceNamingForApi(damage.glassToReplace);
const glassPieces = buildQueryStringParameterFromArrayOfComplexObjects(
glassArray,
"glassPieces"
);
const parentAccountNumber = const parentAccountNumber =
context.getters.order.payment.parentAccountNumber ?? context.getters.order.payment.parentAccountNumber ??
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER; 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}`; var endPoint = `${endpoints.GetServiceabilityDetails.url}?zip=${serviceZipCode}&carId=${carId}&parentAccountNumber=${parentAccountNumber}&isRepair=${damage.isRepair}`;
if (lineItems) { if (lineItemsList) {
endPoint += `&${lineItems}`; endPoint += `&lineItems=${lineItemsList}`;
} }
if (glassPieces) {
endPoint += `&${glassPieces}`;
}
endPoint += `&isHeavyTruckVehicle=${context.getters.order.vehicle.isBigTruck}`;
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetServiceabilityDetails.method, method: endpoints.GetServiceabilityDetails.method,
endpoint: endPoint, endpoint: endPoint,
@ -2146,6 +2124,7 @@ export const actions = {
const carId = context.getters.vehicle.carId; const carId = context.getters.vehicle.carId;
const isRepair = context.getters.damage.isRepair; const isRepair = context.getters.damage.isRepair;
const numberOfChips = context.getters.damage.numberOfChips; const numberOfChips = context.getters.damage.numberOfChips;
const apptType = context.getters.order?.serviceLocation?.appointmentType;
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
method: endpoints.GetSupportingItems.method, method: endpoints.GetSupportingItems.method,
@ -2156,6 +2135,9 @@ export const actions = {
parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER, parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
parts: glassPartsArray, parts: glassPartsArray,
numberOfRepairChips: isRepair ? numberOfChips : 0, numberOfRepairChips: isRepair ? numberOfChips : 0,
providerNumber: context.getters.order.serviceLocation.zipCodeCtu,
facilityType:
apptType && apptType.trim() !== "" ? apptType : AppointmentTypeStrings.IN_SHOP,
}, },
logApiCall: true, logApiCall: true,
pageNameToLog: pageNameToLog, pageNameToLog: pageNameToLog,
@ -2611,6 +2593,7 @@ export const actions = {
lockToken: order.lockToken, lockToken: order.lockToken,
isRecalAckOptIn: order.isRecalAckOptIn, isRecalAckOptIn: order.isRecalAckOptIn,
isMSRFeeApplicable: order.isMSRFeeApplicable, isMSRFeeApplicable: order.isMSRFeeApplicable,
isMSRFeeCoveredByInsurance: order.isMSRFeeCoveredByInsurance,
cashPriceSubTotal: order.cashPriceSubTotal, cashPriceSubTotal: order.cashPriceSubTotal,
}, },
}, },
@ -2723,6 +2706,8 @@ export const actions = {
imageUrl, imageUrl,
imageVifNumber, imageVifNumber,
imageVifColor, imageVifColor,
skipVINLookup,
skipPartQuestions,
} }
) { ) {
if ( if (
@ -2748,6 +2733,8 @@ export const actions = {
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, imageUrl); context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, imageUrl);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, imageVifNumber); context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, imageVifNumber);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, imageVifColor); 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); 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) { saveParentAccountNumber(context, parentAccountNumber) {
context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber); context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber);
}, },
@ -3535,25 +3529,7 @@ export const actions = {
}, },
isVinOptionalVehicle(context) { isVinOptionalVehicle(context) {
//Optional for carIds with only a single windshield if (context.state.order.vehicle.skipVINLookup) return true;
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;
return false; return false;
}, },

View file

@ -4261,6 +4261,8 @@ describe("isVinOptionalVehicle", () => {
}, },
}; };
mutations.updateSkipVINLookup(context.state, expectedVinSkip);
var vinOptionalResult = actions.isVinOptionalVehicle(context); var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip); expect(vinOptionalResult).toEqual(expectedVinSkip);
} }
@ -4291,6 +4293,8 @@ describe("isVinOptionalVehicle", () => {
}, },
}; };
mutations.updateSkipVINLookup(context.state, expectedVinSkip);
var vinOptionalResult = actions.isVinOptionalVehicle(context); var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip); expect(vinOptionalResult).toEqual(expectedVinSkip);
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="alert fade show text-center mb-0 py-2 px-4 border-0" class="alert fade show mb-0 py-2 px-4 border-0"
role="alert" role="alert"
v-on="{ 'close.bs.alert': onAlertClose }" v-on="{ 'close.bs.alert': onAlertClose }"
:class="[ :class="[
@ -8,8 +8,9 @@
this.alertClass, this.alertClass,
this.cssClassNameForCmsWidget, this.cssClassNameForCmsWidget,
this.hasBorder ? 'bordered' : '', this.hasBorder ? 'bordered' : '',
this.leftAlignCopy ? 'text-start' : 'text-center',
]"> ]">
<p class="mx-6 my-0 fw-bold alert-heading"> <p class="my-0 fw-bold alert-heading" :class="leftAlignCopy ? 'mx-4' : 'mx-6'">
<img v-if="showInfoIcon" :src="infoIcon" alt="Info Icon" class="info-icon" /> <img v-if="showInfoIcon" :src="infoIcon" alt="Info Icon" class="info-icon" />
<img <img
v-if="showWarningIcon" v-if="showWarningIcon"
@ -74,6 +75,7 @@ export default {
hasBorder: Boolean, hasBorder: Boolean,
isDismissible: Boolean, isDismissible: Boolean,
alertClass: String, alertClass: String,
leftAlignCopy: Boolean,
/* /*
alertClass class names: alertClass class names:
alert-success (green) alert-success (green)
@ -198,35 +200,117 @@ export default {
} }
&.alert-info { &.alert-info {
background-color: $blue-100; background-color: $blue-100;
.alert-heading {
color: $blue-700; &.bordered {
border-color: $blue-400;
}
&.text-start {
.alert-heading {
display: flex;
align-items: center;
color: $black;
font-weight: 700;
.warning-icon {
margin-right: 0.5rem;
}
}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-color: $blue-400;
}
}
&.text-center {
.alert-heading {
color: $blue-700;
}
} }
svg { svg {
fill: $blue-700; fill: $blue-700;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
hr {
border-color: $red-800;
}
} }
&.alert-danger { &.alert-danger {
background-color: $red-100; background-color: $red-100;
.alert-heading {
color: $red-600; &.bordered {
border-color: $red-400;
}
&.text-start {
.alert-heading {
display: flex;
align-items: center;
color: $black;
font-weight: 700;
.warning-icon {
margin-right: 0.5rem;
}
}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-color: $red-400;
}
}
&.text-center {
.alert-heading {
color: $red-600;
}
} }
svg { svg {
fill: $red-600; fill: $red-600;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
hr {
border-color: $red-800;
}
} }
&.alert-warning { &.alert-warning {
background-color: $yellow-100; background-color: $yellow-100;
.alert-heading {
color: $yellow-600;
&.bordered {
border-color: $yellow-400;
}
&.text-start {
.alert-heading {
display: flex;
align-items: center;
color: $black;
font-weight: 700;
.warning-icon {
margin-right: 0.5rem;
}
}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-color: $yellow-400;
}
}
&.text-center {
.alert-heading {
color: $yellow-600;
}
}
.alert-heading {
.warning-icon { .warning-icon {
height: 1rem; height: 1rem;
} }
} }
svg { svg {
fill: $yellow-600; fill: $yellow-600;
width: 1rem; width: 1rem;
@ -238,14 +322,42 @@ export default {
} }
&.alert-success { &.alert-success {
background-color: $green-100; background-color: $green-100;
.alert-heading {
color: $green-700; &.bordered {
border-color: $green-400;
}
&.text-start {
.alert-heading {
display: flex;
align-items: center;
color: $black;
font-weight: 700;
.warning-icon {
margin-right: 0.5rem;
}
}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-color: $green-400;
}
}
&.text-center {
.alert-heading {
color: $green-700;
}
} }
svg { svg {
fill: $green-700; fill: $green-700;
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
} }
hr {
border-color: $green-800;
}
} }
&.bordered { &.bordered {
border: 1px solid; border: 1px solid;

View file

@ -0,0 +1,121 @@
import { shallowMount, mount } from "@vue/test-utils";
import toolTip from "@/ux-components/tool-tip/tool-tip.vue";
const mockMixin = {
methods: {
getCmsContent: jest.fn(() => "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);
});
});

View file

@ -0,0 +1,140 @@
<template>
<span class="tooltip-container" ref="container">
<span @click="toggleTooltip" class="tooltip-icon">
<slot name="icon">
<img src="@/assets/img/icons/info-circle-blue.svg" />
</slot>
</span>
<span
v-if="isVisible"
class="tooltip"
:class="{ 'tooltip-visible': isVisible }"
:style="{ left: lineItemContainerLength + 'px' }">
<slot>
<h3 v-html="toolTipHeaderText"></h3>
<p v-html="toolTipBodyText"></p>
</slot>
</span>
</span>
</template>
<script>
export default {
name: "toolTip",
props: {
cmsWidgetName: {
type: String,
default(rawProps) {
if (!rawProps.cmsWidgetName) {
console.log("Error: Missing a CMS Widget Name (required field)");
}
return "widgetUndefined";
},
},
customHeaderText: {
type: String,
default: "",
},
customBodyText: {
type: String,
default: "",
},
},
data() {
return {
isVisible: false,
};
},
mounted() {
document.addEventListener("click", this.handleClickOutside);
},
unmounted() {
document.removeEventListener("click", this.handleClickOutside);
},
computed: {
toolTipHeaderText() {
return (
this.customHeaderText || this.getCmsContent(this.cmsWidgetName, "HeaderText") || ""
);
},
toolTipBodyText() {
return this.customBodyText || this.getCmsContent(this.cmsWidgetName, "BodyText") || "";
},
lineItemContainerLength() {
// Get the width of the parent container - used to position the tooltip to the left
if (this.$refs.container && this.$refs.container.parentElement) {
return 0 - this.$refs.container.parentElement.offsetWidth;
}
return 0;
},
},
methods: {
showTooltip() {
this.isVisible = true;
},
hideTooltip() {
this.isVisible = false;
},
toggleTooltip() {
this.isVisible = !this.isVisible;
},
handleClickOutside(event) {
if (this.$refs.container && !this.$refs.container.contains(event.target)) {
this.hideTooltip();
}
},
},
};
</script>
<style lang="scss" scoped>
.tooltip-container {
position: relative;
display: inline-block;
margin-left: 0.2rem;
.tooltip-icon {
cursor: pointer;
display: inline-block;
img {
width: 1rem;
}
}
.tooltip {
display: flex;
flex-direction: column;
position: absolute;
top: 100%;
left: 1rem;
padding: 1rem;
border-radius: $border-radius;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 500;
width: 16.5rem;
background-color: $white;
box-shadow: $box-shadow;
h3 {
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: $font-weight-600;
color: $black;
}
p {
margin: 0;
font-size: 0.75rem;
}
}
.tooltip-visible {
opacity: 1;
visibility: visible;
}
}
</style>