Merge branch 'release/2026.07.30' into nation/CASH-3036
This commit is contained in:
commit
f29b2c6634
38 changed files with 1377 additions and 336 deletions
|
|
@ -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",
|
||||
PHONE_EXTENSION_FORMAT: "The specified extension is invalid",
|
||||
SMS_CONSENT_REQUIRED_1: "Please select checkbox to receive text messages",
|
||||
SMS_CONSENT_REQUIRED_2: "Please select at least one consent option",
|
||||
YEAR_REQUIRED: "Please select your vehicle year",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ const experimentSettings = {
|
|||
|
||||
// Consent Management
|
||||
SHOW_CONSENT_MANAGEMENT: "ShowConsentManagement",
|
||||
|
||||
// YMM
|
||||
BAILOUT_VIN_REQUIRED_VEHICLES: "BailoutVINRequiredVehicles",
|
||||
|
||||
// Promo Banner
|
||||
SHOW_PROMO_BANNER: "ShowQuotePageRegionalPromoBanner",
|
||||
};
|
||||
|
||||
const experimentTriggers = {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ const storeActions = {
|
|||
SAVE_EMAIL: "saveEmail",
|
||||
SAVE_PHONE_NUMBER: "savePhoneNumber",
|
||||
SAVE_IS_SMS_OPT_IN: "saveIsSmsOptIn",
|
||||
SAVE_IS_SMS_MARKETING_OPT_IN: "saveIsSmsMarketingOptIn",
|
||||
SAVE_WAITLIST_REQUESTED: "saveWaitListRequested",
|
||||
SAVE_REGISTRATION_LICENSE_PLATE_LOOKUP: "saveRegistrationLicensePlateLookup",
|
||||
SAVE_VIN: "saveVin",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const storeMutations = {
|
|||
UPDATE_CUSTOMER_EMAIL_ADDRESS: "updateCustomerEmailAddress",
|
||||
UPDATE_CUSTOMER_PHONE_NUMBER: "updateCustomerPhoneNumber",
|
||||
UPDATE_CUSTOMER_IS_SMS_OPT_IN: "updateCustomerIsSmsOptin",
|
||||
UPDATE_CUSTOMER_IS_SMS_MARKETING_OPT_IN: "updateCustomerIsSmsMarketingOptin",
|
||||
UPDATE_CUSTOMER_DETAILS: "updateCustomerDetails",
|
||||
UPDATE_CUSTOMER_WAITLIST_REQUESTED: "updateCustomerWaitListRequested",
|
||||
|
||||
|
|
|
|||
|
|
@ -109,11 +109,11 @@ describe("Question Chain component", () => {
|
|||
expect(wrapper.emitted()).not.toHaveProperty("update:modelValue");
|
||||
});
|
||||
|
||||
test("should NOT emit update:modelValue if returnedAnswer contains 'nextQuestion'", () => {
|
||||
test("should not emit update:modelValue for nextQuestion during normal forward progress", () => {
|
||||
//Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
const testQuestion = {};
|
||||
const testReturnedAnswer = "1|nextQuestion|DB10840|No";
|
||||
const testReturnedAnswer = "1|nextQuestion|3|No";
|
||||
|
||||
//Act
|
||||
wrapper.vm.handleAnswer(testQuestion, testReturnedAnswer);
|
||||
|
|
@ -121,6 +121,24 @@ describe("Question Chain component", () => {
|
|||
//Assert
|
||||
expect(wrapper.emitted()).not.toHaveProperty("update:modelValue");
|
||||
});
|
||||
|
||||
test("should emit update:modelValue for nextQuestion when revising a saved answer", async () => {
|
||||
//Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
await wrapper.setProps({ hasSavedAnswer: true });
|
||||
const testQuestion = {};
|
||||
const testReturnedAnswer = "1|nextQuestion|3|No";
|
||||
|
||||
//Act
|
||||
wrapper.vm.handleAnswer(testQuestion, testReturnedAnswer);
|
||||
|
||||
//Assert
|
||||
expect(wrapper.emitted()).toHaveProperty("update:modelValue");
|
||||
expect(wrapper.emitted()["update:modelValue"][0][0]).toMatchObject({
|
||||
incomplete: true,
|
||||
index: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("method getQuestionChainAnswerIfComplete...", () => {
|
||||
|
|
@ -213,25 +231,103 @@ describe("Question Chain component", () => {
|
|||
wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer);
|
||||
|
||||
//Assert
|
||||
expect(wrapper.vm.questions[1].answerSelected).toBeUndefined;
|
||||
expect(wrapper.vm.questions[1].answerSelected).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return false if returnedAnswer is a nextQuestion (not a final answer)", async () => {
|
||||
test("should follow the No path and show the next question when changing Q1 from Yes to No", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
questionDataProp: [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText: "Is this the Overland edition?",
|
||||
answers: [
|
||||
{
|
||||
answerResult: null,
|
||||
answerText: "Yes",
|
||||
nextQuestionSequence: 2,
|
||||
},
|
||||
{
|
||||
answerResult: null,
|
||||
answerText: "No",
|
||||
nextQuestionSequence: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 2,
|
||||
questionText: "Rain sensing wipers?",
|
||||
answers: [
|
||||
{ answerResult: "PART-A", answerText: "Yes", nextQuestionSequence: 3 },
|
||||
{ answerResult: "PART-B", answerText: "No", nextQuestionSequence: 3 },
|
||||
],
|
||||
answerSelected: "2|answer|PART-A|Yes",
|
||||
},
|
||||
{
|
||||
questionSequence: 3,
|
||||
questionText: "Laminated door glass?",
|
||||
answers: [
|
||||
{
|
||||
answerResult: "PART-C",
|
||||
answerText: "Yes",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
{
|
||||
answerResult: "PART-D",
|
||||
answerText: "No",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
],
|
||||
answerSelected: "3|answer|PART-C|Yes",
|
||||
},
|
||||
{
|
||||
questionSequence: 5,
|
||||
questionText: "Factory installed antenna?",
|
||||
answers: [
|
||||
{
|
||||
answerResult: "PART-E",
|
||||
answerText: "Yes",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
{
|
||||
answerResult: "PART-F",
|
||||
answerText: "No",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
await wrapper.setProps({ index: 0 });
|
||||
|
||||
const result = wrapper.vm.getQuestionChainAnswerIfComplete("1|nextQuestion|5|No");
|
||||
|
||||
expect(result.incomplete).toBe(true);
|
||||
expect(wrapper.vm.questions[1].answerSelected).toBeUndefined();
|
||||
expect(wrapper.vm.questions[2].answerSelected).toBeUndefined();
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(5);
|
||||
expect(result.answeredQuestions).toHaveLength(1);
|
||||
expect(result.answeredQuestions[0].selectedAnswerText).toBe("No");
|
||||
});
|
||||
|
||||
test("should return incomplete answer if returnedAnswer is a nextQuestion (not a final answer)", async () => {
|
||||
//Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
const testReturnedAnswer = "1|nextQuestion|DB10840|No";
|
||||
const testReturnedAnswer = "1|nextQuestion|3|No";
|
||||
|
||||
//Act
|
||||
const result = wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer);
|
||||
|
||||
//Assert
|
||||
expect(result).toBeFalsy();
|
||||
expect(result).toMatchObject({
|
||||
incomplete: true,
|
||||
index: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("should trigger scroll to .current-question if returnedAnswer is a nextQuestion (not a final answer)", async () => {
|
||||
//Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
const testReturnedAnswer = "1|nextQuestion|DB10840|No";
|
||||
const testReturnedAnswer = "1|nextQuestion|3|No";
|
||||
|
||||
//Act
|
||||
wrapper.vm.getQuestionChainAnswerIfComplete(testReturnedAnswer);
|
||||
|
|
@ -323,9 +419,206 @@ describe("Question Chain component", () => {
|
|||
expect(wrapper.vm.questions[0].answers[0].problemQuestionId).toBe(9531);
|
||||
expect(wrapper.vm.questions[0].answers[1].problemQuestionId).toBe(9529);
|
||||
});
|
||||
|
||||
test("should normalize uppercase answerSelected to match button value on created", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
questionDataProp: [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText: "Is your vehicle equipped with rain sensing wipers?",
|
||||
answerSelected: "1|ANSWER|DW01705|YES",
|
||||
answers: [
|
||||
{
|
||||
answerResult: "DW01705",
|
||||
answerText: "Yes",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
{
|
||||
answerResult: "DW01591",
|
||||
answerText: "No",
|
||||
nextQuestionSequence: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.questions[0].answerSelected).toBe("1|answer|DW01705|Yes");
|
||||
});
|
||||
test("should show question 3 after answering No on Cherokee Overland question", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
questionDataProp: getCherokeeWindshieldQuestions(),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const q1 = wrapper.vm.questions[0];
|
||||
wrapper.vm.handleAnswer(q1, "1|nextQuestion|3|No");
|
||||
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(3);
|
||||
expect(
|
||||
wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question))
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ questionSequence: 1 }),
|
||||
expect.objectContaining({ questionSequence: 3 }),
|
||||
])
|
||||
);
|
||||
expect(
|
||||
wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question))
|
||||
).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ questionSequence: 2 })])
|
||||
);
|
||||
});
|
||||
|
||||
test("should resume at question 3 after remount when Q1 No was already saved", async () => {
|
||||
const questionDataProp = getCherokeeWindshieldQuestions();
|
||||
questionDataProp[0].answerSelected = "1|nextQuestion|3|No";
|
||||
|
||||
const { wrapper } = setupMocks({ questionDataProp });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(3);
|
||||
expect(wrapper.vm.isQuestionVisible(wrapper.vm.questions[2])).toBe(true);
|
||||
});
|
||||
|
||||
test("should resolve on Q1 Yes and not show Q2 for Honda HUD question", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
questionDataProp: getHondaAccordWindshieldQuestions(),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const q1 = wrapper.vm.questions[0];
|
||||
wrapper.vm.handleAnswer(q1, "1|answer|FW04796|Yes");
|
||||
|
||||
expect(wrapper.emitted()["update:modelValue"][0][0]).toMatchObject({
|
||||
answerResult: "FW04796",
|
||||
index: 0,
|
||||
});
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(0);
|
||||
expect(wrapper.vm.isQuestionVisible(wrapper.vm.questions[1])).toBe(false);
|
||||
});
|
||||
|
||||
test("should resolve on Q3 No and not continue after remount", async () => {
|
||||
const questionDataProp = getHondaAccordWindshieldQuestions();
|
||||
questionDataProp[0].answerSelected = "1|nextQuestion|2|No";
|
||||
questionDataProp[1].answerSelected = "2|nextQuestion|3|No";
|
||||
questionDataProp[2].answerSelected = "3|answer|FW04793|No";
|
||||
|
||||
const { wrapper } = setupMocks({ questionDataProp });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(0);
|
||||
expect(wrapper.vm.isQuestionVisible(wrapper.vm.questions[2])).toBe(true);
|
||||
expect(
|
||||
wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question))
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("should show Q2 after answering No on Honda HUD question", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
questionDataProp: getHondaAccordWindshieldQuestions(),
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const q1 = wrapper.vm.questions[0];
|
||||
wrapper.vm.handleAnswer(q1, "1|nextQuestion|2|No");
|
||||
|
||||
expect(wrapper.vm.currentQuestionNum).toBe(2);
|
||||
expect(wrapper.vm.isQuestionVisible(wrapper.vm.questions[1])).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getHondaAccordWindshieldQuestions() {
|
||||
return [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText:
|
||||
"Is your vehicle equipped with a Heads-up Display which projects vehicle information, such as speed, onto the windshield?",
|
||||
answers: [
|
||||
{ answerResult: "FW04796", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "", answerText: "No", nextQuestionSequence: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 2,
|
||||
questionText:
|
||||
"Is your vehicle equipped with an auto-dimming rearview mirror which will darken automatically when a vehicles approaches from the rear at night?",
|
||||
answers: [
|
||||
{ answerResult: "FW04795", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "", answerText: "No", nextQuestionSequence: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 3,
|
||||
questionText: "Is your vehicle equipped with a power moonroof?",
|
||||
answers: [
|
||||
{ answerResult: "FW04794", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "FW04793", answerText: "No", nextQuestionSequence: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getCherokeeWindshieldQuestions() {
|
||||
return [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText:
|
||||
"Is your Cherokee the Overland edition which can be identified by having a wood and leather wrapped steering wheel?",
|
||||
answers: [
|
||||
{ answerResult: "", answerText: "Yes", nextQuestionSequence: 2 },
|
||||
{ answerResult: "", answerText: "No", nextQuestionSequence: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 2,
|
||||
questionText:
|
||||
"Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?",
|
||||
answers: [
|
||||
{ answerResult: "DW02270", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "DW02264", answerText: "No", nextQuestionSequence: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 3,
|
||||
questionText:
|
||||
"Is your vehicle equipped with rain sensing wipers that adjust their speed automatically when it rains?",
|
||||
answers: [
|
||||
{ answerResult: "DW02268", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "", answerText: "No", nextQuestionSequence: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 4,
|
||||
questionText:
|
||||
"Is your vehicle equipped with automatic climate control which will change the fan speed automatically in order to maintain a set temperature?",
|
||||
answers: [
|
||||
{ answerResult: "", answerText: "Yes", nextQuestionSequence: 5 },
|
||||
{ answerResult: "", answerText: "No", nextQuestionSequence: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 5,
|
||||
questionText: "Is your vehicle equipped with heated seats?",
|
||||
answers: [
|
||||
{ answerResult: "DW02104", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "DW02103", answerText: "No", nextQuestionSequence: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionSequence: 6,
|
||||
questionText: "Is your vehicle equipped with heated seats?",
|
||||
answers: [
|
||||
{ answerResult: "DW02102", answerText: "Yes", nextQuestionSequence: null },
|
||||
{ answerResult: "DW02101", answerText: "No", nextQuestionSequence: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function setupMocks({
|
||||
questionDataProp = [
|
||||
{
|
||||
|
|
@ -351,6 +644,7 @@ function setupMocks({
|
|||
const mountOptions = getMountOptions({});
|
||||
mountOptions.propsData = {
|
||||
questionData: questionDataProp,
|
||||
index: 0,
|
||||
};
|
||||
mountOptions["attachTo"] = document.body;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
<div v-for="(q, i) in questions" :key="i">
|
||||
<transition appear name="fade" mode="out-in">
|
||||
<buttonQuestion
|
||||
v-if="q.answerSelected || q.questionSequence === currentQuestionNum"
|
||||
v-if="isQuestionVisible(q)"
|
||||
class="radioQuestion"
|
||||
:class="q.questionSequence === currentQuestionNum && 'current-question'"
|
||||
:class="isCurrentQuestion(q) && 'current-question'"
|
||||
:questionText="q.questionText"
|
||||
:answers="q.answers"
|
||||
:groupName="`question-${index}-${q.questionSequence}`"
|
||||
|
|
@ -19,6 +19,11 @@
|
|||
<script>
|
||||
import buttonQuestion from "@/digital-components/button-question/button-question";
|
||||
import { useValidateForm } from "vee-validate";
|
||||
import {
|
||||
isTerminalQuestionChainAnswer,
|
||||
mapQuestionAnswersForChain,
|
||||
normalizeAnswerSelectedValue,
|
||||
} from "@/helpers/question-chain-helper";
|
||||
|
||||
export default {
|
||||
name: "questionChain",
|
||||
|
|
@ -34,54 +39,86 @@ export default {
|
|||
modelValue: Object,
|
||||
index: Number,
|
||||
answerKey: String,
|
||||
hasSavedAnswer: Boolean,
|
||||
hasDownstreamSavedAnswers: Boolean,
|
||||
},
|
||||
async created() {
|
||||
// do a test validation check upon create to prevent out of sync / incorrect valid states
|
||||
await useValidateForm(); // NOTE: needs to have async/await here; tested and won't work without it
|
||||
|
||||
this.questionData.map((q, i) => {
|
||||
const answers = mapQuestionAnswersForChain(q);
|
||||
const question = {
|
||||
questionText: q.questionText,
|
||||
questionSequence: q.questionSequence,
|
||||
answers: q.answers.map((a) => {
|
||||
return {
|
||||
buttonLabel: a.answerText,
|
||||
// Name will either be nextQuestionSequence or answerResult
|
||||
// Name will be used by list-button as the input value.
|
||||
// It must be a single string or number, so concatenating together a string with
|
||||
// 4 pieces of data separated by pipe characters:
|
||||
// question number|type of answer|answer value|answer text
|
||||
value: a.nextQuestionSequence
|
||||
? q.questionSequence +
|
||||
"|nextQuestion|" +
|
||||
a.nextQuestionSequence +
|
||||
"|" +
|
||||
a.answerText
|
||||
: q.questionSequence + "|answer|" + a.answerResult + "|" + a.answerText,
|
||||
nextQuestionSequence: a.nextQuestionSequence,
|
||||
answerResult: a.answerResult,
|
||||
problemQuestionId: a.problemQuestionId,
|
||||
questionSequence: q.questionSequence,
|
||||
questionType: a.nextQuestionSequence ? "nextQuestion" : "answer",
|
||||
};
|
||||
}),
|
||||
answerSelected: q.answerSelected || "",
|
||||
answers,
|
||||
answerSelected: normalizeAnswerSelectedValue(q.answerSelected, answers),
|
||||
};
|
||||
if (!q.suppressThisQuestion) {
|
||||
this.questions.push(question);
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.modelValue?.length > 0 && this.questions.length > 0) {
|
||||
// set this.currentQuestionNum to first valid question
|
||||
this.currentQuestionNum = this.questions[0].questionSequence;
|
||||
// scroll the next question into view
|
||||
this.$nextTick(() => {
|
||||
document.querySelector(".current-question")?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
if (this.questions.length > 0) {
|
||||
this.initializeCurrentQuestionNum();
|
||||
this.scrollCurrentQuestionIntoView();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getQuestionSequence(question) {
|
||||
return Number(question?.questionSequence);
|
||||
},
|
||||
isCurrentQuestion(question) {
|
||||
return this.getQuestionSequence(question) === Number(this.currentQuestionNum);
|
||||
},
|
||||
isQuestionVisible(question) {
|
||||
return !!question.answerSelected || this.isCurrentQuestion(question);
|
||||
},
|
||||
initializeCurrentQuestionNum() {
|
||||
const lastAnsweredQuestion = [...this.questions]
|
||||
.reverse()
|
||||
.find((question) => question.answerSelected);
|
||||
|
||||
if (isTerminalQuestionChainAnswer(lastAnsweredQuestion?.answerSelected)) {
|
||||
this.currentQuestionNum = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAnsweredQuestion?.answerSelected?.includes("|nextQuestion|")) {
|
||||
const nextQuestionSequence = Number(
|
||||
lastAnsweredQuestion.answerSelected.split("|")[2]
|
||||
);
|
||||
|
||||
if (
|
||||
this.questions.some(
|
||||
(question) => this.getQuestionSequence(question) === nextQuestionSequence
|
||||
)
|
||||
) {
|
||||
this.currentQuestionNum = nextQuestionSequence;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const firstUnanswered = this.questions.find((question) => !question.answerSelected);
|
||||
|
||||
if (firstUnanswered) {
|
||||
this.currentQuestionNum = this.getQuestionSequence(firstUnanswered);
|
||||
}
|
||||
},
|
||||
scrollCurrentQuestionIntoView() {
|
||||
this.$nextTick(() => {
|
||||
document.querySelector(".current-question")?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
},
|
||||
syncAnswerSelectedToQuestionData(questionSequence, answerSelected) {
|
||||
const sourceQuestion = this.questionData?.find(
|
||||
(q) => this.getQuestionSequence(q) === Number(questionSequence)
|
||||
);
|
||||
|
||||
if (sourceQuestion) {
|
||||
sourceQuestion.answerSelected = answerSelected;
|
||||
}
|
||||
},
|
||||
getProblemQuestionIdFromSelectedAnswer(question, selectedAnswer) {
|
||||
if (!question?.answers || !selectedAnswer) {
|
||||
return null;
|
||||
|
|
@ -100,10 +137,20 @@ export default {
|
|||
*/
|
||||
|
||||
question.answerSelected = returnedAnswer;
|
||||
const isQuestionChainComplete = this.getQuestionChainAnswerIfComplete(returnedAnswer);
|
||||
this.syncAnswerSelectedToQuestionData(question.questionSequence, returnedAnswer);
|
||||
const questionChainAnswer = this.getQuestionChainAnswerIfComplete(returnedAnswer);
|
||||
|
||||
if (isQuestionChainComplete) {
|
||||
this.$emit("update:modelValue", isQuestionChainComplete);
|
||||
if (!questionChainAnswer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldNotifyParent =
|
||||
!questionChainAnswer.incomplete ||
|
||||
this.hasSavedAnswer ||
|
||||
this.hasDownstreamSavedAnswers;
|
||||
|
||||
if (shouldNotifyParent) {
|
||||
this.$emit("update:modelValue", questionChainAnswer);
|
||||
}
|
||||
},
|
||||
getQuestionChainAnswerIfComplete(returnedAnswer) {
|
||||
|
|
@ -117,20 +164,24 @@ export default {
|
|||
// "5|answer|DW02104|Yes"
|
||||
|
||||
const returnedAnswerArray = returnedAnswer.split("|");
|
||||
const questionNum = parseInt(returnedAnswerArray[0]);
|
||||
const questionNum = Number(returnedAnswerArray[0]);
|
||||
const questionType = returnedAnswerArray[1];
|
||||
const questionAnswer = returnedAnswerArray[2];
|
||||
const answeredQuestions = [];
|
||||
|
||||
this.questions.forEach((q) => {
|
||||
const questionSequence = this.getQuestionSequence(q);
|
||||
|
||||
// find this question and mark it as "answered" by populating answerSelected
|
||||
if (q.questionSequence === questionNum) {
|
||||
if (questionSequence === questionNum) {
|
||||
q.answerSelected = returnedAnswer;
|
||||
this.syncAnswerSelectedToQuestionData(questionSequence, returnedAnswer);
|
||||
}
|
||||
// remove all answers AFTER this question...
|
||||
// (needed in case user is changing previously answered questions)
|
||||
if (q.questionSequence > questionNum) {
|
||||
if (questionSequence > questionNum) {
|
||||
delete q.answerSelected;
|
||||
this.syncAnswerSelectedToQuestionData(questionSequence, "");
|
||||
}
|
||||
if (q.answerSelected) {
|
||||
answeredQuestions.push({
|
||||
|
|
@ -146,22 +197,21 @@ export default {
|
|||
}
|
||||
});
|
||||
|
||||
// return false if there's a nextQuestion... or return an object with final answers (truthy)
|
||||
// return incomplete answer if there's a nextQuestion... or return final answers (truthy)
|
||||
if (questionType === "nextQuestion") {
|
||||
// update to next question index
|
||||
this.currentQuestionNum = parseInt(questionAnswer); // update count to display next question
|
||||
// scroll the next question into view
|
||||
this.$nextTick(() => {
|
||||
document
|
||||
.querySelector(".current-question")
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
return false;
|
||||
this.currentQuestionNum = Number(questionAnswer);
|
||||
this.scrollCurrentQuestionIntoView();
|
||||
return {
|
||||
incomplete: true,
|
||||
answeredQuestions,
|
||||
index: this.index,
|
||||
};
|
||||
} else {
|
||||
// reset current question index (removes .current-question class)
|
||||
this.currentQuestionNum = 0; // reset count
|
||||
const answeredQuestion = this.questions.find(
|
||||
(q) => q.questionSequence === questionNum
|
||||
(q) => this.getQuestionSequence(q) === questionNum
|
||||
);
|
||||
|
||||
// return an object with the part answer, all the answered questions, and the part index
|
||||
|
|
|
|||
|
|
@ -150,6 +150,43 @@ describe("questionsPageLayout.vue", () => {
|
|||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("Should return false when answerData was cleared even if answerSelected remains", async () => {
|
||||
const { wrapper } = setupMocks({});
|
||||
await wrapper.setProps({ index: 1 });
|
||||
|
||||
const testGlassPiece = {
|
||||
questions: [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText: "Is your Grand Cherokee the Laredo model?",
|
||||
answers: [],
|
||||
answerSelected: "1|answer|DD11132|Yes",
|
||||
},
|
||||
],
|
||||
answerData: {},
|
||||
};
|
||||
|
||||
expect(wrapper.vm.showThisQuestionChain(testGlassPiece, 0)).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("Should return false for reset chains ahead of the current index", async () => {
|
||||
const { wrapper } = setupMocks({});
|
||||
await wrapper.setProps({ index: 1 });
|
||||
|
||||
const testGlassPiece = {
|
||||
questions: [
|
||||
{ questionSequence: 1, questionText: "Rain sensing wipers?", answers: [] },
|
||||
],
|
||||
answerData: null,
|
||||
};
|
||||
|
||||
expect(wrapper.vm.showThisQuestionChain(testGlassPiece, 2)).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("method handleForwardButtonAction...", () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-10 col-lg-8 col-xl-7 question-chain-wrapper">
|
||||
<div v-for="(questionsDatum, i) in questionsData" :key="questionsDatum.key">
|
||||
<div
|
||||
v-for="(questionsDatum, i) in questionsData"
|
||||
:key="questionsDatum.key ?? questionsDatum.answerKey ?? i">
|
||||
<questionChain
|
||||
ref="questionChain"
|
||||
v-model="selectedAnswers[questionsDatum.answerKey]"
|
||||
|
|
@ -18,10 +20,12 @@
|
|||
:index="i"
|
||||
v-if="showThisQuestionChain(questionsDatum, i)"
|
||||
:answerKey="questionsDatum.answerKey"
|
||||
:hasSavedAnswer="!!questionsDatum.answerData?.answerResult"
|
||||
:hasDownstreamSavedAnswers="hasDownstreamSavedAnswers(i)"
|
||||
:validationRules="validationRules" />
|
||||
<!-- Save your progress slot -->
|
||||
<slot></slot>
|
||||
</div>
|
||||
<!-- Save your progress slot (once per page, not per glass piece) -->
|
||||
<slot></slot>
|
||||
|
||||
<navbar
|
||||
ref="navbar"
|
||||
|
|
@ -60,12 +64,22 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
hasDownstreamSavedAnswers(glassIndex) {
|
||||
return this.questionsData
|
||||
?.slice(glassIndex + 1)
|
||||
.some((glass) => glass.answerData?.answerResult);
|
||||
},
|
||||
showThisQuestionChain(glass, i) {
|
||||
// return false if no questions or if suppressed
|
||||
if (!glass.questions || glass.questions?.length < 1 || glass.isSuppressedPart) {
|
||||
return false;
|
||||
}
|
||||
return this.index === i || glass.answerData?.answerResult?.length > 0;
|
||||
|
||||
if (this.index === i) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only show completed chains that still have a saved answer
|
||||
return !!glass.answerData?.answerResult;
|
||||
},
|
||||
handleForwardButtonAction() {
|
||||
this.$emit("forwardButtonAction");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ jest.mock("@/helpers/heritage-integration/order-helper.js", () => ({
|
|||
|
||||
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({
|
||||
getSaveProgressSmsConsentConfig: jest.fn().mockReturnValue({
|
||||
copy: {
|
||||
transactional: "Sign me up for updates about my upcoming service.",
|
||||
marketing: "Sign me up for promotional and product offers.",
|
||||
|
|
@ -159,13 +159,37 @@ describe("save-progress-popup-question ", () => {
|
|||
wrapper.vm.selectContactMethod("PhoneAnswer");
|
||||
wrapper.vm.modal.validate = jest.fn().mockResolvedValue({ valid: true });
|
||||
const resetSpy = jest.spyOn(wrapper.vm, "resetPhoneSendButtonStyle");
|
||||
const scrollSpy = jest
|
||||
.spyOn(wrapper.vm, "scrollConsentIntoView")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await wrapper.vm.validatePhoneAndSave();
|
||||
|
||||
expect(wrapper.vm.showConsentErrors).toBe(true);
|
||||
expect(scrollSpy).toHaveBeenCalled();
|
||||
expect(resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should scroll consent checkboxes into view", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
props: {
|
||||
modalWidgetName: "SaveProgressPopupWidget",
|
||||
},
|
||||
});
|
||||
const mockScrollIntoView = jest.fn();
|
||||
const consentEl = document.createElement("fieldset");
|
||||
consentEl.className = "save-progress-popup-sms-consent";
|
||||
consentEl.scrollIntoView = mockScrollIntoView;
|
||||
wrapper.element.appendChild(consentEl);
|
||||
|
||||
await wrapper.vm.scrollConsentIntoView();
|
||||
|
||||
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
});
|
||||
|
||||
test("should reset the send button loader when phone validation fails", async () => {
|
||||
const { wrapper } = setupMocks({
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
isRequired
|
||||
validationRules="phone-number-required" />
|
||||
<saveProgressPopupSmsConsentQuestion
|
||||
ref="smsConsentQuestion"
|
||||
v-model="smsConsent"
|
||||
:consentCopy="smsConsentCopy"
|
||||
:isDisabled="isProgressSaved"
|
||||
|
|
@ -138,7 +139,7 @@ export default {
|
|||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadSmsConsentConfig();
|
||||
this.loadSmsConsentConfig();
|
||||
|
||||
if (this.showSaveProgressPopup) this.modal.openModal();
|
||||
},
|
||||
|
|
@ -200,8 +201,8 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
async loadSmsConsentConfig() {
|
||||
const config = await getSaveProgressSmsConsentConfig();
|
||||
loadSmsConsentConfig() {
|
||||
const config = getSaveProgressSmsConsentConfig();
|
||||
this.smsConsentCopy = config.copy;
|
||||
|
||||
if (!this.isProgressSaved) {
|
||||
|
|
@ -240,6 +241,7 @@ export default {
|
|||
!hasSaveProgressSmsConsentSelection(this.smsConsent, this.showConsentManagement)
|
||||
) {
|
||||
this.showConsentErrors = true;
|
||||
await this.scrollConsentIntoView();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -252,12 +254,19 @@ export default {
|
|||
resetPhoneSendButtonStyle() {
|
||||
this.$refs.phoneSendButton?.resetButtonStyle();
|
||||
},
|
||||
async scrollConsentIntoView() {
|
||||
await this.$nextTick();
|
||||
const consentEl =
|
||||
this.$refs.smsConsentQuestion?.$el ??
|
||||
this.$el?.querySelector(".save-progress-popup-sms-consent");
|
||||
|
||||
consentEl?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
},
|
||||
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)
|
||||
|
|
@ -373,6 +382,11 @@ export default {
|
|||
}
|
||||
}
|
||||
.modal.modal-component .modal-dialog .modal-content {
|
||||
max-height: calc(100dvh - 1rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
p.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -380,6 +394,9 @@ export default {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
.textbox-question {
|
||||
padding: 0;
|
||||
|
|
@ -500,6 +517,8 @@ export default {
|
|||
text-align: left;
|
||||
order: 3;
|
||||
margin: 0;
|
||||
max-height: calc(100dvh - 30rem);
|
||||
overflow-y: auto;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
|
|
|||
59
src/helpers/question-chain-helper.js
Normal file
59
src/helpers/question-chain-helper.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Builds the pipe-delimited value used by question-chain button options.
|
||||
* Format: questionNumber|type|answer value|answer text
|
||||
*/
|
||||
export function buildQuestionChainAnswerValue({
|
||||
questionSequence,
|
||||
nextQuestionSequence,
|
||||
answerResult,
|
||||
answerText,
|
||||
}) {
|
||||
if (nextQuestionSequence) {
|
||||
return `${questionSequence}|nextQuestion|${nextQuestionSequence}|${answerText}`;
|
||||
}
|
||||
|
||||
return `${questionSequence}|answer|${answerResult}|${answerText}`;
|
||||
}
|
||||
|
||||
export function mapQuestionAnswersForChain(question) {
|
||||
return question.answers.map((answer) => ({
|
||||
buttonLabel: answer.answerText,
|
||||
value: buildQuestionChainAnswerValue({
|
||||
questionSequence: question.questionSequence,
|
||||
nextQuestionSequence: answer.nextQuestionSequence,
|
||||
answerResult: answer.answerResult,
|
||||
answerText: answer.answerText,
|
||||
}),
|
||||
nextQuestionSequence: answer.nextQuestionSequence,
|
||||
answerResult: answer.answerResult,
|
||||
problemQuestionId: answer.problemQuestionId,
|
||||
questionSequence: question.questionSequence,
|
||||
questionType: answer.nextQuestionSequence ? "nextQuestion" : "answer",
|
||||
}));
|
||||
}
|
||||
|
||||
export function isTerminalQuestionChainAnswer(answerSelected) {
|
||||
if (!answerSelected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, questionType, answerResult] = answerSelected.split("|");
|
||||
|
||||
return questionType?.toLowerCase() === "answer" && !!answerResult;
|
||||
}
|
||||
|
||||
export function normalizeAnswerSelectedValue(answerSelected, answers) {
|
||||
if (!answerSelected) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (answers.some((answer) => answer.value === answerSelected)) {
|
||||
return answerSelected;
|
||||
}
|
||||
|
||||
const matchedAnswer = answers.find(
|
||||
(answer) => answer.value.toUpperCase() === String(answerSelected).toUpperCase()
|
||||
);
|
||||
|
||||
return matchedAnswer?.value ?? answerSelected;
|
||||
}
|
||||
90
src/helpers/question-chain-helper.spec.js
Normal file
90
src/helpers/question-chain-helper.spec.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
buildQuestionChainAnswerValue,
|
||||
isTerminalQuestionChainAnswer,
|
||||
mapQuestionAnswersForChain,
|
||||
normalizeAnswerSelectedValue,
|
||||
} from "./question-chain-helper";
|
||||
|
||||
describe("question-chain-helper", () => {
|
||||
describe("buildQuestionChainAnswerValue", () => {
|
||||
test("returns nextQuestion format when nextQuestionSequence is set", () => {
|
||||
expect(
|
||||
buildQuestionChainAnswerValue({
|
||||
questionSequence: 1,
|
||||
nextQuestionSequence: 3,
|
||||
answerResult: "ignored",
|
||||
answerText: "Yes",
|
||||
})
|
||||
).toBe("1|nextQuestion|3|Yes");
|
||||
});
|
||||
|
||||
test("returns answer format for final answers", () => {
|
||||
expect(
|
||||
buildQuestionChainAnswerValue({
|
||||
questionSequence: 1,
|
||||
nextQuestionSequence: null,
|
||||
answerResult: "DW01705",
|
||||
answerText: "Yes",
|
||||
})
|
||||
).toBe("1|answer|DW01705|Yes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTerminalQuestionChainAnswer", () => {
|
||||
test("returns true when answer has a part number and no next question", () => {
|
||||
expect(isTerminalQuestionChainAnswer("1|answer|FW04796|Yes")).toBe(true);
|
||||
expect(isTerminalQuestionChainAnswer("3|answer|FW04793|No")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when answer continues to the next question", () => {
|
||||
expect(isTerminalQuestionChainAnswer("1|nextQuestion|2|No")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when answer type is answer but part number is missing", () => {
|
||||
expect(isTerminalQuestionChainAnswer("1|answer||No")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAnswerSelectedValue", () => {
|
||||
const answers = [{ value: "1|answer|DW01705|Yes" }, { value: "1|answer|DW01591|No" }];
|
||||
|
||||
test("returns empty string when answerSelected is empty", () => {
|
||||
expect(normalizeAnswerSelectedValue("", answers)).toBe("");
|
||||
});
|
||||
|
||||
test("returns exact match when casing already matches", () => {
|
||||
expect(normalizeAnswerSelectedValue("1|answer|DW01705|Yes", answers)).toBe(
|
||||
"1|answer|DW01705|Yes"
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes uppercase legacy values to button value casing", () => {
|
||||
expect(normalizeAnswerSelectedValue("1|ANSWER|DW01705|YES", answers)).toBe(
|
||||
"1|answer|DW01705|Yes"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapQuestionAnswersForChain", () => {
|
||||
test("maps API answers to question-chain button answers", () => {
|
||||
const result = mapQuestionAnswersForChain({
|
||||
questionSequence: 1,
|
||||
answers: [
|
||||
{
|
||||
answerResult: "DW01705",
|
||||
answerText: "Yes",
|
||||
nextQuestionSequence: null,
|
||||
problemQuestionId: 123,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
buttonLabel: "Yes",
|
||||
value: "1|answer|DW01705|Yes",
|
||||
problemQuestionId: 123,
|
||||
questionType: "answer",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { storeActions } from "@/constants/store-actions";
|
||||
import store from "@/store";
|
||||
|
||||
export const saveProgressPopupContactMethods = {
|
||||
PHONE: "PhoneAnswer",
|
||||
|
|
@ -20,7 +19,7 @@ export function normalizePhoneNumberForStore(phoneNumber) {
|
|||
|
||||
export async function saveProgressPopupContactToStore(
|
||||
dispatchStoreAction,
|
||||
{ contactMethod, userInput, smsConsent, pageName }
|
||||
{ contactMethod, userInput, smsConsent }
|
||||
) {
|
||||
if (isPhoneContactMethod(contactMethod)) {
|
||||
await dispatchStoreAction(
|
||||
|
|
@ -29,17 +28,9 @@ export async function saveProgressPopupContactToStore(
|
|||
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 },
|
||||
},
|
||||
},
|
||||
storeActions.SAVE_IS_SMS_MARKETING_OPT_IN,
|
||||
smsConsent.marketing,
|
||||
false
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { storeActions } from "@/constants/store-actions";
|
||||
import store from "@/store";
|
||||
import {
|
||||
isPhoneContactMethod,
|
||||
normalizePhoneNumberForStore,
|
||||
|
|
@ -7,16 +6,9 @@ import {
|
|||
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", () => {
|
||||
|
|
@ -41,7 +33,7 @@ describe("save-progress-popup-contact-helper", () => {
|
|||
});
|
||||
|
||||
describe("saveProgressPopupContactToStore", () => {
|
||||
it("should save phone number and persist sms consent on phone tab", async () => {
|
||||
it("should save phone number and sms consent on customer when phone tab is selected", async () => {
|
||||
const dispatchStoreAction = jest.fn().mockResolvedValue(undefined);
|
||||
const smsConsent = { transactional: true, marketing: false };
|
||||
|
||||
|
|
@ -49,7 +41,6 @@ describe("save-progress-popup-contact-helper", () => {
|
|||
contactMethod: saveProgressPopupContactMethods.PHONE,
|
||||
userInput: "555-123-4567",
|
||||
smsConsent,
|
||||
pageName: "quote",
|
||||
});
|
||||
|
||||
expect(dispatchStoreAction).toHaveBeenCalledWith(
|
||||
|
|
@ -68,14 +59,13 @@ describe("save-progress-popup-contact-helper", () => {
|
|||
false
|
||||
);
|
||||
expect(dispatchStoreAction).toHaveBeenCalledWith(
|
||||
storeActions.SAVE_IS_SMS_MARKETING_OPT_IN,
|
||||
false,
|
||||
false
|
||||
);
|
||||
expect(dispatchStoreAction).not.toHaveBeenCalledWith(
|
||||
storeActions.SAVE_PAGE_DATA,
|
||||
{
|
||||
page: "quote",
|
||||
data: {
|
||||
servicePackageSelected: "premium",
|
||||
saveProgressSmsConsent: smsConsent,
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
|
@ -87,7 +77,6 @@ describe("save-progress-popup-contact-helper", () => {
|
|||
contactMethod: saveProgressPopupContactMethods.EMAIL,
|
||||
userInput: "test@example.com",
|
||||
smsConsent: { transactional: false, marketing: false },
|
||||
pageName: "quote",
|
||||
});
|
||||
|
||||
expect(dispatchStoreAction).toHaveBeenCalledWith(
|
||||
|
|
@ -105,6 +94,11 @@ describe("save-progress-popup-contact-helper", () => {
|
|||
expect.anything(),
|
||||
false
|
||||
);
|
||||
expect(dispatchStoreAction).not.toHaveBeenCalledWith(
|
||||
storeActions.SAVE_IS_SMS_MARKETING_OPT_IN,
|
||||
expect.anything(),
|
||||
false
|
||||
);
|
||||
expect(dispatchStoreAction).not.toHaveBeenCalledWith(
|
||||
storeActions.SAVE_PAGE_DATA,
|
||||
expect.anything(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import store from "@/store";
|
||||
|
||||
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.",
|
||||
|
|
@ -18,10 +20,12 @@ export function hasSaveProgressSmsConsentSelection(consent, showConsentManagemen
|
|||
return Boolean(consent?.transactional || (showConsentManagement && consent?.marketing));
|
||||
}
|
||||
|
||||
export async function getSaveProgressSmsConsentConfig() {
|
||||
// Replace with Consent Management API lookup (CASH-1911).
|
||||
export function getSaveProgressSmsConsentConfig() {
|
||||
return {
|
||||
copy: getSaveProgressSmsConsentFallbackCopy(),
|
||||
value: defaultSaveProgressSmsConsent(),
|
||||
value: {
|
||||
transactional: Boolean(store.getters.order.customer.isSmsOptIn),
|
||||
marketing: Boolean(store.getters.order.customer.isSmsMarketingOptIn),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { storeActions } from "@/constants/store-actions";
|
||||
import store from "@/store";
|
||||
import {
|
||||
defaultSaveProgressSmsConsent,
|
||||
getSaveProgressSmsConsentFallbackCopy,
|
||||
|
|
@ -5,12 +7,38 @@ import {
|
|||
hasSaveProgressSmsConsentSelection,
|
||||
} from "./save-progress-sms-consent-helper";
|
||||
|
||||
jest.mock("@/store", () => ({
|
||||
getters: {
|
||||
order: {
|
||||
customer: {
|
||||
isSmsOptIn: null,
|
||||
isSmsMarketingOptIn: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("save-progress-sms-consent-helper", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
store.getters.order.customer.isSmsOptIn = null;
|
||||
store.getters.order.customer.isSmsMarketingOptIn = null;
|
||||
});
|
||||
|
||||
describe("getSaveProgressSmsConsentConfig", () => {
|
||||
it("should return fallback copy and default consent values", async () => {
|
||||
const config = await getSaveProgressSmsConsentConfig();
|
||||
it("should return fallback copy and consent values from the store", () => {
|
||||
store.getters.order.customer.isSmsOptIn = true;
|
||||
store.getters.order.customer.isSmsMarketingOptIn = true;
|
||||
|
||||
const config = getSaveProgressSmsConsentConfig();
|
||||
|
||||
expect(config.copy).toEqual(getSaveProgressSmsConsentFallbackCopy());
|
||||
expect(config.value).toEqual({ transactional: true, marketing: true });
|
||||
});
|
||||
|
||||
it("should return defaults when no consent is stored", () => {
|
||||
const config = getSaveProgressSmsConsentConfig();
|
||||
|
||||
expect(config.value).toEqual(defaultSaveProgressSmsConsent());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ describe("capabilityQuestions.vue", () => {
|
|||
});
|
||||
|
||||
describe("forwardButtonAction", () => {
|
||||
test("Should clear out answerData", () => {
|
||||
test("Should clear out answerData", async () => {
|
||||
// Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
|
||||
|
|
@ -245,7 +245,7 @@ describe("capabilityQuestions.vue", () => {
|
|||
});
|
||||
|
||||
// Act
|
||||
wrapper.vm.forwardButtonAction();
|
||||
await wrapper.vm.forwardButtonAction();
|
||||
|
||||
//Assert
|
||||
expect(wrapper.vm.questionsData[0].answerData).toEqual({});
|
||||
|
|
|
|||
|
|
@ -180,11 +180,6 @@ export default {
|
|||
};
|
||||
});
|
||||
|
||||
// clear out answerData for future page loads; must occur prior to store save
|
||||
this.questionsData.forEach((glass) => {
|
||||
glass.answerData = {};
|
||||
});
|
||||
|
||||
// save to vuex store as order.damage.capabilityQuestionAnswers (array)
|
||||
// used in GET_PART_FROM_CAPABILITY_QUESTION_ANSWER call following this one
|
||||
await this.dispatchStoreAction(
|
||||
|
|
@ -211,6 +206,8 @@ export default {
|
|||
}
|
||||
|
||||
this.navigateForward(partsOrQuestions);
|
||||
|
||||
this.clearQuestionsAnswerData();
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ describe("moldingQuestions.vue", () => {
|
|||
});
|
||||
|
||||
describe("forwardButtonAction", () => {
|
||||
test("Should clear out answerData", () => {
|
||||
test("Should clear out answerData", async () => {
|
||||
// Arrange
|
||||
const { wrapper } = setupMocks({});
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ describe("moldingQuestions.vue", () => {
|
|||
});
|
||||
|
||||
// Act
|
||||
wrapper.vm.forwardButtonAction();
|
||||
await wrapper.vm.forwardButtonAction();
|
||||
|
||||
//Assert
|
||||
expect(wrapper.vm.questionsData[0].answerData).toEqual({});
|
||||
|
|
|
|||
|
|
@ -181,11 +181,6 @@ export default {
|
|||
};
|
||||
});
|
||||
|
||||
// clear out answerData for future page loads; must occur prior to store save
|
||||
this.questionsData.forEach((glass) => {
|
||||
glass.answerData = {};
|
||||
});
|
||||
|
||||
// save to vuex store as order.damage.moldingQuestionArrays (array)
|
||||
await this.dispatchStoreAction(
|
||||
this.storeActions.SAVE_MOLDING_QUESTION_ANSWERS,
|
||||
|
|
@ -209,6 +204,8 @@ export default {
|
|||
}
|
||||
|
||||
this.navigateForward(partsOrQuestions);
|
||||
|
||||
this.clearQuestionsAnswerData();
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -258,9 +258,7 @@ describe("partQuestions.vue...", () => {
|
|||
});
|
||||
|
||||
// Act
|
||||
wrapper.vm.forwardButtonAction();
|
||||
|
||||
await nextTick();
|
||||
await wrapper.vm.forwardButtonAction();
|
||||
|
||||
//Assert
|
||||
expect(wrapper.vm.questionsData[0].answerData).toEqual({});
|
||||
|
|
|
|||
|
|
@ -182,13 +182,6 @@ export default {
|
|||
};
|
||||
});
|
||||
|
||||
// clear out answerData for future page loads; must occur prior to store save
|
||||
this.questionsData.forEach((glass) => {
|
||||
if (glass.answerData) {
|
||||
glass.answerData = {};
|
||||
}
|
||||
});
|
||||
|
||||
this.dispatchStoreAction(this.storeActions.SAVE_IS_OEM_GLASS_SELECTED, false, false);
|
||||
|
||||
// save to vuex store as order.damage.partQuestionAnswers (array)
|
||||
|
|
@ -209,6 +202,8 @@ export default {
|
|||
const glassPartsForStore = partsLookup.data.glassPieceParts;
|
||||
|
||||
this.navigateForward(glassPartsForStore);
|
||||
|
||||
this.clearQuestionsAnswerData();
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<cart
|
||||
ref="cart"
|
||||
:damage="damageInfo"
|
||||
:availableVaps="[]"
|
||||
:availableVaps="availableVaps"
|
||||
:allowItemRemoval="false"
|
||||
v-model="lineItems"
|
||||
servicePackageOptionsCmsName="ServicePackageTitle"
|
||||
|
|
@ -598,6 +598,9 @@ export default {
|
|||
lineItems() {
|
||||
return deepClone(this.$store.getters.lineItems);
|
||||
},
|
||||
availableVaps() {
|
||||
return this.$store.getters.order.lineItems?.vaps ?? [];
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@
|
|||
class="mb-4"
|
||||
cmsWidgetName="PhoneExtensionWidget"
|
||||
v-model="phoneExtension"
|
||||
inputId="phoneExtension" />
|
||||
inputId="phoneExtension"
|
||||
maxLength="5"
|
||||
validationRules="phone-extension-format" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -321,6 +323,7 @@ defineRule("policy-number-format", (value) => {
|
|||
defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED));
|
||||
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
|
||||
defineRule("phone-number-required", required(errorMessages.PHONE_REQUIRED));
|
||||
defineRule("phone-extension-format", regex(/^\d{1,5}$/, errorMessages.PHONE_EXTENSION_FORMAT));
|
||||
defineRule("date-of-loss-required", required(errorMessages.DATE_OF_LOSS_REQUIRED));
|
||||
defineRule("damage-types-required", required(errorMessages.DAMAGE_TYPES_REQUIRED));
|
||||
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<div class="afterpay-modal-banner" role="alert">
|
||||
<component
|
||||
:is="'script'"
|
||||
src="https://js.squarecdn.com/square-marketplace.js"
|
||||
async></component>
|
||||
<div>
|
||||
<span
|
||||
class="callout"
|
||||
|
|
@ -15,54 +19,21 @@
|
|||
<span v-else>{{ getInlineAltText(token) }}</span>
|
||||
</span>
|
||||
<span v-else v-html="token"></span>
|
||||
<span class="nbsp"> </span>
|
||||
</span>
|
||||
<textLink
|
||||
linkType="text"
|
||||
:text="bannerCta"
|
||||
href="javascript:void(0)"
|
||||
@click-event="openModal" />
|
||||
<span class="nbsp"> </span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-link"
|
||||
data-afterpay-modal="en_US"
|
||||
@click.prevent="onLearnMoreClick">
|
||||
{{ bannerCta }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<modal
|
||||
:ref="modalName"
|
||||
class="afterpay-modal"
|
||||
:headerText="modalHeaderText"
|
||||
:isFooterButtonSuppressed="true">
|
||||
<template v-slot:modal-header-slot>
|
||||
<span>{{ modalSubHeaderText }}</span>
|
||||
</template>
|
||||
<template v-slot>
|
||||
<span class="afterpay-sections">
|
||||
<span
|
||||
v-for="section in afterpaySectionsContent"
|
||||
:key="section"
|
||||
class="afterpay-section">
|
||||
<span class="section-header">
|
||||
<img v-if="section.AnswerImageUrl" :src="section.AnswerImageUrl" />
|
||||
<h3 v-if="section.Text" v-html="section.Text"></h3>
|
||||
</span>
|
||||
<span v-html="section.content"></span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:modal-footer-slot>
|
||||
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
doesCopyContainTextLink,
|
||||
splitCopyOnCMSPlaceHolder,
|
||||
getRouterLinkRouteFromCopy,
|
||||
getRouterLinkDisplayTextFromCopy,
|
||||
getExternalLink,
|
||||
splitCMSCopyOnBR,
|
||||
} from "@/helpers/cms-content-helper";
|
||||
import textLink from "@/ux-components/text-link/text-link";
|
||||
import modal from "@/digital-components/modal/modal";
|
||||
import { splitCopyOnCMSPlaceHolder, splitCMSCopyOnBR } from "@/helpers/cms-content-helper";
|
||||
|
||||
const INLINE_IMAGE_TOKEN = "custom:inlineImage";
|
||||
const AFTERPAY_PRICE_TOKEN = "custom:afterpayPrice";
|
||||
|
|
@ -82,25 +53,18 @@ export default {
|
|||
name: "afterpay-banner",
|
||||
props: {
|
||||
cmsWidgetName: String,
|
||||
modalWidgetName: String,
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
methods: {
|
||||
isInlineImageToken,
|
||||
isAfterpayPriceToken,
|
||||
getInlineAltText,
|
||||
doesCopyContainTextLink,
|
||||
splitCopyOnCMSPlaceHolder,
|
||||
getRouterLinkRouteFromCopy,
|
||||
getRouterLinkDisplayTextFromCopy,
|
||||
getExternalLink,
|
||||
openModal() {
|
||||
this.modal?.openModal();
|
||||
},
|
||||
closeModal() {
|
||||
this.modal?.closeModal();
|
||||
onLearnMoreClick() {
|
||||
this.pushEventToGA(
|
||||
this.$route.query[this.queryStrings.FMG_PAGE],
|
||||
this.GaActions.CLICKED,
|
||||
this.bannerCta,
|
||||
true
|
||||
);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -119,31 +83,6 @@ export default {
|
|||
bannerCta() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "FooterText");
|
||||
},
|
||||
modalHeaderText() {
|
||||
return this.getCmsContent(this.modalWidgetName, "HeaderText");
|
||||
},
|
||||
modalDisclaimerText() {
|
||||
return this.getCmsContent(this.modalWidgetName, "BodyText2");
|
||||
},
|
||||
modalName() {
|
||||
return this.modalWidgetName;
|
||||
},
|
||||
modal() {
|
||||
return this.$refs[this.modalName];
|
||||
},
|
||||
afterpaySectionsContent() {
|
||||
const sections = this.getCmsContent("AfterpayModalSectionsWidget", "Answers");
|
||||
if (!sections) return [];
|
||||
sections.forEach((section) => {
|
||||
const sectionContent = this.getCmsContent(section.Name, "BodyText");
|
||||
section["content"] = sectionContent;
|
||||
});
|
||||
return sections;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
textLink,
|
||||
modal,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -197,119 +136,10 @@ export default {
|
|||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
padding-left: 1rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.afterpay-modal {
|
||||
&.modal.modal-component {
|
||||
:deep(.modal-dialog) {
|
||||
margin: 0 1rem;
|
||||
top: 0;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
width: 100%;
|
||||
max-width: 840px;
|
||||
top: 3.4rem;
|
||||
right: 0;
|
||||
left: 0;
|
||||
transform: none;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.modal-content {
|
||||
border-radius: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
|
||||
.modal-header.mt-6 {
|
||||
background-color: $blue-100;
|
||||
margin-top: 0;
|
||||
padding: 1.5rem 2rem 1.5rem 1.5rem;
|
||||
font-family: UrbanistSemibold;
|
||||
|
||||
.modal-title.justify-content-center {
|
||||
// OVERRIDE
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.btn-close {
|
||||
top: 1.25rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
}
|
||||
.afterpay-section {
|
||||
display: block;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
h3 {
|
||||
font-family: UrbanistBold;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0 0.5rem;
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
strong {
|
||||
font-family: UrbanistSemibold;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
|
||||
.afterpay-sections {
|
||||
@include media-breakpoint-up(md) {
|
||||
display: flex;
|
||||
column-gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.afterpay-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
flex: 0 1 33%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&.modal-dialog-centered {
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.modal-footer) {
|
||||
position: relative;
|
||||
background: $gray-100;
|
||||
|
||||
.modal-disclaimer {
|
||||
font-style: italic;
|
||||
font-size: 0.75rem;
|
||||
a {
|
||||
padding: 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
img {
|
||||
transform: translateY(-0.1rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
src/layouts/quote/promo-banner/promo-banner.vue
Normal file
59
src/layouts/quote/promo-banner/promo-banner.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div class="promo-banner" role="note">
|
||||
<img class="promo-banner__icon" :src="promoTagIcon" alt="" width="31" height="36" />
|
||||
<p class="promo-banner__copy mb-0" v-html="promoBannerBodyText"></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "promo-banner",
|
||||
props: {
|
||||
cmsWidgetName: String,
|
||||
},
|
||||
computed: {
|
||||
promoTagIcon() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "Image");
|
||||
},
|
||||
promoBannerBodyText() {
|
||||
return this.getCmsContent(this.cmsWidgetName, "BodyText");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.promo-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
background-color: $gray-100;
|
||||
border-radius: 1rem;
|
||||
text-align: left;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
width: 31px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__copy {
|
||||
color: $gray-650;
|
||||
font-size: $font-size-14;
|
||||
line-height: 1.5;
|
||||
|
||||
:deep(strong) {
|
||||
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,6 +5,14 @@
|
|||
<div
|
||||
class="container quote position-relative"
|
||||
:class="[showSaveProgressPopup ? 'initial-popup-open' : '']">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12">
|
||||
<promoBanner
|
||||
v-if="showPromoBanner"
|
||||
cmsWidgetName="PromoBannerWidget"
|
||||
class="mb-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12">
|
||||
<funnelSubHeader class="mb-5" cmsWidgetName="FunnelSubHeaderWidget" />
|
||||
|
|
@ -47,8 +55,7 @@
|
|||
<!-- If Cash AND vehicle does not require recal OR if Cash AND State requires showing recal, then show Afterpay banner -->
|
||||
<afterpayModalBanner
|
||||
v-if="showAfterpayBanner"
|
||||
cmsWidgetName="AfterpayBannerWidget"
|
||||
modalWidgetName="AfterpayModalWidget" />
|
||||
cmsWidgetName="AfterpayBannerWidget" />
|
||||
|
||||
<!-- If vehicle requires recal AND we are hiding recal pricing info, then show recal disclaimer banner. -->
|
||||
<recalDisclaimer
|
||||
|
|
@ -102,7 +109,12 @@
|
|||
<textBlock
|
||||
cmsWidgetName="quoteDisclaimer"
|
||||
typeStyle="caption"
|
||||
class="mb-5 quote-disclaimer text-left text-md-center" />
|
||||
class="quote-disclaimer text-left text-md-center" />
|
||||
<textBlock
|
||||
v-if="showPromoBanner"
|
||||
cmsWidgetName="PromoBannerDisclaimerWidget"
|
||||
typeStyle="caption"
|
||||
class="quote-disclaimer text-left text-md-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -134,6 +146,7 @@ import textBlock from "@/digital-components/text-block/text-block";
|
|||
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
|
||||
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
||||
import afterpayModalBanner from "@/layouts/quote/afterpay-modal-banner/afterpay-modal-banner";
|
||||
import promoBanner from "@/layouts/quote/promo-banner/promo-banner";
|
||||
import recalDisclaimer from "@/layouts/quote/recal-disclaimer/recal-disclaimer.vue";
|
||||
import saveProgressModalQuestion from "@/fmg-components/save-progress-modal-question/save-progress-modal-question";
|
||||
import saveProgressPopupQuestion from "@/fmg-components/save-progress-popup-question/save-progress-popup-question";
|
||||
|
|
@ -684,6 +697,12 @@ export default {
|
|||
showAfterpayBanner() {
|
||||
return !this.isRecalibrationOnOrder || !this.shouldHideRecalibration;
|
||||
},
|
||||
showPromoBanner() {
|
||||
return experimentMixin.methods.hasSettingEqualTo(
|
||||
experimentSettings.SHOW_PROMO_BANNER,
|
||||
"true"
|
||||
);
|
||||
},
|
||||
isRecalPriceRemove() {
|
||||
return (
|
||||
experimentMixin.methods
|
||||
|
|
@ -1041,6 +1060,7 @@ export default {
|
|||
contentGroupModal,
|
||||
loadingModal,
|
||||
afterpayModalBanner,
|
||||
promoBanner,
|
||||
promoModalQuestion,
|
||||
recalDisclaimer,
|
||||
saveProgressModalQuestion,
|
||||
|
|
@ -1166,4 +1186,7 @@ export default {
|
|||
:deep(.promo-modal-question a) {
|
||||
@include responsive-font-size-md(0.875rem, 1rem);
|
||||
}
|
||||
.quote-disclaimer:last-child {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ jest.mock("@/store", () => ({
|
|||
lineItems: { glassParts: [] },
|
||||
policy: { isItac: false, isNoComp: false },
|
||||
},
|
||||
applicationUser: {
|
||||
experiments: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
@address-clicked="onInshopAddressClicked(provider)" />
|
||||
</div>
|
||||
</Transition>
|
||||
<waitlistQuestion
|
||||
class="mt-5"
|
||||
v-model="isWaitlistRequested"
|
||||
:availableDates="availableDates" />
|
||||
<navbar
|
||||
cmsWidgetName="FunnelFooterWidget"
|
||||
ref="navbar"
|
||||
|
|
@ -68,6 +72,7 @@ import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
|||
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
||||
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
|
||||
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
|
||||
import waitlistQuestion from "@/layouts/scheduling/waitlist-question/waitlist-question";
|
||||
import store from "@/store";
|
||||
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||
|
|
@ -315,6 +320,7 @@ export default {
|
|||
mobileProviderAndTimeSlot: null,
|
||||
mobilePremiumAppointmentFee: null,
|
||||
selectedScheduling: null,
|
||||
isWaitlistRequested: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -442,6 +448,7 @@ export default {
|
|||
mobileSchedulingCard,
|
||||
inshopSchedulingCard,
|
||||
interceptOverlay,
|
||||
waitlistQuestion,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import { mount } from "@vue/test-utils";
|
||||
import waitlistQuestion from "./waitlist-question";
|
||||
import store from "@/store";
|
||||
import { experimentSettings } from "@/constants/experiments";
|
||||
|
||||
jest.mock("@/store", () => ({
|
||||
getters: {
|
||||
applicationUser: {
|
||||
experiments: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MOCK_CMS_CONTENT = {
|
||||
WaitListLabelWidget: { Text: "Want to be notified sooner?" },
|
||||
WaitListQuestionWidget: { QuestionText: "Add me to the waitlist" },
|
||||
};
|
||||
|
||||
function dateStringOffsetFromToday(offsetDays) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offsetDays);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function enableWaitlistExperiment(thresholdDays = 0) {
|
||||
store.getters.applicationUser.experiments = [
|
||||
{
|
||||
isActive: true,
|
||||
settings: {
|
||||
[experimentSettings.DISPLAY_WAITLIST]: "true",
|
||||
[experimentSettings.WAITLIST_THRESHOLD_DAYS]: String(thresholdDays),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
const cmsMixin = {
|
||||
methods: {
|
||||
getCmsContent: jest.fn((widgetName, fieldName) => {
|
||||
return MOCK_CMS_CONTENT[widgetName]?.[fieldName] ?? "";
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return mount(waitlistQuestion, {
|
||||
props: {
|
||||
modelValue: false,
|
||||
availableDates: [dateStringOffsetFromToday(10)],
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
mixins: [cmsMixin],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("waitlist-question.vue", () => {
|
||||
beforeEach(() => {
|
||||
enableWaitlistExperiment();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.getters.applicationUser.experiments = [];
|
||||
});
|
||||
|
||||
it("renders the label and checkbox CMS content", () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain("Want to be notified sooner?");
|
||||
expect(wrapper.text()).toContain("Add me to the waitlist");
|
||||
});
|
||||
|
||||
it("reflects the modelValue prop on the checkbox", () => {
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
expect(wrapper.find("input[type='checkbox']").element.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("emits update:modelValue with true when the checkbox is checked", async () => {
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
|
||||
const input = wrapper.find("input[type='checkbox']");
|
||||
await input.setValue(true);
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[true]]);
|
||||
});
|
||||
|
||||
it("emits update:modelValue with false when the checkbox is unchecked", async () => {
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
const input = wrapper.find("input[type='checkbox']");
|
||||
await input.setValue(false);
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it("renders nothing when shouldDisplay is false", () => {
|
||||
store.getters.applicationUser.experiments = [];
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find("input[type='checkbox']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe("clicking the container", () => {
|
||||
it("toggles localValue to true when clicking outside the checkbox", async () => {
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
|
||||
await wrapper.find(".waitlist-label").trigger("click");
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[true]]);
|
||||
});
|
||||
|
||||
it("toggles localValue to false when clicking outside the checkbox", async () => {
|
||||
const wrapper = mountComponent({ modelValue: true });
|
||||
|
||||
await wrapper.find(".waitlist-question").trigger("click");
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it("does not toggle when the click target is the checkbox input itself", () => {
|
||||
// jsdom doesn't reliably run a checkbox's native activation behavior
|
||||
// (toggling + firing "change") for script-dispatched clicks, so this
|
||||
// calls the handler directly with the real input element as the
|
||||
// event target to verify the guard is skipped in that case.
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
const inputElement = wrapper.find("input[type='checkbox']").element;
|
||||
|
||||
wrapper.vm.handleContainerClick({ target: inputElement });
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not toggle when the click lands inside the checkbox wrapper but not on the input", async () => {
|
||||
const wrapper = mountComponent({ modelValue: false });
|
||||
|
||||
await wrapper.find(".ui-checkbox").trigger("click");
|
||||
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDisplay", () => {
|
||||
it("is false when the DISPLAY_WAITLIST experiment is off", () => {
|
||||
store.getters.applicationUser.experiments = [
|
||||
{
|
||||
isActive: true,
|
||||
settings: { [experimentSettings.WAITLIST_THRESHOLD_DAYS]: "3" },
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mountComponent({ availableDates: [dateStringOffsetFromToday(10)] });
|
||||
|
||||
expect(wrapper.vm.shouldDisplay).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the earliest available date is within the threshold", () => {
|
||||
enableWaitlistExperiment(3);
|
||||
|
||||
const wrapper = mountComponent({ availableDates: [dateStringOffsetFromToday(2)] });
|
||||
|
||||
expect(wrapper.vm.shouldDisplay).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the experiment is on and the earliest available date exceeds the threshold", () => {
|
||||
enableWaitlistExperiment(3);
|
||||
|
||||
const wrapper = mountComponent({ availableDates: [dateStringOffsetFromToday(10)] });
|
||||
|
||||
expect(wrapper.vm.shouldDisplay).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when there are no available dates", () => {
|
||||
enableWaitlistExperiment(0);
|
||||
|
||||
const wrapper = mountComponent({ availableDates: [] });
|
||||
|
||||
expect(wrapper.vm.shouldDisplay).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
src/layouts/scheduling/waitlist-question/waitlist-question.vue
Normal file
146
src/layouts/scheduling/waitlist-question/waitlist-question.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<template v-if="shouldDisplay">
|
||||
<div
|
||||
class="waitlist-question bg-light rounded"
|
||||
v-bind="$attrs"
|
||||
@click="handleContainerClick">
|
||||
<textBlock
|
||||
cmsWidgetName="WaitListLabelWidget"
|
||||
typeStyle="medium"
|
||||
fontWeight="600"
|
||||
class="waitlist-label"
|
||||
marginTopSizeOverride="0" />
|
||||
<div ref="checkboxWrapper">
|
||||
<checkboxQuestion
|
||||
class="mt-3"
|
||||
cmsWidgetName="WaitListQuestionWidget"
|
||||
v-model="localValue" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="localValue" class="rounded waitlist-success">
|
||||
<img :src="waitListSuccessImage" class="success-image" alt="" />
|
||||
<span v-html="waitListSuccessText" class="success-text"></span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import textBlock from "@/digital-components/text-block/text-block";
|
||||
import checkboxQuestion from "@/digital-components/checkbox-question/checkbox-question";
|
||||
import experimentMixin from "@/mixins/experiment-mixin";
|
||||
import { experimentSettings } from "@/constants/experiments";
|
||||
import {
|
||||
calcDaysBetweenDates,
|
||||
convertDateToDateString,
|
||||
} from "@/layouts/schedule/helpers/schedule-helper.js";
|
||||
|
||||
export default {
|
||||
name: "waitlistQuestion",
|
||||
mixins: [experimentMixin],
|
||||
emits: ["update:modelValue"],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
availableDates: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
localValue: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
waitListSuccessImage() {
|
||||
return this.getCmsContent("WaitListSuccessWidget", "Image");
|
||||
},
|
||||
waitListSuccessText() {
|
||||
return this.getCmsContent("WaitListSuccessWidget", "BodyText");
|
||||
},
|
||||
waitlistThresholdDays() {
|
||||
return this.hasSetting(experimentSettings.WAITLIST_THRESHOLD_DAYS)
|
||||
? parseInt(this.getSettingValue(experimentSettings.WAITLIST_THRESHOLD_DAYS), 10)
|
||||
: 0;
|
||||
},
|
||||
daysUntilEarliestAvailableDate() {
|
||||
const earliestDateString = this.availableDates?.[0];
|
||||
if (!earliestDateString) return null;
|
||||
|
||||
const todayString = convertDateToDateString(new Date());
|
||||
return calcDaysBetweenDates(todayString, earliestDateString);
|
||||
},
|
||||
shouldDisplay() {
|
||||
return (
|
||||
this.hasSettingEqualTo(experimentSettings.DISPLAY_WAITLIST, "true") &&
|
||||
this.daysUntilEarliestAvailableDate !== null &&
|
||||
this.waitlistThresholdDays < this.daysUntilEarliestAvailableDate
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleContainerClick(event) {
|
||||
// The checkbox's own label/input already toggles localValue natively,
|
||||
// so ignore clicks that originate inside it to avoid double-toggling.
|
||||
// Checking against our own wrapper element (rather than an internal
|
||||
// class name owned by checkboxQuestion) keeps this decoupled from
|
||||
// that component's markup.
|
||||
if (this.$refs.checkboxWrapper?.contains(event.target)) return;
|
||||
this.localValue = !this.localValue;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
textBlock,
|
||||
checkboxQuestion,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.waitlist-question {
|
||||
box-shadow: 0 1px 5px 0 #00000033;
|
||||
padding: 16px;
|
||||
|
||||
.waitlist-label {
|
||||
font-family: UrbanistSemibold;
|
||||
}
|
||||
|
||||
:deep(.ui-checkbox) {
|
||||
padding-left: 0px;
|
||||
input {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 5px 0 #00000033;
|
||||
}
|
||||
}
|
||||
:deep(.form-check-input) {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
.waitlist-success {
|
||||
margin: 1rem 0 0;
|
||||
background-color: #ecf5e9;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #0c7e47;
|
||||
border-radius: 5px;
|
||||
font-size: 1.125rem;
|
||||
padding: 0.75rem 1rem;
|
||||
.success-text {
|
||||
margin-left: 0.5rem;
|
||||
:deep(p) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.success-image {
|
||||
margin-top: 4.5px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #0c7e47;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -168,6 +168,23 @@ describe("vin-lookup.vue", () => {
|
|||
expect(wrapper.vm.navigateForward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Should keep the continue button loader spinning for vinRequired vehicles when VIN lookup fails", async () => {
|
||||
// Arrange
|
||||
store.getters.vehicle.vinRequired = true;
|
||||
const { wrapper } = setupMocks({});
|
||||
mockOutPromises({ vehicleLookupFailed: true });
|
||||
wrapper.vm.continueWithFailedVin = jest.fn().mockResolvedValue();
|
||||
wrapper.setData({ vin: "1HGCM82633A123456", vinPopulatedOnPageLoad: false });
|
||||
|
||||
// Act
|
||||
await wrapper.vm.forwardButtonAction();
|
||||
|
||||
// Assert
|
||||
expect(wrapper.vm.continueWithFailedVin).toHaveBeenCalled();
|
||||
expect(wrapper.vm.$refs.navbar.removeLoader).not.toHaveBeenCalled();
|
||||
store.getters.vehicle.vinRequired = false;
|
||||
});
|
||||
|
||||
describe("navigateForward", () => {
|
||||
test("carId is different from returned vehicle and selected glass isn't available => continue with different glass", async () => {
|
||||
// Arrange
|
||||
|
|
@ -394,11 +411,14 @@ function setupMocks({ customMountOptions }) {
|
|||
return { wrapper };
|
||||
}
|
||||
|
||||
function mockOutPromises({ carId, isZipValid = true, isZipServiceable = true }) {
|
||||
function mockOutPromises({
|
||||
carId,
|
||||
isZipValid = true,
|
||||
isZipServiceable = true,
|
||||
vehicleLookupFailed = false,
|
||||
}) {
|
||||
const apiResponses = {
|
||||
vehicleLookupResponse: {
|
||||
carId: carId,
|
||||
},
|
||||
vehicleLookupResponse: vehicleLookupFailed ? undefined : { carId: carId },
|
||||
zipCodeData: {
|
||||
isValid: isZipValid,
|
||||
isServiceable: isZipServiceable,
|
||||
|
|
|
|||
|
|
@ -295,7 +295,14 @@ export default {
|
|||
const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
|
||||
if (isVinLookupRequired) {
|
||||
this.requiredVinNotFound = true;
|
||||
this.continueWithFailedVin(this.vin, resultMap.zipCodeData);
|
||||
|
||||
// Check if Service Zip entered is serviceable, if not display an alert
|
||||
if (!resultMap.zipCodeData.isServiceable) {
|
||||
this.displayNonServiceableZipAlert = true;
|
||||
}
|
||||
|
||||
await this.continueWithFailedVin(this.vin, resultMap.zipCodeData);
|
||||
return;
|
||||
} else {
|
||||
this.displayVinNotFoundAlert = true;
|
||||
}
|
||||
|
|
@ -521,7 +528,7 @@ export default {
|
|||
},
|
||||
false
|
||||
);
|
||||
this.navigateForwardWithSingleCarMatch();
|
||||
await this.navigateForwardWithSingleCarMatch();
|
||||
},
|
||||
getRequiredVinNotFound() {
|
||||
// Prevents success alert from showing
|
||||
|
|
|
|||
|
|
@ -7,9 +7,17 @@ import baseMixin from "@/mixins/base-mixin.js";
|
|||
import store from "@/store";
|
||||
import { getIsWindshieldOnly } from "@/helpers/damage-helper";
|
||||
import { partTypeStrings } from "@/constants/part-type-strings";
|
||||
import { buildQuestionChainAnswerValue } from "@/helpers/question-chain-helper";
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
clearQuestionsAnswerData() {
|
||||
this.questionsData?.forEach((glass) => {
|
||||
if (glass.answerData) {
|
||||
glass.answerData = {};
|
||||
}
|
||||
});
|
||||
},
|
||||
hasPartQuestions(partsOrQuestions) {
|
||||
return partsOrQuestions?.some((pq) => pq.partQuestions?.length > 0);
|
||||
},
|
||||
|
|
@ -114,11 +122,12 @@ export default {
|
|||
);
|
||||
});
|
||||
// set the answerString to use for answerSelected
|
||||
if (chosenAns.nextQuestionSequence) {
|
||||
answerString = `${answeredQuestion.questionNum}|nextQuestion|${chosenAns.nextQuestionSequence}|${chosenAns.answerText}`;
|
||||
} else {
|
||||
answerString = `${answeredQuestion.questionNum}|answer|${chosenAns.answerResult}|${chosenAns.answerText}`;
|
||||
}
|
||||
answerString = buildQuestionChainAnswerValue({
|
||||
questionSequence: answeredQuestion.questionNum,
|
||||
nextQuestionSequence: chosenAns.nextQuestionSequence,
|
||||
answerResult: chosenAns.answerResult,
|
||||
answerText: chosenAns.answerText,
|
||||
});
|
||||
|
||||
// mark this question as answered (question-chain will read this)
|
||||
glass.questions[answeredQuestion.questionNum - 1].answerSelected =
|
||||
|
|
@ -177,6 +186,8 @@ export default {
|
|||
*/
|
||||
|
||||
const self = vm ?? this;
|
||||
const isIncomplete = !!answer.incomplete;
|
||||
const hadSavedAnswer = !!self.questionsData[answer.index]?.answerData?.answerResult;
|
||||
|
||||
// clear out any preloaded answers
|
||||
self.selectedAnswers = {};
|
||||
|
|
@ -193,7 +204,6 @@ export default {
|
|||
*/
|
||||
|
||||
const answeredQuestionText = answeredQuestion.questionText.toUpperCase();
|
||||
const answeredQuestionAnswer = answeredQuestion.selectedAnswer.toUpperCase();
|
||||
const answeredQuestionAnswerText =
|
||||
answeredQuestion.selectedAnswerText.toUpperCase();
|
||||
const answeredQuestionNum = answeredQuestion.questionNum;
|
||||
|
|
@ -208,8 +218,7 @@ export default {
|
|||
if (answeredQuestionIndex === 0) question.answerSelected = null;
|
||||
|
||||
if (answeredQuestionNum - 1 === questionIndex) {
|
||||
// on the right question
|
||||
question.answerSelected = answeredQuestionAnswer;
|
||||
question.answerSelected = answeredQuestion.selectedAnswer;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -346,6 +355,27 @@ export default {
|
|||
});
|
||||
});
|
||||
|
||||
if (isIncomplete) {
|
||||
// User changed an earlier answer; downstream questions were cleared in the chain
|
||||
self.questionsData[answer.index].answerData = null;
|
||||
self.currentGlassIndex = answer.index;
|
||||
|
||||
if (hadSavedAnswer) {
|
||||
self.questionsData[answer.index].key =
|
||||
(self.questionsData[answer.index].key ??
|
||||
self.questionsData[answer.index].answerKey ??
|
||||
answer.index) + Date.now().toString();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Force re-render so the updated selection displays when changing a prior answer
|
||||
self.questionsData[answer.index].key =
|
||||
(self.questionsData[answer.index].key ??
|
||||
self.questionsData[answer.index].answerKey ??
|
||||
answer.index) + Date.now().toString();
|
||||
|
||||
// set final answer data for the current answered glass part
|
||||
self.questionsData[answer.index].answerData = {
|
||||
answerResult: answer.answerResult,
|
||||
|
|
|
|||
|
|
@ -499,6 +499,88 @@ describe("vehicle-questions-mixin", () => {
|
|||
// Assert
|
||||
expect(wrapper.vm.selectedAnswers).toMatchObject({});
|
||||
});
|
||||
|
||||
test("should clear answerData and keep currentGlassIndex when answer is incomplete", () => {
|
||||
const answer = {
|
||||
incomplete: true,
|
||||
answeredQuestions: [
|
||||
{
|
||||
questionText: "Is this the Overland edition?",
|
||||
selectedAnswer: "1|nextQuestion|5|No",
|
||||
selectedAnswerText: "No",
|
||||
questionNum: 1,
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
};
|
||||
const { wrapper } = setupMocks({});
|
||||
|
||||
wrapper.vm.currentGlassIndex = 1;
|
||||
wrapper.vm.questionsData = [
|
||||
{
|
||||
glassLocation: "Windshield",
|
||||
glassName: "Single",
|
||||
questions: [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText: "Is this the Overland edition?",
|
||||
answers: [],
|
||||
},
|
||||
],
|
||||
answerData: { answerResult: "OLD-PART" },
|
||||
answerKey: "Windshield-Single",
|
||||
},
|
||||
{
|
||||
glassLocation: "Driver",
|
||||
glassName: "Front",
|
||||
questions: [],
|
||||
answerData: { answerResult: "OTHER-PART" },
|
||||
},
|
||||
];
|
||||
|
||||
wrapper.vm.handleCompletedQuestionChainAnswers(answer, "", wrapper.vm);
|
||||
|
||||
expect(wrapper.vm.questionsData[0].answerData).toBeNull();
|
||||
expect(wrapper.vm.questionsData[1].answerData).toBeNull();
|
||||
expect(wrapper.vm.currentGlassIndex).toBe(0);
|
||||
});
|
||||
|
||||
test("should not bump key when incomplete answer had no saved answer", () => {
|
||||
const answer = {
|
||||
incomplete: true,
|
||||
answeredQuestions: [
|
||||
{
|
||||
questionText: "Is this the Overland edition?",
|
||||
selectedAnswer: "1|nextQuestion|2|Yes",
|
||||
selectedAnswerText: "Yes",
|
||||
questionNum: 1,
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
};
|
||||
const { wrapper } = setupMocks({});
|
||||
|
||||
wrapper.vm.questionsData = [
|
||||
{
|
||||
glassLocation: "Windshield",
|
||||
glassName: "Single",
|
||||
questions: [
|
||||
{
|
||||
questionSequence: 1,
|
||||
questionText: "Is this the Overland edition?",
|
||||
answers: [],
|
||||
},
|
||||
],
|
||||
answerData: null,
|
||||
answerKey: "Windshield-Single",
|
||||
key: "stable-key",
|
||||
},
|
||||
];
|
||||
|
||||
wrapper.vm.handleCompletedQuestionChainAnswers(answer, "", wrapper.vm);
|
||||
|
||||
expect(wrapper.vm.questionsData[0].key).toBe("stable-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("questions in glass parts that are after the answered glass", () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
|
|||
import { experimentSettings } from "@/constants/experiments";
|
||||
import { vinLookupMethodSelections } from "@/constants/vin-lookup-method-selections.js";
|
||||
import { bailoutCodes } from "@/constants/bailout-codes";
|
||||
import experimentMixin from "@/mixins/experiment-mixin.js";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
|
|
@ -37,7 +38,7 @@ export default {
|
|||
pageNameToLog: pageName,
|
||||
});
|
||||
|
||||
if (result.PartNotFound) {
|
||||
if (result.PartNotFound || this.bailoutVinRequiredVehicles()) {
|
||||
return bailoutMixin.methods.navigateToBailoutPage(
|
||||
this,
|
||||
bailoutCodes.PART_NOT_FOUND
|
||||
|
|
@ -75,5 +76,15 @@ export default {
|
|||
const phoneRegex = /^\(?(\d{3})\)?[-. ]?(\d{3})[-. ]?(\d{4})$/;
|
||||
return phoneRegex.test(input);
|
||||
},
|
||||
bailoutVinRequiredVehicles() {
|
||||
var isVinRequiredVehicle = store.getters.order.vehicle?.vinRequired;
|
||||
if (!isVinRequiredVehicle) {
|
||||
return false;
|
||||
}
|
||||
return experimentMixin.methods.hasSettingEqualTo(
|
||||
experimentSettings.BAILOUT_VIN_REQUIRED_VEHICLES,
|
||||
"true"
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ const getDefaultState = () => {
|
|||
phoneNumber: null,
|
||||
phoneExtension: null,
|
||||
isSmsOptIn: null,
|
||||
isSmsMarketingOptIn: null,
|
||||
waitListRequested: null,
|
||||
address: {
|
||||
streetAddress: null,
|
||||
|
|
@ -499,6 +500,9 @@ export const mutations = {
|
|||
updateCustomerIsSmsOptin(state, isSmsOptIn) {
|
||||
state.order.customer.isSmsOptIn = isSmsOptIn;
|
||||
},
|
||||
updateCustomerIsSmsMarketingOptin(state, isSmsMarketingOptIn) {
|
||||
state.order.customer.isSmsMarketingOptIn = isSmsMarketingOptIn;
|
||||
},
|
||||
updateCustomerWaitListRequested(state, waitListRequested) {
|
||||
state.order.customer.waitListRequested = waitListRequested;
|
||||
},
|
||||
|
|
@ -575,6 +579,9 @@ export const mutations = {
|
|||
state.order.customer.emailAddress = customerDetails.emailAddress;
|
||||
state.order.customer.phoneNumber = customerDetails.phoneNumber;
|
||||
state.order.customer.isSmsOptIn = customerDetails.isSmsOptIn;
|
||||
if (customerDetails.isSmsMarketingOptIn !== undefined) {
|
||||
state.order.customer.isSmsMarketingOptIn = customerDetails.isSmsMarketingOptIn;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateInsuranceDetails(state, insuranceDetails) {
|
||||
|
|
@ -972,6 +979,8 @@ export const mutations = {
|
|||
state.order.customer.phoneNumber = sessionInformation.order.customer.homePhone;
|
||||
state.order.customer.phoneExtension = sessionInformation.order.customer.phoneExt;
|
||||
state.order.customer.isSmsOptIn = sessionInformation.order.customer.isSmsOptIn;
|
||||
state.order.customer.isSmsMarketingOptIn =
|
||||
sessionInformation.order.customer.isSmsMarketingOptIn;
|
||||
|
||||
if (sessionInformation.order.customer.address) {
|
||||
state.order.customer.address = Object.assign(state.order.customer.address, {
|
||||
|
|
@ -2830,6 +2839,7 @@ export const actions = {
|
|||
firstName: order.customer.firstName,
|
||||
lastName: order.customer.lastName,
|
||||
isSmsOptIn: order.customer.isSmsOptIn,
|
||||
isSmsMarketingOptIn: order.customer.isSmsMarketingOptIn,
|
||||
phoneNumber: order.customer.phoneNumber,
|
||||
phoneExt: order.customer.phoneExtension,
|
||||
address: {
|
||||
|
|
@ -3882,6 +3892,9 @@ export const actions = {
|
|||
saveIsSmsOptIn(context, isSmsOptIn) {
|
||||
context.commit(storeMutations.UPDATE_CUSTOMER_IS_SMS_OPT_IN, isSmsOptIn);
|
||||
},
|
||||
saveIsSmsMarketingOptIn(context, isSmsMarketingOptIn) {
|
||||
context.commit(storeMutations.UPDATE_CUSTOMER_IS_SMS_MARKETING_OPT_IN, isSmsMarketingOptIn);
|
||||
},
|
||||
|
||||
saveWaitListRequested(context, waitListRequested) {
|
||||
context.commit(storeMutations.UPDATE_CUSTOMER_WAITLIST_REQUESTED, waitListRequested);
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ describe("Mutations", () => {
|
|||
emailAddress: "foo@bar.com",
|
||||
phoneNumber: "555-555-5555",
|
||||
isSmsOptIn: true,
|
||||
isSmsMarketingOptIn: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -365,6 +366,24 @@ describe("Mutations", () => {
|
|||
expect(state.order.customer.emailAddress).toEqual("foo@bar.com");
|
||||
expect(state.order.customer.phoneNumber).toEqual("555-555-5555");
|
||||
expect(state.order.customer.isSmsOptIn).toEqual(true);
|
||||
expect(state.order.customer.isSmsMarketingOptIn).toEqual(false);
|
||||
});
|
||||
|
||||
it("updateCustomerDetails, should preserve marketing sms consent when not provided", () => {
|
||||
const storeState = state;
|
||||
storeState.order.customer.isSmsOptIn = true;
|
||||
storeState.order.customer.isSmsMarketingOptIn = true;
|
||||
|
||||
mutations.updateCustomerDetails(storeState, {
|
||||
firstName: "foo",
|
||||
lastName: "bar",
|
||||
emailAddress: "foo@bar.com",
|
||||
phoneNumber: "555-555-5555",
|
||||
isSmsOptIn: false,
|
||||
});
|
||||
|
||||
expect(state.order.customer.isSmsOptIn).toEqual(false);
|
||||
expect(state.order.customer.isSmsMarketingOptIn).toEqual(true);
|
||||
});
|
||||
|
||||
it("incrementSubmittedStateRevision, should increment submittedStateRevision", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue