From 04c5ce1cfc9185d3e4ec7dc085cd421c0522c118 Mon Sep 17 00:00:00 2001 From: credelinghuys Date: Wed, 1 Jul 2026 14:51:01 -0400 Subject: [PATCH] CASH-2934: Add phone option and consent checks to quote page modal --- src/constants/error-messages.js | 1 + .../checkbox-question.spec.js | 11 + .../checkbox-question/checkbox-question.vue | 5 +- .../phone-number-question.spec.js | 20 ++ .../phone-number-question.vue | 8 +- .../save-progress-question.vue | 2 + .../save-progress-popup-question.spec.js | 284 +++++++++++++++++- .../save-progress-popup-question.vue | 269 ++++++++++++++++- ...rogress-popup-sms-consent-question.spec.js | 116 +++++++ ...ve-progress-popup-sms-consent-question.vue | 104 +++++++ .../save-progress-popup-contact-helper.js | 38 +++ ...save-progress-popup-contact-helper.spec.js | 103 +++++++ .../save-progress-sms-consent-helper.js | 27 ++ .../save-progress-sms-consent-helper.spec.js | 32 ++ src/layouts/quote/quote.vue | 7 +- src/store/index.js | 3 +- 16 files changed, 1009 insertions(+), 21 deletions(-) create mode 100644 src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.spec.js create mode 100644 src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.vue create mode 100644 src/helpers/save-progress-popup-contact-helper.js create mode 100644 src/helpers/save-progress-popup-contact-helper.spec.js create mode 100644 src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.js create mode 100644 src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.spec.js diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index b5cbde3fa..1b0dfb724 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -32,6 +32,7 @@ const errorMessages = { DATE_REQUIRED: "Please select a date", PHONE_REQUIRED: "Please enter your phone number", PHONE_FORMAT: "Phone number must be 10 digits", + SMS_CONSENT_REQUIRED: "Please select at least one consent option", YEAR_REQUIRED: "Please select your vehicle year", MAKE_REQUIRED: "Please select your vehicle make", MODEL_REQUIRED: "Please select your vehicle model", diff --git a/src/digital-components/checkbox-question/checkbox-question.spec.js b/src/digital-components/checkbox-question/checkbox-question.spec.js index fa9fc5294..0a0fdb8d4 100644 --- a/src/digital-components/checkbox-question/checkbox-question.spec.js +++ b/src/digital-components/checkbox-question/checkbox-question.spec.js @@ -89,4 +89,15 @@ describe("checkbox-question.vue", () => { expect(paragraph.text()).toEqual("screenreader text"); }); + + it("Should prefer labelText prop over CMS content", () => { + const wrapper = shallowMount(checkboxQuestion, { + propsData: { + labelText: "Hardcoded label", + }, + mixins: [mockMixin], + }); + + expect(wrapper.vm.checkboxLabelCopy).toBe("Hardcoded label"); + }); }); diff --git a/src/digital-components/checkbox-question/checkbox-question.vue b/src/digital-components/checkbox-question/checkbox-question.vue index d16c27df3..4a1cb75f7 100644 --- a/src/digital-components/checkbox-question/checkbox-question.vue +++ b/src/digital-components/checkbox-question/checkbox-question.vue @@ -13,6 +13,7 @@ :validationRules="validationRules" :id="inputId" :tabindex="tabIndex" + :disabled="isDisabled" :aria-required="isRequired" /> { "outsideValidation|phone-number-format" ); }); + + it("Should pass placeholderText through to textbox-question", async () => { + const wrapper = shallowMount(phoneNumberQuestion, { + propsData: { + placeholderText: "", + }, + }); + + const phoneNumber = wrapper.findComponent({ ref: "phoneNumber" }); + + expect(phoneNumber.props("placeholderText")).toBe(""); + }); + + it("Should default placeholderText to masked phone format", async () => { + const wrapper = shallowMount(phoneNumberQuestion, {}); + + const phoneNumber = wrapper.findComponent({ ref: "phoneNumber" }); + + expect(phoneNumber.props("placeholderText")).toBe("###-###-####"); + }); }); diff --git a/src/digital-components/phone-number-question/phone-number-question.vue b/src/digital-components/phone-number-question/phone-number-question.vue index a97131ae5..74ac63e95 100644 --- a/src/digital-components/phone-number-question/phone-number-question.vue +++ b/src/digital-components/phone-number-question/phone-number-question.vue @@ -6,8 +6,9 @@ :max-length="12" v-model="selectedValue" :mask="mask" - placeholderText="###-###-####" + :placeholderText="placeholderText" :isRequired="isRequired" + :isDisabled="isDisabled" :validationRules="validationRulesForTextBoxQuestion" :cmsWidgetName="cmsWidgetName" /> @@ -32,10 +33,15 @@ export default { props: { cmsWidgetName: String, isRequired: Boolean, + isDisabled: Boolean, validationRules: String, hasError: Boolean, centerErrorMessage: Boolean, modelValue: String, + placeholderText: { + type: String, + default: "###-###-####", + }, }, data() { return { diff --git a/src/fmg-components/save-progress-modal-question/save-progress-question/save-progress-question.vue b/src/fmg-components/save-progress-modal-question/save-progress-question/save-progress-question.vue index 9e22f5a80..8b7e33ada 100644 --- a/src/fmg-components/save-progress-modal-question/save-progress-question/save-progress-question.vue +++ b/src/fmg-components/save-progress-modal-question/save-progress-question/save-progress-question.vue @@ -7,6 +7,7 @@ questionAlignment="center" cornerStyle="rounded" :displayQuestionText="true" + :isDisabled="isDisabled" isRequired @input="validateEmail" validationRules="email-address-required|email-address-format" /> @@ -37,6 +38,7 @@ export default { props: { modelValue: String, cmsWidgetName: String, + isDisabled: Boolean, }, computed: { value: { diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js index e2ca84f1d..ad42fa617 100644 --- a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js @@ -1,6 +1,23 @@ -import { mount, shallowMount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import saveProgressPopupQuestion from "./save-progress-popup-question"; import { getMountOptions } from "@/helpers/unit-test-helper.js"; +import { storeActions } from "@/constants/store-actions"; +import { saveQuote } from "@/helpers/heritage-integration/order-helper.js"; + +jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({ + saveQuote: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper", () => ({ + ...jest.requireActual("@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper"), + getSaveProgressSmsConsentConfig: jest.fn().mockResolvedValue({ + copy: { + transactional: "Sign me up for updates about my upcoming service.", + marketing: "Sign me up for promotional and product offers.", + }, + value: { transactional: false, marketing: false }, + }), +})); jest.mock("@/digital-components/modal/modal", () => ({ methods: { @@ -15,7 +32,6 @@ describe("save-progress-popup-question ", () => { // Arrange const { wrapper } = setupMocks({ props: { - modelValue: "", modalWidgetName: "testModal", }, }); @@ -27,16 +43,276 @@ describe("save-progress-popup-question ", () => { expect(wrapper.vm.modal).not.toBeNull(); }); }); + + describe("contact method tabs", () => { + test("should default to the phone tab", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + expect(wrapper.vm.isPhoneTabSelected).toBe(true); + }); + + test("should use tab labels from phone and email specific CMS widgets", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + cmsContent: { + SaveProgressPopupWidget_PhoneSpecific: { HeadlineText: "PHONE" }, + SaveProgressPopupWidget_EmailSpecific: { HeadlineText: "EMAIL" }, + }, + }); + + expect(wrapper.vm.contactTabs).toEqual([ + { label: "PHONE", value: "PhoneAnswer" }, + { label: "EMAIL", value: "EmailAnswer" }, + ]); + }); + + test("should use disclaimer BodyText from the selected tab widget", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + cmsContent: { + SaveProgressPopupWidget_PhoneSpecific: { BodyText: "Phone disclaimer" }, + SaveProgressPopupWidget_EmailSpecific: { BodyText: "Email disclaimer" }, + }, + }); + + expect(wrapper.vm.modalDisclaimerText).toBe("Phone disclaimer"); + + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.modalDisclaimerText).toBe("Email disclaimer"); + }); + + test("should use phone question CMS widget on the phone tab", () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + expect(wrapper.vm.phoneQuestionWidgetName).toBe("SaveProgressPopupPhoneQuestionWidget"); + }); + + test("should show email content when the email tab is selected", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.contactMethod).toBe("EmailAnswer"); + expect(wrapper.vm.isPhoneTabSelected).toBe(false); + expect(wrapper.vm.userInput).toBe(""); + expect(wrapper.vm.emailQuestionWidgetName).toBe("SaveProgressPopupEmailQuestionWidget"); + }); + + test("should reset sms consent when switching tabs", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + wrapper.vm.smsConsent = { transactional: true, marketing: true }; + wrapper.vm.showConsentErrors = true; + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.smsConsent).toEqual({ transactional: false, marketing: false }); + expect(wrapper.vm.showConsentErrors).toBe(false); + }); + }); + + describe("validatePhoneAndSave", () => { + test("should show consent errors when phone is valid but no consent is selected", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + wrapper.vm.modal.validate = jest.fn().mockResolvedValue({ valid: true }); + const resetSpy = jest.spyOn(wrapper.vm, "resetPhoneSendButtonStyle"); + + await wrapper.vm.validatePhoneAndSave(); + + expect(wrapper.vm.showConsentErrors).toBe(true); + expect(resetSpy).toHaveBeenCalled(); + }); + + test("should reset the send button loader when phone validation fails", async () => { + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + }, + }); + + wrapper.vm.modal.validate = jest.fn().mockResolvedValue({ valid: false }); + const resetSpy = jest.spyOn(wrapper.vm, "resetPhoneSendButtonStyle"); + + await wrapper.vm.validatePhoneAndSave(); + + expect(wrapper.vm.showConsentErrors).toBe(false); + expect(resetSpy).toHaveBeenCalled(); + }); + + test("should save when phone and consent are valid", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + pageName: "quote", + }, + }); + + await flushPromises(); + + wrapper.vm.dispatchStoreAction = dispatchStoreAction; + wrapper.vm.userInput = "555-123-4567"; + wrapper.vm.smsConsent = { transactional: true, marketing: false }; + wrapper.vm.modal.validate = jest.fn().mockResolvedValue({ valid: true }); + const resetSpy = jest.spyOn(wrapper.vm, "resetPhoneSendButtonStyle"); + + await wrapper.vm.validatePhoneAndSave(); + + expect(wrapper.vm.showConsentErrors).toBe(false); + expect(wrapper.vm.isProgressSaved).toBe(true); + expect(resetSpy).toHaveBeenCalled(); + }); + }); + + describe("saveProgress", () => { + test("should save phone number and sms consent when phone tab is selected", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + pageName: "quote", + }, + }); + + wrapper.vm.dispatchStoreAction = dispatchStoreAction; + wrapper.vm.userInput = "555-123-4567"; + wrapper.vm.smsConsent = { transactional: true, marketing: false }; + + await wrapper.vm.saveProgress(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_PHONE_NUMBER, + "555-123-4567", + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_EMAIL, + expect.anything(), + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_IS_SMS_OPT_IN, + true, + false + ); + expect(saveQuote).toHaveBeenCalledWith({ pageNameToLog: "quote" }); + expect(wrapper.vm.isProgressSaved).toBe(true); + expect(wrapper.vm.savedContactMethod).toBe("PhoneAnswer"); + }); + + test("should save email when email tab is selected", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + pageName: "quote", + }, + }); + + wrapper.vm.dispatchStoreAction = dispatchStoreAction; + wrapper.vm.selectContactMethod("EmailAnswer"); + wrapper.vm.userInput = "test@example.com"; + + await wrapper.vm.saveProgress(); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_EMAIL, + "test@example.com", + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_PHONE_NUMBER, + expect.anything(), + false + ); + expect(saveQuote).toHaveBeenCalledWith({ pageNameToLog: "quote" }); + expect(wrapper.vm.savedContactMethod).toBe("EmailAnswer"); + }); + + test("should not allow switching tabs after a successful phone save", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + pageName: "quote", + }, + }); + + wrapper.vm.dispatchStoreAction = dispatchStoreAction; + wrapper.vm.userInput = "555-123-4567"; + wrapper.vm.smsConsent = { transactional: true, marketing: false }; + + await wrapper.vm.saveProgress(); + wrapper.vm.selectContactMethod("EmailAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.contactMethod).toBe("PhoneAnswer"); + expect(wrapper.vm.isContactMethodTabDisabled("EmailAnswer")).toBe(true); + expect(wrapper.vm.isContactMethodTabDisabled("PhoneAnswer")).toBe(false); + }); + + test("should not allow switching tabs after a successful email save", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const { wrapper } = setupMocks({ + props: { + modalWidgetName: "SaveProgressPopupWidget", + pageName: "quote", + }, + }); + + wrapper.vm.dispatchStoreAction = dispatchStoreAction; + wrapper.vm.selectContactMethod("EmailAnswer"); + wrapper.vm.userInput = "test@example.com"; + + await wrapper.vm.saveProgress(); + wrapper.vm.selectContactMethod("PhoneAnswer"); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.contactMethod).toBe("EmailAnswer"); + expect(wrapper.vm.isContactMethodTabDisabled("PhoneAnswer")).toBe(true); + expect(wrapper.vm.isContactMethodTabDisabled("EmailAnswer")).toBe(false); + }); + }); }); -function setupMocks({ options, props }) { +function setupMocks({ options, props, cmsContent = {} }) { const mountOptions = getMountOptions({ ...options, }); const mockBaseMixin = { methods: { - getCmsContent: jest.fn(), + getCmsContent: jest.fn( + (widgetName, fieldName) => cmsContent[widgetName]?.[fieldName] ?? "" + ), dispatchStoreAction: jest.fn(), }, }; diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue index 318d11793..95fb40273 100644 --- a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue @@ -7,12 +7,53 @@ :onModalClosedCallback="onModalClosed" :footerButtonText="modalButtonText" :isFooterButtonPrimary="true" + :isFooterButtonSuppressed="isPhoneTabSelected && !isProgressSaved" @footer-button-event="saveProgress"> +
+
+ +
+
+
+ + +
import saveProgressQuestion from "@/fmg-components/save-progress-modal-question/save-progress-question/save-progress-question"; +import saveProgressPopupSmsConsentQuestion from "./save-progress-popup-sms-consent-question"; +import phoneNumberQuestion from "@/digital-components/phone-number-question/phone-number-question"; import modal from "@/digital-components/modal/modal"; -import { storeActions } from "@/constants/store-actions.js"; +import modalButtonMain from "@/digital-components/modal/ux-components/modal-button-main/modal-button-main"; import buttonMain from "@/ux-components/button-main/button-main"; import alert from "@/ux-components/alert/alert"; import { saveQuote } from "@/helpers/heritage-integration/order-helper.js"; +import { + saveProgressPopupContactToStore, + saveProgressPopupContactMethods, +} from "@/helpers/save-progress-popup-contact-helper"; +import { + defaultSaveProgressSmsConsent, + getSaveProgressSmsConsentFallbackCopy, + getSaveProgressSmsConsentConfig, + hasSaveProgressSmsConsentSelection, +} from "@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper"; +import { defineRule } from "vee-validate"; +import { required } from "@/helpers/validation-rules"; +import { errorMessages } from "@/constants/error-messages"; + +const PHONE_SPECIFIC_WIDGET = "SaveProgressPopupWidget_PhoneSpecific"; +const EMAIL_SPECIFIC_WIDGET = "SaveProgressPopupWidget_EmailSpecific"; +const PHONE_QUESTION_WIDGET = "SaveProgressPopupPhoneQuestionWidget"; +const EMAIL_QUESTION_WIDGET = "SaveProgressPopupEmailQuestionWidget"; + +defineRule("phone-number-required", required(errorMessages.PHONE_REQUIRED)); export default { name: "save-progress-popup-question", @@ -48,15 +118,31 @@ export default { data() { return { userInput: "", + contactMethod: saveProgressPopupContactMethods.PHONE, + smsConsent: defaultSaveProgressSmsConsent(), + smsConsentCopy: getSaveProgressSmsConsentFallbackCopy(), isProgressSaved: false, + savedContactMethod: null, + showConsentErrors: false, }; }, - mounted() { + watch: { + smsConsent: { + handler(consent) { + if (hasSaveProgressSmsConsentSelection(consent)) { + this.showConsentErrors = false; + } + }, + deep: true, + }, + }, + async mounted() { + await this.loadSmsConsentConfig(); + if (this.showSaveProgressPopup) this.modal.openModal(); }, - emits: ["close-save-progress-popup", "save-progress"], // <--- should remove oodles of warnings in dev tools + emits: ["close-save-progress-popup", "save-progress-saved"], props: { - modelValue: Object, modalWidgetName: String, pageName: String, showSaveProgressPopup: Boolean, @@ -77,7 +163,11 @@ export default { return this.getCmsContent(this.modalWidgetName, "FooterText"); }, modalDisclaimerText() { - return this.getCmsContent(this.modalWidgetName, "FooterText2"); + const disclaimerWidgetName = this.isPhoneTabSelected + ? PHONE_SPECIFIC_WIDGET + : EMAIL_SPECIFIC_WIDGET; + + return this.getCmsContent(disclaimerWidgetName, "BodyText"); }, modalName() { return this.modalWidgetName; @@ -85,23 +175,92 @@ export default { modal() { return this.$refs[this.modalName]; }, + contactTabs() { + return [ + { + label: this.getCmsContent(PHONE_SPECIFIC_WIDGET, "HeadlineText") || "Phone", + value: saveProgressPopupContactMethods.PHONE, + }, + { + label: this.getCmsContent(EMAIL_SPECIFIC_WIDGET, "HeadlineText") || "Email", + value: saveProgressPopupContactMethods.EMAIL, + }, + ]; + }, + isPhoneTabSelected() { + return this.contactMethod === saveProgressPopupContactMethods.PHONE; + }, + phoneQuestionWidgetName() { + return PHONE_QUESTION_WIDGET; + }, + emailQuestionWidgetName() { + return EMAIL_QUESTION_WIDGET; + }, }, methods: { + async loadSmsConsentConfig() { + const config = await getSaveProgressSmsConsentConfig(); + this.smsConsentCopy = config.copy; + + if (!this.isProgressSaved) { + this.smsConsent = config.value; + } + }, + selectContactMethod(contactMethod) { + if (this.isProgressSaved || this.contactMethod === contactMethod) { + return; + } + + this.contactMethod = contactMethod; + this.userInput = ""; + this.smsConsent = defaultSaveProgressSmsConsent(); + this.showConsentErrors = false; + }, + isContactMethodTabDisabled(contactMethod) { + return this.isProgressSaved && contactMethod !== this.savedContactMethod; + }, closeModal() { this.modal.closeModal(); }, onModalClosed() { - // send call to close popup + this.showConsentErrors = false; this.$emit("close-save-progress-popup"); }, - async saveProgress() { - // save email address to store - await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.userInput, false); + async validatePhoneAndSave() { + try { + const validationResult = await this.modal.validate(); - // send store call to send to new API (that triggers an email send) + if (!validationResult.valid) { + return; + } + + if (!hasSaveProgressSmsConsentSelection(this.smsConsent)) { + this.showConsentErrors = true; + return; + } + + this.showConsentErrors = false; + await this.saveProgress(); + } finally { + this.resetPhoneSendButtonStyle(); + } + }, + resetPhoneSendButtonStyle() { + this.$refs.phoneSendButton?.resetButtonStyle(); + }, + async saveProgress() { + await saveProgressPopupContactToStore(this.dispatchStoreAction, { + contactMethod: this.contactMethod, + userInput: this.userInput, + smsConsent: this.smsConsent, + pageName: this.pageName, + }); + + // send store call to send to new API (that triggers an email/SMS send) await saveQuote({ pageNameToLog: this.pageName }); // hide save progress button; show success message alert + this.savedContactMethod = this.contactMethod; this.isProgressSaved = true; // communicate to parent the new status @@ -111,8 +270,11 @@ export default { components: { modal, + modalButtonMain, buttonMain, saveProgressQuestion, + phoneNumberQuestion, + saveProgressPopupSmsConsentQuestion, alert, }, }; @@ -227,8 +389,91 @@ export default { } .modal-body-inner { text-align: center; - margin-bottom: 1.5rem; + margin-bottom: 0.5rem; } + + .save-progress-popup-question__tabs { + border: 0; + margin: 0 0 1rem 0; + padding: 0; + } + + .save-progress-popup-question__tab-list { + display: flex; + width: 100%; + } + + .save-progress-popup-question__tab { + flex: 1 1 50%; + display: grid; + min-height: 3.25rem; + margin: 0; + cursor: pointer; + } + + .save-progress-popup-question__tab-input { + grid-area: 1 / 1; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: inherit; + + &:focus-visible + .save-progress-popup-question__tab-label { + outline: 2px solid $blue; + outline-offset: 2px; + border-radius: 0.25rem; + } + } + + .save-progress-popup-question__tab-label { + grid-area: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.875rem 0.5rem; + text-align: center; + text-transform: uppercase; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25rem; + color: $gray-600; + border-bottom: 3px solid transparent; + transition: + color 150ms linear, + border-color 150ms linear; + } + + .save-progress-popup-question__tab--active .save-progress-popup-question__tab-label { + color: $red; + border-bottom-color: $red; + } + + .save-progress-popup-question__tab--disabled { + cursor: not-allowed; + + .save-progress-popup-question__tab-label { + color: $gray-400; + } + } + + .save-progress-popup-question__phone-fields { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0 0 1.5rem 0; + } + + .phone-number-question { + margin: 0; + + label { + text-align: left; + font-weight: 900; + } + } + .modal-footer { padding: 0 1.5rem; margin: 0 0 1.5rem 0; diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.spec.js b/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.spec.js new file mode 100644 index 000000000..4a9fe13a1 --- /dev/null +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from "@vue/test-utils"; +import saveProgressPopupSmsConsentQuestion from "./save-progress-popup-sms-consent-question"; +import { + defaultSaveProgressSmsConsent, + getSaveProgressSmsConsentFallbackCopy, +} from "@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper"; +import { errorMessages } from "@/constants/error-messages"; + +describe("save-progress-popup-sms-consent-question", () => { + it("should render two consent checkboxes with default copy", () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion); + const fallbackCopy = getSaveProgressSmsConsentFallbackCopy(); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + + expect(checkboxes).toHaveLength(2); + expect(checkboxes.at(0).props("labelText")).toBe(fallbackCopy.transactional); + expect(checkboxes.at(1).props("labelText")).toBe(fallbackCopy.marketing); + }); + + it("should emit updated consent when a checkbox changes", async () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + modelValue: defaultSaveProgressSmsConsent(), + }, + }); + + wrapper.vm.transactionalConsent = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted("update:modelValue")).toEqual([ + [{ transactional: true, marketing: false }], + ]); + }); + + it("should accept API-ready consent copy via prop", () => { + const apiCopy = { + transactional: "API transactional copy", + marketing: "API marketing copy", + }; + + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + consentCopy: apiCopy, + }, + }); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + + expect(checkboxes.at(0).props("labelText")).toBe("API transactional copy"); + expect(checkboxes.at(1).props("labelText")).toBe("API marketing copy"); + }); + + it("should disable consent checkboxes when isDisabled is true", () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + isDisabled: true, + }, + }); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + + expect(checkboxes.at(0).props("isDisabled")).toBe(true); + expect(checkboxes.at(1).props("isDisabled")).toBe(true); + expect(wrapper.find("fieldset").attributes("disabled")).toBe(""); + }); + + it("should not show consent errors before showConsentErrors is true", () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + modelValue: defaultSaveProgressSmsConsent(), + showConsentErrors: false, + }, + }); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + + expect(checkboxes.at(0).props("hasError")).toBe(false); + expect(checkboxes.at(1).props("hasError")).toBe(false); + expect(wrapper.text()).not.toContain(errorMessages.SMS_CONSENT_REQUIRED); + }); + + it("should show consent errors when showConsentErrors is true and nothing is selected", async () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + modelValue: defaultSaveProgressSmsConsent(), + showConsentErrors: false, + }, + }); + + await wrapper.setProps({ showConsentErrors: true }); + + expect(wrapper.text()).toContain(errorMessages.SMS_CONSENT_REQUIRED); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + expect(checkboxes.at(0).props("hasError")).toBe(true); + expect(checkboxes.at(1).props("hasError")).toBe(true); + }); + + it("should clear consent errors when a selection is made while showConsentErrors is true", async () => { + const wrapper = shallowMount(saveProgressPopupSmsConsentQuestion, { + propsData: { + modelValue: defaultSaveProgressSmsConsent(), + showConsentErrors: true, + }, + }); + + await wrapper.setProps({ + modelValue: { transactional: true, marketing: false }, + }); + + const checkboxes = wrapper.findAllComponents({ name: "checkboxQuestion" }); + expect(checkboxes.at(0).props("hasError")).toBe(false); + expect(checkboxes.at(1).props("hasError")).toBe(false); + }); +}); diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.vue b/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.vue new file mode 100644 index 000000000..55e18e623 --- /dev/null +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-sms-consent-question.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/helpers/save-progress-popup-contact-helper.js b/src/helpers/save-progress-popup-contact-helper.js new file mode 100644 index 000000000..2853d8c8e --- /dev/null +++ b/src/helpers/save-progress-popup-contact-helper.js @@ -0,0 +1,38 @@ +import { storeActions } from "@/constants/store-actions"; +import store from "@/store"; + +export const saveProgressPopupContactMethods = { + PHONE: "PhoneAnswer", + EMAIL: "EmailAnswer", +}; + +export function isPhoneContactMethod(contactMethod) { + return contactMethod === saveProgressPopupContactMethods.PHONE; +} + +export async function saveProgressPopupContactToStore( + dispatchStoreAction, + { contactMethod, userInput, smsConsent, pageName } +) { + if (isPhoneContactMethod(contactMethod)) { + await dispatchStoreAction(storeActions.SAVE_PHONE_NUMBER, userInput, false); + await dispatchStoreAction(storeActions.SAVE_IS_SMS_OPT_IN, smsConsent.transactional, false); + + const existingPageData = store.getters.pageData(pageName) ?? {}; + await dispatchStoreAction( + storeActions.SAVE_PAGE_DATA, + { + page: pageName, + data: { + ...existingPageData, + saveProgressSmsConsent: { ...smsConsent }, + }, + }, + false + ); + + return; + } + + await dispatchStoreAction(storeActions.SAVE_EMAIL, userInput, false); +} diff --git a/src/helpers/save-progress-popup-contact-helper.spec.js b/src/helpers/save-progress-popup-contact-helper.spec.js new file mode 100644 index 000000000..5935874d9 --- /dev/null +++ b/src/helpers/save-progress-popup-contact-helper.spec.js @@ -0,0 +1,103 @@ +import { storeActions } from "@/constants/store-actions"; +import store from "@/store"; +import { + isPhoneContactMethod, + saveProgressPopupContactMethods, + saveProgressPopupContactToStore, +} from "./save-progress-popup-contact-helper"; + +jest.mock("@/store", () => ({ + getters: { + pageData: jest.fn(), + }, +})); + +describe("save-progress-popup-contact-helper", () => { + beforeEach(() => { + jest.clearAllMocks(); + store.getters.pageData.mockReturnValue({ servicePackageSelected: "premium" }); + }); + + describe("isPhoneContactMethod", () => { + it("should return true for the phone contact method", () => { + expect(isPhoneContactMethod(saveProgressPopupContactMethods.PHONE)).toBe(true); + }); + + it("should return false for the email contact method", () => { + expect(isPhoneContactMethod(saveProgressPopupContactMethods.EMAIL)).toBe(false); + }); + }); + + describe("saveProgressPopupContactToStore", () => { + it("should save phone number and persist sms consent on phone tab", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + const smsConsent = { transactional: true, marketing: false }; + + await saveProgressPopupContactToStore(dispatchStoreAction, { + contactMethod: saveProgressPopupContactMethods.PHONE, + userInput: "555-123-4567", + smsConsent, + pageName: "quote", + }); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_PHONE_NUMBER, + "555-123-4567", + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_EMAIL, + expect.anything(), + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_IS_SMS_OPT_IN, + true, + false + ); + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_PAGE_DATA, + { + page: "quote", + data: { + servicePackageSelected: "premium", + saveProgressSmsConsent: smsConsent, + }, + }, + false + ); + }); + + it("should save email on email tab without updating phone or sms opt in", async () => { + const dispatchStoreAction = jest.fn().mockResolvedValue(undefined); + + await saveProgressPopupContactToStore(dispatchStoreAction, { + contactMethod: saveProgressPopupContactMethods.EMAIL, + userInput: "test@example.com", + smsConsent: { transactional: false, marketing: false }, + pageName: "quote", + }); + + expect(dispatchStoreAction).toHaveBeenCalledWith( + storeActions.SAVE_EMAIL, + "test@example.com", + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_PHONE_NUMBER, + expect.anything(), + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_IS_SMS_OPT_IN, + expect.anything(), + false + ); + expect(dispatchStoreAction).not.toHaveBeenCalledWith( + storeActions.SAVE_PAGE_DATA, + expect.anything(), + false + ); + }); + }); +}); diff --git a/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.js b/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.js new file mode 100644 index 000000000..6c29495a9 --- /dev/null +++ b/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.js @@ -0,0 +1,27 @@ +const SAVE_PROGRESS_SMS_CONSENT_FALLBACK_COPY = { + transactional: "Sign me up for updates about my upcoming service.", + marketing: "Sign me up for promotional and product offers.", +}; + +export function defaultSaveProgressSmsConsent() { + return { + transactional: false, + marketing: false, + }; +} + +export function getSaveProgressSmsConsentFallbackCopy() { + return { ...SAVE_PROGRESS_SMS_CONSENT_FALLBACK_COPY }; +} + +export function hasSaveProgressSmsConsentSelection(consent) { + return Boolean(consent?.transactional || consent?.marketing); +} + +export async function getSaveProgressSmsConsentConfig() { + // Replace with Consent Management API lookup (CASH-1911). + return { + copy: getSaveProgressSmsConsentFallbackCopy(), + value: defaultSaveProgressSmsConsent(), + }; +} diff --git a/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.spec.js b/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.spec.js new file mode 100644 index 000000000..7c05fa866 --- /dev/null +++ b/src/helpers/save-progress-sms-consent/save-progress-sms-consent-helper.spec.js @@ -0,0 +1,32 @@ +import { + defaultSaveProgressSmsConsent, + getSaveProgressSmsConsentFallbackCopy, + getSaveProgressSmsConsentConfig, + hasSaveProgressSmsConsentSelection, +} from "./save-progress-sms-consent-helper"; + +describe("save-progress-sms-consent-helper", () => { + describe("getSaveProgressSmsConsentConfig", () => { + it("should return fallback copy and default consent values", async () => { + const config = await getSaveProgressSmsConsentConfig(); + + expect(config.copy).toEqual(getSaveProgressSmsConsentFallbackCopy()); + expect(config.value).toEqual(defaultSaveProgressSmsConsent()); + }); + }); + + describe("hasSaveProgressSmsConsentSelection", () => { + it("should return false when no consent is selected", () => { + expect(hasSaveProgressSmsConsentSelection(defaultSaveProgressSmsConsent())).toBe(false); + }); + + it("should return true when either consent option is selected", () => { + expect( + hasSaveProgressSmsConsentSelection({ transactional: true, marketing: false }) + ).toBe(true); + expect( + hasSaveProgressSmsConsentSelection({ transactional: false, marketing: true }) + ).toBe(true); + }); + }); +}); diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 7c06d9ea4..cad68ef0f 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -324,12 +324,15 @@ export default { const resultMap = await settleAllPromises(promiseResultMap); const emailFromStore = store.getters.order.customer.emailAddress; + const phoneFromStore = store.getters.order.customer.phoneNumber; const isEmailInStoreOnPageLoad = emailFromStore?.length > 0; + const isPhoneInStoreOnPageLoad = phoneFromStore?.length > 0; + const hasSaveProgressContactInStore = isEmailInStoreOnPageLoad || isPhoneInStoreOnPageLoad; const isPopupSkipped = store.getters.pageData(routeData.QUOTE.name)?.saveProgressPopupSkipped === true; // "popup" is the automatic opening centered popup on load - const showSaveProgressPopup = !(isEmailInStoreOnPageLoad || isPopupSkipped); - const showSaveProgressModal = isEmailInStoreOnPageLoad ? false : true; + const showSaveProgressPopup = !(hasSaveProgressContactInStore || isPopupSkipped); + const showSaveProgressModal = !hasSaveProgressContactInStore; const shouldSkipToInsurance = getBoolFromString( experimentMixin.methods.getSettingValue(experimentSettings.SKIP_TO_INSURANCE) diff --git a/src/store/index.js b/src/store/index.js index bb4f77378..76e98dd4f 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2579,7 +2579,8 @@ export const actions = { referralCorrelationId: order.referralCorrelationId, referralNumber: order.referralNumber?.toString(), referralSequenceNumber: order.referralSequenceNumber, - emailAddress: order.customer.emailAddress, + emailAddress: order.customer.emailAddress || undefined, + phoneNumber: order.customer.phoneNumber || undefined, lastPage: pageNameToLog, isRepair: damage.isRepair, firstName: order.customer.firstName,