DigitalConsumer.FixMyGlass/src/layouts/policy-info/policy-info.vue
Carl Nation b437ca183e CASH-2978 added policy number validation
CASH-2978 added policy number validation
2026-07-06 08:12:52 -04:00

656 lines
25 KiB
Vue

<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<funnelHeader
cmsWidgetName="FunnelHeaderWidget"
ref="funnelHeader"
:overrideImageSrc="clientLogoImageSrc" />
<div class="container">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
class="siteSubHeader pb-4"
:alignLeft="true"
:overrideHeaderText="subHeaderTextOverride"
:overrideHeaderSubText="subHeaderSubTextOverride" />
<textboxQuestion
isRequired
class="mb-1"
cmsWidgetName="PolicyNumberWidget"
:overrideQuestionText="policyNumberLabelOverride"
v-model="policyNumber"
inputId="policyNumber"
validationRules="policy-number-required|policy-number-format" />
<span
v-if="displayPolicyHelperText"
class="d-block mb-5 helper-text"
v-html="policyHelperText"></span>
<datePickerPopup
v-if="displayDateOfBirth"
isRequired
class="mb-4"
cmsWidgetName="DateOfBirthWidget"
v-model="dateOfBirth"
customInputId="dateOfBirth"
placeholderText="MM/DD/YYYY"
validationRules="date-of-birth-required"
format="MM/dd/yyyy"
modelType="yyyy-MM-dd"
:enableTimePicker="false"
:textInput="true"
:maxDate="today"
:preventMinMaxNavigation="true" />
<textboxQuestion
v-if="displayLastName"
isRequired
class="mb-4"
cmsWidgetName="LastNameWidget"
v-model="lastName"
inputId="lastName"
validationRules="last-name-required" />
<textboxQuestion
v-if="displayStreetAddress"
isRequired
class="mb-4"
cmsWidgetName="StreetAddressWidget"
v-model="streetAddress"
inputId="streetAddress"
validationRules="street-address-required" />
<textboxQuestion
isRequired
class="mb-4"
cmsWidgetName="ZipWidget"
v-model="policyZip"
inputId="policyZip"
maxLength="5"
validationRules="zip-code-required|zip-code-format" />
<div class="row">
<div class="col-12 col-md-8">
<textboxQuestion
isRequired
class="mb-4"
cmsWidgetName="PhoneWidget"
v-model="phoneNumber"
inputId="phoneNumber"
mask="###-###-####"
validationRules="phone-number-required" />
</div>
<div class="col-12 col-md-4">
<textboxQuestion
isRequired
class="mb-4"
cmsWidgetName="PhoneExtensionWidget"
v-model="phoneExtension"
inputId="phoneExtension" />
</div>
</div>
<datePickerPopup
isRequired
class="mb-4"
cmsWidgetName="DateOfLossWidget"
v-model="dateOfLoss"
customInputId="dateOfLoss"
placeholderText="MM/DD/YYYY"
validationRules="date-of-loss-required"
format="MM/dd/yyyy"
modelType="yyyy-MM-dd"
:enableTimePicker="false"
:textInput="true"
:maxDate="today"
:preventMinMaxNavigation="true" />
<dropdownQuestion
v-if="displayLossTime"
isRequired
:questionText="timeOfLossQuestionText"
class="mb-4"
v-model="lossTime"
cmsWidgetName="TimeOfLossWidget"
customDropdownId="lossTime"
:options="lossTimeOptions"
:valueAsKey="true"
validationRules="loss-time-required"
:labelBold="true" />
<dropdownQuestion
isRequired
:questionText="damageTypesQuestionText"
class="mb-4"
v-model="damageCause"
cmsWidgetName="DamageTypesWidget"
customDropdownId="damageTypes"
:options="damageTypesOptions"
:valueAsKey="true"
validationRules="damage-types-required"
:labelBold="true" />
<buttonQuestion
v-if="displaySubrogation"
:questionText="subrogationQuestionText"
:answers="subrogationAnswers"
class="mb-4"
groupName="SubrogationQuestion"
buttonTypeString="listButton"
isRequired
validationRules="subrogation-required"
v-model="subrogationSelectedValue" />
<dropdownQuestion
v-if="displayLossState"
class="mb-4"
customDropdownId="policyState"
cmsWidgetName="StateQuestionWidget"
v-model="policyState"
ref="policyState"
:options="stateOptions"
validationRules="state-required"
autocomplete="address-level1"
placeHolderText="Select State"
:labelBold="true" />
<dropdownQuestion
v-if="displayLossLocation"
:questionText="lossLocationQuestionText"
class="mb-4"
v-model="lossLocation"
cmsWidgetName="LossLocationQuestionWidget"
customDropdownId="lossLocation"
:options="lossLocationOptions"
:valueAsKey="true"
validationRules="loss-location-required"
:labelBold="true" />
<textboxQuestion
v-if="displayLossCity"
isRequired
class="mb-4"
cmsWidgetName="CityWidget"
v-model="city"
inputId="city"
validationRules="city-required" />
<hr class="mt-4 mb-0" />
<morePolicyQuestions
ref="morePolicyQuestions"
:cmsWidgetName="morePolicyQuestionsWidgetName"
v-model="morePolicyQuestions"
groupName="MorePolicyQuestionsQuestion" />
<insuranceNavBar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
isBackButtonHidden
@cancel-clicked="cancelButtonAction"
@ForwardClicked="forwardButtonAction" />
<span
v-if="displayDisclaimerText"
class="d-block mb-5 disclaimer-text"
v-html="disclaimerText"></span>
</div>
</div>
</div>
</Form>
</template>
<script>
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import insuranceNavBar from "@/fmg-components/insurance-nav-bar/insurance-nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import datePickerPopup from "@/digital-components/date-picker-popup/date-picker-popup";
import dropdownQuestion from "@/digital-components/dropdown-question/dropdown-question";
import morePolicyQuestions from "@/layouts/policy-info/more-policy-questions/more-policy-questions";
import buttonQuestion from "@/digital-components/button-question/button-question";
import { stateOptions } from "@/constants/state-options";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { regex } from "@/helpers/validation-rules";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import store from "@/store";
import { debugLog } from "@/helpers/debug-log-helper";
// The PolicyNumberValidationWidget is fetched asynchronously from the CMS (see
// beforeRouteEnter below), so the format rule reads it from this module-scoped
// reference rather than capturing a value at defineRule time.
let policyNumberValidationConfig = null;
function getPolicyNumberFormatPattern(validationRuleType) {
switch ((validationRuleType || "").toLowerCase()) {
case "alpha":
return /^[A-Za-z]+$/;
case "numeric":
return /^[0-9]+$/;
case "alphanumeric":
return /^[A-Za-z0-9]+$/;
case "alphanumericwithspaces":
return /^[A-Za-z0-9 ]+$/;
default:
return null;
}
}
// Like ConfigWidget, PolicyNumberValidationWidget's actual config comes back from the CMS
// as a JSON-encoded string in its `Text` property, not as a plain object.
// examples:
/* {
"minimumLength": 1,
"maximumLength": 16,
"validationRuleType": "AlphaNumeric",
"minValidationMessage": "",
"maxValidationMessage": "That looks like a VIN. Your policy number should be 16 characters maximum.",
"invalidCharMessage": "Policy number must be numbers and letters only."
}
{
"minimumLength": 8,
"maximumLength": 9,
"validationRuleType": "AlphaNumeric",
"minValidationMessage": "Policy number should be at least 8 characters.",
"maxValidationMessage": "Policy number must be 9 characters or less."
}
*/
function parsePolicyNumberValidationWidget(widget) {
const raw = widget?.Text;
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch (err) {
console.warn("Failed to parse PolicyNumberValidationWidget.Text", err);
return null;
}
}
defineRule("policy-number-required", required(errorMessages.POLICY_NUMBER_REQUIRED));
defineRule("policy-number-format", (value) => {
if (!value || !value.length || !policyNumberValidationConfig) {
return true;
}
const {
minimumLength,
maximumLength,
validationRuleType,
minValidationMessage,
maxValidationMessage,
invalidCharMessage,
} = policyNumberValidationConfig;
if (typeof minimumLength === "number" && value.length < minimumLength) {
return minValidationMessage || errorMessages.POLICY_NUMBER_FORMAT;
}
if (typeof maximumLength === "number" && value.length > maximumLength) {
return maxValidationMessage || errorMessages.POLICY_NUMBER_FORMAT;
}
const pattern = getPolicyNumberFormatPattern(validationRuleType);
if (pattern && !pattern.test(value)) {
return invalidCharMessage || errorMessages.POLICY_NUMBER_FORMAT;
}
return true;
});
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("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));
defineRule("city-required", required(errorMessages.CITY_REQUIRED));
defineRule("date-of-birth-required", required(errorMessages.DATE_OF_BIRTH_REQUIRED));
defineRule("last-name-required", required(errorMessages.LAST_NAME_REQUIRED));
defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQUIRED));
defineRule("loss-time-required", required(errorMessages.LOSS_TIME_REQUIRED));
defineRule("loss-location-required", required(errorMessages.LOSS_LOCATION_REQUIRED));
defineRule("subrogation-required", required(errorMessages.SUBROGATION_REQUIRED));
export default {
name: "policy-info",
mixins: [],
data() {
return {
clientConfig: {},
policyNumber: this.getPolicyNumberFromStore(),
policyZip: this.getPolicyZipFromStore(),
phoneNumber: this.getPhoneNumberFromStore(),
phoneExtension: this.getPhoneExtensionFromStore(),
dateOfLoss: this.getDateOfLossFromStore(),
lastName: this.getLastNameFromStore(),
streetAddress: this.getStreetAddressFromStore(),
damageCause: this.getDamageCauseFromStore(),
policyState: this.getPolicyStateFromStore(),
city: this.getCityFromStore(),
dateOfBirth: this.getDateOfBirthFromStore(),
lossTime: this.getLossTimeFromStore(),
morePolicyQuestions: this.getMorePolicyQuestionsFromStore(),
subrogationSelectedValue: this.getSubrogationSelectedValueFromStore(),
lossLocation: this.getLossLocationFromStore(),
};
},
components: {
Form,
funnelHeader,
insuranceNavBar,
funnelSubHeader,
textboxQuestion,
datePickerPopup,
dropdownQuestion,
morePolicyQuestions,
buttonQuestion,
},
computed: {
stateOptions() {
return stateOptions;
},
today() {
return new Date();
},
clientLogoImageSrc() {
return this.clientConfig?.ClientLogoWidget?.Image || "";
},
subHeaderTextOverride() {
return this.clientConfig?.PageTitleOverrideWidget?.Text || "";
},
subHeaderSubTextOverride() {
return this.clientConfig?.PageInstructionsOverrideWidget?.Text || "";
},
policyNumberLabelOverride() {
return this.clientConfig?.PolicyNumberLabelOverrideWidget?.Text || "";
},
policyHelperText() {
return this.clientConfig?.PolicyHelperTextOverrideWidget?.Text;
},
disclaimerText() {
return this.clientConfig?.DisclaimerWidget?.Text;
},
displayDisclaimerText() {
return this.disclaimerText !== null && this.disclaimerText !== "";
},
damageTypesQuestionText() {
return this.getCmsContent("DamageTypesWidget", "QuestionText");
},
damageTypesOptions() {
const options = {};
const answers = this.getCmsContent("DamageTypesWidget", "Answers");
if (!Array.isArray(answers)) {
return options;
}
for (const answer of answers) {
options[answer.Name] = answer.Text;
}
return options;
},
lossTimeOptions() {
const options = {};
const answers = this.getCmsContent("TimeOfLossWidget", "Answers");
for (const answer of answers) {
options[answer.Name] = answer.Text;
}
return options;
},
timeOfLossQuestionText() {
return this.getCmsContent("TimeOfLossWidget", "QuestionText");
},
lossLocationOptions() {
const options = {};
const answers = this.getCmsContent("LossLocationQuestionWidget", "Answers");
for (const answer of answers) {
options[answer.Name] = answer.Text;
}
return options;
},
parsedClientConfig() {
const raw = this.clientConfig?.ConfigWidget?.Text;
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (err) {
console.warn("Failed to parse clientConfig.ConfigWidget.Text", err);
return {};
}
},
displayLossState() {
return this.parsedClientConfig.displayLossState ?? false;
},
displayLossCity() {
return this.parsedClientConfig.displayLossCity ?? false;
},
displayLossLocation() {
return this.parsedClientConfig.displayLossLocation ?? false;
},
displaySubrogation() {
return this.parsedClientConfig.displaySubrogation ?? false;
},
displayStreetAddress() {
return this.parsedClientConfig.displayStreetAddress ?? false;
},
displayDateOfBirth() {
return this.parsedClientConfig.displayDateOfBirth ?? false;
},
displayLastName() {
return this.parsedClientConfig.displayLastName ?? false;
},
displayLossTime() {
return this.parsedClientConfig.displayLossTime ?? false;
},
displayPolicyHelperText() {
return this.policyHelperText !== null && this.policyHelperText !== "";
},
subrogationQuestionText() {
return this.getCmsContent("SubrogationQuestionWidget", "QuestionText");
},
subrogationAnswers() {
return this.getCmsContent("SubrogationQuestionWidget", "Answers");
},
lossLocationQuestionText() {
return this.getCmsContent("LossLocationQuestionWidget", "QuestionText");
},
morePolicyQuestionsWidgetName() {
// State Farm may be the only one who uses this
return this.parsedClientConfig.OverrideMorePolicyQuestions
? "MorePolicyQuestionsOverrideWidget"
: "MorePolicyQuestionsWidget";
},
},
// Ensure CMS content is fetched during route navigation without depending on `to`/`from`
async beforeRouteEnter(to, from, next) {
const clientConfigPageName = `client_${store.getters.order.payment.parentAccountNumber}`;
const clientConfigPromise = fetchCmsContentForPage(clientConfigPageName);
const pageName = to.name;
const cmsContentPromise = fetchCmsContentForPage(pageName);
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "clientConfig",
promise: clientConfigPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.clientConfig = resultMap.clientConfig ?? {};
policyNumberValidationConfig = parsePolicyNumberValidationWidget(
vm.clientConfig?.PolicyNumberValidationWidget
);
// The load-session API can return damageCause in a different case than
// the CMS answer values. Reconcile it so the dropdown pre-selects.
vm.damageCause = vm.matchDamageCauseToOption(vm.damageCause);
debugLog(
`policy-infoclientConfig::${clientConfigPageName}`,
JSON.stringify(vm.clientConfig)
);
});
},
methods: {
getPolicyNumberFromStore() {
return store.getters.order.policy.policyNumber;
},
getPolicyZipFromStore() {
return store.getters.order.policy.zipCode;
},
getPhoneNumberFromStore() {
return store.getters.order.policy.phoneNumber;
},
getPhoneExtensionFromStore() {
return store.getters.order.policy.phoneExtension;
},
getDateOfLossFromStore() {
return store.getters.order.damage.dateOfLoss;
},
getDamageCauseFromStore() {
return store.getters.order.damage.damageCause;
},
// Maps a stored damageCause to the matching dropdown option value using a
// case-insensitive comparison (load-session can return it uppercased).
// Returns the canonical option value, or the original value if no match.
matchDamageCauseToOption(value) {
if (!value) {
return value;
}
const optionValues = Object.values(this.damageTypesOptions ?? {});
const match = optionValues.find(
(option) => option?.toLowerCase() === value.toLowerCase()
);
return match ?? value;
},
getPolicyStateFromStore() {
return store.getters.order.policy.state;
},
getCityFromStore() {
return store.getters.order.policy.city;
},
getDateOfBirthFromStore() {
return store.getters.order.policy.dateOfBirth;
},
getLastNameFromStore() {
return store.getters.order.policy.lastName;
},
getStreetAddressFromStore() {
return store.getters.order.policy.streetAddress;
},
getLossTimeFromStore() {
return store.getters.order.policy.lossTime;
},
getSubrogationSelectedValueFromStore() {
return store.getters.order.policy.subrogation;
},
getMorePolicyQuestionsFromStore() {
return store.getters.order.policy.morePolicyQuestions ?? [];
},
getLossLocationFromStore() {
return store.getters.order.policy.lossLocation;
},
cancelButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_CANCEL_VERIFICATION,
this.pageName
);
},
async forwardButtonAction() {
// Explicitly (re)validate every field before navigating, rather than relying
// solely on isForwardActionDisabled/meta.valid already being accurate and on the
// Continue button's incidental native form-submission side effect to surface
// error messages. This guarantees the CMS-driven policy number format error (and
// any other field's error) is actually rendered when Continue is clicked while
// the form is invalid.
const isValid = await this.isFormValid(this.$refs.theForm);
if (!isValid) {
return;
}
this.dispatchStoreAction(
this.storeActions.SAVE_INSURANCE_DETAILS,
{
policyNumber: this.policyNumber,
zipCode: this.policyZip,
phoneNumber: this.phoneNumber,
phoneExtension: this.phoneExtension,
state: this.policyState,
city: this.city,
morePolicyQuestions: this.morePolicyQuestions,
lossLocation: this.lossLocation,
lossTime: this.lossTime,
lastName: this.lastName,
dateOfBirth: this.dateOfBirth,
subrogation: this.subrogationSelectedValue,
streetAddress: this.streetAddress,
},
false
);
this.dispatchStoreAction(
this.storeActions.SAVE_DAMAGE_DETAILS,
{
dateOfLoss: this.dateOfLoss,
damageCause: this.damageCause,
},
false
);
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
},
arePagePrerequisitesValid() {
return true;
},
},
};
</script>
<style lang="scss">
.cc-page-title {
font-size: 20px !important;
line-height: 1.325;
font-weight: 300;
color: $black;
}
.cc-page-instructions {
font-size: 16px !important;
font-weight: 400;
color: $gray-600;
line-height: 1.625;
}
.helper-text {
font-size: 14px !important;
font-weight: 400;
color: $gray-600;
line-height: 1.625;
font-style: italic;
}
.disclaimer-text {
font-size: 12px !important;
font-weight: 400;
color: $gray-600;
line-height: 1.625;
}
</style>