Merge pull request #3248 from Safelite/feature/CASH-2934

CASH-2934: Add phone option and consent checks to quote page modal
This commit is contained in:
Chris 2026-07-02 08:00:21 -04:00 committed by GitHub
commit e35704dce9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1009 additions and 21 deletions

View file

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

View file

@ -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");
});
});

View file

@ -13,6 +13,7 @@
:validationRules="validationRules"
:id="inputId"
:tabindex="tabIndex"
:disabled="isDisabled"
:aria-required="isRequired" />
<textBlock
class="m-0"
@ -34,7 +35,7 @@ export default {
name: "checkboxQuestion",
computed: {
checkboxLabelCopy() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
return this.labelText || this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
},
setup(props) {
@ -72,11 +73,13 @@ export default {
customInputId: String,
validationRules: String,
cmsWidgetName: String,
labelText: String,
checkboxName: String,
buttonID: String,
tabIndex: Number,
screenReaderOnlyText: String,
isRequired: Boolean,
isDisabled: Boolean,
hasError: Boolean,
modelValue: {
type: Boolean,

View file

@ -61,4 +61,24 @@ describe("phone-number-question.vue", () => {
"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("###-###-####");
});
});

View file

@ -6,8 +6,9 @@
:max-length="12"
v-model="selectedValue"
:mask="mask"
placeholderText="###-###-####"
:placeholderText="placeholderText"
:isRequired="isRequired"
:isDisabled="isDisabled"
:validationRules="validationRulesForTextBoxQuestion"
:cmsWidgetName="cmsWidgetName" />
</div>
@ -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 {

View file

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

View file

@ -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",
},
});
function setupMocks({ options, props }) {
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, cmsContent = {} }) {
const mountOptions = getMountOptions({
...options,
});
const mockBaseMixin = {
methods: {
getCmsContent: jest.fn(),
getCmsContent: jest.fn(
(widgetName, fieldName) => cmsContent[widgetName]?.[fieldName] ?? ""
),
dispatchStoreAction: jest.fn(),
},
};

View file

@ -7,12 +7,53 @@
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
:isFooterButtonPrimary="true"
:isFooterButtonSuppressed="isPhoneTabSelected && !isProgressSaved"
@footer-button-event="saveProgress">
<p class="modal-body-inner">{{ modalBodyText }}</p>
<fieldset class="save-progress-popup-question__tabs">
<div class="save-progress-popup-question__tab-list" role="radiogroup">
<label
v-for="tab in contactTabs"
:key="tab.value"
class="save-progress-popup-question__tab"
:class="{
'save-progress-popup-question__tab--active':
contactMethod === tab.value,
'save-progress-popup-question__tab--disabled':
isContactMethodTabDisabled(tab.value),
}">
<input
type="radio"
class="save-progress-popup-question__tab-input"
name="saveProgressPopupContactMethod"
:value="tab.value"
:checked="contactMethod === tab.value"
:disabled="isContactMethodTabDisabled(tab.value)"
@change="selectContactMethod(tab.value)" />
<span class="save-progress-popup-question__tab-label">{{ tab.label }}</span>
</label>
</div>
</fieldset>
<div v-if="isPhoneTabSelected" class="save-progress-popup-question__phone-fields">
<phoneNumberQuestion
v-model="userInput"
:cmsWidgetName="phoneQuestionWidgetName"
placeholderText=""
:isDisabled="isProgressSaved"
isRequired
validationRules="phone-number-required" />
<saveProgressPopupSmsConsentQuestion
v-model="smsConsent"
:consentCopy="smsConsentCopy"
:isDisabled="isProgressSaved"
:showConsentErrors="showConsentErrors" />
</div>
<saveProgressQuestion
v-else
ref="saveProgressQuestion"
v-model="userInput"
cmsWidgetName="SaveProgressPopupQuestionWidget"
:cmsWidgetName="emailQuestionWidgetName"
:isDisabled="isProgressSaved"
class="save-progress-popup-question" />
<template v-slot:modal-footer-slot>
<buttonMain
@ -23,6 +64,13 @@
:suppressLoader="true"
class="skip-button"
@click-event="closeModal" />
<modalButtonMain
v-if="isPhoneTabSelected && !isProgressSaved"
ref="phoneSendButton"
:isPrimary="true"
class="w-100 modal-footer-button"
:buttonText="modalButtonText"
@click-event="validatePhoneAndSave" />
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
</template>
<alert
@ -36,11 +84,33 @@
<script>
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;

View file

@ -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);
});
});

View file

@ -0,0 +1,104 @@
<template>
<fieldset
class="save-progress-popup-sms-consent"
:class="{ 'has-error': showConsentError }"
:disabled="isDisabled">
<checkboxQuestion
v-model="transactionalConsent"
checkboxName="saveProgressTransactionalConsent"
:labelText="consentCopy.transactional"
:hasError="showConsentError"
:isDisabled="isDisabled" />
<checkboxQuestion
v-model="marketingConsent"
checkboxName="saveProgressMarketingConsent"
:labelText="consentCopy.marketing"
:hasError="showConsentError"
:isDisabled="isDisabled" />
<span
v-if="showConsentError"
class="save-progress-popup-sms-consent__error d-inline-flex small mt-1"
role="alert"
aria-live="polite">
{{ errorMessages.SMS_CONSENT_REQUIRED }}
</span>
</fieldset>
</template>
<script>
import checkboxQuestion from "@/digital-components/checkbox-question/checkbox-question";
import { errorMessages } from "@/constants/error-messages";
import {
defaultSaveProgressSmsConsent,
getSaveProgressSmsConsentFallbackCopy,
hasSaveProgressSmsConsentSelection,
} from "@/helpers/save-progress-sms-consent/save-progress-sms-consent-helper";
export default {
name: "save-progress-popup-sms-consent-question",
data() {
return {
errorMessages,
};
},
props: {
modelValue: {
type: Object,
default: defaultSaveProgressSmsConsent,
},
consentCopy: {
type: Object,
default: getSaveProgressSmsConsentFallbackCopy,
},
isDisabled: Boolean,
showConsentErrors: Boolean,
},
computed: {
transactionalConsent: {
get() {
return this.modelValue.transactional;
},
set(value) {
this.emitConsentUpdate({ transactional: value });
},
},
marketingConsent: {
get() {
return this.modelValue.marketing;
},
set(value) {
this.emitConsentUpdate({ marketing: value });
},
},
showConsentError() {
return this.showConsentErrors && !hasSaveProgressSmsConsentSelection(this.modelValue);
},
},
methods: {
emitConsentUpdate(partialConsent) {
this.$emit("update:modelValue", {
...this.modelValue,
...partialConsent,
});
},
},
components: {
checkboxQuestion,
},
};
</script>
<style lang="scss" scoped>
.save-progress-popup-sms-consent {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 0;
margin: 0;
padding: 0;
&__error {
color: $red;
}
}
</style>

View file

@ -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);
}

View file

@ -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
);
});
});
});

View file

@ -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(),
};
}

View file

@ -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);
});
});
});

View file

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

View file

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