Merge branch 'develop' into nation/CASH-2844

This commit is contained in:
Carl Nation 2026-07-02 07:48:43 -04:00
commit 91bcc37bf9
39 changed files with 480 additions and 186 deletions

View file

@ -87,9 +87,22 @@ window.onerror = (msg, url, line, col, error) => {
return suppressErrorAlert; return suppressErrorAlert;
}; };
function isThirdPartyUnhandledRejection(reason) {
const stack = reason instanceof Error ? reason.stack : "";
if (typeof stack !== "string") {
return false;
}
return stack.includes("chrome-extension://") || stack.includes("quantummetric.com");
}
// Log Promise rejections that are never handled (.catch / await try/catch), e.g. fire-and-forget async. // Log Promise rejections that are never handled (.catch / await try/catch), e.g. fire-and-forget async.
window.addEventListener("unhandledrejection", (event) => { window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason; const reason = event.reason;
if (isThirdPartyUnhandledRejection(reason)) {
return;
}
const message = reason instanceof Error ? reason.message : String(reason); const message = reason instanceof Error ? reason.message : String(reason);
global.$logger.logError(`unhandledrejection: ${message}`, { global.$logger.logError(`unhandledrejection: ${message}`, {
stack: reason instanceof Error ? reason.stack : undefined, stack: reason instanceof Error ? reason.stack : undefined,

View file

@ -56,8 +56,8 @@ const endpoints = {
url: "/parts/api/v1/parts/parts-or-questions", url: "/parts/api/v1/parts/parts-or-questions",
method: "POST", method: "POST",
}, },
GetPartsOrQuestionsV3: { GetPartsOrQuestionsV2: {
url: "/parts/api/v3/parts/parts-or-questions", url: "/parts/api/v2/parts/parts-or-questions",
method: "POST", method: "POST",
}, },
GetParts: { GetParts: {

View file

@ -133,8 +133,6 @@ const storeActions = {
UPDATE_HAS_TRIGGERED_ERROR: "updateHasTriggeredError", UPDATE_HAS_TRIGGERED_ERROR: "updateHasTriggeredError",
GET_VALID_IDEMPOTENCY_KEY: "getValidIdempotencyKey", GET_VALID_IDEMPOTENCY_KEY: "getValidIdempotencyKey",
CORRECT_IDEMPOTENCY_KEY_EXPIRY: "correctIdempotencyKeyExpiry", CORRECT_IDEMPOTENCY_KEY_EXPIRY: "correctIdempotencyKeyExpiry",
SAVE_BAILOUT_CODE: "saveBailoutCode",
}; };
export { storeActions }; export { storeActions };

View file

@ -118,9 +118,6 @@ const storeMutations = {
UPDATE_EXPERIMENTS: "updateExperiments", UPDATE_EXPERIMENTS: "updateExperiments",
UPDATE_TRIGGERED_SITE_ENTRY: "updateTriggeredSiteEntry", UPDATE_TRIGGERED_SITE_ENTRY: "updateTriggeredSiteEntry",
// BAILOUT MUTATIONS
UPDATE_BAILOUT_CODE: "updateBailoutCode",
// EXTERNAL_PARAMETER MUTATIONS // EXTERNAL_PARAMETER MUTATIONS
UPDATE_IS_EXTERNAL_PARAMETER: "updateIsExternalParameter", UPDATE_IS_EXTERNAL_PARAMETER: "updateIsExternalParameter",
UPDATE_EXTERNAL_PARAMETER_YEAR: "updateExternalParameterYear", UPDATE_EXTERNAL_PARAMETER_YEAR: "updateExternalParameterYear",

View file

@ -13,6 +13,22 @@ const mockMixin = {
const maska = jest.fn(); const maska = jest.fn();
describe("textboxQuestion.vue", () => { describe("textboxQuestion.vue", () => {
it("Should coerce non-string modelValue to empty string for v-model.trim", async () => {
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
},
},
propsData: {
modelValue: null,
},
mixins: [mockMixin],
});
expect(wrapper.vm.value).toBe("");
});
it("Should render a text input", async () => { it("Should render a text input", async () => {
// Arrange // Arrange
const wrapper = shallowMount(textboxQuestion, { const wrapper = shallowMount(textboxQuestion, {

View file

@ -25,7 +25,7 @@
<input <input
class="form-control" class="form-control"
v-model.trim="value" v-model.trim="value"
v-maska="mask" v-maska="effectiveMask"
:type="type" :type="type"
:ref="inputId" :ref="inputId"
:id="inputId" :id="inputId"
@ -90,6 +90,19 @@ import loader from "@/ux-components/loader/loader.vue";
import { ref } from "vue"; import { ref } from "vue";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
function coerceTextboxValue(modelValue) {
if (modelValue == null) {
return "";
}
if (typeof modelValue === "number") {
return String(modelValue);
}
if (typeof modelValue === "string") {
return modelValue;
}
return "";
}
export default { export default {
name: "textbox-question", name: "textbox-question",
props: { props: {
@ -149,18 +162,8 @@ export default {
const inputId = !props.customInputId ? `input-${uuid}` : props.customInputId; const inputId = !props.customInputId ? `input-${uuid}` : props.customInputId;
const propsClone = Object.assign({}, props); const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue; const modelValue = coerceTextboxValue(propsClone.modelValue);
let initialValue; const initialValue = modelValue;
let isImageProcessing = ref(false);
switch (typeof modelValue) {
case "number":
initialValue = modelValue;
break;
default:
initialValue = modelValue && modelValue.length > 0 ? modelValue : "";
break;
}
const fieldOptions = { const fieldOptions = {
type: "text", type: "text",
@ -168,6 +171,8 @@ export default {
initialValue: initialValue, initialValue: initialValue,
}; };
let isImageProcessing = ref(false);
const { errorMessage, handleBlur, handleChange, meta, validate, errors } = useField( const { errorMessage, handleBlur, handleChange, meta, validate, errors } = useField(
inputId, inputId,
props.validationRules, props.validationRules,
@ -228,18 +233,25 @@ export default {
}, },
value: { value: {
get: function () { get: function () {
return this.modelValue; return coerceTextboxValue(this.modelValue);
}, },
set: function (newValue) { set: function (newValue) {
this.$emit("update:modelValue", newValue); this.$emit("update:modelValue", newValue);
}, },
}, },
effectiveMask() {
return this.mask ?? "";
},
}, },
mounted() { mounted() {
this.$emit("textboxQuestionEvent.inputIdAssigned", this.inputId); this.$emit("textboxQuestionEvent.inputIdAssigned", this.inputId);
}, },
watch: { watch: {
async value(newValue) { async value(newValue) {
if (typeof this.handleChange !== "function") {
return;
}
const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation
if (result.valid) { if (result.valid) {
this.handleChange(newValue); // trigger full validation on this field only this.handleChange(newValue); // trigger full validation on this field only

View file

@ -14,6 +14,7 @@
class="my-4 caption modal-sub-body" class="my-4 caption modal-sub-body"
v-if="ModalSubBodyText" v-if="ModalSubBodyText"
v-html="ModalSubBodyText"></p> v-html="ModalSubBodyText"></p>
<p class="my-4 caption" v-if="ModalFooter2Text" v-html="ModalFooter2Text"></p>
<slot></slot> <slot></slot>
</div> </div>
</modal> </modal>
@ -58,6 +59,9 @@ export default {
ModalCloseButtonText() { ModalCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText"); return this.getCmsContent(this.cmsWidgetName, "FooterText");
}, },
ModalFooter2Text() {
return this.getCmsContent(this.cmsWidgetName, "FooterText2");
},
}, },
methods: { methods: {
openModal() { openModal() {

View file

@ -65,6 +65,9 @@ export async function skipVinLookup() {
if (store.getters.damage.isRepair) { if (store.getters.damage.isRepair) {
return true; return true;
} }
if (store.getters.vehicle.vinRequired) {
return false;
}
const isVinOptionalVehicle = store.getters.order.vehicle.make const isVinOptionalVehicle = store.getters.order.vehicle.make
? await store.dispatch(storeActions.IS_VIN_OPTIONAL_VEHICLE) ? await store.dispatch(storeActions.IS_VIN_OPTIONAL_VEHICLE)
: false; : false;

View file

@ -11,6 +11,7 @@ import { damageLocationsSelected as glassLocations } from "@/constants/damage-lo
import store from "@/store"; import store from "@/store";
import router from "@/router"; import router from "@/router";
import { skipVinLookup } from "@/helpers/heritage-integration/navigation-helper";
const getPageToRouteExistingOrderTo = navigationHelper.getPageToRouteExistingOrderTo; const getPageToRouteExistingOrderTo = navigationHelper.getPageToRouteExistingOrderTo;
const navigateToHeritageFunnel = navigationHelper.navigateToHeritageFunnel; const navigateToHeritageFunnel = navigationHelper.navigateToHeritageFunnel;
@ -147,6 +148,20 @@ describe("navigateToHeritageFunnel", () => {
}); });
}); });
describe("skipVinLookup", () => {
afterEach(() => {
store.commit(storeMutations.UPDATE_VIN_REQUIRED, false);
store.commit(storeMutations.UPDATE_IS_REPAIR, false);
});
test("returns false when vehicle vin is required", async () => {
store.commit(storeMutations.UPDATE_VIN_REQUIRED, true);
store.commit(storeMutations.UPDATE_IS_REPAIR, false);
await expect(skipVinLookup()).resolves.toBe(false);
});
});
/** /**
* `arePagePrerequisitesValidObject` is an object where the keys are fmgPageValue names and the values are booleans that indicate * `arePagePrerequisitesValidObject` is an object where the keys are fmgPageValue names and the values are booleans that indicate
* whether arePagePrerequisitesValid is true or false * whether arePagePrerequisitesValid is true or false

View file

@ -159,7 +159,7 @@ export default {
methods: { methods: {
getBailoutCodeFromStore() { getBailoutCodeFromStore() {
return store.getters.applicationUser.bailoutCode; return store.getters.bailoutCode;
}, },
getFirstNameFromStore() { getFirstNameFromStore() {
return store.getters.order.customer.firstName; return store.getters.order.customer.firstName;
@ -233,7 +233,6 @@ export default {
this.navigationScenarios.CLICKED_FORWARD, this.navigationScenarios.CLICKED_FORWARD,
this.pageName, this.pageName,
{ {
bailoutCode: this.bailoutCode,
submit: true, submit: true,
} }
); );
@ -244,7 +243,7 @@ export default {
} }
}, },
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
return this.getBailoutCodeFromStore() !== null; return this.getBailoutCodeFromStore() != null;
}, },
}, },

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -34,7 +34,7 @@
class="keys-message" /> class="keys-message" />
<mobileAddressQuestions <mobileAddressQuestions
ref="addressQuestions" ref="addressQuestions"
v-model="this.addressQuestions" v-model="addressQuestions"
captureApartmentNumberOrBusinessName="true" captureApartmentNumberOrBusinessName="true"
preserveCityAndStateOnReset="true" preserveCityAndStateOnReset="true"
labelBold="true" labelBold="true"
@ -42,7 +42,7 @@
isZipCodeDisabled="true" /> isZipCodeDisabled="true" />
<vehicleProtectedQuestion <vehicleProtectedQuestion
ref="vehicleProtectedQuestion" ref="vehicleProtectedQuestion"
v-model="this.isVehicleProtected" v-model="isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget" cmsWidgetName="VehicleProtectedQuestionWidget"
labelBold="true" /> labelBold="true" />
<textBlock <textBlock
@ -84,11 +84,11 @@ export default {
data() { data() {
return { return {
addressQuestions: { addressQuestions: {
streetAddress: this.getServiceAddressFromStore(), streetAddress: String(this.getServiceAddressFromStore() ?? ""),
apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(), apartmentNumberOrBusinessName: String(this.getServiceAddress2FromStore() ?? ""),
city: this.getServiceCityFromStore(), city: String(this.getServiceCityFromStore() ?? ""),
state: this.getServiceStateFromStore(), state: String(this.getServiceStateFromStore() ?? ""),
zipCode: this.getServiceZipCodeFromStore(), zipCode: String(this.getServiceZipCodeFromStore() ?? ""),
}, },
isVehicleProtected: this.getIsVehicleProtectedFromStore(), isVehicleProtected: this.getIsVehicleProtectedFromStore(),
}; };

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -10,22 +10,42 @@
class="date-picker__nav-icon date-picker__nav-icon--flipped" class="date-picker__nav-icon date-picker__nav-icon--flipped"
alt="" /> alt="" />
</button> </button>
<div class="date-picker__track d-flex flex-grow-1"> <div
<button class="date-picker__track d-flex flex-grow-1"
v-for="date in visibleDates" :style="isLoadingDates ? { '--card-count': windowSize } : null">
:key="date.value" <template v-if="showLoadingPlaceholders">
class="date-picker__day-card d-flex flex-column align-items-center justify-content-center" <div
:class="{ v-for="index in windowSize"
'date-picker__day-card--selected': isSelected(date.value), :key="index"
'date-picker__day-card--disabled': !date.isAvailable, class="date-picker__day-card date-picker__day-card--loading d-flex flex-column align-items-center justify-content-center"
}" :style="{ '--card-index': index - 1 }"
:disabled="!date.isAvailable" aria-hidden="true">
:aria-pressed="isSelected(date.value)" <!-- Placeholder text is intentionally non-empty so the spans occupy the
:aria-label="`${date.dayAbbr} ${date.monthAbbr} ${date.day}`" same height as real content. The --loading CSS hides them via
@click="selectDate(date)"> visibility:hidden, so they are never visible to users. -->
<span class="date-picker__day-abbr">{{ date.dayAbbr }}</span> <span class="date-picker__day-abbr">MON</span>
<span class="date-picker__day-date">{{ date.monthAbbr }} {{ date.day }}</span> <span class="date-picker__day-date">Jan 00</span>
</button> </div>
</template>
<template v-else>
<button
v-for="(date, index) in visibleDates"
:key="date.value"
class="date-picker__day-card d-flex flex-column align-items-center justify-content-center"
:class="{
'date-picker__day-card--selected': isSelected(date.value),
'date-picker__day-card--disabled': !date.isAvailable,
'date-picker__day-card--loading': isLoadingDates,
}"
:style="isLoadingDates ? { '--card-index': index } : null"
:disabled="!date.isAvailable || isLoadingDates"
:aria-pressed="isSelected(date.value)"
:aria-label="`${date.dayAbbr} ${date.monthAbbr} ${date.day}`"
@click="selectDate(date)">
<span class="date-picker__day-abbr">{{ date.dayAbbr }}</span>
<span class="date-picker__day-date">{{ date.monthAbbr }} {{ date.day }}</span>
</button>
</template>
</div> </div>
<button <button
class="date-picker__nav-btn" class="date-picker__nav-btn"
@ -110,6 +130,9 @@ export default {
}; };
}, },
computed: { computed: {
showLoadingPlaceholders() {
return this.isLoadingDates && !this.allDates.length;
},
allDates() { allDates() {
if (this.availableDates === null) return []; if (this.availableDates === null) return [];
@ -308,6 +331,23 @@ export default {
color: $gray-300; color: $gray-300;
} }
} }
&--loading,
&--loading.date-picker__day-card--selected,
&--loading.date-picker__day-card--disabled {
border-color: $gray-200;
cursor: default;
background-color: $gray-200;
background-image: linear-gradient(90deg, $gray-200 25%, $gray-100 50%, $gray-200 75%);
background-repeat: no-repeat;
background-size: calc(var(--card-count) * 200%) 100%;
animation: date-picker-shimmer 1.5s ease-in-out infinite;
.date-picker__day-abbr,
.date-picker__day-date {
visibility: hidden;
}
}
} }
&__day-abbr, &__day-abbr,
@ -336,4 +376,13 @@ export default {
} }
} }
} }
@keyframes date-picker-shimmer {
0% {
background-position-x: calc(var(--card-index) / (var(--card-count) - 1) * 100% + 100%);
}
100% {
background-position-x: calc(var(--card-index) / (var(--card-count) - 1) * 100% - 100%);
}
}
</style> </style>

View file

@ -91,6 +91,7 @@
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants"; import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper"; import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue"; import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue";
export default { export default {
name: "mobile-scheduling-card", name: "mobile-scheduling-card",
emits: ["update:modelValue", "zip-code-clicked"], emits: ["update:modelValue", "zip-code-clicked"],

View file

@ -63,8 +63,9 @@ function setupMocks() {
describe("scheduling.vue", () => { describe("scheduling.vue", () => {
describe("intercept overlay", () => { describe("intercept overlay", () => {
test("does not render interceptOverlay when isLoadingDates is false", () => { test("does not render interceptOverlay when isLoadingDates is false", async () => {
const { wrapper } = setupMocks(); const { wrapper } = setupMocks();
await wrapper.setData({ isLoadingDates: false });
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false); expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false);
wrapper.unmount(); wrapper.unmount();
}); });

View file

@ -1,7 +1,6 @@
<template> <template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm"> <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<interceptOverlay v-if="isLoadingDates" /> <interceptOverlay v-if="isLoadingDates" />
<loadingModal notFullScreen ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" /> <funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<div class="container page-container-grouped-styles"> <div class="container page-container-grouped-styles">
<div class="row"> <div class="row">
@ -20,29 +19,35 @@
:availableDates="availableDates" :availableDates="availableDates"
:isLoadingDates="isLoadingDates" :isLoadingDates="isLoadingDates"
@requestMoreDates="handleRequestMoreDates" /> @requestMoreDates="handleRequestMoreDates" />
<mobileSchedulingCard <Transition name="card-slide" mode="out-in">
v-if="showMobileSchedulingCard" <div :key="selectedDate">
class="mt-4" <mobileSchedulingCard
v-model="selectedScheduling" v-if="showMobileSchedulingCard"
:providerNumber="mobileProviderAndTimeSlot.providerNumber" class="mt-4"
:timeSlots="mobileTimeSlotsForSelectedDate" v-model="selectedScheduling"
:premiumTimeSlotPrice="premiumTimeSlotPrice" :providerNumber="mobileProviderAndTimeSlot.providerNumber"
:zipCode="serviceZipCode" :timeSlots="mobileTimeSlotsForSelectedDate"
:showFreeFlag="showMobileFreeFlag" :premiumTimeSlotPrice="premiumTimeSlotPrice"
:radioGroupName="schedulingRadioGroupName" :zipCode="serviceZipCode"
:isLoading="isLoadingDates" :showFreeFlag="showMobileFreeFlag"
@zip-code-clicked="onMobileZipCodeClicked" /> :radioGroupName="schedulingRadioGroupName"
<inshopSchedulingCard :isLoading="isLoadingDates"
v-for="{ provider } in inShopProvidersAndTimeslots" @zip-code-clicked="onMobileZipCodeClicked" />
v-show="selectedDate" <inshopSchedulingCard
:key="provider.providerNumber" v-for="{ provider } in inShopProvidersAndTimeslots"
class="mt-4" v-show="showInshopSchedulingCards"
v-model="selectedScheduling" :key="provider.providerNumber"
:provider="provider" class="mt-4"
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)" v-model="selectedScheduling"
:radioGroupName="schedulingRadioGroupName" :provider="provider"
:isLoading="isLoadingDates" :timeSlots="
@address-clicked="onInshopAddressClicked(provider)" /> getInshopTimeSlotsForSelectedDate(provider.providerNumber)
"
:radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates"
@address-clicked="onInshopAddressClicked(provider)" />
</div>
</Transition>
<navbar <navbar
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="navbar" ref="navbar"
@ -62,7 +67,6 @@ import { Form } from "vee-validate";
import datePicker from "@/layouts/scheduling/date-picker/date-picker"; import datePicker from "@/layouts/scheduling/date-picker/date-picker";
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card"; import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card"; import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay"; import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
import store from "@/store"; import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
@ -138,17 +142,6 @@ export default {
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
const serviceZipCode = store.getters.order.serviceLocation.zipCode; const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const shopProviderData = await store.dispatch("getProviders", {
payload: { serviceZipCode },
pageNameToLog: to.name,
});
// Get the first 3 providers from the shopProviderData
const providers = shopProviderData?.data?.shopProviders?.slice(0, 3) ?? [];
const mobileProviderNumber = shopProviderData?.data?.mobileProviderNumber ?? null;
const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const promiseResultMap = [ const promiseResultMap = [
{ {
resultKey: "cmsContent", resultKey: "cmsContent",
@ -160,45 +153,64 @@ export default {
pageNameToLog: to.name, pageNameToLog: to.name,
}), }),
}, },
...providers.map((provider, i) => ({ {
resultKey: `inshopTimeSlots_${i}`, resultKey: "providers",
promise: fetchInshopTimeSlots({ promise: store.dispatch("getProviders", {
startDate, payload: { serviceZipCode },
endDate,
providerNumber: provider.providerNumber,
pageNameToLog: to.name, pageNameToLog: to.name,
}), }),
})), },
// Get the mobile time slots if a mobile provider number is available
...(mobileProviderNumber
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
pageNameToLog: to.name,
}),
},
]
: []),
]; ];
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => { // Get the first 3 providers from the shopProviderData
const providers = resultMap.providers?.shopProviders?.slice(0, 3) ?? [];
const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null;
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
vm.inShopProvidersAndTimeslots = providers.map((provider, i) => ({ vm.inShopProvidersAndTimeslots = providers.map((provider) => ({
provider, provider,
timeSlots: resultMap[`inshopTimeSlots_${i}`] ?? null, timeSlots: null,
})); }));
vm.mobileProviderAndTimeSlot = mobileProviderNumber vm.mobileProviderAndTimeSlot = mobileProviderNumber
? { ? { providerNumber: mobileProviderNumber, timeSlots: null }
providerNumber: mobileProviderNumber,
timeSlots: resultMap.mobileTimeSlots ?? null,
}
: null; : null;
const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const timeSlotsPromiseResultMap = [
...providers.map((provider, i) => ({
resultKey: `inshopTimeSlots_${i}`,
promise: fetchInshopTimeSlots({
startDate,
endDate,
providerNumber: provider.providerNumber,
pageNameToLog: to.name,
}),
})),
// Get the mobile time slots if a mobile provider number is available
...(mobileProviderNumber
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
pageNameToLog: to.name,
}),
},
]
: []),
];
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
vm.inShopProvidersAndTimeslots.forEach((entry, i) => {
entry.timeSlots = timeSlotsResultMap[`inshopTimeSlots_${i}`] ?? null;
});
if (vm.mobileProviderAndTimeSlot) {
vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null;
}
vm.datesLoaded = true; vm.datesLoaded = true;
vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null; vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null;
vm.isLoadingDates = false;
}); });
}, },
watch: { watch: {
@ -217,7 +229,12 @@ export default {
return store.getters.order.serviceLocation.zipCode; return store.getters.order.serviceLocation.zipCode;
}, },
showMobileSchedulingCard() { showMobileSchedulingCard() {
return Boolean(this.mobileProviderAndTimeSlot && this.selectedDate); return Boolean(
this.mobileProviderAndTimeSlot && (this.selectedDate || this.isLoadingDates)
);
},
showInshopSchedulingCards() {
return this.selectedDate || this.isLoadingDates;
}, },
mobileTimeSlotsForSelectedDate() { mobileTimeSlotsForSelectedDate() {
if (!this.selectedDate) { if (!this.selectedDate) {
@ -251,7 +268,7 @@ export default {
data() { data() {
return { return {
selectedDate: null, selectedDate: null,
isLoadingDates: false, isLoadingDates: true,
datesLoaded: false, datesLoaded: false,
datePickerStartDate: toDateString(0), datePickerStartDate: toDateString(0),
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1), datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
@ -378,7 +395,6 @@ export default {
datePicker, datePicker,
mobileSchedulingCard, mobileSchedulingCard,
inshopSchedulingCard, inshopSchedulingCard,
loadingModal,
interceptOverlay, interceptOverlay,
}, },
}; };
@ -391,4 +407,20 @@ h5 {
line-height: 32px; line-height: 32px;
font-size: 1.25rem; font-size: 1.25rem;
} }
.card-slide-enter-active {
transition:
opacity 0.3s ease-out,
transform 0.3s ease-out;
}
.card-slide-leave-active {
transition: opacity 0.32s ease-in;
}
.card-slide-enter-from {
opacity: 0;
transform: translateX(24px);
}
.card-slide-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -25,7 +25,8 @@ export default {
name: "vehicle-protected-question", name: "vehicle-protected-question",
props: { props: {
modelValue: { modelValue: {
isVehicleProtected: Boolean, type: Boolean,
default: null,
}, },
cmsWidgetName: String, cmsWidgetName: String,
labelBold: { labelBold: {

View file

@ -29,7 +29,8 @@ export default {
name: "service-zip-question", name: "service-zip-question",
props: { props: {
modelValue: { modelValue: {
serviceZipCode: String, type: String,
default: "",
}, },
cmsWidgetName: String, cmsWidgetName: String,
isRequired: { isRequired: {

View file

@ -4,7 +4,6 @@
cmsWidgetName="FunnelHeaderWidget" cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader" ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" /> :overrideImageSrc="clientLogoImageSrc" />
<div class="page-gradient"></div>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7"> <div class="col-12 col-md-10 col-lg-8 col-xl-7">

View file

@ -11,8 +11,9 @@ jest.mock("@/store", () => ({
commit: jest.fn(), commit: jest.fn(),
dispatch: jest.fn(), dispatch: jest.fn(),
getters: { getters: {
bailoutCode: null,
applicationUser: { applicationUser: {
bailoutCode: null, pageData: {},
}, },
externalParameterState: { externalParameterState: {
isExternalParameter: false, isExternalParameter: false,

View file

@ -525,7 +525,7 @@ export default {
}, },
getRequiredVinNotFound() { getRequiredVinNotFound() {
// Prevents success alert from showing // Prevents success alert from showing
var bailoutCode = this.$store.getters.applicationUser.bailoutCode; var bailoutCode = this.$store.getters.bailoutCode;
var vinRequired = this.$store.getters.vehicle.vinRequired; var vinRequired = this.$store.getters.vehicle.vinRequired;
if (vinRequired && this.vin && bailoutCode == bailoutCodes.PART_NOT_FOUND) { if (vinRequired && this.vin && bailoutCode == bailoutCodes.PART_NOT_FOUND) {
return true; return true;

View file

@ -34,6 +34,7 @@ import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { containsRecalParts, getRecalPartNumbers } from "@/helpers/recal-helper"; import { containsRecalParts, getRecalPartNumbers } from "@/helpers/recal-helper";
import { getAmountDue, getSubTotal, getSalesTax } from "@/helpers/pricing-helper.js"; import { getAmountDue, getSubTotal, getSalesTax } from "@/helpers/pricing-helper.js";
import { partTypeStrings } from "@/constants/part-type-strings"; import { partTypeStrings } from "@/constants/part-type-strings";
import { bailoutCodes } from "@/constants/bailout-codes";
import router from "@/router"; import router from "@/router";
export default { export default {
@ -271,6 +272,9 @@ export default {
sessionData.totalPrice = getAmountDue(order?.lineItems, true); sessionData.totalPrice = getAmountDue(order?.lineItems, true);
sessionData.userAgent = navigator.userAgent; sessionData.userAgent = navigator.userAgent;
sessionData.cashPriceSubTotal = order?.cashPriceSubTotal; sessionData.cashPriceSubTotal = order?.cashPriceSubTotal;
sessionData.bailoutCode = this.getBailoutCodeEnum(
applicationUser?.pageData?.bailout?.bailoutCode
);
await baseMixin.methods.dispatchStoreAction( await baseMixin.methods.dispatchStoreAction(
storeActions.LOG_FMG_SESSION_DATA, storeActions.LOG_FMG_SESSION_DATA,
@ -279,6 +283,17 @@ export default {
); );
}, },
getBailoutCodeEnum(bailoutCode) {
if (bailoutCode) {
const bailoutCodeEnum = Object.keys(bailoutCodes).find(
(key) => bailoutCodes[key] === bailoutCode
);
return bailoutCodeEnum ? bailoutCode + "_" + bailoutCodeEnum : bailoutCode;
} else {
return null;
}
},
pushOrderToDataLayer() { pushOrderToDataLayer() {
// helper check for if an object is defined (but maybe falsey) // helper check for if an object is defined (but maybe falsey)
const isDefined = (x) => x !== null && x !== undefined; const isDefined = (x) => x !== null && x !== undefined;
@ -622,7 +637,7 @@ export default {
} }
discount += productPrice * -1; discount += productPrice * -1;
subTotal += isQuotePageDiscount ? productPrice : 0; subTotal += productPrice;
} else { } else {
products.push({ products.push({
productType: productType, productType: productType,
@ -722,6 +737,9 @@ export default {
if (quotePageDiscount) { if (quotePageDiscount) {
combinedLineItems = combinedLineItems.concat(quotePageDiscount); combinedLineItems = combinedLineItems.concat(quotePageDiscount);
} }
if (lineItems.promos) {
combinedLineItems = combinedLineItems.concat(lineItems.promos);
}
//Remove child Parts if any //Remove child Parts if any
combinedLineItems.forEach((lineItem) => { combinedLineItems.forEach((lineItem) => {

View file

@ -22,6 +22,7 @@ import {
getUserIdValue, getUserIdValue,
} from "@/helpers/heritage-integration/cookie-helper"; } from "@/helpers/heritage-integration/cookie-helper";
import baseMixin from "./base-mixin"; import baseMixin from "./base-mixin";
import { bailoutCodes } from "@/constants/bailout-codes";
const parts = { const parts = {
windshield: { windshield: {
@ -746,6 +747,71 @@ describe("analyticsMixin.js", () => {
//Assert //Assert
expect(gaLabels).toEqual(GaLabels); expect(gaLabels).toEqual(GaLabels);
}); });
describe("getBailoutCodeEnum", () => {
test("returns null when bailout code is not provided", () => {
expect(analyticsMixin.methods.getBailoutCodeEnum(null)).toBeNull();
expect(analyticsMixin.methods.getBailoutCodeEnum(undefined)).toBeNull();
});
test("returns formatted value when bailout code matches a known enum", () => {
const result = analyticsMixin.methods.getBailoutCodeEnum(bailoutCodes.PART_NOT_FOUND);
expect(result).toBe(`${bailoutCodes.PART_NOT_FOUND}_PART_NOT_FOUND`);
});
test("returns original value when bailout code does not match a known enum", () => {
expect(analyticsMixin.methods.getBailoutCodeEnum(999)).toBe(999);
});
});
describe("pushFmgSessionData", () => {
beforeEach(() => {
baseMixin.methods.hasSubmittedOrder = jest.fn().mockReturnValue(false);
baseMixin.methods.getSubmittedOrder = jest.fn();
baseMixin.methods.hasSubmittedApplicationUser = jest.fn().mockReturnValue(false);
baseMixin.methods.getSubmittedApplicationUser = jest.fn();
store.getters.order = {
vehicle: { vinRequired: false },
payment: {
isInsurance: false,
isClaimAndCoverage: false,
isPia: false,
},
damage: { isRepair: false },
lineItems: {},
serviceLocation: {},
customer: {},
policy: {},
};
store.getters.applicationUser = {
experiments: [],
pageData: {
bailout: {
bailoutCode: bailoutCodes.PART_NOT_FOUND,
},
},
};
});
test("dispatches session data with formatted bailout code", async () => {
const mocks = setupMocksForJsFiles({
actionList: [{ actionName: storeActions.LOG_FMG_SESSION_DATA }],
});
await analyticsMixin.methods.pushFmgSessionData();
expect(mocks.baseMixin.methods.dispatchStoreAction).toHaveBeenCalledWith(
storeActions.LOG_FMG_SESSION_DATA,
expect.objectContaining({
currentPage: "mockedPageName",
bailoutCode: `${bailoutCodes.PART_NOT_FOUND}_PART_NOT_FOUND`,
}),
false
);
});
});
describe("initSession", () => { describe("initSession", () => {
test("Generates random values for userId and deviceId if not present", async () => { test("Generates random values for userId and deviceId if not present", async () => {
// Arrange // Arrange

View file

@ -1,12 +1,17 @@
import { navigationScenarios } from "@/router/constants/navigation-scenarios"; import { navigationScenarios } from "@/router/constants/navigation-scenarios";
import { routeData } from "@/router/constants/routes";
import { savePageData } from "@/router/methods/helpers/save-page-data";
export default { export default {
methods: { methods: {
navigateToBailoutPage(vm, bailoutCode) { navigateToBailoutPage(vm, bailoutCode) {
const self = vm ?? this; const self = vm ?? this;
self.dispatchStoreAction(self.storeActions.SAVE_BAILOUT_CODE, bailoutCode).then(() => { return savePageData(routeData.BAILOUT.name, { bailoutCode }).then(() => {
self.$router.navigateWithoutSaving(navigationScenarios.BAILOUT, self.pageName); return self.$router.navigateWithoutSaving(
navigationScenarios.BAILOUT,
self.pageName
);
}); });
}, },
}, },

View file

@ -1,33 +1,34 @@
import bailoutMixin from "@/mixins/bailout-mixin"; import bailoutMixin from "@/mixins/bailout-mixin";
import { storeActions } from "@/constants/store-actions.js";
import { navigationScenarios } from "@/router/constants/navigation-scenarios"; import { navigationScenarios } from "@/router/constants/navigation-scenarios";
import { bailoutCodes } from "@/constants/bailout-codes.js"; import { bailoutCodes } from "@/constants/bailout-codes.js";
import { routeData } from "@/router/constants/routes";
jest.mock("@/router/methods/helpers/save-page-data", () => ({
savePageData: jest.fn().mockResolvedValue(undefined),
}));
import { savePageData } from "@/router/methods/helpers/save-page-data";
describe("bailout-mixin.js", () => { describe("bailout-mixin.js", () => {
test("navigateToBailoutPage: dispatches SAVE_BAILOUT_CODE action with bailout code", async () => { beforeEach(() => {
// Arrange savePageData.mockClear();
});
test("navigateToBailoutPage: saves bailout code to pageData", async () => {
const mockVm = createMockVm(); const mockVm = createMockVm();
const bailoutCode = bailoutCodes.PART_NOT_FOUND; const bailoutCode = bailoutCodes.PART_NOT_FOUND;
// Act
await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode); await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode);
// Assert expect(savePageData).toHaveBeenCalledWith(routeData.BAILOUT.name, { bailoutCode });
expect(mockVm.dispatchStoreAction).toHaveBeenCalledWith(
storeActions.SAVE_BAILOUT_CODE,
bailoutCode
);
}); });
test("navigateToBailoutPage: navigates to bailout page after saving bailout code", async () => { test("navigateToBailoutPage: navigates to bailout page after saving bailout code", async () => {
// Arrange
const mockVm = createMockVm(); const mockVm = createMockVm();
const bailoutCode = bailoutCodes.PART_NOT_FOUND; const bailoutCode = bailoutCodes.PART_NOT_FOUND;
// Act
await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode); await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode);
// Assert
expect(mockVm.$router.navigateWithoutSaving).toHaveBeenCalledWith( expect(mockVm.$router.navigateWithoutSaving).toHaveBeenCalledWith(
navigationScenarios.BAILOUT, navigationScenarios.BAILOUT,
mockVm.pageName mockVm.pageName
@ -35,55 +36,39 @@ describe("bailout-mixin.js", () => {
}); });
test("navigateToBailoutPage: uses current context (this) when vm is not provided", async () => { test("navigateToBailoutPage: uses current context (this) when vm is not provided", async () => {
// Arrange
const mockRouter = { const mockRouter = {
navigateWithoutSaving: jest.fn().mockResolvedValue(undefined), navigateWithoutSaving: jest.fn().mockResolvedValue(undefined),
}; };
const mockThis = { const mockThis = {
dispatchStoreAction: jest.fn().mockResolvedValue(undefined),
$router: mockRouter, $router: mockRouter,
storeActions: storeActions,
pageName: "test-page", pageName: "test-page",
}; };
const bailoutCode = bailoutCodes.PART_NOT_FOUND; const bailoutCode = bailoutCodes.PART_NOT_FOUND;
// Act
await bailoutMixin.methods.navigateToBailoutPage.call(mockThis, undefined, bailoutCode); await bailoutMixin.methods.navigateToBailoutPage.call(mockThis, undefined, bailoutCode);
// Assert expect(savePageData).toHaveBeenCalledWith(routeData.BAILOUT.name, { bailoutCode });
expect(mockThis.dispatchStoreAction).toHaveBeenCalledWith(
storeActions.SAVE_BAILOUT_CODE,
bailoutCode
);
}); });
test("navigateToBailoutPage: passes correct bailout code to store", async () => { test("navigateToBailoutPage: passes correct bailout code to pageData", async () => {
// Arrange
const mockVm = createMockVm(); const mockVm = createMockVm();
const customBailoutCode = 999; const customBailoutCode = 999;
// Act
await bailoutMixin.methods.navigateToBailoutPage(mockVm, customBailoutCode); await bailoutMixin.methods.navigateToBailoutPage(mockVm, customBailoutCode);
// Assert expect(savePageData).toHaveBeenCalledWith(routeData.BAILOUT.name, {
expect(mockVm.dispatchStoreAction).toHaveBeenCalledWith( bailoutCode: customBailoutCode,
storeActions.SAVE_BAILOUT_CODE, });
customBailoutCode
);
}); });
test("navigateToBailoutPage: calls navigateWithoutSaving with correct parameters", async () => { test("navigateToBailoutPage: calls navigateWithoutSaving with correct parameters", async () => {
// Arrange
const mockVm = createMockVm(); const mockVm = createMockVm();
const mockPageName = "vehicle-damage"; const mockPageName = "vehicle-damage";
mockVm.pageName = mockPageName; mockVm.pageName = mockPageName;
const bailoutCode = bailoutCodes.PART_NOT_FOUND; const bailoutCode = bailoutCodes.PART_NOT_FOUND;
// Act
await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode); await bailoutMixin.methods.navigateToBailoutPage(mockVm, bailoutCode);
// Assert
expect(mockVm.$router.navigateWithoutSaving).toHaveBeenCalledWith( expect(mockVm.$router.navigateWithoutSaving).toHaveBeenCalledWith(
navigationScenarios.BAILOUT, navigationScenarios.BAILOUT,
mockPageName mockPageName
@ -93,11 +78,9 @@ describe("bailout-mixin.js", () => {
function createMockVm() { function createMockVm() {
return { return {
dispatchStoreAction: jest.fn().mockResolvedValue(undefined),
$router: { $router: {
navigateWithoutSaving: jest.fn().mockResolvedValue(undefined), navigateWithoutSaving: jest.fn().mockResolvedValue(undefined),
}, },
storeActions,
pageName: "test-page", pageName: "test-page",
}; };
} }

View file

@ -3,6 +3,10 @@ import { shallowMount } from "@vue/test-utils";
import { setupMocksForJsFiles, getMountOptions } from "@/helpers/unit-test-helper.js"; import { setupMocksForJsFiles, getMountOptions } from "@/helpers/unit-test-helper.js";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin"; import vehicleQuestionsMixin from "@/mixins/vehicle-questions-mixin";
import bailoutMixin from "@/mixins/bailout-mixin";
import { bailoutCodes } from "@/constants/bailout-codes";
import store from "@/store";
import { storeMutations } from "@/constants/store-mutations";
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({ jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateForward: jest.fn(), navigateForward: jest.fn(),
@ -15,6 +19,7 @@ jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({
describe("vin-pages-mixin", () => { describe("vin-pages-mixin", () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
store.commit(storeMutations.UPDATE_VIN_REQUIRED, false);
}); });
describe("navigateForwardWithSingleCarMatch", () => { describe("navigateForwardWithSingleCarMatch", () => {
@ -29,10 +34,46 @@ describe("vin-pages-mixin", () => {
// Assert // Assert
expect(vehicleQuestionsMixin.methods.navigateForward).toHaveBeenCalled(); expect(vehicleQuestionsMixin.methods.navigateForward).toHaveBeenCalled();
}); });
test("navigate to bailout when parts are not found", async () => {
// Arrange
const { wrapper } = setupMocks({ partNotFound: true });
bailoutMixin.methods.navigateToBailoutPage = jest.fn();
vehicleQuestionsMixin.methods.navigateForward = jest.fn();
// Act
await wrapper.vm.navigateForwardWithSingleCarMatch();
// Assert
expect(bailoutMixin.methods.navigateToBailoutPage).toHaveBeenCalledWith(
wrapper.vm,
bailoutCodes.PART_NOT_FOUND
);
expect(vehicleQuestionsMixin.methods.navigateForward).not.toHaveBeenCalled();
});
test("vinRequired vehicles navigate forward when parts are found", async () => {
// Arrange
store.commit(storeMutations.UPDATE_VIN_REQUIRED, true);
const partsOrQuestions = [{ partNumber: "123" }];
const { wrapper } = setupMocks({ partsOrQuestions });
bailoutMixin.methods.navigateToBailoutPage = jest.fn();
vehicleQuestionsMixin.methods.navigateForward = jest.fn();
// Act
await wrapper.vm.navigateForwardWithSingleCarMatch();
// Assert
expect(bailoutMixin.methods.navigateToBailoutPage).not.toHaveBeenCalled();
expect(vehicleQuestionsMixin.methods.navigateForward).toHaveBeenCalledWith(
partsOrQuestions,
wrapper.vm
);
});
}); });
}); });
function setupMocks({ partsOrQuestions = [] }) { function setupMocks({ partsOrQuestions = [], partNotFound = false } = {}) {
const baseMixin = setupMocksForJsFiles({ const baseMixin = setupMocksForJsFiles({
actionList: [ actionList: [
{ {
@ -44,6 +85,14 @@ function setupMocks({ partsOrQuestions = [] }) {
], ],
}); });
if (partNotFound) {
baseMixin.baseMixin.methods.dispatchStoreAction.mockImplementation((actionName) => {
if (actionName === storeActions.GET_PARTS_OR_QUESTIONS) {
return Promise.resolve({ PartNotFound: true });
}
});
}
const mocks = getMountOptions({ const mocks = getMountOptions({
router: { router: {
navigate: jest.fn(), navigate: jest.fn(),

View file

@ -193,5 +193,5 @@ export async function beforeEach(to, from) {
function getIsBailout(submittedState) { function getIsBailout(submittedState) {
const submittedStateObj = JSON.parse(submittedState); const submittedStateObj = JSON.parse(submittedState);
return !!submittedStateObj?.applicationUser?.bailoutCode; return !!submittedStateObj?.applicationUser?.pageData?.bailout?.bailoutCode;
} }

View file

@ -3,6 +3,7 @@ import { buildManualUrl } from "@/router/methods/helpers/build-manual-url";
import { getDestination } from "@/router/methods/helpers/get-destination"; import { getDestination } from "@/router/methods/helpers/get-destination";
import { savePageData } from "@/router/methods/helpers/save-page-data"; import { savePageData } from "@/router/methods/helpers/save-page-data";
import { navigationScenarios } from "@/router/constants/navigation-scenarios"; import { navigationScenarios } from "@/router/constants/navigation-scenarios";
import { routeData } from "@/router/constants/routes";
import router from "@/router"; import router from "@/router";
import store from "@/store"; import store from "@/store";
@ -79,9 +80,13 @@ export async function navigateWithSaving(scenario, currentPageName) {
export async function navigateWithPageData(scenario, currentPageName, pageData = {}) { export async function navigateWithPageData(scenario, currentPageName, pageData = {}) {
const nextPage = getDestination(currentPageName, scenario); const nextPage = getDestination(currentPageName, scenario);
if (pageData && pageData.bailoutCode) { if (currentPageName === routeData.BAILOUT.name) {
pageData.AppName = "FixMyGlass"; const existingPageData = store.getters.pageData(routeData.BAILOUT.name) ?? {};
await savePageData(currentPageName, pageData); await savePageData(routeData.BAILOUT.name, {
...existingPageData,
...pageData,
AppName: "FixMyGlass",
});
} else { } else {
await savePageData(nextPage.name, pageData); await savePageData(nextPage.name, pageData);
} }

View file

@ -229,7 +229,6 @@ const getDefaultState = () => {
affiliateCookies: [], affiliateCookies: [],
loggingOption: false, loggingOption: false,
hasAlreadyTriggeredError: false, hasAlreadyTriggeredError: false,
bailoutCode: null,
}, },
idempotencyKeyFields: { idempotencyKeyFields: {
referralCorrelationId: null, referralCorrelationId: null,
@ -506,6 +505,9 @@ export const mutations = {
state.order.vehicle.carId = vehicleInfo.carId; state.order.vehicle.carId = vehicleInfo.carId;
state.order.vehicle.category = vehicleInfo.category; state.order.vehicle.category = vehicleInfo.category;
state.order.vehicle.vin = vehicleInfo.vin; state.order.vehicle.vin = vehicleInfo.vin;
if (vehicleInfo.vinRequired !== undefined) {
state.order.vehicle.vinRequired = vehicleInfo.vinRequired;
}
state.order.vehicle.imageUrl = vehicleInfo.imageUrl; state.order.vehicle.imageUrl = vehicleInfo.imageUrl;
state.order.vehicle.imageVifNumber = vehicleInfo.imageVifNumber; state.order.vehicle.imageVifNumber = vehicleInfo.imageVifNumber;
@ -1035,9 +1037,6 @@ export const mutations = {
state.idempotencyKeyFields.totalInCents = totalInCents; state.idempotencyKeyFields.totalInCents = totalInCents;
state.idempotencyKeyFields.expiryTime = expiryTime; state.idempotencyKeyFields.expiryTime = expiryTime;
}, },
updateBailoutCode(state, bailoutCode) {
state.applicationUser.bailoutCode = bailoutCode;
},
}; };
// Export Getters // Export Getters
@ -1160,6 +1159,7 @@ export const getters = {
pageData: (state) => (page) => { pageData: (state) => (page) => {
return state.applicationUser.pageData[page]; return state.applicationUser.pageData[page];
}, },
bailoutCode: (state) => state.applicationUser.pageData.bailout?.bailoutCode,
applicationUser: (state) => state.applicationUser, applicationUser: (state) => state.applicationUser,
order: (state) => state.order, order: (state) => state.order,
payment: (state) => state.order.payment, payment: (state) => state.order.payment,
@ -1849,6 +1849,7 @@ export const actions = {
totalPrice, totalPrice,
userAgent, userAgent,
cashPriceSubTotal, cashPriceSubTotal,
bailoutCode,
} }
) { ) {
var payload = { var payload = {
@ -1903,6 +1904,7 @@ export const actions = {
userAgent: userAgent, userAgent: userAgent,
cashPriceSubTotal: cashPriceSubTotal, cashPriceSubTotal: cashPriceSubTotal,
billToAccountNumber: billToAccountNumber, billToAccountNumber: billToAccountNumber,
bailoutCode: bailoutCode,
}; };
return globalMethods.callHttpClient({ return globalMethods.callHttpClient({
@ -2001,7 +2003,7 @@ export const actions = {
const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray); const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray);
const partsOrQuestionsEndpoint = vehicle.vinRequired const partsOrQuestionsEndpoint = vehicle.vinRequired
? endpoints.GetPartsOrQuestionsV3 ? endpoints.GetPartsOrQuestionsV2
: endpoints.GetPartsOrQuestions; : endpoints.GetPartsOrQuestions;
const response = await globalMethods const response = await globalMethods
@ -3980,10 +3982,6 @@ export const actions = {
context.commit(storeMutations.UPDATE_IDEMPOTENCY_KEY, newKey); context.commit(storeMutations.UPDATE_IDEMPOTENCY_KEY, newKey);
} }
}, },
saveBailoutCode(context, bailoutCode) {
context.commit(storeMutations.UPDATE_BAILOUT_CODE, bailoutCode);
},
}; };
export default createStore({ export default createStore({

View file

@ -102,8 +102,3 @@ option,
.btn { .btn {
letter-spacing: 0.03rem; letter-spacing: 0.03rem;
} }
.page-gradient {
height: 12px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0));
}

View file

@ -14,4 +14,33 @@ describe("intercept-overlay.vue", () => {
expect(wrapper.text()).toBe(""); expect(wrapper.text()).toBe("");
wrapper.unmount(); wrapper.unmount();
}); });
describe("keyboard interception", () => {
test("adds a capturing keydown listener on mount and removes it on unmount", () => {
const addSpy = jest.spyOn(document, "addEventListener");
const removeSpy = jest.spyOn(document, "removeEventListener");
const wrapper = shallowMount(interceptOverlay);
expect(addSpy).toHaveBeenCalledWith("keydown", expect.any(Function), true);
const [, handler] = addSpy.mock.calls.find(
([type, , capture]) => type === "keydown" && capture === true
);
wrapper.unmount();
expect(removeSpy).toHaveBeenCalledWith("keydown", handler, true);
addSpy.mockRestore();
removeSpy.mockRestore();
});
test("blockKey prevents default and stops immediate propagation", () => {
const wrapper = shallowMount(interceptOverlay);
const event = { preventDefault: jest.fn(), stopImmediatePropagation: jest.fn() };
wrapper.vm.blockKey(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopImmediatePropagation).toHaveBeenCalled();
wrapper.unmount();
});
});
}); });

View file

@ -1,10 +1,22 @@
<template> <template>
<div class="intercept-overlay" aria-hidden="true"></div> <div class="intercept-overlay" aria-hidden="true" @keydown.capture="blockKey"></div>
</template> </template>
<script> <script>
export default { export default {
name: "intercept-overlay", name: "intercept-overlay",
mounted() {
document.addEventListener("keydown", this.blockKey, true);
},
beforeUnmount() {
document.removeEventListener("keydown", this.blockKey, true);
},
methods: {
blockKey(event) {
event.preventDefault();
event.stopImmediatePropagation();
},
},
}; };
</script> </script>