From e37a3178a3aedc8bd829fba824607a514ad16b40 Mon Sep 17 00:00:00 2001 From: Minojhini Valaiyapathi Date: Thu, 16 Jul 2026 10:25:06 -0400 Subject: [PATCH 01/25] CASH-3034- part-questions/Capability questions/Moulding questions UI fixes and question-chain helper --- .../question-chain/question-chain.spec.js | 213 +++++++++++++++++- .../question-chain/question-chain.vue | 140 ++++++++---- .../questions-page-layout.spec.js | 35 +++ .../questions-page-layout.vue | 24 +- src/helpers/question-chain-helper.js | 49 ++++ src/helpers/question-chain-helper.spec.js | 74 ++++++ .../capability-questions.spec.js | 4 +- .../capability-questions.vue | 7 +- .../molding-questions.spec.js | 4 +- .../molding-questions/molding-questions.vue | 7 +- .../part-questions/part-questions.spec.js | 4 +- src/layouts/part-questions/part-questions.vue | 9 +- src/mixins/vehicle-questions-mixin.js | 46 +++- src/mixins/vehicle-questions-mixin.spec.js | 82 +++++++ 14 files changed, 606 insertions(+), 92 deletions(-) create mode 100644 src/helpers/question-chain-helper.js create mode 100644 src/helpers/question-chain-helper.spec.js diff --git a/src/digital-components/question-chain/question-chain.spec.js b/src/digital-components/question-chain/question-chain.spec.js index 7435feefd..1bdc43d4a 100644 --- a/src/digital-components/question-chain/question-chain.spec.js +++ b/src/digital-components/question-chain/question-chain.spec.js @@ -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,87 @@ 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 +403,127 @@ 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); + }); }); }); +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 +549,7 @@ function setupMocks({ const mountOptions = getMountOptions({}); mountOptions.propsData = { questionData: questionDataProp, + index: 0, }; mountOptions["attachTo"] = document.body; diff --git a/src/digital-components/question-chain/question-chain.vue b/src/digital-components/question-chain/question-chain.vue index b1960eb51..b37755ee7 100644 --- a/src/digital-components/question-chain/question-chain.vue +++ b/src/digital-components/question-chain/question-chain.vue @@ -2,9 +2,9 @@
import buttonQuestion from "@/digital-components/button-question/button-question"; import { useValidateForm } from "vee-validate"; +import { + mapQuestionAnswersForChain, + normalizeAnswerSelectedValue, +} from "@/helpers/question-chain-helper"; export default { name: "questionChain", @@ -34,54 +38,81 @@ 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 pendingNextQuestion = [...this.questions] + .reverse() + .find((question) => question.answerSelected?.includes("|nextQuestion|")); + + if (pendingNextQuestion) { + const nextQuestionSequence = Number( + pendingNextQuestion.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 +131,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 +158,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 +191,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 diff --git a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js index 96e5434cd..ba96232db 100644 --- a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js +++ b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js @@ -150,6 +150,41 @@ 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...", () => { diff --git a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue index c9f9989e2..3152f5b14 100644 --- a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue +++ b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.vue @@ -10,7 +10,9 @@
-
+
- -
+ + 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"); diff --git a/src/helpers/question-chain-helper.js b/src/helpers/question-chain-helper.js new file mode 100644 index 000000000..b5ba8c168 --- /dev/null +++ b/src/helpers/question-chain-helper.js @@ -0,0 +1,49 @@ +/** + * 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 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; +} diff --git a/src/helpers/question-chain-helper.spec.js b/src/helpers/question-chain-helper.spec.js new file mode 100644 index 000000000..fa8db3714 --- /dev/null +++ b/src/helpers/question-chain-helper.spec.js @@ -0,0 +1,74 @@ +import { + buildQuestionChainAnswerValue, + 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("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", + }); + }); + }); +}); diff --git a/src/layouts/capability-questions/capability-questions.spec.js b/src/layouts/capability-questions/capability-questions.spec.js index fb4515f50..271a2e34c 100644 --- a/src/layouts/capability-questions/capability-questions.spec.js +++ b/src/layouts/capability-questions/capability-questions.spec.js @@ -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({}); diff --git a/src/layouts/capability-questions/capability-questions.vue b/src/layouts/capability-questions/capability-questions.vue index c4f5df92c..6b9f5aaa1 100644 --- a/src/layouts/capability-questions/capability-questions.vue +++ b/src/layouts/capability-questions/capability-questions.vue @@ -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: { diff --git a/src/layouts/molding-questions/molding-questions.spec.js b/src/layouts/molding-questions/molding-questions.spec.js index 8daa6e780..bb668b62c 100644 --- a/src/layouts/molding-questions/molding-questions.spec.js +++ b/src/layouts/molding-questions/molding-questions.spec.js @@ -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({}); diff --git a/src/layouts/molding-questions/molding-questions.vue b/src/layouts/molding-questions/molding-questions.vue index a7f4900de..1e1cf1cb4 100644 --- a/src/layouts/molding-questions/molding-questions.vue +++ b/src/layouts/molding-questions/molding-questions.vue @@ -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: { diff --git a/src/layouts/part-questions/part-questions.spec.js b/src/layouts/part-questions/part-questions.spec.js index 4e7cb53dd..527f5dd74 100644 --- a/src/layouts/part-questions/part-questions.spec.js +++ b/src/layouts/part-questions/part-questions.spec.js @@ -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({}); diff --git a/src/layouts/part-questions/part-questions.vue b/src/layouts/part-questions/part-questions.vue index 1c342543f..f6c3c7667 100644 --- a/src/layouts/part-questions/part-questions.vue +++ b/src/layouts/part-questions/part-questions.vue @@ -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: { diff --git a/src/mixins/vehicle-questions-mixin.js b/src/mixins/vehicle-questions-mixin.js index 1636f0c47..265d54fa2 100644 --- a/src/mixins/vehicle-questions-mixin.js +++ b/src/mixins/vehicle-questions-mixin.js @@ -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, diff --git a/src/mixins/vehicle-questions-mixin.spec.js b/src/mixins/vehicle-questions-mixin.spec.js index 0fd027258..a557adefa 100644 --- a/src/mixins/vehicle-questions-mixin.spec.js +++ b/src/mixins/vehicle-questions-mixin.spec.js @@ -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", () => { From bb02e45d66c02d270c7fec3ea311dafd8aff9f93 Mon Sep 17 00:00:00 2001 From: Minojhini Valaiyapathi Date: Thu, 16 Jul 2026 11:00:41 -0400 Subject: [PATCH 02/25] CASH-3034 - prettier check --- .../question-chain/question-chain.spec.js | 28 +++++++++++++++---- .../questions-page-layout.spec.js | 4 ++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/digital-components/question-chain/question-chain.spec.js b/src/digital-components/question-chain/question-chain.spec.js index 1bdc43d4a..1dad2d5de 100644 --- a/src/digital-components/question-chain/question-chain.spec.js +++ b/src/digital-components/question-chain/question-chain.spec.js @@ -266,8 +266,16 @@ describe("Question Chain component", () => { questionSequence: 3, questionText: "Laminated door glass?", answers: [ - { answerResult: "PART-C", answerText: "Yes", nextQuestionSequence: null }, - { answerResult: "PART-D", answerText: "No", nextQuestionSequence: null }, + { + answerResult: "PART-C", + answerText: "Yes", + nextQuestionSequence: null, + }, + { + answerResult: "PART-D", + answerText: "No", + nextQuestionSequence: null, + }, ], answerSelected: "3|answer|PART-C|Yes", }, @@ -275,8 +283,16 @@ describe("Question Chain component", () => { questionSequence: 5, questionText: "Factory installed antenna?", answers: [ - { answerResult: "PART-E", answerText: "Yes", nextQuestionSequence: null }, - { answerResult: "PART-F", answerText: "No", nextQuestionSequence: null }, + { + answerResult: "PART-E", + answerText: "Yes", + nextQuestionSequence: null, + }, + { + answerResult: "PART-F", + answerText: "No", + nextQuestionSequence: null, + }, ], }, ], @@ -451,7 +467,9 @@ describe("Question Chain component", () => { ); expect( wrapper.vm.questions.filter((question) => wrapper.vm.isQuestionVisible(question)) - ).not.toEqual(expect.arrayContaining([expect.objectContaining({ questionSequence: 2 })])); + ).not.toEqual( + expect.arrayContaining([expect.objectContaining({ questionSequence: 2 })]) + ); }); test("should resume at question 3 after remount when Q1 No was already saved", async () => { diff --git a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js index ba96232db..db34bd262 100644 --- a/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js +++ b/src/fmg-components/layouts/questions-page-layout/questions-page-layout.spec.js @@ -177,7 +177,9 @@ describe("questionsPageLayout.vue", () => { await wrapper.setProps({ index: 1 }); const testGlassPiece = { - questions: [{ questionSequence: 1, questionText: "Rain sensing wipers?", answers: [] }], + questions: [ + { questionSequence: 1, questionText: "Rain sensing wipers?", answers: [] }, + ], answerData: null, }; From 9ce35eb3c18fcda6797c97c0797f4742b254d76e Mon Sep 17 00:00:00 2001 From: credelinghuys Date: Thu, 16 Jul 2026 14:12:30 -0400 Subject: [PATCH 03/25] CASH-3031: Update afterpay learn more link --- .../afterpay-modal-banner.vue | 211 ++---------------- src/layouts/quote/quote.vue | 3 +- 2 files changed, 22 insertions(+), 192 deletions(-) diff --git a/src/layouts/quote/afterpay-modal-banner/afterpay-modal-banner.vue b/src/layouts/quote/afterpay-modal-banner/afterpay-modal-banner.vue index cc708dc6a..764d5c9c0 100644 --- a/src/layouts/quote/afterpay-modal-banner/afterpay-modal-banner.vue +++ b/src/layouts/quote/afterpay-modal-banner/afterpay-modal-banner.vue @@ -1,5 +1,9 @@ @@ -198,117 +137,9 @@ export default { @include media-breakpoint-up(md) { padding-left: 1rem; } - } -} -.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); } } } diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index 50512bcbf..920aa8617 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -47,8 +47,7 @@ + cmsWidgetName="AfterpayBannerWidget" /> Date: Fri, 17 Jul 2026 14:10:44 -0400 Subject: [PATCH 04/25] CASH-3049 | Add promo banner to quote page --- src/constants/experiments.js | 3 + .../quote/promo-banner/promo-banner.vue | 59 +++++++++++++++++++ src/layouts/quote/quote.vue | 26 +++++++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/layouts/quote/promo-banner/promo-banner.vue diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 0caec2080..e30984163 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -61,6 +61,9 @@ const experimentSettings = { // YMM BAILOUT_VIN_REQUIRED_VEHICLES: "BailoutVINRequiredVehicles", + + // Promo Banner + SHOW_PROMO_BANNER: "ShowQuotePageRegionalPromoBanner", }; const experimentTriggers = { diff --git a/src/layouts/quote/promo-banner/promo-banner.vue b/src/layouts/quote/promo-banner/promo-banner.vue new file mode 100644 index 000000000..f26a42593 --- /dev/null +++ b/src/layouts/quote/promo-banner/promo-banner.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/layouts/quote/quote.vue b/src/layouts/quote/quote.vue index cad68ef0f..42e3845ab 100644 --- a/src/layouts/quote/quote.vue +++ b/src/layouts/quote/quote.vue @@ -5,6 +5,14 @@
+
+
+ +
+
@@ -102,7 +110,12 @@ + class="quote-disclaimer text-left text-md-center" /> +
@@ -133,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"; @@ -683,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 @@ -1034,6 +1054,7 @@ export default { contentGroupModal, loadingModal, afterpayModalBanner, + promoBanner, promoModalQuestion, recalDisclaimer, saveProgressModalQuestion, @@ -1159,4 +1180,7 @@ export default { :deep(.promo-modal-question a) { @include responsive-font-size-md(0.875rem, 1rem); } +.quote-disclaimer:last-child { + margin-bottom: 1.5rem; +} From 96c288a1ca70c22917a2dc6e0b93a792a86b4576 Mon Sep 17 00:00:00 2001 From: credelinghuys Date: Mon, 20 Jul 2026 13:09:33 -0400 Subject: [PATCH 05/25] CASH-1911: Add scrollbar and scroll to error message for short viewports --- .../save-progress-popup-question.spec.js | 24 +++++++++++++++++++ .../save-progress-popup-question.vue | 20 ++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js index 273f39f11..29d102d01 100644 --- a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.spec.js @@ -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: { diff --git a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue index 172f81718..fc4e95530 100644 --- a/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue +++ b/src/fmg-components/save-progress-popup-question/save-progress-popup-question.vue @@ -43,6 +43,7 @@ isRequired validationRules="phone-number-required" /> Date: Tue, 21 Jul 2026 15:04:24 -0400 Subject: [PATCH 06/25] CASH-2961: Pass PIA authorization amount --- src/constants/query-strings.js | 1 + src/constants/store-actions.js | 1 + src/constants/store-mutations.js | 1 + src/helpers/pricing-helper.js | 33 +++++++ src/helpers/pricing-helper.spec.js | 39 ++++++++ src/layouts/payment-adyen/payment-adyen.vue | 26 +++-- .../payment-pia-return/payment-pia-return.vue | 33 +++---- .../methods/route-logic/adyen-return.js | 12 ++- src/store/index.js | 11 +++ src/store/store.spec.js | 99 +++++++++++++++++++ 10 files changed, 228 insertions(+), 28 deletions(-) diff --git a/src/constants/query-strings.js b/src/constants/query-strings.js index 1857643be..034e0a6ef 100644 --- a/src/constants/query-strings.js +++ b/src/constants/query-strings.js @@ -48,6 +48,7 @@ const queryStrings = { FROM_HERITAGE: "fromheritage", PHONE_NUMBER: "phonenumber", OFFER_QUOTE: "offerquote", + PIA_AUTH_AMOUNT_ADJUST: "piaauthamountadjust", }; export { queryStrings }; diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js index 742e1c461..00875bb6f 100644 --- a/src/constants/store-actions.js +++ b/src/constants/store-actions.js @@ -118,6 +118,7 @@ const storeActions = { SAVE_CCTOKEN: "saveCCToken", SAVE_PAYPAL_TOKEN: "savePaypalToken", SAVE_NEXTGEN_SETTLED_AMOUNT: "saveNextGenSettledAmount", + SAVE_AUTHORIZATION_AMOUNT: "saveAuthorizationAmount", SAVE_IS_RECAL_ACK_OPT_IN: "saveIsRecalAckOptIn", SAVE_IS_RECAL_ACKNOWLEDGED_FOR_SCHEDULING: "saveIsRecalAcknowledgedForScheduling", SAVE_IS_OEM_GLASS_SELECTED: "saveIsOemGlassSelected", diff --git a/src/constants/store-mutations.js b/src/constants/store-mutations.js index 8437a395e..d60bca6ea 100644 --- a/src/constants/store-mutations.js +++ b/src/constants/store-mutations.js @@ -3,6 +3,7 @@ const storeMutations = { UPDATE_CCTOKEN: "updateCCToken", UPDATE_PAYPAL_TOKEN: "updatePaypalToken", UPDATE_NEXTGEN_SETTLED_AMOUNT: "updateNextGenSettledAmount", + UPDATE_AUTHORIZATION_AMOUNT: "updateAuthorizationAmount", // VEHICLE MUTATIONS UPDATE_YEAR: "updateYear", diff --git a/src/helpers/pricing-helper.js b/src/helpers/pricing-helper.js index fff87d82f..aab72b97e 100644 --- a/src/helpers/pricing-helper.js +++ b/src/helpers/pricing-helper.js @@ -3,6 +3,10 @@ import { storeActions } from "@/constants/store-actions"; import baseMixin from "@/mixins/base-mixin.js"; import { deepClone } from "@/helpers/object-helper"; import { partNumberStrings } from "@/constants/part-number-strings"; +import { applicationConfig } from "@/constants/application-config"; +import { queryStrings } from "@/constants/query-strings"; +import { getQuerystringParameter } from "@/helpers/querystring-helper"; +import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash"; export function getDisplayAmountDue(lineItemsObject, includeTax = true) { return getAmountDue(lineItemsObject, includeTax).toLocaleString("en-US", { @@ -56,6 +60,35 @@ export function getAmountDue(lineItemsObject, includeTax = true) { return ((amountDue * 100) / 100).toFixed(2); } +function getPiaAuthAmountAdjustment() { + if (applicationConfig.CURRENT_ENVIRONMENT === "Prod") { + return 0; + } + + const adjustParam = + peekQueryFromStash(queryStrings.PIA_AUTH_AMOUNT_ADJUST) ?? + getQuerystringParameter(queryStrings.PIA_AUTH_AMOUNT_ADJUST); + + if (adjustParam == null || adjustParam === "") { + return 0; + } + + const adjustment = parseFloat(adjustParam); + return Number.isFinite(adjustment) ? adjustment : 0; +} + +export function getPiaAuthorizationAmount(lineItemsObject) { + const amountDue = parseFloat(getAmountDue(lineItemsObject)); + const adjustment = getPiaAuthAmountAdjustment(); + + if (adjustment === 0) { + return amountDue.toFixed(2); + } + + const adjustedAmount = Math.max(0.01, amountDue + adjustment); + return adjustedAmount.toFixed(2); +} + export function getSubTotal(lineItemsObject) { // this is amountDue without sales tax return getAmountDue(lineItemsObject, false); diff --git a/src/helpers/pricing-helper.spec.js b/src/helpers/pricing-helper.spec.js index 8a49bb5ae..2d136fbe4 100644 --- a/src/helpers/pricing-helper.spec.js +++ b/src/helpers/pricing-helper.spec.js @@ -1,9 +1,22 @@ import { getDisplayAmountDue, getAmountDue, + getPiaAuthorizationAmount, getSubTotal, getSalesTax, } from "@/helpers/pricing-helper.js"; +import { applicationConfig } from "@/constants/application-config"; + +jest.mock("@/helpers/querystring-helper", () => ({ + getQuerystringParameter: jest.fn(), +})); + +jest.mock("@/router/methods/helpers/querystring-stash", () => ({ + peekQueryFromStash: jest.fn(), +})); + +import { getQuerystringParameter } from "@/helpers/querystring-helper"; +import { peekQueryFromStash } from "@/router/methods/helpers/querystring-stash"; const lineItems = { glassParts: [], @@ -61,4 +74,30 @@ describe("pricing-helper", () => { expect(result).toBe("3.00"); }); }); + + describe("getPiaAuthorizationAmount", () => { + beforeEach(() => { + jest.clearAllMocks(); + peekQueryFromStash.mockReturnValue(null); + getQuerystringParameter.mockReturnValue(null); + applicationConfig.CURRENT_ENVIRONMENT = "QA"; + }); + + it("returns amount due when no adjustment is configured", () => { + expect(getPiaAuthorizationAmount(lineItems)).toBe("53.00"); + }); + + it("ignores adjustment in production", () => { + applicationConfig.CURRENT_ENVIRONMENT = "Prod"; + peekQueryFromStash.mockReturnValue("-5"); + + expect(getPiaAuthorizationAmount(lineItems)).toBe("53.00"); + }); + + it("applies non-production query string adjustment", () => { + peekQueryFromStash.mockReturnValue("-5"); + + expect(getPiaAuthorizationAmount(lineItems)).toBe("48.00"); + }); + }); }); diff --git a/src/layouts/payment-adyen/payment-adyen.vue b/src/layouts/payment-adyen/payment-adyen.vue index fc3ad668d..f8f67449c 100644 --- a/src/layouts/payment-adyen/payment-adyen.vue +++ b/src/layouts/payment-adyen/payment-adyen.vue @@ -72,7 +72,7 @@ import { endpoints } from "../../constants/endpoints"; import { AppointmentTypeStrings } from "../../constants/schedule-constants"; import baseMixin from "@/mixins/base-mixin.js"; import { storeActions } from "@/constants/store-actions"; -import { getAmountDue } from "@/helpers/pricing-helper.js"; +import { getAmountDue, getPiaAuthorizationAmount } from "@/helpers/pricing-helper.js"; import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js"; import { createAdyenCheckout } from "@/helpers/adyen-helper"; import { Dropin } from "@adyen/adyen-web/auto"; @@ -364,14 +364,22 @@ export default { ); } - await baseMixin.methods.dispatchStoreAction( - storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, - this.amountDue, - false - ); + await this.savePiaPaymentAmounts(); await this.saveAndSubmitWorkOrder(); }, + async savePiaPaymentAmounts() { + await baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, + this.piaAuthorizationAmount, + false + ); + await baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_AUTHORIZATION_AMOUNT, + this.piaAuthorizationAmount, + false + ); + }, async handleFailedPayment(result) { console.log(`Result =`); console.log(result); @@ -555,8 +563,12 @@ export default { return getAmountDue(this.$store.getters.order.lineItems); }, + piaAuthorizationAmount() { + return getPiaAuthorizationAmount(this.$store.getters.order.lineItems); + }, + adyenPriceTotal() { - return Math.round(this.amountDue * 100); + return Math.round(parseFloat(this.piaAuthorizationAmount) * 100); }, // Cart info diff --git a/src/layouts/payment-pia-return/payment-pia-return.vue b/src/layouts/payment-pia-return/payment-pia-return.vue index c905f3d78..0bc7d3e22 100644 --- a/src/layouts/payment-pia-return/payment-pia-return.vue +++ b/src/layouts/payment-pia-return/payment-pia-return.vue @@ -14,12 +14,7 @@ import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue"; import { Form } from "vee-validate"; import { paymentMethods } from "@/constants/payment-method-constants"; import baseMixin from "@/mixins/base-mixin.js"; -import { - getDisplayAmountDue, - getAmountDue, - getSubTotal, - getSalesTax, -} from "@/helpers/pricing-helper.js"; +import { getPiaAuthorizationAmount } from "@/helpers/pricing-helper.js"; // iframeResizer IS loaded into the page and necessary for the package to // to auto scale the iFrame this page is loaded in // Do not remove despite showing as "unused" CASH-309 @@ -103,11 +98,7 @@ export default { const payerId = getQuerystringParameter(queryStrings.PAYERID); baseMixin.methods.dispatchStoreAction(storeActions.SAVE_PAYPAL_TOKEN, token, false); - baseMixin.methods.dispatchStoreAction( - storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, - this.getAmountDue(), - false - ); + this.savePiaPaymentAmounts(); await this.saveAndSubmitWorkOrder(); }, @@ -160,16 +151,22 @@ export default { console.log(ccToken); baseMixin.methods.dispatchStoreAction(storeActions.SAVE_CCTOKEN, ccToken, false); - baseMixin.methods.dispatchStoreAction( - storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, - this.getAmountDue(), - false - ); + this.savePiaPaymentAmounts(); await this.saveAndSubmitWorkOrder(); } }, - getAmountDue() { - return getAmountDue(store.getters.order.lineItems); + savePiaPaymentAmounts() { + const authorizationAmount = getPiaAuthorizationAmount(store.getters.order.lineItems); + baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, + authorizationAmount, + false + ); + baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_AUTHORIZATION_AMOUNT, + authorizationAmount, + false + ); }, async saveAndSubmitWorkOrder() { // Final work order submit after returning from PIA. diff --git a/src/router/methods/route-logic/adyen-return.js b/src/router/methods/route-logic/adyen-return.js index f15168861..9a287b243 100644 --- a/src/router/methods/route-logic/adyen-return.js +++ b/src/router/methods/route-logic/adyen-return.js @@ -2,7 +2,7 @@ import { routeData } from "@/router/constants/routes"; import { createAdyenCheckout, getSessionInfo } from "@/helpers/adyen-helper"; import { mapAdyenToFmgPaymentMethod, generateCcToken } from "@/helpers/adyen-helper"; import baseMixin from "@/mixins/base-mixin.js"; -import { getAmountDue } from "@/helpers/pricing-helper"; +import { getPiaAuthorizationAmount } from "@/helpers/pricing-helper"; import store from "@/store"; import { storeActions } from "@/constants/store-actions"; import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js"; @@ -49,7 +49,7 @@ export async function adyenReturnBeforeEnter(to, from) { // TODO once afterpay info is returned, create cc token and submit order. const paymentMethod = mapAdyenToFmgPaymentMethod(sessionInfo?.paymentMethod); - const amountDue = getAmountDue(store.getters.order.lineItems); + const authorizationAmount = getPiaAuthorizationAmount(store.getters.order.lineItems); const ccToken = generateCcToken(sessionInfo); ccToken.authCode = "831001"; @@ -68,7 +68,13 @@ export async function adyenReturnBeforeEnter(to, from) { await baseMixin.methods.dispatchStoreAction( storeActions.SAVE_NEXTGEN_SETTLED_AMOUNT, - amountDue, + authorizationAmount, + false + ); + + await baseMixin.methods.dispatchStoreAction( + storeActions.SAVE_AUTHORIZATION_AMOUNT, + authorizationAmount, false ); diff --git a/src/store/index.js b/src/store/index.js index d9ca5c6fe..a34307ba9 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -162,6 +162,7 @@ const getDefaultState = () => { inactivePromos: null, paypalToken: null, nextGenSettledAmount: 0, + authorizationAmount: null, ccToken: { subscriptionId: null, expMonth: null, @@ -488,6 +489,9 @@ export const mutations = { updateNextGenSettledAmount(state, nextGenSettledAmount) { state.order.payment.nextGenSettledAmount = nextGenSettledAmount; }, + updateAuthorizationAmount(state, authorizationAmount) { + state.order.payment.authorizationAmount = authorizationAmount; + }, updateInsuranceVerifiedStatus(state, isVerified) { state.order.payment.insuranceCoverage.isVerified = isVerified; }, @@ -2893,6 +2897,9 @@ export const actions = { order.payment.piaType == paymentMethods.APPLE_PAY ? true : false, paypalToken: order.payment.paypalToken, nextGenSettledAmount: order.payment.nextGenSettledAmount, + ...(order.payment.authorizationAmount > 0 + ? { authorizationAmount: order.payment.authorizationAmount } + : {}), ccToken: { subscriptionId: order.payment.ccToken.subscriptionId, expMonth: order.payment.ccToken.expMonth, @@ -3047,6 +3054,10 @@ export const actions = { context.commit(storeMutations.UPDATE_NEXTGEN_SETTLED_AMOUNT, nextGenSettledAmount); }, + saveAuthorizationAmount(context, authorizationAmount) { + context.commit(storeMutations.UPDATE_AUTHORIZATION_AMOUNT, authorizationAmount); + }, + savePaypalToken(context, ppToken) { context.commit(storeMutations.UPDATE_PAYPAL_TOKEN, ppToken); }, diff --git a/src/store/store.spec.js b/src/store/store.spec.js index 74cda6c9f..72eaac142 100644 --- a/src/store/store.spec.js +++ b/src/store/store.spec.js @@ -920,6 +920,105 @@ describe("Actions", () => { ); }); + it("saveSession action, includes authorizationAmount when PIA was authorized", async () => { + const context = createAuthorizationAmountSaveSessionContext(312.45); + + globalMethods.callHttpClient.mockImplementation(() => { + return Promise.resolve({ data: { referralNumber: 123 } }); + }); + + await actions.saveSession(context, { pageNameToLog: "test" }); + + expect(globalMethods.callHttpClient).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + order: expect.objectContaining({ + payment: expect.objectContaining({ + authorizationAmount: 312.45, + }), + }), + }), + }) + ); + }); + + it("saveSession action, omits authorizationAmount when PIA was not authorized", async () => { + const context = createAuthorizationAmountSaveSessionContext(null); + + globalMethods.callHttpClient.mockImplementation(() => { + return Promise.resolve({ data: { referralNumber: 123 } }); + }); + globalMethods.callHttpClient.mockClear(); + + await actions.saveSession(context, { pageNameToLog: "test" }); + + const callPayload = globalMethods.callHttpClient.mock.calls[0][0].payload; + expect(callPayload.order.payment.authorizationAmount).toBeUndefined(); + }); + + function createAuthorizationAmountSaveSessionContext(authorizationAmount) { + const context = state; + const damage = { + numberOfChips: "2", + partQuestionAnswers: {}, + moldingQuestionAnswers: {}, + capabilityQuestionAnswers: {}, + }; + + context.getters = { + vehicle: { registration: {} }, + order: { damage }, + damage: {}, + applicationUser: { + lastPageVisited: "test-page", + crmCustomerId: "xxx-xxx-xxx", + savedSessionId: "xxx-xxx-xxx", + }, + }; + context.state = { + order: { + damage, + payment: { + insuranceCoverage: { isVerified: false }, + isInsurance: false, + authorizationAmount, + ccToken: { + subscriptionId: authorizationAmount ? "sub-123" : null, + expMonth: null, + expYear: null, + cardType: null, + billToPostalCode: null, + billToFirstName: null, + billToLastName: null, + referenceNumber: null, + authCode: authorizationAmount ? "831001" : null, + transactionId: null, + transReferenceNumber: null, + lastFour: null, + }, + }, + serviceLocation: {}, + customer: { + emailAddress: "test@safelite.com", + firstName: "John", + lastName: "Doe", + isSmsOptIn: true, + phoneNumber: "1234567890", + address: { + streetAddress: "123 Main St", + streetAddress2: "Apt 1", + city: "Anytown", + state: "OH", + zipCode: "12345", + }, + }, + lineItems: {}, + }, + }; + + return context; + } + it("loadSession action, returns order information, calls mutation", async () => { // Arrange const context = state; From a51e2eac6574a76118fc1acf95e8f3a344bfb5c0 Mon Sep 17 00:00:00 2001 From: Minojhini Valaiyapathi Date: Tue, 21 Jul 2026 15:16:23 -0400 Subject: [PATCH 07/25] CASH-3065 - Pass billToAccountNumber to v2 parts endpoint --- src/store/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/store/index.js b/src/store/index.js index d9ca5c6fe..ea09dfb84 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2015,6 +2015,7 @@ export const actions = { const vin = vehicle.vin; const serviceType = damage.isRepair ? "Repair" : "Install"; const parentAccountNumber = context.getters.payment.parentAccountNumber; + const billToAccountNumber = context.getters.payment.billToAccountNumber; const referralSeqNumber = order.referralSequenceNumber; // create a new array to avoid mutating state @@ -2036,6 +2037,7 @@ export const actions = { serviceType: serviceType, referralSeqNumber: referralSeqNumber, parentAccountNumber: parentAccountNumber, + billToAccountNumber: billToAccountNumber, }, logApiCall: true, pageNameToLog: pageNameToLog, From cc8448752e7da99e9e37b8c621bd9afc8f219c0e Mon Sep 17 00:00:00 2001 From: mvalaiyapathi Date: Wed, 22 Jul 2026 09:11:16 -0400 Subject: [PATCH 08/25] Revert "CASH-3065 - Pass billToAccountNumber to v2 parts endpoint" --- src/store/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/store/index.js b/src/store/index.js index ea09dfb84..d9ca5c6fe 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2015,7 +2015,6 @@ export const actions = { const vin = vehicle.vin; const serviceType = damage.isRepair ? "Repair" : "Install"; const parentAccountNumber = context.getters.payment.parentAccountNumber; - const billToAccountNumber = context.getters.payment.billToAccountNumber; const referralSeqNumber = order.referralSequenceNumber; // create a new array to avoid mutating state @@ -2037,7 +2036,6 @@ export const actions = { serviceType: serviceType, referralSeqNumber: referralSeqNumber, parentAccountNumber: parentAccountNumber, - billToAccountNumber: billToAccountNumber, }, logApiCall: true, pageNameToLog: pageNameToLog, From adb149f22e01f94a095db46f511500b69340b4b0 Mon Sep 17 00:00:00 2001 From: credelinghuys Date: Wed, 22 Jul 2026 11:35:07 -0400 Subject: [PATCH 09/25] CASH-3052: Group VAPS in adyen cart --- src/layouts/payment-adyen/payment-adyen.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/layouts/payment-adyen/payment-adyen.vue b/src/layouts/payment-adyen/payment-adyen.vue index fc3ad668d..213d21cb1 100644 --- a/src/layouts/payment-adyen/payment-adyen.vue +++ b/src/layouts/payment-adyen/payment-adyen.vue @@ -24,7 +24,7 @@ Date: Thu, 23 Jul 2026 10:43:30 -0400 Subject: [PATCH 10/25] CASH-3047: Add ServicePackage description --- src/constants/experiments.js | 2 + .../service-package-radio-for-afterpay.vue | 68 ++++++++----- .../service-package-radio.vue | 73 +++++++++----- src/helpers/service-package-helper.js | 25 +++-- src/helpers/service-package-helper.spec.js | 95 +++++++++++++------ .../service-package-question.spec.js | 46 +++++++++ .../service-package-question.vue | 23 +++-- .../service-package-radio.vue | 70 +++++++++----- 8 files changed, 290 insertions(+), 112 deletions(-) diff --git a/src/constants/experiments.js b/src/constants/experiments.js index 4fab998af..8df02597c 100644 --- a/src/constants/experiments.js +++ b/src/constants/experiments.js @@ -58,6 +58,8 @@ const experimentSettings = { TIER_ONE: "TierOne", TIER_TWO: "TierTwo", TIER_THREE: "TierThree", + TIER_TWO_DESCRIPTION: "TierTwo_Description", + TIER_THREE_DESCRIPTION: "TierThree_Description", // Consent Management SHOW_CONSENT_MANAGEMENT: "ShowConsentManagement", diff --git a/src/experiment-components/service-package-radio-for-afterpay.vue b/src/experiment-components/service-package-radio-for-afterpay.vue index 7f7e91084..1e896b3f7 100644 --- a/src/experiment-components/service-package-radio-for-afterpay.vue +++ b/src/experiment-components/service-package-radio-for-afterpay.vue @@ -4,6 +4,7 @@ class="package-label" :class="[ this.buttonLabelSubCopy ? 'has-subheader' : '', + this.additionalButtonData?.packageDescription ? 'has-description' : '', this.additionalButtonData.isInsuranceSelected ? 'is-insurance' : '', this.hasPackageDiscount() ? 'has-package-discount' : '', ]" @@ -13,6 +14,10 @@

+

.package-specs { display: inline-flex; } } //Package container height with package discount AND "most popular" label - &.has-package-discount.has-subheader { + &.has-package-discount.has-subheader, + &.has-package-discount.has-description { min-height: 170px; } + &.has-package-discount.has-subheader.has-description { + min-height: 190px; + } //Package container height with package discount only &.has-package-discount { min-height: 136px; @@ -307,30 +318,28 @@ export default { &:checked { + .package-label { + max-height: 1000px; + border: 1px solid $blue; + .package-specs { - max-height: 1000px; - } - } - + .package-label { - .package-specs { + max-height: none; + .hide-when-closed { - display: block; + max-height: none; + overflow: visible; + opacity: 1; + transition: opacity 0.2s ease 0.15s; } } - } - + .package-label { - border: 1px solid $blue; - max-height: 500px; + .special-save-box { background-color: $green-100; } - } - + .package-label { + &:before { box-shadow: 0px 0px 0px 1px $blue; } - } - + .package-label { + &:after { background: $blue; } @@ -356,8 +365,9 @@ export default { flex-direction: column; width: 100%; transition: all 0.5s ease; + @include media-breakpoint-up(md) { - max-height: 500px; + max-height: none; } .row { @@ -378,6 +388,13 @@ export default { font-size: 0.75rem; margin-left: auto; } + &.package-description { + color: $gray-550; + font-size: 0.875rem; + font-weight: 400; + font-style: italic; + white-space: normal; + } } span { &.service-package-discount { @@ -441,10 +458,17 @@ export default { } .hide-when-closed { - display: none; + display: flex; + flex-direction: column; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: opacity 0.15s ease; + @include media-breakpoint-up(md) { - display: flex; - flex-direction: column; + max-height: none; + opacity: 1; + overflow: visible; } } } diff --git a/src/experiment-components/service-package-radio.vue b/src/experiment-components/service-package-radio.vue index a116fecd8..763035ff0 100644 --- a/src/experiment-components/service-package-radio.vue +++ b/src/experiment-components/service-package-radio.vue @@ -4,6 +4,7 @@ class="package-label pricing-by-day-pkg-lbl" :class="[ this.buttonLabelSubCopy ? 'has-subheader' : '', + this.additionalButtonData?.packageDescription ? 'has-description' : '', !this.additionalButtonData.hasDiscount ? 'adjust-top' : '', ]" for="testradio"> @@ -13,6 +14,10 @@

+

+

.package-specs { + display: inline-flex; + } } &:before { @@ -288,12 +302,6 @@ export default { height: 10px; min-width: 10px; } - - &.has-subheader { - & > .package-specs { - display: inline-flex; - } - } } &:hover { @@ -309,30 +317,28 @@ export default { &:checked { + .package-label { + max-height: 1000px; + border: 1px solid $blue; + .package-specs { - max-height: 1000px; - } - } - + .package-label { - .package-specs { + max-height: none; + .hide-when-closed { - display: block; + max-height: none; + overflow: visible; + opacity: 1; + transition: opacity 0.2s ease 0.15s; } } - } - + .package-label { - border: 1px solid $blue; - max-height: 500px; + .special-save-box { background-color: $green-200; } - } - + .package-label { + &:before { box-shadow: 0px 0px 0px 1px $blue; } - } - + .package-label { + &:after { background: $blue; } @@ -358,8 +364,9 @@ export default { flex-direction: column; width: 100%; transition: all 0.5s ease; + @include media-breakpoint-up(md) { - max-height: 500px; + max-height: none; } span.pricing-by-day { @@ -398,6 +405,13 @@ export default { text-transform: uppercase; font-size: 0.75rem; } + &.package-description { + color: $gray-550; + font-size: 0.875rem; + font-weight: 400; + font-style: italic; + white-space: normal; + } } span { &.service-package-discount-pricing-by-day { @@ -458,10 +472,17 @@ export default { } .hide-when-closed { - display: none; + display: flex; + flex-direction: column; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: opacity 0.15s ease; + @include media-breakpoint-up(md) { - display: flex; - flex-direction: column; + max-height: none; + opacity: 1; + overflow: visible; } } } diff --git a/src/helpers/service-package-helper.js b/src/helpers/service-package-helper.js index f43680dd2..b790385fb 100644 --- a/src/helpers/service-package-helper.js +++ b/src/helpers/service-package-helper.js @@ -16,6 +16,11 @@ const tierExperimentSettingKeys = { [packageNames.TIER_THREE]: experimentSettings.TIER_THREE, }; +const tierDescriptionExperimentSettingKeys = { + [packageNames.TIER_TWO]: experimentSettings.TIER_TWO_DESCRIPTION, + [packageNames.TIER_THREE]: experimentSettings.TIER_THREE_DESCRIPTION, +}; + export function containsLineItemWithPartType(typeToFind, itemsToSearch) { const partTypeMatches = findLineItemsWithPartType(typeToFind, itemsToSearch); return !!partTypeMatches?.length; @@ -284,16 +289,24 @@ export function isServicePackageNameTestActive(experimentSettingsMap) { } export function getExperimentPackageDisplayName(tierName, experimentSettingsMap) { + return getExperimentPackageSetting(tierName, tierExperimentSettingKeys, experimentSettingsMap); +} + +export function getExperimentPackageDescription(tierName, experimentSettingsMap) { + return getExperimentPackageSetting( + tierName, + tierDescriptionExperimentSettingKeys, + experimentSettingsMap + ); +} + +function getExperimentPackageSetting(tierName, settingKeys, experimentSettingsMap) { if (!isServicePackageNameTestActive(experimentSettingsMap)) { return null; } - const settingKey = tierExperimentSettingKeys[tierName]; - if (!settingKey) { - return null; - } - - return experimentHelper.getSettingValue(settingKey, experimentSettingsMap); + const settingKey = settingKeys[tierName]; + return settingKey ? experimentHelper.getSettingValue(settingKey, experimentSettingsMap) : null; } function maxTier(tierA, tierB) { diff --git a/src/helpers/service-package-helper.spec.js b/src/helpers/service-package-helper.spec.js index 495436c8d..2e4f12e76 100644 --- a/src/helpers/service-package-helper.spec.js +++ b/src/helpers/service-package-helper.spec.js @@ -969,34 +969,29 @@ describe("service-package-helper.js", () => { describe("getExperimentPackageDisplayName", () => { const { experimentSettings } = require("@/constants/experiments"); + const activeExperimentSettingsMap = { + [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "true", + [experimentSettings.TIER_ONE]: "Essential", + [experimentSettings.TIER_TWO]: "Plus", + [experimentSettings.TIER_THREE]: "Full Service", + [experimentSettings.TIER_TWO_DESCRIPTION]: "Most popular choice", + [experimentSettings.TIER_THREE_DESCRIPTION]: "Best value", + }; it("Returns null when the experiment is not active", () => { - const result = servicePackageHelper.getExperimentPackageDisplayName( - packageNames.TIER_ONE, - {} - ); - - expect(result).toBeNull(); + expect( + servicePackageHelper.getExperimentPackageDisplayName(packageNames.TIER_ONE, {}) + ).toBeNull(); + expect( + servicePackageHelper.getExperimentPackageDescription(packageNames.TIER_TWO, {}) + ).toBeNull(); }); it("Returns null when the test flag is not true", () => { - const result = servicePackageHelper.getExperimentPackageDisplayName( - packageNames.TIER_ONE, - { - [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "false", - [experimentSettings.TIER_ONE]: "Essential", - } - ); - - expect(result).toBeNull(); - }); - - it("Returns the experiment display name for each tier when active", () => { const experimentSettingsMap = { - [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "true", + [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "false", [experimentSettings.TIER_ONE]: "Essential", - [experimentSettings.TIER_TWO]: "Plus", - [experimentSettings.TIER_THREE]: "Full Service", + [experimentSettings.TIER_TWO_DESCRIPTION]: "Most popular choice", }; expect( @@ -1004,28 +999,70 @@ describe("service-package-helper.js", () => { packageNames.TIER_ONE, experimentSettingsMap ) + ).toBeNull(); + expect( + servicePackageHelper.getExperimentPackageDescription( + packageNames.TIER_TWO, + experimentSettingsMap + ) + ).toBeNull(); + }); + + it("Returns the experiment display name for each tier when active", () => { + expect( + servicePackageHelper.getExperimentPackageDisplayName( + packageNames.TIER_ONE, + activeExperimentSettingsMap + ) ).toBe("Essential"); expect( servicePackageHelper.getExperimentPackageDisplayName( packageNames.TIER_TWO, - experimentSettingsMap + activeExperimentSettingsMap ) ).toBe("Plus"); expect( servicePackageHelper.getExperimentPackageDisplayName( packageNames.TIER_THREE, - experimentSettingsMap + activeExperimentSettingsMap ) ).toBe("Full Service"); }); - it("Returns null for an unknown tier", () => { - const result = servicePackageHelper.getExperimentPackageDisplayName("UnknownTier", { - [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "true", - [experimentSettings.TIER_ONE]: "Essential", - }); + it("Returns the experiment description for tier two and three when active", () => { + expect( + servicePackageHelper.getExperimentPackageDescription( + packageNames.TIER_TWO, + activeExperimentSettingsMap + ) + ).toBe("Most popular choice"); + expect( + servicePackageHelper.getExperimentPackageDescription( + packageNames.TIER_THREE, + activeExperimentSettingsMap + ) + ).toBe("Best value"); + }); - expect(result).toBeNull(); + it("Returns null for unknown tiers and tiers without configured settings", () => { + expect( + servicePackageHelper.getExperimentPackageDisplayName("UnknownTier", { + [experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST]: "true", + [experimentSettings.TIER_ONE]: "Essential", + }) + ).toBeNull(); + expect( + servicePackageHelper.getExperimentPackageDescription( + packageNames.TIER_ONE, + activeExperimentSettingsMap + ) + ).toBeNull(); + expect( + servicePackageHelper.getExperimentPackageDescription( + "UnknownTier", + activeExperimentSettingsMap + ) + ).toBeNull(); }); }); }); diff --git a/src/layouts/quote/service-package-question/service-package-question.spec.js b/src/layouts/quote/service-package-question/service-package-question.spec.js index 1d0f78c1a..46b084d02 100644 --- a/src/layouts/quote/service-package-question/service-package-question.spec.js +++ b/src/layouts/quote/service-package-question/service-package-question.spec.js @@ -856,6 +856,52 @@ describe("service-package-question.vue, matching business rules for package disp getExperimentPackageLabelSpy.mockRestore(); }); + it("should use experiment package descriptions when ServicePackageNameTest is active", () => { + const getExperimentPackageDescriptionSpy = jest + .spyOn(servicePackageQuestion.methods, "getExperimentPackageDescription") + .mockImplementation((tierName) => { + const experimentPackageDescriptions = { + [packageNames.TIER_TWO]: "Most popular choice", + [packageNames.TIER_THREE]: "Best value", + }; + + return experimentPackageDescriptions[tierName] ?? null; + }); + + const packageNameKey = "05_01_CSR_Quote_Standard_Repair"; + Object.assign(processedCmsContent, mockProcessedCmsContent[packageNameKey]); + mockProps.availableLineItems.push(driverFrontWiperLineItem); + mockProps.availableLineItems.push(passengerFrontWiperLineItem); + const wrapper = setupMocks({ + mountOptionsMockData: { + store: { + getters: { + order: { + damage: { + isRepair: true, + glassToReplace: [], + }, + }, + hasAnyNonWindshieldGlassParts: false, + }, + }, + }, + }); + + expect( + wrapper.vm.servicePackageAnswers[0].additionalButtonData.packageDescription + ).toBeNull(); + expect(wrapper.vm.servicePackageAnswers[1].additionalButtonData.packageDescription).toBe( + "Most popular choice" + ); + expect(wrapper.vm.servicePackageAnswers[2].additionalButtonData.packageDescription).toBe( + "Best value" + ); + expect(wrapper.vm.servicePackageAnswers[1].buttonLabelSubCopy).toBe("MOST POPULAR"); + expect(wrapper.vm.servicePackageAnswers[2].buttonLabelSubCopy).toBe(""); + + getExperimentPackageDescriptionSpy.mockRestore(); + }); it("should use CMS package names when ServicePackageNameTest is not active", () => { const packageNameKey = "05_01_CSR_Quote_Standard_Repair"; Object.assign(processedCmsContent, mockProcessedCmsContent[packageNameKey]); diff --git a/src/layouts/quote/service-package-question/service-package-question.vue b/src/layouts/quote/service-package-question/service-package-question.vue index 4a54ee3f5..905898481 100644 --- a/src/layouts/quote/service-package-question/service-package-question.vue +++ b/src/layouts/quote/service-package-question/service-package-question.vue @@ -159,6 +159,7 @@ export default { ? false : "Special: Save $" + this.getDiscountPrice(answer.Name), isInsuranceSelected: this.isInsuranceSelected, + packageDescription: this.getExperimentPackageDescription(answer.Name), }, })); @@ -265,6 +266,19 @@ export default { ); }, getExperimentPackageLabel(tierName) { + return this.getExperimentPackageSetting(tierName, { + [packageNames.TIER_ONE]: experimentSettings.TIER_ONE, + [packageNames.TIER_TWO]: experimentSettings.TIER_TWO, + [packageNames.TIER_THREE]: experimentSettings.TIER_THREE, + }); + }, + getExperimentPackageDescription(tierName) { + return this.getExperimentPackageSetting(tierName, { + [packageNames.TIER_TWO]: experimentSettings.TIER_TWO_DESCRIPTION, + [packageNames.TIER_THREE]: experimentSettings.TIER_THREE_DESCRIPTION, + }); + }, + getExperimentPackageSetting(tierName, tierSettingKeys) { if ( !experimentMixin.methods.hasSettingEqualTo( experimentSettings.SHOW_SERVICE_PACKAGE_NAME_TEST, @@ -274,13 +288,8 @@ export default { return null; } - const tierSettingKey = { - [packageNames.TIER_ONE]: experimentSettings.TIER_ONE, - [packageNames.TIER_TWO]: experimentSettings.TIER_TWO, - [packageNames.TIER_THREE]: experimentSettings.TIER_THREE, - }[tierName]; - - return tierSettingKey ? experimentMixin.methods.getSettingValue(tierSettingKey) : null; + const settingKey = tierSettingKeys[tierName]; + return settingKey ? experimentMixin.methods.getSettingValue(settingKey) : null; }, getHeaderTextFromCms(cmsWidgetName) { return this.getCmsContent(cmsWidgetName, "HeaderText"); diff --git a/src/layouts/quote/service-package-question/service-package-radio/service-package-radio.vue b/src/layouts/quote/service-package-question/service-package-radio/service-package-radio.vue index 959c7345e..bae95af8f 100644 --- a/src/layouts/quote/service-package-question/service-package-radio/service-package-radio.vue +++ b/src/layouts/quote/service-package-question/service-package-radio/service-package-radio.vue @@ -2,7 +2,10 @@

@@ -10,6 +13,10 @@

+

+

Date: Fri, 24 Jul 2026 07:45:26 -0400 Subject: [PATCH 11/25] CASH-3075 add validation for phone extenstion CASH-3075 add validation for phone extenstion --- src/constants/error-messages.js | 1 + src/layouts/policy-info/policy-info.vue | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js index 58f22ebe2..262979ed4 100644 --- a/src/constants/error-messages.js +++ b/src/constants/error-messages.js @@ -32,6 +32,7 @@ const errorMessages = { DATE_REQUIRED: "Please select a date", PHONE_REQUIRED: "Please enter your phone number", PHONE_FORMAT: "Phone number must be 10 digits", + 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", diff --git a/src/layouts/policy-info/policy-info.vue b/src/layouts/policy-info/policy-info.vue index 14c7b93ce..f246b9387 100644 --- a/src/layouts/policy-info/policy-info.vue +++ b/src/layouts/policy-info/policy-info.vue @@ -87,7 +87,9 @@ class="mb-4" cmsWidgetName="PhoneExtensionWidget" v-model="phoneExtension" - inputId="phoneExtension" /> + inputId="phoneExtension" + maxLength="5" + validationRules="phone-extension-format" />

@@ -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)); From 5b6211301cd58f1a2905a5423622e66662f788dc Mon Sep 17 00:00:00 2001 From: Minojhini Valaiyapathi Date: Fri, 24 Jul 2026 13:28:39 -0400 Subject: [PATCH 12/25] CASH-3078 - Fix question-chain resolving when answerResult has a part number --- .../question-chain/question-chain.spec.js | 77 +++++++++++++++++++ .../question-chain/question-chain.vue | 14 +++- src/helpers/question-chain-helper.js | 10 +++ src/helpers/question-chain-helper.spec.js | 16 ++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/digital-components/question-chain/question-chain.spec.js b/src/digital-components/question-chain/question-chain.spec.js index 1dad2d5de..d0e54a895 100644 --- a/src/digital-components/question-chain/question-chain.spec.js +++ b/src/digital-components/question-chain/question-chain.spec.js @@ -482,9 +482,86 @@ describe("Question Chain component", () => { 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 [ { diff --git a/src/digital-components/question-chain/question-chain.vue b/src/digital-components/question-chain/question-chain.vue index b37755ee7..467e9619e 100644 --- a/src/digital-components/question-chain/question-chain.vue +++ b/src/digital-components/question-chain/question-chain.vue @@ -20,6 +20,7 @@ import buttonQuestion from "@/digital-components/button-question/button-question"; import { useValidateForm } from "vee-validate"; import { + isTerminalQuestionChainAnswer, mapQuestionAnswersForChain, normalizeAnswerSelectedValue, } from "@/helpers/question-chain-helper"; @@ -74,13 +75,18 @@ export default { return !!question.answerSelected || this.isCurrentQuestion(question); }, initializeCurrentQuestionNum() { - const pendingNextQuestion = [...this.questions] + const lastAnsweredQuestion = [...this.questions] .reverse() - .find((question) => question.answerSelected?.includes("|nextQuestion|")); + .find((question) => question.answerSelected); - if (pendingNextQuestion) { + if (isTerminalQuestionChainAnswer(lastAnsweredQuestion?.answerSelected)) { + this.currentQuestionNum = 0; + return; + } + + if (lastAnsweredQuestion?.answerSelected?.includes("|nextQuestion|")) { const nextQuestionSequence = Number( - pendingNextQuestion.answerSelected.split("|")[2] + lastAnsweredQuestion.answerSelected.split("|")[2] ); if ( diff --git a/src/helpers/question-chain-helper.js b/src/helpers/question-chain-helper.js index b5ba8c168..fdf7f194c 100644 --- a/src/helpers/question-chain-helper.js +++ b/src/helpers/question-chain-helper.js @@ -32,6 +32,16 @@ export function mapQuestionAnswersForChain(question) { })); } +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 ""; diff --git a/src/helpers/question-chain-helper.spec.js b/src/helpers/question-chain-helper.spec.js index fa8db3714..f3a31acc1 100644 --- a/src/helpers/question-chain-helper.spec.js +++ b/src/helpers/question-chain-helper.spec.js @@ -1,5 +1,6 @@ import { buildQuestionChainAnswerValue, + isTerminalQuestionChainAnswer, mapQuestionAnswersForChain, normalizeAnswerSelectedValue, } from "./question-chain-helper"; @@ -29,6 +30,21 @@ describe("question-chain-helper", () => { }); }); + 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" }]; From 8be35fea23282f9e424dc301e31abc05d2886305 Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Fri, 24 Jul 2026 16:48:57 -0400 Subject: [PATCH 13/25] CASH-3036 missed otherResponsibleParty CASH-3036 missed otherResponsibleParty --- src/layouts/policy-info/policy-info.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layouts/policy-info/policy-info.vue b/src/layouts/policy-info/policy-info.vue index f246b9387..b59811c3a 100644 --- a/src/layouts/policy-info/policy-info.vue +++ b/src/layouts/policy-info/policy-info.vue @@ -496,9 +496,11 @@ export default { const additionalDamageFlag = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.ADDITIONAL_DAMAGE]; const rentalFlag = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.RENTAL]; + const otherResponsibleParty = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.OTHER_RESPONSIBLE_PARTY]; return Boolean( this.morePolicyQuestions?.[additionalDamageFlag] || - this.morePolicyQuestions?.[rentalFlag] + this.morePolicyQuestions?.[rentalFlag] || + this.morePolicyQuestions?.[otherResponsibleParty] ); }, }, From f21f21e68ddc645b81d862e8a1206c5c0db143d0 Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Fri, 24 Jul 2026 16:54:50 -0400 Subject: [PATCH 14/25] prettier --- src/layouts/policy-info/policy-info.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layouts/policy-info/policy-info.vue b/src/layouts/policy-info/policy-info.vue index b59811c3a..3858eb1ad 100644 --- a/src/layouts/policy-info/policy-info.vue +++ b/src/layouts/policy-info/policy-info.vue @@ -496,7 +496,8 @@ export default { const additionalDamageFlag = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.ADDITIONAL_DAMAGE]; const rentalFlag = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.RENTAL]; - const otherResponsibleParty = morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.OTHER_RESPONSIBLE_PARTY]; + const otherResponsibleParty = + morePolicyQuestionAnswerToFlag[morePolicyQuestionAnswers.OTHER_RESPONSIBLE_PARTY]; return Boolean( this.morePolicyQuestions?.[additionalDamageFlag] || this.morePolicyQuestions?.[rentalFlag] || From 0a777012d872acc82307bce72234dc7a76f2553c Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Fri, 24 Jul 2026 17:04:23 -0400 Subject: [PATCH 15/25] test test --- src/layouts/policy-info/policy-info.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layouts/policy-info/policy-info.spec.js b/src/layouts/policy-info/policy-info.spec.js index 6caaa9f34..4907461b7 100644 --- a/src/layouts/policy-info/policy-info.spec.js +++ b/src/layouts/policy-info/policy-info.spec.js @@ -87,12 +87,12 @@ describe("policy-info.vue", () => { expect(wrapper.vm.displayClaimNumber).toBe(true); }); - test("is false when only unrelated flags (e.g. otherResponsibleParty, thirdPartyVehicle, injuries) are checked", async () => { + test("is false when only unrelated flags (e.g. thirdPartyVehicle, injuries) are checked", async () => { const { wrapper } = setupMocks(); wrapper.vm.morePolicyQuestions = { additionalDamage: false, rental: false, - otherResponsibleParty: true, + otherResponsibleParty: false, thirdPartyVehicle: true, injuries: true, }; From 01c9938a3cb440d7908f986cda6edeafd59840bd Mon Sep 17 00:00:00 2001 From: Minojhini Valaiyapathi Date: Mon, 27 Jul 2026 08:50:37 -0400 Subject: [PATCH 16/25] CASH-3065 - Pass billToAccountNumber to Parts api v6 endpoint --- src/store/index.js | 68 +++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/src/store/index.js b/src/store/index.js index a34307ba9..90866c740 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2012,35 +2012,30 @@ export const actions = { const vehicle = context.getters.vehicle; const damage = context.getters.damage; const order = context.state.order; + const payment = context.getters.payment; - const carId = vehicle.carId; - const glassArray = damage.glassToReplace; - const zipCode = order.serviceLocation.zipCode; - const vin = vehicle.vin; - const serviceType = damage.isRepair ? "Repair" : "Install"; - const parentAccountNumber = context.getters.payment.parentAccountNumber; - const referralSeqNumber = order.referralSequenceNumber; - - // create a new array to avoid mutating state - const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray); + const glassArrayForPayload = convertGlassPieceNamingForApi(damage.glassToReplace); const partsOrQuestionsEndpoint = vehicle.vinRequired ? endpoints.GetPartsOrQuestionsV2 : endpoints.GetPartsOrQuestions; + const requestPayload = { + carId: vehicle.carId, + glassPieces: glassArrayForPayload, + zip: order.serviceLocation.zipCode, + vin: vehicle.vin, + serviceType: damage.isRepair ? "Repair" : "Install", + referralSeqNumber: order.referralSequenceNumber, + parentAccountNumber: payment.parentAccountNumber, + billToAccountNumber: payment.billToAccountNumber, + }; + const response = await globalMethods .callHttpClient({ method: partsOrQuestionsEndpoint.method, endpoint: partsOrQuestionsEndpoint.url, - payload: { - carId: carId, - glassPieces: glassArrayForPayload, - zip: zipCode, - vin: vin, - serviceType: serviceType, - referralSeqNumber: referralSeqNumber, - parentAccountNumber: parentAccountNumber, - }, + payload: requestPayload, logApiCall: true, pageNameToLog: pageNameToLog, }) @@ -2076,32 +2071,25 @@ export const actions = { const order = context.state.order; const payment = context.getters.payment; - const carId = vehicle.carId; - const glassArray = damage.glassToReplace; - const resultsArray = damage.partQuestionAnswers; - const zipCode = order.serviceLocation.zipCode; - const vin = vehicle.vin; - const serviceType = order.serviceLocation?.appointmentType; - const referralSeqNumber = order.referralSequenceNumber; - const parentAccountNumber = payment.parentAccountNumber; + const glassArrayForPayload = convertGlassPieceNamingForApi(damage.glassToReplace); + const resultsArrayForPayload = convertResultsForApi(damage.partQuestionAnswers); - // create a new array to avoid mutating state - const glassArrayForPayload = convertGlassPieceNamingForApi(glassArray); - const resultsArrayForPayload = convertResultsForApi(resultsArray); + const requestPayload = { + carId: vehicle.carId, + glassPieces: glassArrayForPayload, + answerResults: resultsArrayForPayload, + zip: order.serviceLocation.zipCode, + vin: vehicle.vin, + serviceType: order.serviceLocation?.appointmentType, + referralSeqNumber: order.referralSequenceNumber, + parentAccountNumber: payment.parentAccountNumber, + billToAccountNumber: payment.billToAccountNumber, + }; const response = await globalMethods.callHttpClient({ method: endpoints.GetParts.method, endpoint: endpoints.GetParts.url, - payload: { - carId: carId, - glassPieces: glassArrayForPayload, - answerResults: resultsArrayForPayload, - zip: zipCode, - vin: vin, - serviceType: serviceType, - referralSeqNumber: referralSeqNumber, - parentAccountNumber: parentAccountNumber, - }, + payload: requestPayload, logApiCall: true, pageNameToLog: pageNameToLog, }); From 9941d3c1df7d36c4c473c2fa090f6799bcbb9ba5 Mon Sep 17 00:00:00 2001 From: Carl Nation Date: Mon, 27 Jul 2026 10:50:19 -0400 Subject: [PATCH 17/25] CASH-3086 properly save policy city, state, zip. CASH-3086 properly save policy city, state, zip. The policy zip will trigger a state lookup and should be saved in the customer address. The damage city, state should save to the referral loss city and referral loss state. --- src/layouts/policy-info/policy-info.vue | 10 ++++++---- src/store/index.js | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/layouts/policy-info/policy-info.vue b/src/layouts/policy-info/policy-info.vue index 3858eb1ad..ac11f8c32 100644 --- a/src/layouts/policy-info/policy-info.vue +++ b/src/layouts/policy-info/policy-info.vue @@ -572,10 +572,10 @@ export default { return match ?? value; }, getPolicyStateFromStore() { - return store.getters.order.customer.address.state; + return store.getters.order.damage.damageState; }, getCityFromStore() { - return store.getters.order.customer.address.city; + return store.getters.order.damage.damageCity; }, getDateOfBirthFromStore() { return store.getters.order.policy.dateOfBirth; @@ -626,8 +626,8 @@ export default { zipCode: this.policyZip, phoneNumber: this.phoneNumber, phoneExtension: this.phoneExtension, - state: this.policyState, - city: this.city, + state: null, + city: null, morePolicyQuestions: this.morePolicyQuestions, lossLocation: this.lossLocation, lossTime: this.lossTime, @@ -645,6 +645,8 @@ export default { { dateOfLoss: this.dateOfLoss, damageCause: this.damageCause, + damageState: this.policyState, + damageCity: this.city, }, false ); diff --git a/src/store/index.js b/src/store/index.js index 90866c740..d6410b196 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -133,6 +133,8 @@ const getDefaultState = () => { dateOfLoss: null, damageCause: null, installOemGlass: null, + damageCity: null, + damageState: null, }, lineItems: { glassParts: null, @@ -618,6 +620,8 @@ export const mutations = { if (damageDetails) { state.order.damage.dateOfLoss = damageDetails.dateOfLoss; state.order.damage.damageCause = damageDetails.damageCause; + state.order.damage.damageState = damageDetails.damageState; + state.order.damage.damageCity = damageDetails.damageCity; } }, @@ -891,7 +895,8 @@ export const mutations = { sessionInformation.order.damage?.dateOfLoss ); state.order.damage.damageCause = sessionInformation.order.damage?.damageCause; - + state.order.damage.damageState = sessionInformation.order.damage?.damageState; + state.order.damage.damageCity = sessionInformation.order.damage?.damageCity; state.order.damage.partQuestionAnswers = sessionInformation.order.damage.partQuestionAnswers; state.order.damage.moldingQuestionAnswers = @@ -2852,6 +2857,8 @@ export const actions = { installOemGlass: order.damage.installOemGlass, dateOfLoss: order.damage.dateOfLoss, damageCause: order.damage.damageCause, + damageState: order.damage.damageState, + damageCity: order.damage.damageCity, }, lineItems: { glassParts: lineItems.glassParts, From 9aae54aca45466ca0018886cc356511073cdac5e Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Jul 2026 13:29:37 -0400 Subject: [PATCH 18/25] Move functionality to intercept-overlay --- .../insurance-nav-bar/insurance-nav-bar.vue | 12 +------ src/fmg-components/nav-bar/nav-bar.vue | 11 +----- src/ux-components/button-main/button-main.vue | 6 ++-- .../intercept-overlay/intercept-overlay.vue | 9 ++++- src/ux-components/loader/loader.vue | 35 ++++--------------- 5 files changed, 21 insertions(+), 52 deletions(-) diff --git a/src/fmg-components/insurance-nav-bar/insurance-nav-bar.vue b/src/fmg-components/insurance-nav-bar/insurance-nav-bar.vue index a805485d7..bbcc6ed64 100644 --- a/src/fmg-components/insurance-nav-bar/insurance-nav-bar.vue +++ b/src/fmg-components/insurance-nav-bar/insurance-nav-bar.vue @@ -28,6 +28,7 @@ isPrimary :buttonText="overrideButtonText || buttonText" loaderColor="white" + loaderPosition="center" :class="isForwardActionDisabled && 'form-test-invalid'" :aria-disabled="isForwardActionDisabled" :isDisabled="isForwardActionDisabled" @@ -68,10 +69,6 @@ export default { customButtontext: "", }; }, - - unmounted() { - document.onkeydown = null; - }, computed: { backLink() { return ( @@ -91,15 +88,8 @@ export default { }, removeLoader() { this.$refs.buttonMain.removeLoader(); - document.onkeydown = function (e) { - return true; - }; }, buttonClick() { - //prevent keyboard input after button click - document.onkeydown = function (e) { - return false; - }; // check session expired and initSession to recreate cookies if (analyticsMixin.methods.sessionExpired()) { this.routeReturnUser(); diff --git a/src/fmg-components/nav-bar/nav-bar.vue b/src/fmg-components/nav-bar/nav-bar.vue index 96b67f44e..22267f6a1 100644 --- a/src/fmg-components/nav-bar/nav-bar.vue +++ b/src/fmg-components/nav-bar/nav-bar.vue @@ -16,6 +16,7 @@ isPrimary :buttonText="overrideButtonText || buttonText" loaderColor="white" + loaderPosition="center" :class="isForwardActionDisabled && 'form-test-invalid'" :aria-disabled="isForwardActionDisabled" :isDisabled="isForwardActionDisabled" @@ -69,9 +70,6 @@ export default { }; }, - unmounted() { - document.onkeydown = null; - }, computed: { backLink() { return ( @@ -91,15 +89,8 @@ export default { }, removeLoader() { this.$refs.buttonMain.removeLoader(); - document.onkeydown = function (e) { - return true; - }; }, buttonClick() { - //prevent keyboard input after button click - document.onkeydown = function (e) { - return false; - }; // check session expired and initSession to recreate cookies if (analyticsMixin.methods.sessionExpired()) { this.routeReturnUser(); diff --git a/src/ux-components/button-main/button-main.vue b/src/ux-components/button-main/button-main.vue index 87eb28841..689df5931 100644 --- a/src/ux-components/button-main/button-main.vue +++ b/src/ux-components/button-main/button-main.vue @@ -12,7 +12,8 @@ + :loaderColor="loaderColor" + :loaderPosition="loaderPosition" /> @@ -48,7 +49,7 @@ export default { ); if (!this.isDisabled) { if (!this.suppressLoader) this.isLoaderDisplayed = true; - this.$emit("click-event"); + //this.$emit("click-event"); } }, resetButtonStyle() { @@ -64,6 +65,7 @@ export default { From f4e55f359cc16bfa787f251f233c636e53ca2937 Mon Sep 17 00:00:00 2001 From: Chloe Herd Date: Tue, 21 Jul 2026 14:33:51 -0400 Subject: [PATCH 19/25] Use teleport to avoid rendering inconsistency. --- src/ux-components/button-main/button-main.vue | 4 ++-- src/ux-components/intercept-overlay/intercept-overlay.vue | 1 + src/ux-components/loader/loader.vue | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ux-components/button-main/button-main.vue b/src/ux-components/button-main/button-main.vue index 689df5931..d91ebf7cd 100644 --- a/src/ux-components/button-main/button-main.vue +++ b/src/ux-components/button-main/button-main.vue @@ -49,7 +49,7 @@ export default { ); if (!this.isDisabled) { if (!this.suppressLoader) this.isLoaderDisplayed = true; - //this.$emit("click-event"); + this.$emit("click-event"); } }, resetButtonStyle() { @@ -65,7 +65,7 @@ export default { From 5eec03aa8466c2b951df7906e90ab3091260b210 Mon Sep 17 00:00:00 2001 From: scottkiener-at-safelite Date: Wed, 29 Jul 2026 08:56:01 -0400 Subject: [PATCH 25/25] Revert "Merge pull request #3289 from Safelite/feature/CASH-2815-revert" This reverts commit 22f6052848bfb3e7722f9f80ed5b0b010f58ea14, reversing changes made to 6fe17f6810454ff35fd60e89e182dc4854b29a04. --- src/layouts/scheduling/scheduling.spec.js | 3 + src/layouts/scheduling/scheduling.vue | 7 + .../waitlist-question.spec.js | 185 ++++++++++++++++++ .../waitlist-question/waitlist-question.vue | 146 ++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 src/layouts/scheduling/waitlist-question/waitlist-question.spec.js create mode 100644 src/layouts/scheduling/waitlist-question/waitlist-question.vue diff --git a/src/layouts/scheduling/scheduling.spec.js b/src/layouts/scheduling/scheduling.spec.js index d64ffa779..c76a0985f 100644 --- a/src/layouts/scheduling/scheduling.spec.js +++ b/src/layouts/scheduling/scheduling.spec.js @@ -15,6 +15,9 @@ jest.mock("@/store", () => ({ lineItems: { glassParts: [] }, policy: { isItac: false, isNoComp: false }, }, + applicationUser: { + experiments: [], + }, }, })); diff --git a/src/layouts/scheduling/scheduling.vue b/src/layouts/scheduling/scheduling.vue index 3ace9edf7..2b2a8e803 100644 --- a/src/layouts/scheduling/scheduling.vue +++ b/src/layouts/scheduling/scheduling.vue @@ -48,6 +48,10 @@ @address-clicked="onInshopAddressClicked(provider)" />
+ diff --git a/src/layouts/scheduling/waitlist-question/waitlist-question.spec.js b/src/layouts/scheduling/waitlist-question/waitlist-question.spec.js new file mode 100644 index 000000000..83582df3a --- /dev/null +++ b/src/layouts/scheduling/waitlist-question/waitlist-question.spec.js @@ -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); + }); + }); +}); diff --git a/src/layouts/scheduling/waitlist-question/waitlist-question.vue b/src/layouts/scheduling/waitlist-question/waitlist-question.vue new file mode 100644 index 000000000..c6a81c8b0 --- /dev/null +++ b/src/layouts/scheduling/waitlist-question/waitlist-question.vue @@ -0,0 +1,146 @@ + + + + +