SSR-1064 payment method pia options

This commit is contained in:
Matt Caimi 2024-02-01 13:40:30 -05:00
parent 7215f0e464
commit 5559033c41
9 changed files with 785 additions and 14 deletions

View file

@ -0,0 +1,7 @@
export const paymentMethods = {
NONE: null,
LATER: "Later",
CREDIT_CARD: "CreditCard",
PAYPAL: "Paypal",
AFTERPAY: "Afterpay",
};

View file

@ -0,0 +1,59 @@
import { mount } from "@vue/test-utils";
import navbar from "./nav-bar";
describe("nav-bar.vue", () => {
test("Should emit ForwardClicked on button click", async () => {
// Act
const wrapper = mount(navbar, {
mixins: [mockMixin],
});
wrapper.vm.buttonClick();
// Assert
expect(wrapper.emitted()["ForwardClicked"][0]).toHaveBeenCalled;
});
test("Should emit BackClicked on link click", async () => {
// Act
const wrapper = mount(navbar, {
mixins: [mockMixin],
});
wrapper.vm.linkClick();
// Assert
expect(wrapper.emitted()["BackClicked"][0]).toHaveBeenCalled;
});
test("Should change button text when update button text is called", async () => {
// Act
const wrapper = mount(navbar, {
mixins: [mockMixin],
});
wrapper.vm.updateButtonText("newText");
// Assert
expect(wrapper.componentVM.customButtontext).toBe("newText");
});
test("should run removeLoader fn on buttonMain and return false for onkeydown fn", async () => {
// Arrange
const wrapper = mount(navbar, {
mixins: [mockMixin],
});
// Act
wrapper.vm.$refs.buttonMain.removeLoader = jest.fn();
wrapper.vm.removeLoader();
const spy = jest.spyOn(document, "onkeydown");
document.onkeydown();
// Assert
expect(wrapper.vm.$refs.buttonMain.removeLoader).toHaveBeenCalled();
expect(spy).toReturnWith(true);
});
});
const mockMixin = {
methods: {
getCmsContent: jest.fn(),
getFooterInfoBoxHeight: jest.fn(() => 80),
},
};

View file

@ -0,0 +1,163 @@
<template>
<div class="row"></div>
<div class="nav-bar container-fluid g-5 my-5 px-0" id="infoBox">
<div
class="row d-flex justify-content-between vw-100"
:class="[
buttonSize
? 'flex-column align-items-stretch'
: 'flex-row-reverse align-items-center',
]">
<div
:class="['col button-col d-flex', buttonSize ? 'large-button' : 'medium-button']"
id="stacked">
<buttonMain
ref="buttonMain"
isPrimary
:buttonText="buttonText"
loaderColor="white"
:class="isForwardActionDisabled && 'form-test-invalid'"
:aria-disabled="isForwardActionDisabled"
:isDisabled="isForwardActionDisabled"
@click-event="buttonClick"
data-bs-target="#footerModal"
data-test-id="nav-bar-main-button"
data-bs-dismiss="modal"
v-if="!isSubmitHidden" />
</div>
<div
v-if="!isBackButtonHidden"
class="col-auto link-col py-1 text-break"
:class="[buttonSize ? 'back-centered-below mt-5' : '']">
<textLink
linkType="navigation"
:text="backLink"
useLoadingModal
@click-event="linkClick"
href="javascript:void(0)"
data-bs-target="#footerModal"
data-bs-dismiss="modal" />
</div>
</div>
</div>
</template>
<script>
import textLink from "@/ux-components/text-link/text-link";
import buttonMain from "@/ux-components/button-main/button-main";
export default {
name: "navbar",
emits: ["BackClicked", "ForwardClicked"], // <--- should remove oodles of warnings in dev tools
props: {
isForwardActionDisabled: Boolean,
isBackButtonHidden: { type: Boolean, default: false },
cmsWidgetName: String,
buttonSize: { type: Boolean, default: false },
isSubmitHidden: { type: Boolean, default: false },
},
components: {
textLink,
buttonMain,
},
data() {
return {
customButtontext: "",
};
},
unmounted() {
document.onkeydown = null;
},
computed: {
backLink() {
return this.getCmsContent(this.cmsWidgetName, "BackButtonText");
},
buttonText() {
return this.customButtontext
? this.customButtontext
: this.getCmsContent(this.cmsWidgetName, "ForwardButtonText");
},
},
methods: {
updateButtonText(newText) {
this.customButtontext = newText;
},
removeLoader() {
this.$refs.buttonMain.removeLoader();
document.onkeydown = function (e) {
return true;
};
},
buttonClick() {
//prevent keyboard input after button click
document.onkeydown = function (e) {
return false;
};
this.$emit("ForwardClicked");
},
linkClick() {
this.$emit("BackClicked");
},
},
};
</script>
<style lang="scss" scoped>
.nav-bar {
overflow: visible;
display: flex;
padding: 0;
a {
display: flex;
justify-content: center;
}
.col,
.col-auto,
.col button {
width: 100%;
}
.back-centered-below {
display: flex;
justify-content: center;
}
@media only screen and (min-width: 340px) {
.col,
.col-auto,
.col button {
width: auto;
justify-content: flex-end;
}
.back-centered-below {
display: flex;
justify-content: center;
}
.btn-primary {
width: auto;
}
a {
display: flex;
justify-content: flex-start;
}
}
& > .container-fluid {
overflow-x: visible; // needed to fix hidden footer on some iphones
}
.medium-button {
// Define styles for a medium button (default size)
justify-content: flex-end;
width: auto;
}
.large-button {
justify-content: center;
.btn-primary {
width: 100%;
justify-content: center;
}
}
}
</style>

View file

@ -0,0 +1,104 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import paymentMethodListButton from "@/layouts/payment-method/payment-method-question/payment-method-list-button/payment-method-list-button";
const testConstants = {
images: {
A: "imageA",
B: "imageB",
NONE: null,
},
names: {
A: "nameA",
B: "nameB",
},
content: {
withInline: "Button text with {custom:inlineImage} inline.",
noInline: "Button text with no inline",
},
};
let cmsContent;
describe("Payment Method Question", () => {
beforeEach(() => {
cmsContent = {};
});
describe("Side image", () => {
it("Renders side image if image is present and not inline", () => {
// Arrange
const props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
const showSideImage = wrapper.vm.shouldDisplaySideImage;
expect(showSideImage).toBe(true);
});
it("Does not render side image if no image is present", () => {
// Arrange
let props = generateDefaultProps();
props.buttonImage = testConstants.images.NONE;
const { wrapper } = setupMocks({
propsData: props,
});
// Act
const showSideImage = wrapper.vm.shouldDisplaySideImage;
expect(showSideImage).toBe(false);
});
it("Does not render side image if inline", () => {
// Arrange
let props = generateDefaultProps();
props.buttonLabel = testConstants.content.withInline;
props.altText = testConstants.content.withInline;
const { wrapper } = setupMocks({
propsData: props,
});
// Act
const showSideImage = wrapper.vm.shouldDisplaySideImage;
expect(showSideImage).toBe(false);
});
});
});
function generateDefaultProps() {
return {
buttonLabel: testConstants.noInline,
altText: testConstants.noInline,
groupName: "payment-method",
value: testConstants.names.A,
buttonImage: testConstants.images.A,
};
}
function setupMocks(customMountOptions) {
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return cmsContent?.[widgetName]?.[cmsFieldName] ?? "";
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(paymentMethodListButton, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,123 @@
<template>
<baseInputButton
v-bind="$props"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2"
v-model="selectedValue">
<div class="button-content list-button-content d-flex flex-row py-3 px-4">
<img
v-if="shouldDisplaySideImage"
:id="buttonImageId"
class="ms-auto order-3"
:src="buttonImage"
:alt="altText" />
<div class="order-2">
<p class="m-0">
<span v-for="token in tokens" :key="token">
<span v-if="isInlineImageToken(token)">
<img
v-if="hasImage"
:src="buttonImage"
:alt="getInlineAltText(token)" />
<span v-else> {{ getInlineAltText(token) }}</span>
</span>
<span v-else>
{{ token }}
</span>
</span>
</p>
</div>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
const INLINE_IMAGE_TOKEN = "custom:inlineImage";
function isInlineImageToken(token) {
return token.includes(INLINE_IMAGE_TOKEN);
}
function getInlineAltText(token) {
let innerTokens = token.split(",");
return innerTokens[1] ?? "";
}
export default {
name: "payment-method-list-button",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
},
methods: {
isInlineImageToken,
getInlineAltText,
},
computed: {
tokens() {
return splitCopyOnCMSPlaceHolder(this.buttonLabel ?? "");
},
hasImage() {
return !!this.buttonImage;
},
shouldDisplaySideImage() {
return this.hasImage && this.tokens.every((token) => !isInlineImageToken(token));
},
},
};
</script>
<style lang="scss" scoped>
.list-button {
outline: none;
input[type="radio"],
input[type="checkbox"] {
position: static; //override bootstrap
&:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content {
color: $black;
font-weight: 500;
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
}
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {
font-weight: 500;
}
&:checked + .list-button-content span:nth-child(2) {
font-weight: 400;
color: $gray-600;
}
}
}
.list-button-content {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
}
</style>

View file

@ -0,0 +1,130 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import paymentMethodQuestion from "@/layouts/payment-method/payment-method-question/payment-method-question";
const testConstants = {
cms: {
question: {
text: "Choose a payment option",
},
answers: {
optionA: {
name: "NameA",
text: "TextA",
subText: "SubTextA",
imageId: "idA",
image: "imageA",
subWidget: "subWidgetA",
},
optionB: {
name: "NameB",
text: "TextB",
subText: "SubTextB",
imageId: "idB",
image: "imageB",
subWidget: "subWidgetB",
},
},
},
};
let cmsContent;
describe("Payment Method Question", () => {
beforeEach(() => {
cmsContent = {
PaymentMethodWidget: {
QuestionText: testConstants.cms.question,
Answers: [
{
Name: testConstants.cms.answers.optionA.name,
Text: testConstants.cms.answers.optionA.text,
SubText: testConstants.cms.answers.optionA.subText,
ImageId: testConstants.cms.answers.optionA.imageId,
AnswerImageUrl: testConstants.cms.answers.optionA.image,
SubWidgetName: testConstants.cms.answers.optionA.subWidget,
},
{
Name: testConstants.cms.answers.optionB.name,
Text: testConstants.cms.answers.optionB.text,
SubText: testConstants.cms.answers.optionB.subText,
ImageId: testConstants.cms.answers.optionB.imageId,
AnswerImageUrl: testConstants.cms.answers.optionB.image,
SubWidgetName: testConstants.cms.answers.optionB.subWidget,
},
],
},
};
});
it("Should map cms content correctly", () => {
// Arrange
const props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
const mappedContent = wrapper.vm.paymentMethodAnswerData;
// Assert
expect(mappedContent).toEqual([
{
buttonLabel: testConstants.cms.answers.optionA.text,
altText: testConstants.cms.answers.optionA.text,
groupName: "payment-method",
value: testConstants.cms.answers.optionA.name,
buttonImage: testConstants.cms.answers.optionA.image,
},
{
buttonLabel: testConstants.cms.answers.optionB.text,
altText: testConstants.cms.answers.optionB.text,
groupName: "payment-method",
value: testConstants.cms.answers.optionB.name,
buttonImage: testConstants.cms.answers.optionB.image,
},
]);
});
it("Handles null cms content gracefully", () => {
// Arrange
cmsContent = {};
const props = generateDefaultProps();
const { wrapper } = setupMocks({
propsData: props,
});
// Act
const mappedContent = wrapper.vm.paymentMethodAnswerData;
// Assert
expect(mappedContent).toEqual([]);
});
});
function generateDefaultProps() {
return {
modelValue: "modelValue",
};
}
function setupMocks(customMountOptions) {
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return cmsContent?.[widgetName]?.[cmsFieldName] ?? "";
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(paymentMethodQuestion, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,82 @@
<template>
<div class="payment-method-question">
<buttonQuestion
groupName="payment-method"
buttonTypeString="payment-method-list-button"
:buttonTypeObject="paymentMethodListButton"
isWide
:questionText="paymentMethodQuestionText"
:answers="paymentMethodAnswerData"
isRequired
:validationRules="validationRules"
v-model="selectedMethod"
textPosition="text-start" />
</div>
</template>
<script>
import buttonQuestion from "@/digital-components/button-question/button-question";
import paymentMethodListButton from "./payment-method-list-button/payment-method-list-button";
export default {
name: "payment-method-question",
data() {
return {
paymentMethodListButton: paymentMethodListButton,
};
},
props: {
modelValue: String,
validationRules: String,
},
methods: {
getAnswersNullSafe(widgetName) {
const rawData = this.getCmsContent(widgetName, "Answers");
if (!rawData) {
return [];
} else {
return rawData;
}
},
},
computed: {
selectedMethod: {
get: function () {
return this.modelValue;
},
set: function (selectedMethod) {
this.$emit("update:modelValue", selectedMethod);
},
},
paymentMethodQuestionText() {
return this.getCmsContent("PaymentMethodWidget", "QuestionText");
},
paymentMethodAnswerData() {
const cmsData = this.getAnswersNullSafe("PaymentMethodWidget");
return cmsData.map((answer) => {
return {
buttonLabel: answer.Text,
altText: answer.Text,
groupName: "payment-method",
value: answer.Name,
buttonImage: answer.AnswerImageUrl,
};
});
},
},
components: {
buttonQuestion,
},
};
</script>
<style lang="scss">
.payment-method-question {
.question-text span {
text-align: left;
line-height: 24px;
}
}
</style>

View file

@ -4,6 +4,7 @@
v-slot="{ meta }"
@submit="onSubmit"
@invalidSubmit="onInvalidSubmit">
<loadingModal notFullScreen ref="loadingModal" />
<div class="page-container-grouped-styles">
<siteHeader
ref="siteHeader"
@ -21,15 +22,18 @@
<div>Cart Placeholder</div>
<hr class="my-5" />
<div>Pia Alert Placeholder</div>
<div>Payment Method Question</div>
<paymentMethodQuestion
v-if="!isPiaDisabled"
v-model="paymentMethodInternalModel"
validationRules="option-required" />
<div>Pia Disabled Placeholder</div>
<siteFooter
<navbar
ref="siteFooter"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
:isBackButtonHidden="shouldHideBackButton"
buttonSize
@backClicked="navigateBack"
@backClicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
@ -39,14 +43,17 @@
<script>
// Components
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import navbar from "@/iss-components/nav-bar/nav-bar.vue";
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import reviewDropdown from '@/layouts/payment-method/review-dropdown/review-dropdown.vue';
import paymentMethodQuestion from "@/layouts/payment-method/payment-method-question/payment-method-question.vue";
import loadingModal from '@/iss-components/loading-modal/loading-modal.vue';
// Supporting Items
import settleAllPromises from '@/helpers/layout-helper';
import { useMainStore } from '@/store';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import { paymentMethods } from "@/constants/payment-method-constants";
import { Form, defineRule } from 'vee-validate';
@ -63,8 +70,10 @@ export default {
Form,
siteHeader,
siteSubHeader,
siteFooter,
reviewDropdown
navbar,
reviewDropdown,
paymentMethodQuestion,
loadingModal,
},
async beforeRouteEnter(to, from, next) {
// Call APIs
@ -87,16 +96,51 @@ export default {
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.updateFooterButtonText(vm.customCtaCopy);
vm.$refs.reviewDropdown.initializeComponent(resultMap.reviewDropdownData);
});
},
data() {
return { };
return {
paymentMethodInternalModel: this.getPaymentMethodFromStore(),
};
},
computed: {
damageInfo() {
return useMainStore().order.damage;
}
},
customCtaCopy() {
switch (this.paymentMethod) {
case paymentMethods.CREDIT_CARD:
return "Continue to checkout";
case paymentMethods.PAYPAL:
return "Continue to Paypal";
case paymentMethods.AFTERPAY:
return "Continue to Afterpay";
default:
return null;
}
},
isPiaDisabled() {
// do we have this experiment in NextGen ISS?
/*
const piaExperience = this.getSettingValue(experimentSettings.PIA_EXPERIENCE);
const isEnabled = piaExperience === "PIA Optional" || piaExperience === "PIA Required";
return !isEnabled;
*/
return false;
},
paymentMethod() {
if (this.isPiaDisabled) {
return paymentMethods.LATER;
}
return this.paymentMethodInternalModel;
},
},
methods: {
arePagePrerequisitesValid() {
@ -167,11 +211,61 @@ export default {
&& customerReqs
);
},
getPaymentMethodFromStore() {
const piaType = useMainStore().order.payment.piaType;
const isPia = useMainStore().order.payment.isPia && !!piaType;
return isPia ? piaType : paymentMethods.LATER;
},
updateFooterButtonText(newValue) {
this.$refs.siteFooter.updateButtonText(newValue);
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
// Multiple paths based on payment
console.log('forward button hit...');
}
}
await useMainStore().savePaymentMethodChoice(this.paymentMethod);
if (this.paymentMethod == paymentMethods.LATER) {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
} else {
this.setupPia();
}
},
async setupPia() {
this.$refs.loadingModal.showModal();
// if we don't have a work order in delete substatus, set the flag and submit.
// we need a work order number for pia so we can pass it to safeliteHop.
if (!store.getters.order.workOrderNumber) {
try {
await submitWorkOrder({
pageNameToLog: "payment-method",
submitAfterSave: false,
createDeleteStatusWorkOrderForPia: true,
});
} catch (error) {
console.log("error: response from pia submit work order:" + error.message);
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
this.$route.params[routerParams.DISPLAY_PIA_ALERT] = true;
return;
}
}
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_PAY_NOW,
this.$route
);
},
},
watch: {
customCtaCopy(newValue) {
this.updateFooterButtonText(newValue);
},
},
};
</script>

View file

@ -13,6 +13,7 @@ import damageLocationsSelected from '@/constants/damage-locations-selected';
import coverageStatuses from '@/constants/coverage-statuses';
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from '@/constants/schedule-constants';
import { convertDateStringToDate, getDateDifferenceInDays, militaryToTwelveHourTime } from '@/helpers/date-helper';
import { paymentMethods } from "@/constants/payment-method-constants";
const storeId = 'main';
@ -147,7 +148,9 @@ const getDefaultState = () => ({
coverageStatus: coverageStatuses.PENDING,
claimNumber: null
},
parentAccountNumber: 0
parentAccountNumber: 0,
isPia: null,
piaType: null,
},
contactInfo: {
firstName: null,
@ -2127,7 +2130,13 @@ export const useMainStore = defineStore({
this.resetVehicleState();
this.resetDamageState();
this.resetBailout();
}
},
savePaymentMethodChoice(paymentMethod) {
const isPia = paymentMethod !== paymentMethods.LATER;
this.order.payment.isPia = isPia;
this.order.payment.piaType = isPia ? paymentMethod : null;
},
},
persist: true