Merge branch 'develop' into feature/CSR-1388

This commit is contained in:
Leah Schumann 2023-11-14 08:44:49 -05:00
commit dd01528864
13 changed files with 514 additions and 45 deletions

View file

@ -33,6 +33,7 @@ const errorMessages = {
MAKE_REQUIRED: "Please select your vehicle make",
MODEL_REQUIRED: "Please select your vehicle model",
STYLE_REQUIRED: "Please select your vehicle style",
PROMO_REQUIRED: "Please enter a promo code",
};
export { errorMessages };

View file

@ -32,7 +32,6 @@ export default {
name: "funnel-header",
data() {
return {
alertIdCounter: 0,
globalAlertMessages: [],
};
},
@ -47,8 +46,7 @@ export default {
methods: {
pushGlobalAlert(alertToPush, isAutoDismissing) {
alertToPush.displayAlert = true;
alertToPush.id = this.alertIdCounter;
this.alertIdCounter++;
alertToPush.id = crypto.randomUUID();
if (isAutoDismissing) {
setTimeout(() => {
alertToPush.displayAlert = false;

View file

@ -54,6 +54,14 @@
</template>
<script>
const windowPageShowCallback = function (evt) {
if (evt.persisted) {
setTimeout(function () {
window.location.reload();
}, 10);
}
};
export default {
name: "Modal",
data() {
@ -93,17 +101,11 @@ export default {
this.isModalTextCarouselVisible = showTextCarousel;
this.isModalVisible = true;
//Force page reload on back button
window.addEventListener(
"pageshow",
function (evt) {
if (evt.persisted) {
setTimeout(function () {
window.location.reload();
}, 10);
}
},
false
);
window.addEventListener("pageshow", windowPageShowCallback, false);
},
hideModal() {
this.isModalVisible = false;
window.removeEventListener("pageshow", windowPageShowCallback, false);
},
},
};

View file

@ -5,7 +5,7 @@ export function getDateDifferenceInDays(startDate, endDate) {
// Use the built-in method to get the difference in milliseconds
var difference = date1 - date2;
// Convert milliseconds to days and return the result
return difference / (1000 * 3600 * 24);
return Math.floor(difference / (1000 * 3600 * 24));
}
export function get12HourTimeFormat(time) {
// Check correct time format and split into components

View file

@ -16,7 +16,7 @@ export const addableVapsPromoPartNumbers = [
promoPartNumberStrings.WIPER_DISCOUNT_PART_NUMBER,
];
const promoErrorCodes = {
export const promoErrorCodes = {
UNKNOWN: "Unknown",
PROMO_NOT_YET_IN_USE: "PromoNotYetInUse",
PROMO_USAGE_COUNT_EXCEEDED: "PromoUsageCountExceeded",
@ -246,6 +246,20 @@ export function getVapsThatNeedToBeAddedToSatisfyPromos(
}
}
export function removeCurrentlyActivePromoCodesFromInactivePromos(
activePromoObjects,
inactivePromos
) {
activePromoObjects = activePromoObjects ?? [];
inactivePromos = inactivePromos ?? [];
const activePromoCodes = activePromoObjects.map((promoObject) =>
getPromoCodeWithoutBundleIdentifier(promoObject.promoCode).toUpperCase()
);
return inactivePromos.filter((inactivePromo) => {
return !activePromoCodes.includes(inactivePromo.toUpperCase());
});
}
// bundle promos look like: "bundlePromo/###"" for each promo, this returns "bundlePromo" only
export function getPromoCodeWithoutBundleIdentifier(promoCode) {
return promoCode.split("/")[0];

View file

@ -2,8 +2,8 @@
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal notFullScreen ref="loadingModal" />
<div class="container-fluid">
<div class="row">
<div class="col">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
@ -30,6 +30,12 @@
recyclingModalCmsWidgetName="RecycleModal" />
<hr class="my-5" />
<promoModalQuestion
class="pb-3"
v-model="lineItems"
:availableVaps="availableVaps"
linkWidgetName="PromoLinkWidget"
modalWidgetName="PromoModalWidget" />
<div>
<alert
@ -73,6 +79,7 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
import paymentMethodQuestion from "@/layouts/payment-method/payment-method-question/payment-method-question";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import cart from "@/fmg-components/cart/cart";
import promoModalQuestion from "@/layouts/payment-method/promo-modal-question/promo-modal-question";
import addVapsModalButtons from "./add-vaps-modal-buttons/add-vaps-modal-buttons";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import alert from "@/ux-components/alert/alert";
@ -316,6 +323,7 @@ export default {
return this.$store.getters.order.payment.inactivePromos;
},
async revalidatePromos() {
this.$refs.loadingModal.showModal();
const revalidatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA,
{
@ -337,6 +345,7 @@ export default {
this.lineItems.promos = revalidatePromoResponse.promoLineItems;
this.inactivePromos = revalidatePromoResponse.errors.map((x) => x.promoCode);
this.$refs.loadingModal.hideModal();
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
@ -487,6 +496,7 @@ export default {
alert,
addVapsModalButtons,
paymentMethodQuestion,
promoModalQuestion,
},
};
</script>

View file

@ -0,0 +1,90 @@
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(),
},
}));
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";
const mockLinkCmsContent = {
BodyText: "Sample link body text here.",
};
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === linkWidgetName) {
return mockLinkCmsContent[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: {
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.onModalClosed();
// Assert
expect(wrapper.vm.displayInvalidPromoAlert).toBe(false);
expect(wrapper.vm.displayInvalidOnOrderPromoAlert).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,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
await wrapper.vm.onModalClosed();
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([[lineItems]]);
});
});

View file

@ -0,0 +1,266 @@
<template>
<div>
<textLink
id="promoLinkPromptId"
linkType="text"
:text="promoLinkText"
href="#!"
@click-event="openModal" />
<modal
:ref="modalName"
:headerText="modalHeaderText"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
@footer-button-event="addPromoCode">
<promoQuestion
ref="promoQuestion"
customInputId="promoCode"
v-model="promoCode"
cmsWidgetName="PromoQuestionWidget" />
<alert
ref="alertInvalidPromo"
v-if="displayInvalidPromoAlert"
v-html="InvalidPromoText"
class="my-4 alert"
cmsWidgetName="AlertInvalidPromoWidget"
alertClass="alert-danger"
v-bind:isDismissible="true" />
<alert
ref="alertInvalidPromoOnOrder"
v-if="displayInvalidOnOrderPromoAlert"
v-html="InvalidPromoOnOrderText"
class="my-4 alert"
cmsWidgetName="AlertInvalidPromoOnOrderWidget"
alertClass="alert-danger"
v-bind:isDismissible="true" />
<div v-for="(promo, i) in this.getPromoList()" :key="i">
<span class="applied-promo">Promo code "{{ promo.promoCode }}" applied </span>
<textLink
ref="removeLink"
linkType="text"
:text="removeLinkText"
href="#!"
@click-event="removeItem(promo.promoCode)" />
</div>
</modal>
</div>
</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 modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert";
import { storeActions } from "@/constants/store-actions.js";
import {
getVapsThatNeedToBeAddedToSatisfyPromos,
promoErrorCodes,
} from "@/helpers/promotions-helper";
import { deepClone } from "@/helpers/object-helper";
import baseMixin from "@/mixins/base-mixin.js";
import { cartItemCategories } from "@/constants/cart-item-categories";
import store from "@/store";
import { mapTaxedLineItemsToStoreFormat } from "@/store";
export default {
name: "promo-modal-question",
data() {
return {
promoCode: "",
promoTextInputId: "",
displayInvalidPromoAlert: false,
displayInvalidOnOrderPromoAlert: false,
lineItems: deepClone(this.modelValue),
};
},
props: {
modelValue: Object,
linkWidgetName: String,
modalWidgetName: String,
availableVaps: Object,
},
computed: {
promoLinkText() {
return this.getCmsContent(this.linkWidgetName, "BodyText");
},
modalHeaderText() {
return this.getCmsContent("PromoQuestionWidget", "QuestionText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
PromoOnOrderText() {
return this.getCmsContent("AlertInvalidPromoOnOrderWidget", "HeadlineText");
},
InvalidPromoOnOrderText() {
return this.PromoOnOrderText?.replaceAll(
"{custom:NEWPROMOCODE}",
this.promoCode.toUpperCase()
).replaceAll("{custom:OLDPROMOCODE}", this.getPromoCode().toUpperCase());
},
PromoText() {
return this.getCmsContent("AlertInvalidPromoWidget", "HeadlineText");
},
InvalidPromoText() {
return this.PromoText?.replaceAll("{custom:PROMOCODE}", this.promoCode.toUpperCase());
},
removeLinkText() {
return this.getCmsContent("RemoveCartItemTextWidget", "Text");
},
},
methods: {
resetAlerts() {
this.displayInvalidOnOrderPromoAlert = false;
this.displayInvalidPromoAlert = false;
},
resetsOnPromoInput() {
this.resetAlerts();
},
getPromoList() {
return this.lineItems.promos;
},
getPromoCode() {
return this.lineItems.promos?.[0].promoCode;
},
openModal() {
this.modal.openModal();
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.resetAlerts();
this.$emit("update:modelValue", this.lineItems);
this.promoCode = "";
},
focusOnPromoInput() {
const input = document.getElementById(this.promoTextInputId);
input?.focus();
},
resetModalButtonStyle() {
this.modal.resetButtonStyle();
},
async getPromoCodeData(promoCode, lineItems, availableVaps, pageNameToLog = null) {
const pageName = pageNameToLog ?? this.$options?.name;
const promoValidationResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA,
{
promoCode: promoCode,
lineItemsToUse: lineItems,
addableVaps: availableVaps,
},
pageName,
false
);
return {
isValid: !("errorCode" in promoValidationResponse),
promoCode: promoValidationResponse?.orderPromos,
errorCode: promoValidationResponse?.errorCode,
};
},
removeItem(promo) {
this.lineItems[cartItemCategories.PROMOS] = this.lineItems[
cartItemCategories.PROMOS
].filter((lineItemsToKeep) => lineItemsToKeep.promoCode != promo);
},
async addPromoCode() {
if (this.promoCode) {
this.resetAlerts();
const promoCodeData = await this.getPromoCodeData(
this.promoCode,
this.lineItems,
this.availableVaps,
"payment-method"
);
if (promoCodeData.isValid) {
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
);
const getVaps = getVapsThatNeedToBeAddedToSatisfyPromos(
promoCodeData.promoCode,
taxedVaps,
this.lineItems
);
this.lineItems?.promos.push(...promoCodeData.promoCode);
this.lineItems.vaps?.push(...getVaps);
this.closeModal();
} else {
if (promoCodeData.errorCode == promoErrorCodes.PROMO_STACKING_NOT_ALLOWED) {
this.displayInvalidOnOrderPromoAlert = true;
this.focusOnPromoInput();
this.resetModalButtonStyle();
} else {
this.displayInvalidPromoAlert = true;
this.focusOnPromoInput();
this.resetModalButtonStyle();
}
}
}
},
},
watch: {
promoCode() {
this.displayInvalidPromoAlert = false;
this.displayInvalidOnOrderPromoAlert = false;
},
modelValue: {
handler(newValue) {
this.lineItems = deepClone(newValue);
},
deep: true,
},
},
components: {
textLink,
promoQuestion,
modal,
alert,
},
};
</script>
<style lang="scss" scoped>
.applied-promo {
padding-right: 4rem;
font-size: 14px;
font-weight: 500;
line-height: 24px;
color: #0c7e47;
}
.alert {
color: #d4281c;
font-size: 14px;
font-weight: 500;
line-height: 24px;
}
</style>

View file

@ -0,0 +1,40 @@
import { shallowMount } from "@vue/test-utils";
import promoQuestion from "./promo-question";
describe("promo-question.vue", () => {
it("Should get the modelValue", async () => {
// Arrange
const text = "test";
const wrapper = shallowMount(promoQuestion, {
props: {
modelValue: text,
},
attachTo: document.body,
});
// Act
const modelValueText = wrapper.vm.value;
wrapper.vm.value = "test also";
// Assert
expect(modelValueText).toEqual("test");
});
it("Should emit to set value", async () => {
// Arrange
const text = "test";
const wrapper = shallowMount(promoQuestion, {
props: {
modelValue: text,
},
attachTo: document.body,
});
// Act
const modelValueText = wrapper.vm.value;
wrapper.vm.value = "test also";
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([["test also"]]);
});
});

View file

@ -0,0 +1,45 @@
<template>
<textboxQuestion
ref="promoInputTextQuestion"
:cmsWidgetName="cmsWidgetName"
v-model="value"
inputId="promoCode"
questionAlignment="center"
cornerStyle="rounded"
:displayQuestionText="false"
isRequired
validationRules="promo-required" />
</template>
<script>
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
//Supporting Files
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// Define Validation Rules
defineRule("promo-required", required(errorMessages.PROMO_REQUIRED));
export default {
name: "promo-question",
props: {
modelValue: String,
cmsWidgetName: String,
},
computed: {
value: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
},
components: {
textboxQuestion,
},
};
</script>

View file

@ -131,6 +131,7 @@ export default {
}
this.$refs.loadingModal.isModalVisible = false;
await baseMixin.methods.dispatchStoreAction(storeActions.CREATE_SUBMITTED_ORDER);
this.$router.navigateWithoutSaving(this.navigationScenarios.PIA_SUCCESS, this.$route);
},
forwardButtonAction() {

View file

@ -169,7 +169,16 @@ router.beforeEach(async (to, from, next) => {
showFmgLoadingModal(true);
}
next();
const toQueryPage = to.query?.fmgPage;
const notToPIAReturn = toQueryPage != "payment-pia-return";
const isInIframe = window !== window.top;
if (isInIframe && notToPIAReturn) {
const newUrl = `${window.top.location.origin}${to.href}`;
window.top.location.href = newUrl;
} else {
next();
}
});
router.afterEach(async (to, from) => {

View file

@ -23,7 +23,10 @@ import {
getDisplayTextForDurationLength,
} from "@/layouts/schedule/helpers/schedule-helper";
import { paymentMethods } from "@/constants/payment-method-constants";
import { getPromoCodeWithoutBundleIdentifier } from "@/helpers/promotions-helper";
import {
getPromoCodeWithoutBundleIdentifier,
removeCurrentlyActivePromoCodesFromInactivePromos,
} from "@/helpers/promotions-helper";
import { getDateDifferenceInDays } from "@/helpers/date-helper";
// Export State
const getDefaultState = () => {
@ -543,7 +546,6 @@ export const mutations = {
state.order.customer.phoneNumber = sessionInformation.order.customer.phoneNumber;
state.order.customer.isSmsOptIn = sessionInformation.order.customer.isSmsOptIn;
state.order.existingPromoCode = sessionInformation.order.existingPromoCode;
state.applicationUser.experiments = sessionInformation.applicationUser.experiments;
state.applicationUser.crmCustomerId = sessionInformation.applicationUser.crmCustomerId;
state.applicationUser.pageData = sessionInformation.applicationUser.pageData;
@ -1715,7 +1717,6 @@ export const actions = {
jobMaxMinutes: order.schedule?.jobMaxMinutes,
jobMinMinutes: order.schedule?.jobMinMinutes,
},
existingPromoCode: null,
referralCorrelationId: order.referralCorrelationId,
referralDate: order.referralDate,
referralNumber: order.referralNumber?.toString(),
@ -2120,13 +2121,6 @@ export const actions = {
addGuidToLineItemsIfNotAlreadyThere(vaps);
context.commit(storeMutations.UPDATE_VAPS, vaps);
},
// COMBINE THESE
savePromos(context, promos) {
context.commit(storeMutations.UPDATE_PROMOS, promos);
},
saveInactivePromos(context, inactivePromos) {
context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, inactivePromos);
},
// Manage promo saving to ensure a promoCode never ends up in both active and inactive
saveActiveAndOrInactivePromos(context, { activePromos = null, inactivePromos = null }) {
let activePromosToSave;
@ -2138,12 +2132,10 @@ export const actions = {
} else if (activePromos && inactivePromos) {
// Prioritize active promos when both inactive and active are supplied
activePromosToSave = activePromos;
const activePromoCodes = activePromos.map((promoObject) =>
getPromoCodeWithoutBundleIdentifier(promoObject.promoCode)
inactivePromosToSave = removeCurrentlyActivePromoCodesFromInactivePromos(
activePromosToSave,
inactivePromos
);
inactivePromosToSave = inactivePromos.filter((inactivePromo) => {
return !activePromoCodes.includes(inactivePromo);
});
} else if (!activePromos) {
// Only inactivePromos supplied
inactivePromosToSave = inactivePromos;
@ -2155,13 +2147,9 @@ export const actions = {
} else if (!inactivePromos) {
// Only activePromos supplied
activePromosToSave = activePromos;
const activePromoCodes = activePromos.map((promoObject) =>
getPromoCodeWithoutBundleIdentifier(promoObject.promoCode)
);
inactivePromosToSave = (context.getters.payment.inactivePromos ?? []).filter(
(inactivePromo) => {
return !activePromoCodes.includes(inactivePromo);
}
inactivePromosToSave = removeCurrentlyActivePromoCodesFromInactivePromos(
activePromosToSave,
context.getters.payment.inactivePromos ?? []
);
}
context.commit(storeMutations.UPDATE_PROMOS, activePromosToSave);
@ -2360,10 +2348,15 @@ export const actions = {
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;
inactivePromosToUse = inactivePromosToUse ?? order.payment.inactivePromos;
inactivePromosToUse = removeCurrentlyActivePromoCodesFromInactivePromos(
lineItemsToUse.promos,
inactivePromosToUse
);
let requestObject = {
inactivePromos: inactivePromosToUse,
order: {
@ -2797,15 +2790,15 @@ function convertGlassPieceToBackEndCompatibleFormat(glassPieces) {
function renameGlassToReplaceAttributes(glassToReplace) {
let newGlassToReplace = [];
if (glassToReplace) {
glassToReplace.forEach((item) => {
newGlassToReplace.push({ location: item.glassLocation, name: item.glassName });
newGlassToReplace = glassToReplace.map((item) => {
return { location: item.glassLocation, name: item.glassName };
});
}
return newGlassToReplace;
}
function addGuidToLineItemsIfNotAlreadyThere(lineItems) {
lineItems.forEach((lineItem) => {
lineItems?.forEach((lineItem) => {
if (!lineItem.id) {
lineItem.id = crypto.randomUUID();
}