DigitalConsumer.FixMyGlass/src/layouts/payment-method/payment-method.vue
2024-03-06 14:10:13 -05:00

604 lines
23 KiB
Vue

<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal notFullScreen ref="loadingModal" />
<div class="container-fluid">
<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 class="mb-5 mt-4" cmsWidgetName="FunnelSubHeaderWidget" />
<hr class="my-0" />
<reviewDropdown ref="reviewDropdown" />
<hr class="my-0" />
<cart
:damage="damageInfo"
:availableVaps="availableVaps"
:allowItemRemoval="true"
v-model="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle"
recyclingModalCmsWidgetName="RecycleModal" />
<hr class="my-5" />
<div>
<alert
ref="piaErrorAlert"
class="my-4"
v-if="shouldDisplayPiaAlert"
cmsWidgetName="PIAErrorAlertWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</div>
<paymentMethodQuestion
v-if="!isPiaDisabled"
v-model="paymentMethodInternalModel"
validationRules="option-required" />
<alert
v-if="isPiaDisabled"
:isDismissible="false"
alertClass="alert-info"
cmsWidgetName="NoPiaDisclaimerWidget"
shouldScrollToOnMount="false" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
:isBackButtonHidden="shouldHideBackButton"
buttonSize
@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 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 reviewDropdown from "@/layouts/payment-method/review-dropdown/review-dropdown";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import alert from "@/ux-components/alert/alert";
// Supporting Items
import baseMixin from "@/mixins/base-mixin.js";
import { settleAllPromises } from "@/helpers/layout-helper";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { paymentMethods } from "@/constants/payment-method-constants";
import { experimentSettings } from "@/constants/experiments";
import {
revalidatePromosAndValidateQueryStringPromo,
buildToastMessagesFromRevalidateOrValidatePromoResponse,
getVapsThatNeedToBeAddedToSatisfyPromos,
getPromoCodeWithoutBundleIdentifier,
removeCurrentlyActivePromoCodesFromInactivePromos,
createPromoSuccessAlert,
createPromoErrorAlert,
getNewlyInactivatedPromos,
} from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { routerParams } from "@/router/router-constants/router-params";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { deepClone } from "@/helpers/object-helper";
import { Form } from "vee-validate";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { partTypeStrings } from "@/constants/part-type-strings";
import { mapTaxedLineItemsToStoreFormat } from "../../store";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "paymentMethod",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const lineItemsFromStore = deepClone(store.getters.order.lineItems);
const frontWipersOnOrder =
lineItemsFromStore.vaps.filter(
(wiper) => wiper.partType == partTypeStrings.FRONT_WIPER
) ?? [];
const rearWipersOnOrder =
lineItemsFromStore.vaps.filter(
(wiper) => wiper.partType == partTypeStrings.REAR_WIPER
) ?? [];
const orderHasFrontWipers = frontWipersOnOrder.length > 0;
const orderHasRearWipers = rearWipersOnOrder.length > 0;
const wipersPromise =
!orderHasFrontWipers || !orderHasRearWipers
? baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_WIPERS,
{
serviceZipCode: store.getters.order.serviceLocation.zipCode,
carId: store.getters.vehicle.carId,
},
"payment-method"
)
: Promise.resolve([]);
const rainDefensePromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_RAIN_DEFENSE,
null,
"payment-method"
);
const reviewDropdownPromise = reviewDropdown.methods.loadInitialData();
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "wipers",
promise: wipersPromise,
},
{
resultKey: "rainDefense",
promise: rainDefensePromise,
},
{
resultKey: "reviewDropdownData",
promise: reviewDropdownPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
const glassParts = lineItemsFromStore.glassParts ?? [];
const supportingItems = lineItemsFromStore.supportingItems ?? [];
const vaps = lineItemsFromStore.vaps ?? [];
// if the order already has wipers on it from the quote page, use those as the available wipers
// instead of what comes from the backend. This is to prevent issues with part interchange.
const availableFrontWipers = orderHasFrontWipers
? frontWipersOnOrder
: resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.FRONT_WIPER) ??
[];
const availableRearWipers = orderHasRearWipers
? rearWipersOnOrder
: resultMap.wipers.filter((wiper) => wiper.partType == partTypeStrings.REAR_WIPER) ??
[];
const lineItemsToTax = [
resultMap.rainDefense,
...supportingItems,
...availableFrontWipers,
...availableRearWipers,
...glassParts,
...vaps,
];
const availableVaps = [
resultMap.rainDefense,
...availableFrontWipers,
...availableRearWipers,
];
const pricedLineItemsToTax = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: lineItemsToTax,
},
"payment-method",
false
);
// Promo logic
// Populate the previous state of promos for toast message usage in "next()"
const oldActivePromos = store.getters.lineItems.promos?.slice(0);
const oldInactivePromos = store.getters.order.payment.inactivePromos?.slice(0);
const promoCodeFromQueryString = getQuerystringParameter(queryStrings.PROMO);
const { validatePromoResponse, revalidatePromoResponse } =
await revalidatePromosAndValidateQueryStringPromo(
promoCodeFromQueryString,
pricedLineItemsToTax,
"payment-method"
);
// Add newly validated promos to the array to get taxed
const newValidatedPromos = validatePromoResponse?.orderPromos ?? [];
newValidatedPromos.push(
...(revalidatePromoResponse ? revalidatePromoResponse.promoLineItems : [])
);
pricedLineItemsToTax.push(...newValidatedPromos);
// End of promo logic
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
);
// Match all line items to the line items as they are in the store
// and rebuild the original structure.
const lineItems = mapTaxedLineItemsToStoreFormat(taxedLineItems, lineItemsFromStore);
const taxedVaps = mapTaxedLineItemsToStoreFormat(taxedLineItems, availableVaps);
lineItems.promos = newValidatedPromos ?? [];
const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos(
newValidatedPromos,
taxedVaps,
lineItems
);
lineItems.vaps = lineItems.vaps ?? [];
lineItems.vaps.push(...vapsToAddToCart);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.availableVaps = taxedVaps;
vm.lineItems = lineItems;
vm.inactivePromos = removeCurrentlyActivePromoCodesFromInactivePromos(
vm.lineItems.promos,
vm.inactivePromos
);
vm.updateFooterButtonText(vm.customCtaCopy);
vm.$refs.reviewDropdown.initializeComponent(resultMap.reviewDropdownData);
if (revalidatePromoResponse) {
const revalidateAlerts = buildToastMessagesFromRevalidateOrValidatePromoResponse(
revalidatePromoResponse,
oldActivePromos,
oldInactivePromos
);
revalidateAlerts.forEach((alert) => {
vm.$refs.funnelHeader.pushGlobalAlert(alert, alert.shouldAutoFade);
});
}
if (validatePromoResponse) {
const validateAlerts =
buildToastMessagesFromRevalidateOrValidatePromoResponse(validatePromoResponse);
vm.$refs.funnelHeader.pushGlobalAlert(
validateAlerts[0],
validateAlerts[0].shouldAutoFade
);
}
});
},
data() {
return {
lineItems: [],
availableVaps: [],
paymentMethodInternalModel: this.getPaymentMethodFromStore(),
inactivePromos: this.getInactivePromosFromStore(),
};
},
methods: {
arePagePrerequisitesValid() {
// Service Location
const serviceLocation = store.getters.order.serviceLocation;
const mobileReqs = !!(
serviceLocation.address &&
serviceLocation.city &&
serviceLocation.state &&
serviceLocation.zipCode
);
const providerLocation = serviceLocation.provider.address;
const dropOffInshopReqs = !!(
providerLocation.streetAddress &&
providerLocation.city &&
providerLocation.state &&
providerLocation.zipCode
);
const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
const serviceLocationReqs =
(isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs);
// Insurance
const isInsuranceSet = store.getters.order.payment.isInsurance !== null;
// Schedule
const schedule = store.getters.order.schedule;
const scheduleReqs = !!(
schedule.date &&
schedule.startTime &&
schedule.endTime &&
schedule.jobMaxMinutes &&
schedule.jobMinMinutes
);
// Customer
const customer = store.getters.order.customer;
const customerReqs = !!(
customer.firstName &&
customer.lastName &&
customer.phoneNumber &&
customer.emailAddress
);
return serviceLocationReqs && isInsuranceSet && scheduleReqs && customerReqs;
},
getPaymentMethodFromStore() {
const piaType = store.getters.order.payment.piaType;
const isPia = store.getters.order.payment.isPia && !!piaType;
return isPia ? piaType : paymentMethods.LATER;
},
updateFooterButtonText(newValue) {
this.$refs.navbar.updateButtonText(newValue);
},
getInactivePromosFromStore() {
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,
{
activePromosToUse: this.lineItems.promos,
inactivePromosToUse: this.inactivePromos,
lineItemsToUse: this.lineItems,
},
"payment-method",
false
);
// Handle error alerts here, success alerts are handled in the watcher
if (revalidatePromoResponse.errors.length) {
getNewlyInactivatedPromos(
this.inactivePromos,
revalidatePromoResponse.errors
).forEach((promoCode) => {
const errorAlert = createPromoErrorAlert(promoCode);
this.$refs.funnelHeader.pushGlobalAlert(errorAlert, errorAlert.shouldAutoFade);
});
}
if (revalidatePromoResponse.promoLineItems.length > 0) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
billToAccountNumber: store.getters.payment.billToAccountNumber,
providerNumber:
this.$store.getters.order.serviceLocation.provider.providerNumber,
appointmentType: this.$store.getters.order.serviceLocation.appointmentType,
serviceLocationCity: this.$store.getters.order.serviceLocation.city,
serviceLocationState: this.$store.getters.order.serviceLocation.state,
serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode,
pricedLineItems: revalidatePromoResponse.promoLineItems,
},
"payment-method",
false
);
}
const vapsToAddToCart = getVapsThatNeedToBeAddedToSatisfyPromos(
revalidatePromoResponse.promoLineItems,
this.availableVaps,
this.lineItems
);
this.lineItems.vaps.push(...vapsToAddToCart);
this.lineItems.promos = revalidatePromoResponse.promoLineItems;
this.inactivePromos = revalidatePromoResponse.errors.map((x) =>
getPromoCodeWithoutBundleIdentifier(x.promoCode)
);
this.$refs.loadingModal.hideModal();
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
await this.dispatchStoreAction(
storeActions.SAVE_PAYMENT_METHOD_CHOICE,
this.paymentMethod,
false
);
// save lineitems as they now have salestax added
await this.dispatchStoreAction(
storeActions.SAVE_GLASS_PARTS_SUPPRESSING_STATE_RESETTING,
this.lineItems.glassParts,
false
);
await this.dispatchStoreAction(
storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
this.lineItems.supportingItems,
false
);
await this.dispatchStoreAction(storeActions.SAVE_VAPS, this.lineItems.vaps, false);
this.dispatchStoreAction(
storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS,
{
activePromos: this.lineItems.promos,
inactivePromos: this.inactivePromos,
},
false
);
if (this.paymentMethod == paymentMethods.LATER) {
// this creates the final work order
await submitWorkOrder({ pageNameToLog: "payment-method", submitAfterSave: true });
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
} else {
this.setupPia();
}
},
async setupPia() {
this.$refs.loadingModal.showModal();
// if we don't have a work order in delete substatus, set the flag and submit.
// we need a work order number for pia so we can pass it to safeliteHop.
if (!store.getters.order.workOrderNumber) {
try {
await submitWorkOrder({
pageNameToLog: "payment-method",
submitAfterSave: false,
createDeleteStatusWorkOrderForPia: true,
});
} catch (error) {
console.log("error: response from pia submit work order:" + error.message);
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
this.$route.params[routerParams.DISPLAY_PIA_ALERT] = true;
return;
}
}
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_PAY_NOW,
this.$route
);
},
},
computed: {
damageInfo() {
return this.$store.getters.damage;
},
noPiaDisclaimer() {
return this.getCmsContent("NoPiaDisclaimerWidget", "Text");
},
customCtaCopy() {
switch (this.paymentMethod) {
case paymentMethods.CREDIT_CARD:
return "Continue to checkout";
case paymentMethods.PAYPAL:
return "Continue to Paypal";
case paymentMethods.AFTERPAY:
return "Continue to Afterpay";
default:
return null;
}
},
isPiaDisabled() {
const piaExperience = this.getSettingValue(experimentSettings.PIA_EXPERIENCE);
const piaInsurance = this.getSettingValue(experimentSettings.PIA_INSURANCE);
const isEnabled =
piaExperience === "PIA Optional" ||
piaExperience === "PIA Required" ||
piaInsurance === "true";
return !isEnabled;
},
paymentMethod() {
if (this.isPiaDisabled) {
return paymentMethods.LATER;
}
return this.paymentMethodInternalModel;
},
shouldDisplayPiaAlert() {
return (
this.$route.query[queryStrings.DISPLAY_PIA_ALERT] ||
this.$route.params[routerParams.DISPLAY_PIA_ALERT]
);
},
// Necessary to make the watcher of lineItems work
// JavaScript does not keep a record of the old value, only a reference to it's location in the memory.
// This creates a separate location for oldValue/newValue so they can be compared
// See Vue github issue #2164
lineItemsCloneForWatcher() {
return Object.assign({}, this.lineItems);
},
},
watch: {
customCtaCopy(newValue) {
this.updateFooterButtonText(newValue);
},
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);
} else if (
oldValue.promos.length == newValue.promos.length &&
oldValue.vaps.length != newValue.vaps.length
) {
this.revalidatePromos();
}
},
deep: true,
},
},
components: {
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
cart,
alert,
paymentMethodQuestion,
reviewDropdown,
},
};
</script>
<style lang="scss" scoped>
.cart {
margin-bottom: 0;
}
.no-pia-disclaimer {
color: $gray-550;
margin: 0;
}
</style>