Merge pull request #3224 from Safelite/rlsmerge/2026.06.18-to-develop
Rlsmerge/2026.06.18 to develop
This commit is contained in:
commit
5f62160534
32 changed files with 1465 additions and 47 deletions
|
|
@ -258,10 +258,36 @@ body {
|
|||
return match?.value;
|
||||
}
|
||||
|
||||
// Refactor opportunity: sessionStorage keys and cookie names below are duplicated from the
|
||||
// Vue app because this static page is not webpack-bundled. Canonical sources:
|
||||
// - src/constants/session-storage.js (sessionStorageKeyConstants)
|
||||
// - src/constants/cookie-names.js (cookieNames; env-suffixed funnel cookies)
|
||||
// - src/router/methods/error.js (clearBailoutSessionStorage — keep in sync on bailout keys)
|
||||
// Options: build-time inject into this file, shared plain JS under public/static/, or generate
|
||||
// from constants during CI. Until then, update all three places when keys change.
|
||||
function clearApplicationData() {
|
||||
window.localStorage.removeItem('vuex');
|
||||
|
||||
// Keys match sessionStorageKeyConstants in src/constants/session-storage.js and
|
||||
// clearBailoutSessionStorage() in src/router/methods/error.js — keep in sync.
|
||||
const sessionStorageKeysToClear = [
|
||||
'submittedState',
|
||||
'heritageSuppressRedirectCount',
|
||||
'beforeEachErrorRecoveryCount',
|
||||
'externalParameterState',
|
||||
];
|
||||
// Clear sessionStorage keys that can re-poison the funnel on re-entry.
|
||||
sessionStorageKeysToClear.forEach((key) => {
|
||||
try {
|
||||
window.sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// sessionStorage unavailable
|
||||
}
|
||||
});
|
||||
|
||||
const environmentData = getCurrentEnvironmentData();
|
||||
// Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here
|
||||
// (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app).
|
||||
const cookieNames = [
|
||||
`FunnelUserId-${environmentData?.name}`,
|
||||
`FunnelSessionKey-${environmentData?.name}`,
|
||||
|
|
|
|||
9
src/assets/img/party.svg
Normal file
9
src/assets/img/party.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.08048 11.2565C7.10347 12.7378 6.74105 14.0529 7.27617 14.5523C8.01886 15.2464 10.2121 14.1048 12.1742 12.0032C14.1363 9.90159 15.1238 7.63545 14.3803 6.94141C13.7861 6.38602 12.2617 7.00628 10.671 8.35383" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.5095 7.10645L17.4713 16.4087C17.61 16.8441 17.2135 17.2608 16.7724 17.1449L7.55371 14.719" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.5 5.67676H2.51946M4.85242 14.3255H4.87187" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.08657 4.91572C8.08657 5.46787 7.63902 5.91543 7.08687 5.91543C6.53472 5.91543 6.08716 5.46787 6.08716 4.91572C6.08716 4.36357 6.53472 3.91602 7.08687 3.91602C7.63902 3.91602 8.08657 4.36357 8.08657 4.91572Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.38436 11.5723C5.38436 11.5723 3.81872 11.6031 2.62036 13.1793" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.4553 10.5741C10.4553 10.5741 7.71892 7.75006 3.5271 8.94357" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.4734 2.83496C11.4734 2.83496 9.8891 5.00465 11.6437 7.61298" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
src/assets/img/van.svg
Normal file
7
src/assets/img/van.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.80005 18.4746C7.90462 18.4746 8.80005 17.5792 8.80005 16.4746C8.80005 15.37 7.90462 14.4746 6.80005 14.4746C5.69548 14.4746 4.80005 15.37 4.80005 16.4746C4.80005 17.5792 5.69548 18.4746 6.80005 18.4746Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.6001 18.4746C18.7047 18.4746 19.6001 17.5792 19.6001 16.4746C19.6001 15.37 18.7047 14.4746 17.6001 14.4746C16.4955 14.4746 15.6001 15.37 15.6001 16.4746C15.6001 17.5792 16.4955 18.4746 17.6001 18.4746Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.5 5.52539V10.0254H18.1" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.8 16.1754H3C2.45 16.1754 2 15.7254 2 15.1754V6.52539C2 5.97539 2.45 5.52539 3 5.52539H14.92C15.28 5.52539 15.61 5.71539 15.79 6.02539L18.1 10.0254L20.72 11.0354C21.49 11.3354 22 12.0754 22 12.9054V15.1854C22 15.7354 21.55 16.1854 21 16.1854H19.6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.6 16.1748H8.80005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -56,6 +56,10 @@ const endpoints = {
|
|||
url: "/parts/api/v1/parts/parts-or-questions",
|
||||
method: "POST",
|
||||
},
|
||||
GetPartsOrQuestionsV3: {
|
||||
url: "/parts/api/v3/parts/parts-or-questions",
|
||||
method: "POST",
|
||||
},
|
||||
GetParts: {
|
||||
url: "/parts/api/v1/parts/parts",
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export const headerKeys = {
|
|||
TRANSACTION_ID: "X-Transaction-Id",
|
||||
EON: "X-Enterprise-Order-Number",
|
||||
LOG_ENABLED: "log-enabled",
|
||||
PAGE_NAME_TO_LOG: "X-Page-Name-To-Log",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@ export const sessionStorageKeyConstants = {
|
|||
SUBMITTED_STATE: "submittedState",
|
||||
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
|
||||
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
|
||||
/** Counts beforeEach uncaught-exception recovery attempts to avoid an infinite ERROR redirect loop. */
|
||||
BEFORE_EACH_ERROR_RECOVERY_COUNT: "beforeEachErrorRecoveryCount",
|
||||
/** Persists deep-link / querystring funnel entry state across navigations. */
|
||||
EXTERNAL_PARAMETER_STATE: "externalParameterState",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -290,4 +290,16 @@ export default {
|
|||
background-color: #ffc966 !important;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
// Enlarge the calendar popup. The menu is teleported to <body>, so these must
|
||||
// be global rules. VueDatePicker sizes itself via CSS custom properties.
|
||||
.dp__menu {
|
||||
--dp-cell-size: 46px;
|
||||
--dp-cell-padding: 8px;
|
||||
--dp-font-size: 1.05rem;
|
||||
--dp-menu-min-width: 320px;
|
||||
--dp-button-height: 40px;
|
||||
--dp-month-year-row-height: 40px;
|
||||
--dp-row-margin: 6px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="funnel-header" :class="{ scrolled: isScrolled }" v-if="imageSrc">
|
||||
<div
|
||||
class="funnel-header"
|
||||
:class="{ scrolled: isScrolled, 'has-override-logo': !!overrideImageSrc }"
|
||||
v-if="imageSrc">
|
||||
<div class="d-flex w-100">
|
||||
<progress-bar :page="$route.name" />
|
||||
</div>
|
||||
|
|
@ -183,6 +186,17 @@ export default {
|
|||
padding: 2rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Insurance pages use a taller client logo; drop the header's vertical
|
||||
// padding so the overall header height matches the standard site header.
|
||||
&.has-override-logo .container {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
@include media-breakpoint-up(md) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
}
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="row"></div>
|
||||
<div class="insurance-nav-bar nav-bar container-fluid g-5 my-5 px-0" id="infoBox">
|
||||
<div class="insurance-nav-bar nav-bar container-fluid g-5 my-6 px-0" id="infoBox">
|
||||
<div class="row d-flex justify-content-between align-items-center vw-100">
|
||||
<div v-if="!isCancelButtonHidden" class="col-auto cancel-col py-1 text-break">
|
||||
<textLink
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export default {
|
|||
[headerKeys.APPLICATION_NAME]: applicationConfig.APPLICATION_NAME,
|
||||
[headerKeys.SESSION_SEQUENCE_NUMBER]: getSessionKeyValue(),
|
||||
[headerKeys.REFERRAL_SEQUENCE_NUMBER]: order?.referralSequenceNumber,
|
||||
[headerKeys.PAGE_NAME_TO_LOG]: pageNameToLog,
|
||||
[headerKeys.TRANSACTION_ID]: crypto.randomUUID(),
|
||||
[headerKeys.EON]: order?.eon,
|
||||
[headerKeys.LOG_ENABLED]: store.getters.applicationUser?.loggingOption ?? false,
|
||||
|
|
|
|||
|
|
@ -355,7 +355,15 @@ export default {
|
|||
}
|
||||
} else {
|
||||
// No VINS found.
|
||||
this.displayVinNotFoundAlert = true;
|
||||
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||
if (isVinLookupRequired) {
|
||||
this.continueWithFailedLookup(
|
||||
this.customerQuestions,
|
||||
resultMap.serviceZipValidationResponse
|
||||
);
|
||||
} else {
|
||||
this.displayVinNotFoundAlert = true;
|
||||
}
|
||||
return this.$refs.navbar.removeLoader();
|
||||
}
|
||||
|
||||
|
|
@ -462,6 +470,28 @@ export default {
|
|||
this.displayVinLookupByHomeAddressNotAllowedAlert = false;
|
||||
this.displayNoServiceAlert = false;
|
||||
},
|
||||
async continueWithFailedLookup(customerQuestions, zipCodeValidationResponse) {
|
||||
// Save the entered customer questions and service zip code (if applicable) so that it can be used in the next page if needed
|
||||
await this.dispatchStoreAction(
|
||||
this.storeActions.SAVE_CUSTOMER_DETAILS,
|
||||
{
|
||||
firstName: customerQuestions.firstName,
|
||||
lastName: customerQuestions.lastName,
|
||||
},
|
||||
false
|
||||
);
|
||||
await this.dispatchStoreAction(
|
||||
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||
{
|
||||
state: zipCodeValidationResponse.state,
|
||||
zipCode: this.serviceZipCode || this.customerQuestions.addressQuestions.zipCode,
|
||||
zipCodeCtu: zipCodeValidationResponse.zipCodeCtu,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
this.navigateForwardWithSingleCarMatch();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.attachCustomEvents();
|
||||
|
|
|
|||
|
|
@ -254,7 +254,12 @@ export default {
|
|||
resultMap.registrationZipValidationResponse.state
|
||||
).catch(() => {
|
||||
// No VIN found.
|
||||
this.displayVinNotFoundAlert = true;
|
||||
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||
if (isVinLookupRequired) {
|
||||
this.continueWithFailedLookup(resultMap.serviceZipValidationResponse);
|
||||
} else {
|
||||
this.displayVinNotFoundAlert = true;
|
||||
}
|
||||
return this.$refs.navbar.removeLoader();
|
||||
});
|
||||
// If no VIN was found, stop processing after displaying alert
|
||||
|
|
@ -393,6 +398,18 @@ export default {
|
|||
this.displayInvalidZipAlert = false;
|
||||
this.displayNoServiceAlert = false;
|
||||
},
|
||||
async continueWithFailedLookup(zipCodeData) {
|
||||
await this.dispatchStoreAction(
|
||||
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||
{
|
||||
state: zipCodeData.state,
|
||||
zipCode: this.serviceZipCode || this.registrationZipCode,
|
||||
zipCodeCtu: zipCodeData.zipCodeCtu,
|
||||
},
|
||||
false
|
||||
);
|
||||
this.navigateForwardWithSingleCarMatch();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.attachCustomEvents();
|
||||
|
|
|
|||
|
|
@ -567,14 +567,14 @@ export default {
|
|||
|
||||
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
|
||||
|
||||
const shouldUseAdyen = experimentMixin.methods.hasSettingEqualTo(
|
||||
const shouldUseCybersource = experimentMixin.methods.hasSettingEqualTo(
|
||||
experimentSettings.USE_ADYEN_PAYMENT,
|
||||
"true"
|
||||
"false"
|
||||
);
|
||||
|
||||
const scenarioName = shouldUseAdyen
|
||||
? this.navigationScenarios.CLICKED_PAY_NOW_ADYEN
|
||||
: this.navigationScenarios.CLICKED_PAY_NOW;
|
||||
const scenarioName = shouldUseCybersource
|
||||
? this.navigationScenarios.CLICKED_PAY_NOW
|
||||
: this.navigationScenarios.CLICKED_PAY_NOW_ADYEN;
|
||||
|
||||
this.$router.navigateWithoutSaving(scenarioName, this.pageName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
|
||||
<funnelSubHeader class="pb-4" cmsWidgetName="FunnelSubHeaderWidget" alignLeft />
|
||||
<funnelSubHeader
|
||||
class="pb-4 policy-vehicle-subheader"
|
||||
cmsWidgetName="FunnelSubHeaderWidget"
|
||||
alignLeft />
|
||||
|
||||
<buttonMain
|
||||
id="btn-vehicle-not-listed"
|
||||
|
|
@ -144,6 +147,10 @@ export default {
|
|||
color: $white;
|
||||
}
|
||||
|
||||
.policy-vehicle-subheader {
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.disclaimer-text {
|
||||
font-size: 12px !important;
|
||||
font-weight: 400;
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ describe("date-picker.vue", () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("returns empty array when availableDates is empty", () => {
|
||||
const wrapper = mountDesktop({ availableDates: [] });
|
||||
test("returns empty array when availableDates is null", () => {
|
||||
const wrapper = mountDesktop({ availableDates: null });
|
||||
expect(wrapper.vm.allDates).toEqual([]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
|
@ -427,7 +427,7 @@ describe("date-picker.vue", () => {
|
|||
|
||||
describe("initializeWindow", () => {
|
||||
test("sets the initialized flag to true", async () => {
|
||||
const wrapper = mountDesktop({ availableDates: [] });
|
||||
const wrapper = mountDesktop({ availableDates: null });
|
||||
expect(wrapper.vm.initialized).toBe(false);
|
||||
await wrapper.setProps({ availableDates: AVAILABLE_DATES });
|
||||
expect(wrapper.vm.initialized).toBe(true);
|
||||
|
|
@ -436,7 +436,7 @@ describe("date-picker.vue", () => {
|
|||
|
||||
test("positions windowStart to the window containing the first available date", async () => {
|
||||
const wrapper = mountDesktop({
|
||||
availableDates: [],
|
||||
availableDates: null,
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-10",
|
||||
});
|
||||
|
|
@ -447,7 +447,7 @@ describe("date-picker.vue", () => {
|
|||
});
|
||||
|
||||
test("emits update:modelValue with the first available date", async () => {
|
||||
const wrapper = mountDesktop({ availableDates: [] });
|
||||
const wrapper = mountDesktop({ availableDates: null });
|
||||
await wrapper.setProps({ availableDates: AVAILABLE_DATES });
|
||||
expect(wrapper.emitted("update:modelValue")[0][0]).toBe("2026-01-21");
|
||||
wrapper.unmount();
|
||||
|
|
@ -455,7 +455,7 @@ describe("date-picker.vue", () => {
|
|||
|
||||
test("positions windowStart to the last window and does not emit when no dates are available", async () => {
|
||||
const wrapper = mountDesktop({
|
||||
availableDates: [],
|
||||
availableDates: null,
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-10",
|
||||
});
|
||||
|
|
@ -468,7 +468,7 @@ describe("date-picker.vue", () => {
|
|||
});
|
||||
|
||||
test("does not re-run after initialized flag is set", async () => {
|
||||
const wrapper = mountDesktop({ availableDates: [] });
|
||||
const wrapper = mountDesktop({ availableDates: null });
|
||||
await wrapper.setProps({ availableDates: AVAILABLE_DATES });
|
||||
const emissionCount = wrapper.emitted("update:modelValue").length;
|
||||
// Add a newly available date within the range to trigger the watcher again
|
||||
|
|
@ -481,8 +481,8 @@ describe("date-picker.vue", () => {
|
|||
|
||||
describe("allDates watcher", () => {
|
||||
test("calls initializeWindow when dates first arrive with no modelValue set", async () => {
|
||||
const wrapper = mountDesktop({ availableDates: [] });
|
||||
// No dates → mounted() does nothing; initialized stays false
|
||||
const wrapper = mountDesktop({ availableDates: null });
|
||||
// null availableDates → allDates=[] → mounted() does nothing; initialized stays false
|
||||
expect(wrapper.vm.initialized).toBe(false);
|
||||
await wrapper.setProps({ availableDates: AVAILABLE_DATES });
|
||||
expect(wrapper.vm.initialized).toBe(true);
|
||||
|
|
@ -492,7 +492,7 @@ describe("date-picker.vue", () => {
|
|||
|
||||
test("clears pendingAutoSelect and advances the window when pendingAutoSelect is true", async () => {
|
||||
const wrapper = mountDesktop({
|
||||
availableDates: [],
|
||||
availableDates: null,
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-10",
|
||||
});
|
||||
|
|
@ -506,11 +506,11 @@ describe("date-picker.vue", () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("takes no action when allDates becomes empty", async () => {
|
||||
test("takes no action when availableDates becomes null", async () => {
|
||||
const wrapper = mountDesktop({ availableDates: AVAILABLE_DATES });
|
||||
// mounted() calls initializeWindow() → emits; record count
|
||||
const emissionCount = wrapper.emitted("update:modelValue").length;
|
||||
await wrapper.setProps({ availableDates: [] });
|
||||
await wrapper.setProps({ availableDates: null });
|
||||
// watcher fires with newDates=[] → early return, nothing changes
|
||||
expect(wrapper.emitted("update:modelValue").length).toBe(emissionCount);
|
||||
wrapper.unmount();
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ export default {
|
|||
name: "datePicker",
|
||||
props: {
|
||||
availableDates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
default: null,
|
||||
validator: (v) => v === null || Array.isArray(v),
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
|
|
@ -111,9 +111,11 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
allDates() {
|
||||
if (this.availableDates === null) return [];
|
||||
|
||||
const availableSet = new Set(this.availableDates);
|
||||
|
||||
if (!this.availableDates.length || !this.startDate || !this.endDate) return [];
|
||||
if (!this.startDate || !this.endDate) return [];
|
||||
|
||||
const first = parseLocalDate(this.startDate);
|
||||
const last = parseLocalDate(this.endDate);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { shallowMount } from "@vue/test-utils";
|
||||
import mobileSchedulingCard from "./mobile-scheduling-card";
|
||||
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
|
||||
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
||||
|
||||
const MOCK_TIME_SLOTS = [
|
||||
{
|
||||
id: "3357I-03357-M-I*21346*AM",
|
||||
startTime: "08:00",
|
||||
endTime: "12:00",
|
||||
offerPremium: true,
|
||||
},
|
||||
{
|
||||
id: "3357I-03357-M-I*21346*PM",
|
||||
startTime: "12:00",
|
||||
endTime: "17:00",
|
||||
offerPremium: false,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_CMS_CONTENT = {
|
||||
MobileCardWidget: {
|
||||
HeaderText: "We'll come to you",
|
||||
SubheaderText: "We'll come to you for free!",
|
||||
BodyText: "Enter address on the next page",
|
||||
FooterText: "First stop of the day",
|
||||
FooterText2: "8:00 am - 12:00 pm arrival window",
|
||||
},
|
||||
};
|
||||
|
||||
function mountComponent(props = {}, cmsContent = {}) {
|
||||
const cmsContentByWidget = {
|
||||
...MOCK_CMS_CONTENT,
|
||||
...cmsContent,
|
||||
MobileCardWidget: {
|
||||
...MOCK_CMS_CONTENT.MobileCardWidget,
|
||||
...cmsContent.MobileCardWidget,
|
||||
},
|
||||
};
|
||||
|
||||
const cmsMixin = {
|
||||
methods: {
|
||||
getCmsContent: jest.fn((widgetName, fieldName) => {
|
||||
return cmsContentByWidget[widgetName]?.[fieldName] ?? "";
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mountOptions = getMountOptions({
|
||||
route: { name: "scheduling" },
|
||||
mixins: [cmsMixin],
|
||||
});
|
||||
|
||||
return shallowMount(mobileSchedulingCard, {
|
||||
props: {
|
||||
zipCode: "43235",
|
||||
timeSlots: MOCK_TIME_SLOTS,
|
||||
premiumTimeSlotPrice: 29.99,
|
||||
...props,
|
||||
},
|
||||
global: mountOptions.global,
|
||||
});
|
||||
}
|
||||
|
||||
describe("mobile-scheduling-card.vue", () => {
|
||||
test("renders header, zip code, and time slot options", () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain("We'll come to you");
|
||||
expect(wrapper.text()).toContain("43235");
|
||||
expect(wrapper.text()).toContain("Enter address on the next page");
|
||||
expect(wrapper.text()).toContain("First stop of the day");
|
||||
expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window");
|
||||
expect(wrapper.text()).toContain("+$29.99");
|
||||
expect(wrapper.text()).toContain("3 appts");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("shows greyed out 0 appts badge when there are no time slots", () => {
|
||||
const wrapper = mountComponent({ timeSlots: [] });
|
||||
expect(wrapper.text()).toContain("0 appts");
|
||||
expect(wrapper.find(".mobile-scheduling-card__appt-badge--empty").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("shows free flag when showFreeFlag is true", () => {
|
||||
const wrapper = mountComponent({ showFreeFlag: true });
|
||||
expect(wrapper.text()).toContain("We'll come to you for free!");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits zip-code-clicked when zip link is clicked", async () => {
|
||||
const wrapper = mountComponent();
|
||||
await wrapper.find(".mobile-scheduling-card__zip-link").trigger("click");
|
||||
expect(wrapper.emitted("zip-code-clicked")).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits update:modelValue with premium selection", async () => {
|
||||
const wrapper = mountComponent();
|
||||
const premiumInput = wrapper.find(
|
||||
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]`
|
||||
);
|
||||
await premiumInput.setValue(true);
|
||||
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
|
||||
selectedMobileTimeSlot: {
|
||||
routeCodeId: MOCK_TIME_SLOTS[0].id,
|
||||
isPremiumAppointment: true,
|
||||
},
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("shows premium sublabel only when the premium option is selected", async () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).not.toContain("8:00 am - 12:00 pm arrival window");
|
||||
|
||||
const premiumInput = wrapper.find(
|
||||
`input[value="${MOCK_TIME_SLOTS[0].id}${PREMIUM_TIME_SLOT_ID_FLAG}"]`
|
||||
);
|
||||
await premiumInput.setValue(true);
|
||||
await wrapper.setProps({
|
||||
modelValue: {
|
||||
selectedMobileTimeSlot: {
|
||||
routeCodeId: MOCK_TIME_SLOTS[0].id,
|
||||
isPremiumAppointment: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("8:00 am - 12:00 pm arrival window");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("emits update:modelValue with standard time slot selection", async () => {
|
||||
const wrapper = mountComponent();
|
||||
const pmInput = wrapper.find(`input[value="${MOCK_TIME_SLOTS[1].id}"]`);
|
||||
await pmInput.setValue(true);
|
||||
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
|
||||
selectedMobileTimeSlot: {
|
||||
routeCodeId: MOCK_TIME_SLOTS[1].id,
|
||||
isPremiumAppointment: false,
|
||||
},
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("collapses and expands body when header is clicked", async () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
|
||||
await wrapper.find(".mobile-scheduling-card__header").trigger("click");
|
||||
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(false);
|
||||
expect(wrapper.find(".mobile-scheduling-card__location-bar").isVisible()).toBe(true);
|
||||
await wrapper.find(".mobile-scheduling-card__header").trigger("click");
|
||||
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
<template>
|
||||
<div class="mobile-scheduling-card">
|
||||
<div v-if="showFreeFlag" class="mobile-scheduling-card__free-flag">
|
||||
<img
|
||||
class="mobile-scheduling-card__free-flag-icon"
|
||||
src="@/assets/img/party.svg"
|
||||
alt=""
|
||||
aria-hidden="true" />
|
||||
<span class="mobile-scheduling-card__free-flag-text">{{ freeFlagText }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="mobile-scheduling-card__panel"
|
||||
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-scheduling-card__header"
|
||||
:aria-expanded="isExpanded"
|
||||
@click="toggleExpanded">
|
||||
<img
|
||||
class="mobile-scheduling-card__van-icon"
|
||||
src="@/assets/img/van.svg"
|
||||
alt=""
|
||||
aria-hidden="true" />
|
||||
<span class="mobile-scheduling-card__header-title">{{ headerTitle }}</span>
|
||||
<span
|
||||
class="mobile-scheduling-card__appt-badge"
|
||||
:class="{
|
||||
'mobile-scheduling-card__appt-badge--empty': appointmentCount === 0,
|
||||
}">
|
||||
{{ appointmentCountLabel }}
|
||||
</span>
|
||||
<img
|
||||
class="mobile-scheduling-card__chevron"
|
||||
:class="{ 'mobile-scheduling-card__chevron--expanded': isExpanded }"
|
||||
src="@/assets/img/icons/chevron-right-blue.svg"
|
||||
alt=""
|
||||
aria-hidden="true" />
|
||||
</button>
|
||||
<div class="mobile-scheduling-card__location-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-scheduling-card__zip-link"
|
||||
@click.stop="onZipCodeClick">
|
||||
{{ zipCode }}
|
||||
</button>
|
||||
<span class="mobile-scheduling-card__location-divider" aria-hidden="true">|</span>
|
||||
<span class="mobile-scheduling-card__address-hint">{{ addressHintText }}</span>
|
||||
</div>
|
||||
<div v-show="isExpanded" class="mobile-scheduling-card__body">
|
||||
<fieldset class="mobile-scheduling-card__time-slots">
|
||||
<legend class="sr-only">{{ headerTitle }}</legend>
|
||||
<label
|
||||
v-for="slot in displayTimeSlots"
|
||||
:key="slot.value"
|
||||
class="mobile-scheduling-card__slot"
|
||||
:class="{
|
||||
'mobile-scheduling-card__slot--selected': isSlotSelected(slot),
|
||||
}">
|
||||
<input
|
||||
type="radio"
|
||||
class="mobile-scheduling-card__slot-input"
|
||||
name="mobileSchedulingTimeSlot"
|
||||
:value="slot.value"
|
||||
:checked="isSlotSelected(slot)"
|
||||
@change="onTimeSlotSelected(slot)" />
|
||||
<span class="mobile-scheduling-card__slot-label">
|
||||
<span class="mobile-scheduling-card__slot-label-primary">
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="slot.subLabel && isSlotSelected(slot)"
|
||||
class="mobile-scheduling-card__slot-label-secondary">
|
||||
{{ slot.subLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="slot.priceLabel" class="mobile-scheduling-card__slot-price">
|
||||
{{ slot.priceLabel }}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { PREMIUM_TIME_SLOT_ID_FLAG } from "@/constants/schedule-constants";
|
||||
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
|
||||
|
||||
export default {
|
||||
name: "mobile-scheduling-card",
|
||||
emits: ["update:modelValue", "zip-code-clicked"],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
timeSlots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
premiumTimeSlotPrice: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
zipCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showFreeFlag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
cmsWidgetName: {
|
||||
type: String,
|
||||
default: "MobileCardWidget",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isExpanded: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
headerTitle() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
|
||||
},
|
||||
freeFlagText() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
|
||||
},
|
||||
addressHintText() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "BodyText");
|
||||
},
|
||||
premiumTimeSlotLabel() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "FooterText");
|
||||
},
|
||||
premiumTimeSlotSubLabel() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "FooterText2");
|
||||
},
|
||||
appointmentCount() {
|
||||
return this.displayTimeSlots.length;
|
||||
},
|
||||
appointmentCountLabel() {
|
||||
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
|
||||
},
|
||||
hasPremiumTimeSlot() {
|
||||
return this.timeSlots?.length > 0; //this.timeSlots?.[0]?.offerPremium === true && this.premiumTimeSlotPrice != null;
|
||||
},
|
||||
displayTimeSlots() {
|
||||
if (!this.timeSlots?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slots = this.timeSlots.map((timeSlot) => ({
|
||||
value: timeSlot.id,
|
||||
label: this.formatArrivalWindow(timeSlot.startTime, timeSlot.endTime),
|
||||
priceLabel: null,
|
||||
isPremiumAppointment: false,
|
||||
routeCodeId: timeSlot.id,
|
||||
}));
|
||||
|
||||
if (this.hasPremiumTimeSlot) {
|
||||
slots.unshift({
|
||||
value: this.addPremiumFlagToInput(this.timeSlots[0].id),
|
||||
label: this.premiumTimeSlotLabel,
|
||||
subLabel: this.premiumTimeSlotSubLabel || null,
|
||||
priceLabel: this.formattedPremiumPrice,
|
||||
isPremiumAppointment: true,
|
||||
routeCodeId: this.timeSlots[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
},
|
||||
formattedPremiumPrice() {
|
||||
if (this.premiumTimeSlotPrice == null) {
|
||||
return null;
|
||||
}
|
||||
return `+$${this.premiumTimeSlotPrice.toFixed(2)}`;
|
||||
},
|
||||
selectedRouteCodeValue() {
|
||||
const selected = this.modelValue?.selectedMobileTimeSlot;
|
||||
if (!selected?.routeCodeId) {
|
||||
return null;
|
||||
}
|
||||
return selected.isPremiumAppointment
|
||||
? this.addPremiumFlagToInput(selected.routeCodeId)
|
||||
: selected.routeCodeId;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleExpanded() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
},
|
||||
onZipCodeClick() {
|
||||
this.$emit("zip-code-clicked");
|
||||
},
|
||||
formatArrivalWindow(startTime, endTime) {
|
||||
return `${militaryToTwelveHourTime(startTime)} - ${militaryToTwelveHourTime(
|
||||
endTime
|
||||
)} arrival window`;
|
||||
},
|
||||
isSlotSelected(slot) {
|
||||
return this.selectedRouteCodeValue === slot.value;
|
||||
},
|
||||
onTimeSlotSelected(slot) {
|
||||
this.$emit("update:modelValue", {
|
||||
selectedMobileTimeSlot: {
|
||||
routeCodeId: slot.routeCodeId,
|
||||
isPremiumAppointment: slot.isPremiumAppointment,
|
||||
},
|
||||
});
|
||||
},
|
||||
addPremiumFlagToInput(routeCode) {
|
||||
return routeCode + PREMIUM_TIME_SLOT_ID_FLAG;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-scheduling-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
&__free-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: $green;
|
||||
color: $green-600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 2px 8px 2px 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
&__free-flag-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__panel {
|
||||
width: 100%;
|
||||
border: 1px solid $gray-1000;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: $white;
|
||||
|
||||
&--with-free-flag {
|
||||
border-radius: 0 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__van-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__header-title {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
color: $black;
|
||||
}
|
||||
|
||||
&__appt-badge {
|
||||
background: $blue-150;
|
||||
color: $blue;
|
||||
border-radius: 72px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25rem;
|
||||
white-space: nowrap;
|
||||
|
||||
&--empty {
|
||||
background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 150ms linear;
|
||||
|
||||
&--expanded {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&__location-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__zip-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: $blue;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__location-divider,
|
||||
&__address-hint {
|
||||
color: $gray-600;
|
||||
}
|
||||
|
||||
&__time-slots {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__slot {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border: 1px solid $gray-1000;
|
||||
border-radius: 8px;
|
||||
padding: 12px 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
background: $white;
|
||||
|
||||
&--selected {
|
||||
border: 2px solid $blue;
|
||||
background: $blue-150;
|
||||
}
|
||||
}
|
||||
|
||||
&__slot-input {
|
||||
margin-top: 4px;
|
||||
accent-color: $blue;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__slot-label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__slot-label-primary,
|
||||
&__slot-label-secondary {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
color: $gray-600;
|
||||
}
|
||||
|
||||
&__slot--selected &__slot-label-primary {
|
||||
color: $black;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__slot-price {
|
||||
background: $green;
|
||||
color: $green-600;
|
||||
border-radius: 72px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,6 +19,15 @@
|
|||
:availableDates="availableDates"
|
||||
:isLoadingDates="isLoadingDates"
|
||||
@requestMoreDates="handleRequestMoreDates" />
|
||||
<mobileSchedulingCard
|
||||
v-if="showMobileSchedulingCard"
|
||||
class="mt-4"
|
||||
v-model="selectedMobileScheduling"
|
||||
:timeSlots="mobileTimeSlotsForSelectedDate"
|
||||
:premiumTimeSlotPrice="premiumTimeSlotPrice"
|
||||
:zipCode="serviceZipCode"
|
||||
:showFreeFlag="showMobileFreeFlag"
|
||||
@zip-code-clicked="onMobileZipCodeClicked" />
|
||||
<navbar
|
||||
cmsWidgetName="FunnelFooterWidget"
|
||||
ref="navbar"
|
||||
|
|
@ -36,11 +45,12 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
|
|||
import navbar from "@/fmg-components/nav-bar/nav-bar";
|
||||
import { Form } from "vee-validate";
|
||||
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
||||
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
||||
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
||||
import store from "@/store";
|
||||
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
|
||||
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
||||
import {
|
||||
flushPagePrereqsLogs,
|
||||
|
|
@ -84,6 +94,12 @@ export default {
|
|||
resultKey: "cmsContent",
|
||||
promise: fetchCmsContentForPage(to.name),
|
||||
},
|
||||
{
|
||||
resultKey: "mobilePremiumFee",
|
||||
promise: store.dispatch("getMobilePremiumFee", {
|
||||
pageNameToLog: to.name,
|
||||
}),
|
||||
},
|
||||
...providers.map((provider, i) => ({
|
||||
resultKey: `inshopTimeSlots_${i}`,
|
||||
promise: store.dispatch("getShopTimeSlots", {
|
||||
|
|
@ -122,13 +138,45 @@ export default {
|
|||
timeSlots: resultMap.mobileTimeSlots ?? null,
|
||||
}
|
||||
: null;
|
||||
vm.datesLoaded = true;
|
||||
vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null;
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
selectedDate() {
|
||||
this.selectedMobileScheduling = null;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
serviceLocationText() {
|
||||
return this.getCmsContent("ServiceLocationText", "Text");
|
||||
},
|
||||
serviceZipCode() {
|
||||
return store.getters.order.serviceLocation.zipCode;
|
||||
},
|
||||
showMobileSchedulingCard() {
|
||||
return Boolean(this.mobileProviderAndTimeSlot && this.selectedDate);
|
||||
},
|
||||
mobileTimeSlotsForSelectedDate() {
|
||||
if (!this.selectedDate) {
|
||||
return [];
|
||||
}
|
||||
const day = this.mobileProviderAndTimeSlot?.timeSlots?.days?.find(
|
||||
(d) => d.date === this.selectedDate
|
||||
);
|
||||
return day?.timeSlots ?? [];
|
||||
},
|
||||
premiumTimeSlotPrice() {
|
||||
if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) {
|
||||
return null;
|
||||
}
|
||||
return this.getTotalLineItemPrice(this.mobilePremiumAppointmentFee);
|
||||
},
|
||||
showMobileFreeFlag() {
|
||||
return true;
|
||||
},
|
||||
availableDates() {
|
||||
if (!this.datesLoaded) return null;
|
||||
const inshopDates = this.inShopProvidersAndTimeslots.flatMap(({ timeSlots }) =>
|
||||
(timeSlots?.days ?? []).map((d) => d.date)
|
||||
);
|
||||
|
|
@ -142,13 +190,19 @@ export default {
|
|||
return {
|
||||
selectedDate: null,
|
||||
isLoadingDates: false,
|
||||
datesLoaded: false,
|
||||
datePickerStartDate: toDateString(0),
|
||||
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
|
||||
inShopProvidersAndTimeslots: [],
|
||||
mobileProviderAndTimeSlot: null,
|
||||
mobilePremiumAppointmentFee: null,
|
||||
selectedMobileScheduling: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onMobileZipCodeClicked() {
|
||||
// TODO: open service zip modal when zip edit is implemented for scheduling page
|
||||
},
|
||||
arePagePrerequisitesValid() {
|
||||
const order = store.getters.order;
|
||||
const logQueue = [];
|
||||
|
|
@ -212,6 +266,7 @@ export default {
|
|||
navbar,
|
||||
Form,
|
||||
datePicker,
|
||||
mobileSchedulingCard,
|
||||
loadingModal,
|
||||
},
|
||||
};
|
||||
|
|
@ -220,4 +275,8 @@ export default {
|
|||
.dark-header {
|
||||
color: $black;
|
||||
}
|
||||
h5 {
|
||||
line-height: 32px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,20 @@ jest.mock("@/store", () => ({
|
|||
commit: jest.fn(),
|
||||
dispatch: jest.fn(),
|
||||
getters: {
|
||||
applicationUser: {
|
||||
bailoutCode: null,
|
||||
},
|
||||
externalParameterState: {
|
||||
isExternalParameter: false,
|
||||
},
|
||||
emailOrSms: "email",
|
||||
vehicle: {
|
||||
year: 2019,
|
||||
carId: "C00000",
|
||||
vin: null,
|
||||
vinRequired: false,
|
||||
isBigTruck: false,
|
||||
canSafeliteService: true,
|
||||
},
|
||||
order: {
|
||||
serviceLocation: {
|
||||
|
|
@ -23,6 +34,11 @@ jest.mock("@/store", () => ({
|
|||
emailAddress: "builddigitaltest@safelite.com",
|
||||
},
|
||||
referralNumber: "666666",
|
||||
vehicle: {
|
||||
make: null,
|
||||
model: null,
|
||||
year: null,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
insuranceCoverage: {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@
|
|||
vinPopulatedOnPageLoad &&
|
||||
!isInsuranceVerified &&
|
||||
!displayInvalidZipAlert &&
|
||||
!displayNonServiceableZipAlert
|
||||
!displayNonServiceableZipAlert &&
|
||||
!requiredVinNotFound
|
||||
"
|
||||
alertClass="alert-success" />
|
||||
|
||||
|
|
@ -144,6 +145,7 @@ import baseMixin from "@/mixins/base-mixin.js";
|
|||
import store from "@/store";
|
||||
import vinPagesMixin from "@/mixins/vin-pages-mixin";
|
||||
import { routeData } from "@/router/constants/routes";
|
||||
import { bailoutCodes } from "@/constants/bailout-codes";
|
||||
|
||||
// DEFINE VALIDATION RULES
|
||||
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
|
||||
|
|
@ -202,6 +204,7 @@ export default {
|
|||
displayMatchedDifferentVehicleAlert: false,
|
||||
displayVinScanFailedAlert: false,
|
||||
displayNoServiceAlert: false,
|
||||
requiredVinNotFound: this.getRequiredVinNotFound(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -289,7 +292,13 @@ export default {
|
|||
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
|
||||
// If the vehicle result is undefined, the vin entered was invalid.
|
||||
if (!resultMap.vehicleLookupResponse) {
|
||||
this.displayVinNotFoundAlert = true;
|
||||
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||
if (isVinLookupRequired) {
|
||||
this.requiredVinNotFound = true;
|
||||
this.continueWithFailedVin(this.vin, resultMap.zipCodeData);
|
||||
} else {
|
||||
this.displayVinNotFoundAlert = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Service Zip entered is serviceable, if not display an alert
|
||||
|
|
@ -303,18 +312,18 @@ export default {
|
|||
|
||||
// Check if the CarId is different from the lookup vs what is in state currently.
|
||||
this.isCarIdDifferent =
|
||||
resultMap.vehicleLookupResponse.carId !== this.$store.getters.vehicle.carId;
|
||||
resultMap.vehicleLookupResponse?.carId !== this.$store.getters.vehicle.carId;
|
||||
|
||||
if (
|
||||
this.isCarIdDifferent &&
|
||||
resultMap.vehicleLookupResponse.carId !== this.previouslyEnteredCarId
|
||||
resultMap.vehicleLookupResponse?.carId !== this.previouslyEnteredCarId
|
||||
) {
|
||||
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse.carId;
|
||||
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse?.carId;
|
||||
this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse;
|
||||
this.displayMatchedDifferentVehicleAlert = true;
|
||||
|
||||
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
|
||||
resultMap.vehicleLookupResponse.carId,
|
||||
resultMap.vehicleLookupResponse?.carId,
|
||||
"vin-lookup"
|
||||
);
|
||||
|
||||
|
|
@ -486,10 +495,47 @@ export default {
|
|||
this.displayVinNotFoundAlert = false;
|
||||
this.displayVinScanFailedAlert = false;
|
||||
this.displayNoServiceAlert = false;
|
||||
this.requiredVinNotFound = false;
|
||||
},
|
||||
async continueWithFailedVin(vin, zipCodeData) {
|
||||
var selectedVehicle = {
|
||||
vin: vin,
|
||||
vehicle: this.$store.getters.vehicle,
|
||||
};
|
||||
await this.dispatchStoreAction(
|
||||
storeActions.SAVE_VIN,
|
||||
{
|
||||
vehicleInfo: Object.assign(selectedVehicle.vehicle, {
|
||||
vin: vin,
|
||||
}),
|
||||
isSelectedGlassAvailableForVehicle: this.isSelectedGlassAvailableForVehicle,
|
||||
},
|
||||
false
|
||||
);
|
||||
await this.dispatchStoreAction(
|
||||
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
|
||||
{
|
||||
state: zipCodeData.state,
|
||||
zipCode: this.serviceZipCode,
|
||||
zipCodeCtu: zipCodeData.zipCodeCtu,
|
||||
},
|
||||
false
|
||||
);
|
||||
this.navigateForwardWithSingleCarMatch();
|
||||
},
|
||||
getRequiredVinNotFound() {
|
||||
// Prevents success alert from showing
|
||||
var bailoutCode = this.$store.getters.applicationUser.bailoutCode;
|
||||
var vinRequired = this.$store.getters.vehicle.vinRequired;
|
||||
if (vinRequired && this.vin && bailoutCode == bailoutCodes.PART_NOT_FOUND) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.attachCustomEvents();
|
||||
this.requiredVinNotFound = this.getRequiredVinNotFound();
|
||||
},
|
||||
computed: {
|
||||
AlertMatchedDifferentVehicleHeader() {
|
||||
|
|
|
|||
|
|
@ -363,6 +363,12 @@ export default {
|
|||
payload.carId = "";
|
||||
}
|
||||
|
||||
if (isDefined(order.vehicle.vinRequired)) {
|
||||
payload.vehicleIsVinRequired = order.vehicle.vinRequired;
|
||||
} else {
|
||||
payload.vehicleIsVinRequired = false;
|
||||
}
|
||||
|
||||
// Glass pieces
|
||||
const glass = order.damage.glassToReplace ?? [];
|
||||
if (glass.length === 0) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ export default {
|
|||
});
|
||||
|
||||
if (result.PartNotFound) {
|
||||
bailoutMixin.methods.navigateToBailoutPage(this, bailoutCodes.PART_NOT_FOUND);
|
||||
return bailoutMixin.methods.navigateToBailoutPage(
|
||||
this,
|
||||
bailoutCodes.PART_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const partsOrQuestions = result.data.partsOrQuestions;
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@ const routingTable = function () {
|
|||
scenario: navigationScenarios.CLICKED_VIN_RETRY,
|
||||
destinationPageData: routeData.ESTIMATE,
|
||||
},
|
||||
{
|
||||
scenario: navigationScenarios.BAILOUT,
|
||||
destinationPageData: routeData.BAILOUT,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
51
src/router/methods/before-each-error-recovery.js
Normal file
51
src/router/methods/before-each-error-recovery.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||
|
||||
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||
|
||||
/** Used when sessionStorage is missing, throws, or does not persist (e.g. some bots). */
|
||||
let inMemoryRecoveryCount = 0;
|
||||
|
||||
export function getBeforeEachErrorRecoveryCount() {
|
||||
try {
|
||||
const stored = window.sessionStorage?.getItem(recoveryKey);
|
||||
if (stored !== null && stored !== undefined) {
|
||||
return Number(stored) || 0;
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage unavailable; use in-memory fallback
|
||||
}
|
||||
|
||||
return inMemoryRecoveryCount;
|
||||
}
|
||||
|
||||
export function incrementBeforeEachErrorRecoveryCount() {
|
||||
const nextCount = getBeforeEachErrorRecoveryCount() + 1;
|
||||
inMemoryRecoveryCount = nextCount;
|
||||
|
||||
try {
|
||||
window.sessionStorage?.setItem(recoveryKey, String(nextCount));
|
||||
} catch {
|
||||
// in-memory count already updated
|
||||
}
|
||||
|
||||
return nextCount;
|
||||
}
|
||||
|
||||
export function clearBeforeEachErrorRecoveryCount() {
|
||||
inMemoryRecoveryCount = 0;
|
||||
|
||||
try {
|
||||
window.sessionStorage?.removeItem(recoveryKey);
|
||||
} catch {
|
||||
// in-memory count already cleared
|
||||
}
|
||||
}
|
||||
|
||||
export function hasBeforeEachErrorRecoveryBeenAttempted() {
|
||||
return getBeforeEachErrorRecoveryCount() >= 1;
|
||||
}
|
||||
|
||||
/** Test-only: resets in-memory and sessionStorage recovery state between unit tests. */
|
||||
export function resetBeforeEachErrorRecoveryStateForTests() {
|
||||
clearBeforeEachErrorRecoveryCount();
|
||||
}
|
||||
80
src/router/methods/before-each-error-recovery.spec.js
Normal file
80
src/router/methods/before-each-error-recovery.spec.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
clearBeforeEachErrorRecoveryCount,
|
||||
getBeforeEachErrorRecoveryCount,
|
||||
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||
incrementBeforeEachErrorRecoveryCount,
|
||||
resetBeforeEachErrorRecoveryStateForTests,
|
||||
} from "./before-each-error-recovery";
|
||||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||
|
||||
describe("router/methods/before-each-error-recovery", () => {
|
||||
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||
let originalSessionStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
resetBeforeEachErrorRecoveryStateForTests();
|
||||
originalSessionStorage = window.sessionStorage;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: originalSessionStorage,
|
||||
});
|
||||
resetBeforeEachErrorRecoveryStateForTests();
|
||||
});
|
||||
|
||||
it("reads and writes recovery count via sessionStorage when available", () => {
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears sessionStorage and in-memory count", () => {
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
clearBeforeEachErrorRecoveryCount();
|
||||
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||
expect(getBeforeEachErrorRecoveryCount()).toBe(0);
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses in-memory count when sessionStorage getItem throws", () => {
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => {
|
||||
throw new Error("sessionStorage blocked");
|
||||
},
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
expect(getBeforeEachErrorRecoveryCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("uses in-memory count when sessionStorage setItem throws", () => {
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("sessionStorage blocked");
|
||||
},
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,16 @@ import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
|
|||
import { runExperiments } from "@/router/methods/helpers/run-experiments";
|
||||
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
|
||||
|
||||
import { handleSoftError, handleHardError } from "@/router/methods/error";
|
||||
import {
|
||||
handleSoftError,
|
||||
handleHardError,
|
||||
redirectToStaticErrorPage,
|
||||
} from "@/router/methods/error";
|
||||
import {
|
||||
clearBeforeEachErrorRecoveryCount,
|
||||
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||
incrementBeforeEachErrorRecoveryCount,
|
||||
} from "@/router/methods/before-each-error-recovery";
|
||||
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
|
||||
import { checkLogParam } from "@/helpers/debug-log-helper";
|
||||
import { debugLog } from "@/helpers/debug-log-helper";
|
||||
|
|
@ -19,8 +28,21 @@ import router from "@/router";
|
|||
|
||||
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
|
||||
|
||||
function isRecoveryRoute(routeName) {
|
||||
return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name;
|
||||
}
|
||||
|
||||
export async function beforeEach(to, from) {
|
||||
try {
|
||||
if (isRecoveryRoute(to.name)) {
|
||||
debugLog(`--- before-each.js recovery route ${to.name} ---`);
|
||||
// Do NOT clear the recovery counter here. The counter must accumulate across
|
||||
// consecutive failures that bounce through the ERROR/RESTART route so the
|
||||
// circuit breaker can trip and fall back to the static error page. It is only
|
||||
// cleared after a fully successful guard pass (end of try block).
|
||||
return;
|
||||
}
|
||||
|
||||
await analyticsMixin.methods.validateSession();
|
||||
|
||||
// prettier-ignore
|
||||
|
|
@ -63,7 +85,7 @@ export async function beforeEach(to, from) {
|
|||
}
|
||||
}
|
||||
|
||||
// Ensure session has not expired
|
||||
// Ensure session has not expired - not primary check for session expiration and uses FunnelSessionInfo-{environment} cookie.
|
||||
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
|
||||
const errorPayload = {
|
||||
cause: "expired session",
|
||||
|
|
@ -141,18 +163,31 @@ export async function beforeEach(to, from) {
|
|||
{
|
||||
debugLog("--- before-each.js end ---");
|
||||
}
|
||||
|
||||
clearBeforeEachErrorRecoveryCount();
|
||||
} catch (error) {
|
||||
const errorPayload = {
|
||||
cause: "Uncaught exception in `beforeEach`.",
|
||||
currentPage: from?.name,
|
||||
nextPage: to?.name,
|
||||
errorMessage: error?.message,
|
||||
errorStack: error?.stack,
|
||||
};
|
||||
|
||||
console.log(error);
|
||||
global.$logger?.logError("beforeEach uncaught exception", errorPayload);
|
||||
|
||||
if (to.name === routeData.ERROR.name || hasBeforeEachErrorRecoveryBeenAttempted()) {
|
||||
clearBeforeEachErrorRecoveryCount();
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
return false;
|
||||
}
|
||||
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
|
||||
// Eject user from Vue app in this scenario and clear localstorage.
|
||||
await handleHardError(errorPayload);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
257
src/router/methods/before-each.spec.js
Normal file
257
src/router/methods/before-each.spec.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import analyticsMixin from "@/mixins/analytics-mixin";
|
||||
import { runExperiments } from "@/router/methods/helpers/run-experiments";
|
||||
import { handleHardError, redirectToStaticErrorPage } from "@/router/methods/error";
|
||||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||
import { routeData } from "@/router/constants/routes";
|
||||
import { debugLog } from "@/helpers/debug-log-helper";
|
||||
|
||||
jest.mock("@/mixins/analytics-mixin", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
methods: {
|
||||
validateSession: jest.fn(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/router/methods/helpers/run-experiments", () => ({
|
||||
runExperiments: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
|
||||
getFunnelCookie: jest.fn(() => null),
|
||||
updateOrCreateFunnelCookie: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/heritage-integration/session-helper", () => ({
|
||||
isSavedSessionStillActive: jest.fn(() => true),
|
||||
getDateForSavedSessionTimeout: jest.fn(() => "2099-01-01T00:00:00.000Z"),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
|
||||
navigateToHeritageFunnel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/router/methods/helpers/handle-heritage-return", () => ({
|
||||
handleHeritageReturn: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/router/methods/page-prerequisites", () => ({
|
||||
checkPagePrerequisites: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/debug-log-helper", () => ({
|
||||
debugLog: jest.fn(),
|
||||
checkLogParam: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/router/methods/error", () => ({
|
||||
handleSoftError: jest.fn(() => Promise.resolve()),
|
||||
handleHardError: jest.fn(() => Promise.resolve()),
|
||||
redirectToStaticErrorPage: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("@/router", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { beforeEach as routerBeforeEach } from "./before-each";
|
||||
import {
|
||||
hasBeforeEachErrorRecoveryBeenAttempted,
|
||||
incrementBeforeEachErrorRecoveryCount,
|
||||
resetBeforeEachErrorRecoveryStateForTests,
|
||||
} from "./before-each-error-recovery";
|
||||
|
||||
describe("router/methods/before-each", () => {
|
||||
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetBeforeEachErrorRecoveryStateForTests();
|
||||
window.sessionStorage.removeItem(recoveryKey);
|
||||
window.sessionStorage.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
|
||||
analyticsMixin.methods.validateSession.mockResolvedValue(undefined);
|
||||
debugLog.mockImplementation(() => undefined);
|
||||
global.$logger = {
|
||||
logError: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("skips validateSession and runExperiments when navigating to ERROR", async () => {
|
||||
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
|
||||
|
||||
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
|
||||
expect(runExperiments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips validateSession and runExperiments when navigating to RESTART", async () => {
|
||||
await routerBeforeEach({ name: routeData.RESTART.name }, { name: routeData.VEHICLE.name });
|
||||
|
||||
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
|
||||
expect(runExperiments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears recovery counter after successful funnel navigation", async () => {
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
|
||||
await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves recovery counter when passing through the ERROR recovery route", async () => {
|
||||
incrementBeforeEachErrorRecoveryCount();
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
|
||||
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
|
||||
|
||||
// The counter must survive the ERROR bounce so consecutive failures can trip the
|
||||
// circuit breaker; it is only cleared after a fully successful guard pass.
|
||||
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||
});
|
||||
|
||||
it("runs validateSession on a normal funnel page", async () => {
|
||||
await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(analyticsMixin.methods.validateSession).toHaveBeenCalled();
|
||||
expect(runExperiments).toHaveBeenCalledWith(routeData.VEHICLE_DAMAGE.name);
|
||||
});
|
||||
|
||||
it("calls handleHardError once on uncaught exception", async () => {
|
||||
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||
|
||||
const result = await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(handleHardError).toHaveBeenCalledTimes(1);
|
||||
expect(handleHardError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cause: "Uncaught exception in `beforeEach`.",
|
||||
errorMessage: "session failure",
|
||||
errorStack: expect.any(String),
|
||||
})
|
||||
);
|
||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||
"beforeEach uncaught exception",
|
||||
expect.objectContaining({ errorMessage: "session failure" })
|
||||
);
|
||||
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("redirects to static error page when exception occurs on ERROR route", async () => {
|
||||
debugLog.mockImplementation(() => {
|
||||
throw new Error("recovery route failure");
|
||||
});
|
||||
|
||||
const result = await routerBeforeEach(
|
||||
{ name: routeData.ERROR.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||
"beforeEach uncaught exception",
|
||||
expect.objectContaining({
|
||||
errorMessage: "recovery route failure",
|
||||
nextPage: routeData.ERROR.name,
|
||||
})
|
||||
);
|
||||
expect(redirectToStaticErrorPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cause: "Uncaught exception in `beforeEach`.",
|
||||
errorMessage: "recovery route failure",
|
||||
nextPage: routeData.ERROR.name,
|
||||
})
|
||||
);
|
||||
expect(handleHardError).not.toHaveBeenCalled();
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("redirects to static error page on second uncaught exception in session", async () => {
|
||||
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||
window.sessionStorage.setItem(recoveryKey, "1");
|
||||
|
||||
const result = await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||
"beforeEach uncaught exception",
|
||||
expect.objectContaining({ cause: "Uncaught exception in `beforeEach`." })
|
||||
);
|
||||
expect(redirectToStaticErrorPage).toHaveBeenCalled();
|
||||
expect(handleHardError).not.toHaveBeenCalled();
|
||||
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("redirects to static error page on second failure when sessionStorage throws", async () => {
|
||||
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
|
||||
|
||||
const storageBlockedError = new Error("sessionStorage blocked");
|
||||
const underlyingStorage = window.sessionStorage;
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem(key) {
|
||||
if (key === recoveryKey) {
|
||||
throw storageBlockedError;
|
||||
}
|
||||
return underlyingStorage.getItem(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (key === recoveryKey) {
|
||||
throw storageBlockedError;
|
||||
}
|
||||
underlyingStorage.setItem(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
if (key === recoveryKey) {
|
||||
throw storageBlockedError;
|
||||
}
|
||||
underlyingStorage.removeItem(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
expect(handleHardError).toHaveBeenCalledTimes(1);
|
||||
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const result = await routerBeforeEach(
|
||||
{ name: routeData.VEHICLE_DAMAGE.name },
|
||||
{ name: routeData.VEHICLE.name }
|
||||
);
|
||||
|
||||
expect(redirectToStaticErrorPage).toHaveBeenCalled();
|
||||
expect(handleHardError).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: underlyingStorage,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,9 +3,41 @@ import { routeData } from "@/router/constants/routes";
|
|||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
import { storeActions } from "@/constants/store-actions";
|
||||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
||||
import { clearBeforeEachErrorRecoveryCount } from "@/router/methods/before-each-error-recovery";
|
||||
import experimentMixin from "../../mixins/experiment-mixin";
|
||||
|
||||
const STATIC_ERROR_PAGE_PATH = "/fmg/static/error";
|
||||
|
||||
/** Clears sessionStorage keys that can re-poison the funnel after a bailout. */
|
||||
export function clearBailoutSessionStorage() {
|
||||
clearBeforeEachErrorRecoveryCount();
|
||||
|
||||
try {
|
||||
window.sessionStorage?.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
|
||||
window.sessionStorage?.removeItem(
|
||||
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
|
||||
);
|
||||
window.sessionStorage?.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
|
||||
} catch {
|
||||
// sessionStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToStaticErrorPage(errorPayload) {
|
||||
await logErrorToDataLayerAndLogger("redirectToStaticErrorPage", errorPayload, null);
|
||||
clearBailoutSessionStorage();
|
||||
|
||||
// Prefer top-level navigation so the static page loads outside any iframe wrapper.
|
||||
// Assigning window.top.location throws SecurityError when cross-origin; fall back to this frame.
|
||||
try {
|
||||
window.top.location = STATIC_ERROR_PAGE_PATH;
|
||||
} catch {
|
||||
window.location = STATIC_ERROR_PAGE_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSoftError(errorPayload, forceRestart = false) {
|
||||
if (forceRestart) {
|
||||
await store.dispatch(storeActions.RESET_STATE);
|
||||
|
|
@ -27,8 +59,7 @@ export async function handleHardError(errorPayload) {
|
|||
);
|
||||
|
||||
if (isInStaticErrorExperiment) {
|
||||
logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
|
||||
window.top.location = "/fmg/static/error";
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
return;
|
||||
} else {
|
||||
await handleSoftError(errorPayload, true);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { handleSoftError, handleHardError } from "./error";
|
||||
import { handleSoftError, handleHardError, redirectToStaticErrorPage } from "./error";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
import analyticsMixin from "@/mixins/analytics-mixin";
|
||||
import experimentMixin from "../../mixins/experiment-mixin";
|
||||
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
|
||||
import { storeActions } from "@/constants/store-actions";
|
||||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||
import { routeData } from "@/router/constants/routes";
|
||||
|
||||
jest.mock("@/router", () => ({
|
||||
|
|
@ -98,6 +99,126 @@ describe("router/methods/error", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("redirectToStaticErrorPage", () => {
|
||||
let topLocationValue;
|
||||
|
||||
beforeEach(() => {
|
||||
topLocationValue = undefined;
|
||||
Object.defineProperty(window, "top", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(window.top, "location", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
topLocationValue = value;
|
||||
},
|
||||
get() {
|
||||
return topLocationValue;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("logs and sets static error location", async () => {
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
|
||||
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
|
||||
errorPayload
|
||||
);
|
||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||
"redirectToStaticErrorPage: test cause",
|
||||
errorPayload
|
||||
);
|
||||
expect(topLocationValue).toBe("/fmg/static/error");
|
||||
});
|
||||
|
||||
it("clears bailout sessionStorage keys before navigating away", async () => {
|
||||
window.sessionStorage.setItem(sessionStorageKeyConstants.SUBMITTED_STATE, "not-json");
|
||||
window.sessionStorage.setItem(
|
||||
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT,
|
||||
"3"
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT,
|
||||
"1"
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
|
||||
"{}"
|
||||
);
|
||||
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
|
||||
expect(
|
||||
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)
|
||||
).toBeNull();
|
||||
expect(
|
||||
window.sessionStorage.getItem(
|
||||
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
window.sessionStorage.getItem(
|
||||
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to window.location when window.top.location setter throws", async () => {
|
||||
let windowLocationValue;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
windowLocationValue = value;
|
||||
},
|
||||
get() {
|
||||
return windowLocationValue;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window.top, "location", {
|
||||
configurable: true,
|
||||
set() {
|
||||
throw new Error("cross-origin frame");
|
||||
},
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
|
||||
expect(windowLocationValue).toBe("/fmg/static/error");
|
||||
expect(topLocationValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("awaits logging before navigating away", async () => {
|
||||
const callOrder = [];
|
||||
analyticsMixin.methods.pushPageErrorToDataLayer.mockImplementation(() => {
|
||||
callOrder.push("datalayer");
|
||||
});
|
||||
global.$logger.logError.mockImplementation(() => {
|
||||
callOrder.push("logger");
|
||||
});
|
||||
Object.defineProperty(window.top, "location", {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
callOrder.push("navigate");
|
||||
topLocationValue = value;
|
||||
},
|
||||
get() {
|
||||
return topLocationValue;
|
||||
},
|
||||
});
|
||||
|
||||
await redirectToStaticErrorPage(errorPayload);
|
||||
|
||||
expect(callOrder).toEqual(["datalayer", "logger", "navigate"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleHardError", () => {
|
||||
let topLocationValue;
|
||||
|
||||
|
|
@ -131,7 +252,7 @@ describe("router/methods/error", () => {
|
|||
errorPayload
|
||||
);
|
||||
expect(global.$logger.logError).toHaveBeenCalledWith(
|
||||
"handleHardError: test cause",
|
||||
"redirectToStaticErrorPage: test cause",
|
||||
errorPayload
|
||||
);
|
||||
expect(topLocationValue).toBe("/fmg/static/error");
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export async function errorBeforeEnter(to, from) {
|
|||
|
||||
// If we've already encountered one error, clear session to avoid more.
|
||||
// Otherwise mark that we encountered an error here.
|
||||
// Note: an earlier breaker also runs in before-each.js via before-each-error-recovery.js
|
||||
// (consecutive uncaught guard failures before this route handler runs).
|
||||
if (store.getters.applicationUser.hasAlreadyTriggeredError) {
|
||||
const errorPayload = {
|
||||
cause: "Successive errors triggered.",
|
||||
|
|
|
|||
|
|
@ -1988,10 +1988,14 @@ export const actions = {
|
|||
// create a new array to avoid mutating state
|
||||
const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray);
|
||||
|
||||
const partsOrQuestionsEndpoint = vehicle.vinRequired
|
||||
? endpoints.GetPartsOrQuestionsV3
|
||||
: endpoints.GetPartsOrQuestions;
|
||||
|
||||
const response = await globalMethods
|
||||
.callHttpClient({
|
||||
method: endpoints.GetPartsOrQuestions.method,
|
||||
endpoint: endpoints.GetPartsOrQuestions.url,
|
||||
method: partsOrQuestionsEndpoint.method,
|
||||
endpoint: partsOrQuestionsEndpoint.url,
|
||||
payload: {
|
||||
carId: carId,
|
||||
glassPieces: glassArrayForPayload,
|
||||
|
|
@ -2034,6 +2038,7 @@ export const actions = {
|
|||
const vehicle = context.getters.vehicle;
|
||||
const damage = context.getters.damage;
|
||||
const order = context.state.order;
|
||||
const payment = context.getters.payment;
|
||||
|
||||
const carId = vehicle.carId;
|
||||
const glassArray = damage.glassToReplace;
|
||||
|
|
@ -2042,6 +2047,7 @@ export const actions = {
|
|||
const vin = vehicle.vin;
|
||||
const serviceType = order.serviceLocation?.appointmentType;
|
||||
const referralSeqNumber = order.referralSequenceNumber;
|
||||
const parentAccountNumber = payment.parentAccountNumber;
|
||||
|
||||
// create a new array to avoid mutating state
|
||||
const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray);
|
||||
|
|
@ -2058,6 +2064,7 @@ export const actions = {
|
|||
vin: vin,
|
||||
serviceType: serviceType,
|
||||
referralSeqNumber: referralSeqNumber,
|
||||
parentAccountNumber: parentAccountNumber,
|
||||
},
|
||||
logApiCall: true,
|
||||
pageNameToLog: pageNameToLog,
|
||||
|
|
@ -3762,7 +3769,7 @@ export const actions = {
|
|||
context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments);
|
||||
|
||||
//clear externalParameter session storage
|
||||
window.sessionStorage.removeItem("externalParameterState");
|
||||
window.sessionStorage.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
|
||||
|
||||
//restore affiliate cookies
|
||||
context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies);
|
||||
|
|
@ -4476,14 +4483,21 @@ function createExternalParameterDefaultState() {
|
|||
saveExternalParameterState(externalParameterDefaultState);
|
||||
}
|
||||
function getExternalParameterDefaultState() {
|
||||
const externalParameterState = window.sessionStorage.getItem("externalParameterState");
|
||||
const externalParameterState = window.sessionStorage.getItem(
|
||||
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE
|
||||
);
|
||||
if (externalParameterState === null) {
|
||||
createExternalParameterDefaultState();
|
||||
}
|
||||
return JSON.parse(window.sessionStorage.getItem("externalParameterState"));
|
||||
return JSON.parse(
|
||||
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
|
||||
);
|
||||
}
|
||||
function saveExternalParameterState(externalParameterState) {
|
||||
window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState));
|
||||
window.sessionStorage.setItem(
|
||||
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
|
||||
JSON.stringify(externalParameterState)
|
||||
);
|
||||
}
|
||||
|
||||
const timeSlotCallFlags = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue