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.',
SERVICE_ZIP_REQUIRED: 'ZIP code is required',
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_FORMAT:
// eslint-disable-next-line max-len

View file

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

View file

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

View file

@ -51,7 +51,7 @@ export default {
props: {
customText: String, // used to allow the insert of token values into textblock
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)
fontWeight: String, // bold=500, default is 400
cmsWidgetName: String,
@ -109,5 +109,12 @@ export default {
&.dark {
color: $black;
}
&.disclaimer {
color: $lighter-gray;
font-weight: $font-weight-light;
font-size: .8125rem;
font-style: italic;
margin-top: .625rem;
}
}
</style>

View file

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

View file

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

View file

@ -2,13 +2,13 @@
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">
<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>
`;
exports[`Shop list button should render correctly with required props 1`] = `
<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>
`;

View file

@ -57,16 +57,12 @@ export default {
return retCms;
},
answersToDisplay() {
const shouldShowMobile = this.isServiceableMobile && this.isItacOrNoComp;
const shouldShowMobileNotITACNotNoComp = this.isServiceableMobile && !this.isItacOrNoComp;
const shouldShowMobile = this.isServiceableMobile;
const shouldShowInshop = this.isServiceableInshop;
const shouldShowDropoff = this.isServiceableInshop && !useMainStore().damage.isRepair;
return this.answersFromCms
? this.answersFromCms.filter((answer) => (
(answer.Name === AppointmentTypeStrings.IN_SHOP && shouldShowInshop)
|| (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 (
newValue.length === 1
&& newValue.findIndex((answer) => (answer.Name === AppointmentTypeStrings.MOBILE
|| answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP)) !== -1
&& newValue.findIndex((answer) => (answer.Name === AppointmentTypeStrings.MOBILE)) !== -1
) {
if (this.isItacOrNoComp) {
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
this.selectedValues = AppointmentTypeStrings.MOBILE;
}
},
immediate: true
@ -112,11 +103,7 @@ export default {
isMobileOnly: {
handler(newValue) {
if (newValue) {
if (this.isItacOrNoComp) {
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
this.selectedValues = AppointmentTypeStrings.MOBILE;
}
}
}

View file

@ -62,26 +62,39 @@
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" />
</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
v-if="isInshop"
v-model="selectedProvider"
:serviceZipcode="zipCode"
:isDisplayed="isInshop"
:selectedAppointmentType="selectedAppointmentType"
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
:ref="RECAL_MODAL_REF_NAME"
cssModalHeadlineClass="text-center"
@ -111,35 +124,23 @@ import { useMainStore } from '@/store';
import {
getPricedMobileFeePart,
getServiceabilityDetails,
getZipCodeData
getZipCodeData,
getMobileZipCodeData
} from '@/helpers/service-location-helper';
// Import Component
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 baseFormMixin from '@/mixins/base-form-mixin';
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 siteFooter from '@/iss-components/site-footer/site-footer.vue';
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import shopAddress from './shop-address/shop-address.vue';
// DEFINE VALIDATION RULES
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));
import shopAddress from '@/layouts/service-location/shop-address/shop-address.vue';
import serviceZipQuestion from '@/layouts/service-location/service-zip-question/service-zip-question.vue';
import widgetFields from '@/constants/cms-widget-fields';
const RECAL_MODAL_REF_NAME = 'RecalModal';
const SITE_FOOTER_REF_NAME = 'siteFooter';
@ -148,15 +149,16 @@ export default {
name: 'service-location',
components: {
alert,
textBlock,
appointmentTypeQuestion,
contentGroupModal,
mobileLocationModalQuestions,
siteFooter,
siteHeader,
siteSubHeader,
// eslint-disable-next-line vue/no-reserved-component-names
Form,
shopAddress
shopAddress,
serviceZipQuestion
},
mixins: [baseFormMixin],
async beforeRouteEnter(to, from, next) {
@ -232,8 +234,12 @@ export default {
mobileProviderNumber: null,
zipContainsMilitaryBase: false,
zipCodeCtu: null,
mobileZipCode: '',
mobileZipError: '',
showMobileOption: false,
RECAL_MODAL_REF_NAME,
SITE_FOOTER_REF_NAME
SITE_FOOTER_REF_NAME,
errorMessages
};
},
computed: {
@ -246,35 +252,6 @@ export default {
answersFromCms() {
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() {
if (this.isRecalibrationServiceableMobile !== null) {
return (
@ -301,15 +278,10 @@ export default {
);
},
isAppointmentTypeDisplayed() {
return this.zipCode && !this.displayNoShopsAlert;
return this.zipCode && !this.displayNoShopsAlert && this.showMobileOption;
},
isMobile() {
return (
this.selectedAppointmentType
=== AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType
=== AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP
);
return this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
},
requiresInshopRecalibration() {
// Specifically check for isRecalibrationServiceableMobile === false, not null or true.
@ -341,6 +313,9 @@ export default {
displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
},
displayMobileFeeDisclaimer() {
return this.mainStore.isNoComp || this.mainStore.isITAC;
},
navigateBackScenario() {
const { isNoComp, isITAC } = useMainStore();
return isNoComp || isITAC
@ -349,7 +324,10 @@ export default {
},
isBigTruck() {
return this.mainStore.order.vehicle.isBigTruck;
}
},
mobileZipPlaceholder() {
return this.getCmsContent('MobileZipPlaceHolderWidget', widgetFields.TEXT_BLOCK_WIDGET.TEXT);
},
},
methods: {
arePagePrerequisitesValid() {
@ -445,6 +423,7 @@ export default {
if (serviceabilityDetails) {
this.setServiceabilityDetails(serviceabilityDetails);
this.showMobileOption = this.isServiceableMobile;
}
if (mobileFeePart) {
@ -503,6 +482,65 @@ export default {
serviceabilityDetails.isGlassServiceableMobile;
this.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>
$page-side-padding: 1.5rem;
.iss-heritage-container-width {
.service-location-container {
position: relative;
@ -524,42 +561,12 @@ $page-side-padding: 1.5rem;
.subheader {
margin: 1.25rem 0;
}
}
.question-text {
span {
line-height: 1.5rem;
text-align: center;
.form-test-error {
font-weight: $font-weight-bold;
}
}
.list-card-content p {
&:first-of-type {
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;
.mobile-service-zip-question {
:deep(.form-test-error span) {
font-weight: $font-weight-bold;
}
}
}

View file

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

View file

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

View file

@ -6,13 +6,6 @@
v-if="isDisplayed"
class="shop-address"
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="current-address">
<div class="address-line">
@ -32,9 +25,9 @@
:ref="modalName"
:headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:modalPosition="modalPositions.center"
:allowInvalidSubmit="true"
@footerButtonEvent="updateSelectedProvider">
<googleMap
id="map"
@ -49,7 +42,7 @@
:variant="dropdownVariants.compact"
:options="searchRadiusOptions" />
<buttonQuestion
ref="buttonQuestion"
ref="shopListButtonQuestion"
v-model="internalProviderNumber"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
@ -65,17 +58,18 @@
</span>
<serviceZipQuestion
v-if="renderServiceZip"
ref="serviceZipCodeQuestion"
:ref="SERVICE_ZIP_QUESTION_REF_NAME"
v-model="internalZipcode"
customInputId="serviceZipCode"
:cmsWidgetName="textboxQuestionWidgetName" />
<alert
v-if="displayInvalidZipAlert"
ref="alertInvalidZip"
class="my-4"
:cmsWidgetName="alertInvalidZipWidgetName"
alertClass="alert-danger"
:isDismissible="false" />
<div
v-if="errorMessage"
ref="errorMessageDiv"
class="row my-1 form-test-error">
<span
class="d-inline-flex mt-0"
role="alert">{{ errorMessage }}</span>
</div>
</modal>
</div>
</transition>
@ -96,11 +90,17 @@ import googleMap from '@/iss-components/google-map/google-map.vue';
import baseMixin from '@/mixins/base-mixin.js';
import { toTitleCase } from '@/helpers/text-helper.js';
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 widgetFields from '@/constants/cms-widget-fields';
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 {
name: 'shop-address',
@ -130,21 +130,21 @@ export default {
return {
toTitleCase,
serviceZipCodeTextInputId: '',
displayInvalidZipAlert: false,
internalZipcode: this.serviceZipcode,
internalProviderNumber: this.modelValue.providerNumber,
searchRadiusQuestionWidgetName: 'SearchRadiusQuestionWidget',
textboxQuestionWidgetName: 'ChangeLocationZipQuestionWidget',
alertInvalidZipWidgetName: 'AlertInvalidZipWidget',
shopListButton: markRaw(shopListButton),
searchRadiusInMiles: 100,
searchRadiusInMiles: "25",
nearbyShops: [],
modalPositions,
dropdownVariants,
renderServiceZip: true
renderServiceZip: true,
errorMessage: '',
SERVICE_ZIP_QUESTION_REF_NAME
};
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'zip-updated'],
computed: {
questionText() {
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);
return options;
},
searchRadiusOptions() {
const optionsObj = {};
options.forEach(option => {
optionsObj[option.Name] = option.Text;
});
if(this.searchRadiusArray) {
this.searchRadiusArray.forEach(option => {
optionsObj[option.Name] = option.Text;
});
}
return optionsObj;
},
providerAddresses() {
@ -217,19 +222,30 @@ export default {
},
watch: {
async internalZipcode() {
await this.resetsOnZipInput();
const shopsUpdated = await this.updateShops();
if (shopsUpdated) {
this.$refs[SERVICE_ZIP_QUESTION_REF_NAME].internalZipcode = '';
}
},
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: {
resetAlerts() {
this.displayInvalidZipAlert = false;
},
async resetsOnZipInput() {
this.resetAlerts();
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles);
async updateShops() {
this.errorMessage = '';
const result = await this.getNearbyShops(this.searchRadiusInMiles);
if (result.length === 0) {
this.errorMessage = errorMessages.ZIP_CODE_NOT_SERVICED_FOR_VEHICLE;
}
else {
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles);
}
},
openModal() {
this.$refs[this.modalName].openModal();
@ -239,26 +255,19 @@ export default {
},
onModalOpened() {
this.internalZipcode = this.serviceZipcode;
this.searchRadiusInMiles = 25;
this.resetsOnZipInput().then(() => {
this.searchRadiusInMiles = "25";
this.updateShops().then(() => {
this.internalProviderNumber = this.nearbyShops[0]?.providerNumber;
});
this.forceServiceZipRerender();
},
onModalClosed() {
this.internalZipcode = this.serviceZipcode;
this.resetsOnZipInput().then(() => {
this.internalProviderNumber = this.nearbyShops[0]?.providerNumber;
});
},
async updateSelectedProvider() {
if (this.internalProviderNumber !== this.modelValue.providerNumber) {
const selectedShop = this.nearbyShops.find(shop => shop.providerNumber === this.internalProviderNumber);
if (selectedShop) {
this.$emit('update:modelValue', selectedShop);
}
const selectedShop = this.nearbyShops.find(shop => shop.providerNumber === this.internalProviderNumber);
if (selectedShop) {
this.$emit('update:modelValue', selectedShop);
this.$emit('zip-updated', this.internalZipcode);
this.closeModal();
}
this.closeModal();
},
async getNearbyShops(radiusInMiles) {
const result = await useMainStore().getProviders(this.internalZipcode, radiusInMiles);
@ -336,5 +345,22 @@ export default {
:deep(#map) {
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>

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

View file

@ -115,21 +115,3 @@ caption,
font-weight: 800;
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: #525656;
$lighter-gray: #9aa1a3;
// scss-docs-start colors-map
$colors: (
@ -220,5 +221,5 @@ $enable-important-utilities: false;
// Letter Spacing
$letter-spacing-primary: 0.03em;
// Borders
$border-input: 1px solid rgba(179, 180, 181);
$border-input: 1px solid $gray-350;
$border-input-focus: 2.5px solid $heritage-blue-primary;