Some updates for validation on zip entry/shop search

This commit is contained in:
Alex Humphries 2026-01-15 13:18:12 -05:00
parent 21e9127b50
commit d85c0f6c38
16 changed files with 255 additions and 512 deletions

View file

@ -25,6 +25,11 @@ const errorMessages = Object.freeze({
EMAIL_ADDRESS_FORMAT: 'Please enter a valid email address.', EMAIL_ADDRESS_FORMAT: 'Please enter a valid email address.',
SERVICE_ZIP_REQUIRED: 'ZIP code is required', SERVICE_ZIP_REQUIRED: 'ZIP code is required',
SERVICE_ZIP_FORMAT: 'Invalid ZIP, please enter a new ZIP (5 characters)', SERVICE_ZIP_FORMAT: 'Invalid ZIP, please enter a new ZIP (5 characters)',
MOBILE_SERVICE_ZIP_FORMAT: 'Invalid ZIP code',
NO_SERVICE_IN_ZIP: (zip) => `We're sorry, but we don't offer service in ${zip} at this time.`,
PROVIDER_REQUIRED: 'Please select a provider.',
INVALID_ZIP: 'The ZIP you entered was invalid. Please enter a valid ZIP.',
ZIP_CODE_NOT_SERVICED_FOR_VEHICLE: 'We do not currently offer glass service for your vehicle in this ZIP code. Please try another ZIP code.',
VIN_REQUIRED: 'Please enter your VIN', VIN_REQUIRED: 'Please enter your VIN',
VIN_FORMAT: VIN_FORMAT:
// eslint-disable-next-line max-len // eslint-disable-next-line max-len

View file

@ -294,6 +294,11 @@ export default {
this.resetField({ this.resetField({
value: newValue value: newValue
}); });
},
answers() {
this.resetField({
value: this.modelValue
});
} }
}, },
beforeMount() { beforeMount() {

View file

@ -86,6 +86,10 @@ export default {
modalPosition: { modalPosition: {
type: String, type: String,
default: modalPositions.edge default: modalPositions.edge
},
allowInvalidSubmit: {
type: Boolean,
default: false
} }
}, },
emits: ['footer-button-event', 'isModalOpened'], emits: ['footer-button-event', 'isModalOpened'],
@ -123,7 +127,7 @@ export default {
methods: { methods: {
async validateAndEmit() { async validateAndEmit() {
const validationResult = await this.validate(); const validationResult = await this.validate();
if (validationResult.valid) { if (validationResult.valid || this.allowInvalidSubmit) {
this.$emit('footer-button-event'); this.$emit('footer-button-event');
} }
}, },

View file

@ -51,7 +51,7 @@ export default {
props: { props: {
customText: String, // used to allow the insert of token values into textblock customText: String, // used to allow the insert of token values into textblock
justifyText: String, // left, right, center justifyText: String, // left, right, center
typeStyle: String, // h1-h6, body, small, label, caption typeStyle: String, // h1-h6, body, small, label, caption, disclaimer
// (see Figma or Confluence documentation) // (see Figma or Confluence documentation)
fontWeight: String, // bold=500, default is 400 fontWeight: String, // bold=500, default is 400
cmsWidgetName: String, cmsWidgetName: String,
@ -109,5 +109,12 @@ export default {
&.dark { &.dark {
color: $black; color: $black;
} }
&.disclaimer {
color: $lighter-gray;
font-weight: $font-weight-light;
font-size: .8125rem;
font-style: italic;
margin-top: .625rem;
}
} }
</style> </style>

View file

@ -250,8 +250,10 @@ export default {
this.$emit('click-event'); this.$emit('click-event');
} }
}, },
clickedTextButton() { async clickedTextButton() {
if (this.meta.valid) { const result = await validate(this.value, this.validationRules);
if (result.valid) {
this.handleChange(this.value);
this.$emit('click-event'); this.$emit('click-event');
} }
} }
@ -378,8 +380,8 @@ input::-webkit-date-and-time-value {
.form-control { .form-control {
border: $border-input; border: $border-input;
border-radius: $border-radius; border-radius: $border-radius;
min-height: 3rem; min-height: 2.8125rem;
max-height: 3rem; max-height: 2.8125rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
letter-spacing: inherit; letter-spacing: inherit;
box-shadow: $box-shadow-input; box-shadow: $box-shadow-input;

View file

@ -55,7 +55,7 @@ export default {
const { AdvancedMarkerElement, PinElement } = await window.google.maps.importLibrary('marker'); const { AdvancedMarkerElement, PinElement } = await window.google.maps.importLibrary('marker');
markers?.forEach((marker, index) => { markers?.forEach((marker, index) => {
const pinElement = new PinElement({ const pinElement = new PinElement({
glyph: String.fromCharCode('A'.charCodeAt(0) + index), glyphText: String.fromCharCode('A'.charCodeAt(0) + index),
glyphColor: '#000000', glyphColor: '#000000',
borderColor: '#000000' borderColor: '#000000'
}); });

View file

@ -2,13 +2,13 @@
exports[`Shop list button should render correctly with all relevant props 1`] = ` exports[`Shop list button should render correctly with all relevant props 1`] = `
<transition-stub name="fade" mode="out-in" appear="false" persisted="false" css="true" selectedvalue="selected value"> <transition-stub name="fade" mode="out-in" appear="false" persisted="false" css="true" selectedvalue="selected value">
<base-input-button-stub modelvalue="value of modal" groupname="name of group" buttonwrapperclasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2" ismultiselect="false" validationrules="" isrequired="true" selectinginitiatesload="false" suppresserror="false" buttonlabel="Label of button" buttonlabelsubcopy="Sub copy of button" buttonbodycopy="button body copy" screenreaderonlytext="screen reader only text" additionalbuttondata="[object Object]" alttext="" iswide="false" value="1234"></base-input-button-stub> <base-input-button-stub modelvalue="value of modal" groupname="name of group" buttonwrapperclasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 no-hover" ismultiselect="false" validationrules="" isrequired="true" selectinginitiatesload="false" suppresserror="false" buttonlabel="Label of button" buttonlabelsubcopy="Sub copy of button" buttonbodycopy="button body copy" screenreaderonlytext="screen reader only text" additionalbuttondata="[object Object]" alttext="" iswide="false" value="1234"></base-input-button-stub>
</transition-stub> </transition-stub>
`; `;
exports[`Shop list button should render correctly with required props 1`] = ` exports[`Shop list button should render correctly with required props 1`] = `
<transition-stub name="fade" mode="out-in" appear="false" persisted="false" css="true"> <transition-stub name="fade" mode="out-in" appear="false" persisted="false" css="true">
<base-input-button-stub modelvalue="value of modal" groupname="name of group" buttonwrapperclasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2" ismultiselect="false" validationrules="" isrequired="true" selectinginitiatesload="false" suppresserror="false" alttext="" iswide="false" value="1234"></base-input-button-stub> <base-input-button-stub modelvalue="value of modal" groupname="name of group" buttonwrapperclasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 no-hover" ismultiselect="false" validationrules="" isrequired="true" selectinginitiatesload="false" suppresserror="false" alttext="" iswide="false" value="1234"></base-input-button-stub>
</transition-stub> </transition-stub>
`; `;

View file

@ -57,16 +57,12 @@ export default {
return retCms; return retCms;
}, },
answersToDisplay() { answersToDisplay() {
const shouldShowMobile = this.isServiceableMobile && this.isItacOrNoComp; const shouldShowMobile = this.isServiceableMobile;
const shouldShowMobileNotITACNotNoComp = this.isServiceableMobile && !this.isItacOrNoComp;
const shouldShowInshop = this.isServiceableInshop; const shouldShowInshop = this.isServiceableInshop;
const shouldShowDropoff = this.isServiceableInshop && !useMainStore().damage.isRepair;
return this.answersFromCms return this.answersFromCms
? this.answersFromCms.filter((answer) => ( ? this.answersFromCms.filter((answer) => (
(answer.Name === AppointmentTypeStrings.IN_SHOP && shouldShowInshop) (answer.Name === AppointmentTypeStrings.IN_SHOP && shouldShowInshop)
|| (answer.Name === AppointmentTypeStrings.MOBILE && shouldShowMobile) || (answer.Name === AppointmentTypeStrings.MOBILE && shouldShowMobile)
|| (answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP && shouldShowMobileNotITACNotNoComp)
|| (answer.Name === AppointmentTypeStrings.DROP_OFF && shouldShowDropoff)
)) ))
: []; : [];
}, },
@ -97,14 +93,9 @@ export default {
// If there is only one option to display and that option is 'Mobile' then select it // If there is only one option to display and that option is 'Mobile' then select it
if ( if (
newValue.length === 1 newValue.length === 1
&& newValue.findIndex((answer) => (answer.Name === AppointmentTypeStrings.MOBILE && newValue.findIndex((answer) => (answer.Name === AppointmentTypeStrings.MOBILE)) !== -1
|| answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP)) !== -1
) { ) {
if (this.isItacOrNoComp) { this.selectedValues = AppointmentTypeStrings.MOBILE;
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
} }
}, },
immediate: true immediate: true
@ -112,11 +103,7 @@ export default {
isMobileOnly: { isMobileOnly: {
handler(newValue) { handler(newValue) {
if (newValue) { if (newValue) {
if (this.isItacOrNoComp) { this.selectedValues = AppointmentTypeStrings.MOBILE;
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
} }
} }
} }

View file

@ -62,26 +62,39 @@
cmsWidgetName="AppointmentTypeQuestionWidget" cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" /> validationRules="option-required" />
</div> </div>
<mobileLocationModalQuestions
v-if="isMobile"
ref="mobileLocationQuestions"
v-model="mobileLocationQuestions"
customComponentId="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
validationRules="mobile-location-required"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
@updated-mobile-fee-part="setMobileFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
@updated-mobile-ctu="setCtuForMobile" />
<shopAddress <shopAddress
v-if="isInshop"
v-model="selectedProvider" v-model="selectedProvider"
:serviceZipcode="zipCode" :serviceZipcode="zipCode"
:isDisplayed="isInshop" :isDisplayed="isInshop"
:selectedAppointmentType="selectedAppointmentType" :selectedAppointmentType="selectedAppointmentType"
modalWidgetName="ChangeLocationModalWidget" modalWidgetName="ChangeLocationModalWidget"
cmsWidgetName="ShopAddressWidget" /> cmsWidgetName="ShopAddressWidget"
@zip-updated="handleInShopZipUpdated" />
<div
v-if="isMobile">
<serviceZipQuestion
ref="mobileServiceZipCodeQuestion"
v-model="mobileZipCode"
customInputId="mobileServiceZipCode"
class="mobile-service-zip-question"
:placeholderText="mobileZipPlaceholder"
:serviceZipFormatErrorMessage="errorMessages.MOBILE_SERVICE_ZIP_FORMAT"
:hasError="mobileZipError !== ''"
cmsWidgetName="MobileZipWidget" />
<div
v-if="mobileZipError"
ref="errorMessageDiv"
class="row my-1 form-test-error">
<span
class="d-inline-flex mt-0"
role="alert">{{ mobileZipError }}</span>
</div>
<textBlock
v-if="displayMobileFeeDisclaimer"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="disclaimer" />
</div>
<contentGroupModal <contentGroupModal
:ref="RECAL_MODAL_REF_NAME" :ref="RECAL_MODAL_REF_NAME"
cssModalHeadlineClass="text-center" cssModalHeadlineClass="text-center"
@ -111,35 +124,23 @@ import { useMainStore } from '@/store';
import { import {
getPricedMobileFeePart, getPricedMobileFeePart,
getServiceabilityDetails, getServiceabilityDetails,
getZipCodeData getZipCodeData,
getMobileZipCodeData
} from '@/helpers/service-location-helper'; } from '@/helpers/service-location-helper';
// Import Component // Import Component
import alert from '@/ux-components/alert/alert.vue'; import alert from '@/ux-components/alert/alert.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
import appointmentTypeQuestion from '@/layouts/service-location/appointment-type-question/appointment-type-question.vue'; import appointmentTypeQuestion from '@/layouts/service-location/appointment-type-question/appointment-type-question.vue';
import baseFormMixin from '@/mixins/base-form-mixin'; import baseFormMixin from '@/mixins/base-form-mixin';
import contentGroupModal from '@/iss-components/content-group-modal/content-group-modal.vue'; import contentGroupModal from '@/iss-components/content-group-modal/content-group-modal.vue';
import mobileLocationModalQuestions from '@/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue';
import { Form, defineRule } from 'vee-validate'; import { Form, defineRule } from 'vee-validate';
import siteFooter from '@/iss-components/site-footer/site-footer.vue'; import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import siteHeader from '@/iss-components/site-header/site-header.vue'; import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue'; import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import shopAddress from './shop-address/shop-address.vue'; import shopAddress from '@/layouts/service-location/shop-address/shop-address.vue';
import serviceZipQuestion from '@/layouts/service-location/service-zip-question/service-zip-question.vue';
// DEFINE VALIDATION RULES import widgetFields from '@/constants/cms-widget-fields';
defineRule('mobile-location-required', (value) => {
if (
value.addressQuestions.streetAddress === ''
|| value.addressQuestions.city === ''
|| value.addressQuestions.state === ''
|| value.addressQuestions.zipCode === ''
|| value.isVehicleProtected == null
) {
return errorMessages.MOBILE_LOCATION_REQUIRED;
}
return true;
});
defineRule('selection-required', required(errorMessages.OPTION_REQUIRED));
const RECAL_MODAL_REF_NAME = 'RecalModal'; const RECAL_MODAL_REF_NAME = 'RecalModal';
const SITE_FOOTER_REF_NAME = 'siteFooter'; const SITE_FOOTER_REF_NAME = 'siteFooter';
@ -148,15 +149,16 @@ export default {
name: 'service-location', name: 'service-location',
components: { components: {
alert, alert,
textBlock,
appointmentTypeQuestion, appointmentTypeQuestion,
contentGroupModal, contentGroupModal,
mobileLocationModalQuestions,
siteFooter, siteFooter,
siteHeader, siteHeader,
siteSubHeader, siteSubHeader,
// eslint-disable-next-line vue/no-reserved-component-names // eslint-disable-next-line vue/no-reserved-component-names
Form, Form,
shopAddress shopAddress,
serviceZipQuestion
}, },
mixins: [baseFormMixin], mixins: [baseFormMixin],
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -232,8 +234,12 @@ export default {
mobileProviderNumber: null, mobileProviderNumber: null,
zipContainsMilitaryBase: false, zipContainsMilitaryBase: false,
zipCodeCtu: null, zipCodeCtu: null,
mobileZipCode: '',
mobileZipError: '',
showMobileOption: false,
RECAL_MODAL_REF_NAME, RECAL_MODAL_REF_NAME,
SITE_FOOTER_REF_NAME SITE_FOOTER_REF_NAME,
errorMessages
}; };
}, },
computed: { computed: {
@ -246,35 +252,6 @@ export default {
answersFromCms() { answersFromCms() {
return this.getCmsContent('ServiceTypeQuestionWidget', 'Answers'); return this.getCmsContent('ServiceTypeQuestionWidget', 'Answers');
}, },
mobileLocationQuestions: {
get() {
return {
addressQuestions: {
streetAddress: this.streetAddress,
streetAddress2: this.streetAddress2,
city: this.city,
state: this.state,
zipCode: this.zipCode
},
isVehicleProtected: this.isVehicleProtected
};
},
set(newValue) {
this.streetAddress = newValue.addressQuestions.streetAddress;
this.streetAddress2 = newValue.addressQuestions.streetAddress2;
this.city = newValue.addressQuestions.city;
this.state = newValue.addressQuestions.state;
this.zipCode = newValue.addressQuestions.zipCode;
this.isVehicleProtected = newValue.isVehicleProtected;
if (newValue.zipCode !== this.zipCode) {
if (!this.isMobile) {
this.selectedAppointmentType = null;
}
this.selectedProvider = null;
}
}
},
isServiceableMobile() { isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) { if (this.isRecalibrationServiceableMobile !== null) {
return ( return (
@ -301,15 +278,10 @@ export default {
); );
}, },
isAppointmentTypeDisplayed() { isAppointmentTypeDisplayed() {
return this.zipCode && !this.displayNoShopsAlert; return this.zipCode && !this.displayNoShopsAlert && this.showMobileOption;
}, },
isMobile() { isMobile() {
return ( return this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
this.selectedAppointmentType
=== AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType
=== AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP
);
}, },
requiresInshopRecalibration() { requiresInshopRecalibration() {
// Specifically check for isRecalibrationServiceableMobile === false, not null or true. // Specifically check for isRecalibrationServiceableMobile === false, not null or true.
@ -341,6 +313,9 @@ export default {
displayServiceableMobileOnly() { displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop; return this.isServiceableMobile && !this.isServiceableInshop;
}, },
displayMobileFeeDisclaimer() {
return this.mainStore.isNoComp || this.mainStore.isITAC;
},
navigateBackScenario() { navigateBackScenario() {
const { isNoComp, isITAC } = useMainStore(); const { isNoComp, isITAC } = useMainStore();
return isNoComp || isITAC return isNoComp || isITAC
@ -349,7 +324,10 @@ export default {
}, },
isBigTruck() { isBigTruck() {
return this.mainStore.order.vehicle.isBigTruck; return this.mainStore.order.vehicle.isBigTruck;
} },
mobileZipPlaceholder() {
return this.getCmsContent('MobileZipPlaceHolderWidget', widgetFields.TEXT_BLOCK_WIDGET.TEXT);
},
}, },
methods: { methods: {
arePagePrerequisitesValid() { arePagePrerequisitesValid() {
@ -445,6 +423,7 @@ export default {
if (serviceabilityDetails) { if (serviceabilityDetails) {
this.setServiceabilityDetails(serviceabilityDetails); this.setServiceabilityDetails(serviceabilityDetails);
this.showMobileOption = this.isServiceableMobile;
} }
if (mobileFeePart) { if (mobileFeePart) {
@ -503,6 +482,65 @@ export default {
serviceabilityDetails.isGlassServiceableMobile; serviceabilityDetails.isGlassServiceableMobile;
this.isRecalibrationServiceableMobile = this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile; serviceabilityDetails.isRecalibrationServiceableMobile;
},
async updateMobileZip() {
this.mobileZipError = '';
const zipCodeData = await getMobileZipCodeData(this.mobileZipCode);
if (!zipCodeData.isValid) {
this.mobileZipError = errorMessages.INVALID_ZIP;
} else if (!zipCodeData.isServiceable) {
this.mobileZipError = errorMessages.NO_SERVICE_IN_ZIP(this.mobileZipCode);
} else {
this.zipCode = this.mobileZipCode;
this.setCtuForMobile(zipCodeData.zipCodeCtu);
this.setContainsMilitaryBase(zipCodeData.containsMilitaryBase);
const serviceabilityDetailsPromise = getServiceabilityDetails(this.mobileZipCode);
serviceabilityDetailsPromise.then((result) => {
const details = result.data;
if (details) {
this.setServiceabilityDetails(details);
}
});
const providersPromise = useMainStore().getProviders(this.mobileZipCode);
providersPromise.then((result) => {
const providers = result.data;
if (providers) {
this.setMobileProviderNumber(providers.mobileProviderNumber);
if(providers.shopProviders.length > 0) {
this.selectedProvider = providers.shopProviders[0];
}
}
});
}
},
async handleInShopZipUpdated(newZip) {
this.zipCode = newZip;
const zipCodeData = await getZipCodeData(this.zipCode);
this.setCtuForMobile(zipCodeData.zipCodeCtu);
this.setContainsMilitaryBase(zipCodeData.containsMilitaryBase);
const serviceabilityDetailsPromise = getServiceabilityDetails(this.zipCode);
serviceabilityDetailsPromise.then((result) => {
const details = result.data;
if (details) {
this.setServiceabilityDetails(details);
this.showMobileOption = this.isServiceableMobile;
}
});
}
},
watch: {
mobileZipCode(newZip) {
if(newZip !== '') {
this.updateMobileZip();
}
},
selectedAppointmentType() {
this.mobileZipCode = '';
} }
} }
}; };
@ -510,7 +548,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
$page-side-padding: 1.5rem; $page-side-padding: 1.5rem;
.iss-heritage-container-width { .iss-heritage-container-width {
.service-location-container { .service-location-container {
position: relative; position: relative;
@ -524,42 +561,12 @@ $page-side-padding: 1.5rem;
.subheader { .subheader {
margin: 1.25rem 0; margin: 1.25rem 0;
} }
} .form-test-error {
font-weight: $font-weight-bold;
.question-text {
span {
line-height: 1.5rem;
text-align: center;
} }
} .mobile-service-zip-question {
.list-card-content p { :deep(.form-test-error span) {
&:first-of-type { font-weight: $font-weight-bold;
line-height: 1.5rem;
}
&:not(:nth-of-type(1)) {
line-height: 1.25rem;
}
}
.choose-option {
.button-question > div {
&:first-of-type {
margin-bottom: 0.95rem;
line-height: 1.5rem;
}
}
}
.button-question {
.row.form-test-error {
line-height: 1.5rem;
padding-left: 0rem !important;
}
}
.service-location-button-question {
.question-text {
margin-top: 1.5rem;
span {
text-align: center;
} }
} }
} }

View file

@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import serviceZipQuestion from '@/layouts/service-location/service-zip-question/service-zip-question.vue';
describe('service-zip-question.vue', () => { describe('service-zip-question.vue', () => {
it('Should get the modelValue', async () => { it('Should get the modelValue', async () => {
@ -12,8 +13,8 @@ describe('service-zip-question.vue', () => {
}); });
// Act // Act
const modelValueText = wrapper.vm.value; const modelValueText = wrapper.vm.internalZipcode;
wrapper.vm.value = 'test also'; wrapper.vm.internalZipcode = 'test also';
// Assert // Assert
expect(modelValueText).toEqual('test'); expect(modelValueText).toEqual('test');
@ -30,7 +31,8 @@ describe('service-zip-question.vue', () => {
}); });
// Act // Act
wrapper.vm.value = 'test also'; wrapper.vm.internalZipcode = 'test also';
wrapper.vm.updateModelValue();
// Assert // Assert
expect(wrapper.emitted('update:modelValue')).toEqual([['test also']]); expect(wrapper.emitted('update:modelValue')).toEqual([['test also']]);

View file

@ -7,6 +7,7 @@
cornerStyle="rounded" cornerStyle="rounded"
mask="#####" mask="#####"
isRequired isRequired
:hasError="hasError"
@clickEvent="updateModelValue" @clickEvent="updateModelValue"
validationRules="zip-required|zip-format" /> validationRules="zip-required|zip-format" />
</template> </template>
@ -21,7 +22,6 @@ import errorMessages from '@/constants/error-messages';
// Define Validation Rules // Define Validation Rules
defineRule('zip-required', required(errorMessages.SERVICE_ZIP_REQUIRED)); defineRule('zip-required', required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule('zip-format', regex(/^\d{5}$/, errorMessages.SERVICE_ZIP_FORMAT));
export default { export default {
name: 'service-zip-question', name: 'service-zip-question',
@ -31,7 +31,15 @@ export default {
// TODO: Correct prop def. // TODO: Correct prop def.
props: { props: {
modelValue: String, modelValue: String,
cmsWidgetName: String cmsWidgetName: String,
serviceZipFormatErrorMessage: {
type: String,
default: errorMessages.SERVICE_ZIP_FORMAT
},
hasError: {
type: Boolean,
default: false
}
}, },
data() { data() {
return { return {
@ -42,16 +50,20 @@ export default {
methods: { methods: {
updateModelValue() { updateModelValue() {
this.$emit('update:modelValue', this.internalZipcode); this.$emit('update:modelValue', this.internalZipcode);
this.internalZipcode = ''; }
}, },
mounted() {
defineRule('zip-format', regex(/^\d{5}$/, this.serviceZipFormatErrorMessage));
} }
}; };
</script> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
.textbox-question { .textbox-question.has-success {
:deep(.form-label) { :deep(input.form-control) {
font-weight: $font-weight-normal; border: $border-input;
color: $darker-gray; &:focus {
border: $border-input-focus;
}
}
} }
}
</style> </style>

View file

@ -6,13 +6,6 @@
v-if="isDisplayed" v-if="isDisplayed"
class="shop-address" class="shop-address"
aria-live="polite"> aria-live="polite">
<alert
v-if="displayDropoffInformation"
ref="alertDropoffInformation"
class="mb-4 drop-off-alert"
cmsWidgetName="AlertDropoffInformationWidget"
alertClass="alert-info"
:isDismissible="false" />
<div class="address-header">{{ questionText }}</div> <div class="address-header">{{ questionText }}</div>
<div class="current-address"> <div class="current-address">
<div class="address-line"> <div class="address-line">
@ -32,9 +25,9 @@
:ref="modalName" :ref="modalName"
:headerText="modalHeaderText" :headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened" :onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText" :footerButtonText="modalFooterText"
:modalPosition="modalPositions.center" :modalPosition="modalPositions.center"
:allowInvalidSubmit="true"
@footerButtonEvent="updateSelectedProvider"> @footerButtonEvent="updateSelectedProvider">
<googleMap <googleMap
id="map" id="map"
@ -49,7 +42,7 @@
:variant="dropdownVariants.compact" :variant="dropdownVariants.compact"
:options="searchRadiusOptions" /> :options="searchRadiusOptions" />
<buttonQuestion <buttonQuestion
ref="buttonQuestion" ref="shopListButtonQuestion"
v-model="internalProviderNumber" v-model="internalProviderNumber"
buttonTypeString="shopListButton" buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton" :buttonTypeObject="shopListButton"
@ -65,17 +58,18 @@
</span> </span>
<serviceZipQuestion <serviceZipQuestion
v-if="renderServiceZip" v-if="renderServiceZip"
ref="serviceZipCodeQuestion" :ref="SERVICE_ZIP_QUESTION_REF_NAME"
v-model="internalZipcode" v-model="internalZipcode"
customInputId="serviceZipCode" customInputId="serviceZipCode"
:cmsWidgetName="textboxQuestionWidgetName" /> :cmsWidgetName="textboxQuestionWidgetName" />
<alert <div
v-if="displayInvalidZipAlert" v-if="errorMessage"
ref="alertInvalidZip" ref="errorMessageDiv"
class="my-4" class="row my-1 form-test-error">
:cmsWidgetName="alertInvalidZipWidgetName" <span
alertClass="alert-danger" class="d-inline-flex mt-0"
:isDismissible="false" /> role="alert">{{ errorMessage }}</span>
</div>
</modal> </modal>
</div> </div>
</transition> </transition>
@ -96,11 +90,17 @@ import googleMap from '@/iss-components/google-map/google-map.vue';
import baseMixin from '@/mixins/base-mixin.js'; import baseMixin from '@/mixins/base-mixin.js';
import { toTitleCase } from '@/helpers/text-helper.js'; import { toTitleCase } from '@/helpers/text-helper.js';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { getServiceabilityDetails, getZipCodeData,getAvailabilityRating } from '@/helpers/service-location-helper'; import { getAvailabilityRating } from '@/helpers/service-location-helper';
import { useMainStore } from '@/store'; import { useMainStore } from '@/store';
import widgetFields from '@/constants/cms-widget-fields'; import widgetFields from '@/constants/cms-widget-fields';
import { dropdownVariants, modalPositions } from '@/constants/component-variants'; import { dropdownVariants, modalPositions } from '@/constants/component-variants';
import errorMessages from '@/constants/error-messages';
import { defineRule } from 'vee-validate';
import { required } from '@/helpers/validation-rules';
defineRule('option-required', required(errorMessages.PROVIDER_REQUIRED));
const SERVICE_ZIP_QUESTION_REF_NAME = 'serviceZipCodeQuestion';
export default { export default {
name: 'shop-address', name: 'shop-address',
@ -130,21 +130,21 @@ export default {
return { return {
toTitleCase, toTitleCase,
serviceZipCodeTextInputId: '', serviceZipCodeTextInputId: '',
displayInvalidZipAlert: false,
internalZipcode: this.serviceZipcode, internalZipcode: this.serviceZipcode,
internalProviderNumber: this.modelValue.providerNumber, internalProviderNumber: this.modelValue.providerNumber,
searchRadiusQuestionWidgetName: 'SearchRadiusQuestionWidget', searchRadiusQuestionWidgetName: 'SearchRadiusQuestionWidget',
textboxQuestionWidgetName: 'ChangeLocationZipQuestionWidget', textboxQuestionWidgetName: 'ChangeLocationZipQuestionWidget',
alertInvalidZipWidgetName: 'AlertInvalidZipWidget',
shopListButton: markRaw(shopListButton), shopListButton: markRaw(shopListButton),
searchRadiusInMiles: 100, searchRadiusInMiles: "25",
nearbyShops: [], nearbyShops: [],
modalPositions, modalPositions,
dropdownVariants, dropdownVariants,
renderServiceZip: true renderServiceZip: true,
errorMessage: '',
SERVICE_ZIP_QUESTION_REF_NAME
}; };
}, },
emits: ['update:modelValue'], emits: ['update:modelValue', 'zip-updated'],
computed: { computed: {
questionText() { questionText() {
return this.getCmsContent(this.cmsWidgetName, widgetFields.INPUT_QUESTION_WIDGET.QUESTION_TEXT); return this.getCmsContent(this.cmsWidgetName, widgetFields.INPUT_QUESTION_WIDGET.QUESTION_TEXT);
@ -199,12 +199,17 @@ export default {
}; };
}); });
}, },
searchRadiusOptions() { searchRadiusArray() {
const options = this.getCmsContent(this.searchRadiusQuestionWidgetName, widgetFields.INPUT_QUESTION_WIDGET.ANSWERS); const options = this.getCmsContent(this.searchRadiusQuestionWidgetName, widgetFields.INPUT_QUESTION_WIDGET.ANSWERS);
return options;
},
searchRadiusOptions() {
const optionsObj = {}; const optionsObj = {};
options.forEach(option => { if(this.searchRadiusArray) {
optionsObj[option.Name] = option.Text; this.searchRadiusArray.forEach(option => {
}); optionsObj[option.Name] = option.Text;
});
}
return optionsObj; return optionsObj;
}, },
providerAddresses() { providerAddresses() {
@ -217,19 +222,30 @@ export default {
}, },
watch: { watch: {
async internalZipcode() { async internalZipcode() {
await this.resetsOnZipInput(); const shopsUpdated = await this.updateShops();
if (shopsUpdated) {
this.$refs[SERVICE_ZIP_QUESTION_REF_NAME].internalZipcode = '';
}
}, },
async searchRadiusInMiles() { async searchRadiusInMiles() {
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles); await this.updateShops();
},
nearbyShops(newShops) {
if (!newShops.find(shop => shop.providerNumber === this.internalProviderNumber)) {
this.internalProviderNumber = '';
}
} }
}, },
methods: { methods: {
resetAlerts() { async updateShops() {
this.displayInvalidZipAlert = false; this.errorMessage = '';
}, const result = await this.getNearbyShops(this.searchRadiusInMiles);
async resetsOnZipInput() { if (result.length === 0) {
this.resetAlerts(); this.errorMessage = errorMessages.ZIP_CODE_NOT_SERVICED_FOR_VEHICLE;
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles); }
else {
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles);
}
}, },
openModal() { openModal() {
this.$refs[this.modalName].openModal(); this.$refs[this.modalName].openModal();
@ -239,26 +255,19 @@ export default {
}, },
onModalOpened() { onModalOpened() {
this.internalZipcode = this.serviceZipcode; this.internalZipcode = this.serviceZipcode;
this.searchRadiusInMiles = 25; this.searchRadiusInMiles = "25";
this.resetsOnZipInput().then(() => { this.updateShops().then(() => {
this.internalProviderNumber = this.nearbyShops[0]?.providerNumber; this.internalProviderNumber = this.nearbyShops[0]?.providerNumber;
}); });
this.forceServiceZipRerender(); this.forceServiceZipRerender();
}, },
onModalClosed() {
this.internalZipcode = this.serviceZipcode;
this.resetsOnZipInput().then(() => {
this.internalProviderNumber = this.nearbyShops[0]?.providerNumber;
});
},
async updateSelectedProvider() { async updateSelectedProvider() {
if (this.internalProviderNumber !== this.modelValue.providerNumber) { const selectedShop = this.nearbyShops.find(shop => shop.providerNumber === this.internalProviderNumber);
const selectedShop = this.nearbyShops.find(shop => shop.providerNumber === this.internalProviderNumber); if (selectedShop) {
if (selectedShop) { this.$emit('update:modelValue', selectedShop);
this.$emit('update:modelValue', selectedShop); this.$emit('zip-updated', this.internalZipcode);
} this.closeModal();
} }
this.closeModal();
}, },
async getNearbyShops(radiusInMiles) { async getNearbyShops(radiusInMiles) {
const result = await useMainStore().getProviders(this.internalZipcode, radiusInMiles); const result = await useMainStore().getProviders(this.internalZipcode, radiusInMiles);
@ -336,5 +345,22 @@ export default {
:deep(#map) { :deep(#map) {
height: 27rem; height: 27rem;
} }
:deep(.textbox-question) {
&.has-error {
input.form-control {
border: $border-input;
&:focus {
border: $border-input-focus;
}
}
}
.form-label {
font-weight: $font-weight-normal;
color: $darker-gray;
}
.btn-primary {
margin-bottom: 1.25rem;
}
}
} }
</style> </style>

View file

@ -1,294 +0,0 @@
<template>
<transition
name="fade"
mode="out-in">
<div
v-if="isDisplayed"
class="shop-question"
aria-live="polite">
<alert
v-if="displayDropoffInformation"
ref="alertDropoffInformation"
class="mb-4 drop-off-alert"
cmsWidgetName="AlertDropoffInformationWidget"
alertClass="alert-info"
:isDismissible="false" />
<buttonQuestion
ref="buttonQuestion"
v-model="selectedProviderNumber"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
class="radioQuestion"
:questionText="questionText"
:answers="answers"
groupName="chooseShop"
textPosition="text-start"
isRequired
validationRules="option-required"
:additionalButtonData="additionalButtonData" />
<textLink
v-show="displaySeeMoreLocationsLink"
id="showMoreShopsId"
ref="showMoreShopsLink"
class="show-more-shops-link"
cmsWidgetName="ShowMoreShopsLinkWidget"
linkType="text"
:text="showMoreShopsLinkText"
href="#!"
:aria-label="showMoreShopsLinkText"
@clickEvent="getNextShopsFromList" />
</div>
</transition>
</template>
<script>
// Components
import alert from '@/ux-components/alert/alert.vue';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import textLink from '@/ux-components/text-link/text-link.vue';
// Supporting files
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import baseMixin from '@/mixins/base-mixin.js';
import { defineRule } from 'vee-validate';
import { required } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages';
import { useMainStore } from '@/store/index.js';
import { markRaw, nextTick } from 'vue';
import { getAvailabilityRating } from '@/helpers/service-location-helper';
import shopListButton from '@/iss-components/shop-list-button/shop-list-button.vue';
defineRule('option-required', required(errorMessages.OPTION_REQUIRED));
export default {
name: 'shop-question',
components: {
alert,
buttonQuestion,
textLink
},
mixins: [baseMixin],
props: {
modelValue: {
type: Object,
default: () => null
},
selectedAppointmentType: String,
cmsWidgetName: String,
validationRules: String,
isDisplayed: Boolean
},
emits: ['update:modelValue', 'updatedMobileProviderNumber'],
data() {
return {
shopProviders: [],
shopListButton: markRaw(shopListButton),
answers: [],
shopIndex: 0,
displaySeeMoreLocationsLink: false
};
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValue: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
},
selectedProviderNumber: {
get() {
return this.selectedValue?.providerNumber;
},
set(newValue) {
// Button Question only supports Number, or String data types so we must get the full object to emit
this.selectedValue = this.getSelectedProviderObject(newValue);
}
},
displayDropoffInformation() {
return this.selectedAppointmentType === 'Dropoff';
},
showMoreShopsLinkText() {
return this.getCmsContent('ShowMoreShopsLinkWidget', 'Text');
},
additionalButtonData() {
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + 6);
const formattedStartDate = startDate.toISOString().split('T')[0];
const formattedEndDate = endDate.toISOString().split('T')[0];
return {
displayAvailabilityIndicators: true,
availabilityRatingCallback: getAvailabilityRating,
startDate: formattedStartDate,
endDate: formattedEndDate,
shopAppointmentType: this.selectedAppointmentType
};
}
},
watch: {
selectedAppointmentType: {
async handler(newValue) {
this.resetAnswers();
await nextTick();
this.selectedProviderNumber = null;
await nextTick();
if (newValue !== AppointmentTypeStrings.MOBILE && newValue !== AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP) {
this.getNextShopsFromList();
}
}
},
shopProviders: {
async handler(newValue) {
await nextTick();
if (this.selectedAppointmentType) {
const selectedShopIndex = this.getSelectedProviderIndex(
newValue,
this.selectedProviderNumber
);
if (selectedShopIndex >= 3) {
await this.getNextShopsFromList(selectedShopIndex + 1);
} else {
await this.getNextShopsFromList();
await nextTick();
}
}
}
}
},
methods: {
loadInitialData(serviceZipCode) {
return this.loadData(serviceZipCode);
},
loadData(serviceZipCode) {
return useMainStore().getProviders(serviceZipCode);
},
initializeComponent(shopQuestionInitialData) {
this.shopProviders = shopQuestionInitialData.shopProviders;
},
async getNextShopsFromList(numberToGet = 3) {
const shopIterator = (array, n) => {
const l = array.length;
return () => {
const end = this.shopIndex + n;
const part = array.slice(this.shopIndex, end);
this.shopIndex = end < l ? end : this.shopProviders.length;
return part;
};
};
const toTitleCase = (str) => str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
const nextShop = shopIterator(this.shopProviders, numberToGet);
// Map API result data
const mappedData = nextShop().map((shopProvider) => {
const streetAddress = toTitleCase(shopProvider.address.streetAddress);
const city = toTitleCase(shopProvider.address.city);
const { state } = shopProvider.address;
const { zipCode } = shopProvider.address;
const distanceInMiles = Math.round(shopProvider.distanceInMiles * 2) / 2;
return {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
value: shopProvider.providerNumber
};
});
if (this.answers.length === 0) {
this.answers = mappedData;
} else {
mappedData.forEach((shop) => {
this.answers.push(shop);
});
}
await nextTick();
if (this.shopIndex === this.shopProviders.length) {
this.displaySeeMoreLocationsLink = false;
} else {
this.displaySeeMoreLocationsLink = true;
}
await nextTick();
this.scrollToPageBottom();
},
resetAnswers() {
this.answers = [];
this.shopIndex = 0;
},
async reloadShopData(serviceZipCode) {
const result = await this.loadData(serviceZipCode);
this.initializeComponent(result.data);
if (result.data?.mobileProviderNumber
&& (this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP)) {
this.$emit('updatedMobileProviderNumber', result.data.mobileProviderNumber);
}
this.resetAnswers();
await nextTick();
await this.getNextShopsFromList();
},
getSelectedProviderObject(providerNumber) {
const provider = this.shopProviders?.find((p) => p.providerNumber === providerNumber);
return provider;
},
getSelectedProviderIndex(providers, selectedProviderNumber) {
const index = providers.findIndex((p) => p.providerNumber === selectedProviderNumber);
return index;
}
}
};
</script>
<style lang="scss">
@import "@/styles/ux-variables-svg-strings.scss";
.shop-question {
margin-top: 1rem;
text-align: center;
.button-question {
.question-text {
margin-top: 0.5rem;
}
}
.drop-off-alert {
background-image: url($svg-drop-off-alert);
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem !important;
gap: 0.25rem;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
}
</style>

View file

@ -1798,7 +1798,6 @@ export const useMainStore = defineStore({
this.order.vehicle.registration.lastName = registrationInfo?.lastName; this.order.vehicle.registration.lastName = registrationInfo?.lastName;
}, },
updateServiceLocation(serviceLocationInfo) { updateServiceLocation(serviceLocationInfo) {
console.log('Updating service location info:', serviceLocationInfo);
this.order.serviceLocation.address = serviceLocationInfo.address; this.order.serviceLocation.address = serviceLocationInfo.address;
this.order.serviceLocation.address2 = serviceLocationInfo.address2; this.order.serviceLocation.address2 = serviceLocationInfo.address2;
this.order.serviceLocation.city = serviceLocationInfo.city; this.order.serviceLocation.city = serviceLocationInfo.city;
@ -2708,7 +2707,6 @@ export const useMainStore = defineStore({
}, },
saveServiceLocation(serviceLocationInfo) { saveServiceLocation(serviceLocationInfo) {
console.log('Saving service location info:', serviceLocationInfo);
if (this.order.serviceLocation) { if (this.order.serviceLocation) {
if ( if (
serviceLocationInfo.zipCode !== this.order.serviceLocation.zipCode serviceLocationInfo.zipCode !== this.order.serviceLocation.zipCode
@ -2719,7 +2717,6 @@ export const useMainStore = defineStore({
|| serviceLocationInfo.appointmentType || serviceLocationInfo.appointmentType
!== this.order.serviceLocation.appointmentType !== this.order.serviceLocation.appointmentType
) { ) {
console.log('Service location info has changed, resetting dependent state.');
this.resetSchedule(); this.resetSchedule();
} }
} }

View file

@ -115,21 +115,3 @@ caption,
font-weight: 800; font-weight: 800;
font-style: normal; font-style: normal;
} }
@font-face {
font-family: "Urbanist";
src:
url("@/assets/fonts/Urbanist-MediumItalic.woff2") format("woff2"),
url("@/assets/fonts/Urbanist-MediumItalic.woff") format("woff");
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: "Urbanist";
src:
url("@/assets/fonts/Urbanist-BoldItalic.woff2") format("woff2"),
url("@/assets/fonts/Urbanist-BoldItalic.woff") format("woff");
font-weight: 700;
font-style: italic;
}

View file

@ -76,6 +76,7 @@ $heritage-blue-secondary: #167cac;
// Darker gray // Darker gray
$darker-gray: #525656; $darker-gray: #525656;
$lighter-gray: #9aa1a3;
// scss-docs-start colors-map // scss-docs-start colors-map
$colors: ( $colors: (
@ -220,5 +221,5 @@ $enable-important-utilities: false;
// Letter Spacing // Letter Spacing
$letter-spacing-primary: 0.03em; $letter-spacing-primary: 0.03em;
// Borders // Borders
$border-input: 1px solid rgba(179, 180, 181); $border-input: 1px solid $gray-350;
$border-input-focus: 2.5px solid $heritage-blue-primary; $border-input-focus: 2.5px solid $heritage-blue-primary;