Merge branch 'develop' into feature/CSR-1384-modals-for-add-vaps

This commit is contained in:
Adam Caouette 2023-11-06 13:47:20 -05:00
commit ab81351bd5
27 changed files with 1105 additions and 158 deletions

18
package-lock.json generated
View file

@ -14,6 +14,7 @@
"canvas-confetti": "^1.4.0",
"core-js": "^3.6.5",
"http-status-codes": "^2.1.4",
"iframe-resizer": "^4.3.7",
"jest-junit": "^13.0.0",
"maska": "^1.5.0",
"vee-validate": "^4.5.7",
@ -11813,6 +11814,18 @@
"integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=",
"dev": true
},
"node_modules/iframe-resizer": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.7.tgz",
"integrity": "sha512-a3EGVScU9NtUpj6lWvGhVw3EfOw5AopRs5xGsQU385kWdgQt++OsD6PCnTV+8YkgBu/g28rLIh0EztFg9UQr1Q==",
"engines": {
"node": ">=0.8.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/davidjbradshaw/iframe-resizer/blob/master/FUNDING.md"
}
},
"node_modules/ignore": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
@ -33845,6 +33858,11 @@
"integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=",
"dev": true
},
"iframe-resizer": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.3.7.tgz",
"integrity": "sha512-a3EGVScU9NtUpj6lWvGhVw3EfOw5AopRs5xGsQU385kWdgQt++OsD6PCnTV+8YkgBu/g28rLIh0EztFg9UQr1Q=="
},
"ignore": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",

View file

@ -17,6 +17,7 @@
"canvas-confetti": "^1.4.0",
"core-js": "^3.6.5",
"http-status-codes": "^2.1.4",
"iframe-resizer": "^4.3.7",
"jest-junit": "^13.0.0",
"maska": "^1.5.0",
"vee-validate": "^4.5.7",

View file

@ -75,14 +75,11 @@ body .creditCardSpecific div {
padding: 8px 0;
}
body .creditCardSpecific div .headerlinecontainer {
padding: 0;
display: none;
}
body .creditCardSpecific div label {
font-weight: 500;
}
body .creditCardSpecific div:first-child {
display: none;
}
body .creditCardSpecific #infoRow2 {
margin-bottom: 0;
}

View file

@ -88,14 +88,11 @@ body {
div {
padding: 8px 0;
.headerlinecontainer {
padding: 0;
display: none;
}
label {
font-weight: 500;
}
&:first-child {
display: none;
}
}
#infoRow2 {
margin-bottom: 0;

View file

@ -5,15 +5,33 @@
<component :is="Component" @focusin="handleAnyComponentFocus" />
</transition>
</router-view>
<loadingModal :showLoader="shouldShowLoader" :showTextCarousel="shouldShowTextCarousel" />
</template>
<script>
import { handleAnyComponentFocus } from "@/helpers/button-question-focus-helper";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
export default {
name: "app",
setup() {
const { globalNonpersistedState } = showFmgLoadingModal();
return { globalNonpersistedState };
},
methods: {
handleAnyComponentFocus: handleAnyComponentFocus,
},
computed: {
shouldShowLoader() {
return this.globalNonpersistedState.showLoadingModal;
},
shouldShowTextCarousel() {
return this.globalNonpersistedState.showTextCarousel;
},
},
components: {
loadingModal,
},
};
</script>

View file

@ -7,6 +7,7 @@ const cartItemTypes = {
RECYCLE_FEE: "RECYCLE FEE",
MOBILE_FEE: "MOBILE FEE",
PROMOS: "PROMOS",
EARLY_BIRD: "EARLY BIRD",
};
export { cartItemTypes };

View file

@ -5,6 +5,8 @@ const partTypeStrings = {
RECALIBRATION: "RECALIBRATION",
RECYCLE_FEE: "RECYCLE FEE",
MOBILE_FEE: "MOBILE FEE",
REPAIR_FEE: "REPAIR FEE",
EARLY_BIRD: "EARLY BIRD",
};
export { partTypeStrings };

View file

@ -52,7 +52,7 @@ const storeActions = {
RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise",
GET_SIGNATURE: "getSignature",
VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA: "validateOrderPromoAndSaveServerData",
REVALIDATE_ORDER_PROMOS_AND_UPDATE_STORE: "revalidateOrderPromosAndUpdateStore",
REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA: "revalidateOrderPromosAndSaveServerData",
// DEPENDENCY MUTATIONS
RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies",
@ -87,10 +87,14 @@ const storeActions = {
"saveSupportingItemsSuppressingStateResetting",
SAVE_VAPS: "saveVaps",
SAVE_PROMOS: "savePromos",
SAVE_INACTIVE_PROMOS: "saveInactivePromos",
SAVE_CUSTOMER_DETAILS: "saveCustomerDetails",
SAVE_SERVICE_LOCATION_TECH_NOTES: "saveServiceLocationTechNotes",
SAVE_CCTOKEN: "saveCCToken",
SAVE_PAYPAL_TOKEN: "savePaypalToken",
CREATE_SUBMITTED_ORDER: "createSubmittedOrder",
RESET_SUBMITTED_ORDER: "resetSubmittedOrder",
};
export { storeActions };

View file

@ -255,7 +255,7 @@ export default {
buttonFooterCopy: answer.buttonFooterCopy ?? answer.buttonFooterCopy,
buttonImage: answer.buttonImage ?? answer.AnswerImageUrl,
buttonImageId: answer.buttonImageId ?? answer.ImageId,
additionalButtonData: answer.additionalButtonData ?? answer.additionalButtonData,
additionalButtonData: answer.additionalButtonData,
groupName: this.formatString(this.groupName),
value:
answer.value ??

View file

@ -0,0 +1,369 @@
// Components
import cart from "@/fmg-components/cart/cart.vue";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
describe("cart.vue", () => {
describe("cartItem computeds", () => {
test("if there are one or more front wipers on the order, a front wiper cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [
{
cartItemType: "FRONT WIPERS",
description: 'WIPER BLADE STANDARD 18"',
kitPrice: 0,
laborAmount: 0,
partNumber: "WB18",
partType: "FRONT WIPER",
salesTax: 2.44,
sellingPrice: 32.49,
},
{
cartItemType: "FRONT WIPERS",
description: 'WIPER BLADE STANDARD 26"',
kitPrice: 0,
laborAmount: 0,
partNumber: "WB26",
partType: "FRONT WIPER",
salesTax: 2.44,
sellingPrice: 32.49,
},
],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.frontWipersCartItem).not.toBeNull();
expect(wrapper.vm.frontWipersCartItem.subTotal).toEqual(64.98);
expect(wrapper.vm.frontWipersCartItem.salesTax).toEqual(4.88);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.frontWipersCartItem
) >= 0;
expect(found).toBe(true);
});
test("if there are one or more rear wipers on the order, a rear wiper cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [
{
cartItemType: "REAR WIPERS",
description: 'REAR WIPER BLADE STANDARD 18"',
kitPrice: 0,
laborAmount: 0,
partNumber: "EWB18",
partType: "REAR WIPER",
salesTax: 2.44,
sellingPrice: 32.49,
},
{
cartItemType: "REAR WIPERS",
description: 'REAR WIPER BLADE STANDARD 26"',
kitPrice: 0,
laborAmount: 0,
partNumber: "RWB26",
partType: "REAR WIPER",
salesTax: 2.44,
sellingPrice: 32.49,
},
],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.rearWipersCartItem).not.toBeNull();
expect(wrapper.vm.rearWipersCartItem.subTotal).toEqual(64.98);
expect(wrapper.vm.rearWipersCartItem.salesTax).toEqual(4.88);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.rearWipersCartItem
) >= 0;
expect(found).toBe(true);
});
test("if there is Rain Defense on the order, a rain defense cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [],
vaps: [
{
cartItemType: "RAIN DEFENSE",
description: null,
kitPrice: 0,
laborAmount: 0,
listPrice: "44.99",
partNumber: "RAIN DEFENSE",
partType: "RAIN DEFENSE",
salesTax: 3.37,
sellingPrice: 44.99,
},
],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.rainDefenseCartItem).not.toBeNull();
expect(wrapper.vm.rainDefenseCartItem.subTotal).toEqual(44.99);
expect(wrapper.vm.rainDefenseCartItem.salesTax).toEqual(3.37);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.rainDefenseCartItem
) >= 0;
expect(found).toBe(true);
});
// test("if there are glass parts on the order, a glass part cart item should be added to the cart but should not be displayed", () => {
// // Arrange
// const windshieldWithChildParts = {
// canSafeliteRecalibrate: true,
// childParts: [
// {
// kitPrice: 0,
// laborAmount: 23.55,
// partNumber: "GGG FW4896",
// salesTax: 1.77,
// sellingPrice: 0,
// },
// ],
// color: "Green Tint",
// description:
// "solar, soundproofing, lane keep assist, lane departure warning system, w/adaptive cruise control",
// id: "db22fd44-10dd-456f-979b-ff88cf68cca6",
// kitPrice: 0,
// laborAmount: 60,
// partNumber: "FW04896GTYN",
// partType: "WINDSHIELD",
// recalibrationType: "STATIC",
// requiresCapabilityQuestions: false,
// requiresRecalibration: true,
// salesTax: 63.86,
// sellingPrice: 791.46,
// };
// const lineItems = {
// glassParts: [windshieldWithChildParts],
// supportingItems: [],
// vaps: [],
// promos: [],
// };
// const availableLineItems = [];
// // Act
// const { wrapper } = setupMocks({
// props: {
// modelValue: lineItems,
// availableLineItems: availableLineItems,
// },
// });
// // Assert
// expect(wrapper.vm.glassPartCartItems[0]).not.toBeNull();
// expect(wrapper.vm.glassPartCartItems[0].subTotal).toEqual(875.01);
// expect(wrapper.vm.glassPartCartItems[0].salesTax).toEqual(65.63);
// expect(wrapper.vm.glassPartCartItems[0].isDisplayed).toEqual(false);
// const found =
// wrapper.vm.cartItems.findIndex(
// (cartItem) => cartItem == wrapper.vm.glassPartsCartItem
// ) >= 0;
// expect(found).toBe(true);
// });
// Recycle Fee Cart Item
test("if there is recycling fee on the order, a recycle fee cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [
{
description: null,
id: "9ff98501-03a4-432b-884e-e4e28f884a2f",
kitPrice: 0,
laborAmount: 34.98,
partNumber: "RECYCLE FEE",
partType: "REPLACE FEE",
salesTax: 2.62,
sellingPrice: 0,
},
],
vaps: [],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.recycleFeeCartItem).not.toBeNull();
expect(wrapper.vm.recycleFeeCartItem.subTotal).toEqual(34.98);
expect(wrapper.vm.recycleFeeCartItem.salesTax).toEqual(2.62);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.recycleFeeCartItem
) >= 0;
expect(found).toBe(true);
});
// Mobile Fee Cart Item
test("if there is mobile fee on the order, a mobile fee cart item should be added to the cart", () => {
// Arrange
const lineItems = {
glassParts: [],
supportingItems: [
{
description: null,
id: "9ff98501-03a4-432b-884e-e4e28f884a2f",
kitPrice: 0,
laborAmount: 34.98,
partNumber: "MOBILE FEE",
partType: "MOBILE FEE",
salesTax: 2.62,
sellingPrice: 0,
},
],
vaps: [],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.mobileFeeCartItem).not.toBeNull();
expect(wrapper.vm.mobileFeeCartItem.subTotal).toEqual(34.98);
expect(wrapper.vm.mobileFeeCartItem.salesTax).toEqual(2.62);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.mobileFeeCartItem
) >= 0;
expect(found).toBe(true);
});
// Other Supporting Items Cart Item
test("if there are other supporting items on the order, an other supporting items cart item should be added to the cart but should not be displayed", () => {
// Arrange
const recalibrationLineItem = {
cartItemType: "SUPPORTING ITEMS",
description: null,
id: "8ab6ef9b-8620-430a-8a92-29f7d4bb8b00",
kitPrice: 0,
laborAmount: 450,
partNumber: "RECAL STATIC",
partType: "RECALIBRATION",
salesTax: 33.75,
sellingPrice: 0,
};
const lineItems = {
glassParts: [],
supportingItems: [recalibrationLineItem],
vaps: [],
promos: [],
};
const availableLineItems = [];
// Act
const { wrapper } = setupMocks({
props: {
modelValue: lineItems,
availableLineItems: availableLineItems,
},
});
// Assert
expect(wrapper.vm.otherSupportingItemsCartItem).not.toBeNull();
expect(wrapper.vm.otherSupportingItemsCartItem.subTotal).toEqual(450);
expect(wrapper.vm.otherSupportingItemsCartItem.salesTax).toEqual(33.75);
expect(wrapper.vm.otherSupportingItemsCartItem.isDisplayed).toEqual(false);
const found =
wrapper.vm.cartItems.findIndex(
(cartItem) => cartItem == wrapper.vm.otherSupportingItemsCartItem
) >= 0;
expect(found).toBe(true);
});
});
});
function setupMocks({ options, props }) {
const mountOptions = getMountOptions({
...options,
});
const mockBaseMixin = {
methods: {
getTierOnePackagePrice: jest.fn(),
filterOutFees: jest.fn(),
getCmsContent: jest.fn(),
},
};
if (props) mountOptions.propsData = props;
mountOptions.global.mixins = [mockBaseMixin];
const wrapper = shallowMount(cart, mountOptions);
return { wrapper };
}

View file

@ -33,7 +33,10 @@
<!-- Nonpackaged Cart Items -->
<hr style="background: red" />
<div v-for="(cartItem, i) in nonpackageCartItems" :key="i">
<div
class="premium-appt-cart-item"
v-for="(cartItem, i) in nonpackageCartItems"
:key="i">
<span>{{ cartItem.name }}</span>
<textLink
ref="removeLink"
@ -46,14 +49,37 @@
<!-- Fees -->
<div v-if="recycleFeeCartItem">
<span>{{ recycleFeeCartItem.name }}</span
><span>{{ currencyFormatter.format(recycleFeeCartItem.subTotal) }}</span>
<textLink
linkType="text"
:text="recycleFeeCartItem.name"
href="#!"
@click-event="openModal(recyclingModal)" />
<span>{{ currencyFormatter.format(recycleFeeCartItem.subTotal) }}</span>
</div>
<div v-if="mobileFeeCartItem">
<span>{{ mobileFeeCartItem.name }}</span
><span>{{ currencyFormatter.format(mobileFeeCartItem.subTotal) }}</span>
</div>
<div v-if="premiumAppointmentDiscountCartItem">
<span>{{ premiumAppointmentDiscountCartItem.name }}</span>
<textLink
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="#!"
@click-event="
removeItem(
premiumAppointmentDiscountCartItem.cartItemType,
'supportingItems'
)
" />
<span>{{
currencyFormatter.format(premiumAppointmentDiscountCartItem.subTotal)
}}</span>
</div>
<!-- promos here -->
<!-- Sub total, sales tax, total columns -->
<div class="sub-total">
<span>{{ subtotalText }}</span
@ -73,13 +99,18 @@
</template>
<script>
import baseMixin from "@/mixins/base-mixin.js";
import { getHighestFullySatisfiedTier, getPackageContents } from "@/helpers/service-package-helper";
// Components
import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block";
import { LineItemUtilities } from "@/store";
// Mixins
import baseMixin from "@/mixins/base-mixin.js";
// Helpers
import { deepClone } from "@/helpers/object-helper";
import { getHighestFullySatisfiedTier, getPackageContents } from "@/helpers/service-package-helper";
// Constants
import { partTypeStrings } from "@/constants/part-type-strings";
import { cartItemCategories } from "@/constants/cart-item-categories";
import { cartItemTypes } from "@/constants/cart-item-types";
@ -101,7 +132,6 @@ export default {
currency: "USD",
}),
lineItems: deepClone(this.modelValue),
lineItemUtilities: new LineItemUtilities(),
};
},
methods: {
@ -114,7 +144,7 @@ export default {
let subTotal = 0;
vapsItems.forEach((item) => {
subTotal += item.subTotal; // baseMixin.methods.getTotalLineItemPrice(item);
subTotal += item.subTotal;
});
return subTotal;
@ -175,8 +205,10 @@ export default {
cartItems.push(this.rainDefenseCartItem);
}
if (this.glassPartsCartItem) {
cartItems.push(this.glassPartsCartItem);
if (this.glassPartCartItems) {
this.glassPartCartItems.forEach((glassPartCartItem) => {
cartItems.push(glassPartCartItem);
});
}
if (this.otherSupportingItemsCartItem) {
@ -210,6 +242,10 @@ export default {
cartItems.push(cartItem);
});
if (this.premiumAppointmentDiscountCartItem) {
cartItems.push(this.premiumAppointmentDiscountCartItem);
}
return cartItems;
},
},
@ -264,6 +300,10 @@ export default {
return this.damage.glassToReplace;
},
glassParts() {
const glassParts = this.lineItems?.glassParts;
return glassParts?.length > 0 ? glassParts : [];
},
vaps() {
const vaps = this.lineItems?.vaps;
return vaps?.length > 0 ? vaps : [];
@ -284,7 +324,11 @@ export default {
const nonpackageCartItems = [];
this.cartItems.forEach((cartItem) => {
if (cartItem == this.recycleFeeCartItem || cartItem == this.mobileFeeCartItem) {
if (
cartItem == this.recycleFeeCartItem ||
cartItem == this.mobileFeeCartItem ||
cartItem == this.premiumAppointmentDiscountCartItem
) {
return;
}
@ -403,12 +447,12 @@ export default {
return cartItem;
},
glassPartsCartItem() {
let cartItem = null;
glassPartCartItems() {
const glassPartCartItems = [];
if (this.lineItems?.glassParts?.length > 0) {
this.lineItems?.glassParts?.forEach((lineItem) => {
cartItem = {
if (this.glassParts.length > 0) {
this.glassParts.forEach((lineItem) => {
let glassPartCartItem = {
name: null,
category: cartItemCategories.GLASS_PARTS,
cartItemType: cartItemTypes.GLASS_PARTS,
@ -417,24 +461,27 @@ export default {
(lineItem.kitPrice ?? 0) +
(lineItem.laborAmount ?? 0) +
(lineItem.sellingPrice ?? 0),
salesTax: lineItem.salesTax ?? 0,
lineItems: [],
};
if (lineItem.childParts?.length > 0) {
lineItem.childParts.forEach((childPartLineItem) => {
cartItem.subTotal +=
glassPartCartItem.subTotal +=
(childPartLineItem.kitPrice ?? 0) +
(childPartLineItem.laborAmount ?? 0) +
(childPartLineItem.sellingPrice ?? 0);
cartItem.salesTax += childPartLineItem.salesTax;
glassPartCartItem.salesTax += childPartLineItem.salesTax;
});
}
glassPartCartItems.push(glassPartCartItem);
});
}
return cartItem;
return glassPartCartItems;
},
recycleFeeCartItemName() {
return this.getCmsContent("RecycleFeeTextWidget", "Text");
@ -457,8 +504,8 @@ export default {
lineItems: [],
};
//recycleFeeLineItem.cartItemType = cartItem.cartItemType;
//cartItem.lineItems.push(recycleFeeLineItem);
recycleFeeLineItem.cartItemType = cartItem.cartItemType;
cartItem.lineItems.push(recycleFeeLineItem);
cartItem.subTotal +=
(recycleFeeLineItem.kitPrice ?? 0) +
@ -477,7 +524,7 @@ export default {
let cartItem = null;
const mobileFeeLineItem = this.supportingItems.find(
(lineItem) => lineItem.partNumber == partTypeStrings.MOBILE_FEE
(lineItem) => lineItem.partType == partTypeStrings.MOBILE_FEE
);
if (mobileFeeLineItem) {
@ -507,10 +554,11 @@ export default {
otherSupportingItemsCartItem() {
let cartItem = null;
const otherSupportingItemsLineItems = this.supportingItems.filter(
(lineItem) =>
lineItem.partNumber != partTypeStrings.MOBILE_FEE &&
lineItem.partNumber != partTypeStrings.RECYCLE_FEE
let otherSupportingItemsLineItems = baseMixin.methods.filterOutFees(
this.supportingItems
);
otherSupportingItemsLineItems = otherSupportingItemsLineItems.filter(
(lineItem) => lineItem.partType != partTypeStrings.EARLY_BIRD
);
if (otherSupportingItemsLineItems.length > 0) {
@ -539,6 +587,40 @@ export default {
return cartItem;
},
premiumAppointmentDiscountCartItemName() {
return "Early bird appointment"; // TODO: get from CMS
},
premiumAppointmentDiscountCartItem() {
let cartItem = null;
const premiumAppointmentDiscountLineItem = this.supportingItems.find(
(lineItem) => lineItem.partType == partTypeStrings.EARLY_BIRD
);
if (premiumAppointmentDiscountLineItem) {
cartItem = {
name: this.premiumAppointmentDiscountCartItemName,
category: cartItemCategories.SUPPORTING_ITEMS,
cartItemType: cartItemTypes.EARLY_BIRD,
isDisplayed: true,
subTotal: 0,
salesTax: 0,
lineItems: [],
};
premiumAppointmentDiscountLineItem.cartItemType = cartItem.cartItemType;
cartItem.lineItems.push(premiumAppointmentDiscountLineItem);
cartItem.subTotal +=
(premiumAppointmentDiscountLineItem.kitPrice ?? 0) +
(premiumAppointmentDiscountLineItem.laborAmount ?? 0) +
(premiumAppointmentDiscountLineItem.sellingPrice ?? 0);
cartItem.salesTax += premiumAppointmentDiscountLineItem.salesTax ?? 0;
}
return cartItem;
},
removeLinkText() {
return this.getCmsContent("RemoveCartItemTextWidget", "Text");
},
@ -630,7 +712,7 @@ export default {
align-items: center;
span {
display: flex;
width: 33.33333%;
font-size: 0.875rem;
&:nth-child(2) {
justify-content: flex-end;
}
@ -660,6 +742,17 @@ export default {
padding: 0;
}
}
// Custom order/wrap for nonpackaged Cart Items
.premium-appt-cart-item {
flex-wrap: wrap;
span {
display: flex;
justify-content: space-between;
}
a {
order: 3;
}
}
}
.vin-toggle {
&:after {

View file

@ -1,5 +1,5 @@
<template>
<div v-if="isModalVisible">
<div v-if="showModalContent">
<div v-if="notFullScreen" class="dimmer-overlay"></div>
<div class="modal-loader" :class="layoutClass">
<div class="row g-2 h-100 d-flex align-items-center">
@ -11,7 +11,7 @@
</div>
</div>
</div>
<div class="row">
<div v-if="showTextCarouselContent" class="row">
<div class="col">
<div class="text-container slide">
<p>
@ -59,6 +59,7 @@ export default {
data() {
return {
isModalVisible: false,
isModalTextCarouselVisible: false,
};
},
props: {
@ -66,15 +67,30 @@ export default {
type: Boolean,
default: false,
},
showLoader: {
type: Boolean,
default: false,
},
showTextCarousel: {
type: Boolean,
default: false,
},
},
computed: {
layoutClass() {
return this.notFullScreen ? "card-layout" : "full-screen container-fluid";
},
showModalContent() {
return this.isModalVisible || this.showLoader;
},
showTextCarouselContent() {
return this.isModalTextCarouselVisible || this.showTextCarousel;
},
},
methods: {
showModal() {
showModal(showTextCarousel = true) {
//Display modal
this.isModalTextCarouselVisible = showTextCarousel;
this.isModalVisible = true;
//Force page reload on back button
window.addEventListener(

View file

@ -0,0 +1,18 @@
import { reactive } from "vue";
// state encapsulated and managed by the composable
const globalNonpersistedState = reactive({
showLoadingModal: true,
showTextCarousel: false,
});
export const showFmgLoadingModal = (newValue, showTextCarousel = false) => {
if (newValue !== undefined) {
globalNonpersistedState.showLoadingModal = newValue;
}
if (showTextCarousel !== undefined) {
globalNonpersistedState.showTextCarousel = showTextCarousel;
}
return { globalNonpersistedState };
};

View file

@ -37,6 +37,8 @@ export function getLineItemsThatMatchPromos(promos, availableLineItems) {
return matchingLineItems;
}
// Note: Addable VAPs for the Promo Validation endpoint can also be on the order already
// There is no need to only send un-added VAPs in this array.
export function getAddableVapsFromAvailableLineItems(availableLineItems) {
const addableVaps = [];
addableVaps.push(...findLineItemsWithPartType(partTypeStrings.FRONT_WIPER, availableLineItems));
@ -75,11 +77,22 @@ export async function revalidatePromosAndValidateNewPromo(
if (hasActivePromos || hasHasInactivePromos) {
revalidatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.REVALIDATE_ORDER_PROMOS_AND_UPDATE_STORE,
null,
storeActions.REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA,
{},
pageNameToLog,
false
);
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_PROMOS,
revalidatePromoResponse.promoLineItems,
false
);
const revalidationErrorPromoCodes = revalidatePromoResponse.errors.map((x) => x.promoCode);
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_INACTIVE_PROMOS,
revalidationErrorPromoCodes,
false
);
}
if (hasNewPromo) {
validatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(

View file

@ -19,6 +19,53 @@ beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
store.getters = {
submittedOrder: {
schedule: {
date: "2023-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: "000",
},
lineItems: {
glassParts: [
{
partNumber: "ABC123",
},
],
supportingItems: [],
},
serviceLocation: {
address: "test",
address2: "123",
city: "test",
state: "AZ",
appointmentType: "Inshop",
zipCode: "12345",
zipCodeCtu: "01234",
provider: {
providerNumber: "123",
address: {
streetAddress: "test1",
city: "test",
state: "AZ",
zipCode: "12345",
zipCodeCtu: "01234",
},
},
},
damage: {
isRepair: false,
},
referralNumber: "1234567",
vehicle: {
year: "2004",
make: "Ford",
model: "F Series F250",
},
customer: {
emailAddress: "test@test.com",
},
},
order: {
schedule: {
date: "2023-01-01",
@ -127,9 +174,10 @@ describe("computed properties...", () => {
});
test("ScheduleTimeFormatted should return mobile time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.MOBILE;
store.getters.submittedOrder.schedule.startTime = "09:00";
store.getters.submittedOrder.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
@ -140,9 +188,10 @@ describe("computed properties...", () => {
});
test("ScheduleTimeFormatted should return DROP_OFF time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.DROP_OFF;
store.getters.submittedOrder.schedule.startTime = "09:00";
store.getters.submittedOrder.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
@ -153,9 +202,10 @@ describe("computed properties...", () => {
});
test("ScheduleTimeFormatted should return Inshop time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.IN_SHOP;
store.getters.submittedOrder.schedule.startTime = "09:00";
store.getters.submittedOrder.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
@ -166,7 +216,8 @@ describe("computed properties...", () => {
});
test("AppointmentWordingText should return mobile text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.MOBILE;
const { wrapper } = setupMocks({});
// Act
@ -177,7 +228,8 @@ describe("computed properties...", () => {
});
test("AppointmentWordingText should return DROP_OFF text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.DROP_OFF;
const { wrapper } = setupMocks({});
@ -189,7 +241,8 @@ describe("computed properties...", () => {
});
test("AppointmentWordingText should return IN_SHOP text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
store.getters.submittedOrder.serviceLocation.appointmentType =
AppointmentTypeStrings.IN_SHOP;
const { wrapper } = setupMocks({});

View file

@ -57,6 +57,8 @@ import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import addToCalendar from "@/layouts/confirmation/add-to-calendar/add-to-calendar";
//Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { settleAllPromises } from "@/helpers/layout-helper";
@ -70,6 +72,8 @@ import { get12HourTimeFormat, get12HourTimeMobileFormat } from "@/helpers/date-h
export default {
name: "confirmation",
async beforeRouteEnter(to, from, next) {
await baseMixin.methods.dispatchStoreAction(storeActions.CREATE_SUBMITTED_ORDER);
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
@ -95,7 +99,7 @@ export default {
return this.getCmsContent("ScheduleConfirmationWidget", "Image");
},
CustomerPortalLoginToken() {
return store.getters.order.customerPortalLoginToken;
return store.getters.submittedOrder.customerPortalLoginToken;
},
ConfirmationEmailText() {
return this.getCmsContent("ConfirmationEmailWidget", "BodyText")
@ -105,16 +109,16 @@ export default {
?.replaceAll("&gt;", ">");
},
ScheduleDate() {
return store.getters.order.schedule.date;
return store.getters.submittedOrder.schedule.date;
},
AppointmentType() {
return store.getters.order.serviceLocation.appointmentType;
return store.getters.submittedOrder.serviceLocation.appointmentType;
},
ScheduleStartTime() {
return store.getters.order.schedule.startTime;
return store.getters.submittedOrder.schedule.startTime;
},
ScheduleEndTime() {
return store.getters.order.schedule.endTime;
return store.getters.submittedOrder.schedule.endTime;
},
InShopWordingText() {
return this.getCmsContent("InShopWordingWidget", "BodyText");
@ -126,31 +130,31 @@ export default {
return this.getCmsContent("DropOffWordingWidget", "BodyText");
},
ServiceLocationAddress() {
return store.getters.order.serviceLocation.address;
return store.getters.submittedOrder.serviceLocation.address;
},
ServiceLocationAddress2() {
return store.getters.order.serviceLocation.address2;
return store.getters.submittedOrder.serviceLocation.address2;
},
ServiceLocationCity() {
return store.getters.order.serviceLocation.city;
return store.getters.submittedOrder.serviceLocation.city;
},
ServiceLocationState() {
return store.getters.order.serviceLocation.state;
return store.getters.submittedOrder.serviceLocation.state;
},
ServiceLocationZipCode() {
return store.getters.order.serviceLocation.zipCode;
return store.getters.submittedOrder.serviceLocation.zipCode;
},
ProviderAddress() {
return store.getters.order.serviceLocation.provider.address.streetAddress;
return store.getters.submittedOrder.serviceLocation.provider.address.streetAddress;
},
ProviderCity() {
return store.getters.order.serviceLocation.provider.address.city;
return store.getters.submittedOrder.serviceLocation.provider.address.city;
},
ProviderState() {
return store.getters.order.serviceLocation.provider.address.state;
return store.getters.submittedOrder.serviceLocation.provider.address.state;
},
ProviderZipCode() {
return store.getters.order.serviceLocation.provider.address.zipCode;
return store.getters.submittedOrder.serviceLocation.provider.address.zipCode;
},
AppointmentWordingText() {
if (this.AppointmentType == AppointmentTypeStrings.MOBILE) {
@ -204,6 +208,10 @@ export default {
},
methods: {
arePagePrerequisitesValid() {
if (store.getters.hasSubmittedOrder) {
return true;
}
// Service Location
const serviceLocation = store.getters.order.serviceLocation;
const mobileReqs = !!(

View file

@ -13,10 +13,7 @@
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<funnelSubHeader
class="mb-5 mt-4"
cmsWidgetName="FunnelSubHeaderWidget"
leftAlignHeader />
<funnelSubHeader class="mb-5 mt-4" cmsWidgetName="FunnelSubHeaderWidget" />
<p v-if="isPiaDisabled" class="fw-normal no-pia-disclaimer">
<span>{{ noPiaDisclaimer }}</span>
@ -31,6 +28,8 @@
servicePackageOptionsCmsName="ServicePackageTitle"
@cart-remove="cartRemove" />
<hr />
<div>
<alert
ref="piaErrorAlert"
@ -85,6 +84,12 @@ import store from "@/store";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { paymentMethods } from "@/constants/payment-method-constants";
import { experimentSettings } from "@/constants/experiments";
import {
revalidatePromosAndValidateNewPromo,
getAddableVapsFromAvailableLineItems,
} from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { Form } from "vee-validate";
import { defineRule } from "vee-validate";
@ -160,6 +165,19 @@ export default {
false
);
// Promo logic
const promoCodeFromQueryString = getQuerystringParameter(queryStrings.PROMO);
const { validatePromoResponse, revalidatePromoResponse } =
await revalidatePromosAndValidateNewPromo(
promoCodeFromQueryString,
availableLineItems,
"payment-method"
);
availableLineItems.push(...(validatePromoResponse?.orderPromos ?? []));
// End of promo logic
const taxedAvailableLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
@ -175,6 +193,8 @@ export default {
false
);
// Match all available line items to the line items as they are in the store
// and rebuild the original structure.
const lineItems = mapTaxedAvailableLineItemsToStoreFormat(
availableLineItems,
lineItemsFromStore
@ -185,6 +205,8 @@ export default {
vm.setCmsContent(resultMap.cmsContent);
vm.availableLineItems = taxedAvailableLineItems;
vm.lineItems = lineItems;
vm.updateFooterButtonText(vm.customCtaCopy);
});
},
data() {
@ -192,6 +214,7 @@ export default {
lineItems: [],
availableLineItems: [],
paymentMethodInternalModel: this.getPaymentMethodFromStore(),
inactivePromos: this.getInactivePromosFromStore(),
};
},
methods: {
@ -260,6 +283,27 @@ export default {
return paymentMethods.LATER;
}
},
updateFooterButtonText(newValue) {
this.$refs.navbar.updateButtonText(newValue);
},
getInactivePromosFromStore() {
return this.$store.getters.order.payment.inactivePromos;
},
async revalidatePromos() {
const revalidatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA,
{
activePromosToUse: this.lineItems.promos,
inactivePromosToUse: this.inactivePromos,
lineItemsToUse: this.lineItems,
},
"payment-method",
false
);
this.lineItems.promos = revalidatePromoResponse.promoLineItems;
this.inactivePromos = revalidatePromoResponse.errors.map((x) => x.promoCode);
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
@ -284,6 +328,11 @@ export default {
);
await this.dispatchStoreAction(storeActions.SAVE_VAPS, this.lineItems.vaps, false);
await this.dispatchStoreAction(storeActions.SAVE_PROMOS, this.lineItems.promos, false);
await this.dispatchStoreAction(
storeActions.SAVE_INACTIVE_PROMOS,
this.inactivePromos,
false
);
if (this.paymentMethod == paymentMethods.LATER) {
// this creates the final work order
@ -364,7 +413,7 @@ export default {
},
watch: {
customCtaCopy(newValue) {
this.$refs.navbar.updateButtonText(newValue);
this.updateFooterButtonText(newValue);
},
},
components: {

View file

@ -0,0 +1,73 @@
<template>
<button
type="button"
class="btn d-flex align-items-center justify-content-center py-3 px-4 delay"
@click="handleClick">
<!-- <span class="m-0">{{ buttonLabelText }}</span> -->
<span v-for="token in tokens" :key="token">
<span v-if="isInlineImageToken(token)">
<img
v-if="hasImage"
class="ms-2"
:src="buttonImage"
:alt="getInlineAltText(token)" />
<span v-else> {{ getInlineAltText(token) }}</span>
</span>
<span v-else>
{{ token }}
</span>
</span>
</button>
</template>
<script>
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
const INLINE_IMAGE_TOKEN = "custom:inlineImage";
export default {
name: "payment-switch-button",
props: {
buttonText: String,
buttonImage: String,
buttonImageId: String,
},
methods: {
handleClick(clickValue) {
this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
this.GaActions.CLICKED,
this.buttonText,
true
);
this.$emit("click-event", clickValue);
},
isInlineImageToken(token) {
return token.includes(INLINE_IMAGE_TOKEN);
},
getInlineAltText(token) {
let innerTokens = token.split(",");
return innerTokens[1] ?? "";
},
},
computed: {
tokens() {
return splitCopyOnCMSPlaceHolder(this.buttonText ?? "");
},
hasImage() {
return !!this.buttonImage;
},
buttonLabelText() {
return this.tokens[0];
},
},
};
</script>
<style lang="scss" scoped>
.btn {
border: 1px solid $gray-600;
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,99 @@
<template>
<div class="switch-payment-header pb-3">{{ switchPaymentHeaderText }}</div>
<div class="switch-payment-button">
<paymentSwitchButton
v-if="paymentType !== paymentTypes.cc"
:buttonText="paymentMethodsInfo[paymentTypes.cc]?.buttonText"
:buttonImage="paymentMethodsInfo[paymentTypes.cc]?.answerImageUrl"
:buttonImageId="paymentMethodsInfo[paymentTypes.cc]?.imageId"
@click-event="switchPaymentClick(paymentTypes.cc)" />
</div>
<div class="switch-button-separator py-3" v-if="paymentType !== paymentTypes.cc">or</div>
<div class="switch-payment-button">
<paymentSwitchButton
v-if="paymentType !== paymentTypes.pp"
:buttonText="paymentMethodsInfo[paymentTypes.pp]?.buttonText"
:buttonImage="paymentMethodsInfo[paymentTypes.pp]?.answerImageUrl"
:buttonImageId="paymentMethodsInfo[paymentTypes.pp]?.imageId"
@click-event="switchPaymentClick(paymentTypes.pp)" />
</div>
<div class="switch-button-separator py-3" v-if="paymentType === paymentTypes.cc">or</div>
<div class="switch-payment-button">
<paymentSwitchButton
v-if="paymentType !== paymentTypes.ap"
:buttonText="paymentMethodsInfo[paymentTypes.ap]?.buttonText"
:buttonImage="paymentMethodsInfo[paymentTypes.ap]?.answerImageUrl"
:buttonImageId="paymentMethodsInfo[paymentTypes.ap]?.imageId"
@click-event="switchPaymentClick(paymentTypes.ap)" />
</div>
</template>
<script>
import paymentSwitchButton from "./payment-switch-button/payment-switch-button";
export default {
name: "paymentSwitch",
data() {
return {
paymentTypes: {
cc: "cc",
pp: "pp",
ap: "ap",
},
};
},
props: {
cmsWidgetName: String,
paymentType: String,
},
computed: {
switchPaymentHeaderText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
paymentMethodAnswerData() {
const rawData = this.getCmsContent(this.cmsWidgetName, "Answers");
if (!rawData) {
return [];
} else {
return rawData;
}
},
paymentMethodsInfo() {
return this.paymentMethodAnswerData.reduce((accumulator, paymentMethod) => {
accumulator[paymentMethod.Name] = {
buttonText: paymentMethod.Text,
answerImageUrl: paymentMethod.AnswerImageUrl,
imageId: paymentMethod.ImageId,
};
return accumulator;
}, {});
},
},
methods: {
switchPaymentClick(paymentType) {
this.$emit("switch-payment-event", paymentType);
},
},
components: {
paymentSwitchButton,
},
};
</script>
<style lang="scss">
.switch-payment-header {
font-weight: 500;
color: $black;
}
.switch-button-separator {
text-align: center;
color: $black;
}
.switch-payment-button button {
width: 100%;
color: $black;
}
</style>

View file

@ -12,6 +12,7 @@
<div class="col-md-6 col-xl-4 p-0">
<div id="card-container">
<iframe
v-resize
ref="paymentFrame"
class="hop-iframe payment-iframe"
name="card-frame"
@ -20,39 +21,10 @@
scrolling="no">
</iframe>
</div>
<div>{{ switchPaymentText }}</div>
<div>
<button
v-if="paymentType !== 'cc'"
ref="switchToCCButton"
type="button"
@mousedown="switchPIAMethod('cc')"
aria-label="Switch to Credit Card">
Switch to Credit Card
</button>
</div>
<div v-if="paymentType !== 'cc'">or</div>
<div>
<button
v-if="paymentType !== 'pp'"
ref="switchToPPButton"
type="button"
@mousedown="switchPIAMethod('pp')"
aria-label="Switch to PayPal">
Switch to PayPal
</button>
</div>
<div v-if="paymentType === 'cc'">or</div>
<div>
<button
v-if="paymentType !== 'ap'"
ref="switchToAPButton"
type="button"
@mousedown="switchPIAMethod('ap')"
aria-label="Switch to Afterpay">
Switch to Afterpay
</button>
</div>
<paymentSwitch
cmsWidgetName="PaymentMethodWidget"
:paymentType="paymentType"
@switch-payment-event="switchPIAMethod" />
<navbar
cmsWidgetName="FunnelFooterWidget"
isForwardActionDisabled="true"
@ -192,6 +164,8 @@ import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import paymentSwitch from "./payment-switch/payment-switch.vue";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
import { externalUrls } from "@/router/router-constants/externalUrl-values";
@ -202,7 +176,7 @@ import { paymentMethods } from "@/constants/payment-method-constants";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import baseMixin from "@/mixins/base-mixin.js";
// Validation
import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.js";
export default {
name: "payment",
@ -235,6 +209,18 @@ export default {
paypalPayment: this.isPaypal(),
};
},
directives: {
resize: {
mounted: function (el) {
el.addEventListener("load", () => {
iframeResize({ checkOrigin: false }, el);
});
},
unmounted: function (el) {
el.iFrameResizer.removeListeners();
},
},
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
@ -480,6 +466,7 @@ export default {
funnelHeader,
navbar,
loadingModal,
paymentSwitch,
},
};
</script>
@ -487,7 +474,7 @@ export default {
<style lang="scss">
.hop-iframe {
position: relative;
min-height: 1320px;
min-height: 1120px;
width: 100%;
border: 0 none;
}

View file

@ -83,7 +83,7 @@ import { payWithInsuranceStates } from "@/constants/pay-with-insurance-states";
import { revalidatePromosAndValidateNewPromo } from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { setTransitionHooks } from "vue";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
export default {
@ -168,7 +168,7 @@ export default {
vm.supportingItems = resultMap.supportingItems;
vm.availableLineItems = pricingResults;
vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems);
vm.newValidatedPromos = validatePromoResponse?.data.orderPromos;
vm.newValidatedPromos = validatePromoResponse?.orderPromos;
});
},
data() {

View file

@ -186,18 +186,21 @@ describe("shop-question.vue", () => {
buttonLabel: "Westerville",
buttonLabelSubCopy: "5 mi",
value: "003335",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "760 Dearborn Park Ln, Worthington, OH 43085",
buttonLabel: "Worthington",
buttonLabelSubCopy: "10.5 mi",
value: "001820",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "5015 N High St, Columbus, OH 43214",
buttonLabel: "Columbus",
buttonLabelSubCopy: "11.5 mi",
value: "003343",
additionalButtonData: wrapper.vm.additionalButtonData,
},
]);
});
@ -414,6 +417,10 @@ describe("shop-question.vue", () => {
},
});
displayedAnswers.forEach(
(answer) => (answer.additionalButtonData = wrapper.vm.additionalButtonData)
);
// Act
await wrapper.setProps({
shopProviderData: shopQuestionInitialData,
@ -445,42 +452,49 @@ describe("shop-question.vue", () => {
// Assert
expect(wrapper.vm.answers.length).toBe(6);
expect(wrapper.vm.answers).toEqual([
{
buttonBodyCopy: "4403 Executive Pkwy, Westerville, OH 43081",
buttonLabel: "Westerville",
buttonLabelSubCopy: "5 mi",
value: "003335",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "760 Dearborn Park Ln, Worthington, OH 43085",
buttonLabel: "Worthington",
buttonLabelSubCopy: "10.5 mi",
value: "001820",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "5015 N High St, Columbus, OH 43214",
buttonLabel: "Columbus",
buttonLabelSubCopy: "11.5 mi",
value: "003343",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "1670 Harmon Ave, Columbus, OH 43223",
buttonLabel: "Columbus",
buttonLabelSubCopy: "16 mi",
value: "006747",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "3938 Powell Rd, Powell, OH 43065",
buttonLabel: "Powell",
buttonLabelSubCopy: "16.5 mi",
value: "003341",
additionalButtonData: wrapper.vm.additionalButtonData,
},
{
buttonBodyCopy: "4580 W Broad St, Columbus, OH 43228",
buttonLabel: "Columbus",
buttonLabelSubCopy: "19.5 mi",
value: "003342",
additionalButtonData: wrapper.vm.additionalButtonData,
},
]);
});

View file

@ -19,8 +19,7 @@
textPosition="text-start"
v-model="selectedProviderNumber"
isRequired
validationRules="option-required"
:additionalButtonData="additionalButtonData" />
validationRules="option-required" />
<textLink
v-if="displaySeeMoreLocationsLink"
ref="showMoreShopsLink"
@ -156,6 +155,7 @@ export default {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
additionalButtonData: this.additionalButtonData,
value: shopProvider.providerNumber,
};
});

View file

@ -334,6 +334,7 @@ export default {
},
async forwardButtonAction() {
await this.dispatchStoreAction(storeActions.RESET_SUBMITTED_ORDER);
this.dispatchStoreAction(
storeActions.SAVE_VEHICLE,
{

View file

@ -89,7 +89,7 @@ export default {
);
});
let totalPrice = this.getTotalPriceOfAllLineItemsAndChildParts(lineItemsToPrice);
let totalPrice = this.getTotalPriceOfAllLineItemsAndChildParts(lineItemsToPrice, false);
return totalPrice;
},
filterOutFees(lineItems) {
@ -101,22 +101,30 @@ export default {
});
return filteredLineItems;
},
getTotalPriceOfAllLineItemsAndChildParts(lineItems) {
getTotalPriceOfAllLineItemsAndChildParts(lineItems, includeTax) {
let totalPrice = 0;
lineItems.forEach((lineItem) => {
totalPrice += this.getTotalLineItemPrice(lineItem);
totalPrice += this.getTotalLineItemPrice(lineItem, includeTax);
if (lineItem.childParts) {
totalPrice += this.getTotalPriceOfAllLineItemsAndChildParts(
lineItem.childParts
lineItem.childParts,
includeTax
);
}
});
return totalPrice;
},
getTotalLineItemPrice(lineItem) {
return (
lineItem.kitPrice + lineItem.laborAmount + lineItem.sellingPrice + lineItem.salesTax
);
getTotalLineItemPrice(lineItem, includeTax) {
if (includeTax) {
return (
lineItem.kitPrice +
lineItem.laborAmount +
lineItem.sellingPrice +
lineItem.salesTax
);
} else {
return lineItem.kitPrice + lineItem.laborAmount + lineItem.sellingPrice;
}
},
getDisplayAmountDue(lineItems) {
return this.getAmountDue(lineItems).toLocaleString("en-US", {
@ -127,17 +135,21 @@ export default {
getAmountDue(lineItems) {
var amountDue = 0;
if (lineItems.glassParts) {
amountDue = this.getTotalPriceOfAllLineItemsAndChildParts(lineItems.glassParts);
amountDue += this.getTotalPriceOfAllLineItemsAndChildParts(
lineItems.glassParts,
true
);
}
if (lineItems.supportingItems) {
amountDue += this.getTotalPriceOfAllLineItemsAndChildParts(
lineItems.supportingItems
lineItems.supportingItems,
true
);
}
if (lineItems.vaps) {
amountDue += this.getTotalPriceOfAllLineItemsAndChildParts(lineItems.vaps);
amountDue += this.getTotalPriceOfAllLineItemsAndChildParts(lineItems.vaps, true);
}
return amountDue;
return ((amountDue * 100) / 100).toFixed(2);
},
scrollToPageTop() {
const container = document.getElementsByClassName("page-container-grouped-styles")[0];

View file

@ -8,6 +8,7 @@ import { globalEvents, globalEventTypes } from "@/constants/events";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
// Heritage integration
import { isSavedSessionStillActive } from "@/helpers/heritage-integration/session-helper";
@ -54,8 +55,17 @@ const routes = [
await GoToFunnelStartOn404(next);
}
// Intercept all navigation if a submitted order exists in storage
if (store.getters.hasSubmittedOrder) {
if (to.query.fmgPage !== "vehicle") {
to.query.fmgPage = "confirmation";
}
}
// On entering the funnel "fresh", read cookie information, decide what to do next. payment pages used to return from safelitehop so exclude here
if (from.redirectedFrom === undefined && !to.query.fmgPage.startsWith("payment")) {
else if (
from.redirectedFrom === undefined &&
!to.query.fmgPage.startsWith("payment")
) {
// clear the saveSessionPromise - if it exists in the vuex store but a new instance was created
// the saveSessionPromise will no longer point to a valid promise
baseMixin.methods.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
@ -154,6 +164,11 @@ router.beforeEach(async (to, from, next) => {
// use current page url query string name when to.name is "root" (due to unresolved navigation in beforeEach)
router.lastNavigationPage = to.name == "root" ? analyticsMixin.methods.getPageName() : to.name;
const fromQueryPage = from.query?.fmgPage;
if (fromQueryPage === undefined) {
showFmgLoadingModal(true);
}
next();
});
@ -171,6 +186,8 @@ router.afterEach(async (to, from) => {
}
}
showFmgLoadingModal(false);
// Push page view to GA
analyticsMixin.methods.pushPageViewToGA();

View file

@ -691,6 +691,8 @@ export const getters = {
.filter((x) => !!x.isActive)
.map((x) => x.settings)
.reduce((r, c) => Object.assign(r, c), {}) ?? {},
submittedOrder: (state) => JSON.parse(window.sessionStorage.getItem("submittedOrder")),
hasSubmittedOrder: (state) => window.sessionStorage.getItem("submittedOrder") !== null,
};
function getNonFalseValuesOfPropertyInArrayOfObjects(array, propertyName) {
@ -2105,6 +2107,9 @@ export const actions = {
savePromos(context, promos) {
context.commit(storeMutations.UPDATE_PROMOS, promos);
},
saveInactivePromos(context, inactivePromos) {
context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, inactivePromos);
},
// Price order actions
async priceOrderItemsAndSaveServerData(
@ -2238,11 +2243,15 @@ export const actions = {
return pricedAvailableLineItems;
},
// The <addableVaps> parameter is an array of ALL available front wipers and rain defense
// for the vehicle. There is no need to confirm an addableVap is not already on the order
// <lineItemsToUse> is an optional parameter if the lineItems in the store may not be up to date
async validateOrderPromoAndSaveServerData(
context,
{ payload: { promoCode, addableVaps }, pageNameToLog }
{ payload: { promoCode, lineItemsToUse, addableVaps }, pageNameToLog }
) {
const order = context.getters.order;
lineItemsToUse = lineItemsToUse ?? order.lineItems;
addGuidToLineItemsIfNotAlreadyThere(addableVaps);
const requestObject = {
promoCode: promoCode,
@ -2254,7 +2263,7 @@ export const actions = {
eon: order.eon,
isRepair: order.damage.isRepair,
glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace),
lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems),
lineItemsOnOrder: getArrayOfAllLineItems(lineItemsToUse),
parentAccountNumber: order.payment.parentAccountNumber,
referralSequenceNumber: order.referralSequenceNumber,
serviceState: order.serviceLocation.state,
@ -2267,13 +2276,18 @@ export const actions = {
},
};
const validateResponse = await globalMethods.callHttpClient({
method: endpoints.ValidatePromo.method,
endpoint: endpoints.ValidatePromo.url,
payload: requestObject,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
const validateResponse = await globalMethods
.callHttpClient({
method: endpoints.ValidatePromo.method,
endpoint: endpoints.ValidatePromo.url,
payload: requestObject,
logApiCall: true,
pageNameToLog: pageNameToLog,
})
.catch((error) => {
// Validate promo will return 4xx errors when an invalid promo
return error;
});
if (validateResponse?.data?.serverData) {
context.commit(
@ -2282,13 +2296,22 @@ export const actions = {
);
}
return validateResponse;
return validateResponse?.data;
},
async revalidateOrderPromosAndUpdateStore(context, { pageNameToLog }) {
// All payload parameters are optional - will use store data if not provided
async revalidateOrderPromosAndSaveServerData(
context,
{ payload: { activePromosToUse, inactivePromosToUse, lineItemsToUse }, pageNameToLog }
) {
const order = context.getters.order;
activePromosToUse = activePromosToUse ?? order.lineItems.promos;
inactivePromosToUse = inactivePromosToUse ?? order.payment.inactivePromos;
lineItemsToUse = lineItemsToUse ? deepClone(lineItemsToUse) : deepClone(order.lineItems);
lineItemsToUse.promos = activePromosToUse;
let requestObject = {
inactivePromos: order.payment.inactivePromos,
inactivePromos: inactivePromosToUse,
order: {
appointmentType: order.serviceLocation.appointmentType,
carId: order.vehicle.carId,
@ -2296,7 +2319,7 @@ export const actions = {
eon: order.eon,
isRepair: order.damage.isRepair,
glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace),
lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems),
lineItemsOnOrder: getArrayOfAllLineItems(lineItemsToUse),
parentAccountNumber: order.payment.parentAccountNumber,
referralSequenceNumber: order.referralSequenceNumber,
serviceState: order.serviceLocation.state,
@ -2317,16 +2340,12 @@ export const actions = {
pageNameToLog: pageNameToLog,
});
const revalidationErrorPromoCodes = revalidateResponse.data.errors.map((x) => x.promoCode);
context.commit(storeMutations.UPDATE_PROMOS, revalidateResponse.data.promoLineItems);
context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, revalidationErrorPromoCodes);
context.commit(
storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA,
revalidateResponse.data.lineItemsServerData
);
return revalidateResponse;
return revalidateResponse?.data;
},
// Misc order actions
@ -2436,6 +2455,26 @@ export const actions = {
return false;
},
createSubmittedOrder(context) {
if (context.getters.hasSubmittedOrder) {
return;
}
// create a submitted order object from vuex.
const submittedOrder = context.state.order;
// set to local storage
window.sessionStorage.setItem("submittedOrder", JSON.stringify(submittedOrder));
// clear vuex
context.commit(storeMutations.RESET_STATE);
},
resetSubmittedOrder(context) {
// clear from local storage
window.sessionStorage.removeItem("submittedOrder");
},
};
export default createStore({
@ -2571,15 +2610,6 @@ function addTaxesToPricedLineItems(pricedLineItems, taxingLineItems = []) {
return pricedLineItems;
}
export class LineItemUtilities {
constructor() {
this.getArrayOfAllLineItems = getArrayOfAllLineItems;
this.getFlattenedArrayOfLineItemsWithChildParts =
getFlattenedArrayOfLineItemsWithChildParts;
this.addGuidToLineItemsIfNotAlreadyThere = addGuidToLineItemsIfNotAlreadyThere;
}
}
export function mapTaxedAvailableLineItemsToStoreFormat(availableLineItems, storeLineItems) {
// clone the lineItems array because what we're passing in is referencing the store directly
const lineItems = deepClone(storeLineItems);
@ -2587,12 +2617,69 @@ export function mapTaxedAvailableLineItemsToStoreFormat(availableLineItems, stor
for (let [category, lineItemsInCategory] of Object.entries(lineItems)) {
lineItemsInCategory = lineItemsInCategory ?? [];
for (let lineItemIndex = 0; lineItemIndex < lineItemsInCategory.length; lineItemIndex++) {
const availableLineItem = availableLineItems.find(
(ali) => ali.partNumber == lineItemsInCategory[lineItemIndex].partNumber
if (category == "supportingItems") {
// if the category is supporting items we need to filter out the items that aren't repair chips
const nonRepairChipSupportItemsLineItems = lineItemsInCategory.filter(
(lineItem) => lineItem.partNumber != "WSREPAIR"
);
if (availableLineItem) {
lineItemsInCategory[lineItemIndex].salesTax = availableLineItem.salesTax;
/*
Because repair chips all have the same part number but different prices based on the quantity,
we have to sort the store line items and available line items by descending labor amount in order to
map the tax correctly to each repair chip
*/
// get the supporting items that ARE repair chips and sort them by descending labor amount
let repairChipLineItems = lineItemsInCategory.filter(
(lineItem) => lineItem.partNumber == "WSREPAIR"
);
repairChipLineItems = repairChipLineItems.sort(
(a, b) => parseFloat(b.laborAmount) - parseFloat(a.laborAmount)
);
// get the supporting items from the available line items (taxed) that ARE repair chips and sort them by descending labor amount
let availableRepairChipLineItems = availableLineItems.filter(
(lineItem) => lineItem.partNumber == "WSREPAIR"
);
availableRepairChipLineItems = availableRepairChipLineItems.sort(
(a, b) => parseFloat(b.laborAmount) - parseFloat(a.laborAmount)
);
// go through each one of those mapping the taxes to the correct chip
for (let i = 0; i < availableRepairChipLineItems.length; i++) {
repairChipLineItems[i].salesTax = availableRepairChipLineItems[i].salesTax;
}
// then we map the non-repair chip items based on part number
for (
let lineItemIndex = 0;
lineItemIndex < nonRepairChipSupportItemsLineItems.length;
lineItemIndex++
) {
const availableLineItem = availableLineItems.find(
(ali) =>
ali.partNumber ==
nonRepairChipSupportItemsLineItems[lineItemIndex].partNumber
);
if (availableLineItem) {
nonRepairChipSupportItemsLineItems[lineItemIndex].salesTax =
availableLineItem.salesTax;
}
}
// finally we splice the two arrays back into one
lineItemsInCategory = nonRepairChipSupportItemsLineItems.concat(repairChipLineItems);
} else {
for (
let lineItemIndex = 0;
lineItemIndex < lineItemsInCategory.length;
lineItemIndex++
) {
const availableLineItem = availableLineItems.find(
(ali) => ali.partNumber == lineItemsInCategory[lineItemIndex].partNumber
);
if (availableLineItem) {
lineItemsInCategory[lineItemIndex].salesTax = availableLineItem.salesTax;
}
}
}
}