Initial addition of "More Locations" modal

This commit is contained in:
Alex Humphries 2026-01-07 10:19:36 -05:00
parent a43480dd02
commit cc3ed87815
12 changed files with 285 additions and 81 deletions

View file

@ -329,7 +329,7 @@ export default {
}
.question-text {
margin-bottom: .625rem;
margin-bottom: 1rem;
font-size: 1rem;
line-height: 1.625rem;

View file

@ -17,11 +17,11 @@
role="dialog"
aria-modal="true">
<div class="modal-header mb-2 mt-2">
<label
<span
v-if="headerText"
class="modal-title d-flex justify-content-center pb-0 w-100">
{{ headerText }}
</label>
class="modal-title d-flex justify-content-center pb-0 w-100"
:innerHTML="headerText">
</span>
<button
ref="closeButton"
type="button"

View file

@ -63,7 +63,8 @@ export default {
[headerKeys.TRANSACTION_ID]: crypto.randomUUID(),
[headerKeys.ENTERPRISE_ORDER_NUMBER]: store.order.eon,
[headerKeys.REFERRAL_SEQUENCE_NUMBER]: store.order.referralSequenceNumber,
[headerKeys.SESSION_SEQUENCE_NUMBER]: sessionKey
[headerKeys.SESSION_SEQUENCE_NUMBER]: sessionKey,
'Cache-Control': 'no-cache, no-store, max-age=1'
};
axios({

View file

@ -13,15 +13,6 @@
<siteSubHeader
cmsWidgetName="SiteSubHeaderWidget"
class="subheader" />
<!-- <serviceZipModalQuestion
ref="serviceZipCodeQuestion"
v-model="serviceZipCodeQuestion"
modalWidgetName="ServiceZipModalWidget"
:onZipUpdateCallback="reloadShopData"
@updatedServiceability="setServiceabilityDetails"
@updatedContainsMilitaryBase="
setContainsMilitaryBase
" /> -->
<alert
v-if="displayMilitaryZipAlert"
ref="alertMilitaryBaseZip"
@ -72,7 +63,7 @@
validationRules="option-required" />
</div>
<mobileLocationModalQuestions
v-if="isMobileLocationDisplayed"
v-if="isMobile"
ref="mobileLocationQuestions"
v-model="mobileLocationQuestions"
customComponentId="mobileLocationQuestions"
@ -80,21 +71,16 @@
validationRules="mobile-location-required"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
:onZipUpdateCallback="reloadShopData"
@updated-mobile-fee-part="setMobileFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
@updated-mobile-ctu="setCtuForMobile" />
<shopQuestion
v-show="isShopQuestionDisplayed"
:ref="SHOP_QUESTION_REF_NAME"
<shopAddress
v-model="selectedProvider"
:selectedAppointmentType="selectedAppointmentType"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget"
@updatedMobileProviderNumber="
setMobileProviderNumber
" />
:isDisplayed="isInshop"
:selectAppointmentType="selectedAppointmentType"
modalWidgetName="ChangeLocationModalWidget"
cmsWidgetName="ShopAddressWidget" />
<contentGroupModal
:ref="RECAL_MODAL_REF_NAME"
cssModalHeadlineClass="text-center"
@ -134,11 +120,11 @@ 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 shopQuestion from '@/layouts/service-location/shop-question/shop-question.vue';
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 serviceZipModalQuestion from '@/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue';
import shopAddress from './shop-address/shop-address.vue';
// DEFINE VALIDATION RULES
defineRule('mobile-location-required', (value) => {
@ -155,7 +141,6 @@ defineRule('mobile-location-required', (value) => {
});
defineRule('selection-required', required(errorMessages.OPTION_REQUIRED));
const SHOP_QUESTION_REF_NAME = 'shopQuestion';
const RECAL_MODAL_REF_NAME = 'RecalModal';
const SITE_FOOTER_REF_NAME = 'siteFooter';
@ -172,7 +157,7 @@ export default {
// eslint-disable-next-line vue/no-reserved-component-names
Form,
serviceZipModalQuestion,
shopQuestion
shopAddress
},
mixins: [baseFormMixin],
async beforeRouteEnter(to, from, next) {
@ -182,8 +167,8 @@ export default {
const zipCodeData = getZipCodeData(serviceZipCode);
const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode);
const serviceabilityDetailsPromise = getServiceabilityDetails(serviceZipCode);
const shopQuestionInitialDataPromise = shopQuestion.methods.loadInitialData(serviceZipCode);
const getGlassFeesPromise = useMainStore().getGlassFees();
const providersPromise = useMainStore().getProviders(serviceZipCode);
// Settle promises and get results
const promiseResultMap = [
@ -203,13 +188,13 @@ export default {
resultKey: 'serviceabilityDetails',
promise: serviceabilityDetailsPromise
},
{
resultKey: 'shopQuestionInitialData',
promise: shopQuestionInitialDataPromise
},
{
resultKey: 'zipCodeData',
promise: zipCodeData
},
{
resultKey: 'providers',
promise: providersPromise
}
];
@ -221,10 +206,9 @@ export default {
resultMap.zipCodeData,
resultMap.serviceabilityDetails,
resultMap.mobileFeePart,
resultMap.shopQuestionInitialData?.mobileProviderNumber,
resultMap.glassFees
resultMap.glassFees,
resultMap.providers
);
vm.$refs[SHOP_QUESTION_REF_NAME].initializeComponent(resultMap.shopQuestionInitialData);
});
},
setup() {
@ -249,7 +233,6 @@ export default {
mobileProviderNumber: null,
zipContainsMilitaryBase: false,
zipCodeCtu: null,
SHOP_QUESTION_REF_NAME,
RECAL_MODAL_REF_NAME,
SITE_FOOTER_REF_NAME
};
@ -264,27 +247,6 @@ export default {
answersFromCms() {
return this.getCmsContent('ServiceTypeQuestionWidget', 'Answers');
},
serviceZipCodeQuestion: {
get() {
return {
state: this.state,
zipCode: this.zipCode
};
},
set(newValue) {
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation();
this.selectedAppointmentType = null;
this.selectedProvider = null;
}
this.state = newValue.state;
this.zipCode = newValue.zipCode;
// eslint-disable-next-line vue/valid-next-tick
this.$nextTick();
}
},
mobileLocationQuestions: {
get() {
return {
@ -307,12 +269,7 @@ export default {
this.isVehicleProtected = newValue.isVehicleProtected;
if (newValue.zipCode !== this.zipCode) {
if (
!(
this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP
)
) {
if (!this.isMobile) {
this.selectedAppointmentType = null;
}
this.selectedProvider = null;
@ -338,7 +295,7 @@ export default {
return this.isGlassServiceableInshop;
},
isShopQuestionDisplayed() {
isInshop() {
return (
this.selectedAppointmentType === 'Inshop'
|| this.selectedAppointmentType === 'Dropoff'
@ -347,7 +304,7 @@ export default {
isAppointmentTypeDisplayed() {
return this.zipCode && !this.displayNoShopsAlert;
},
isMobileLocationDisplayed() {
isMobile() {
return (
this.selectedAppointmentType
=== AppointmentTypeStrings.MOBILE
@ -401,9 +358,6 @@ export default {
useMainStore().order.serviceLocation.zipCode !== null
);
},
async reloadShopData(zipCode) {
await this.$refs[SHOP_QUESTION_REF_NAME].reloadShopData(zipCode);
},
async forwardButtonAction() {
let provider = this.selectedProvider;
this.mainStore.updateMobileFee(null);
@ -482,8 +436,8 @@ export default {
zipCodeData,
serviceabilityDetails,
mobileFeePart,
mobileProviderNumber,
glassFees
glassFees,
providers
) {
if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
@ -498,8 +452,11 @@ export default {
this.mobileFeePart = mobileFeePart;
}
if (mobileProviderNumber) {
this.setMobileProviderNumber(mobileProviderNumber);
if (providers) {
this.setMobileProviderNumber(providers.mobileProviderNumber);
if(providers.shopProviders.length > 0) {
this.selectedProvider = providers.shopProviders[0];
}
}
if (glassFees) {

View file

@ -34,7 +34,7 @@
<script>
import textLink from '@/ux-components/text-link/text-link.vue';
import serviceZipQuestion from '@/layouts/service-location/service-zip-modal-question/service-zip-question/service-zip-question.vue';
import serviceZipQuestion from '@/layouts/service-location/service-zip-question/service-zip-question.vue';
import modal from '@/digital-components/modal/modal.vue';
import alert from '@/ux-components/alert/alert.vue';

View file

@ -22,7 +22,7 @@ import errorMessages from '@/constants/error-messages';
// Define Validation Rules
defineRule('zip-required', required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule('zip-format', regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
defineRule('zip-format', regex(/^\d{5}$/, errorMessages.SERVICE_ZIP_FORMAT));
export default {
name: 'service-zip-question',

View file

@ -0,0 +1,247 @@
<template>
<transition
name="fade"
mode="out-in">
<div
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">
<span class="shop-name">{{ displayedShopName }}</span>
<span class="shop-distance">{{ modelValue.distanceInMiles.toFixed(2) }} mi</span>
</div>
<div class="address-line">{{ toTitleCase(modelValue.address.streetAddress) }}</div>
<div class="address-line">
{{ toTitleCase(modelValue.address.city) }}, {{ modelValue.address.state }} {{ modelValue.address.zipCode }}
</div>
</div>
<buttonMain
:buttonText="showMoreShopsLinkText"
:variant="'primary'"
@clickEvent="openModal" />
<modal
:ref="modalName"
:headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
@footerButtonEvent="updateSelectedProvider">
<buttonQuestion
ref="buttonQuestion"
v-model="internalProviderNumber"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
class="radioQuestion"
:questionText="questionText"
:answers="nearbyShops"
groupName="chooseShop"
textPosition="text-start"
isRequired
validationRules="option-required"
:additionalButtonData="additionalButtonData" />
<serviceZipQuestion
ref="serviceZipCodeQuestion"
v-model="internalZipcode"
customInputId="serviceZipCode"
:cmsWidgetName="textboxQuestionWidgetName"
v-on="{ 'textboxQuestionEvent.inputIdAssigned': onInputIdAssigned }" />
<alert
v-if="displayInvalidZipAlert"
ref="alertInvalidZip"
class="my-4"
:cmsWidgetName="alertInvalidZipWidgetName"
alertClass="alert-danger"
:isDismissible="false" />
</modal>
</div>
</transition>
</template>
<script>
// Components
import alert from '@/ux-components/alert/alert.vue';
import buttonMain from '@/ux-components/button-main/button-main.vue';
import modal from '@/digital-components/modal/modal.vue';
import serviceZipQuestion from '@/layouts/service-location/service-zip-question/service-zip-question.vue';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import shopListButton from '@/iss-components/shop-list-button/shop-list-button.vue';
// Supporting files
import baseMixin from '@/mixins/base-mixin.js';
import { toTitleCase } from '@/helpers/text-helper.js';
import { markRaw, nextTick } from 'vue';
import { getServiceabilityDetails, getZipCodeData,getAvailabilityRating } from '@/helpers/service-location-helper';
import { useMainStore } from '@/store';
export default {
name: 'shop-address',
components: {
alert,
buttonMain,
modal,
serviceZipQuestion,
buttonQuestion
},
mixins: [baseMixin],
props: {
modelValue: {
type: Object,
default: () => null
},
cmsWidgetName: String,
validationRules: String,
isDisplayed: Boolean,
modalWidgetName: String,
selectedAppointmentType: String
},
data() {
return {
toTitleCase,
serviceZipCodeTextInputId: '',
displayInvalidZipAlert: false,
internalZipcode: this.modelValue.address.zipCode,
internalProviderNumber: this.modelValue.providerNumber,
textboxQuestionWidgetName: 'ChangeLocationZipQuestionWidget',
alertInvalidZipWidgetName: 'AlertInvalidZipWidget',
shopListButton: markRaw(shopListButton),
searchRadiusInMiles: 25,
nearbyShops: this.getNearbyShops(25)
};
},
emits: ['update:modelValue'],
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
showMoreShopsLinkText() {
return this.getCmsContent('ShowMoreShopsLinkWidget', 'Text');
},
displayedShopName() {
if(this.modelValue.isSafeliteShop) {
return this.getCmsContent('SafeliteShopNameWidget', 'Text');
}
return toTitleCase(this.modelValue.companyName);
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, 'HeaderText').replaceAll('{custom:serviceZipcode}', this.internalZipcode);
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, 'FooterText');
},
modalName() {
return this.modalWidgetName;
},
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: {
internalZipcode(newZipcode) {
this.resetsOnZipInput();
},
},
methods: {
resetAlerts() {
this.displayInvalidZipAlert = false;
},
async resetsOnZipInput() {
this.resetAlerts();
this.nearbyShops = await this.getNearbyShops(this.searchRadiusInMiles);
},
focusOnZipInput() {
const input = document.getElementById(this.serviceZipCodeTextInputId);
input?.focus();
},
openModal() {
this.$refs[this.modalName].openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
},
onInputIdAssigned(inputId) {
this.serviceZipCodeTextInputId = inputId;
},
onModalOpened() {
this.internalZipcode = this.modelValue.address.zipCode;
this.internalProviderNumber = this.modelValue.providerNumber;
this.focusOnZipInput();
},
onModalClosed() {
this.internalZipcode = this.modelValue.address.zipCode;
this.internalProviderNumber = this.modelValue.providerNumber;
this.resetsOnZipInput();
},
async updateSelectedProvider() {
// TODO: Do something with the zipcode?
},
async getNearbyShops(radiusInMiles) {
const result = await useMainStore().getProviders(this.internalZipcode, radiusInMiles);
const mappedData = result.data.shopProviders.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
};
});
console.log('Internal provider number after getting shops:', this.internalProviderNumber);
return mappedData;
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/ux-variables-svg-strings.scss";
.shop-address {
.address-header {
font-weight: 600;
margin-bottom: 0.625rem;
}
.address-line {
display: flex;
}
.shop-distance {
margin-left: auto;
}
.current-address {
margin: .625rem 0;
}
:deep(.modal-title) {
font-weight: 400;
display: block;
}
}
</style>

View file

@ -1011,14 +1011,13 @@ export const useMainStore = defineStore({
});
},
getProviders(serviceZipCode) {
getProviders(serviceZipCode, shopRadiusInMiles = 100) {
const { carId, isBigTruck } = this.order.vehicle;
const damageType = this.damage.isRepair ? 'Repair' : 'Replace';
const { parentAccountNumber } = this.order;
const partsWithRecal = getTopLevelGlassPartsWithRecal(this.order.lineItems.glassParts);
const windshieldPartWithRecal = partsWithRecal?.length > 0 ? partsWithRecal[0] : null;
const safeliteOnly = true;
const shopRadiusInMiles = 100;
let url = `${endpoints.GetProviders.url}/${serviceZipCode}/${damageType}/${shopRadiusInMiles}/${parentAccountNumber}/${safeliteOnly}/${carId}`;
if (windshieldPartWithRecal) {

View file

@ -145,7 +145,7 @@ $font-weight-lighter: lighter;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-bold: 500;
$font-weight-bolder: bolder;
$font-weight-bolder: 600;
//Border Radius

View file

@ -85,7 +85,7 @@ export default {
color: $black;
font-weight: 500;
background: #e7f1f6;
border-color: #0070d1;
border-color: $heritage-blue-primary;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {

View file

@ -62,7 +62,7 @@ export default {
<style lang="scss" scoped>
a {
color: #0070d1;
color: $heritage-blue-primary;
text-decoration: none;
line-height: 1.56;
padding: 0;