Merge branch 'develop' into feature/CSR-1072

This commit is contained in:
CarlNation 2024-02-28 13:22:06 -05:00
commit cad3879110
46 changed files with 1905 additions and 376 deletions

View file

@ -24,12 +24,13 @@ module.exports = {
"!src/layouts/review/*.vue", // Temp test exclusion while in development
"!src/layouts/payment/*.vue", // Temp test exclusion while in development
"!src/layouts/payment-pia-return/*.vue", // Temp test exclusion while in development
"!src/layouts/insurance/*.vue", // Temp test exclusion while in development
// END
], // ! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],
coverageThreshold: {
global: {
statements: 78,
statements: 77,
},
},
// Uncomment this to avoid the massive amount of warnings we are getting for onSubmit and onInvalidSubmit

View file

@ -8,6 +8,7 @@ const experimentSettings = {
DISPLAY_AVAILABILITY_INDICATORS: "DisplayAvailabilityIndicators",
PIA_EXPERIENCE: "PIA Experience",
SUBMIT_ORDER_ENABLE_PIA: "SubmitOrder_Enable_PIA",
IS_EMAIL_OPTIONAL: "isEmailOptional",
};
const experimentTriggers = {

View file

@ -21,6 +21,7 @@ const queryStrings = {
TRANS_REFERENCE_NUMBER: "auth_trans_ref_no",
LAST_FOUR: "last_four",
DISPLAY_PIA_ALERT: "displayPiaAlert",
PAGE_ERROR: "pageerror",
};
export { queryStrings };

View file

@ -46,7 +46,11 @@
:maxlength="maxLength ? maxLength : '999'"
@focus="$emit('focus', $event.target.value)"
@keydown="keyDownHandler" />
<button v-if="includeSearchIcon" type="submit" aria-label="Search button" />
<button
v-if="includeSearchIcon"
type="submit"
aria-label="Search button"
@click="focusSearchInput" />
<template v-if="includeImageQuestion">
<template v-if="!isDisabled">
<label class="camera-icon-input" v-show="!isImageProcessing">
@ -121,6 +125,11 @@ export default {
hideInput: Boolean,
centerErrorMessage: Boolean,
keyDownHandler: Function,
addOptionalText: {
type: Boolean,
default: false,
isRequired: false,
},
},
setup(props) {
const uuid = uuidv4();
@ -164,6 +173,11 @@ export default {
};
},
methods: {
focusSearchInput() {
//Focus cursor in input when search icon is clicked
const field = document.querySelector("input");
field.focus();
},
async imageChanged(e) {
let file = e.target.files[0];
@ -193,7 +207,9 @@ export default {
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
return this.addOptionalText
? this.getCmsContent(this.cmsWidgetName, "QuestionText") + " (optional)"
: this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
value: {
get: function () {

View file

@ -13,78 +13,107 @@
<span class="label amount-due">{{ getFormattedAmount("", amountDue) }}</span>
</a>
</div>
<div class="price-table">
<div class="service-type">
<textBlock :cmsWidgetName="servicePackageTitleWidget" />
<span>
{{ getFormattedAmount("", packagePrice) }}
</span>
</div>
<div class="cart-panel">
<div class="price-table">
<div class="service-type">
<textBlock :cmsWidgetName="servicePackageTitleWidget" />
<span>
{{ getFormattedAmount("", packagePrice) }}
</span>
<span
v-if="packagePriceWithoutDiscount > packagePrice"
class="struck-out-price">
{{ getFormattedAmount("", packagePriceWithoutDiscount) }}
</span>
</div>
<!-- Packaged Cart Items -->
<div v-for="(cartItem, i) in packageCartItems" :key="i" class="packaged-cart-item">
<span>{{ cartItem.name }}</span>
<textLink
v-if="allowItemRemoval"
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="javascript:void(0)"
@click-event="removeItem(cartItem.cartItemType, cartItem.category)">
<template v-slot:after-text>
<span class="sr-only">{{ cartItem.name }}</span>
</template>
</textLink>
</div>
<!-- Packaged Cart Items -->
<div
v-for="(cartItem, i) in packageCartItems"
:key="i"
class="packaged-cart-item">
<span
><span v-if="cartItem.category === 'promos'">&lt;</span
>{{ cartItem.name
}}<span v-if="cartItem.category === 'promos'">&gt;</span></span
>
<textLink
v-if="allowItemRemoval"
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="javascript:void(0)"
@click-event="removeItem(cartItem.cartItemType, cartItem.category)">
<template v-slot:after-text>
<span class="sr-only">{{ cartItem.name }}</span>
</template>
</textLink>
</div>
<!-- Nonpackaged Cart Items -->
<div
v-for="(cartItem, i) in nonpackageCartItems"
:key="i"
class="non-packaged-cart-item"
:class="[
i % 2 == 0 ? 'even' : 'odd',
cartItem.isRemovable ? 'removable-cart-item' : '',
]">
<span v-if="cartItem != recycleFeeCartItem">{{ cartItem.name }}</span>
<textLink
v-if="allowItemRemoval && cartItem.isRemovable"
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="javascript:void(0)"
@click-event="removeItem(cartItem.cartItemType, cartItem.category)" />
<textLink
v-else-if="cartItem == recycleFeeCartItem && recyclingModalCmsWidgetName"
linkType="text"
:text="recycleFeeCartItem.name"
href="javascript:void(0)"
@click-event="openModal(recyclingModalCmsWidgetName)" />
<span v-else-if="cartItem == recycleFeeCartItem">
{{ recycleFeeCartItem.name }}
</span>
<span>{{ getFormattedAmount(cartItem.category, cartItem.subTotal) }}</span>
</div>
<!-- Nonpackaged Cart Items -->
<div
v-for="(cartItem, i) in nonpackageCartItems"
:key="i"
class="non-packaged-cart-item"
:class="[
i % 2 == 0 ? 'even' : 'odd',
cartItem.isRemovable ? 'removable-cart-item' : '',
]">
<span v-if="cartItem != recycleFeeCartItem">{{ cartItem.name }}</span>
<textLink
v-if="allowItemRemoval && cartItem.isRemovable"
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="javascript:void(0)"
@click-event="removeItem(cartItem.cartItemType, cartItem.category)" />
<textLink
v-else-if="
cartItem == recycleFeeCartItem && recyclingModalCmsWidgetName
"
linkType="text"
:text="recycleFeeCartItem.name"
href="javascript:void(0)"
@click-event="openModal(recyclingModalCmsWidgetName)" />
<span v-else-if="cartItem == recycleFeeCartItem">
{{ recycleFeeCartItem.name }}
</span>
<span>{{ getFormattedAmount(cartItem.category, cartItem.subTotal) }}</span>
</div>
<!-- Sub total, sales tax, total columns -->
<div class="sub-total">
<span>{{ subtotalText }}</span
><span>{{ getFormattedAmount("", subTotal) }}</span>
</div>
<div class="sales-tax">
<span>{{ salesTaxText }}</span
><span>{{ getFormattedAmount("", salesTax) }}</span>
</div>
<div v-if="showAsPaid" class="amount-paid">
<span>{{ amountPaidText }}</span
><span>{{ getFormattedAmount("", amountPaid) }}</span>
</div>
<div class="amount-due">
<span>{{ amountDueText }}</span
><span>{{ getFormattedAmount("", amountDue) }}</span>
<!-- Sub total, sales tax, total columns -->
<div class="sub-total">
<span>{{ subtotalText }}</span
><span>{{ getFormattedAmount("", subTotal) }}</span>
</div>
<div class="sales-tax">
<span>{{ salesTaxText }}</span
><span>{{ getFormattedAmount("", salesTax) }}</span>
</div>
<div v-if="showAsPaid" class="amount-paid">
<span>{{ amountPaidText }}</span
><span>{{ getFormattedAmount("", amountPaid) }}</span>
</div>
<div class="amount-due">
<span>{{ amountDueText }}</span
><span>{{ getFormattedAmount("", amountDue) }}</span>
</div>
</div>
<promoModalQuestion
ref="promoModalQuestion"
class="small mt-4"
v-model="lineItems"
@addedPromo="handleAddedPromo"
:availableVaps="availableVaps"
modalWidgetName="PromoModalWidget" />
</div>
</div>
<div
class="my-2 lh-1 applied-promo"
v-for="(promoCode, i) in this.getPromoCodeList()"
:key="i">
<span class="caption">Promo code &lt;{{ promoCode }}&gt; applied </span>
</div>
<contentGroupModal ref="RecycleModal" cmsWidgetName="RecycleModal">
<textBlock cmsWidgetName="RecycleTextBlock" />
</contentGroupModal>
@ -96,6 +125,7 @@
import textLink from "@/ux-components/text-link/text-link";
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";
// Mixins
import baseMixin from "@/mixins/base-mixin.js";
@ -149,6 +179,17 @@ export default {
return subTotal;
},
getPromoDiscounts() {
const promoItems = this.promoCartItems;
let subTotal = 0;
promoItems?.forEach((item) => {
subTotal += item.subTotal;
});
return subTotal;
},
getVapsCartItemsForSelectedPackage(packageName) {
const packageContentTypes = getPackageContents(
this.glassToReplace,
@ -167,6 +208,12 @@ export default {
});
});
if (this.promoCartItems) {
this.promoCartItems.forEach((promoCartItem) => {
vapsCartItemsForSelectedPackage.push(promoCartItem);
});
}
return vapsCartItemsForSelectedPackage;
},
getCmsContentForVapsType(vapsType) {
@ -192,6 +239,16 @@ export default {
this.$emit("update:modelValue", this.lineItems);
},
getPromoCodeList() {
if (this.$refs["promoModalQuestion"]) {
return this.$refs["promoModalQuestion"].getPromoCodeList();
}
return [];
},
handleAddedPromo(lineItems) {
// NOTE: these lineItems are passed from promo-modal-question
this.$emit("update:modelValue", lineItems);
},
},
computed: {
screenReaderTotalAmountDueText() {
@ -313,6 +370,12 @@ export default {
return packagePrice;
},
packagePriceWithoutDiscount() {
let fullPrice = this.packagePrice;
let discount = this.getPromoDiscounts();
fullPrice += Math.abs(discount);
return fullPrice;
},
isRepair() {
if (!this.damage) {
return;
@ -540,7 +603,6 @@ export default {
return cartItem;
},
suppliesRepairCartItemName() {
return this.getCmsContent("SuppliesRepairTextWidget", "Text");
},
@ -578,7 +640,6 @@ export default {
return cartItem;
},
mobileFeeCartItemName() {
return this.getCmsContent("MobileServiceTextWidget", "Text");
},
@ -734,7 +795,6 @@ export default {
return promoCartItems;
},
removeLinkText() {
return this.getCmsContent("RemoveCartItemTextWidget", "Text");
},
@ -795,6 +855,7 @@ export default {
textBlock,
textLink,
contentGroupModal,
promoModalQuestion,
},
};
</script>
@ -824,12 +885,14 @@ export default {
.amount-due {
color: $green;
}
.price-table {
.cart-panel {
max-height: 0;
transition: all 350ms ease-in;
overflow: hidden;
visibility: hidden;
color: $gray-600;
}
.price-table {
div {
display: flex;
justify-content: space-between;
@ -846,6 +909,10 @@ export default {
background-color: $gray-100;
align-items: center;
}
.struck-out-price {
text-decoration: line-through;
padding-left: 0.5rem;
}
.packaged-cart-item {
background-color: $gray-100;
padding-left: 2.5rem;
@ -934,17 +1001,26 @@ export default {
&.expanded:after {
transform: rotate(180deg);
}
&.expanded + .price-table {
&.expanded + .cart-panel {
max-height: 650px;
transition: all 150ms ease-in;
overflow: hidden;
padding-top: 1rem;
padding: 1rem 0 0.5rem;
visibility: visible;
}
a {
text-decoration: none;
}
}
.applied-promo {
span {
color: $green-700;
font-weight: 500;
padding: 2px 8px;
background-color: $green-100;
border-radius: 12px;
}
}
}
.payment-method-question {
.question-text {

View file

@ -120,7 +120,7 @@ export default {
<style lang="scss" scoped>
.menu-modal-container {
position: absolute;
padding: 1.47rem 1rem 1.47rem 1.47rem;
padding: 1.47rem 0 1.47rem 1.47rem;
right: 0;
button {
border: none;
@ -186,7 +186,7 @@ export default {
}
.menu-modal-container {
position: absolute;
padding: 1.47rem 1rem 1.47rem 1.47rem;
padding: 1.47rem 0 1.47rem 1.47rem;
right: 0;
top: -5rem;
button {

View file

@ -0,0 +1,400 @@
import { mount, shallowMount } from "@vue/test-utils";
import promoModalQuestion from "./promo-modal-question";
import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js";
import {
promoErrorCodes,
getPromoCodesFromPromoObjectsWithoutDuplicates,
getPromoCodeWithoutBundleIdentifier,
} from "@/helpers/promotions-helper";
import { cartItemCategories } from "@/constants/cart-item-categories";
let mockReturnsForStoreActions = {};
jest.mock("@/mixins/base-mixin", () => ({
...jest.requireActual("@/mixins/base-mixin"),
methods: {
dispatchStoreActionWithLogging: jest.fn(
(action, { promoCode, addableVaps }, pageNameToLog, someBool) => {
return mockReturnsForStoreActions[action];
}
),
},
}));
afterEach(() => {
// reset store action returns
mockReturnsForStoreActions = {};
});
jest.mock("@/digital-components/textbox-question/textbox-question", () => ({
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return widgetName[cmsFieldName];
}),
}));
jest.mock("@/digital-components/modal/modal", () => ({
methods: {
closeModal: jest.fn(),
resetButtonStyle: jest.fn(),
resetForm: jest.fn(),
},
}));
const modalWidgetName = "modalWidgetName";
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
return null;
}),
},
};
describe("promo-modal-question.vue", () => {
it("Should reset all alerts on onModalClosed", async () => {
// Arrange
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.onModalClosed();
// Assert
expect(wrapper.vm.displayInvalidPromoAlert).toBe(false);
expect(wrapper.vm.displayStackingPromoAlert).toBe(false);
expect(wrapper.vm.displayInShopPromoAlert).toBe(false);
expect(wrapper.vm.displaySimilarPromoAlert).toBe(false);
});
it("Should emit update:modelValue on Modal closed", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
await wrapper.vm.onModalClosed();
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([[lineItems]]);
});
it("focuses on the input element when focusOnPromoInput is called", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const focusMock = jest.fn();
const inputMock = { focus: focusMock };
// Mock document.getElementById to return the inputMock
jest.spyOn(document, "getElementById").mockReturnValue(inputMock);
//Act
wrapper.vm.focusOnPromoInput();
await wrapper.vm.$nextTick();
//Assert
expect(focusMock).toHaveBeenCalled();
});
it("returns a validate response on applying promo", async () => {
// Arrange
const newPromo = "testPromo";
const pageNameToLog = "testPage";
const validateResponse = { orderPromos: [] };
const addableVaps = [];
mockReturnsForStoreActions[storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA] =
validateResponse;
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.getPromoCodeData();
const promoValidationResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA,
{
newPromo,
lineItems,
addableVaps,
},
pageNameToLog,
false
);
// Assert
expect(promoValidationResponse).toEqual(validateResponse);
});
test("If promocode is valid return taxed lineItems", async () => {
//Arrange
const taxedlineItems = {};
mockReturnsForStoreActions[storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA] =
taxedlineItems;
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
let promoCode = "1wiper0";
const pricedLineItemsToTax = [];
pricedLineItemsToTax.push(promoCode);
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.addPromoCode();
const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: "87291",
providerNumber: 2,
appointmentType: "IN_SHOP",
serviceLocationCity: "city",
serviceLocationState: "state",
serviceLocationZipCode: "12345",
pricedLineItems: pricedLineItemsToTax,
},
"payment-method",
false
);
// Assert
expect(taxedLineItems).toEqual(taxedlineItems);
});
test("Get promoCode list will return the applied promos", async () => {
// Arrange
const promos = [
{ promoCode: "duplicatePromo" },
{ promoCode: "duplicatePromo" },
{ promoCode: "bundlePromo/400" },
{ promoCode: "bundlePromo/401" },
];
const appliedPromos = ["duplicatePromo", "bundlePromo"];
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.getPromoCodeList();
const promoCodeToDisplay = getPromoCodesFromPromoObjectsWithoutDuplicates(promos);
// Assert
expect(promoCodeToDisplay).toEqual(appliedPromos);
});
test("On clicking remove the promo gets removed from the lineItems", async () => {
//Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [{ promoCode: "duplicatePromo" }, { promoCode: "duplicatePromo" }],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const promo = ["bundlePromo"];
//Act
wrapper.vm.removeItem(promo);
const existingPromo = (lineItems[cartItemCategories.PROMOS] = lineItems[
cartItemCategories.PROMOS
].filter(
(lineItemsToKeep) =>
getPromoCodeWithoutBundleIdentifier(lineItemsToKeep.promoCode) != promo
));
//Assert
expect(existingPromo).toEqual(lineItems.promos);
});
test("Getting stacking alert on stack promo error code", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const additionalInfo = ["testAdditionalInfo"];
const stackingErrorCode = promoErrorCodes.PROMO_STACKING_NOT_ALLOWED;
// Act
wrapper.vm.getErrorMessage(stackingErrorCode, additionalInfo);
// Assert
expect(wrapper.vm.displayStackingPromoAlert).toBe(true);
});
test("Getting invalid promo alert on invalid promoCode", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const additionalInfo = ["testAdditionalInfo"];
const invalidErrorCode = promoErrorCodes.INVALID_PROMO_ON_ORDER;
// Act
wrapper.vm.getErrorMessage(invalidErrorCode, additionalInfo);
// Assert
expect(wrapper.vm.displayInvalidPromoAlert).toBe(true);
});
test("Getting invalid promo alert on invalid promoCode", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const additionalInfo = ["APPOINTMENT_TYPE"];
const invalidErrorCode = promoErrorCodes.INVALID_PROMO_ON_ORDER;
// Act
wrapper.vm.getErrorMessage(invalidErrorCode, additionalInfo);
// Assert
expect(wrapper.vm.displayInShopPromoAlert).toBe(true);
});
test("Get conflicting promoCodes on applying more than one promo", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
const additionalInfo = ["1wiper0", "Glass30"];
const conflictingCodes = ["1wiper0", "Glass30"];
//Act
wrapper.vm.getConflictingPromoCode(additionalInfo);
//Assert
expect(conflictingCodes).toEqual(additionalInfo);
});
});

View file

@ -64,7 +64,7 @@
</template>
<script>
import textLink from "@/ux-components/text-link/text-link";
import promoQuestion from "@/layouts/payment-method/promo-modal-question/promo-question/promo-question";
import promoQuestion from "@/fmg-components/promo-modal-question/promo-question/promo-question";
import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert";
import { storeActions } from "@/constants/store-actions.js";
@ -100,6 +100,12 @@ export default {
modelValue: Object,
modalWidgetName: String,
availableVaps: Object,
pageName: String,
taxPromos: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
promoLinkText() {
@ -253,43 +259,52 @@ export default {
this.promoCode,
this.lineItems,
this.availableVaps,
"payment-method"
this.pageName
);
if (promoCodeData.isValid) {
const pricedLineItemsToTax = [];
pricedLineItemsToTax.push(...promoCodeData.promoCode);
const taxedLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: store.getters.payment.billToAccountNumber,
providerNumber:
store.getters.order.serviceLocation.provider.providerNumber,
appointmentType: store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: store.getters.order.serviceLocation.city,
serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: pricedLineItemsToTax,
},
"payment-method",
false
);
if (this.taxPromos) {
const pricedLineItemsToTax = [];
pricedLineItemsToTax.push(...promoCodeData.promoCode);
const taxedLineItems =
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: "87291",
providerNumber:
store.getters.order.serviceLocation.provider.providerNumber,
appointmentType:
store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: store.getters.order.serviceLocation.city,
serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode:
store.getters.order.serviceLocation.zipCode,
pricedLineItems: pricedLineItemsToTax,
},
"payment-method",
false
);
// Match all line items to the line items as they are in the store
// and rebuild the original structure.
this.lineItems = mapTaxedLineItemsToStoreFormat(taxedLineItems, this.lineItems);
const taxedVaps = mapTaxedLineItemsToStoreFormat(
taxedLineItems,
this.availableVaps
);
// Match all line items to the line items as they are in the store
// and rebuild the original structure.
this.lineItems = mapTaxedLineItemsToStoreFormat(
taxedLineItems,
this.lineItems
);
const taxedVaps = mapTaxedLineItemsToStoreFormat(
taxedLineItems,
this.availableVaps
);
const getVaps = getVapsThatNeedToBeAddedToSatisfyPromos(
promoCodeData.promoCode,
taxedVaps,
this.lineItems
);
const getVaps = getVapsThatNeedToBeAddedToSatisfyPromos(
promoCodeData.promoCode,
taxedVaps,
this.lineItems
);
this.lineItems.vaps?.push(...getVaps);
}
this.lineItems?.promos.push(...promoCodeData.promoCode);
this.lineItems.vaps?.push(...getVaps);
this.$emit("added-promo", this.lineItems);
this.closeModal();
} else {
this.getErrorMessage(promoCodeData.errorCode, promoCodeData.additionalInfo);

View file

@ -61,7 +61,11 @@ export default {
);
}
//router.navigateError();
// do not route to error logic when no wipers found or no promo found (404s)
if (error.response.status != "404") {
router.navigateError();
}
return reject(error.response);
}
);

View file

@ -1,6 +1,7 @@
import globalMethods from "@/global-methods";
import axios from "axios";
import analyticsMixIn from "@/mixins/analytics-mixin";
import router from "@/router";
//Mock external dependencies
jest.mock("axios");
@ -29,6 +30,7 @@ it("Global Methods - Call Http Client - Should Reject Promise", () => {
isError: true,
});
analyticsMixIn.methods.pushEventToGA = jest.fn();
router.navigateError = jest.fn();
//Act
globalMethods.callHttpClient(httpArgs).catch((err) => {

View file

@ -36,12 +36,7 @@ export async function getPageToRouteExistingOrderTo(toRoute = {}) {
// If navigating to a specific page, and that page is not part of the vin pages.
// Return that page, so that it can navigate like normal.
if (
toRoute.query[queryStrings.FMG_PAGE] !== undefined &&
toRoute.query[queryStrings.FMG_PAGE] !== fmgPageValues.SERVICE_LOCATION &&
toRoute.query[queryStrings.FMG_PAGE] !== fmgPageValues.SCHEDULE &&
!isVinRelatedPage(toRoute)
) {
if (toRoute.query[queryStrings.FMG_PAGE] !== undefined && !isVinRelatedPage(toRoute)) {
return overrideYmmsDirectionIfNeeded(toRoute);
}
@ -139,11 +134,7 @@ async function getLatestPageForRedirection() {
} else if (!estimateComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_DAMAGE;
} else {
if (scheduleComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.SCHEDULE;
} else if (serviceLocationComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.SERVICE_LOCATION;
} else if (quoteComponent.methods.arePagePrerequisitesValid()) {
if (quoteComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.QUOTE;
} else if (capabilityQuestionsComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.CAPABILITY_QUESTIONS;

View file

@ -10,6 +10,7 @@ import { storeMutations } from "@/constants/store-mutations";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios";
import store from "@/store";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { experimentSettings } from "@/constants/experiments";
jest.mock("@/helpers/damage-helper", () => ({
isGlassAvailableForCarId: jest.fn().mockImplementation(() => true),
@ -641,6 +642,17 @@ describe("address-lookup.vue", () => {
});
});
const mockMixin = {
methods: {
getSettingValue: jest.fn((settingName) => {
if (settingName === experimentSettings.IS_EMAIL_OPTIONAL) {
return "true";
}
return "false";
}),
},
};
function setupMocks({
isZipValid = true,
isZipServiceable = true,
@ -709,6 +721,7 @@ function setupMocks({
},
},
},
mixins: [mockMixin],
})
);

View file

@ -20,7 +20,11 @@
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" ref="funnelSubHeader" />
<customerQuestions ref="customerQuestions" v-model="customerQuestions" />
<customerQuestions
ref="customerQuestions"
v-model="customerQuestions"
:validationRules="EmailValidationRules"
:isEmailOptional="IsEmailOptional" />
<alert
ref="alertVinNotFound"

View file

@ -27,7 +27,9 @@
v-model="customerModel.emailAddress"
ref="emailAddress"
customInputId="emailAddress"
validationRules="email-address-required|email-address-format" />
:isRequired="!isEmailOptional"
:validationRules="validationRules"
:addOptionalText="isEmailOptional" />
</div>
</div>
<div class="row mb-0">
@ -79,6 +81,7 @@ export default {
}),
},
validationRules: String,
isEmailOptional: Boolean,
},
computed: {
customerModel: {

View file

@ -164,7 +164,6 @@ export default {
if (
store.getters.order.vehicle.carId &&
store.getters.order.serviceLocation.zipCode &&
store.getters.order.customer.emailAddress &&
store.getters.pageData(fmgPageValues.ADDRESS_VEHICLES)
) {
return true;

View file

@ -0,0 +1,48 @@
// Components
import bailout from "@/layouts/bailout/bailout.vue";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
settleAllPromises: jest.fn(),
}));
// Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn(),
}));
describe("bailout.vue", () => {
test("arePagePrerequisitesValid should be true ", async () => {
//Arrange
const { wrapper } = setupMocks();
//Act
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
//Assert
expect(arePagePrerequisitesValid).toBe(true);
});
});
function setupMocks() {
const mountOptions = getMountOptions({});
//Mock props
const mockMixin = {
methods: {
getCmsContent: jest.fn(),
},
};
mountOptions.mixins = [mockMixin];
const wrapper = shallowMount(bailout, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
wrapper.vm.backButtonAction = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,79 @@
<template>
<Form>
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<vehicleBanner
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<navbar
cmsWidgetName="FunnelFooterWidget"
isForwardActionDisabled="true"
isSubmitHidden="true"
@back-clicked="backButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
export default {
name: "bailout",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
methods: {
arePagePrerequisitesValid() {
return true;
},
backButtonAction() {
this.$router.go(-1);
},
},
components: {
funnelHeader,
navbar,
vehicleBanner,
funnelSubHeader,
loadingModal,
},
};
</script>

View file

@ -135,7 +135,6 @@ export default {
vm.setCmsContent(resultMap.cmsContent);
vm.lineItems = lineItemsFromSubmittedOrder;
vm.vaps = availableVaps;
analyticsMixin.methods.pushSubmittedOrderToDataLayer();
});
},
data() {

View file

@ -11,6 +11,7 @@ import { settleAllPromises } from "@/helpers/layout-helper.js";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import baseMixin from "../../mixins/base-mixin";
import { vinLookupMethodSelections } from "@/constants/vin-lookup-method-selections.js";
import { experimentSettings } from "@/constants/experiments";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
@ -273,12 +274,24 @@ function setupMocks({
Answers: cmsAnswers,
FunnelFooterWidget: FunnelFooterWidget,
};
const mockMixin = {
methods: {
getSettingValue: jest.fn((settingName) => {
if (settingName === experimentSettings.IS_EMAIL_OPTIONAL) {
return "true";
}
return "false";
}),
},
};
const apiPromise = Promise.resolve({ cmsContent });
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions({ ...mountOptionsMockData, mixins: [baseMixin] });
const mountOptions = getMountOptions({
...mountOptionsMockData,
mixins: [baseMixin, mockMixin],
});
mountOptions["attachTo"] = document.body;
const wrapper = shallowMount(estimate, mountOptions);

View file

@ -52,9 +52,10 @@
cmsWidgetName="EmailAddressQuestionWidget"
v-model="emailAddress"
inputId="emailAddress"
isRequired
:isRequired="!IsEmailOptional"
disableAutoFill
validationRules="email-address-required|email-address-format" />
:validationRules="EmailValidationRules"
:addOptionalText="IsEmailOptional" />
<textBlock cmsWidgetName="QuoteEmailTextBlockWidget" typeStyle="caption" />
<alert

View file

@ -0,0 +1,3 @@
describe("insurance", () => {
it.todo("Should render a normal string");
});

View file

@ -0,0 +1,326 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4" />
<textboxQuestion
class="mb-2 mt-5"
cmsWidgetName="InsuranceCoQuestionWidget"
includeSearchIcon
cornerStyle="rounded" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { storeActions } from "@/constants/store-actions";
import { errorMessages } from "@/constants/error-messages";
import {
getDamageString,
getIsWindshieldOnly,
isGlassAvailableForCarId,
} from "@/helpers/damage-helper";
import { required, regex } from "@/helpers/validation-rules";
import { Form, defineRule } from "vee-validate";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { routerParams } from "@/router/router-constants/router-params";
import store from "@/store";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
// DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule(
"email-address-format",
regex(
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9-]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT
)
);
defineRule("vin-required", required(errorMessages.VIN_REQUIRED));
defineRule("vin-format", regex(/^[a-hA-Hj-nJ-NpPr-zR-Z0-9]{17}$/, errorMessages.VIN_FORMAT));
export default {
name: "insurance",
mixins: [vinPagesMixin],
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
data() {
return {
vin: this.getVinFromStore(),
serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipcode,
emailAddress: this.getEmailFromStore(),
isCarIdDifferent: false,
customAlertData: {},
previouslyEnteredCarId: "",
vinPopulatedOnPageLoad: this.getVinFromStore()?.length > 0,
isSelectedGlassAvailableForVehicle: true,
displayInvalidZipAlert: false,
displayNonServiceableZipAlert: false,
displayVinNotFoundAlert: false,
displayMatchedDifferentVehicleAlert: false,
displayVinScanFailedAlert: false,
};
},
methods: {
arePagePrerequisitesValid() {
return store.getters.vehicle.carId !== null;
},
getEmailFromStore() {
return this.$store.getters.order.customer.emailAddress;
},
getVinFromStore() {
return this.$store.getters.vehicle.vin;
},
getZipFromStore() {
return this.$store.getters.order.serviceLocation.zipCode;
},
attachCustomEvents() {
this.prependActionToMethod(this, this.forwardButtonAction, () => {
this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
this.GaActions.SUBMITTED,
this.GaLabels.VIN_LOOKUP,
true
);
});
},
backButtonAction() {
// route to move backwards
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
this.resetAlerts();
// If this is a new VIN Lookup, do both a Vehicle Lookup and a Zip Validation
if (!this.vinPopulatedOnPageLoad) {
const vehicleLookupResponse = this.dispatchStoreActionWithLogging(
storeActions.LOOKUP_VEHICLE_BY_VIN,
{ vin: this.vin },
"vin-lookup"
);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "vehicleLookupResponse",
promise: vehicleLookupResponse,
},
{
resultKey: "zipCodeData",
promise: this.getZipCodeData(this.serviceZipCode),
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// If a Service Zip is entered and it is an invalid zip code (ex. 11111) then show an alert
const isZipValid = resultMap.zipCodeData.isValid;
if (this.serviceZipCode && !isZipValid) {
this.displayInvalidZipAlert = true;
return this.$refs.navbar.removeLoader();
}
this.displayInvalidZipAlert = false;
// If either lookup fails, remove the loader and stop processing the page.
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
// If the vehicle result is undefined, the vin entered was invalid.
if (!resultMap.vehicleLookupResponse) {
this.displayVinNotFoundAlert = true;
}
// Check if Service Zip entered is serviceable, if not display an alert
if (!resultMap.zipCodeData.isServiceable) {
this.displayNonServiceableZipAlert = true;
}
// Remove loader and stop processing the page.
return this.$refs.navbar.removeLoader();
}
// Check if the CarId is different from the lookup vs what is in state currently.
this.isCarIdDifferent =
resultMap.vehicleLookupResponse.carId !== this.$store.getters.vehicle.carId;
if (
this.isCarIdDifferent &&
resultMap.vehicleLookupResponse.carId !== this.previouslyEnteredCarId
) {
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse.carId;
this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse;
this.displayMatchedDifferentVehicleAlert = true;
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
resultMap.vehicleLookupResponse.carId,
"vin-lookup"
);
// Update button "Continue with..."
this.$refs.navbar.updateButtonText(
`Continue with ${resultMap.vehicleLookupResponse.year} ${resultMap.vehicleLookupResponse.make} ${resultMap.vehicleLookupResponse.model}`
);
return this.$refs.navbar.removeLoader();
}
// Save vin, vehicle, customer and service information
await this.dispatchStoreAction(
storeActions.SAVE_VIN_LOOKUP,
{
isSelectedGlassAvailableForVehicle: this.isSelectedGlassAvailableForVehicle,
vehicleInfo: Object.assign(resultMap.vehicleLookupResponse, {
vin: this.vin,
}),
},
false
);
await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.emailAddress, false);
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
state: resultMap.zipCodeData.state,
zipCode: this.serviceZipCode,
zipCodeCtu: resultMap.zipCodeData.zipCodeCtu,
},
false
);
return await this.navigateForward();
}
// If a VIN has already been found. Validate the Service Zip (in case of changes)
const zipCodeData = await this.getZipCodeData(this.serviceZipCode);
// Check if Service Zip entered is serviceable then save the ZIP info
if (zipCodeData.isServiceable) {
//Only save the zipCode, state, and zipCodeCtu if the zip changed or we lack zipCodeCtu
if (
this.$store.getters.order.serviceLocation.zipCode != this.serviceZipCode ||
!this.$store.getters.order.serviceLocation.zipCodeCtu
) {
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
state: zipCodeData.state,
zipCode: this.serviceZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
},
false
);
}
await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.emailAddress, false);
return await this.navigateForward();
}
// If the Service Zip is NOT serviceable then show an alert
if (zipCodeData.isValid) {
this.displayNonServiceableZipAlert = true;
} else {
this.displayInvalidZipAlert = true;
}
return this.$refs.navbar.removeLoader();
},
async navigateForward() {
if (this.isCarIdDifferent && !this.isSelectedGlassAvailableForVehicle) {
this.$router.navigateWithSaving(
this.navigationScenarios.SELECTED_VIN_WITH_MISMATCHED_GLASS,
this.$route,
{},
{ [routerParams.DISPLAY_VEHICLE_CHANGE_ALERT]: true }
);
} else {
await this.navigateForwardWithSingleCarMatch();
}
},
displayVinScanAlert() {
this.displayVinScanFailedAlert = true;
},
getVinFromImage(image) {
return new Promise((resolve, reject) => {
this.dispatchStoreActionWithLogging(
storeActions.LOOKUP_VIN_BY_IMAGE,
image,
"vin-lookup"
)
.then((response) => {
if (response.data.length > 0) {
resolve(response.data[0]);
} else {
reject("No VINs detected.");
}
})
.catch(() => {
reject("An error occurred during the lookup.");
});
});
},
resetAlerts() {
this.displayMatchedDifferentVehicleAlert = false;
this.displayNonServiceableZipAlert = false;
this.displayInvalidZipAlert = false;
this.displayVinNotFoundAlert = false;
this.displayVinScanFailedAlert = false;
},
},
mounted() {
this.attachCustomEvents();
},
components: {
funnelHeader,
navbar,
funnelSubHeader,
textboxQuestion,
Form,
loadingModal,
},
};
</script>

View file

@ -11,6 +11,7 @@ import { nextTick } from "vue";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { experimentSettings } from "@/constants/experiments";
jest.mock("@/assets/img/loader.gif", () => "loader.gif");
jest.mock("@/assets/img/windshield.png", () => "windshield.png");
@ -692,11 +693,20 @@ function setupMocks({
};
const apiPromise = Promise.resolve(apiResponses);
const mockMixin = {
methods: {
getSettingValue: jest.fn((settingName) => {
if (settingName === experimentSettings.IS_EMAIL_OPTIONAL) {
return "true";
}
return "false";
}),
},
};
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions(mountOptionsMockData);
const mountOptions = getMountOptions({ ...mountOptionsMockData, mixins: [mockMixin] });
mountOptions["attachTo"] = document.body; // append wrapper to document.body to test DOM methods
const wrapper = shallowMount(licensePlateLookup, mountOptions);

View file

@ -34,7 +34,9 @@
cmsWidgetName="EmailAddressQuestionWidget"
v-model="email"
customInputId="email"
validationRules="email-address-required|email-address-format" />
:isRequired="!IsEmailOptional"
:validationRules="EmailValidationRules"
:addOptionalText="IsEmailOptional" />
<textBlock cmsWidgetName="QuoteEmailTextBlockWidget" typeStyle="caption" />

View file

@ -29,12 +29,6 @@
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" />
<promoModalQuestion
class="small mt-4"
v-model="lineItems"
:availableVaps="availableVaps"
modalWidgetName="PromoModalWidget" />
<hr class="my-5" />
<div>
@ -82,7 +76,6 @@ import paymentMethodQuestion from "@/layouts/payment-method/payment-method-quest
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import cart from "@/fmg-components/cart/cart";
import reviewDropdown from "@/layouts/payment-method/review-dropdown/review-dropdown";
import promoModalQuestion from "@/layouts/payment-method/promo-modal-question/promo-modal-question";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import alert from "@/ux-components/alert/alert";
@ -591,7 +584,6 @@ export default {
cart,
alert,
paymentMethodQuestion,
promoModalQuestion,
reviewDropdown,
},
};

View file

@ -1,82 +0,0 @@
import { mount, shallowMount } from "@vue/test-utils";
import promoModalQuestion from "./promo-modal-question";
jest.mock("@/digital-components/textbox-question/textbox-question", () => ({
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return widgetName[cmsFieldName];
}),
}));
jest.mock("@/digital-components/modal/modal", () => ({
methods: {
closeModal: jest.fn(),
resetButtonStyle: jest.fn(),
resetForm: jest.fn(),
},
}));
const modalWidgetName = "modalWidgetName";
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
return null;
}),
},
};
describe("promo-modal-question.vue", () => {
it("Should reset all alerts on onModalClosed", async () => {
// Arrange
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.onModalClosed();
// Assert
expect(wrapper.vm.displayInvalidPromoAlert).toBe(false);
expect(wrapper.vm.displayStackingPromoAlert).toBe(false);
expect(wrapper.vm.displayInShopPromoAlert).toBe(false);
expect(wrapper.vm.displaySimilarPromoAlert).toBe(false);
});
it("Should emit update:modelValue on Modal closed", async () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [],
promos: [],
};
const wrapper = mount(promoModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: lineItems,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
await wrapper.vm.onModalClosed();
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([[lineItems]]);
});
});

View file

@ -512,7 +512,7 @@ describe("payment.vue", () => {
});
});
describe("handleIFrameContentWindwMessage", () => {
describe("handleIFrameContentWindowMessage", () => {
test("Navigates back if afterpay is closed", () => {
// Arrange
const event = {
@ -524,7 +524,7 @@ describe("payment.vue", () => {
wrapper.vm.backButtonAction = jest.fn();
// Act
wrapper.vm.handleIFrameContentWindwMessage(event);
wrapper.vm.handleIFrameContentWindowMessage(event);
// Assert
expect(wrapper.vm.backButtonAction).toBeCalled();
@ -539,7 +539,7 @@ describe("payment.vue", () => {
const wrapper = setupMocks({});
// Act
wrapper.vm.handleIFrameContentWindwMessage(event);
wrapper.vm.handleIFrameContentWindowMessage(event);
// Assert
expect(wrapper.vm.shouldBlockInteraction).toBe(true);

View file

@ -643,17 +643,19 @@ export default {
this.submitHopForm();
},
handleIFrameContentWindwMessage(event) {
if (event.data.indexOf("afterpayClosed") > -1) {
this.backButtonAction();
}
if (event.data.indexOf("creditCardSubmit") > -1) {
this.setUIBlock(true);
handleIFrameContentWindowMessage(event) {
if (typeof event.data === "string") {
if (event.data.indexOf("afterpayClosed") > -1) {
this.backButtonAction();
}
if (event.data.indexOf("creditCardSubmit") > -1) {
this.setUIBlock(true);
}
}
},
setIFrameListener() {
window.addEventListener("message", (event) =>
this.handleIFrameContentWindwMessage(event)
this.handleIFrameContentWindowMessage(event)
);
},
setUIBlock(val) {

View file

@ -133,8 +133,6 @@ describe("quote.vue", () => {
};
});
wrapper.vm.pricedGlassParts = [];
//Act
await wrapper.vm.forwardButtonAction();
@ -142,44 +140,6 @@ describe("quote.vue", () => {
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
});
test("IsInsurance true should navigateToHeritageFunnel", async () => {
//Arrange
store.getters = {
payment: {
insuranceCoverage: {},
isInsurance: true,
},
order: {
lineItems: [],
payment: {
parentAccountNumber: null,
},
},
};
const { wrapper } = setupMocks({
customMountOptions: {
router: {
navigateWithSaving: jest.fn(),
},
route: { quote },
mixins: [mockMixin],
},
});
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.pricedGlassParts = [];
//Act
await wrapper.vm.forwardButtonAction();
//Assert
expect(navigateToHeritage.navigateToHeritageFunnel).toHaveBeenCalled();
});
test("should pass arePagePrerequisitesValid with a repair order", () => {
//Arrange
store.getters = {
@ -288,8 +248,8 @@ describe("quote.vue", () => {
);
//Assert
expect(wrapper.vm.pricedGlassParts !== null).toBe(true);
expect(wrapper.vm.supportingItems !== null).toBe(true);
expect(wrapper.vm.lineItems !== null).toBe(true);
expect(wrapper.vm.availableVaps !== null).toBe(true);
expect(wrapper.vm.availableLineItems !== null).toBe(true);
// This should have its own test
//expect(vm.isInsuranceSelected !== null).toBe(true);
@ -548,6 +508,31 @@ describe("quote.vue", () => {
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(true);
});
test("On forward button action save promos", async () => {
//Arrange
store.getters.payment = {
insuranceCoverage: {},
isInsurance: false,
};
store.getters.order = {
lineItems: [],
payment: {
parentAccountNumber: 167132,
},
};
const { wrapper } = setupMocks({
customMountOptions: {
router: {
navigateWithSaving: jest.fn(),
},
route: { quote },
},
});
wrapper.vm.forwardButtonAction();
expect(wrapper.vm.lineItems.promos !== null).toBe(true);
});
});
function setupMocks({ customMountOptions }) {

View file

@ -39,6 +39,14 @@
cmsWidgetName="AfterpayModalWidget"
:lineItems="availableLineItems" />
<promoModalQuestion
class="small mt-4"
v-model="lineItems"
:availableVaps="availableVaps"
pageName="quote"
:taxPromos="false"
modalWidgetName="PromoModalWidget" />
<textBlock
cmsWidgetName="quoteDisclaimer"
justifyText="left"
@ -89,9 +97,11 @@ import { payWithInsuranceStates } from "@/constants/pay-with-insurance-states";
import {
revalidatePromosAndValidateQueryStringPromo,
buildToastMessagesFromRevalidateOrValidatePromoResponse,
createPromoSuccessAlert,
} from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import promoModalQuestion from "@/fmg-components/promo-modal-question/promo-modal-question";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
@ -139,7 +149,8 @@ export default {
];
const resultMap = await settleAllPromises(promiseResultMap);
const nullSafeGlassParts = store.getters.order.lineItems.glassParts ?? [];
const lineItems = store.getters.order.lineItems;
const nullSafeGlassParts = lineItems.glassParts ?? [];
const availableLineItems = [
resultMap.rainDefense,
...resultMap.supportingItems,
@ -164,6 +175,7 @@ export default {
// Promo logic
// Populate the previous state of promos for toast message usage in "next()"
const availableVaps = [...resultMap.wipers, resultMap.rainDefense];
const oldActivePromos = store.getters.lineItems.promos?.slice(0);
const oldInactivePromos = store.getters.order.payment.inactivePromos?.slice(0);
@ -175,13 +187,16 @@ export default {
pricingResults,
"quote"
);
lineItems.glassParts = nullSafeGlassParts;
lineItems.promos = lineItems.promos ?? [];
lineItems.vaps = lineItems.vaps ?? [];
// End of promo logic
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.pricedGlassParts = nullSafeGlassParts;
vm.supportingItems = resultMap.supportingItems;
vm.availableVaps = availableVaps;
vm.lineItems = lineItems;
vm.availableLineItems = pricingResults;
vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems);
@ -208,15 +223,17 @@ export default {
data() {
return {
isInsuranceSelected: null,
selectedVaps: null,
availableLineItems: null,
supportingItems: null,
pricedGlassParts: null,
lineItems: [],
availableVaps: [],
};
},
computed: {
allActivePromos() {
return this.$store.getters.order.lineItems.promos ?? [];
return this.lineItems.promos ?? [];
},
lineItemsCloneForWatcher() {
return Object.assign({}, this.lineItems);
},
},
methods: {
@ -258,7 +275,7 @@ export default {
}
},
vapsItemsSelectedAction(vapsItemsSelected) {
this.selectedVaps = vapsItemsSelected;
this.lineItems.vaps = vapsItemsSelected;
},
backButtonAction() {
vehicleQuestionsMixin.methods.navigateBack(this);
@ -290,23 +307,30 @@ export default {
this.supportingItems = this.filterOutFees(this.supportingItems);
}
if (this.pricedGlassParts.length > 0) {
if (this.lineItems.glassParts?.length > 0) {
this.dispatchStoreAction(
this.storeActions.SAVE_GLASS_PARTS_SUPPRESSING_STATE_RESETTING,
this.pricedGlassParts,
this.lineItems.glassParts,
false
);
}
this.dispatchStoreAction(this.storeActions.SAVE_VAPS, this.selectedVaps, false);
this.dispatchStoreAction(this.storeActions.SAVE_VAPS, this.lineItems.vaps, false);
this.dispatchStoreAction(
storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS,
{
activePromos: this.lineItems.promos,
inactivePromos: this.$store.getters.order.payment.inactivePromos,
},
false
);
const payment = this.$store.getters.payment;
if (payment.isInsurance) {
navigateToHeritageFunnel({
shouldSaveSession: true,
pageNameToLog: "quote",
loadingModal: this.$refs.loadingModal,
});
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_WITH_INSURANCE,
this.$route
);
} else {
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_WITH_CASH,
@ -315,6 +339,32 @@ export default {
}
},
},
watch: {
lineItemsCloneForWatcher: {
handler(newValue, oldValue) {
if (
!oldValue ||
oldValue.length == 0 ||
!oldValue.vaps ||
!newValue ||
newValue.length == 0
) {
return;
}
if (oldValue.promos.length < newValue.promos.length) {
const oldPromoCodes = oldValue.promos.map(
(promoObject) => promoObject.promoCode
);
const newlyActivatedPromoCodes = newValue.promos.filter(
(newPromo) => !oldPromoCodes.includes(newPromo.promoCode)
);
const alert = createPromoSuccessAlert(newlyActivatedPromoCodes[0].promoCode);
this.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade);
}
},
deep: true,
},
},
components: {
funnelHeader,
navbar,
@ -327,6 +377,7 @@ export default {
contentGroupModal,
loadingModal,
afterpayModalBanner,
promoModalQuestion,
},
};
</script>

View file

@ -256,6 +256,57 @@ describe("service-package-question.vue", () => {
// Assert
expect(wrapper.vm.selectedPackageName).toBe("TierThree");
});
it("should select default package if promos are added", async () => {
//Arrange
mockProps.activePromos != null;
const wrapper = setupMocks({
mountOptionsMockData: {
store: {
getters: {
order: {
damage: {
isRepair: false,
glassToReplace: [{ glassLocation: "Windshield" }],
},
},
lineItems: {
vaps: [],
},
hasAnyNonWindshieldGlassParts: false,
payment: {
isInsurance: false,
},
},
},
},
});
//Act
wrapper.setProps({
activePromos: [
{
discountedLineItemIds: [
{
0: "428ec73c-38e4-4e14-8703-a987b0391898",
1: "79db94d7-163c-4408-a1ec-f83e58c11992",
},
],
partType: "PROMO_DISCOUNT",
promoCode: "1WIPER0",
partNumber: "WIPER DISCOUNT",
laborAmount: 0,
sellingPrice: -10,
kitPrice: 0,
salesTax: null,
},
],
});
const selectDefaultPackageMock = jest.spyOn(wrapper.vm, "selectDefaultPackage");
await nextTick();
//Assert
expect(selectDefaultPackageMock).toHaveBeenCalled();
});
});
describe("service-package-question.vue, matching business rules for package display", () => {
// mock scenarios in figma:

View file

@ -63,6 +63,9 @@ export default {
this.selectDefaultPackage();
}
},
activePromos() {
if (this.activePromos?.length) this.selectDefaultPackage();
},
selectedPackageName(newValue) {
const VapsProductsInSelectedPackage = this.getVapsLineItemsForSelectedPackage(newValue);
this.$emit("vapsItemsSelected", VapsProductsInSelectedPackage);

View file

@ -469,7 +469,7 @@ export default {
supportingItems[mobileFeeIndex].sellingPrice = this.mobileFeePart.sellingPrice;
supportingItems[mobileFeeIndex].kitPrice = this.mobileFeePart.kitPrice;
} else {
supportingItems.push(this.mobileFeePart);
if (this.mobileFeePart !== null) supportingItems.push(this.mobileFeePart);
}
this.dispatchStoreAction(

View file

@ -3,6 +3,7 @@ import vinLookup from "./vin-lookup.vue";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios.js";
import { settleAllPromises } from "@/helpers/layout-helper.js";
import { experimentSettings } from "@/constants/experiments";
import store from "@/store";
@ -378,5 +379,11 @@ function mockOutStubFunctions(wrapper) {
const mockMixin = {
methods: {
getCmsContent: jest.fn(() => "placeholder CMS content"),
getSettingValue: jest.fn((settingName) => {
if (settingName === experimentSettings.IS_EMAIL_OPTIONAL) {
return "true";
}
return "false";
}),
},
};

View file

@ -53,8 +53,9 @@
cmsWidgetName="EmailAddressQuestionWidget"
v-model="emailAddress"
customInputId="emailAddress"
isRequired
validationRules="email-address-required|email-address-format" />
:isRequired="!IsEmailOptional"
:validationRules="EmailValidationRules"
:addOptionalText="IsEmailOptional" />
<textBlock cmsWidgetName="QuoteEmailTextBlockWidget" typeStyle="caption" />

View file

@ -120,77 +120,182 @@ export default {
await this.logPageView(analyticsPageEvents.ENTRY);
},
pushSubmittedOrderToDataLayer() {
// check if submitted order exists; exit if not.
const hasSubmittedOrder = store.getters.hasSubmittedOrder;
pushOrderToDataLayer() {
// helper check for if an object is defined (but maybe falsey)
const isDefined = (x) => x !== null && x !== undefined;
if (!hasSubmittedOrder) {
return;
// Get correct order object
const hasSubmittedOrder = store.getters.hasSubmittedOrder;
const order = hasSubmittedOrder ? store.getters.submittedOrder : store.getters.order;
// Begin assembling payload for data layer
const payload = {};
// Service Zip
if (
order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE &&
isDefined(order.serviceLocation.zipCode)
) {
payload.serviceZipCode = order.serviceLocation.zipCode;
} else if (
isDefined(order.serviceLocation.appointmentType) &&
order.serviceLocation.appointmentType !== AppointmentTypeStrings.MOBILE &&
isDefined(order.serviceLocation.provider.address.zipCode)
) {
payload.serviceZipCode = order.serviceLocation.provider.address.zipCode;
} else {
payload.serviceZipCode = "";
}
const order = store.getters.submittedOrder;
// Damage Type
if (isDefined(order.damage.isRepair)) {
payload.damageType = order.damage.isRepair ? "repair" : "replace";
} else {
payload.damageType = "";
}
// assemble data for payload
// // reduce promocode array
// Account Type
if (isDefined(order.payment.isInsurance)) {
payload.accountType = order.payment.isInsurance ? "insurance" : "cash";
} else {
payload.accountType = "";
}
// Promo Codes
const promos = order.lineItems.promos ?? [];
const promoCodes = promos.map((promo) => promo.promoCode);
const promoString =
promoCodes.length === 0 ? "" : promoCodes.reduce((prev, next) => `${prev},${next}`);
if (promos.length === 0) {
payload.promoCodes = "";
} else {
const promoCodes = promos.map((promo) => promo.promoCode);
const promoString = promoCodes.reduce((prev, next) => `${prev},${next}`);
payload.promoCodes = promoString;
}
// // reduce glass array
const glassToReplace = order.damage.glassToReplace ?? [];
const glassToReplaceNames = glassToReplace.map(
(glassPiece) => `${glassPiece.glassLocation}/${glassPiece.glassName}`
);
const glassString =
glassToReplaceNames.length === 0
? ""
: glassToReplaceNames.reduce((prev, next) => `${prev},${next}`);
// Vehicle info
if (isDefined(order.vehicle.year)) {
// Ensure cast to string.
payload.vehicleYear = `${order.vehicle.year}`;
} else {
payload.vehicleYear = "";
}
// // calculate subtotal
const lineItems = order.lineItems;
if (isDefined(order.vehicle.make)) {
payload.vehicleMake = order.vehicle.make;
} else {
payload.vehicleMake = "";
}
if (isDefined(order.vehicle.model)) {
payload.vehicleModel = order.vehicle.model;
} else {
payload.vehicleModel = "";
}
if (isDefined(order.vehicle.style)) {
payload.vehicleStyle = order.vehicle.style;
} else {
payload.vehicleStyle = "";
}
// Glass pieces
const glass = order.damage.glassToReplace ?? [];
if (glass.length === 0) {
payload.glassToReplace = "";
} else {
const glassNames = glass.map((g) => `${g.glassLocation}/${g.glassName}`);
const glassString = glassNames.reduce((prev, next) => `${prev},${next}`);
payload.glassToReplace = glassString;
}
// Work Order Id
if (order.workOrderId) {
const parsedId = parseInt(order.workOrderId);
if (!isNaN(parsedId)) {
payload.workOrderId = parsedId;
} else {
payload.workOrderId = "";
}
} else {
payload.workOrderId = "";
}
// Provider Ctu
if (isDefined(order.serviceLocation.zipCodeCtu)) {
const parsedCtu = parseInt(order.serviceLocation.zipCodeCtu);
if (!isNaN(parsedCtu)) {
payload.providerCtu = parseInt(order.serviceLocation.zipCodeCtu);
} else {
payload.providerCtu = "";
}
} else {
payload.providerCtu = "";
}
// Work Order Number
if (order.workOrderNumber) {
payload.orderNumber = order.workOrderNumber;
} else {
payload.orderNumber = "";
}
// Pricing
// Only fire for completed orders?
const lineItems = order.lineItems ?? {};
const combinedLineItems = [
...(lineItems.glassParts ?? []),
...(lineItems.supportingItems ?? []),
...(lineItems.vaps ?? []),
...(lineItems.promos ?? []),
];
const subtotal = baseMixin.methods
.getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, false)
.toFixed(2);
// // get correct zip code
const providerZip = order.serviceLocation.provider.address.zipCode;
const serviceZip =
order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE
? order.serviceLocation.zipCode
: providerZip;
const isPricingAvailable =
combinedLineItems.length > 0 &&
combinedLineItems.every(
(lineItem) =>
isDefined(lineItem.kitPrice) &&
isDefined(lineItem.laborAmount) &&
isDefined(lineItem.sellingPrice)
);
const isTaxAvailable =
isPricingAvailable &&
combinedLineItems.every((lineItem) => isDefined(lineItem.salesTax));
// // calculate total
const total = baseMixin.methods
.getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, true)
.toFixed(2);
if (isPricingAvailable) {
const subtotal = baseMixin.methods
.getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, false)
.toFixed(2);
const payload = {
serviceZipCode: serviceZip,
damageType: order.damage.isRepair ? "repair" : "replace",
accountType: order.payment.isInsurance ? "insurance" : "cash",
promoCodes: promoString,
vehicleYear: `${order.vehicle.year}`,
vehicleMake: order.vehicle.make,
vehicleModel: order.vehicle.model,
vehicleStyle: order.vehicle.style,
glassToReplace: glassString,
workOrderId: parseInt(order.workOrderId),
providerCtu: parseInt(order.serviceLocation.zipCodeCtu),
orderNumber: order.workOrderNumber,
priceTotal: parseFloat(total),
priceSubTotal: parseFloat(subtotal),
isRecalibrationOnOrder: store.getters.isRecalibrationOnSubmittedOrder,
appointmentType: order.serviceLocation.appointmentType,
};
payload.priceSubTotal = parseFloat(subtotal);
} else {
payload.priceSubTotal = "";
}
if (isTaxAvailable) {
const total = baseMixin.methods
.getTotalPriceOfAllLineItemsAndChildParts(combinedLineItems, true)
.toFixed(2);
payload.priceTotal = parseFloat(total);
} else {
payload.priceTotal = "";
}
// Recalibration
if (hasSubmittedOrder) {
payload.isRecalibrationOnOrder = store.getters.isRecalibrationOnSubmittedOrder;
} else {
payload.isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder;
}
// Appointment Type
if (isDefined(order.serviceLocation.appointmentType)) {
payload.appointmentType = order.serviceLocation.appointmentType;
} else {
payload.appointmentType = "";
}
// push to data layer.
pushToDataLayerIfDefined(payload);
},

View file

@ -38,27 +38,38 @@ const parts = {
requiresRecalibration: true,
salesTax: 63.86,
sellingPrice: 791.46,
kitPrice: 0,
laborAmount: 0,
},
frontWipers: {
name: "front wipers",
partNumber: "SBB16",
description: "SAFELITE BEAM BLADE 16",
partType: "FRONT WIPER",
price: 32.64,
sellingPrice: 32.64,
kitPrice: 0,
laborAmount: 0,
salesTax: 1,
},
rearWipers: {
name: "rear wipers",
partNumber: "SBBR12A",
description: "SAFELITE REAR BLADE 12A",
partType: "REAR WIPER",
price: 24.48,
sellingPrice: 24.48,
kitPrice: 0,
laborAmount: 0,
salesTax: 2,
},
rainDefense: {
name: "rain defense",
partNumber: "RAIN DEFENSE",
description: null,
partType: "RAIN DEFENSE",
price: 35.5,
sellingPrice: 35.5,
kitPrice: 0,
laborAmount: 0,
salesTax: 0,
},
};
@ -324,10 +335,10 @@ describe("analyticsMixin.js", () => {
]);
});
describe("pushSubmittedOrderToDataLayer", () => {
describe("pushOrderToDataLayer", () => {
beforeEach(() => {
store.getters.hasSubmittedOrder = true;
store.getters.submittedOrder = {
store.getters.hasSubmittedOrder = false;
store.getters.order = {
vehicle: {
year: "2020",
make: "acura",
@ -342,8 +353,8 @@ describe("analyticsMixin.js", () => {
address2: "add2",
city: "city",
state: "state",
zipCode: "zip",
zipCodeCtu: "zipCtu",
zipCode: "11111",
zipCodeCtu: "11110",
appointmentType: "IN_SHOP",
isVehicleProtected: true,
provider: {
@ -352,8 +363,8 @@ describe("analyticsMixin.js", () => {
streetAddress: "add3",
city: "city2",
state: "state2",
zipCode: "zip2",
zipCodeCtu: "zipCtu2",
zipCode: "22222",
zipCodeCtu: "22220",
},
},
techNotes: "",
@ -368,13 +379,28 @@ describe("analyticsMixin.js", () => {
damage: {
isRepair: false,
numberOfChips: null,
glassToReplace: [{ glassName: "single", location: "windshield" }],
glassToReplace: [{ glassName: "single", glassLocation: "windshield" }],
},
lineItems: {
glassParts: [parts.windshield],
supportingItems: [],
vaps: [parts.frontWipers],
promos: [{ promoCode: "promoTEST" }, { promoCode: "promoTEST2" }],
promos: [
{
promoCode: "promoTEST",
kitPrice: 0,
sellingPrice: 0,
laborAmount: 0,
salesTax: 0,
},
{
promoCode: "promoTEST2",
kitPrice: 0,
sellingPrice: 0,
laborAmount: 0,
salesTax: 0,
},
],
},
payment: {
isInsurance: false,
@ -397,6 +423,8 @@ describe("analyticsMixin.js", () => {
workOrderNumber: "01820-111111",
workOrderId: "222222222222",
};
store.getters.isRecalibrationOnOrder = true;
store.getters.isRecalibrationOnSubmittedOrder = false;
});
test("Pushes to data layer if nominal", () => {
@ -408,28 +436,203 @@ describe("analyticsMixin.js", () => {
};
// Act
analyticsMixin.methods.pushSubmittedOrderToDataLayer();
analyticsMixin.methods.pushOrderToDataLayer();
// Assert
expect(mockDataLayerFn).toHaveBeenCalled();
});
test("Does not push to data layer if no submitted order available.", () => {
test("Pushes populated data to data layer", () => {
// Arrange
store.getters.hasSubmittedOrder = false;
store.getters.submittedOrder = undefined;
window.dataLayer = [];
const mockDataLayerFn = jest.fn();
// Act
analyticsMixin.methods.pushOrderToDataLayer();
window.dataLayer = {
push: mockDataLayerFn,
const result = window.dataLayer[0];
// Assert
console.log(result);
const allFieldsPopulated = Object.keys(result).every(
(key) => result[key] === false || !!result[key]
);
expect(allFieldsPopulated).toBe(true);
});
test("Pushes correct data when submitted order is present", () => {
// Arrange
window.dataLayer = [];
store.getters.hasSubmittedOrder = true;
store.getters.submittedOrder = store.getters.order;
store.getters.order = {
vehicle: {
year: null,
make: null,
model: null,
style: null,
carId: null,
category: null,
vin: null,
},
serviceLocation: {
address: null,
address2: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
appointmentType: null,
isVehicleProtected: null,
provider: {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
},
},
techNotes: null,
},
customer: {
firstName: null,
lastName: null,
emailAddress: null,
phoneNumber: null,
isSmsOptIn: null,
},
damage: {
isRepair: null,
numberOfChips: null,
glassToReplace: null,
},
lineItems: {
glassParts: null,
supportingItems: null,
vaps: null,
promos: null,
},
payment: {
isInsurance: null,
insuranceCoverage: {
isVerified: null,
coverageStatus: null,
coverageVerificationType: null,
},
isPia: null,
piaType: null,
inactivePromos: null,
},
schedule: {
date: null,
startTime: null,
endTime: null,
jobMinMinutes: null,
jobMaxMinutes: null,
},
workOrderNumber: null,
workOrderId: null,
};
// Act
analyticsMixin.methods.pushSubmittedOrderToDataLayer();
analyticsMixin.methods.pushOrderToDataLayer();
const result = window.dataLayer[0];
// Assert
expect(mockDataLayerFn).not.toHaveBeenCalled();
console.log(result);
const allFieldsPopulated = Object.keys(result).every(
(key) => result[key] === false || !!result[key]
);
expect(allFieldsPopulated).toBe(true);
});
test("Pushes default values when data is missing", () => {
// Arrange
window.dataLayer = [];
store.getters.order = {
vehicle: {
year: null,
make: null,
model: null,
style: null,
carId: null,
category: null,
vin: null,
},
serviceLocation: {
address: null,
address2: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
appointmentType: null,
isVehicleProtected: null,
provider: {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
},
},
techNotes: null,
},
customer: {
firstName: null,
lastName: null,
emailAddress: null,
phoneNumber: null,
isSmsOptIn: null,
},
damage: {
isRepair: null,
numberOfChips: null,
glassToReplace: null,
},
lineItems: {
glassParts: null,
supportingItems: null,
vaps: null,
promos: null,
},
payment: {
isInsurance: null,
insuranceCoverage: {
isVerified: null,
coverageStatus: null,
coverageVerificationType: null,
},
isPia: null,
piaType: null,
inactivePromos: null,
},
schedule: {
date: null,
startTime: null,
endTime: null,
jobMinMinutes: null,
jobMaxMinutes: null,
},
workOrderNumber: null,
workOrderId: null,
};
// Act
analyticsMixin.methods.pushOrderToDataLayer();
const result = window.dataLayer[0];
// Assert
console.log(result);
const allFieldsPopulatedOrDefault = Object.keys(result).every(
(key) => result[key] === false || !!result[key] || result[key] === ""
);
expect(allFieldsPopulatedOrDefault).toBe(true);
});
test("Glass and Promo strings correctly formatted", () => {
@ -437,7 +640,7 @@ describe("analyticsMixin.js", () => {
window.dataLayer = [];
// Act
analyticsMixin.methods.pushSubmittedOrderToDataLayer();
analyticsMixin.methods.pushOrderToDataLayer();
// Assert
const glassString = window.dataLayer[0].glassToReplace;

View file

@ -2,8 +2,20 @@ import { storeActions } from "@/constants/store-actions.js";
import store from "@/store";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import { experimentSettings } from "@/constants/experiments";
export default {
computed: {
IsEmailOptional() {
const emailOptional = this.getSettingValue(experimentSettings.IS_EMAIL_OPTIONAL);
return emailOptional === "true";
},
EmailValidationRules() {
return this.IsEmailOptional
? "email-address-format"
: "email-address-required|email-address-format";
},
},
methods: {
async navigateForwardWithSingleCarMatch() {
const pageName = this.$options?.name;

View file

@ -32,8 +32,14 @@ import analyticsMixin from "@/mixins/analytics-mixin";
import { experimentTriggers } from "../constants/experiments";
import { applicationConfig } from "../constants/application-config";
import { shouldStripPromoQueryString } from "@/helpers/promotions-helper";
import bailout from "@/layouts/bailout/bailout";
const routes = [
{
path: "/bailout",
name: "bailout",
component: bailout,
},
{
path: "/",
name: "root",
@ -225,6 +231,9 @@ router.afterEach(async (to, from) => {
// Push experiments to Data Layer
analyticsMixin.methods.pushExperimentsToDataLayer();
// Push current order status to Data Layer
analyticsMixin.methods.pushOrderToDataLayer();
});
router.navigateWithoutSaving = (
@ -326,6 +335,7 @@ async function navigate(
const hasZip = getQuerystringParameter(queryStrings.ZIP_CODE);
const zip = getQuerystringParameter(queryStrings.ZIP_CODE);
const promo = getQuerystringParameter(queryStrings.PROMO);
const pageError = getQuerystringParameter(queryStrings.PAGE_ERROR);
if (
hasZip &&
@ -341,6 +351,10 @@ async function navigate(
queryStringsObject[queryStrings.PROMO] = promo;
}
if (pageError) {
queryStringsObject[queryStrings.PAGE_ERROR] = pageError;
}
router.push({
name: "root",
query: Object.assign(optionalQuery, queryStringsObject),
@ -433,7 +447,33 @@ async function DisplayPageError() {
);
baseMixin.methods.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
location.reload();
const pageError = getQuerystringParameter(queryStrings.PAGE_ERROR);
// we use the pageError querystring as a counter to how many times a user has experienced an error and been routed here.
// once they receive more than 1 pageerrors, we'll reset their state to hopefully correct any issues they may be having.
if (pageError) {
var errorCount = Number(pageError);
if (errorCount > 1) {
deleteFunnelCookie();
await baseMixin.methods.dispatchStoreAction(storeActions.RESET_STATE);
router.push({
path: "/",
query: { fmgPage: "vehicle" },
});
} else {
errorCount++;
router.push({
path: "/",
query: { fmgPage: "vehicle", pageError: errorCount },
});
}
} else {
router.push({
path: "/",
query: { fmgPage: "vehicle", pageError: "1" },
});
}
}
function isExistingFmgPageName(pageName) {

View file

@ -11,6 +11,7 @@ const fmgPageValues = {
ESTIMATE: "estimate",
ADDRESS_VEHICLES: "address-vehicles",
QUOTE: "quote",
INSURANCE: "insurance",
SERVICE_LOCATION: "service-location",
HERITAGE: "heritage",
SCHEDULE: "schedule",

View file

@ -11,6 +11,7 @@ const navigationScenarios = {
CLICKED_BACK: "CLICKED_BACK",
CLICKED_FORWARD: "CLICKED_FORWARD",
CLICKED_FORWARD_WITH_CASH: "CLICKED_FORWARD_WITH_CASH",
CLICKED_FORWARD_WITH_INSURANCE: "CLICKED_FORWARD_WITH_INSURANCE",
// Vin selection
CLICKED_BACK_WITH_VIN: "CLICKED_BACK_WITH_VIN",

View file

@ -389,6 +389,23 @@ const routingTable = function (store) {
scenario: navigationScenarios.CLICKED_FORWARD_WITH_CASH,
destinationFmgPageValue: fmgPageValues.SERVICE_LOCATION,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_INSURANCE,
destinationFmgPageValue: fmgPageValues.INSURANCE,
},
],
},
{
fmgPageValue: fmgPageValues.INSURANCE,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.QUOTE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.SCHEDULE,
},
],
},
{

View file

@ -2366,6 +2366,9 @@ export const actions = {
) {
const order = context.getters.order;
lineItemsToUse = lineItemsToUse ?? order.lineItems;
addGuidToLineItemsIfNotAlreadyThere(lineItemsToUse.vaps);
syncLineItemIds(addableVaps, lineItemsToUse.vaps);
// addableVaps still won't have IDs if they weren't already in lineItemsToUse
addGuidToLineItemsIfNotAlreadyThere(addableVaps);
const requestObject = {
promoCode: promoCode,
@ -2884,12 +2887,18 @@ function syncLineItemIds(lineItemsWithoutIds, lineItemsWithIds) {
if (!lineItemsWithIds || !lineItemsWithoutIds) {
return;
}
var clonedLineItemsWithIds = deepClone(lineItemsWithIds);
lineItemsWithoutIds.forEach((noId) => {
lineItemsWithIds.forEach((withId) => {
var matchedLineItemIndex = -1;
clonedLineItemsWithIds.forEach((withId, index) => {
if (noId.partNumber === withId.partNumber && noId.partType === withId.partType) {
matchedLineItemIndex = index;
noId.id = withId.id;
}
});
if (matchedLineItemIndex > -1) {
clonedLineItemsWithIds.splice(matchedLineItemIndex, 1);
}
});
}

View file

@ -2929,11 +2929,135 @@ describe("Actions", () => {
});
});
describe("validateOrderPromoAndSaveServerData", () => {
it("should add GUIDs if not there to provided lineItemsToUse.vaps before sending the http call", async () => {
// Arrange
const context = state;
const promoCode = "testPromo";
const lineItemsToUse = { vaps: [{ partNumber: 1 }], promos: [2] };
const addableVaps = [{ partNumber: "addableVap" }];
context["getters"] = {
order: {
serviceLocation: {
appointmentType: "test",
state: "test",
zipCodeCtu: "test",
},
vehicle: {
carId: "test",
year: "test",
},
referralCorrelationId: "test",
eon: "test",
damage: {
isRepair: true,
glassToReplace: null,
},
payment: {
parentAccountNumber: "test",
},
referralSequenceNumber: "test",
lineItems: {
serverData: "test",
},
},
};
globalMethods.callHttpClient = jest.fn().mockResolvedValue({
data: {},
});
crypto.randomUUID = jest.fn(() => "GUID");
// Act
actions.validateOrderPromoAndSaveServerData(context, {
payload: {
promoCode: promoCode,
lineItemsToUse: lineItemsToUse,
addableVaps: addableVaps,
},
pageNameToLog: "test",
});
const firstCallArgs = globalMethods.callHttpClient.mock.calls[0];
// Assert
const vapsItem = firstCallArgs[0].payload.order.lineItemsOnOrder.filter(
(item) => item.partNumber == 1
)[0];
expect(vapsItem.id).toEqual("GUID");
});
it("should not duplicate ids during syncing if there are two identical vaps items", async () => {
// Arrange
const context = state;
const promoCode = "testPromo";
// AddableVaps will sync its ids to vaps already on the order
const lineItemsToUse = {
vaps: [
{ partNumber: "SBB22", id: "GUID1" },
{ partNumber: "SBB22", id: "GUID2" },
],
promos: [2],
};
const addableVaps = [{ partNumber: "SBB22" }, { partNumber: "SBB22" }];
context["getters"] = {
order: {
serviceLocation: {
appointmentType: "test",
state: "test",
zipCodeCtu: "test",
},
vehicle: {
carId: "test",
year: "test",
},
referralCorrelationId: "test",
eon: "test",
damage: {
isRepair: true,
glassToReplace: null,
},
payment: {
parentAccountNumber: "test",
},
referralSequenceNumber: "test",
lineItems: {
serverData: "test",
},
},
};
globalMethods.callHttpClient = jest.fn().mockResolvedValue({
data: {},
});
crypto.randomUUID = jest.fn(() => "GUID");
// Act
actions.validateOrderPromoAndSaveServerData(context, {
payload: {
promoCode: promoCode,
lineItemsToUse: lineItemsToUse,
addableVaps: addableVaps,
},
pageNameToLog: "test",
});
const firstCallArgs = globalMethods.callHttpClient.mock.calls[0];
// Assert
const vapsItems = firstCallArgs[0].payload.addableVaps.filter(
(item) => item.partNumber == "SBB22"
);
expect(vapsItems[1].id).toEqual("GUID1");
expect(vapsItems[0].id).toEqual("GUID2");
});
it("should add GUIDs if not there to provided addableVaps before sending the http call", async () => {
// Arrange
const context = state;
const promoCode = "testPromo";
const lineItemsToUse = { vaps: [1], promos: [2] };
const lineItemsToUse = { vaps: [{ partNumber: 1 }], promos: [2] };
const addableVaps = [{ partNumber: "addableVap" }];
context["getters"] = {
@ -3013,7 +3137,7 @@ describe("Actions", () => {
},
referralSequenceNumber: "test",
lineItems: {
vaps: [1],
vaps: [{ partNumber: 1 }],
promos: [2],
serverData: "test",
},
@ -3026,7 +3150,7 @@ describe("Actions", () => {
crypto.randomUUID = jest.fn(() => "GUID");
const expectedLineItemsOnOrder = [1, 2];
const expectedLineItemsOnOrder = [{ partNumber: 1, id: "GUID" }, 2];
// Act
actions.validateOrderPromoAndSaveServerData(context, {
@ -3073,7 +3197,7 @@ describe("Actions", () => {
},
referralSequenceNumber: "test",
lineItems: {
vaps: [1],
vaps: [{ partNumber: 1 }],
promos: [2],
serverData: "test",
},
@ -3131,7 +3255,7 @@ describe("Actions", () => {
},
referralSequenceNumber: "test",
lineItems: {
vaps: [1],
vaps: [{ partNumber: 1 }],
promos: [2],
serverData: "test",
},