Merge branch 'develop' into CASH-132-dontation-widget-foster-love

This commit is contained in:
Bryan Mauger 2025-02-03 15:36:19 -05:00
commit 6ef5a30c5c
31 changed files with 859 additions and 236 deletions

View file

@ -27,7 +27,6 @@ module.exports = {
"!src/layouts/insurance/*.vue", // Temp test exclusion while in development
"!src/layouts/insurance-company/*.vue", // Temp test exclusion while in development
"!src/layouts/insurance-company/insurance-company-question/*.vue", // Temp test exclusion while in development
"!src/layouts/return-user/*.vue", // Temp test exclusion while in development
// END
], // ! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],

View file

@ -125,7 +125,7 @@ const endpoints = {
method: "POST",
},
SaveQuote: {
url: "/order/api/v1/order/initiate-saved-progress-email",
url: "/order/api/v1/order/save-progress",
method: "POST",
},
GetSignature: {
@ -168,6 +168,10 @@ const endpoints = {
url: "/analytics/api/v1/analytics/initialize",
method: "POST",
},
LogPartQuestions: {
url: "/analytics/api/v1/analytics/log-part-questions",
method: "POST",
},
GetExperimentsByUser: {
url: "/analytics/api/v1/analytics/get-experiments",
method: "GET",

View file

@ -1,6 +1,7 @@
const experimentUniverses = {
CONCEPT_FUNNEL: "ConceptFunnel",
RECAL_PRICE_REMOVAL: "NextGen_RecalPriceRemoval",
MSR: "MSR",
};
const experimentSettings = {
@ -16,6 +17,7 @@ const experimentSettings = {
RECAL_PRICE_REMOVE: "RecalPriceRemove",
INSURANCE_TAB_TO_DISPLAY_THRESHOLD_INTERNAL: "NextGen_InternalInsuranceTabDisplayThreshold",
INSURANCE_TAB_TO_DISPLAY_THRESHOLD_EXTERNAL: "NextGen_ExternalInsuranceTabDisplayThreshold",
DISPLAY_MSR: "DisplayMSR",
};
const experimentTriggers = {

View file

@ -5,4 +5,5 @@ export const headerKeys = {
SESSION_SEQUENCE_NUMBER: "X-Session-Sequence-Number",
TRANSACTION_ID: "X-Transaction-Id",
EON: "X-Enterprise-Order-Number",
LOG_ENABLED: "log-enabled"
};

View file

@ -0,0 +1,7 @@
const partNumberStrings = {
// Recalibration
MOBILE_STATIC_RECAL_FEE: "RECAL MOBILE",
MOBILE_DUAL_RECAL_FEE: "RECAL MOBILEDUAL",
};
export { partNumberStrings };

View file

@ -42,10 +42,6 @@ const storeActions = {
VALIDATE_ZIP: "validateZip",
PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "priceOrderItemsAndSaveServerData",
TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "taxOrderItemsAndSaveServerData",
LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure",
LOG_PAGE_VIEW: "logPageView",
LOG_CUSTOM_EVENT: "logCustomEvent",
INITIALIZE_SESSION: "initializeSession",
GET_EXPERIMENTS_BY_USER: "GetExperimentsByUser",
RUN_EXPERIMENTS_FOR_TRIGGER: "runExperimentsForTrigger",
CLEAR_VIN: "clearVin",
@ -57,6 +53,13 @@ const storeActions = {
GET_BILL_TO_ACCOUNT_NUMBER: "getBillToAccountNumber",
GET_RECAL_PARTS_AND_SAVE_TO_LINE_ITEMS: "getRecalPartsAndSaveToLineItems",
// Analytics
LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure",
LOG_PAGE_VIEW: "logPageView",
LOG_CUSTOM_EVENT: "logCustomEvent",
INITIALIZE_SESSION: "initializeSession",
LOG_PART_QUESTIONS: "logPartQuestions",
// DEPENDENCY MUTATIONS
RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleStateAndDependencies",
RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies",

View file

@ -29,9 +29,10 @@
<slot></slot>
</div>
<div class="modal-footer">
<slot name="modal-footer-slot"></slot>
<modalButtonMain
isPrimary
class="w-100"
class="w-100 modal-footer-button"
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
loaderColor="white"
@ -45,6 +46,9 @@
</template>
<script>
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import analyticsMixin from "@/mixins/analytics-mixin";
import router from "@/router/index.js";
import modalButtonMain from "@/digital-components/modal/ux-components/modal-button-main/modal-button-main";
import { Modal } from "bootstrap";
import { useForm } from "vee-validate";
@ -86,7 +90,15 @@ export default {
async validateAndEmit() {
const validationResult = await this.validate();
if (validationResult.valid) {
this.$emit("footer-button-event");
if (analyticsMixin.methods.sessionExpired()) {
analyticsMixin.methods.initSession();
router.push({
path: "/",
query: { fmgPage: fmgPageValues.RETURN_USER },
});
} else {
this.$emit("footer-button-event");
}
} else {
this.resetButtonStyle();
}
@ -247,56 +259,7 @@ export default {
}
}
}
.save-progress-modal-question {
p.modal-body {
padding: 0;
}
.modal-body {
display: flex;
flex-direction: column;
.textbox-question {
padding: 0;
margin: 0 0 1.5rem 0;
}
}
}
.save-progress-modal-question,
.save-progress-popup-question {
p.modal-body {
padding: 0;
}
.modal-body {
display: flex;
flex-direction: column;
.textbox-question {
label {
text-align: left;
font-weight: 900;
}
}
}
.modal-body-inner {
text-align: center;
margin-bottom: 1.5rem;
}
.modal-disclaimer {
font-size: 0.75rem;
order: 2;
text-align: left;
a {
text-decoration: none;
}
}
.modal-footer {
margin: 0 0 1.5rem 0;
padding-top: 0;
padding-bottom: 0;
}
}
body {
.modal-backdrop {
height: 100%;

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,13 +1,12 @@
<template>
<div class="save-progress-modal-question" :class="isProgressSaved ? 'progress-saved' : ''">
<buttonMain
ref="buttonMain"
type="button"
v-if="!isProgressSaved"
:buttonText="buttonText"
loaderColor="white"
:suppressLoader="true"
class="save-progress-button"
class="open-save-progress-button"
@click-event="openModal" />
<alert
class="my-4"
@ -21,12 +20,14 @@
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
@footer-button-event="saveProgress">
<p class="modal-body-inner">{{ modalBodyText }}</p>
<p class="modal-body-inner" v-html="modalBodyText"></p>
<saveProgressQuestion
ref="saveProgressQuestion"
v-model="userInput"
cmsWidgetName="SaveProgressQuestionWidget" />
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
<template v-slot:modal-footer-slot>
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
</template>
</modal>
</div>
</template>
@ -112,7 +113,7 @@ export default {
};
</script>
<style lang="scss" scoped>
<style lang="scss">
.save-progress-modal-question {
margin-bottom: 1.5rem;
order: 2;
@ -128,7 +129,7 @@ export default {
background: none;
}
.save-progress-button {
.open-save-progress-button {
order: 4;
position: relative;
color: $blue;
@ -160,12 +161,57 @@ export default {
transition: background 0s 0s ease-in-out;
}
}
:deep(.alert) {
.modal.modal-component .modal-dialog .modal-content {
.modal-header {
padding-top: 0;
margin-top: 1.875rem;
}
.modal-body {
display: flex;
flex-direction: column;
padding: 0 1.5rem;
.textbox-question {
padding: 0;
margin: 0 0 1.5rem 0;
label {
text-align: left;
font-weight: 900;
}
}
}
.modal-body-inner {
text-align: center;
margin-bottom: 1.5rem;
}
.modal-disclaimer {
font-size: 0.75rem;
order: 2;
text-align: left;
margin: 0;
a {
text-decoration: none;
}
}
.modal-footer {
margin: 0 0 1.5rem 0;
padding-top: 0;
padding-bottom: 0;
button.btn {
margin-bottom: 1.5rem;
}
}
}
.alert {
border-radius: $border-radius-lg;
border: 1px solid $green;
margin-top: 0;
}
:deep(.alert-heading) {
.alert-heading {
position: relative;
font-weight: 600;
font-size: 1rem;
@ -183,20 +229,4 @@ export default {
}
}
}
.applied-promo {
font-size: 0.875rem;
font-weight: 500;
line-height: 24px;
color: $green;
.promo-code {
text-transform: uppercase;
}
}
.alert {
color: $red;
font-size: 0.875rem;
font-weight: 500;
line-height: 24px;
}
</style>

View file

@ -14,20 +14,22 @@
v-model="userInput"
cmsWidgetName="SaveProgressPopupQuestionWidget"
class="save-progress-popup-question" />
<buttonMain
ref="skipButton"
type="button"
:buttonText="buttonText"
loaderColor="white"
:suppressLoader="true"
class="skip-button"
@click-event="closeModal" />
<template v-slot:modal-footer-slot>
<buttonMain
ref="skipButton"
type="button"
:buttonText="buttonText"
loaderColor="white"
:suppressLoader="true"
class="skip-button"
@click-event="closeModal" />
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
</template>
<alert
class="my-4"
v-if="isProgressSaved"
cmsWidgetName="SaveProgressModalAlertWidget"
alertClass="alert-success" />
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
</modal>
</div>
</template>
@ -205,35 +207,63 @@ export default {
left: 0;
}
}
.modal-footer {
order: 3;
padding: 0;
}
.modal-disclaimer {
order: 4;
.modal.modal-component .modal-dialog .modal-content {
p.modal-body {
padding: 0;
}
.modal-body {
display: flex;
flex-direction: column;
padding: 0 1.5rem;
.textbox-question {
padding: 0;
margin: 0 0 1.5rem 0;
label {
text-align: left;
font-weight: 900;
}
}
}
.modal-body-inner {
text-align: center;
margin-bottom: 1.5rem;
}
.modal-footer {
padding: 0 1.5rem;
margin: 0 0 1.5rem 0;
button.btn {
order: 1;
margin-bottom: 1.5rem;
}
button.btn.skip-button {
padding: 0;
order: 2;
font-size: 1rem;
}
.modal-disclaimer {
font-size: 0.75rem;
text-align: left;
order: 3;
margin: 0;
a {
text-decoration: none;
}
}
}
}
&.progress-saved {
.modal-footer,
.save-progress-popup-question {
.modal-footer-button,
.modal-disclaimer {
display: none;
}
.skip-button {
margin-bottom: 0;
}
}
}
.applied-promo {
font-size: 0.875rem;
font-weight: 500;
line-height: 24px;
color: $green;
.promo-code {
text-transform: uppercase;
}
}
.alert {
color: $red;
font-size: 0.875rem;
font-weight: 500;
line-height: 24px;
}
</style>

View file

@ -70,6 +70,7 @@ export default {
[headerKeys.REFERRAL_SEQUENCE_NUMBER]: order?.referralSequenceNumber,
[headerKeys.TRANSACTION_ID]: crypto.randomUUID(),
[headerKeys.EON]: order?.eon,
[headerKeys.LOG_ENABLED]: store.getters.applicationUser?.loggingOption ?? false,
};
axios({

View file

@ -0,0 +1,19 @@
export function getBoolFromString(str) {
// Catch edge cases
if (str === null || str === undefined) {
return false;
}
if (typeof str === "boolean") {
return str;
}
if (typeof str !== "string") {
return false;
}
// string logic
const toLower = str?.toLowerCase();
return toLower === "true";
}

View file

@ -0,0 +1,146 @@
import { getBoolFromString } from "./boolean-helper";
describe("getBoolFromString", () => {
describe("Happy paths", () => {
test('"true" -> true', () => {
const input = "true";
const output = getBoolFromString(input);
expect(output).toBe(true);
});
test('"false" -> false', () => {
const input = "false";
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test('"other string" -> false', () => {
const input = "some random value";
const output = getBoolFromString(input);
expect(output).toBe(false);
});
});
describe("Capitalization", () => {
test('"TRUE" -> true', () => {
const input = "TRUE";
const output = getBoolFromString(input);
expect(output).toBe(true);
});
test('"True" -> true', () => {
const input = "True";
const output = getBoolFromString(input);
expect(output).toBe(true);
});
test('"FALSE" -> false', () => {
const input = "FALSE";
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test('"False" -> false', () => {
const input = "False";
const output = getBoolFromString(input);
expect(output).toBe(false);
});
});
describe("Graceful type errors", () => {
describe("Boolean", () => {
test("true -> true", () => {
const input = true;
const output = getBoolFromString(input);
expect(output).toBe(true);
});
test("false -> false", () => {
const input = false;
const output = getBoolFromString(input);
expect(output).toBe(false);
});
});
describe("Empty", () => {
test("undefined -> false", () => {
const input = undefined;
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test("null -> false", () => {
const input = null;
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test("{} -> false", () => {
const input = {};
const output = getBoolFromString(input);
expect(output).toBe(false);
});
});
describe("Other types", () => {
test("1 -> false", () => {
const input = 1;
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test("Object -> false", () => {
const input = {
test: true,
true: true,
};
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test("function -> false", () => {
const input = () => true;
const output = getBoolFromString(input);
expect(output).toBe(false);
});
test("array -> false", () => {
const input = [true];
const output = getBoolFromString(input);
expect(output).toBe(false);
});
});
});
});

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

@ -64,9 +64,7 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
await baseMixin.methods.dispatchStoreAction(

View file

@ -120,6 +120,43 @@ export default {
async beforeRouteEnter(to, from, next) {
const hasRecalPriceRemoveExperiment = await store.getters.shouldHideRecalibration;
// send part question data to SPS API for logging
const orderFromStore = await deepClone(store.getters.order);
const ctu = orderFromStore.serviceLocation?.zipCodeCtu
const partsOrQuestions = orderFromStore.damage?.partQuestionAnswers;
partsOrQuestions.forEach(pqa => {
const payloadPartQuestions = [];
pqa.answeredQuestions.forEach(answer => {
payloadPartQuestions.push({
questionSeq: answer?.questionNum,
questionText: answer?.questionText,
answerText: answer?.selectedAnswerText,
basePart: pqa?.result,
});
});
const payload = {
eon: orderFromStore.eon,
ctu: ctu,
workOrderId: orderFromStore.workOrderId,
workOrderNumber: orderFromStore.workOrderNumber,
carId: orderFromStore.vehicle.carId,
glassLocation: pqa?.glassLocation,
partQuestions: payloadPartQuestions,
};
baseMixin.methods.dispatchStoreAction(
storeActions.LOG_PART_QUESTIONS,
payload,
false
);
});
// Create order
await baseMixin.methods.dispatchStoreAction(storeActions.CREATE_SUBMITTED_ORDER); // This nulls the Store order
// Call APIs

View file

@ -64,9 +64,7 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
await baseMixin.methods.dispatchStoreAction(

View file

@ -64,9 +64,7 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
vm.showSaveProgressModal = !(emailFromStore?.length > 0);
if (store.getters.externalParameterState?.isExternalParameter) {
if (store.getters.externalParameterServiceZip.zipCode) {
const serviceState = store.getters.order.serviceLocation.state;

View file

@ -156,6 +156,7 @@ import { mapTaxedLineItemsToStoreFormat } from "../../store";
import { coverageStatus } from "@/constants/insurance";
import { containsLineItemWithPartType } from "@/helpers/service-package-helper";
import { containsRecalParts } from "@/helpers/recal-helper";
import { getBoolFromString } from "@/helpers/boolean-helper";
defineRule("payment-method-required", required(errorMessages.OPTION_REQUIRED));
defineRule("recal-ack-required", required(errorMessages.RECAL_ACK_REQUIRED));
@ -429,7 +430,7 @@ export default {
// prettier-ignore
{
const log = getQuerystringParameter(queryStrings.LOG);
const logAsBool = (log?.toLowerCase() === "true");
const logAsBool = getBoolFromString(log);
if (logAsBool || !preReqResult) {
console.log("------------- payment-method.vue pagePrereqs start -----------------");
console.log(new Date() + " serviceLocationReqs::isMobile: " + isMobile);
@ -786,7 +787,7 @@ export default {
shouldDisplayPiaAlert() {
return (
this.$route.query[queryStrings.DISPLAY_PIA_ALERT] ||
eval(window.history.state.displayPiaAlert)
getBoolFromString(window.history.state.displayPiaAlert)
);
},
// Necessary to make the watcher of lineItems work

View file

@ -234,6 +234,7 @@ import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js"
import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.js";
import { routerParams } from "@/router/router-constants/router-params";
import { coverageStatus } from "@/constants/insurance";
import { getBoolFromString } from "@/helpers/boolean-helper.js";
export default {
name: "payment",
@ -800,7 +801,7 @@ export default {
shouldDisplayPiaAlert(payMethod) {
return (
this.$route.query[queryStrings.DISPLAY_PIA_ALERT] === payMethod ||
eval(window.history.state.displayPiaAlert)
getBoolFromString(window.history.state.displayPiaAlert)
);
},
},

View file

@ -159,6 +159,7 @@ import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigat
import { containsRecalParts } from "@/helpers/recal-helper";
import { externalParameterStatus } from "@/constants/external-parameters";
import { saveQuote } from "@/helpers/heritage-integration/order-helper.js";
import { getBoolFromString } from "@/helpers/boolean-helper";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
@ -293,12 +294,8 @@ export default {
// Call the "next" function to complete the transition to this page.
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent);
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment the 2 lines below
// vm.showSaveProgressPopup = showSaveProgressPopup;
// vm.showSaveProgressModal = showSaveProgressModal;
vm.showSaveProgressPopup = showSaveProgressPopup;
vm.showSaveProgressModal = showSaveProgressModal;
vm.addableVaps = addableVaps;
vm.lineItems = lineItems;
vm.availableLineItems = pricingResults;
@ -407,7 +404,7 @@ export default {
baseMixin.methods.ResetExternalParamsAndHideModal();
} else {
// user came from an external source, however, the externalParms may have been reset on service-zip, property-questions, etc...
if (eval(store.getters.externalParameterQuote?.isInsurance)) {
if (getBoolFromString(store.getters.externalParameterQuote?.isInsurance)) {
// did user intentionally select insurance?
vm.isInsuranceSelected = true;
vm.servicePackage = store.getters.externalParameterQuote.servicePackage;

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

@ -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

@ -7,6 +7,7 @@ import { getMountOptions } from "@/helpers/unit-test-helper";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
import { experimentSettings } from "@/constants/experiments";
// Define Mocks
jest.mock("@/helpers/cms-content-helper", () => ({
@ -199,6 +200,9 @@ beforeEach(() => {
zipCode: "43054",
},
},
experimentSettings: {
settingName: experimentSettings.DISPLAY_MSR,
},
};
});

View file

@ -139,6 +139,8 @@ import contentGroupModal from "@/fmg-components/content-group-modal/content-grou
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -151,6 +153,7 @@ import {
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { applicationConfig } from "@/constants/application-config";
import { Provider } from "@/layouts/service-location/classes/provider";
import { partNumberStrings } from "@/constants/part-number-strings";
import store from "@/store";
@ -159,7 +162,6 @@ 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) => {
@ -319,11 +321,19 @@ export default {
},
isMobileStaticRecalibrationApplicable() {
return (
this.displayMSR &&
this.isVehicleMobileStaticRecalibrationApplicable &&
this.mobileFeePart?.partNumber == MOBILE_STATIC_RECAL_FEE_PART_NUMBER &&
this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE &&
(this.isInsurance ? this.mobileFeePart?.isInsurable : true)
);
},
displayMSR() {
return (
experimentMixin.methods
.getSettingValue(experimentSettings.DISPLAY_MSR)
?.toLowerCase() === "true"
);
},
mobileFeeApplies() {
if (
this.mobileFeePart?.laborAmount > 0 ||

View file

@ -102,6 +102,7 @@ import { routerParams } from "@/router/router-constants/router-params";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
import { nextTick } from "vue";
import { getBoolFromString } from "@/helpers/boolean-helper";
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.REPLACE_OPTIONS_REQUIRED));
@ -622,7 +623,7 @@ export default {
);
},
shouldDisplayVehicleChangeAlert() {
return eval(window.history.state.displayVehicleChangeAlert);
return getBoolFromString(window.history.state.displayVehicleChangeAlert);
},
shouldHideBackButton() {
return this.$store.getters.requiresVerifiedRedirecting;

View file

@ -94,10 +94,8 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
// turned off Save Your Progress for 1/23/25 release
// to restore, uncomment line below
// vm.showSaveProgressModal = !(emailFromStore?.length > 0);
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)

View file

@ -80,7 +80,11 @@ import vehicleQuestion from "@/layouts/vehicle/vehicle-question/vehicle-question
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { experimentUniverses } from "@/constants/experiments";
import { getSessionKeyValue, getUserIdValue } from "@/helpers/heritage-integration/cookie-helper";
import {
getSessionKeyValue,
getUserIdValue,
getDeviceIdValue,
} from "@/helpers/heritage-integration/cookie-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
@ -186,11 +190,12 @@ export default {
storeActions.LOG_EXPERIMENT_EXPOSURE,
{
userId: getUserIdValue(),
deviceId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: to.query.fmgPage,
pageName: "vehicle-logExposure", //to.query.fmgPage,
experiment: experimentForLogging,
},
"vehicle",
"vehicle-logExposure",
false
);
}

View file

@ -712,24 +712,19 @@ export default {
return !areAllSessionCookiesSet();
},
async validateSession() {
// The noSession function checks the cookies related to analytics logging(sid). it is not the funnel info cookie.
// The sid cookie for analytics will expire every 30 minutes and get recreated in initSession. If this happens
// and we also have the funnel cookie present, that indicates they have an existing session that is now expired
// so route them to the return user page.
// 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";
const funnelCookieLastTouched = getFunnelCookie()?.LastTouched;
if (
this.noSession() &&
!fromHeritage &&
funnelCookieLastTouched !== null &&
funnelCookieLastTouched !== undefined
) {
await this.initSession();
router.push({
path: "/",
query: { fmgPage: fmgPageValues.RETURN_USER },
});
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;
}

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";
@ -38,6 +38,7 @@ import { applicationConfig } from "../constants/application-config";
import { shouldStripPromoQueryString } from "@/helpers/promotions-helper";
import bailout from "@/layouts/bailout/bailout";
import { nextTick } from "vue";
import { getBoolFromString } from "@/helpers/boolean-helper";
const routes = [
{
@ -58,6 +59,11 @@ const routes = [
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 });
@ -95,27 +101,24 @@ const routes = [
}
// On entering the funnel "fresh", read cookie information, decide what to do next.
else if (from.redirectedFrom === undefined || fromReturnUser) {
// if entering the funnel from the content site, check and see if there is already a funnel cookie.
// 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.
const funnelCookieLastTouched = getFunnelCookie()?.LastTouched;
if (
fromContentSite &&
funnelCookieLastTouched !== null &&
funnelCookieLastTouched !== undefined &&
!toReturnUserPage
!toReturnUserPage &&
store.getters.order.vehicle?.year > 0
) {
log(" --from content site navigate to return user");
var qso = {
fmgPage: fmgPageValues.RETURN_USER,
};
const lg = getQuerystringParameter(queryStrings.LOG);
if (lg) {
qso[queryStrings.LOG] = true;
}
const newQueryString = buildQuerystringObject(qso, true);
log(" -- returnUser add querystring: " + JSON.stringify(newQueryString));
router.push({
path: "/",
query: Object.assign({}, qso),
query: Object.assign({}, newQueryString),
});
return;
}
@ -173,8 +176,8 @@ const routes = [
// clear part related state because heritage selected a new vehicle
if (
to.query.fmgPage === fmgPageValues.VEHICLE &&
eval(getFunnelCookie()?.HasDelayedClaimRegistration &&
!fromReturnUser)
getBoolFromString(getFunnelCookie()?.HasDelayedClaimRegistration) &&
!fromReturnUser
) {
store.commit(storeMutations.RESET_GLASS_PARTS_STATE);
}
@ -182,7 +185,7 @@ const routes = [
// 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");
log(" --clear to.query");
delete to.query[queryStrings.FMG_PAGE];
//to.query[queryStrings.FMG_PAGE] = "";
@ -422,7 +425,7 @@ router.afterEach(async (to, from) => {
store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name);
// If saving on navigation is requested, check for saved SessionId or EmailAddress to determine if saving is appropriate
if (eval(window.history.state.isSavingNavigation)) {
if (getBoolFromString(window.history.state.isSavingNavigation)) {
if (
store.getters.applicationUser.savedSessionId ||
store.getters.order.customer?.emailAddress
@ -566,7 +569,7 @@ async function navigate(
const pageError = getQuerystringParameter(queryStrings.PAGE_ERROR);
const logQs = getQuerystringParameter(queryStrings.LOG);
baseMixin.methods.dispatchStoreAction(storeActions.SAVE_LOGGING_OPTION, logQs, false);
log("------------- router index.js navigate start -----------------");
log(" --scenario: ", scenario);
log(" --isSavingNavigation: ", isSavingNavigation);
@ -631,7 +634,7 @@ 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;
@ -794,7 +797,7 @@ async function runExperiments(nextPage) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.RUN_EXPERIMENTS_FOR_TRIGGER,
{
userId: getDeviceIdValue(),
deviceId: getDeviceIdValue(),
triggerEvent: experimentTriggers.SITE_ENTRY,
triggerValue: applicationConfig.SITE_ENTRY_TRIGGER_VALUE,
},
@ -805,9 +808,9 @@ async function runExperiments(nextPage) {
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.RUN_EXPERIMENTS_FOR_TRIGGER,
{
userId: getDeviceIdValue(),
deviceId: getDeviceIdValue(),
triggerEvent: experimentTriggers.PAGE_ENTRY,
triggerValue: nextPage,
triggerValue: nextPage == "vehicle" ? "vehicle-pageEntry" : nextPage,
},
nextPage
);

View file

@ -1252,7 +1252,7 @@ export const actions = {
// Analytics Actions
logExperimentExposure(
context,
{ payload: { userId, sessionKey, pageName, experiment }, pageNameToLog }
{ payload: { userId, deviceId, sessionKey, pageName, experiment }, pageNameToLog }
) {
return globalMethods.callHttpClient({
method: endpoints.LogExperimentExposureIfAssigned.method,
@ -1260,6 +1260,7 @@ export const actions = {
payload: {
experimentForLogging: {
userId: userId,
deviceId: deviceId,
experimentUniverseId: experiment.universeId,
experimentUniverseName: experiment.universeName,
experimentTestId: experiment.testId,
@ -1445,6 +1446,39 @@ export const actions = {
});
},
logPartQuestions(
context,
{
eon,
ctu,
workOrderId,
workOrderNumber,
carId,
glassLocation,
partQuestions,
userAgent,
}
) {
var payload = {
applicationName: applicationConfig.APPLICATION_NAME,
eon: eon,
ctu: ctu,
workOrderId: workOrderId,
workOrderNumber: workOrderNumber,
carId: carId,
glassLocation: glassLocation,
partQuestions: partQuestions,
userAgent: navigator.userAgent
};
return globalMethods
.callHttpClient({
method: endpoints.LogPartQuestions.method,
endpoint: endpoints.LogPartQuestions.url,
payload: payload,
});
},
// Misc Actions
setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) {
context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber);
@ -1463,7 +1497,7 @@ export const actions = {
async runExperimentsForTrigger(
context,
{ payload: { userId, triggerEvent, triggerValue }, pageNameToLog }
{ payload: { deviceId, triggerEvent, triggerValue }, pageNameToLog }
) {
if (triggerEvent == experimentTriggers.SITE_ENTRY) {
context.commit(storeMutations.UPDATE_TRIGGERED_SITE_ENTRY, true);
@ -1471,7 +1505,7 @@ export const actions = {
var payload = {
applicationName: applicationConfig.APPLICATION_NAME,
userId: userId,
deviceId: deviceId,
triggerEvent: triggerEvent,
triggerValue: triggerValue,
experimentOrder: context.getters.experimentOrder,
@ -1638,10 +1672,10 @@ export const actions = {
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 recalPartNumber = getRecalPartNumber(order.lineItems?.glassParts[0]);
const carId = order.vehicle?.carId;
if (staticRecalPartNumber && carId) {
endPoint = `${endPoint}&partNumbers=${staticRecalPartNumber}&carId=${carId}`;
if (recalPartNumber && carId) {
endPoint = `${endPoint}&partNumbers=${recalPartNumber}&carId=${carId}`;
}
}
@ -3685,9 +3719,11 @@ 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");
//This function finds the recalibration part and returns the part number for it.
function getRecalPartNumber(glassPartsArray) {
const recalPart = glassPartsArray.childParts.find(
(item) => item.partType === partTypeStrings.ADAS_RECALIBRATION
);
if (recalPart) {
return recalPart.partNumber;
} else {