Merge branch 'develop' into feature/CASH-93

This commit is contained in:
Johnny shultz 2025-01-28 14:08:25 -05:00
commit 089529757b
39 changed files with 1078 additions and 178 deletions

View file

@ -100,6 +100,7 @@ stages:
__VUE_APP_SAFELITE_HOP__: $(__VUE_APP_SAFELITE_HOP__)
__VUE_APP_CURRENT_ENVIRONMENT__: $(__VUE_APP_CURRENT_ENVIRONMENT__)
__VUE_APP_MY_ACCOUNT__: $(__VUE_APP_MY_ACCOUNT__)
__VUE_APP_SESSION_TIMEOUT_MINUTES__: $(__VUE_APP_SESSION_TIMEOUT_MINUTES__)
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
@ -160,6 +161,7 @@ stages:
__VUE_APP_SAFELITE_HOP__: $(__VUE_APP_SAFELITE_HOP__)
__VUE_APP_CURRENT_ENVIRONMENT__: $(__VUE_APP_CURRENT_ENVIRONMENT__)
__VUE_APP_MY_ACCOUNT__: $(__VUE_APP_MY_ACCOUNT__)
__VUE_APP_SESSION_TIMEOUT_MINUTES__: $(__VUE_APP_SESSION_TIMEOUT_MINUTES__)
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
@ -220,6 +222,7 @@ stages:
__VUE_APP_SAFELITE_HOP__: $(__VUE_APP_SAFELITE_HOP__)
__VUE_APP_CURRENT_ENVIRONMENT__: $(__VUE_APP_CURRENT_ENVIRONMENT__)
__VUE_APP_MY_ACCOUNT__: $(__VUE_APP_MY_ACCOUNT__)
__VUE_APP_SESSION_TIMEOUT_MINUTES__: $(__VUE_APP_SESSION_TIMEOUT_MINUTES__)
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
@ -280,6 +283,7 @@ stages:
__VUE_APP_SAFELITE_HOP__: $(__VUE_APP_SAFELITE_HOP__)
__VUE_APP_CURRENT_ENVIRONMENT__: $(__VUE_APP_CURRENT_ENVIRONMENT__)
__VUE_APP_MY_ACCOUNT__: $(__VUE_APP_MY_ACCOUNT__)
__VUE_APP_SESSION_TIMEOUT_MINUTES__: $(__VUE_APP_SESSION_TIMEOUT_MINUTES__)
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)

View file

@ -37,6 +37,8 @@ const applicationConfig = {
FRONTEND_LOGGER_PATH: "/analytics/api/v1/logging",
AFFILIATE_COOKIE_CONTAINING_NAME: "_Track",
COOKIE_NAME_LENGTH_MAX_LIMIT: 44,
SESSION_TIMEOUT_MINUTES: process.env.VUE_APP_SESSION_TIMEOUT_MINUTES,
RETURN_USER_PAGE: "/fmg/?fmgPage=return-user",
};
export { applicationConfig };

View file

@ -16,7 +16,7 @@ const cookieNames = {
};
const cookieExpirations = {
SESSION_ID: convertToSeconds({ minutes: 30 }),
SESSION_ID: convertToSeconds({ minutes: applicationConfig.SESSION_TIMEOUT_MINUTES }),
DXDEV: convertToSeconds({ years: 1 }),
FUNNEL_USER_ID: convertToSeconds({ weeks: 1 }),
FUNNEL_SESSION_KEY: convertToSeconds({ minutes: 30 }),

View file

@ -45,6 +45,7 @@ const queryStrings = {
ORGANIC: "organic",
ORGANIC_SOCIAL: "organic_social",
EXPERIMENTS: "experiments",
FROM_HERITAGE: "fromheritage",
};
export { queryStrings };

View file

@ -16,6 +16,8 @@ const storeMutations = {
UPDATE_VEHICLE_IMAGE_COLOR: "updateVehicleImageColor",
UPDATE_VEHICLE_VIN: "updateVehicleVin",
UPDATE_VEHICLE: "updateVehicle",
UPDATE_VEHICLE_MOBILE_STATIC_RECALIBRATION_APPLICABLE:
"updateIsMobileStaticRecalibrationApplicable",
UPDATE_IS_REPAIR: "updateIsRepair",
UPDATE_NUMBER_OF_CHIPS: "updateNumberOfChips",

View file

@ -226,7 +226,7 @@ export default {
if (this.buttonTypeString == "radio") {
classes += " radio-button-container";
} else if (this.buttonTypeString == "servicePackageRadio") {
classes = "package-wrapper col-md-4";
classes = "package-wrapper col";
}
if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) {

View file

@ -27,17 +27,17 @@
</div>
<div class="modal-body ps-5 pe-5 pb-4 pt-1">
<slot></slot>
<div class="modal-footer">
<modalButtonMain
isPrimary
class="w-100"
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
loaderColor="white"
:buttonText="footerButtonText"
@click-event="validateAndEmit"
:class="isFooterButtonDisabled && 'form-test-invalid'" />
</div>
</div>
<div class="modal-footer">
<modalButtonMain
isPrimary
class="w-100"
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
loaderColor="white"
:buttonText="footerButtonText"
@click-event="validateAndEmit"
:class="isFooterButtonDisabled && 'form-test-invalid'" />
</div>
</div>
</div>
@ -173,7 +173,7 @@ export default {
}
}
.modal-footer {
padding: 1rem 0;
padding: 1rem 1.5rem;
position: sticky;
width: 100%;
bottom: 0;
@ -216,8 +216,12 @@ export default {
overflow: auto;
flex: none;
}
.textbox-question {
padding: 0.25rem 0;
}
}
}
&.modal-dialog-centered {
align-items: flex-end;
min-height: 100%;
@ -269,8 +273,6 @@ export default {
flex-direction: column;
.textbox-question {
padding: 0;
label {
text-align: left;
font-weight: 900;
@ -284,6 +286,10 @@ export default {
.modal-disclaimer {
font-size: 0.75rem;
order: 2;
text-align: left;
a {
text-decoration: none;
}
}
.modal-footer {
margin: 0 0 1.5rem 0;

View file

@ -128,7 +128,7 @@ export default {
@include blue-gradient;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
&:focus-visible {
// Keyboard focus for accessibility
outline: none;
box-shadow:

View file

@ -45,6 +45,9 @@
<script>
import textLink from "@/ux-components/text-link/text-link";
import buttonMain from "@/ux-components/button-main/button-main";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import analyticsMixin from "@/mixins/analytics-mixin";
import router from "@/router/index.js";
export default {
name: "navbar",
@ -94,10 +97,28 @@ export default {
document.onkeydown = function (e) {
return false;
};
this.$emit("ForwardClicked");
// check session expired and initSession to recreate cookies
if (analyticsMixin.methods.sessionExpired()) {
this.routeReturnUser();
} else {
this.$emit("ForwardClicked");
}
},
linkClick() {
this.$emit("BackClicked");
// check session expired and initSession to recreate cookies
if (analyticsMixin.methods.sessionExpired()) {
this.routeReturnUser();
} else {
this.$emit("BackClicked");
}
},
routeReturnUser() {
analyticsMixin.methods.initSession();
router.push({
path: "/",
query: { fmgPage: fmgPageValues.RETURN_USER },
});
},
},
};

View file

@ -1,7 +1,8 @@
<template>
<div class="save-progress-modal-question">
<div class="save-progress-modal-question" :class="isProgressSaved ? 'progress-saved' : ''">
<buttonMain
ref="buttonMain"
type="button"
v-if="!isProgressSaved"
:buttonText="buttonText"
loaderColor="white"
@ -17,6 +18,7 @@
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
@footer-button-event="saveProgress">
<p class="modal-body-inner">{{ modalBodyText }}</p>
@ -35,6 +37,7 @@ import modal from "@/digital-components/modal/modal";
import { storeActions } from "@/constants/store-actions.js";
import buttonMain from "@/ux-components/button-main/button-main";
import alert from "@/ux-components/alert/alert";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
export default {
name: "save-progress-modal-question",
@ -50,6 +53,10 @@ export default {
modalWidgetName: String,
pageName: String,
},
unmounted() {
// remove any modal effects before leaving page (e.g. user hits browser back button)
this.modal.closeModal();
},
computed: {
buttonText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
@ -78,17 +85,21 @@ export default {
this.modal.openModal();
this.modal.resetButtonStyle();
},
onModalClosed() {
this.userInput = "";
this.modal.resetForm();
},
async saveProgress() {
// save email address to store
await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.userInput, false);
// send store call to send to new API (that triggers an email send)
await saveQuote({ pageNameToLog: this.pageName });
// hide save progress button; show success message alert
this.isProgressSaved = true;
this.modal.closeModal();
// send store call to send to new API (that triggers an email send)
this.$emit("save-progress");
},
},
@ -113,6 +124,10 @@ export default {
align-items: center;
border-radius: 0.5rem;
&.progress-saved {
background: none;
}
.save-progress-button {
order: 4;
position: relative;
@ -125,7 +140,7 @@ export default {
width: auto;
height: auto;
text-decoration: underline;
text-underline-offset: 0.375rem;
text-underline-offset: 0.25rem;
line-height: 1rem;
padding: 1rem;
margin: 0 auto;
@ -148,6 +163,7 @@ export default {
:deep(.alert) {
border-radius: $border-radius-lg;
border: 1px solid $green;
margin-top: 0;
}
:deep(.alert-heading) {
position: relative;

View file

@ -16,6 +16,7 @@
class="save-progress-popup-question" />
<buttonMain
ref="skipButton"
type="button"
:buttonText="buttonText"
loaderColor="white"
:suppressLoader="true"
@ -37,6 +38,7 @@ import modal from "@/digital-components/modal/modal";
import { storeActions } from "@/constants/store-actions.js";
import buttonMain from "@/ux-components/button-main/button-main";
import alert from "@/ux-components/alert/alert";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
export default {
name: "save-progress-popup-question",
@ -50,6 +52,10 @@ export default {
mounted() {
if (this.showSaveProgressPopup) this.modal.openModal();
},
unmounted() {
// remove any modal effects before leaving page (e.g. user hits browser back button)
this.modal.closeModal();
},
emits: ["close-save-progress-popup", "save-progress"], // <--- should remove oodles of warnings in dev tools
props: {
modelValue: Object,
@ -94,15 +100,14 @@ export default {
// save email address to store
await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.userInput, false);
// send store call to send to new API (that triggers an email send)
await saveQuote({ pageNameToLog: this.pageName });
// hide save progress button; show success message alert
this.isProgressSaved = true;
// send store call to send to new API (that triggers an email send)
this.$emit("save-progress");
// send call to close popup
this.closeModal();
this.$emit("close-save-progress-popup");
// communicate to parent the new status
this.$emit("save-progress-saved");
},
},
@ -138,11 +143,12 @@ export default {
}
}
.skip-button {
.btn.btn-secondary.skip-button {
order: 4;
position: relative;
color: $black;
border: none;
border-radius: 0.25rem;
font-weight: 900;
font-size: 0.875rem;
text-align: center;
@ -150,7 +156,7 @@ export default {
width: auto;
height: auto;
text-decoration: underline;
text-underline-offset: 0.375rem;
text-underline-offset: 0.25rem;
line-height: 1rem;
padding: 0 0 4px 0;
margin: 0 auto 1.5rem auto;
@ -159,12 +165,17 @@ export default {
&:hover,
&:active,
&:focus,
&:focus-visible {
&:focus {
color: $blue-700;
background: none;
box-shadow: none;
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 3px $white,
0 0 0 5.5px $blue-300;
}
&.delay {
// fixes flicker while transitioning between states
@ -200,9 +211,6 @@ export default {
}
.modal-disclaimer {
order: 4;
a {
text-decoration: none;
}
}
&.progress-saved {

View file

@ -110,7 +110,13 @@ export default {
if (error.response.status && error.response.status != "404") {
// Do not route to error logic when no wipers found or no promo found (404s)
router.navigateError();
const errorPayload = {
cause: `Response error ${error.response.status}`,
currentPage: pageNameToLog,
endpoint: endpoint,
};
router.navigateError(errorPayload);
// do not log 404 errors from services because we return NotFound
// when a service doesn't return an object

View file

@ -76,6 +76,10 @@ export async function getImplicitNavigation(toRoute) {
const skipVin = await skipVinLookup();
if (store.getters.order.payment.insuranceCoverage?.isVerified) {
return fmgPageValues.HERITAGE;
}
if (quoteComponent.methods.arePagePrerequisitesValid()) {
// Do not send to quote if verified insurance user.
if (store.getters.requiresVerifiedRedirecting) {

View file

@ -8,3 +8,17 @@ export function getQuerystringParameter(key) {
return lowerCaseParams.get(key) ? lowerCaseParams.get(key) : null;
}
// if you add the fmgPage to the querystringobject before calling, then pass true for skipFmgPageName
export function buildQuerystringObject(qso, skipFmgPageName=false) {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
for (const [name, value] of urlParams) {
if (name.toLowerCase() === "fmgpage" && skipFmgPageName) {
continue;
}
qso[name] = value;
}
return qso;
}

View file

@ -16,8 +16,8 @@
<!-- save progress goes into <slot> on questions-page-layout -->
<saveProgressModalQuestion
modalWidgetName="SaveProgressModalWidget"
v-if="!isEmailInStoreOnPageLoad"
@save-progress="saveProgress" />
pageName="capability-questions"
v-if="showSaveProgressModal" />
</questions-page-layout>
</Form>
</template>
@ -38,7 +38,6 @@ import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import baseMixin from "@/mixins/base-mixin.js";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
// DEFINE VALIDATION RULES
defineRule("questions-required", required(errorMessages.OPTION_REQUIRED));
@ -65,7 +64,9 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.isEmailInStoreOnPageLoad = emailFromStore?.length > 0;
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
await baseMixin.methods.dispatchStoreAction(
@ -87,7 +88,7 @@ export default {
questionsData: null,
selectedAnswers: {},
currentGlassIndex: 0,
isEmailInStoreOnPageLoad: null,
showSaveProgressModal: null,
};
},
computed: {
@ -157,9 +158,6 @@ export default {
this.questionsData
);
},
saveProgress() {
saveQuote({ pageNameToLog: "capability-questions" });
},
async forwardButtonAction() {
const questionAnswersArray = this.questionsData.map((glass) => {
// get answerResult2 of returned answer

View file

@ -16,8 +16,8 @@
<!-- save progress goes into <slot> on questions-page-layout -->
<saveProgressModalQuestion
modalWidgetName="SaveProgressModalWidget"
v-if="!isEmailInStoreOnPageLoad"
@save-progress="saveProgress" />
pageName="molding-questions"
v-if="showSaveProgressModal" />
</questions-page-layout>
</Form>
</template>
@ -38,7 +38,6 @@ import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import baseMixin from "@/mixins/base-mixin.js";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
// DEFINE VALIDATION RULES
defineRule("questions-required", required(errorMessages.OPTION_REQUIRED));
@ -65,7 +64,9 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.isEmailInStoreOnPageLoad = emailFromStore?.length > 0;
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
await baseMixin.methods.dispatchStoreAction(
@ -88,7 +89,7 @@ export default {
questionsData: null,
selectedAnswers: {},
currentGlassIndex: 0,
isEmailInStoreOnPageLoad: null,
showSaveProgressModal: null,
};
},
computed: {
@ -157,9 +158,6 @@ export default {
this.questionsData
);
},
saveProgress() {
saveQuote({ pageNameToLog: "molding-questions" });
},
async forwardButtonAction() {
const questionAnswersArray = this.questionsData.map((glass) => {
return {

View file

@ -16,8 +16,8 @@
<!-- save progress goes into <slot> on questions-page-layout -->
<saveProgressModalQuestion
modalWidgetName="SaveProgressModalWidget"
v-if="!isEmailInStoreOnPageLoad"
@save-progress="saveProgress" />
pageName="part-questions"
v-if="showSaveProgressModal" />
</questions-page-layout>
</Form>
</template>
@ -38,7 +38,6 @@ import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import baseMixin from "@/mixins/base-mixin.js";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
// DEFINE VALIDATION RULES
defineRule("questions-required", required(errorMessages.OPTION_REQUIRED));
@ -65,7 +64,9 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.isEmailInStoreOnPageLoad = emailFromStore?.length > 0;
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
const serviceState = store.getters.order.serviceLocation.state;
@ -89,7 +90,7 @@ export default {
questionsData: null,
selectedAnswers: {},
currentGlassIndex: 0,
isEmailInStoreOnPageLoad: null,
showSaveProgressModal: null,
};
},
computed: {
@ -156,9 +157,6 @@ export default {
this.questionsData
);
},
saveProgress() {
saveQuote({ pageNameToLog: "part-questions" });
},
async forwardButtonAction() {
const questionAnswersArray = this.questionsData.map((glass) => {
return {

View file

@ -429,7 +429,8 @@ export default {
// prettier-ignore
{
const log = getQuerystringParameter(queryStrings.LOG);
if (eval(log) || !preReqResult) {
const logAsBool = (log?.toLowerCase() === "true");
if (logAsBool || !preReqResult) {
console.log("------------- payment-method.vue pagePrereqs start -----------------");
console.log(new Date() + " serviceLocationReqs::isMobile: " + isMobile);
console.log(new Date() + " serviceLocationReqs::mobileReqs: " + mobileReqs);
@ -621,7 +622,7 @@ export default {
: this.$store.getters.order.serviceLocation?.appointmentType;
},
ServiceLocationFullAddressText() {
return `${this.ServiceLocationAddress.toLowerCase()}${this.ServiceLocationAddress2 ? ", " + this.ServiceLocationAddress2 : ""}, ${this.ServiceLocationCity.toLowerCase()}, ${this.ServiceLocationState} ${this.ServiceLocationZipCode}`;
return `${this.ServiceLocationAddress?.toLowerCase()}${this.ServiceLocationAddress2 ? ", " + this.ServiceLocationAddress2 : ""}, ${this.ServiceLocationCity?.toLowerCase()}, ${this.ServiceLocationState} ${this.ServiceLocationZipCode}`;
},
ServiceLocationFullAddress() {
if (this.AppointmentType === AppointmentTypeStrings.MOBILE) {

View file

@ -26,7 +26,7 @@ export default {
computed: {
displayContent() {
return [
`${this.addressInfo.address.toLowerCase()}${this.addressInfo.address2 ? ", " : ""}${this.addressInfo.address2.toLowerCase()}, ${this.addressInfo.city.toLowerCase()}, ${this.addressInfo.state} ${this.addressInfo.zipCode}`,
`${this.addressInfo?.address?.toLowerCase()}${this.addressInfo?.address2 ? ", " : ""}${this.addressInfo?.address2?.toLowerCase()}, ${this.addressInfo?.city?.toLowerCase()}, ${this.addressInfo.state} ${this.addressInfo.zipCode}`,
];
},
addressInfo() {

View file

@ -20,7 +20,7 @@
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-12 col-xl-8">
<div class="col-md-12 mb-5" :class="getColCount()">
<servicePackageQuestion
ref="servicePackage"
cashCmsWidgetName="CashServicePackageQuestionWidget"
@ -32,6 +32,7 @@
:shouldHideRecalibration="shouldHideRecalibration"
@vapsItemsSelected="vapsItemsSelectedAction"
@servicePackageDiscountSelected="servicePackageDiscountSelectedAction"
@currentNumberOfPackages="currentNumberOfPackagesAction"
:servicePackage="servicePackage"
:activePromos="lineItems.promos"
v-on="{ 'buttonEvent.openModal': openModalAction }"
@ -40,7 +41,7 @@
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-12 col-xl-8 p-md-0">
<div class="col-md-12" :class="getColCount()">
<!-- If Cash AND vehicle does not require recal OR if Cash AND State requires showing recal, then show Afterpay banner -->
<afterpayModalBanner
v-if="showAfterpayBanner"
@ -82,8 +83,8 @@
<saveProgressModalQuestion
modalWidgetName="SaveProgressModalWidget"
modalName="SaveProgressModal"
v-if="!isEmailInStoreOnPageLoad"
@save-progress="saveProgress" />
v-if="showSaveProgressModal"
pageName="quote" />
<textBlock
cmsWidgetName="quoteDisclaimer"
@ -98,9 +99,10 @@
<saveProgressPopupQuestion
modalWidgetName="SaveProgressPopupWidget"
modalName="SaveProgressPopup"
v-if="!isEmailInStoreOnPageLoad && showSaveProgressPopup"
pageName="quote"
v-if="showSaveProgressPopup"
:showSaveProgressPopup="showSaveProgressPopup"
@save-progress="saveProgress"
@save-progress-saved="updateSaveProgressAsSaved"
@close-save-progress-popup="closeSaveProgressPopup" />
</div>
</div>
@ -209,6 +211,7 @@ export default {
const emailFromStore = store.getters.order.customer.emailAddress;
const isEmailInStoreOnPageLoad = emailFromStore?.length > 0;
const showSaveProgressPopup = isEmailInStoreOnPageLoad ? false : true;
const showSaveProgressModal = isEmailInStoreOnPageLoad ? false : true;
const lineItems = deepClone(store.getters.order.lineItems);
lineItems.vaps = lineItems.vaps ?? [];
@ -290,8 +293,12 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.isEmailInStoreOnPageLoad = isEmailInStoreOnPageLoad;
vm.showSaveProgressPopup = showSaveProgressPopup;
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment the 2 lines below
// vm.showSaveProgressPopup = showSaveProgressPopup;
// vm.showSaveProgressModal = showSaveProgressModal;
vm.addableVaps = addableVaps;
vm.lineItems = lineItems;
vm.availableLineItems = pricingResults;
@ -438,10 +445,15 @@ export default {
servicePackage: null,
holdParentAccountNumber: null,
holdBillToAccountNumber: null,
isEmailInStoreOnPageLoad: null,
showSaveProgressPopup: null,
showSaveProgressModal: null,
isSaveProgressComplete: null,
packageNumber: null,
};
},
mounted() {
this.attachCustomEvents();
},
computed: {
lineItemsCloneForWatcher() {
return Object.assign({}, this.lineItems);
@ -486,6 +498,16 @@ export default {
},
},
methods: {
getColCount() {
if (this.packageNumber > 2) {
return "col-xl-8";
} else {
return "col-xl-6";
}
},
currentNumberOfPackagesAction(packageNumber) {
this.packageNumber = packageNumber;
},
openModalAction(modalName) {
this.$refs[modalName].openModal();
},
@ -526,10 +548,8 @@ export default {
backButtonAction() {
vehicleQuestionsMixin.methods.navigateBack(this);
},
saveProgress() {
this.isEmailInStoreOnPageLoad = true;
this.showSaveProgressPopup = false;
saveQuote({ pageNameToLog: "quote" });
updateSaveProgressAsSaved() {
this.showSaveProgressModal = false;
},
closeSaveProgressPopup() {
this.showSaveProgressPopup = false;
@ -677,9 +697,6 @@ export default {
});
},
},
mounted() {
this.attachCustomEvents();
},
watch: {
lineItemsCloneForWatcher: {
handler(newValue, oldValue) {

View file

@ -150,6 +150,7 @@ export default {
: "Special: Save $" + this.getServicePackageDiscountPrice(),
},
}));
this.$emit("currentNumberOfPackages", modifiedAnswers.length);
return modifiedAnswers;
},
isServicePackageDiscountOnOrder() {

View file

@ -141,10 +141,22 @@ export default {
.package-main {
.package-wrapper {
margin: 1rem 0;
&:first-child {
margin: 1rem 0 0 0;
}
&:last-child {
margin: 1rem 0 0 0;
}
@include media-breakpoint-up(md) {
margin: 1rem 0.5rem;
margin: 1rem 0.5rem 0 0.5rem;
display: flex;
flex-direction: column;
&:first-child {
margin: 1rem 0.5rem 0 0;
}
&:last-child {
margin: 1rem 0 0 0.5rem;
}
}
label {
@ -322,6 +334,7 @@ export default {
display: flex;
justify-content: flex-end;
align-items: center;
align-self: flex-start;
div.strikethrough-discount-price {
text-decoration: line-through;
margin-right: 0.5rem;

View file

@ -0,0 +1,312 @@
// Components
import returnUser from "@/layouts/return-user/return-user.vue";
// Supporting Files
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper";
import baseMixin from "@/mixins/base-mixin.js";
import { dispatchStoreAction } from "@/mixins/base-mixin.js";
import store from "@/store";
import router from "@/router";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import { Form } from "vee-validate";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { navigationScenarios } from "@/router/router-constants/navigation-scenarios";
import { settleAllPromises } from "@/helpers/layout-helper";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import { experimentSettings } from "../../constants/experiments";
import { storeActions } from "@/constants/store-actions";
import buttonMain from "@/ux-components/button-main/button-main";
// Constants
// Setup global mocks
let mockStoreActionData = {};
let mockStoreData = {};
let mockExperimentSettings = {
experiments: [
{
universeName: "ConceptFunnel",
settings: {
SuppressVinCapture: false,
},
},
],
};
function resetMockStoreData() {
mockStoreData = {
vehicle: {
year: "2000",
make: "TestMake",
model: "TestModel",
style: "TestStyle",
carId: "TestID",
vin: null,
registration: {
licensePlate: null,
},
},
serviceLocation: {
address: null,
address2: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
appointmentType: null,
isVehicleProtected: null,
provider: {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
},
},
techNotes: null,
},
customer: {
firstName: null,
lastName: null,
emailAddress: null,
phoneNumber: null,
isSmsOptIn: null,
},
damage: {
isRepair: false,
numberOfChips: null,
glassToReplace: [{ glassLocation: "Windshield", glassName: "windshield" }],
partQuestionAnswers: null,
moldingQuestionAnswers: null,
capabilityQuestionAnswers: null,
dateOfLoss: null,
damageCause: null,
},
lineItems: {
glassParts: [
{
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,
},
],
supportingItems: null,
vaps: null,
serverData: null,
promos: null,
},
payment: {
isInsurance: null,
insuranceCoverage: {
isVerified: null,
coverageStatus: null,
coverageType: null,
coverageVerificationType: null,
},
parentAccountNumber: 0,
billToAccountNumber: null,
isPia: null,
piaType: null,
inactivePromos: null,
paypalToken: null,
nextGenSettledAmount: 0,
ccToken: {
subscriptionId: null,
expMonth: null,
expYear: null,
cardType: null,
billToPostalCode: null,
billToFirstName: null,
billToLastName: null,
referenceNumber: null,
authCode: null,
transactionId: null,
transReferenceNumber: null,
lastFour: null,
},
},
policy: {
currentDeductible: 0,
policyNumber: null,
isItac: false,
additionalAuthFlag: null,
isNoComp: false,
insuranceCompanyName: null,
},
schedule: {
date: null,
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
externalParameterServiceZip: {
zipCode: null,
emailAddress: null,
},
externalParameterState: { isExternalParameter: false },
};
}
function applyMockStoreDataToGetters() {
store.getters = {
vehicle: mockStoreData.vehicle,
};
store.state.order = mockStoreData;
store.state.applicationUser.experiments = mockExperimentSettings;
}
async function mockDispatchStoreAction(actionName) {
return mockStoreActionData[actionName];
}
jest.mock("@/mixins/base-mixin.js", () => ({
methods: {
dispatchStoreAction: jest.fn(),
dispatchStoreActionWithLogging: jest.fn().mockImplementation(mockDispatchStoreAction),
},
}));
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: () => Promise.resolve("content"),
}));
jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({
saveSession: jest.fn(),
}));
jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
deleteFunnelCookie: jest.fn(),
getFunnelCookie: jest.fn(),
}));
router.navigateWithoutSaving = jest.fn();
router.navigateWithSaving = jest.fn();
// Tests
describe("return-user.vue", () => {
beforeEach(() => {
resetMockStoreData();
jest.clearAllMocks();
});
describe("Test prerequisites are valid and child components are rendered", () => {
test("expect pagePrerequisites are valid to be called", () => {
// Arrange
const wrapper = setupMocks({});
applyMockStoreDataToGetters();
// Act
const pagePrerequisitesSpy = jest.spyOn(wrapper.vm, "arePagePrerequisitesValid");
wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(pagePrerequisitesSpy).toBeCalled();
expect(getFunnelCookie).toHaveBeenCalled();
});
test("renders child components", () => {
const wrapper = setupMocks({});
expect(wrapper.findComponent(funnelHeader).exists()).toBe(true);
expect(wrapper.findComponent(funnelSubHeader).exists()).toBe(true);
expect(wrapper.findComponent(navbar).exists()).toBe(true);
});
});
describe("Navigation", () => {
test("Check forwardButtonAction is working", async () => {
// Arrange
const wrapper = setupMocks({});
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith(
navigationScenarios.CLICKED_FORWARD,
wrapper.vm.$route
);
});
test("expect functions in startOver to be called", async () => {
//Arrange
const wrapper = setupMocks({});
// Act
const dispatchStoreActionSpy = jest.spyOn(wrapper.vm, "dispatchStoreAction");
await wrapper.vm.$nextTick();
await wrapper.vm.startOver();
// Assert
expect(wrapper.vm.$router.navigateWithoutSaving).toBeCalledWith(
navigationScenarios.CLICKED_FORWARD,
wrapper.vm.$route
);
expect(dispatchStoreActionSpy).toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(deleteFunnelCookie).toHaveBeenCalled();
});
});
});
function setupMocks({ customMountOptions }) {
const route = { query: { fmgPage: "return-user" }, params: {} };
baseMixin.methods.ResetExternalParamsAndHideModal = jest.fn();
const mountOptions = getMountOptions({
...customMountOptions,
route: route,
});
mountOptions.global.mocks["$store"] = store;
mountOptions.global.mocks["$router"] = router;
baseMixin.methods.isFormValid = jest.fn().mockReturnValue(true);
mountOptions["attachTo"] = document.body;
const wrapper = shallowMount(returnUser, mountOptions, {
stubs: {
Form,
funnelHeader,
funnelSubHeader,
navbar,
loadingModal: true,
buttonMain,
},
});
return wrapper;
}

View file

@ -0,0 +1,129 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<div class="return-user small-question-text">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center return-user-spacing">
<div class="col-md-6 col-xl-4 mt-4">
<funnelSubHeader
ref="funnelSubHeader"
cmsWidgetName="FunnelSubHeaderWidget" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
buttonSize
:isBackButtonHidden="true"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4 start-over">
<a @click="startOver" alt="Start Over Button"
><img v-bind:src="startOverImage" />{{ startOverText }}</a
>
</div>
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { Form } from "vee-validate";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { storeActions } from "@/constants/store-actions";
export default {
name: "return-user",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.startOverText = resultMap.cmsContent.StartOverWidget.HeaderText;
vm.startOverImage = resultMap.cmsContent.StartOverWidget.Image;
});
},
data() {
return {
startOverText: "",
startOverImage: null,
};
},
methods: {
arePagePrerequisitesValid() {
return getFunnelCookie() !== null;
},
async forwardButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
},
async startOver() {
this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
this.GaActions.CLICKED,
"Start over",
true
);
deleteFunnelCookie();
await this.dispatchStoreAction(storeActions.RESET_STATE);
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
},
},
components: {
Form,
funnelHeader,
funnelSubHeader,
navbar,
},
};
</script>
<style lang="scss" scoped>
.start-over {
text-align: center;
a {
font-weight: 600;
color: $black;
cursor: pointer;
text-decoration: underline;
}
}
.return-user {
.return-user-spacing {
margin-top: 14rem;
}
}
</style>

View file

@ -11,7 +11,10 @@ export async function getPricedMobileFeePart(serviceZipCode, pageNameToLog) {
// Get the Mobile Fee Part
const mobileFeePart = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_MOBILE_FEE_PART,
null,
{
serviceZipCode: serviceZipCode,
serviceZipCodeCtu: zipCodeData.zipCodeCtu,
},
pageNameToLog,
false
);

View file

@ -241,57 +241,46 @@ export default {
this.displayMismatchStateAndZipAlert = false;
},
async setMobileLocation() {
if (
this.internalModel.addressQuestions.zipCode !==
this.modelValue.addressQuestions.zipCode
) {
this.resetAlerts();
// Validate the Zip Code
const zipCodeData = await this.getZipCodeData(
this.internalModel.addressQuestions.zipCode,
this.resetAlerts();
// Validate the Zip Code
const zipCodeData = await this.getZipCodeData(
this.internalModel.addressQuestions.zipCode,
"service-location"
);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.resetModalButtonStyle();
} else if (zipCodeData.state != this.internalModel.addressQuestions.state) {
this.displayMismatchStateAndZipAlert = true;
this.resetModalButtonStyle();
} else {
// retrieve mobile fee part
const serviceZipCode = this.internalModel.addressQuestions.zipCode;
const mobileFeePart = await getPricedMobileFeePart(
serviceZipCode,
"service-location"
);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.resetModalButtonStyle();
} else if (zipCodeData.state != this.internalModel.addressQuestions.state) {
this.displayMismatchStateAndZipAlert = true;
this.resetModalButtonStyle();
} else {
// retrieve mobile fee part
const serviceZipCode = this.internalModel.addressQuestions.zipCode;
const mobileFeePart = await getPricedMobileFeePart(
serviceZipCode,
"service-location"
);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(
serviceZipCode,
null,
"service-location"
);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(
serviceZipCode,
null,
"service-location"
);
const billToAccountNumber = await getBillToAccountNumber(
this.internalModel.zipCodeCtu
);
const billToAccountNumber = await getBillToAccountNumber(
this.internalModel.zipCodeCtu
);
// update content related to service zip code
this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-serviceability", serviceabilityDetails.data);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
this.$emit("updated-mobile-ctu", zipCodeData.zipCodeCtu);
this.$emit("updated-bill-to-account-number", billToAccountNumber);
// update content related to service zip code
this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-serviceability", serviceabilityDetails.data);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
this.$emit("updated-mobile-ctu", zipCodeData.zipCodeCtu);
this.$emit("updated-bill-to-account-number", billToAccountNumber);
// update the page level model
this.$emit("update:modelValue", this.internalModel);
//Page advance to Schedule page
this.$emit("mobileLocationSelected");
}
} else {
// Update the page level model
// update the page level model
this.$emit("update:modelValue", this.internalModel);
//Page advance to Schedule page

View file

@ -181,6 +181,9 @@ beforeEach(() => {
zipCode: "43235",
state: "OH",
},
vehicle: {
isMobileStaticRecalibrationApplicable: true,
},
},
damage: {
isRepair: false,

View file

@ -159,6 +159,7 @@ import { defineRule } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
const MOBILE_FEE_PART_TYPE = "MOBILE FEE";
const MOBILE_STATIC_RECAL_FEE_PART_NUMBER = "RECAL MOBILE";
// DEFINE VALIDATION RULES
defineRule("mobile-location-required", (value) => {
@ -188,6 +189,8 @@ export default {
isRecalibrationServiceableInshop: null,
isGlassServiceableMobile: null,
isRecalibrationServiceableMobile: null,
isVehicleMobileStaticRecalibrationApplicable:
this.getIsVehicleMobileStaticRecalibrationApplicableFromStore(),
selectedAppointmentType: this.getSelectedAppointmentType(),
selectedProvider: this.getSelectedProvider(),
mobileFeePart: null,
@ -306,11 +309,21 @@ export default {
},
isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) {
return this.isGlassServiceableMobile && this.isRecalibrationServiceableMobile;
return (
(this.isGlassServiceableMobile && this.isRecalibrationServiceableMobile) ||
this.isMobileStaticRecalibrationApplicable
);
} else {
return this.isGlassServiceableMobile;
}
},
isMobileStaticRecalibrationApplicable() {
return (
this.isVehicleMobileStaticRecalibrationApplicable &&
this.mobileFeePart?.partNumber == MOBILE_STATIC_RECAL_FEE_PART_NUMBER &&
(this.isInsurance ? this.mobileFeePart?.isInsurable : true)
);
},
mobileFeeApplies() {
if (
this.mobileFeePart?.laborAmount > 0 ||
@ -349,7 +362,8 @@ export default {
return (
this.isServiceableInshop &&
this.isGlassServiceableMobile &&
this.isRecalibrationServiceableMobile === false
this.isRecalibrationServiceableMobile === false &&
!this.isMobileStaticRecalibrationApplicable
);
},
displayRecalibrationWarning() {
@ -485,6 +499,9 @@ export default {
getSelectedProvider() {
return store.getters.order.serviceLocation.provider;
},
getIsVehicleMobileStaticRecalibrationApplicableFromStore() {
return store.getters.order.vehicle.isMobileStaticRecalibrationApplicable;
},
resetMobileLocation() {
this.streetAddress = "";
this.apartmentNumberOrBusinessName = "";

View file

@ -48,8 +48,8 @@
<saveProgressModalQuestion
modalWidgetName="SaveProgressModalWidget"
v-if="!isEmailInStoreOnPageLoad"
@save-progress="saveProgress" />
pageName="vehicle-parts"
v-if="showSaveProgressModal" />
</div>
</div>
</div>
@ -75,7 +75,6 @@ import store from "@/store";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import baseMixin from "@/mixins/base-mixin.js";
import { partTypeStrings } from "@/constants/part-type-strings";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
export default {
name: "vehicle-parts",
@ -95,7 +94,10 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.isEmailInStoreOnPageLoad = emailFromStore?.length > 0;
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
// Glass Part Question dynamic component
Object.keys(vm.$refs)
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
@ -115,7 +117,7 @@ export default {
selectedGlassParts: {},
alertWidgetData: Object,
alreadyPopulatedPartsData: [],
isEmailInStoreOnPageLoad: null,
showSaveProgressModal: null,
};
},
computed: {
@ -234,9 +236,6 @@ export default {
});
});
},
saveProgress() {
saveQuote({ pageNameToLog: "vehicle-parts" });
},
},
mounted() {
this.LoadInitialPartsData();

View file

@ -117,6 +117,7 @@ export default {
modelOptions: [],
styleOptions: [],
displayNoServiceAlert: false,
isMobileStaticRecalibrationApplicable: this.getIsMobileStaticRecalibrationApplicable(),
};
},
@ -438,6 +439,8 @@ export default {
this.imageVifNumber = result?.data.imageVifNumber;
this.imageVifColor = result?.data.imageVifColor;
this.displayNoServiceAlert = !result?.data.canSafeliteService;
this.isMobileStaticRecalibrationApplicable =
result?.data.isMobileStaticRecalibrationApplicable;
},
resetAlert() {
this.displayNoServiceAlert = false;
@ -456,6 +459,8 @@ export default {
imageUrl: this.imageUrl,
imageVifNumber: this.imageVifNumber,
imageVifColor: this.imageVifColor,
isMobileStaticRecalibrationApplicable:
this.isMobileStaticRecalibrationApplicable,
},
false
);
@ -531,6 +536,9 @@ export default {
getImageVifColorfromStore() {
return store.getters.vehicle.imageVifColor;
},
getIsMobileStaticRecalibrationApplicable() {
return store.getters.vehicle.isMobileStaticRecalibrationApplicable;
},
},
components: {

View file

@ -26,6 +26,10 @@ import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
import { applicationConfig } from "../constants/application-config";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import router from "@/router/index.js";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
export default {
methods: {
@ -650,6 +654,13 @@ export default {
});
},
pushPageErrorToDataLayer(error) {
pushToDataLayerIfDefined({
event: "page-error",
error: error,
});
},
prependActionToMethod(object, method, actionToPrepend) {
const baseMethodName = method.name.startsWith("bound ")
? method.name.substring(6)
@ -701,7 +712,22 @@ export default {
return !areAllSessionCookiesSet();
},
// sessionExpired is true when one of the analytics cookies(sid, dxdev) has expired but we still have the vehicle year in vuex
sessionExpired() {
const fromHeritage = getQuerystringParameter(queryStrings.FROM_HERITAGE) === "true";
if (this.noSession() && !fromHeritage && store.getters.order.vehicle?.year > 0) {
return true;
} else {
return false;
}
},
async validateSession() {
// do not init session if it is expired. let the click event on nav-bar handle session expired logic
if (this.sessionExpired()) {
return;
}
if (this.noSession()) {
await this.initSession();
}

View file

@ -6,7 +6,7 @@ import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader.js"
import { routingTable } from "@/router/router-constants/routing-table.js";
import { globalEvents, globalEventTypes } from "@/constants/events";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { getQuerystringParameter, buildQuerystringObject } from "@/helpers/querystring-helper";
import { getDeviceIdValue } from "@/helpers/heritage-integration/cookie-helper";
import { showFmgLoadingModal } from "@/helpers/loading-modal-helper";
import { fmgPageValues, funnelStartPageName } from "@/router/router-constants/fmgPage-values";
@ -26,6 +26,7 @@ import { loadSessionIfPresent, saveSession } from "@/helpers/heritage-integratio
import {
getPageToRouteExistingOrderTo,
navigateToHeritageFunnel,
getImplicitNavigation,
} from "@/helpers/heritage-integration/navigation-helper";
import baseMixin from "@/mixins/base-mixin";
@ -53,9 +54,15 @@ const routes = [
log("------------- router index.js beforeEnter start -----------------");
log(" --to: ", to);
log(" --cookie: ", getFunnelCookie());
log(` --from.redirectedFrom:>${JSON.stringify(from.redirectedFrom)}<`, "");
await analyticsMixin.methods.validateSession();
// after session is validated, remove the fromHeritage querystring if it exists so session expiration works
if (to.query) {
delete to.query[queryStrings.FROM_HERITAGE];
}
if (getFunnelCookie()?.SuppressConceptFunnel) {
log(" --go to heritage suppressConceptFunnel: ");
await navigateToHeritageFunnel({ shouldSaveSession: false });
@ -65,11 +72,25 @@ const routes = [
// If the saved session has timed out, clear the session, execute 404 logic.
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
log(" --save session timeout go to start");
const errorPayload = {
cause: "expired session",
currentPage: from?.query?.fmgPage,
nextPage: to?.query?.fmgPage,
};
await baseMixin.methods.dispatchStoreAction(storeActions.RESET_STATE);
deleteFunnelCookie();
GoToFunnelStartOn404(next);
GoToFunnelStartOn404(next, errorPayload);
return;
}
const fromReturnUser = from?.name === fmgPageValues.RETURN_USER;
const fromContentSite = getQuerystringParameter(queryStrings.START_TYPE) === "fmg";
const toReturnUserPage = to.query?.fmgPage === fmgPageValues.RETURN_USER;
log(" --fromReturnUser ", fromReturnUser);
log(" --to.query.fmgPage ", to.query?.fmgPage);
// Intercept all navigation if a submitted order exists in storage
if (window.sessionStorage.getItem("submittedOrder") !== null) {
if (to.query.fmgPage !== funnelStartPageName) {
@ -78,7 +99,29 @@ const routes = [
}
}
// On entering the funnel "fresh", read cookie information, decide what to do next.
else if (from.redirectedFrom === undefined) {
else if (from.redirectedFrom === undefined || fromReturnUser) {
// if entering the funnel from the content site, check and see if there is already a vehicle year in vuex.
// if so, send them to return-user page.
if (
fromContentSite &&
!toReturnUserPage &&
store.getters.order.vehicle?.year > 0
) {
log(" --from content site navigate to return user");
var qso = {
fmgPage: fmgPageValues.RETURN_USER,
};
const newQueryString = buildQuerystringObject(qso, true);
log(" -- returnUser add querystring: " + JSON.stringify(newQueryString));
router.push({
path: "/",
query: Object.assign({}, newQueryString),
});
return;
}
// 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
log(" --clear promise");
@ -125,18 +168,28 @@ const routes = [
log(" --load session end");
//Update external parameter state but not when returning from heritage
if (!to.query.fmgPage?.startsWith("service-location")) {
if (!to.query.fmgPage?.startsWith("service-location") && !fromReturnUser) {
updateExternalParameterState();
}
// clear part related state because heritage selected a new vehicle
if (
to.query.fmgPage === fmgPageValues.VEHICLE &&
eval(getFunnelCookie()?.HasDelayedClaimRegistration)
eval(getFunnelCookie()?.HasDelayedClaimRegistration && !fromReturnUser)
) {
store.commit(storeMutations.RESET_GLASS_PARTS_STATE);
}
// if coming from the return user page, clear the destination page so implicit navigation runs
log(" --to.query ", JSON.stringify(to.query));
if (fromReturnUser && to.query) {
log(" --clear to.query");
delete to.query[queryStrings.FMG_PAGE];
//to.query[queryStrings.FMG_PAGE] = "";
log(" --to.query cleared ", JSON.stringify(to.query));
}
const pageToRedirectTo = await getPageToRouteExistingOrderTo(to);
log(" --pageToRedirectTo ", JSON.stringify(pageToRedirectTo));
@ -206,7 +259,14 @@ const routes = [
return;
}
GoToFunnelStartOn404(next);
const errorPayload = {
cause: "invalid page prerequisites for page",
currentPage: from?.query?.fmgPage,
nextPage: to?.query?.fmgPage,
};
GoToFunnelStartOn404(next, errorPayload);
return;
}
log(" --has route:", to.query.fmgPage);
@ -214,13 +274,19 @@ const routes = [
}
if (!isExistingFmgPageName(to.query.fmgPage)) {
console.log("Not an existing fmg page name:" + to.query.fmgPage);
GoToFunnelStartOn404(next);
const errorPayload = {
cause: "invalid page name",
currentPage: from?.query?.fmgPage,
nextPage: to?.query?.fmgPage,
};
GoToFunnelStartOn404(next, errorPayload);
return;
}
// Get route info for the given url. Names will have a 1:1 relationship with names in the Cms.
const routeData = GetRouteInfoFromPageName(to.query.fmgPage);
log("--routeData:", routeData);
// Add our dynamic route.
router.addRoute({
@ -239,7 +305,15 @@ const routes = [
if (!arePagePrerequisitesValid(nextComponent)) {
// prettier-ignore
console.log("Page Prereqs not valid for next component: " + nextComponent.default.name);
GoToFunnelStartOn404(next);
const errorPayload = {
cause: "invalid page prerequisites for page",
currentPage: from?.query?.fmgPage,
nextPage: to?.query?.fmgPage,
};
GoToFunnelStartOn404(next, errorPayload);
return;
}
log("------------- router index.js beforeEnter end -----------------");
@ -261,7 +335,16 @@ const routes = [
console.log(new Date() + " Exception in beforeEnter:" + JSON.stringify(error));
// If we don't have a route, go to our 404 page.
GoToFunnelStartOn404(next);
const errorPayload = {
cause: "uncaught error in beforeEnter",
currentPage: from?.query?.fmgPage,
nextPage: to?.query?.fmgPage,
fullError: error,
errorStack: error?.stack,
};
GoToFunnelStartOn404(next, errorPayload);
return;
}
},
},
@ -280,6 +363,7 @@ const router = createRouter({
//---------------------------------------------------------- Router Functions ----------------------------------------------------------
router.beforeEach(async (to, from, next) => {
log("------------- router index.js beforeEach start -----------------");
// set lastNavigationPage here to capture state before API calls for analytics.
// 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;
@ -331,6 +415,7 @@ router.beforeEach(async (to, from, next) => {
} else {
next();
}
log("------------- router index.js beforeEach end -----------------");
});
router.afterEach(async (to, from) => {
@ -397,8 +482,8 @@ router.navigateToExternalUrl = (url, optionalQuery = {}) => {
navigateToUrl(url, optionalQuery);
};
router.navigateError = () => {
DisplayPageError();
router.navigateError = (errorPayload = null) => {
DisplayPageError(errorPayload);
};
//Use this navigation when you need to call next() explicitly. beforeRouteEnter is a good example.
@ -546,6 +631,8 @@ function getNavigationMap(scenario, currentRoute) {
function log(message, data) {
const log = getQuerystringParameter(queryStrings.LOG);
baseMixin.methods.dispatchStoreAction(storeActions.SAVE_LOGGING_OPTION, log, false);
data = data ?? "";
const outData = typeof data === "object" ? JSON.stringify(data) : data;
@ -588,7 +675,18 @@ function GetRouteInfoFromPageName(pageName) {
}
// Go to our start page on a 404.
function GoToFunnelStartOn404(next) {
function GoToFunnelStartOn404(next, errorPayload = null) {
if (errorPayload !== null) {
errorPayload.type = "GoToFunnelStartOn404";
analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload);
console.log(
"%cGoToFunnelStartOn404()... errorPayload",
"color: white; background-color: blue; padding: 5px;",
errorPayload
);
}
// Put item on the bus
eventBus.addEventToBus(
globalEvents.Categories.GLOBAL_ALERT,
@ -607,7 +705,28 @@ function GoToFunnelStartOn404(next) {
});
}
async function DisplayPageError() {
async function DisplayPageError(errorPayload = null) {
console.log("%c running DisplayPageError()... ", "font-size: 20px; color: purple;");
if (errorPayload !== null) {
errorPayload.type = "DisplayPageError";
analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload);
} else {
// very cautiously to avoid additional errors:
var currentPage = "";
try {
currentPage = getQuerystringParameter(queryStrings.FMG_PAGE);
} catch (e) {
// pass
}
errorPayload = {
type: "DisplayPageError",
cause: "Unknown page error",
currentPage: currentPage,
};
analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload);
}
// Put item on the bus
eventBus.addEventToBus(
globalEvents.Categories.GLOBAL_ALERT,
@ -619,7 +738,11 @@ async function DisplayPageError() {
type: globalEventTypes.Danger,
}
);
store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.INACTIVE);
if (
store.getters.externalParameterState?.isExternalParameter == externalParameterStatus.ACTIVE
) {
store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.INACTIVE);
}
baseMixin.methods.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
const pageError = getQuerystringParameter(queryStrings.PAGE_ERROR);
@ -700,22 +823,6 @@ function getRedirectedStartPage() {
}
function updateExternalParameterState() {
// if there is an existing external parameter state then they have already been through from an external source(LeadGen) and
// are returning. clear out prior related things that need to be selected again like parts and damage type.
if (
store.getters.externalParameterState?.isExternalParameter ==
externalParameterStatus.INACTIVE
) {
store.dispatch(storeActions.RESET_VEHICLE_STATE_AND_DEPENDENCIES);
store.dispatch(storeActions.SAVE_SERVICE_ZIP_CODE_INFO, {
state: null,
zipCode: null,
zipCodeCtu: null,
});
store.dispatch(storeActions.SAVE_EMAIL, null);
store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
}
const externalParameterYear = getQuerystringParameter(queryStrings.VEHICLE_YEAR);
const externalParameterMake = getQuerystringParameter(queryStrings.VEHICLE_MAKE);
const externalParameterModel = getQuerystringParameter(queryStrings.VEHICLE_MODEL);
@ -734,6 +841,7 @@ function updateExternalParameterState() {
externalParameterModel &&
externalParameterStyle
) {
resetStoreForExternalParameter();
store.commit(storeMutations.UPDATE_IS_EXTERNAL_PARAMETER, externalParameterStatus.ACTIVE);
store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_YEAR, externalParameterYear);
store.commit(storeMutations.UPDATE_EXTERNAL_PARAMETER_MAKE, externalParameterMake);
@ -784,4 +892,22 @@ function updateExternalParameterState() {
}
}
// if there is an existing external parameter state then they have already been through from an external source(LeadGen) and
// are returning. clear out prior related things that need to be selected again like parts and damage type.
function resetStoreForExternalParameter() {
if (
store.getters.externalParameterState?.isExternalParameter ==
externalParameterStatus.INACTIVE
) {
store.dispatch(storeActions.RESET_VEHICLE_STATE_AND_DEPENDENCIES);
store.dispatch(storeActions.SAVE_SERVICE_ZIP_CODE_INFO, {
state: null,
zipCode: null,
zipCodeCtu: null,
});
store.dispatch(storeActions.SAVE_EMAIL, null);
store.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
}
}
export default router;

View file

@ -22,8 +22,10 @@ const fmgPageValues = {
PAYMENT: "payment",
PAYMENT_PIA_RETURN: "payment-pia-return",
CONFIRMATION: "confirmation",
RETURN_USER: "return-user",
};
const funnelStartPageName = fmgPageValues.VEHICLE;
const returnUserPageName = fmgPageValues.RETURN_USER;
export { fmgPageValues, funnelStartPageName };
export { fmgPageValues, funnelStartPageName, returnUserPageName };

View file

@ -530,6 +530,16 @@ const routingTable = function (store) {
{
fmgPageValue: fmgPageValues.CONFIRMATION,
},
// The destination page for return user does not matter. the router will invoke implicit navigation
{
fmgPageValue: fmgPageValues.RETURN_USER,
maps: [
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.VEHICLE,
},
],
},
];
};

View file

@ -68,6 +68,7 @@ const getDefaultState = () => {
registration: {
licensePlate: null,
},
isMobileStaticRecalibrationApplicable: false,
},
serviceLocation: {
address: null,
@ -225,6 +226,10 @@ export const mutations = {
updateVehicleVin(state, vin) {
state.order.vehicle.vin = vin;
},
updateIsMobileStaticRecalibrationApplicable(state, isMobileStaticRecalibrationApplicable) {
state.order.vehicle.isMobileStaticRecalibrationApplicable =
isMobileStaticRecalibrationApplicable;
},
updateIsRepair(state, isRepair) {
state.order.damage.isRepair = isRepair;
},
@ -350,6 +355,8 @@ export const mutations = {
state.order.vehicle.imageUrl = vehicleInfo.imageUrl;
state.order.vehicle.imageVifNumber = vehicleInfo.imageVifNumber;
state.order.vehicle.imageColor = vehicleInfo.imageVifColor;
state.order.vehicle.isMobileStaticRecalibrationApplicable =
vehicleInfo.isMobileStaticRecalibrationApplicable;
},
updateRegistration(state, registrationInfo) {
state.order.vehicle.registration.licensePlate = registrationInfo?.licensePlate;
@ -534,6 +541,7 @@ export const mutations = {
state.order.vehicle.imageUrl = null;
state.order.vehicle.imageVifNumber = null;
state.order.vehicle.imageColor = null;
state.order.vehicle.isMobileStaticRecalibrationApplicable = false;
},
resetDamageState(state) {
state.order.damage.isRepair = null;
@ -1600,7 +1608,7 @@ export const actions = {
return response;
},
getMobileFeePart(context, { pageNameToLog }) {
getMobileFeePart(context, { payload: { serviceZipCode, serviceZipCodeCtu }, pageNameToLog }) {
const serviceType = context.getters.damage.isRepair ? "Repair" : "Install";
const facilityType = "Mobile";
const parentAccountNumber = context.getters.payment.parentAccountNumber;
@ -1612,16 +1620,31 @@ export const actions = {
const coverageStatus = coverageStatusEnum(order.payment?.insuranceCoverage?.coverageStatus);
const coverageType = coverageTypeEnum(order.payment?.insuranceCoverage?.coverageType);
const isItacOptimized = order.policy?.isItac ?? false;
const isMobileStaticRecalibrationApplicable =
order.vehicle?.isMobileStaticRecalibrationApplicable;
const zipCode =
order.serviceLocation?.provider?.address?.zipCode ?? order.serviceLocation?.zipCode;
serviceZipCode ??
order.serviceLocation?.provider?.address?.zipCode ??
order.serviceLocation?.zipCode;
var providerNumber =
order.serviceLocation?.provider?.providerNumber ?? order.serviceLocation?.zipCodeCtu;
serviceZipCodeCtu ??
order.serviceLocation?.provider?.providerNumber ??
order.serviceLocation?.zipCodeCtu;
if (providerNumber.startsWith("00") && providerNumber.length > 5) {
providerNumber = providerNumber.substring(1);
}
var endPoint = `${endpoints.GetMobileFeePart.url}/?serviceType=${serviceType}&facilityType=${facilityType}&parentAccountNumber=${parentAccountNumber}&billToAccountNumber=${billToAccountNumber}&providerNumber=${providerNumber}&isItacOptimized=${isItacOptimized}&zipCode=${zipCode}`;
if (isMobileStaticRecalibrationApplicable) {
const staticRecalPartNumber = getStaticRecalPartNumber(order.lineItems?.glassParts[0]);
const carId = order.vehicle?.carId;
if (staticRecalPartNumber && carId) {
endPoint = `${endPoint}&partNumbers=${staticRecalPartNumber}&carId=${carId}`;
}
}
if (coverageStatus) {
endPoint = `${endPoint}&coverageStatus=${coverageStatus}`;
}
@ -2222,7 +2245,18 @@ export const actions = {
// Vehicle
saveVehicle(
context,
{ year, make, model, style, carId, category, imageUrl, imageVifNumber, imageVifColor }
{
year,
make,
model,
style,
carId,
category,
imageUrl,
imageVifNumber,
imageVifColor,
isMobileStaticRecalibrationApplicable,
}
) {
if (
context.state.order.vehicle.year != year ||
@ -2243,6 +2277,10 @@ export const actions = {
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, imageUrl);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, imageVifNumber);
context.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, imageVifColor);
context.commit(
storeMutations.UPDATE_VEHICLE_MOBILE_STATIC_RECALIBRATION_APPLICABLE,
isMobileStaticRecalibrationApplicable
);
}
},
@ -3646,3 +3684,13 @@ function getExternalParameterDefaultState() {
function saveExternalParameterState(externalParameterState) {
window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState));
}
//This function checks if static recalibration is available for the vehicle and returns the part number for it.
function getStaticRecalPartNumber(glassPartsArray) {
const recalPart = glassPartsArray.childParts.find((item) => item.partNumber === "RECAL STATIC");
if (recalPart) {
return recalPart.partNumber;
} else {
return null;
}
}

View file

@ -5,7 +5,7 @@
:class="[
isPrimary ? 'btn-primary' : 'btn-secondary',
isFloat ? 'float-end' : '',
(isLoaderDisplayed && !suppressLoader) ? 'has-loader' : '',
isLoaderDisplayed && !suppressLoader ? 'has-loader' : '',
]"
@click="clicked">
<span class="m-0">{{ this.buttonText }}</span>

View file

@ -83,7 +83,6 @@ export default {
.list-button-horizontal-content {
outline: none;
position: relative;
background: $white;
transition: all 150ms linear;
border: 1px solid $gray-500;
border-radius: 0;
@ -110,8 +109,8 @@ export default {
}
.list-button-horizontal-content:hover {
background-color: $blue-700;
color: $white;
background-color: transparent;
color: $blue;
box-shadow: none;
}
@ -220,4 +219,121 @@ export default {
}
}
}
// Special styles when there are multiple list-button-horizontal buttons
// (number of chips, pay w/cash/insurance)
.windshield-chip-count-question {
fieldset {
.d-flex {
.col {
&:first-child {
label {
&.list-group.list-button-horizontal {
z-index: 2;
&:hover {
background-color: $white;
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
}
}
}
}
fieldset {
.d-flex {
.col {
label {
&.list-group.list-button-horizontal {
&:hover {
background-color: $white;
border-radius: 0;
}
}
}
}
}
}
fieldset {
.d-flex {
.col {
&:last-child {
label {
&.list-group.list-button-horizontal {
z-index: 2;
&:hover {
background-color: $white;
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
}
}
}
}
}
//NOT Windshiled Chip Count
fieldset {
.d-flex {
.col {
&:first-child {
label {
&.list-group.list-button-horizontal {
z-index: 6;
&:hover {
background-color: $white;
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
}
}
}
}
fieldset {
.d-flex {
.col {
label {
&.list-group.list-button-horizontal {
&:hover {
background-color: $white;
border-radius: 0;
}
}
}
}
}
}
fieldset {
.d-flex {
.col {
&:last-child {
label {
&.list-group.list-button-horizontal {
z-index: 6;
&:hover {
background-color: $white;
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
}
}
}
}
</style>

View file

@ -7,6 +7,7 @@ process.env.VUE_APP_MY_ACCOUNT = "https://myaccountdev.safelite.com/";
//process.env.VUE_APP_SAFELITE_HOP = "http://localhost:60966/fmgCheckoutShared.aspx";
process.env.VUE_APP_SAFELITE_HOP = "https://sv2-safelitehop-dev.safelite.com/fmgCheckoutShared.aspx";
process.env.VUE_APP_SOLARWINDS_MONITORING = "";
process.env.VUE_APP_SESSION_TIMEOUT_MINUTES = 30;
// GA & GTM
process.env.VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY =

View file

@ -5,6 +5,7 @@ process.env.VUE_APP_CURRENT_ENVIRONMENT = "__VUE_APP_CURRENT_ENVIRONMENT__";
process.env.VUE_APP_MY_ACCOUNT = "__VUE_APP_MY_ACCOUNT__";
process.env.VUE_APP_SAFELITE_HOP = "__VUE_APP_SAFELITE_HOP__";
process.env.VUE_APP_SOLARWINDS_MONITORING_SCRIPT = "__VUE_APP_SOLARWINDS_MONITORING_SCRIPT__";
process.env.VUE_APP_SESSION_TIMEOUT_MINUTES = 30;
// GA & GTM
process.env.VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY = "__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__";