Merge branch 'develop' into feature/CASH-2952

This commit is contained in:
matthew-sykes 2026-06-29 09:46:05 -04:00 committed by GitHub
commit 71766a922e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 443 additions and 41 deletions

View file

@ -208,6 +208,10 @@ const endpoints = {
url: "/account/api/v1/account/bill-to-account-number", url: "/account/api/v1/account/bill-to-account-number",
method: "GET", method: "GET",
}, },
GetAccountDetails: {
url: "/account/api/v1/account/account-details",
method: "GET",
},
ClosestApplicableShops: { ClosestApplicableShops: {
url: "/location/api/v1/location/closest-applicable-shops", url: "/location/api/v1/location/closest-applicable-shops",
method: "GET", method: "GET",

View file

@ -55,6 +55,7 @@ const storeActions = {
REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA: "revalidateOrderPromosAndSaveServerData", REVALIDATE_ORDER_PROMOS_AND_SAVE_SERVER_DATA: "revalidateOrderPromosAndSaveServerData",
GET_INSURANCE_COMPANY_LIST: "getInsuranceCompanyList", GET_INSURANCE_COMPANY_LIST: "getInsuranceCompanyList",
GET_BILL_TO_ACCOUNT_NUMBER: "getBillToAccountNumber", GET_BILL_TO_ACCOUNT_NUMBER: "getBillToAccountNumber",
GET_ACCOUNT_DETAILS: "getAccountDetails",
GET_RECAL_PARTS_AND_SAVE_TO_LINE_ITEMS: "getRecalPartsAndSaveToLineItems", GET_RECAL_PARTS_AND_SAVE_TO_LINE_ITEMS: "getRecalPartsAndSaveToLineItems",
GET_CLOSEST_APPLICABLE_SHOPS: "getClosestApplicableShops", GET_CLOSEST_APPLICABLE_SHOPS: "getClosestApplicableShops",
@ -100,8 +101,7 @@ const storeActions = {
SAVE_PAYMENT_METHOD_CHOICE: "savePaymentMethodChoice", SAVE_PAYMENT_METHOD_CHOICE: "savePaymentMethodChoice",
SAVE_PARENT_ACCOUNT_NUMBER: "saveParentAccountNumber", SAVE_PARENT_ACCOUNT_NUMBER: "saveParentAccountNumber",
SAVE_ACCOUNT_NAME: "saveAccountName", SAVE_ACCOUNT_NAME: "saveAccountName",
SAVE_IS_CLAIM_AND_COVERAGE: "saveIsClaimAndCoverage", SAVE_INSURANCE_ACCOUNT_DETAILS: "saveInsuranceAccountDetails",
SAVE_CAN_SCHEDULE_ONLINE: "saveCanScheduleOnline",
SAVE_BILL_TO_ACCOUNT_NUMBER: "saveBillToAccountNumber", SAVE_BILL_TO_ACCOUNT_NUMBER: "saveBillToAccountNumber",
SAVE_SUPPORTING_ITEMS: "saveSupportingItems", SAVE_SUPPORTING_ITEMS: "saveSupportingItems",
SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING: SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING:
@ -120,6 +120,7 @@ const storeActions = {
SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected", SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected",
SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable", SAVE_IS_MSR_FEE_APPLICABLE: "saveIsMSRFeeApplicable",
SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance", SAVE_IS_MSR_FEE_COVERED_BY_INSURANCE: "saveIsMSRFeeCoveredByInsurance",
SAVE_IS_NEW_INSURANCE_FLOW: "saveIsNewInsuranceFlow",
CREATE_SUBMITTED_STATE: "createSubmittedState", CREATE_SUBMITTED_STATE: "createSubmittedState",
RESET_SUBMITTED_STATE: "resetSubmittedState", RESET_SUBMITTED_STATE: "resetSubmittedState",

View file

@ -65,6 +65,7 @@ const storeMutations = {
UPDATE_ACCOUNT_NAME: "updateAccountName", UPDATE_ACCOUNT_NAME: "updateAccountName",
UPDATE_IS_CLAIM_AND_COVERAGE: "updateIsClaimAndCoverage", UPDATE_IS_CLAIM_AND_COVERAGE: "updateIsClaimAndCoverage",
UPDATE_CAN_SCHEDULE_ONLINE: "updateCanScheduleOnline", UPDATE_CAN_SCHEDULE_ONLINE: "updateCanScheduleOnline",
UPDATE_IS_MANAGED_ACCOUNT: "updateIsManagedAccount",
UPDATE_BILL_TO_ACCT_NUMBER: "updateBillToAcctNumber", UPDATE_BILL_TO_ACCT_NUMBER: "updateBillToAcctNumber",
UPDATE_EON: "updateEON", UPDATE_EON: "updateEON",
UPDATE_IS_INSURANCE: "updateIsInsurance", UPDATE_IS_INSURANCE: "updateIsInsurance",
@ -83,6 +84,7 @@ const storeMutations = {
UPDATE_IS_OEM_GLASS_SELECTED: "updateIsOemGlassSelected", UPDATE_IS_OEM_GLASS_SELECTED: "updateIsOemGlassSelected",
UPDATE_IS_MSR_FEE_APPLICABLE: "updateIsMSRFeeApplicable", UPDATE_IS_MSR_FEE_APPLICABLE: "updateIsMSRFeeApplicable",
UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE: "updateIsMSRFeeCoveredByInsurance", UPDATE_IS_MSR_FEE_COVERED_BY_INSURANCE: "updateIsMSRFeeCoveredByInsurance",
UPDATE_IS_NEW_INSURANCE_FLOW: "updateIsNewInsuranceFlow",
UPDATE_CASH_PRICE_SUBTOTAL: "updateCashPriceSubTotal", UPDATE_CASH_PRICE_SUBTOTAL: "updateCashPriceSubTotal",
// EVENT BUS MUTATIONS // EVENT BUS MUTATIONS

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

@ -87,6 +87,7 @@ import modal from "@/digital-components/modal/modal";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal.vue"; import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal.vue";
import textBlock from "@/digital-components/text-block/text-block.vue"; import textBlock from "@/digital-components/text-block/text-block.vue";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
import { loadSessionIfPresent } from "@/helpers/heritage-integration/order-helper";
export default { export default {
name: "coverage-statement", name: "coverage-statement",
mixins: [], mixins: [],
@ -186,6 +187,13 @@ export default {
); );
}, },
async forwardButtonAction() { async forwardButtonAction() {
// The safelite api will often remove fees when switching cash to insurance. Heritage does the same.
// The heritage flow triggers a load session when returning to pick up new parts to make sure we're in-sync.
// The new insurance flow needs to trigger a load session to pick up these same type of part changes.
if (store.getters.order.isNewInsuranceFlow) {
await loadSessionIfPresent(false, this.pageName);
}
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD, this.navigationScenarios.CLICKED_FORWARD,
this.pageName this.pageName

View file

@ -122,6 +122,7 @@ export default {
accountName: this.accountNameFromStore(), accountName: this.accountNameFromStore(),
isClaimAndCoverage: this.isClaimAndCoverageFromStore(), isClaimAndCoverage: this.isClaimAndCoverageFromStore(),
canScheduleOnline: this.canScheduleOnlineFromStore(), canScheduleOnline: this.canScheduleOnlineFromStore(),
isManagedAccount: this.isManagedAccountFromStore(),
}, },
originalList: [], originalList: [],
}; };
@ -156,6 +157,9 @@ export default {
canScheduleOnlineFromStore() { canScheduleOnlineFromStore() {
return store.getters.order?.payment?.canScheduleOnline; return store.getters.order?.payment?.canScheduleOnline;
}, },
isManagedAccountFromStore() {
return store.getters.order?.payment?.isManagedAccount;
},
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
return store.getters.order.payment.isInsurance === true; return store.getters.order.payment.isInsurance === true;
}, },
@ -192,15 +196,24 @@ export default {
false false
); );
await this.dispatchStoreAction( // Fetch the latest account details for the selected parent account so that
this.storeActions.SAVE_IS_CLAIM_AND_COVERAGE, // routing and the saved values below reflect the current source of truth.
this.selectedAccount.isClaimAndCoverage, const accountDetails = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_ACCOUNT_DETAILS,
{ parentAccountNumber: this.selectedAccount.parentAccountNumber },
"insurance-company",
false false
); );
this.selectedAccount.isManagedAccount = accountDetails.isManagedAccount;
await this.dispatchStoreAction( await this.dispatchStoreAction(
this.storeActions.SAVE_CAN_SCHEDULE_ONLINE, this.storeActions.SAVE_INSURANCE_ACCOUNT_DETAILS,
this.selectedAccount.canScheduleOnline, {
isClaimAndCoverage: this.selectedAccount.isClaimAndCoverage,
canScheduleOnline: this.selectedAccount.canScheduleOnline,
isManagedAccount: this.selectedAccount.isManagedAccount,
},
false false
); );
@ -236,6 +249,11 @@ export default {
debugLog( debugLog(
`--- ${this.selectedAccount.parentAccountNumber} C&C experiment active for this parent account number, sending to vue app Policy Info page ---` `--- ${this.selectedAccount.parentAccountNumber} C&C experiment active for this parent account number, sending to vue app Policy Info page ---`
); );
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
true,
false
);
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_POLICY_INFO, this.navigationScenarios.CLICKED_FORWARD_POLICY_INFO,
this.pageName this.pageName
@ -248,6 +266,11 @@ export default {
debugLog( debugLog(
`--- ${this.selectedAccount.parentAccountNumber} C&C experiment setting is false, but is a C&C client, sending to heritage ---` `--- ${this.selectedAccount.parentAccountNumber} C&C experiment setting is false, but is a C&C client, sending to heritage ---`
); );
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
false,
false
);
navigateToHeritageFunnel({ navigateToHeritageFunnel({
shouldSaveSession: true, shouldSaveSession: true,
pageNameToLog: this.pageName, pageNameToLog: this.pageName,
@ -265,6 +288,11 @@ export default {
debugLog( debugLog(
`--- ${this.selectedAccount.parentAccountNumber} Non integrated experiment setting is true, sending to vue app Insurance Details page ---` `--- ${this.selectedAccount.parentAccountNumber} Non integrated experiment setting is true, sending to vue app Insurance Details page ---`
); );
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
true,
false
);
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_NON_CLAIM_AND_COVERAGE, this.navigationScenarios.CLICKED_FORWARD_NON_CLAIM_AND_COVERAGE,
this.pageName this.pageName
@ -290,6 +318,11 @@ export default {
debugLog( debugLog(
`--- ${this.selectedAccount.parentAccountNumber} Non integrated experiment setting is false, but a parent account is NON_CLAIM_AND_COVERAGE_TEST_PARENT_ACCOUNT experiment contains a specific parent account number, sending to vue app Insurance Details page ---` `--- ${this.selectedAccount.parentAccountNumber} Non integrated experiment setting is false, but a parent account is NON_CLAIM_AND_COVERAGE_TEST_PARENT_ACCOUNT experiment contains a specific parent account number, sending to vue app Insurance Details page ---`
); );
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
true,
false
);
this.$router.navigateWithSaving( this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_NON_CLAIM_AND_COVERAGE, this.navigationScenarios.CLICKED_FORWARD_NON_CLAIM_AND_COVERAGE,
this.pageName this.pageName
@ -297,6 +330,11 @@ export default {
return; return;
} }
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
false,
false
);
navigateToHeritageFunnel({ navigateToHeritageFunnel({
shouldSaveSession: true, shouldSaveSession: true,
pageNameToLog: "insurance-company", pageNameToLog: "insurance-company",

View file

@ -57,7 +57,7 @@
:questionText="damageTypesQuestionText" :questionText="damageTypesQuestionText"
class="mb-4" class="mb-4"
v-model="damageCause" v-model="damageCause"
cmsWidgetName="DamageTypesWidget" :cmsWidgetName="damageTypesWidgetName"
customDropdownId="damageTypes" customDropdownId="damageTypes"
:options="damageTypesOptions" :options="damageTypesOptions"
:valueAsKey="true" :valueAsKey="true"
@ -183,6 +183,7 @@ export default {
policyZip: this.getPolicyZipFromStore(), policyZip: this.getPolicyZipFromStore(),
claimNumber: this.getClaimNumberFromStore(), claimNumber: this.getClaimNumberFromStore(),
showSaveProgressModal: null, showSaveProgressModal: null,
isManagedAccount: this.getIsManagedAccountFromStore(),
}; };
}, },
components: { components: {
@ -249,13 +250,22 @@ export default {
stateOptions() { stateOptions() {
return stateOptionsInternational; return stateOptionsInternational;
}, },
damageTypesWidgetName() {
return this.isManagedAccount ? "DamageTypesWidget" : "UnmanagedDamageTypesWidget";
},
damageCauseQuestionText() { damageCauseQuestionText() {
return this.getCmsContent("DamageTypesWidget", "QuestionText"); if (this.isManagedAccount) {
return this.getCmsContent("DamageTypesWidget", "QuestionText");
} else {
return this.getCmsContent("UnmanagedDamageTypesWidget", "QuestionText");
}
}, },
damageTypesOptions() { damageTypesOptions() {
const options = {}; const options = {};
const answers = this.getCmsContent("DamageTypesWidget", "Answers"); const answers = this.isManagedAccount
? this.getCmsContent("DamageTypesWidget", "Answers")
: this.getCmsContent("UnmanagedDamageTypesWidget", "Answers");
if (!Array.isArray(answers)) { if (!Array.isArray(answers)) {
return options; return options;
} }
@ -267,6 +277,9 @@ export default {
}, },
}, },
methods: { methods: {
getIsManagedAccountFromStore() {
return store.getters.order?.payment?.isManagedAccount ?? false;
},
getInsuranceCompanyNameFromStore() { getInsuranceCompanyNameFromStore() {
return store.getters.order.policy.insuranceCompanyName; return store.getters.order.policy.insuranceCompanyName;
}, },

View file

@ -777,6 +777,12 @@ export default {
} }
}, },
async forwardButtonAction() { async forwardButtonAction() {
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_IS_NEW_INSURANCE_FLOW,
false,
false
);
this.dispatchStoreAction( this.dispatchStoreAction(
this.storeActions.SAVE_PAYMENT_TYPE, this.storeActions.SAVE_PAYMENT_TYPE,
this.isInsuranceSelected, this.isInsuranceSelected,
@ -795,12 +801,15 @@ export default {
false false
); );
this.dispatchStoreAction( this.dispatchStoreAction(
this.storeActions.SAVE_IS_CLAIM_AND_COVERAGE, this.storeActions.SAVE_INSURANCE_ACCOUNT_DETAILS,
false, {
isClaimAndCoverage: false,
canScheduleOnline: false,
isManagedAccount: false,
},
false false
); );
this.dispatchStoreAction(this.storeActions.RESET_INSURANCE_DETAILS, false, false); this.dispatchStoreAction(this.storeActions.RESET_INSURANCE_DETAILS, false, false);
this.dispatchStoreAction(this.storeActions.SAVE_CAN_SCHEDULE_ONLINE, false, false);
this.dispatchStoreAction(this.storeActions.SAVE_ACCOUNT_NAME, null, false); this.dispatchStoreAction(this.storeActions.SAVE_ACCOUNT_NAME, null, false);
} else { } else {
baseMixin.methods.dispatchStoreAction( baseMixin.methods.dispatchStoreAction(

View file

@ -243,4 +243,27 @@ describe("inshop-scheduling-card.vue", () => {
expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false); expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false);
wrapper.unmount(); wrapper.unmount();
}); });
describe("isLoading prop", () => {
test("shows the skeleton loader and hides real content when isLoading is true", () => {
const wrapper = mountComponent({ isLoading: true });
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(true);
expect(wrapper.find(".inshop-scheduling-card__header").exists()).toBe(false);
wrapper.unmount();
});
test("hides the skeleton loader and shows real content when isLoading is false", () => {
const wrapper = mountComponent({ isLoading: false });
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
expect(wrapper.find(".inshop-scheduling-card__header").exists()).toBe(true);
wrapper.unmount();
});
test("defaults isLoading to false", () => {
const wrapper = mountComponent();
expect(wrapper.props("isLoading")).toBe(false);
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
wrapper.unmount();
});
});
}); });

View file

@ -1,6 +1,9 @@
<template> <template>
<div class="inshop-scheduling-card"> <div class="inshop-scheduling-card">
<div class="inshop-scheduling-card__panel"> <div v-if="isLoading" class="inshop-scheduling-card__panel">
<schedulingCardLoader />
</div>
<div v-else class="inshop-scheduling-card__panel">
<button <button
type="button" type="button"
class="inshop-scheduling-card__header" class="inshop-scheduling-card__header"
@ -113,6 +116,7 @@ import {
INSHOP_INITIAL_VISIBLE_TIME_SLOTS, INSHOP_INITIAL_VISIBLE_TIME_SLOTS,
mapInshopTimeSlotToDisplaySlot, mapInshopTimeSlotToDisplaySlot,
} from "@/layouts/schedule/helpers/schedule-helper"; } from "@/layouts/schedule/helpers/schedule-helper";
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader";
function toTitleCase(str) { function toTitleCase(str) {
if (!str) return ""; if (!str) return "";
@ -155,6 +159,10 @@ export default {
type: String, type: String,
default: "DropoffQuestionWidget", default: "DropoffQuestionWidget",
}, },
isLoading: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -317,6 +325,9 @@ export default {
}); });
}, },
}, },
components: {
schedulingCardLoader,
},
}; };
</script> </script>

View file

@ -167,4 +167,33 @@ describe("mobile-scheduling-card.vue", () => {
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true); expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
wrapper.unmount(); wrapper.unmount();
}); });
describe("isLoading prop", () => {
test("shows the skeleton loader and hides real content when isLoading is true", () => {
const wrapper = mountComponent({ isLoading: true });
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(true);
expect(wrapper.find(".mobile-scheduling-card__header").exists()).toBe(false);
wrapper.unmount();
});
test("hides the skeleton loader and shows real content when isLoading is false", () => {
const wrapper = mountComponent({ isLoading: false });
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
expect(wrapper.find(".mobile-scheduling-card__header").exists()).toBe(true);
wrapper.unmount();
});
test("hides the free flag when isLoading is true even if showFreeFlag is true", () => {
const wrapper = mountComponent({ isLoading: true, showFreeFlag: true });
expect(wrapper.find(".mobile-scheduling-card__free-flag").exists()).toBe(false);
wrapper.unmount();
});
test("defaults isLoading to false", () => {
const wrapper = mountComponent();
expect(wrapper.props("isLoading")).toBe(false);
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
wrapper.unmount();
});
});
}); });

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="mobile-scheduling-card"> <div class="mobile-scheduling-card">
<div v-if="showFreeFlag" class="mobile-scheduling-card__free-flag"> <div v-if="showFreeFlag && !isLoading" class="mobile-scheduling-card__free-flag">
<img <img
class="mobile-scheduling-card__free-flag-icon" class="mobile-scheduling-card__free-flag-icon"
src="@/assets/img/party.svg" src="@/assets/img/party.svg"
@ -8,7 +8,11 @@
aria-hidden="true" /> aria-hidden="true" />
<span class="mobile-scheduling-card__free-flag-text">{{ freeFlagText }}</span> <span class="mobile-scheduling-card__free-flag-text">{{ freeFlagText }}</span>
</div> </div>
<div v-if="isLoading" class="mobile-scheduling-card__panel">
<schedulingCardLoader />
</div>
<div <div
v-else
class="mobile-scheduling-card__panel" class="mobile-scheduling-card__panel"
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }"> :class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
<button <button
@ -86,7 +90,7 @@
<script> <script>
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";
export default { export default {
name: "mobile-scheduling-card", name: "mobile-scheduling-card",
emits: ["update:modelValue", "zip-code-clicked"], emits: ["update:modelValue", "zip-code-clicked"],
@ -123,6 +127,10 @@ export default {
type: String, type: String,
default: "MobileCardWidget", default: "MobileCardWidget",
}, },
isLoading: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -228,6 +236,9 @@ export default {
return routeCode + PREMIUM_TIME_SLOT_ID_FLAG; return routeCode + PREMIUM_TIME_SLOT_ID_FLAG;
}, },
}, },
components: {
schedulingCardLoader,
},
}; };
</script> </script>

View file

@ -0,0 +1,25 @@
import { shallowMount } from "@vue/test-utils";
import schedulingCardLoader from "./scheduling-card-loader";
describe("scheduling-card-loader.vue", () => {
test("renders the loader container with aria-hidden", () => {
const wrapper = shallowMount(schedulingCardLoader);
expect(wrapper.find(".scheduling-card-loader").exists()).toBe(true);
expect(wrapper.find(".scheduling-card-loader").attributes("aria-hidden")).toBe("true");
wrapper.unmount();
});
test("renders exactly two skeleton rows", () => {
const wrapper = shallowMount(schedulingCardLoader);
expect(wrapper.findAll(".scheduling-card-loader__row").length).toBe(2);
wrapper.unmount();
});
test("each row contains a wide shimmer bar", () => {
const wrapper = shallowMount(schedulingCardLoader);
wrapper.findAll(".scheduling-card-loader__row").forEach((row) => {
expect(row.find(".scheduling-card-loader__bar--wide").exists()).toBe(true);
});
wrapper.unmount();
});
});

View file

@ -0,0 +1,51 @@
<template>
<div class="scheduling-card-loader" aria-hidden="true">
<div class="scheduling-card-loader__row">
<span class="scheduling-card-loader__bar scheduling-card-loader__bar--wide"></span>
</div>
<div class="scheduling-card-loader__row">
<span class="scheduling-card-loader__bar scheduling-card-loader__bar--wide"></span>
</div>
</div>
</template>
<script>
export default {
name: "scheduling-card-loader",
};
</script>
<style lang="scss" scoped>
.scheduling-card-loader {
display: flex;
flex-direction: column;
gap: 12px;
&__row {
display: flex;
align-items: center;
gap: 16px;
}
&__bar {
height: 20px;
border-radius: 4px;
background: linear-gradient(90deg, $gray-200 25%, $gray-100 50%, $gray-200 75%);
background-size: 200% 100%;
animation: scheduling-card-shimmer 1.5s ease-in-out infinite;
&--wide {
flex: 1;
}
}
}
@keyframes scheduling-card-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View file

@ -0,0 +1,110 @@
import { shallowMount } from "@vue/test-utils";
import scheduling from "./scheduling";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { settleAllPromises } from "@/helpers/layout-helper";
jest.mock("@/store", () => ({
dispatch: jest.fn().mockResolvedValue(null),
getters: {
order: {
serviceLocation: { zipCode: "43235", appointmentType: null },
payment: { isInsurance: false, insuranceCoverage: { isVerified: false } },
referralNumber: "",
damage: { isRepair: false },
lineItems: { glassParts: [] },
policy: { isItac: false, isNoComp: false },
},
},
}));
jest.mock("@/helpers/layout-helper", () => ({
settleAllPromises: jest.fn().mockResolvedValue({}),
}));
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn().mockResolvedValue({}),
}));
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateToHeritageFunnel: jest.fn(),
}));
jest.mock("@/helpers/debug-log-helper", () => ({
debugLog: jest.fn(),
}));
jest.mock("@/helpers/page-prerequisites-helper.js", () => ({
flushPagePrereqsLogs: jest.fn(),
hasServiceZipInfo: jest.fn(() => true),
hasGlassPartsOrRepairInfo: jest.fn(() => true),
hasInsuranceInfo: jest.fn(() => true),
}));
function setupMocks() {
const baseMixin = {
methods: {
getCmsContent: jest.fn(() => ""),
setCmsContent: jest.fn(),
getTotalLineItemPrice: jest.fn(() => 0),
},
};
const mountOptions = getMountOptions({
route: { name: "scheduling" },
router: {
navigate: jest.fn(),
navigateWithSaving: jest.fn(),
navigateWithoutSaving: jest.fn(),
},
});
mountOptions.global.mixins = [baseMixin];
const wrapper = shallowMount(scheduling, mountOptions);
return { wrapper };
}
describe("scheduling.vue", () => {
describe("intercept overlay", () => {
test("does not render interceptOverlay when isLoadingDates is false", () => {
const { wrapper } = setupMocks();
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false);
wrapper.unmount();
});
test("renders interceptOverlay when isLoadingDates is true", async () => {
const { wrapper } = setupMocks();
await wrapper.setData({ isLoadingDates: true });
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(true);
wrapper.unmount();
});
});
describe("handleRequestMoreDates", () => {
test("sets isLoadingDates to true while fetching and false after resolving", async () => {
let resolve;
settleAllPromises.mockReturnValueOnce(
new Promise((r) => {
resolve = r;
})
);
const { wrapper } = setupMocks();
const fetchPromise = wrapper.vm.handleRequestMoreDates();
expect(wrapper.vm.isLoadingDates).toBe(true);
resolve({});
await fetchPromise;
expect(wrapper.vm.isLoadingDates).toBe(false);
wrapper.unmount();
});
test("completes without invoking loadingModal methods (stub has none)", async () => {
// The loadingModal stub rendered by shallowMount has no showModal/hideModal.
// If those calls were still in handleRequestMoreDates they would throw here.
settleAllPromises.mockResolvedValueOnce({});
const { wrapper } = setupMocks();
await expect(wrapper.vm.handleRequestMoreDates()).resolves.toBeUndefined();
wrapper.unmount();
});
});
});

View file

@ -1,5 +1,6 @@
<template> <template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm"> <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<interceptOverlay v-if="isLoadingDates" />
<loadingModal notFullScreen ref="loadingModal" /> <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">
@ -29,6 +30,7 @@
:zipCode="serviceZipCode" :zipCode="serviceZipCode"
:showFreeFlag="showMobileFreeFlag" :showFreeFlag="showMobileFreeFlag"
:radioGroupName="schedulingRadioGroupName" :radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates"
@zip-code-clicked="onMobileZipCodeClicked" /> @zip-code-clicked="onMobileZipCodeClicked" />
<inshopSchedulingCard <inshopSchedulingCard
v-for="{ provider } in inShopProvidersAndTimeslots" v-for="{ provider } in inShopProvidersAndTimeslots"
@ -39,6 +41,7 @@
:provider="provider" :provider="provider"
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)" :timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)"
:radioGroupName="schedulingRadioGroupName" :radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates"
@address-clicked="onInshopAddressClicked(provider)" /> @address-clicked="onInshopAddressClicked(provider)" />
<navbar <navbar
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
@ -60,6 +63,7 @@ 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 loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
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";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper"; import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -88,6 +92,20 @@ function toDateString(offsetDays, base = new Date()) {
const SCHEDULE_FETCH_DAYS = 15; const SCHEDULE_FETCH_DAYS = 15;
const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot"; const SCHEDULING_RADIO_GROUP_NAME = "schedulingTimeSlot";
/**
* Appends days from newSlots into entry.timeSlots, initializing it if absent.
* @param {{ timeSlots: { days: any[] } | null }} entry
* @param {{ days: any[] } | null | undefined} newSlots
*/
function appendDays(entry, newSlots) {
if (!newSlots) return;
if (!entry.timeSlots) {
entry.timeSlots = newSlots;
} else {
entry.timeSlots.days = [...(entry.timeSlots.days ?? []), ...(newSlots.days ?? [])];
}
}
/** /**
* Returns a promise for inshop time slots for a single provider. * Returns a promise for inshop time slots for a single provider.
* @param {{ startDate: string, endDate: string, providerNumber: string, pageNameToLog: string }} params * @param {{ startDate: string, endDate: string, providerNumber: string, pageNameToLog: string }} params
@ -294,7 +312,6 @@ export default {
}, },
async handleRequestMoreDates() { async handleRequestMoreDates() {
this.isLoadingDates = true; this.isLoadingDates = true;
this.$refs.loadingModal.showModal();
const newStartDate = toDateString(1, this.datePickerEndDate); const newStartDate = toDateString(1, this.datePickerEndDate);
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate); const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
@ -326,34 +343,17 @@ export default {
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
// Provider order is fixed after beforeRouteEnter, so index keys are stable.
this.inShopProvidersAndTimeslots.forEach((entry, index) => { this.inShopProvidersAndTimeslots.forEach((entry, index) => {
const newSlots = resultMap[`inshopTimeSlots_${index}`]; appendDays(entry, resultMap[`inshopTimeSlots_${index}`]);
if (!newSlots) return;
if (!entry.timeSlots) {
entry.timeSlots = newSlots;
} else {
entry.timeSlots.days = [
...(entry.timeSlots.days ?? []),
...(newSlots.days ?? []),
];
}
}); });
if (this.mobileProviderAndTimeSlot && resultMap.mobileTimeSlots) { if (this.mobileProviderAndTimeSlot) {
const newSlots = resultMap.mobileTimeSlots; appendDays(this.mobileProviderAndTimeSlot, resultMap.mobileTimeSlots);
if (!this.mobileProviderAndTimeSlot.timeSlots) {
this.mobileProviderAndTimeSlot.timeSlots = newSlots;
} else {
this.mobileProviderAndTimeSlot.timeSlots.days = [
...(this.mobileProviderAndTimeSlot.timeSlots.days ?? []),
...(newSlots.days ?? []),
];
}
} }
this.datePickerEndDate = newEndDate; this.datePickerEndDate = newEndDate;
this.isLoadingDates = false; this.isLoadingDates = false;
this.$refs.loadingModal.hideModal();
}, },
forwardButtonAction() { forwardButtonAction() {
const appointmentType = store.getters.order.serviceLocation.appointmentType; // TODO: remove this once we have a proper appointment type const appointmentType = store.getters.order.serviceLocation.appointmentType; // TODO: remove this once we have a proper appointment type
@ -379,6 +379,7 @@ export default {
mobileSchedulingCard, mobileSchedulingCard,
inshopSchedulingCard, inshopSchedulingCard,
loadingModal, loadingModal,
interceptOverlay,
}, },
}; };
</script> </script>

View file

@ -154,6 +154,7 @@ const getDefaultState = () => {
accountName: null, accountName: null,
isClaimAndCoverage: false, isClaimAndCoverage: false,
canScheduleOnline: false, canScheduleOnline: false,
isManagedAccount: false,
billToAccountNumber: null, billToAccountNumber: null,
isPia: null, isPia: null,
piaType: null, piaType: null,
@ -213,6 +214,7 @@ const getDefaultState = () => {
isRecalAcknowledgedForScheduling: "", isRecalAcknowledgedForScheduling: "",
isMSRFeeApplicable: false, isMSRFeeApplicable: false,
isMSRFeeCoveredByInsurance: false, isMSRFeeCoveredByInsurance: false,
isNewInsuranceFlow: false,
}, },
applicationUser: { applicationUser: {
eventBus: [], eventBus: [],
@ -386,6 +388,9 @@ export const mutations = {
updateCanScheduleOnline(state, canScheduleOnline) { updateCanScheduleOnline(state, canScheduleOnline) {
state.order.payment.canScheduleOnline = canScheduleOnline; state.order.payment.canScheduleOnline = canScheduleOnline;
}, },
updateIsManagedAccount(state, isManagedAccount) {
state.order.payment.isManagedAccount = isManagedAccount;
},
updateBillToAcctNumber(state, billToAcctNumber) { updateBillToAcctNumber(state, billToAcctNumber) {
state.order.payment.billToAccountNumber = billToAcctNumber; state.order.payment.billToAccountNumber = billToAcctNumber;
}, },
@ -451,6 +456,9 @@ export const mutations = {
updateIsMSRFeeCoveredByInsurance(state, isMSRFeeCoveredByInsurance) { updateIsMSRFeeCoveredByInsurance(state, isMSRFeeCoveredByInsurance) {
state.order.isMSRFeeCoveredByInsurance = isMSRFeeCoveredByInsurance; state.order.isMSRFeeCoveredByInsurance = isMSRFeeCoveredByInsurance;
}, },
updateIsNewInsuranceFlow(state, isNewInsuranceFlow) {
state.order.isNewInsuranceFlow = isNewInsuranceFlow;
},
updateCashPriceSubTotal(state, cashPriceSubTotal) { updateCashPriceSubTotal(state, cashPriceSubTotal) {
state.order.cashPriceSubTotal = state.order.cashPriceSubTotal =
cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString(); cashPriceSubTotal === "" ? null : cashPriceSubTotal.toString();
@ -2709,6 +2717,7 @@ export const actions = {
billToAccountNumber: order.payment.billToAccountNumber, billToAccountNumber: order.payment.billToAccountNumber,
isClaimAndCoverage: order.payment.isClaimAndCoverage, isClaimAndCoverage: order.payment.isClaimAndCoverage,
canScheduleOnline: order.payment.canScheduleOnline, canScheduleOnline: order.payment.canScheduleOnline,
isManagedAccount: order.payment.isManagedAccount,
inactivePromos: order.payment.inactivePromos, inactivePromos: order.payment.inactivePromos,
isCreditCard: isCreditCard:
order.payment.piaType == paymentMethods.CREDIT_CARD ? true : false, order.payment.piaType == paymentMethods.CREDIT_CARD ? true : false,
@ -3225,17 +3234,23 @@ export const actions = {
); );
}, },
saveIsNewInsuranceFlow(context, isNewInsuranceFlow) {
context.commit(storeMutations.UPDATE_IS_NEW_INSURANCE_FLOW, isNewInsuranceFlow);
},
saveParentAccountNumber(context, parentAccountNumber) { saveParentAccountNumber(context, parentAccountNumber) {
context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber); context.commit(storeMutations.UPDATE_PARENT_ACCT_NUMBER, parentAccountNumber);
}, },
saveAccountName(context, accountName) { saveAccountName(context, accountName) {
context.commit(storeMutations.UPDATE_ACCOUNT_NAME, accountName); context.commit(storeMutations.UPDATE_ACCOUNT_NAME, accountName);
}, },
saveIsClaimAndCoverage(context, isClaimAndCoverage) { saveInsuranceAccountDetails(
context,
{ isClaimAndCoverage, canScheduleOnline, isManagedAccount }
) {
context.commit(storeMutations.UPDATE_IS_CLAIM_AND_COVERAGE, isClaimAndCoverage); context.commit(storeMutations.UPDATE_IS_CLAIM_AND_COVERAGE, isClaimAndCoverage);
},
saveCanScheduleOnline(context, canScheduleOnline) {
context.commit(storeMutations.UPDATE_CAN_SCHEDULE_ONLINE, canScheduleOnline); context.commit(storeMutations.UPDATE_CAN_SCHEDULE_ONLINE, canScheduleOnline);
context.commit(storeMutations.UPDATE_IS_MANAGED_ACCOUNT, isManagedAccount);
}, },
saveBillToAccountNumber(context, billToAccountNumber) { saveBillToAccountNumber(context, billToAccountNumber) {
context.commit(storeMutations.UPDATE_BILL_TO_ACCT_NUMBER, billToAccountNumber); context.commit(storeMutations.UPDATE_BILL_TO_ACCT_NUMBER, billToAccountNumber);
@ -3626,6 +3641,15 @@ export const actions = {
return billToAccountNumberResponse.data.toString(); return billToAccountNumberResponse.data.toString();
}, },
// Get Account Details
async getAccountDetails(context, { payload: { parentAccountNumber }, pageNameToLog }) {
const accountDetailsResponse = await globalMethods.callHttpClient({
method: endpoints.GetAccountDetails.method,
endpoint: `${endpoints.GetAccountDetails.url}/${parentAccountNumber}`,
});
return accountDetailsResponse.data;
},
////////////////////////////////// //////////////////////////////////
// END OF Account Service Calls // // END OF Account Service Calls //
////////////////////////////////// //////////////////////////////////

View file

@ -0,0 +1,17 @@
import { shallowMount } from "@vue/test-utils";
import interceptOverlay from "./intercept-overlay";
describe("intercept-overlay.vue", () => {
test("renders the overlay container with aria-hidden", () => {
const wrapper = shallowMount(interceptOverlay);
expect(wrapper.find(".intercept-overlay").exists()).toBe(true);
expect(wrapper.find(".intercept-overlay").attributes("aria-hidden")).toBe("true");
wrapper.unmount();
});
test("renders as a single root element with no visible content", () => {
const wrapper = shallowMount(interceptOverlay);
expect(wrapper.text()).toBe("");
wrapper.unmount();
});
});

View file

@ -0,0 +1,21 @@
<template>
<div class="intercept-overlay" aria-hidden="true"></div>
</template>
<script>
export default {
name: "intercept-overlay",
};
</script>
<style lang="scss" scoped>
.intercept-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
cursor: default;
}
</style>