Merge pull request #1003 from Safelite/feature/humphries/INSR-7768

INSR-7768: service-location parity updates
This commit is contained in:
AHumphriesSL 2026-01-19 09:24:28 -05:00 committed by GitHub
commit f94d3a0bbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1482 additions and 1085 deletions

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="21" viewBox="0 0 20 21">
<path fill="#E86421" fill-rule="nonzero" d="M10 .5c5.523 0 10 4.477 10 10s-4.477 10-10 10-10-4.477-10-10S4.477.5 10 .5zm.043 13.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm.77-9.2h-1.54l-.088.009a.502.502 0 0 0-.299.193.66.66 0 0 0-.125.47l.747 6.806.017.096c.065.249.263.425.495.426l.084-.008c.22-.042.396-.246.427-.51l.793-6.805.004-.102a.651.651 0 0 0-.127-.37.489.489 0 0 0-.388-.205z"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="9" viewBox="0 0 15 9">
<path fill="#E86421" fill-rule="nonzero" d="M7.5 0a.806.806 0 0 0-.593.265L.246 7.455a.957.957 0 0 0 0 1.28.796.796 0 0 0 1.185 0l6.07-6.55 6.068 6.55a.796.796 0 0 0 1.186 0 .957.957 0 0 0 0-1.28L8.093.265A.806.806 0 0 0 7.5 0"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

View file

@ -1,9 +0,0 @@
const buttonVariants = Object.freeze({
primary: 'primary',
secondary: 'secondary',
success: 'success',
link: 'link',
navigation: 'navigation'
});
export default buttonVariants;

View file

@ -0,0 +1,15 @@
export const buttonVariants = Object.freeze({
primary: 'primary',
secondary: 'secondary',
success: 'success',
link: 'link',
navigation: 'navigation'
});
export const dropdownVariants = Object.freeze({
primary: 'primary',
compact: 'compact'
});
export const modalPositions = Object.freeze({
center: 'center',
edge: 'edge'
});

View file

@ -23,8 +23,13 @@ const errorMessages = Object.freeze({
LAST_NAME_REQUIRED: 'Please enter your last name', LAST_NAME_REQUIRED: 'Please enter your last name',
EMAIL_ADDRESS_REQUIRED: 'Email is required.', EMAIL_ADDRESS_REQUIRED: 'Email is required.',
EMAIL_ADDRESS_FORMAT: 'Please enter a valid email address.', EMAIL_ADDRESS_FORMAT: 'Please enter a valid email address.',
SERVICE_ZIP_REQUIRED: 'Please enter your service ZIP', SERVICE_ZIP_REQUIRED: 'ZIP code is required',
SERVICE_ZIP_FORMAT: 'Please enter a valid service ZIP', SERVICE_ZIP_FORMAT: 'Invalid ZIP, please enter a new ZIP (5 characters)',
MOBILE_SERVICE_ZIP_FORMAT: 'Invalid ZIP code',
NO_SERVICE_IN_AREA: (area) => `We're sorry, but we don't offer service in ${area} 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

@ -2,8 +2,9 @@
<div <div
class="dropdown-question" class="dropdown-question"
:class="{ :class="{
'has-error' : (meta.touched && errors && errors.length && !isDisabled) || hasError, 'has-error' : (meta?.touched && errors && errors.length && !isDisabled) || hasError,
'has-success': (meta.touched && (!errors || !errors.length) && !isDisabled) 'has-success': (meta?.touched && (!errors || !errors.length) && !isDisabled),
[`dropdown-${variant}`]: true
}"> }">
<label <label
:for="inputId" :for="inputId"
@ -34,7 +35,7 @@
</option> </option>
</select> </select>
<div <div
v-show="meta.touched && errorMessage && !isDisabled" v-show="meta?.touched && errorMessage && !isDisabled"
class="row mt-1 form-test-error"> class="row mt-1 form-test-error">
<span role="alert">{{ errorMessage }}</span> <span role="alert">{{ errorMessage }}</span>
</div> </div>
@ -42,6 +43,7 @@
</template> </template>
<script> <script>
import { dropdownVariants } from '@/constants/component-variants';
import { useField } from 'vee-validate'; import { useField } from 'vee-validate';
export default { export default {
@ -59,7 +61,11 @@ export default {
validationRules: String, validationRules: String,
cmsWidgetName: String, cmsWidgetName: String,
hasError: Boolean, hasError: Boolean,
placeHolderText: String placeHolderText: String,
variant: {
type: String,
default: dropdownVariants.primary
}
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
setup(props) { setup(props) {
@ -154,5 +160,30 @@ export default {
filter: grayscale(100%); filter: grayscale(100%);
} }
} }
&.dropdown-compact {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 1.25rem;
.form-label {
color: $darker-gray;
font-weight: $font-weight-light;
margin: 0;
}
.form-select {
color: $heritage-blue-secondary;
background-position-x: 98%;
padding: 0;
padding-left: .3125rem;
width: 5.625rem;
min-height: auto;
border: none;
box-shadow: none;
&:focus,
&:focus-visible {
border: .125rem solid $black;
}
}
}
} }
</style> </style>

View file

@ -13,19 +13,19 @@
}"> }">
<div class="modal-dialog" <div class="modal-dialog"
:class="{ :class="{
'modal-dialog-edge': modalPosition === 'edge', 'modal-dialog-edge': modalPosition === modalPositions.edge,
'modal-dialog-centered': modalPosition === 'center' 'modal-dialog-centered': modalPosition === modalPositions.center
}"> }">
<div <div
class="modal-content" class="modal-content"
role="dialog" role="dialog"
aria-modal="true"> aria-modal="true">
<div class="modal-header mb-2 mt-2"> <div class="modal-header mb-2 mt-2">
<label <span
v-if="headerText" v-if="headerText"
class="modal-title d-flex justify-content-center pb-0 w-100"> class="modal-title d-flex justify-content-center pb-0 w-100"
{{ headerText }} :innerHTML="headerText">
</label> </span>
<button <button
ref="closeButton" ref="closeButton"
type="button" type="button"
@ -60,6 +60,7 @@
import ButtonMain from '@/ux-components/button-main/button-main.vue'; import ButtonMain from '@/ux-components/button-main/button-main.vue';
import { Modal } from 'bootstrap'; import { Modal } from 'bootstrap';
import { useForm } from 'vee-validate'; import { useForm } from 'vee-validate';
import { modalPositions } from '@/constants/component-variants';
export default { export default {
// eslint-disable-next-line vue/multi-word-component-names // eslint-disable-next-line vue/multi-word-component-names
@ -84,7 +85,11 @@ export default {
}, },
modalPosition: { modalPosition: {
type: String, type: String,
default: 'edge' default: modalPositions.edge
},
allowInvalidSubmit: {
type: Boolean,
default: false
} }
}, },
emits: ['footer-button-event', 'isModalOpened'], emits: ['footer-button-event', 'isModalOpened'],
@ -102,7 +107,8 @@ export default {
modalId, modalId,
meta, meta,
validate, validate,
resetForm resetForm,
modalPositions
}; };
}, },
computed: { computed: {
@ -121,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');
} }
}, },
@ -257,6 +263,9 @@ export default {
border-radius: 1.5rem; border-radius: 1.5rem;
overflow: auto; overflow: auto;
} }
.modal-header {
justify-content: center;
}
} }
} }
&.show .modal-dialog { &.show .modal-dialog {

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

@ -16,7 +16,8 @@
class="input-wrapper" class="input-wrapper"
:class="[ :class="[
includeSearchIcon ? 'has-search-icon' : '', includeSearchIcon ? 'has-search-icon' : '',
includeSelectIcon ? 'has-select-icon' : '' includeSelectIcon ? 'has-select-icon' : '',
hasTextButton ? 'has-text-button' : ''
]"> ]">
<input <input
:id="inputId" :id="inputId"
@ -61,6 +62,13 @@
data-bs-toggle="modal" data-bs-toggle="modal"
:data-bs-target="'#' + cmsWidgetName" :data-bs-target="'#' + cmsWidgetName"
aria-label="Select button" /> aria-label="Select button" />
<buttonMain
v-if="hasTextButton"
ref="buttonMain"
variant="primary"
:buttonText="buttonText"
@clickEvent="clickedTextButton">
</buttonMain>
</div> </div>
<div <div
v-show="errorMessage" v-show="errorMessage"
@ -76,9 +84,13 @@
<script> <script>
import { useField, validate } from 'vee-validate'; import { useField, validate } from 'vee-validate';
import { extractSpanText } from '@/helpers/text-helper'; import { extractSpanText } from '@/helpers/text-helper';
import buttonMain from '@/ux-components/button-main/button-main.vue';
export default { export default {
name: 'textbox-question', name: 'textbox-question',
components: {
buttonMain
},
props: { props: {
type: { type: {
type: String, type: String,
@ -118,7 +130,11 @@ export default {
includeSelectIcon: Boolean, includeSelectIcon: Boolean,
min: String, min: String,
max: String, max: String,
disableAutoFill: Boolean disableAutoFill: Boolean,
buttonCallback: {
type: Function,
default: () => {}
}
}, },
emits: ['focus', 'update:modelValue', 'textboxQuestionEvent.inputIdAssigned', 'click-event'], emits: ['focus', 'update:modelValue', 'textboxQuestionEvent.inputIdAssigned', 'click-event'],
setup(props) { setup(props) {
@ -161,6 +177,12 @@ export default {
questionTextForAria() { questionTextForAria() {
return extractSpanText(this.getCmsContent(this.cmsWidgetName, 'QuestionText')); return extractSpanText(this.getCmsContent(this.cmsWidgetName, 'QuestionText'));
}, },
buttonText() {
return this.getCmsContent(this.cmsWidgetName, 'ButtonText');
},
hasTextButton() {
return this.buttonText && this.buttonText.length > 0;
},
value: { value: {
get() { get() {
return this.modelValue; return this.modelValue;
@ -227,6 +249,13 @@ export default {
if (this.meta.valid) { if (this.meta.valid) {
this.$emit('click-event'); this.$emit('click-event');
} }
},
async clickedTextButton() {
const result = await validate(this.value, this.validationRules);
if (result.valid) {
this.handleChange(this.value);
this.$emit('click-event');
}
} }
} }
}; };
@ -318,6 +347,19 @@ input::-webkit-date-and-time-value {
display: flex; display: flex;
} }
} }
&.has-text-button {
display: flex;
input[type='text'] {
border-radius: 1.5rem;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 6.875rem;
}
}
} }
input { input {
&.has-icon { &.has-icon {
@ -338,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

@ -5,6 +5,7 @@ function processZipCodeResults(request) {
containsMilitaryBase: serviceZipValidationResponse.containsMilitaryBase, containsMilitaryBase: serviceZipValidationResponse.containsMilitaryBase,
isValid: serviceZipValidationResponse.isValid, isValid: serviceZipValidationResponse.isValid,
isServiceable: serviceZipValidationResponse.isServiceable, isServiceable: serviceZipValidationResponse.isServiceable,
city: serviceZipValidationResponse.city,
state: serviceZipValidationResponse.state, state: serviceZipValidationResponse.state,
zipCodeCtu: serviceZipValidationResponse.zipCodeCtu zipCodeCtu: serviceZipValidationResponse.zipCodeCtu
})).catch(() => ({ })).catch(() => ({

View file

@ -314,7 +314,6 @@ describe('Google Map', () => {
// Arrange // Arrange
const wrapper = shallowMount(googleMap, {}); const wrapper = shallowMount(googleMap, {});
await awaitingSetupTicks(wrapper); await awaitingSetupTicks(wrapper);
const numberOfExtendCallsDuringMount = 2;
mockGeocode.mockImplementation(() => { mockGeocode.mockImplementation(() => {
const result = { const result = {
@ -329,7 +328,7 @@ describe('Google Map', () => {
const result = wrapper.vm.getBounds(locations); const result = wrapper.vm.getBounds(locations);
// Assert // Assert
expect(mockExtend).toHaveBeenCalledTimes(numberOfExtendCallsDuringMount + numberOfExtendCallsPostMount); expect(mockExtend).toHaveBeenCalledTimes(numberOfExtendCallsPostMount);
expect(result).toBe(mockLatLngBound); expect(result).toBe(mockLatLngBound);
} }
); );

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'
}); });
@ -107,9 +107,11 @@ export default {
async getBoundsFromAddress(address) { async getBoundsFromAddress(address) {
await this.setGeocoder(); await this.setGeocoder();
const result = await this.geocoder.geocode({ address }); const result = await this.geocoder.geocode({ address });
return result?.results?.length > 0 if (result?.results?.length > 0) {
? result.results[0].geometry?.bounds return result.results[0].geometry?.bounds ?? null;
: null; } else {
return null;
}
}, },
async createMapWithMarkersForAddresses(markers) { async createMapWithMarkersForAddresses(markers) {
const markerPositions = await this.getLocationsFromAddresses(markers); const markerPositions = await this.getLocationsFromAddresses(markers);
@ -117,9 +119,12 @@ export default {
const map = await this.getMap(zipBounds?.getCenter()); const map = await this.getMap(zipBounds?.getCenter());
await this.addMarkersToMap(map, markerPositions); await this.addMarkersToMap(map, markerPositions);
let positionsToDisplay = markerPositions.map((marker) => marker.position);
if(zipBounds) {
positionsToDisplay.push(zipBounds.getNorthEast());
positionsToDisplay.push(zipBounds.getSouthWest());
}
const positionsToDisplay = markerPositions.map((marker) => marker.position)
.concat(zipBounds?.getNorthEast(), zipBounds?.getSouthWest());
const bounds = this.getBounds(positionsToDisplay); const bounds = this.getBounds(positionsToDisplay);
map.fitBounds(bounds); map.fitBounds(bounds);
} }

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 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>
</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 mb-2" ismultiselect="false" validationrules="" isrequired="true" selectinginitiatesload="false" suppresserror="false" alttext="" iswide="false" value="1234"></base-input-button-stub>
</transition-stub> </transition-stub>
`; `;

View file

@ -149,10 +149,9 @@ describe('Shop list button', () => {
// Assert // Assert
expect(buttonLabelSubCopy.exists()).toBeTruthy(); expect(buttonLabelSubCopy.exists()).toBeTruthy();
expect(buttonLabelSubCopy.classes().length).toBe(4); expect(buttonLabelSubCopy.classes().length).toBe(3);
expect(buttonLabelSubCopy.classes()).toContain('m-0'); expect(buttonLabelSubCopy.classes()).toContain('m-0');
expect(buttonLabelSubCopy.classes()).toContain('caption'); expect(buttonLabelSubCopy.classes()).toContain('button-label-sub-copy');
expect(buttonLabelSubCopy.classes()).toContain('ms-2');
expect(buttonLabelSubCopy.classes()).toContain(textPosition); expect(buttonLabelSubCopy.classes()).toContain(textPosition);
}); });
test('when "textPosition" prop not provided', async () => { test('when "textPosition" prop not provided', async () => {
@ -173,10 +172,9 @@ describe('Shop list button', () => {
// Assert // Assert
expect(buttonLabelSubCopy.exists()).toBeTruthy(); expect(buttonLabelSubCopy.exists()).toBeTruthy();
expect(buttonLabelSubCopy.classes().length).toBe(3); expect(buttonLabelSubCopy.classes().length).toBe(2);
expect(buttonLabelSubCopy.classes()).toContain('m-0'); expect(buttonLabelSubCopy.classes()).toContain('m-0');
expect(buttonLabelSubCopy.classes()).toContain('caption'); expect(buttonLabelSubCopy.classes()).toContain('button-label-sub-copy');
expect(buttonLabelSubCopy.classes()).toContain('ms-2');
}); });
}); });
describe('availability indicator block with expected when displayAvailabilityIndicators true', () => { describe('availability indicator block with expected when displayAvailabilityIndicators true', () => {
@ -378,8 +376,7 @@ describe('Shop list button', () => {
// Assert // Assert
expect(buttonBodyCopy.exists()).toBeTruthy(); expect(buttonBodyCopy.exists()).toBeTruthy();
expect(buttonBodyCopy.classes()).toContain('m-0'); expect(buttonBodyCopy.classes()).toContain('m-0');
expect(buttonBodyCopy.classes()).toContain('button-label-sub-copy'); expect(buttonBodyCopy.classes()).toContain('button-body-copy');
expect(buttonBodyCopy.classes()).toContain('small');
}); });
test('screen reader only text when screenReaderOnlyText provided', async () => { test('screen reader only text when screenReaderOnlyText provided', async () => {
// Arrange // Arrange

View file

@ -5,35 +5,39 @@
<baseInputButton <baseInputButton
v-bind="$props" v-bind="$props"
v-model="selectedValue" v-model="selectedValue"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2"> buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 no-hover mb-2">
<div <div
:aria-label="buttonLabel" :aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4"> class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4">
<div class="row-one"> <div class="row-one">
<div class="availability-indicator-spacer"></div>
<span <span
id="buttonLabelSpan" id="buttonLabelSpan"
class="m-0 button-label-copy" class="m-0 button-label-copy"
:class="textPosition">{{ buttonLabel }} :class="textPosition">{{ buttonLabel }}
</span> </span>
<span class="separator"></span>
<span <span
id="buttonLabelSubCopySpan" id="buttonLabelSubCopySpan"
class="m-0 caption ms-2" class="m-0 button-label-sub-copy"
:class="textPosition">{{ buttonLabelSubCopy }} :class="textPosition">{{ buttonLabelSubCopy }}
</span> </span>
<div <div class="availability-indicator-container">
v-if="displayAvailabilityIndicators"
id="availabilityIndicatorBlock"
class="availability-indicator rounded-pill"
:class="availabilityRatingClass">
<div <div
id="availabilityIndicator" v-if="displayAvailabilityIndicators"
class="d-flex align-items-center"> id="availabilityIndicatorBlock"
class="availability-indicator rounded-pill"
:class="availabilityRatingClass">
<div <div
id="availabilityBadge" id="availabilityIndicator"
class="availability-badge" class="d-flex align-items-center">
:class="availabilityRating == 'high' ? 'green' : 'orange'"> <div
id="availabilityBadge"
class="availability-badge"
:class="availabilityRatingClass">
</div>
<span class="m-0 button-auxillary-copy">{{ badgeText }}</span>
</div> </div>
<span class="m-0 button-auxillary-copy">{{ badgeText }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -41,7 +45,7 @@
<span <span
v-if="buttonBodyCopy" v-if="buttonBodyCopy"
id="buttonBodyCopy" id="buttonBodyCopy"
class="m-0 button-label-sub-copy small" class="m-0 button-body-copy"
v-html="buttonBodyCopy"></span> v-html="buttonBodyCopy"></span>
</div> </div>
<span <span
@ -103,116 +107,128 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "@/styles/ux-variables-svg-strings.scss"; @import "@/styles/ux-variables-svg-strings.scss";
$heritage-border-radius: 60px;
$heritage-border-color: #CACBCC;
$heritage-box-shadow: 0 1px 5px rgba($black, 0.2);
$heritage-checked-background-color: #e7f1f6;
$heritage-checked-border-color: #0070d1;
.list-button { .list-button {
outline: none; outline: none;
input[type="radio"], input[type="radio"],
input[type="checkbox"] { input[type="checkbox"] {
position: static; //override bootstrap position: static; //override bootstrap
&:checked + .list-button-content {
&:focus-visible + .list-button-content { background: $heritage-checked-background-color;
box-shadow: 0 0 0 2.5px $blue; border-color: $heritage-checked-border-color;
} box-shadow: 0 0 0 1px $blue;
&:focus + .list-button-content { .button-label-copy {
box-shadow: 0 0 0 2.5px $blue; font-weight: 500;
} color: $black;
&:checked + .list-button-content { }
color: $black; }
font-weight: 500; &:focus:checked + .list-button-content {
background: $blue-100; border-width: 2.5px;
box-shadow: 0 0 0 1px $blue; }
} }
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {
font-weight: 500;
}
}
} }
.list-button-content { .list-button-content {
color: $gray-600; color: $darker-gray;
position: relative; font-weight: $font-weight-normal;
background: $white; position: relative;
transition: all 150ms linear; background: $white;
border-radius: $border-radius-lg; transition: all 150ms linear;
border: 1px solid $gray-500; border-radius: $heritage-border-radius;
width: 100%; border: 1px solid $heritage-border-color;
outline: none; box-shadow: $heritage-box-shadow;
width: 100%;
span { outline: none;
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
} }
.button-content { .button-content {
row-gap: 0.25rem; row-gap: 0.25rem;
.row-one { .row-one {
display: flex; display: flex;
align-items: center; align-items: center;
line-height: 1.5rem; justify-content: center;
line-height: 1.5rem;
.button-label-copy { .separator {
font-weight: 500; height: 1.25rem;
} margin: 0rem .3125rem;
border-left: 1px solid $heritage-border-color;
}
.availability-indicator { .availability-indicator-spacer {
background-repeat: no-repeat; width: 8rem;
display: flex; margin-right: auto;
align-items: center; }
margin-left: auto;
padding: 0.125rem 0.5rem;
.availability-badge { .availability-indicator-container {
display: inline-flex; display: flex;
width: 13px; align-items: center;
height: 12px; justify-content: flex-end;
background-position: center; margin-left: auto;
background-repeat: no-repeat; width: 8rem;
margin: 0 0.25rem 0 0; }
&.green { .availability-indicator {
background-image: url($svg-shop-list-button-green-availability); background-repeat: no-repeat;
} display: flex;
align-items: center;
padding: 0.125rem 0.5rem;
&.orange { .availability-badge {
background-image: url($svg-shop-list-button-orange-availability); display: inline-flex;
} width: 13px;
} height: 12px;
.button-auxillary-copy { background-position: center;
box-sizing: border-box; background-repeat: no-repeat;
justify-content: right; margin: 0 0.25rem 0 0;
line-height: 1.25rem;
font-weight: 500;
font-size: 0.75rem;
align-items: center;
}
&.green { &.green {
color: $green-700; background-image: url($svg-shop-list-button-green-availability);
background-color: $green-100; }
}
&.orange { &.orange {
color: $orange-600; background-image: url($svg-shop-list-button-orange-availability);
background-color: $orange-100; }
}
&.gray { &.gray {
color: $gray-600; background-image: url(~@/assets/img/heritage-loader-blue.gif);
background-color: $gray-100; background-size: contain;
padding-left: 0.125rem; }
padding-right: 0.125rem; }
} .button-auxillary-copy {
} box-sizing: border-box;
} justify-content: right;
line-height: 1.25rem;
font-weight: 500;
font-size: 0.75rem;
align-items: center;
}
.row-two { &.green {
text-align: left; color: $green-700;
} background-color: $green-100;
}
&.orange {
color: $orange-600;
background-color: $orange-100;
}
&.gray {
color: $gray-600;
background-color: $gray-100;
padding-left: 0.125rem;
padding-right: 0.125rem;
}
}
}
.row-two {
text-align: center;
}
} }
</style> </style>

View file

@ -54,7 +54,7 @@ import siteFooter from '@/iss-components/site-footer/site-footer.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 buttonQuestion from '@/digital-components/button-question/button-question.vue'; import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import buttonMain from '@/ux-components/button-main/button-main.vue'; import buttonMain from '@/ux-components/button-main/button-main.vue';
import buttonVariants from '@/constants/button-variants'; import { buttonVariants } from '@/constants/component-variants';
// Supporting files // Supporting files
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper.js'; import { fetchCmsContentForPage } from '@/helpers/cms-content-helper.js';

View file

@ -70,7 +70,7 @@ import coverageType from '@/constants/coverage-type';
import alert from '@/ux-components/alert/alert.vue'; import alert from '@/ux-components/alert/alert.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 buttonMain from '@/ux-components/button-main/button-main.vue'; import buttonMain from '@/ux-components/button-main/button-main.vue';
import buttonVariants from '@/constants/button-variants'; import { buttonVariants } from '@/constants/component-variants';
export default { export default {
name: 'policy-vehicles', name: 'policy-vehicles',

View file

@ -1,24 +1,21 @@
<template> <template>
<transition <transition
name="fade" name="fade"
mode="out-in"> mode="out-in">
<div <div
v-if="isDisplayed" class="appointment-type-question"
class="appointment-type-question" aria-live="polite">
aria-live="polite"> <buttonQuestion
<buttonQuestion ref="buttonQuestion"
ref="buttonQuestion" v-model="selectedValues"
v-model="selectedValues" customButtonQuestionId="appointmentTypeQuestion"
customButtonQuestionId="appointmentTypeQuestion" :answers="answersToDisplay"
:questionText="questionText" :groupName="groupName"
:answers="answersToDisplay" :suppressError="suppressError"
:groupName="groupName" :validationRules="validationRules"
buttonTypeString="listCard" isRequired />
:suppressError="suppressError" </div>
:validationRules="validationRules" </transition>
isRequired />
</div>
</transition>
</template> </template>
<script> <script>
@ -38,12 +35,9 @@ export default {
modelValue: String, modelValue: String,
groupName: String, groupName: String,
isAvailable: Boolean, isAvailable: Boolean,
isDisplayed: Boolean,
suppressError: Boolean, suppressError: Boolean,
validationRules: String, validationRules: String,
cmsWidgetName: String, cmsWidgetName: String
isServiceableMobile: Boolean,
isServiceableInshop: Boolean
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
computed: { computed: {
@ -51,23 +45,13 @@ export default {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText'); return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
}, },
answersFromCms() { answersFromCms() {
const retCms = this.getCmsContent(this.cmsWidgetName, 'Answers'); return this.getCmsContent(this.cmsWidgetName, 'Answers');
if (retCms && retCms.length > 0 && this.isMobileFeeHidden) {
return retCms.map((x) => (x.Name === AppointmentTypeStrings.MOBILE ? { ...x, SubText: x.SubText.replace('*', '') } : x));
}
return retCms;
}, },
answersToDisplay() { answersToDisplay() {
const shouldShowMobile = this.isServiceableMobile && this.isItacOrNoComp;
const shouldShowMobileNotITACNotNoComp = this.isServiceableMobile && !this.isItacOrNoComp;
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)
|| (answer.Name === AppointmentTypeStrings.MOBILE && shouldShowMobile) || (answer.Name === AppointmentTypeStrings.MOBILE)
|| (answer.Name === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP && shouldShowMobileNotITACNotNoComp)
|| (answer.Name === AppointmentTypeStrings.DROP_OFF && shouldShowDropoff)
)) ))
: []; : [];
}, },
@ -78,58 +62,13 @@ export default {
set(newValue) { set(newValue) {
this.$emit('update:modelValue', newValue); this.$emit('update:modelValue', newValue);
} }
},
isITAC() {
return useMainStore().isITAC;
},
isItacOrNoComp() {
return this.isITAC || useMainStore().isNoComp;
},
isMobileFeeHidden() {
return this.getSettingValue(experimentSettings.ISS_FEATURE_TOGGLE_IS_MOBILE_FEE_HIDDEN) === 'true';
},
isMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
} }
}, },
watch: {
answersToDisplay: {
handler(newValue) {
// 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
) {
if (this.isItacOrNoComp) {
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
}
},
immediate: true
},
isMobileOnly: {
handler(newValue) {
if (newValue) {
if (this.isItacOrNoComp) {
this.selectedValues = AppointmentTypeStrings.MOBILE;
} else {
this.selectedValues = AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
}
}
}
}
}
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.button-question) { .appointment-type-question {
.list-card img { margin-bottom: 1.25rem;
height: auto;
width: 3.417rem;
}
} }
</style> </style>

View file

@ -1,29 +1,13 @@
/* eslint-env jest */
import baseMixin from '@/mixins/base-mixin'; import baseMixin from '@/mixins/base-mixin';
import { shallowMount } from '@vue/test-utils'; import { mount, flushPromises } from '@vue/test-utils';
import { getMountOptions } from '@/helpers/unit-test-helper.js'; import { createTestingPinia } from '@pinia/testing';
import { AppointmentTypeStrings } from '@/constants/schedule-constants';
import navigationScenarios from '@/router/router-constants/navigation-scenarios';
import routerParams from '@/router/router-constants/router-params';
import { useMainStore } from '@/store';
import serviceLocation from '@/layouts/service-location/service-location.vue'; import serviceLocation from '@/layouts/service-location/service-location.vue';
import { getZipCodeData } from '@/helpers/service-location-helper';
// Define Mocks
jest.mock('@/helpers/cms-content-helper', () => ({
fetchCmsContentForPage: jest.fn(() => Promise.resolve('content'))
}));
/** @ignore */
function setupMocks() {
const wrapper = shallowMount(
serviceLocation,
getMountOptions({
router: {
navigate: jest.fn()
}
})
);
wrapper.vm.$router.navigateWithSpinner = jest.fn();
wrapper.vm.navigateBack = baseMixin.methods.navigateBack;
return { wrapper };
}
const mockGetServiceabilityDetails = () => { const mockGetServiceabilityDetails = () => {
const serviceabilityDetails = { const serviceabilityDetails = {
@ -35,123 +19,199 @@ const mockGetServiceabilityDetails = () => {
return Promise.resolve(serviceabilityDetails); return Promise.resolve(serviceabilityDetails);
}; };
const mockZipcodeData = (zip) => {
if (zip === '43235' || zip === '55555') {
return Promise.resolve({
containsMilitaryBase: false,
isValid: true,
isServiceable: true,
city: 'Columbus',
state: 'OH',
zipCodeCtu: '01820'
});
}
if (zip === '45433') {
return Promise.resolve({
containsMilitaryBase: true,
isValid: true,
isServiceable: true,
city: 'Columbus',
state: 'OH',
zipCodeCtu: '01820'
});
}
return Promise.resolve({
containsMilitaryBase: false,
isValid: false,
isServiceable: false,
city: null,
state: null,
zipCodeCtu: null
});
}
const mockProviders = () => {
return Promise.resolve([
{
"address": {
"city": "COLUMBUS",
"country": "US",
"state": "OH",
"streetAddress": "6826 Sawmill Rd",
"streetAddress2": "",
"zipCode": "43235",
"zipCodeCtu": "03357"
},
"distanceInMiles": 4.136335989015438,
"providerNumber": "003357",
"companyName": "SAFELITE AUTOGLASS - COLUMBUS, OH",
"phoneNumber": "6142336400",
"isSafeliteShop": true
},
{
"address": {
"city": "Lewis Center",
"country": "US",
"state": "OH",
"streetAddress": "1343 Cameron Ave",
"streetAddress2": "",
"zipCode": "43035",
"zipCodeCtu": "03357"
},
"distanceInMiles": 8.193072412262042,
"providerNumber": "003417",
"companyName": "SAFELITE AUTOGLASS - LEWIS CENTER, OH",
"phoneNumber": "6147815433",
"isSafeliteShop": true
}
])
}
jest.mock( jest.mock(
'@/helpers/service-location-helper', '@/helpers/service-location-helper',
() => ({ () => ({
getServiceabilityDetails: jest.fn((mockServiceZipCode) => mockGetServiceabilityDetails(mockServiceZipCode)) getServiceabilityDetails: jest.fn((mockServiceZipCode) => mockGetServiceabilityDetails(mockServiceZipCode)),
getZipCodeData: jest.fn((mockServiceZipCode) => mockZipcodeData(mockServiceZipCode)),
getMobileZipCodeData: jest.fn((mockServiceZipCode) => mockZipcodeData(mockServiceZipCode))
}) })
); );
const mockMixin = { const mockRoute = {
methods: { params: {}
getCmsContent: jest.fn((widgetName, cmsFieldName) => { };
// linkWidgetName is not defined. This whole spec file needs review. const mockRouter = {
if (widgetName === linkWidgetName) { navigate: jest.fn()
return mockLinkCmsContent[cmsFieldName]; };
const mountOptions = {
global: {
mixins: [
{
computed: {
navigationScenarios() {
return navigationScenarios;
},
routerParams() {
return routerParams;
}
},
methods: {
getCmsContent: jest.fn(),
getFooterInfoBoxHeight: jest.fn(() => 80),
getPageNameByQueryString: jest.fn(() => '')
}
} }
],
if (widgetName === modalWidgetName) { mocks: {
return mockModalCmsContent[cmsFieldName]; $route: mockRoute,
} $router: mockRouter
},
return null; stubs: {
}), alert: true,
textBlock: true,
getZipCodeData: jest.fn((zip) => { appointmentTypeQuestion: true,
if (zip === '43235' || zip === '55555') { contentGroupModal: true,
return Promise.resolve({ siteFooter: true,
containsMilitaryBase: false, siteHeader: true,
isValid: true, siteSubHeader: true,
state: 'OH', shopAddress: true,
zipCodeCtu: '01820' serviceZipQuestion: true
}); }
}
if (zip === '45433') {
return Promise.resolve({
containsMilitaryBase: true,
isValid: true,
state: 'OH'
});
}
return Promise.resolve({
containsMilitaryBase: false,
isValid: false,
state: null,
zipCodeCtu: null
});
}),
onSubmit: jest.fn(),
onInvalidSubmit: jest.fn()
} }
}; };
function setupMocks({ appointmentType = AppointmentTypeStrings.IN_SHOP }) {
mountOptions.global.plugins = [createTestingPinia({
initialState: {
main: {
order: {
serviceLocation: {
appointmentType: appointmentType
}
}
}
}
})];
useMainStore().getProviders = jest.fn().mockImplementation(() => mockProviders());
const wrapper = mount(serviceLocation, mountOptions);
describe('navigation', () => { wrapper.vm.$router.navigateWithSpinner = jest.fn();
test('if the back button is clicked, navigate back', async () => { wrapper.vm.navigateBack = baseMixin.methods.navigateBack;
// Arrange
const { wrapper } = setupMocks({ return { wrapper };
}
beforeEach(() => {
jest.clearAllMocks();
});
describe('service-location.vue', () => {
describe('navigation', () => {
test('if the back button is clicked, navigate back', async () => {
// Arrange
const { wrapper } = setupMocks({
});
// Act
await wrapper.vm.navigateBack();
// Assert
expect(wrapper.vm.$router.navigateWithSpinner).toHaveBeenCalled();
});
});
describe('updating service zip', () => {
test('updates the page model after changing the service zip code', () => {
// Arrange
const { wrapper } = setupMocks({ appointmentType: AppointmentTypeStrings.IN_SHOP });
const newServiceZipCode = '61606';
const shopAddressComponent = wrapper.findComponent({
ref: 'shopAddress'
});
// Act
shopAddressComponent.vm.$emit('zip-updated', newServiceZipCode);
// Assert
expect(wrapper.vm.zipCode).toStrictEqual(newServiceZipCode);
}); });
// Act test('displays military zip message when zip is updated', async () => {
await wrapper.vm.navigateBack(); // Arrange
const { wrapper } = setupMocks({ appointmentType: AppointmentTypeStrings.MOBILE });
// Assert const mobileServiceZipCodeQuestion = wrapper.findComponent({
expect(wrapper.vm.$router.navigateWithSpinner).toHaveBeenCalled(); ref: 'mobileServiceZipCodeQuestion'
}); });
});
expect(wrapper.vm.zipContainsMilitaryBase).toBe(false);
describe('updating service zip', () => {
test('updates the page model after changing the service zip code', () => { const newMobileServiceZipCode = '45433'
// Arrange
const { wrapper } = setupMocks({ // Act
mixins: [mockMixin] mobileServiceZipCodeQuestion.vm.$emit('update:modelValue', newMobileServiceZipCode);
}); await wrapper.vm.$nextTick();
const newServiceZipCodeQuestion = { // Assert
zipCode: '61606', expect(wrapper.vm.zipContainsMilitaryBase).toBe(true);
state: 'IL' });
};
const serviceZipCodeComponent = wrapper.findComponent({
ref: 'serviceZipCodeQuestion'
});
// Act
serviceZipCodeComponent.vm.$emit('update:modelValue', newServiceZipCodeQuestion);
// Assert
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeQuestion);
});
test('displays military zip message when zip is updated', () => {
// Arrange
const { wrapper } = setupMocks({});
const mobileLocationQuestionsComponent = wrapper.findComponent({
ref: 'mobileLocationQuestions'
});
mobileLocationQuestionsComponent.resetComponent = jest.fn();
const serviceZipCodeComponent = wrapper.findComponent({
ref: 'serviceZipCodeQuestion'
});
serviceZipCodeComponent.resetMobileFeePart = jest.fn();
expect(wrapper.vm.zipContainsMilitaryBase).toBe(false);
// TODO: Use or remove
const newServiceZipCodeQuestion = {
zipCode: '45433',
state: 'OH'
};
// Act
serviceZipCodeComponent.vm.$emit('updated-contains-military-base', true);
// Assert
expect(wrapper.vm.zipContainsMilitaryBase).toBe(true);
}); });
}); });

View file

@ -12,28 +12,7 @@
<div class="service-location-container iss-heritage-content-container-width"> <div class="service-location-container iss-heritage-content-container-width">
<siteSubHeader <siteSubHeader
cmsWidgetName="SiteSubHeaderWidget" cmsWidgetName="SiteSubHeaderWidget"
class="mt-4" /> class="subheader" />
<serviceZipModalQuestion
ref="serviceZipCodeQuestion"
v-model="serviceZipCodeQuestion"
modalWidgetName="ServiceZipModalWidget"
:onZipUpdateCallback="reloadShopData"
@updatedServiceability="setServiceabilityDetails"
@updatedContainsMilitaryBase="
setContainsMilitaryBase
" />
<alert
v-if="displayMilitaryZipAlert"
ref="alertMilitaryBaseZip"
class="my-5"
cmsWidgetName="AlertMilitaryBaseZipWidget"
alertClass="alert-warning" />
<alert
v-if="displayServiceableMobileOnly"
ref="alertMobileOnly"
class="my-5"
cmsWidgetName="AlertMobileOnlyWidget"
alertClass="alert-warning" />
<alert <alert
v-if="displayRecalibrationWarning" v-if="displayRecalibrationWarning"
ref="alertRecalNoMobile" ref="alertRecalNoMobile"
@ -41,18 +20,6 @@
cmsWidgetName="AlertRecalNoMobileWidget" cmsWidgetName="AlertRecalNoMobileWidget"
alertClass="alert-warning" alertClass="alert-warning"
@text-link-clicked="openModalAction" /> @text-link-clicked="openModalAction" />
<alert
v-if="displayServiceableInshopOnly"
ref="alertInshopOnly"
class="my-5"
cmsWidgetName="AlertInshopOnlyWidget"
alertClass="alert-warning" />
<alert
v-if="displayNoShopsAlert"
ref="alertNoShops"
class="my-5"
cmsWidgetName="AlertNoShopsWidget"
alertClass="alert-warning" />
<alert <alert
v-if="displayBigTruckNoShops" v-if="displayBigTruckNoShops"
ref="alertBigTruckNoShops" ref="alertBigTruckNoShops"
@ -60,41 +27,94 @@
cmsWidgetName="AlertBigTruckNoShopsWidget" cmsWidgetName="AlertBigTruckNoShopsWidget"
alertClass="alert-warning" /> alertClass="alert-warning" />
<div class="appointment-type"> <div class="appointment-type">
<div class="appointment-type-question-text d-flex">
<span class="w-100 recal-text" v-if="recalibrationRequired">{{ scheduleRecalText }}</span>
<span class="w-100" v-if="!requiresInshopRecalibration">{{ appointmentQuestionText }}</span>
</div>
<alert
v-if="displayServiceableInshopOnly"
ref="alertInshopOnly"
cmsWidgetName="AlertInshopOnlyWidget"
:manualCopy="inShopOnlyCopy"
:isCollapsible="true"
alertClass="alert-warning" />
<alert
v-if="displayServiceableMobileOnly"
ref="alertMobileOnly"
cmsWidgetName="AlertMobileOnlyWidget"
:manualCopy="mobileOnlyCopy"
:isCollapsible="true"
alertClass="alert-warning" />
<appointmentTypeQuestion <appointmentTypeQuestion
v-show="isAppointmentTypeDisplayed" v-if="!requiresInshopRecalibration && isServiceableMobile"
ref="appointmentTypeQuestion" ref="appointmentTypeQuestion"
v-model="selectedAppointmentType" v-model="selectedAppointmentType"
:isServiceableMobile="isServiceableMobile"
:isServiceableInshop="isServiceableInshop"
:isDisplayed="isAppointmentTypeDisplayed"
groupName="appointmentTypeQuestion" groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget" cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" /> validationRules="option-required" />
</div> </div>
<mobileLocationModalQuestions <div
v-if="isMobileLocationDisplayed" v-if="isInshop">
ref="mobileLocationQuestions" <alert
v-model="mobileLocationQuestions" v-if="displayLowAvailabilityInshop"
customComponentId="mobileLocationQuestions" ref="alertLowAvailabilityInshop"
:mobileFeePart="mobileFeePart" cmsWidgetName="AlertLowAvailabilityInshopWidget"
validationRules="mobile-location-required" :isCollapsible="true"
linkWidgetName="MobileLocationLinkWidget" alertClass="alert-warning" />
modalWidgetName="MobileLocationModalWidget" <shopAddress
:onZipUpdateCallback="reloadShopData" ref="shopAddress"
@updated-mobile-fee-part="setMobileFeePart" v-model="selectedProvider"
@updated-serviceability="setServiceabilityDetails" :serviceZipcode="zipCode"
@updated-contains-military-base="setContainsMilitaryBase" :selectedAppointmentType="selectedAppointmentType"
@updated-mobile-ctu="setCtuForMobile" /> modalWidgetName="ChangeLocationModalWidget"
<shopQuestion cmsWidgetName="ShopAddressWidget"
v-show="isShopQuestionDisplayed" @zip-updated="handleInShopZipUpdated" />
:ref="SHOP_QUESTION_REF_NAME" <!-- INTEGRATION TODO: Put this right under the calendar -->
v-model="selectedProvider" <alert
:selectedAppointmentType="selectedAppointmentType" v-if="selectedProvider === null"
:isDisplayed="isShopQuestionDisplayed" ref="alertNoAvailableShops"
cmsWidgetName="ShopQuestionWidget" cmsWidgetName="AlertNoAvailableShopsWidget"
@updatedMobileProviderNumber=" :isCollapsible="true"
setMobileProviderNumber alertClass="alert-danger" />
" /> </div>
<div
v-if="isMobile">
<div class="expandable-link-container">
<a
v-if="displayMilitaryZipAlert && !militaryBaseWarningExpanded"
href="#"
class="expandable-link"
@click.prevent="militaryBaseWarningExpanded = true">
{{ militaryBaseWarningLinkText }}
</a>
<div
v-if="displayMilitaryZipAlert && militaryBaseWarningExpanded"
class="expandable-link-text">
{{ militaryBaseWarningText }}
</div>
</div>
<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"
@ -104,7 +124,7 @@
class="mt-5" class="mt-5"
cmsWidgetName="SiteFooterWidget" cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled=" :isForwardActionDisabled="
!meta.valid || displayNoShopsAlert !meta.valid || displayNoShopsAlert || displayBigTruckNoShops
" "
@backClicked="navigateBack(this, navigateBackScenario)" @backClicked="navigateBack(this, navigateBackScenario)"
@forwardClicked="forwardButtonAction" /> @forwardClicked="forwardButtonAction" />
@ -118,44 +138,31 @@
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js'; import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper'; import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import settleAllPromises from '@/helpers/layout-helper'; import settleAllPromises from '@/helpers/layout-helper';
import { required } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages'; import errorMessages from '@/constants/error-messages';
import { useMainStore } from '@/store'; import { useMainStore } from '@/store';
import { import {
getPricedMobileFeePart, getPricedMobileFeePart,
getServiceabilityDetails, getServiceabilityDetails,
getZipCodeData getZipCodeData,
getMobileZipCodeData
} from '@/helpers/service-location-helper'; } from '@/helpers/service-location-helper';
import { toTitleCase } from '@/helpers/text-helper.js';
import { getAvailabilityRating } 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 shopQuestion from '@/layouts/service-location/shop-question/shop-question.vue';
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 serviceZipModalQuestion from '@/layouts/service-location/service-zip-modal-question/service-zip-modal-question.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';
import widgetFields from '@/constants/cms-widget-fields';
// 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));
const SHOP_QUESTION_REF_NAME = 'shopQuestion';
const RECAL_MODAL_REF_NAME = 'RecalModal'; const RECAL_MODAL_REF_NAME = 'RecalModal';
const SITE_FOOTER_REF_NAME = 'siteFooter'; const SITE_FOOTER_REF_NAME = 'siteFooter';
@ -163,16 +170,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,
serviceZipModalQuestion, shopAddress,
shopQuestion serviceZipQuestion
}, },
mixins: [baseFormMixin], mixins: [baseFormMixin],
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -182,8 +189,8 @@ export default {
const zipCodeData = getZipCodeData(serviceZipCode); const zipCodeData = getZipCodeData(serviceZipCode);
const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode); const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode);
const serviceabilityDetailsPromise = getServiceabilityDetails(serviceZipCode); const serviceabilityDetailsPromise = getServiceabilityDetails(serviceZipCode);
const shopQuestionInitialDataPromise = shopQuestion.methods.loadInitialData(serviceZipCode);
const getGlassFeesPromise = useMainStore().getGlassFees(); const getGlassFeesPromise = useMainStore().getGlassFees();
const providersPromise = useMainStore().getProviders(serviceZipCode);
// Settle promises and get results // Settle promises and get results
const promiseResultMap = [ const promiseResultMap = [
@ -203,13 +210,13 @@ export default {
resultKey: 'serviceabilityDetails', resultKey: 'serviceabilityDetails',
promise: serviceabilityDetailsPromise promise: serviceabilityDetailsPromise
}, },
{
resultKey: 'shopQuestionInitialData',
promise: shopQuestionInitialDataPromise
},
{ {
resultKey: 'zipCodeData', resultKey: 'zipCodeData',
promise: zipCodeData promise: zipCodeData
},
{
resultKey: 'providers',
promise: providersPromise
} }
]; ];
@ -221,10 +228,9 @@ export default {
resultMap.zipCodeData, resultMap.zipCodeData,
resultMap.serviceabilityDetails, resultMap.serviceabilityDetails,
resultMap.mobileFeePart, resultMap.mobileFeePart,
resultMap.shopQuestionInitialData?.mobileProviderNumber, resultMap.glassFees,
resultMap.glassFees resultMap.providers
); );
vm.$refs[SHOP_QUESTION_REF_NAME].initializeComponent(resultMap.shopQuestionInitialData);
}); });
}, },
setup() { setup() {
@ -238,7 +244,6 @@ export default {
city: this.getServiceCityFromStore(), city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(), state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(), zipCode: this.getServiceZipCodeFromStore(),
isVehicleProtected: this.getIsVehicleProtectedFromStore(),
isGlassServiceableInshop: null, isGlassServiceableInshop: null,
isRecalibrationServiceableInshop: null, isRecalibrationServiceableInshop: null,
isGlassServiceableMobile: null, isGlassServiceableMobile: null,
@ -249,9 +254,13 @@ export default {
mobileProviderNumber: null, mobileProviderNumber: null,
zipContainsMilitaryBase: false, zipContainsMilitaryBase: false,
zipCodeCtu: null, zipCodeCtu: null,
SHOP_QUESTION_REF_NAME, mobileZipCode: '',
mobileZipError: '',
militaryBaseWarningExpanded: false,
availabilityRating: null,
RECAL_MODAL_REF_NAME, RECAL_MODAL_REF_NAME,
SITE_FOOTER_REF_NAME SITE_FOOTER_REF_NAME,
errorMessages
}; };
}, },
computed: { computed: {
@ -264,61 +273,6 @@ export default {
answersFromCms() { answersFromCms() {
return this.getCmsContent('ServiceTypeQuestionWidget', 'Answers'); 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 {
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.selectedAppointmentType === AppointmentTypeStrings.MOBILE
|| this.selectedAppointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP
)
) {
this.selectedAppointmentType = null;
}
this.selectedProvider = null;
}
}
},
isServiceableMobile() { isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) { if (this.isRecalibrationServiceableMobile !== null) {
return ( return (
@ -338,22 +292,14 @@ export default {
return this.isGlassServiceableInshop; return this.isGlassServiceableInshop;
}, },
isShopQuestionDisplayed() { isInshop() {
return ( return (
this.selectedAppointmentType === 'Inshop' this.selectedAppointmentType === 'Inshop'
|| this.selectedAppointmentType === 'Dropoff' || this.selectedAppointmentType === 'Dropoff'
); );
}, },
isAppointmentTypeDisplayed() { isMobile() {
return this.zipCode && !this.displayNoShopsAlert; return this.selectedAppointmentType === AppointmentTypeStrings.MOBILE
},
isMobileLocationDisplayed() {
return (
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.
@ -375,15 +321,17 @@ export default {
displayRecalibrationWarning() { displayRecalibrationWarning() {
return this.requiresInshopRecalibration; return this.requiresInshopRecalibration;
}, },
displayLowAvailabilityInshop() {
return this.availabilityRating === 'low' && this.isInshop;
},
displayServiceableInshopOnly() { displayServiceableInshopOnly() {
return ( return !this.isServiceableMobile && this.isServiceableInshop && !this.displayRecalibrationWarning;
!this.displayRecalibrationWarning
&& this.isServiceableInshop
&& !this.isServiceableMobile
);
}, },
displayServiceableMobileOnly() { displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop; return this.isServiceableMobile && this.recalibrationRequired && !this.isServiceableInshop;
},
displayMobileFeeDisclaimer() {
return this.mainStore.isNoComp || this.mainStore.isITAC;
}, },
navigateBackScenario() { navigateBackScenario() {
const { isNoComp, isITAC } = useMainStore(); const { isNoComp, isITAC } = useMainStore();
@ -393,6 +341,38 @@ 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);
},
militaryBaseWarningLinkText() {
return this.getCmsContent('AlertMilitaryBaseZipWidget', widgetFields.ALERT_WIDGET.HEADLINE_TEXT);
},
militaryBaseWarningText() {
return this.getCmsContent('AlertMilitaryBaseZipWidget', widgetFields.ALERT_WIDGET.BODY_TEXT);
},
appointmentQuestionText() {
return this.getCmsContent('WhereWouldYouLikeServiceWidget', widgetFields.CONTENT_GROUP_WIDGET.HEADER_TEXT);
},
scheduleRecalText() {
if(this.requiresInshopRecalibration) {
return this.getCmsContent('WhereWouldYouLikeServiceWidget', widgetFields.CONTENT_GROUP_WIDGET.BODY_TEXT_2);
} else {
return this.getCmsContent('WhereWouldYouLikeServiceWidget', widgetFields.CONTENT_GROUP_WIDGET.BODY_TEXT);
}
},
inShopOnlyCopy() {
let content = this.getCmsContent('AlertInshopOnlyWidget', widgetFields.ALERT_WIDGET.BODY_TEXT);
content = content.replace('{custom:city}', toTitleCase(this.city));
return content;
},
mobileOnlyCopy() {
let content = this.getCmsContent('AlertMobileOnlyWidget', widgetFields.ALERT_WIDGET.BODY_TEXT);
content = content.replace('{custom:city}', toTitleCase(this.city));
return content;
},
recalibrationRequired() {
return this.mainStore.hasRecalibrationPart;
} }
}, },
methods: { methods: {
@ -401,13 +381,10 @@ export default {
useMainStore().order.serviceLocation.zipCode !== null useMainStore().order.serviceLocation.zipCode !== null
); );
}, },
async reloadShopData(zipCode) {
await this.$refs[SHOP_QUESTION_REF_NAME].reloadShopData(zipCode);
},
async forwardButtonAction() { async forwardButtonAction() {
let provider = this.selectedProvider; let provider = this.selectedProvider;
this.mainStore.updateMobileFee(null); this.mainStore.updateMobileFee(null);
if (this.isMobileLocationDisplayed) { if (this.isMobile) {
if (this.mainStore.isNoComp || this.mainStore.isITAC) { if (this.mainStore.isNoComp || this.mainStore.isITAC) {
this.mainStore.updateMobileFee(this.mobileFeePart); this.mainStore.updateMobileFee(this.mobileFeePart);
} }
@ -432,7 +409,6 @@ export default {
zipCode: this.zipCode, zipCode: this.zipCode,
zipCodeCtu: this.zipCodeCtu, zipCodeCtu: this.zipCodeCtu,
appointmentType: this.selectedAppointmentType, appointmentType: this.selectedAppointmentType,
isVehicleProtected: this.isVehicleProtected,
provider provider
}); });
@ -466,9 +442,6 @@ export default {
|| useMainStore().order.customer.address.zipCode || useMainStore().order.customer.address.zipCode
); );
}, },
getIsVehicleProtectedFromStore() {
return useMainStore().order.serviceLocation.isVehicleProtected;
},
getSelectedAppointmentType() { getSelectedAppointmentType() {
return useMainStore().order.serviceLocation.appointmentType; return useMainStore().order.serviceLocation.appointmentType;
}, },
@ -482,12 +455,13 @@ export default {
zipCodeData, zipCodeData,
serviceabilityDetails, serviceabilityDetails,
mobileFeePart, mobileFeePart,
mobileProviderNumber, glassFees,
glassFees providers
) { ) {
if (zipCodeData) { if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase; this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
this.zipCodeCtu = zipCodeData.zipCodeCtu; this.zipCodeCtu = zipCodeData.zipCodeCtu;
this.city = zipCodeData.city;
} }
if (serviceabilityDetails) { if (serviceabilityDetails) {
@ -498,8 +472,24 @@ export default {
this.mobileFeePart = mobileFeePart; this.mobileFeePart = mobileFeePart;
} }
if (mobileProviderNumber) { if (providers) {
this.setMobileProviderNumber(mobileProviderNumber); this.setMobileProviderNumber(providers.mobileProviderNumber);
let foundMatch = false;
if(this.selectedProvider && this.selectedProvider.providerNumber) {
const matchedProvider = providers.shopProviders.find(
(provider) => provider.providerNumber === this.selectedProvider.providerNumber
);
if (matchedProvider) {
foundMatch = true;
this.selectedProvider = matchedProvider;
}
}
if(providers.shopProviders.length > 0 && !foundMatch) {
this.selectedProvider = providers.shopProviders[0];
}
else if(providers.shopProviders.length === 0) {
this.selectedProvider = null;
}
} }
if (glassFees) { if (glassFees) {
@ -521,13 +511,6 @@ export default {
setMobileProviderNumber(providerNumber) { setMobileProviderNumber(providerNumber) {
this.mobileProviderNumber = providerNumber; this.mobileProviderNumber = providerNumber;
}, },
resetMobileLocation() {
this.streetAddress = '';
this.streetAddress2 = '';
this.city = '';
this.isVehicleProtected = null;
},
setServiceabilityDetails(serviceabilityDetails) { setServiceabilityDetails(serviceabilityDetails) {
this.isGlassServiceableInshop = this.isGlassServiceableInshop =
serviceabilityDetails.isGlassServiceableInshop; serviceabilityDetails.isGlassServiceableInshop;
@ -537,14 +520,94 @@ export default {
serviceabilityDetails.isGlassServiceableMobile; serviceabilityDetails.isGlassServiceableMobile;
this.isRecalibrationServiceableMobile = this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile; serviceabilityDetails.isRecalibrationServiceableMobile;
if (!this.isServiceableMobile) {
this.selectedAppointmentType = AppointmentTypeStrings.IN_SHOP;
}
},
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_AREA(toTitleCase(zipCodeData.city));
} else {
this.city = zipCodeData.city;
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];
}
else {
this.selectedProvider = null;
}
}
});
}
},
async handleInShopZipUpdated(newZip) {
this.zipCode = newZip;
const zipCodeData = await getZipCodeData(this.zipCode);
this.city = zipCodeData.city;
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);
}
});
}
},
watch: {
mobileZipCode(newZip) {
if(newZip !== '') {
this.updateMobileZip();
}
},
selectedAppointmentType() {
this.mobileZipCode = '';
if(this.selectedAppointmentType === AppointmentTypeStrings.IN_SHOP && this.selectedProvider) {
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];
getAvailabilityRating(
formattedStartDate,
formattedEndDate,
AppointmentTypeStrings.IN_SHOP,
this.selectedProvider ? this.selectedProvider.providerNumber : null
).then((rating) => {
this.availabilityRating = rating;
});
}
} }
} }
}; };
</script> </script>
<style lang="scss"> <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;
@ -554,58 +617,41 @@ $page-side-padding: 1.5rem;
} }
} }
.question-text { .service-location-container {
span { .subheader {
line-height: 1.5rem; margin: 1.25rem 0;
text-align: center;
} }
} .form-test-error {
.list-card-content p { font-weight: $font-weight-bold;
&:first-of-type {
line-height: 1.5rem;
} }
&:not(:nth-of-type(1)) { .appointment-type-question-text {
line-height: 1.25rem; color: $black;
} font-weight: 600;
} margin-bottom: 1rem;
.choose-option { font-size: 1rem;
.button-question > div { line-height: 1.625rem;
&:first-of-type { flex-direction: column;
margin-bottom: 0.95rem; .recal-text {
line-height: 1.5rem; margin-bottom: 1.25rem;
font-weight: $font-weight-bold;
} }
} }
} .mobile-service-zip-question {
.button-question { :deep(.form-test-error span) {
.row.form-test-error { font-weight: $font-weight-bold;
line-height: 1.5rem;
padding-left: 0rem !important;
}
}
.appointment-type-question {
.row {
margin-top: 0;
.col-12 {
margin: 0 0 .5rem 0;
&:last-child {
margin-bottom: 0;
}
} }
} }
.question-text { .expandable-link-container {
margin: 0; margin-bottom: 1rem;
span {
padding: 1rem 0;
text-align: center;
}
} }
} .expandable-link {
.service-location-button-question { font-style: italic;
.question-text { font-weight: $font-weight-bold;
margin-top: 1.5rem; text-decoration: none;
color: $heritage-blue-primary;
span { line-height: 1.375rem;
text-align: center; &:hover {
text-decoration: underline;
} }
} }
} }

View file

@ -35,7 +35,7 @@
<script> <script>
import textLink from '@/ux-components/text-link/text-link.vue'; 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 modal from '@/digital-components/modal/modal.vue';
import alert from '@/ux-components/alert/alert.vue'; import alert from '@/ux-components/alert/alert.vue';

View file

@ -1,51 +0,0 @@
<template>
<textboxQuestion
ref="zipInputTextQuestion"
v-model="value"
:cmsWidgetName="cmsWidgetName"
inputId="serviceZipCode"
questionAlignment="center"
cornerStyle="rounded"
mask="#####"
:displayQuestionText="false"
isRequired
validationRules="zip-required|zip-format" />
</template>
<script>
import textboxQuestion from '@/digital-components/textbox-question/textbox-question.vue';
// Supporting Files
import { defineRule } from 'vee-validate';
import { required, regex } from '@/helpers/validation-rules';
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));
export default {
name: 'service-zip-question',
components: {
textboxQuestion
},
// TODO: Correct prop def.
props: {
modelValue: {
serviceZipCode: String
},
cmsWidgetName: String
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
}
}
};
</script>

View file

@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
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';
describe('service-zip-question.vue', () => { describe('service-zip-question.vue', () => {
it('Should get the modelValue', async () => { it('Should get the modelValue', async () => {
// Arrange // Arrange
const text = 'test'; const text = '12345';
const wrapper = shallowMount(serviceZipQuestion, { const wrapper = shallowMount(serviceZipQuestion, {
props: { props: {
modelValue: text modelValue: text
@ -13,16 +13,16 @@ 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 = '55555';
// Assert // Assert
expect(modelValueText).toEqual('test'); expect(modelValueText).toEqual('12345');
}); });
it('Should emit to set value', async () => { it('Should emit to set value', async () => {
// Arrange // Arrange
const text = 'test'; const text = '12345';
const wrapper = shallowMount(serviceZipQuestion, { const wrapper = shallowMount(serviceZipQuestion, {
props: { props: {
modelValue: text modelValue: text
@ -31,9 +31,10 @@ describe('service-zip-question.vue', () => {
}); });
// Act // Act
wrapper.vm.value = 'test also'; wrapper.vm.internalZipcode = '55555';
wrapper.vm.updateModelValue();
// Assert // Assert
expect(wrapper.emitted('update:modelValue')).toEqual([['test also']]); expect(wrapper.emitted('update:modelValue')).toEqual([['55555']]);
}); });
}); });

View file

@ -0,0 +1,74 @@
<template>
<textboxQuestion
ref="zipInputTextQuestion"
v-model="internalZipcode"
:cmsWidgetName="cmsWidgetName"
inputId="serviceZipCode"
cornerStyle="rounded"
mask="#####"
isRequired
:hasError="hasError"
@clickEvent="updateModelValue"
:validationRules="`zip-required|${cmsWidgetName}-zip-format`" />
</template>
<script>
import textboxQuestion from '@/digital-components/textbox-question/textbox-question.vue';
// Supporting Files
import { defineRule } from 'vee-validate';
import { required, regex } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages';
// Define Validation Rules
defineRule('zip-required', required(errorMessages.SERVICE_ZIP_REQUIRED));
export default {
name: 'service-zip-question',
components: {
textboxQuestion
},
// TODO: Correct prop def.
props: {
modelValue: String,
cmsWidgetName: String,
serviceZipFormatErrorMessage: {
type: String,
default: errorMessages.SERVICE_ZIP_FORMAT
},
hasError: {
type: Boolean,
default: false
}
},
data() {
return {
internalZipcode: this.modelValue
}
},
emits: ['update:modelValue'],
methods: {
updateModelValue() {
this.$emit('update:modelValue', this.internalZipcode);
}
},
watch: {
modelValue(newValue) {
this.internalZipcode = newValue;
}
},
mounted() {
defineRule(`${this.cmsWidgetName}-zip-format`, regex(/^\d{5}$/, this.serviceZipFormatErrorMessage));
}
};
</script>
<style lang="scss" scoped>
.textbox-question.has-success {
:deep(input.form-control) {
border: $border-input;
&:focus {
border: $border-input-focus;
}
}
}
</style>

View file

@ -0,0 +1,411 @@
<template>
<transition
name="fade"
mode="out-in">
<div
class="shop-address"
aria-live="polite">
<div class="address-header">{{ questionText }}</div>
<div
v-if="modelValue"
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">{{ getProviderAddress(modelValue) }}</div>
<div class="address-line">
{{ getProviderCityZipState(modelValue) }}
</div>
</div>
<div
v-else
class="no-shops-found">
{{ `No shops found within 100 miles of ${internalZipcode}` }}
</div>
<buttonMain
class="more-shops-button"
:buttonText="showMoreShopsLinkText"
:variant="'primary'"
@clickEvent="openModal" />
<alert
class="alert-shop-distance"
v-if="modelValue?.distanceInMiles > 30"
ref="alertShopDistance"
cmsWidgetName="AlertShopDistanceWidget"
alertClass="alert-warning" />
<modal
:ref="modalName"
:headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened"
:footerButtonText="modalFooterText"
:modalPosition="modalPositions.center"
:allowInvalidSubmit="true"
@footerButtonEvent="updateSelectedProvider">
<googleMap
id="map"
class="mb-4"
:markers="providerAddresses"
:zipCode="internalZipcode" />
<dropdownQuestion
inputId="searchRadiusDropdown"
ref="searchRadiusQuestion"
v-model="searchRadiusInMiles"
:cmsWidgetName="searchRadiusQuestionWidgetName"
:variant="dropdownVariants.compact"
:options="searchRadiusOptions" />
<buttonQuestion
ref="shopListButtonQuestion"
v-model="internalProviderNumber"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
class="radioQuestion"
:answers="nearbyShopsData"
groupName="chooseShop"
textPosition="text-start"
isRequired
validationRules="option-required"
:additionalButtonData="additionalButtonData" />
<span class="service-zip-prompt">
{{ serviceZipPrompt }}
</span>
<serviceZipQuestion
v-if="renderServiceZip"
:ref="SERVICE_ZIP_QUESTION_REF_NAME"
v-model="internalZipcode"
customInputId="serviceZipCode"
:cmsWidgetName="textboxQuestionWidgetName" />
<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>
</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';
import dropdownQuestion from '@/digital-components/dropdown-question/dropdown-question.vue';
import googleMap from '@/iss-components/google-map/google-map.vue';
// Supporting files
import baseMixin from '@/mixins/base-mixin.js';
import { toTitleCase } from '@/helpers/text-helper.js';
import { markRaw } from 'vue';
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',
components: {
alert,
buttonMain,
modal,
serviceZipQuestion,
buttonQuestion,
dropdownQuestion,
googleMap
},
mixins: [baseMixin],
props: {
modelValue: {
type: Object,
default: () => null
},
serviceZipcode: String,
cmsWidgetName: String,
validationRules: String,
modalWidgetName: String,
selectedAppointmentType: String,
},
data() {
return {
toTitleCase,
serviceZipCodeTextInputId: '',
internalZipcode: this.serviceZipcode,
internalProviderNumber: this.modelValue?.providerNumber,
searchRadiusQuestionWidgetName: 'SearchRadiusQuestionWidget',
textboxQuestionWidgetName: 'ChangeLocationZipQuestionWidget',
shopListButton: markRaw(shopListButton),
searchRadiusInMiles: "25",
nearbyShops: [],
modalPositions,
dropdownVariants,
renderServiceZip: true,
errorMessage: '',
openingModal: false,
SERVICE_ZIP_QUESTION_REF_NAME
};
},
emits: ['update:modelValue', 'zip-updated'],
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, widgetFields.INPUT_QUESTION_WIDGET.QUESTION_TEXT);
},
showMoreShopsLinkText() {
return this.getCmsContent('ShowMoreShopsLinkWidget', widgetFields.TEXT_BLOCK_WIDGET.TEXT);
},
displayedShopName() {
if(this.modelValue.isSafeliteShop) {
return this.getCmsContent('SafeliteShopNameWidget', widgetFields.TEXT_BLOCK_WIDGET.TEXT);
}
return toTitleCase(this.modelValue.companyName);
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, widgetFields.CONTENT_GROUP_WIDGET.HEADER_TEXT).replaceAll('{custom:serviceZipcode}', this.internalZipcode);
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, widgetFields.CONTENT_GROUP_WIDGET.FOOTER_TEXT);
},
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: "InshopOrDropoff"
};
},
serviceZipPrompt() {
return this.getCmsContent(this.modalWidgetName, widgetFields.CONTENT_GROUP_WIDGET.BODY_TEXT_2);
},
nearbyShopsData() {
return this.nearbyShops.map((shopProvider) => {
const city = toTitleCase(shopProvider.address.city);
const distanceInMiles = shopProvider.distanceInMiles.toFixed(2);
return {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: this.getFullProviderAddress(shopProvider),
value: shopProvider.providerNumber
};
});
},
searchRadiusArray() {
const options = this.getCmsContent(this.searchRadiusQuestionWidgetName, widgetFields.INPUT_QUESTION_WIDGET.ANSWERS);
return options;
},
searchRadiusOptions() {
const optionsObj = {};
if(this.searchRadiusArray) {
this.searchRadiusArray.forEach(option => {
optionsObj[option.Name] = option.Text;
});
}
return optionsObj;
},
providerAddresses() {
return this.nearbyShops?.map((provider) => ({
title: toTitleCase(provider.companyName),
fullAddress: this.getFullProviderAddress(provider),
addressLines: [this.getProviderAddress(provider), this.getProviderCityZipState(provider)]
})) ?? [];
},
},
watch: {
async internalZipcode() {
if(this.openingModal) {
return;
}
const shopsUpdated = await this.updateShops();
if (shopsUpdated) {
await this.$nextTick();
this.$refs[SERVICE_ZIP_QUESTION_REF_NAME].internalZipcode = '';
}
},
async searchRadiusInMiles() {
if(this.openingModal) {
return;
}
await this.updateShops();
}
},
methods: {
async updateShops(autoExpand = false) {
this.errorMessage = '';
let result = await this.getNearbyShops(this.searchRadiusInMiles);
let currentSearchIndex = this.searchRadiusArray.findIndex(option => option.Name === this.searchRadiusInMiles);
while (autoExpand && result.length === 0 && currentSearchIndex < this.searchRadiusArray.length - 1) {
const newSearchRadius = this.searchRadiusArray[currentSearchIndex + 1].Name;
result = await this.getNearbyShops(newSearchRadius);
currentSearchIndex++;
}
if (result.length === 0) {
this.errorMessage = errorMessages.ZIP_CODE_NOT_SERVICED_FOR_VEHICLE;
}
else {
if (!result.find(shop => shop.providerNumber === this.internalProviderNumber)) {
this.internalProviderNumber = '';
await this.$nextTick();
}
this.nearbyShops = result;
this.searchRadiusInMiles = this.searchRadiusArray[currentSearchIndex].Name;
return true;
}
},
openModal() {
this.$refs[this.modalName].openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
},
onModalOpened() {
this.openingModal = true;
this.internalZipcode = this.serviceZipcode;
this.searchRadiusInMiles = "25";
this.nearbyShops = [];
this.updateShops(true).then(() => {
this.internalProviderNumber = this.nearbyShops[0]?.providerNumber;
}).finally(() => {
this.$nextTick().then(() => {
this.openingModal = false;
});
});
this.forceServiceZipRerender();
},
async updateSelectedProvider() {
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();
}
},
async getNearbyShops(radiusInMiles) {
const result = await useMainStore().getProviders(this.internalZipcode, radiusInMiles);
return result.data.shopProviders;
},
getFullProviderAddress(provider) {
const addressLine1 = this.getProviderAddress(provider);
const addressLine2 = this.getProviderCityZipState(provider);
const joinString =
addressLine1.length > 0 && addressLine2.length > 0 ? ', ' : '';
return [addressLine1, addressLine2].join(joinString);
},
getProviderAddress(provider) {
return toTitleCase(provider?.address?.streetAddress);
},
getProviderCityZipState(provider) {
const city = toTitleCase(provider?.address?.city);
const state = provider?.address?.state ?? '';
const zipCode = provider?.address?.zipCode ?? '';
let addressLine2 = '';
if (city) {
addressLine2 += city;
if (state || zipCode) {
addressLine2 += state ? ', ' : ' ';
}
}
if (state) {
addressLine2 += state;
addressLine2 += zipCode ? ' ' : '';
}
if (zipCode) {
addressLine2 += zipCode;
}
return addressLine2;
},
forceServiceZipRerender() {
this.renderServiceZip = false;
this.$nextTick(() => {
this.renderServiceZip = true;
});
},
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/ux-variables-svg-strings.scss";
.shop-address {
.address-header {
color: $black;
font-weight: 600;
margin-bottom: 0.625rem;
}
.address-line {
display: flex;
}
.shop-distance {
margin-left: auto;
}
.current-address {
margin: .625rem 0;
}
.no-shops-found {
font-size: .75rem;
margin: .75rem 0rem;
font-weight: $font-weight-bold;
line-height: 1.375rem;
}
.more-shops-button {
margin-bottom: 1.25rem;
}
.alert-shop-distance {
margin-top: 2.5rem;
}
:deep(.modal-title) {
font-weight: 400;
display: block;
width: fit-content;
}
.radioQuestion {
margin-bottom: .625rem;
}
.service-zip-prompt {
color: $black;
font-weight: 600;
}
: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

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

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

@ -14,17 +14,17 @@ html {
border-radius: 60px; border-radius: 60px;
.button-content { .button-content {
border: none; border: 1px solid $red;
}
&:hover {
@include box-shadow-hover($red-200);
z-index: 5;
} }
input[type="radio"]:focus+.list-button-content { input[type="radio"]:focus+.list-button-content {
box-shadow: 0 0 0 2.5px $red; box-shadow: 0 0 0 2.5px $red;
} }
&:not(.selected):not(.no-hover):hover {
position: relative;
@include box-shadow-hover($red-200);
z-index: 5;
}
} }
&.list-card { &.list-card {

View file

@ -1,7 +1,9 @@
.base-input-button { .base-input-button {
&:not(.has-error):hover { &:not(.disabled) {
cursor: pointer; cursor: pointer;
}
&:not(.has-error):hover {
&.list-button, &.list-button,
&.list-button-horizontal, &.list-button-horizontal,
&.list-card { &.list-card {

View file

@ -70,8 +70,13 @@ $orange: #fd7e14;
$teal: #20c997; $teal: #20c997;
$cyan: #0dcaf0; $cyan: #0dcaf0;
//Heritage
$heritage-blue-primary: #0070D1;
$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: (
@ -153,6 +158,7 @@ $font-weight-bolder: bolder;
$border-radius: 0.25rem; $border-radius: 0.25rem;
$border-radius-sm: 0.2rem; $border-radius-sm: 0.2rem;
$border-radius-lg: 0.5rem; //Used for buttons. Can be used for other things, of course. $border-radius-lg: 0.5rem; //Used for buttons. Can be used for other things, of course.
$border-radius-xl: 1.375rem;
$border-radius-pill: 50rem; $border-radius-pill: 50rem;
$border-radius-list-button: 1.375rem; // Used for list buttons $border-radius-list-button: 1.375rem; // Used for list buttons
@ -205,6 +211,11 @@ $alert-bg-scale: -90%;
$alert-border-scale: -100%; $alert-border-scale: -100%;
$alert-color-scale: 40%; $alert-color-scale: 40%;
$alert-yellow-bg: #fef6e8;
$alert-yellow-color: #e86421;
$alert-red-bg: #fbe9e8;
$alert-red-color: $red;
//Modal animation //Modal animation
// This affects all [Bootstrap] modals // This affects all [Bootstrap] modals
$modal-fade-transform: translate(0, 100%); $modal-fade-transform: translate(0, 100%);
@ -216,9 +227,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 $blue; $border-input-focus: 2.5px solid $heritage-blue-primary;
//Heritage
$heritage-blue-primary: #0070D1;
$heritage-blue-secondary: #167cac;

View file

@ -1,64 +1,85 @@
<template> <template>
<div <div
class="alert fade show text-center mb-0 py-2 px-4" class="alert fade show"
role="alert" role="alert"
:class="[ :class="[
isDismissible ? 'alert-dismissible' : '', isDismissible ? 'alert-dismissible' : '',
alertClass, alertClass,
cssClassNameForCmsWidget, cssClassNameForCmsWidget,
]"> ]">
<p class="m-0 fw-bold alert-heading"> <div class="alert-header">
{{ alertHeadline }} <svg class="alert-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="21" viewBox="0 0 20 21">
</p> <path fill-rule="nonzero" d="M10 .5c5.523 0 10 4.477 10 10s-4.477 10-10 10-10-4.477-10-10S4.477.5 10 .5zm.043 13.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm.77-9.2h-1.54l-.088.009a.502.502 0 0 0-.299.193.66.66 0 0 0-.125.47l.747 6.806.017.096c.065.249.263.425.495.426l.084-.008c.22-.042.396-.246.427-.51l.793-6.805.004-.102a.651.651 0 0 0-.127-.37.489.489 0 0 0-.388-.205z"/>
<template
v-for="paragraph in splitAlertCopyForParagraphTag"
:key="paragraph">
<p
v-if="!doesCopyContainRouterLink(paragraph) && !doesCopyContainTextLink(paragraph)"
class="m-0 text-body small"
v-html="paragraph"></p>
<p
v-else
class="m-0 text-body small">
<template v-if="doesCopyContainRouterLink(paragraph)">
<textBlock
:customText="paragraph"
class="mb-1"
marginTopSizeOverride="0" />
</template>
<template
v-for="copy in splitCopyOnCMSPlaceHolder(paragraph)"
v-else
:key="copy">
<span v-if="doesCopyContainTextLink(copy)">
<textLink
linkType="text"
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="#!"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
@click-event="
$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))
" />
</span>
<span
v-else
v-html="copy"></span>
</template>
</p>
</template>
<button
type="button"
class="btn-close p-2"
data-bs-dismiss="alert"
aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 23.7 23.7"
xml:space="preserve">
<path
d="m23.24 2.7-9.15 9.15L23.24 21a1.581 1.581 0 0 1-1.12 2.7c-.42 0-.82-.16-1.12-.46l-9.15-9.15-9.15 9.15c-.3.3-.7.46-1.12.46A1.581 1.581 0 0 1 .46 21l8.47-8.47.68-.68L.46 2.7c-.62-.62-.62-1.62 0-2.24.62-.62 1.62-.62 2.24 0l8.47 8.47.68.68L21 .46a1.57 1.57 0 0 1 2.23 0c.63.62.63 1.62.01 2.24z" />
</svg> </svg>
</button> <span class="m-0 fw-bold alert-header-text">
{{ alertHeadline }}
</span>
<button
v-if="isCollapsible"
class="btn-collapse"
type="button"
@click="toggleCollapse">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="9" viewBox="0 0 15 9"
class="btn-collapse-icon"
:class="{ 'rotated': !collapsed }">
<path fill-rule="nonzero" d="M7.5 0a.806.806 0 0 0-.593.265L.246 7.455a.957.957 0 0 0 0 1.28.796.796 0 0 0 1.185 0l6.07-6.55 6.068 6.55a.796.796 0 0 0 1.186 0 .957.957 0 0 0 0-1.28L8.093.265A.806.806 0 0 0 7.5 0"/>
</svg>
</button>
<button
v-if="isDismissible"
type="button"
class="btn-close p-2"
data-bs-dismiss="alert"
aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 23.7 23.7"
xml:space="preserve">
<path
d="m23.24 2.7-9.15 9.15L23.24 21a1.581 1.581 0 0 1-1.12 2.7c-.42 0-.82-.16-1.12-.46l-9.15-9.15-9.15 9.15c-.3.3-.7.46-1.12.46A1.581 1.581 0 0 1 .46 21l8.47-8.47.68-.68L.46 2.7c-.62-.62-.62-1.62 0-2.24.62-.62 1.62-.62 2.24 0l8.47 8.47.68.68L21 .46a1.57 1.57 0 0 1 2.23 0c.63.62.63 1.62.01 2.24z" />
</svg>
</button>
</div>
<div class="alert-body"
v-show="alertCopy"
:id="collapseId">
<template
v-for="paragraph in splitAlertCopyForParagraphTag"
:key="paragraph">
<p
v-if="!doesCopyContainRouterLink(paragraph) && !doesCopyContainTextLink(paragraph)"
class="m-0 text-body"
v-html="paragraph"></p>
<p
v-else
class="m-0 text-body">
<template v-if="doesCopyContainRouterLink(paragraph)">
<textBlock
:customText="paragraph"
class="mb-1"
marginTopSizeOverride="0" />
</template>
<template
v-for="copy in splitCopyOnCMSPlaceHolder(paragraph)"
v-else
:key="copy">
<span v-if="doesCopyContainTextLink(copy)">
<textLink
linkType="text"
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="#!"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
@click-event="
$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))
" />
</span>
<span
v-else
v-html="copy"></span>
</template>
</p>
</template>
</div>
</div> </div>
</template> </template>
@ -74,6 +95,7 @@ import {
import applicationConfig from '@/constants/application-config'; import applicationConfig from '@/constants/application-config';
import textBlock from '@/digital-components/text-block/text-block.vue'; import textBlock from '@/digital-components/text-block/text-block.vue';
import textLink from '@/ux-components/text-link/text-link.vue'; import textLink from '@/ux-components/text-link/text-link.vue';
import { Collapse } from 'bootstrap';
export default { export default {
// eslint-disable-next-line vue/multi-word-component-names // eslint-disable-next-line vue/multi-word-component-names
@ -84,13 +106,14 @@ export default {
}, },
props: { props: {
isDismissible: Boolean, isDismissible: Boolean,
isCollapsible: Boolean,
/* /*
alertClass class names: alertClass class names:
alert-success (green) alert-success (green)
alert-danger (red) alert-danger (red)
alert-warning (yellow) alert-warning (yellow)
alert-info (blue) alert-info (blue)
*/ */
alertClass: String, alertClass: String,
cmsWidgetName: { cmsWidgetName: {
type: String, type: String,
@ -106,7 +129,14 @@ export default {
shouldScrollToOnMount: { shouldScrollToOnMount: {
type: Boolean, type: Boolean,
default: true default: true
} },
startCollapsed: Boolean
},
data() {
return {
collapsed: this.startCollapsed || false,
collapseElement: null,
};
}, },
computed: { computed: {
pageQueryString() { pageQueryString() {
@ -124,10 +154,18 @@ export default {
}, },
splitAlertCopyForParagraphTag() { splitAlertCopyForParagraphTag() {
return splitCMSCopyOnParagraphTag(this.alertCopy); return splitCMSCopyOnParagraphTag(this.alertCopy);
} },
collapseId() {
return this.isCollapsible
? `${this.cmsWidgetName}-collapse`
: null;
},
}, },
mounted() { mounted() {
this.ensureAlertIsInViewPort(); this.ensureAlertIsInViewPort();
if (this.isCollapsible) {
this.collapseElement = Collapse.getOrCreateInstance(document.getElementById(this.collapseId));
}
}, },
methods: { methods: {
doesCopyContainRouterLink, doesCopyContainRouterLink,
@ -152,37 +190,82 @@ export default {
<= (window.innerHeight - footerHeight <= (window.innerHeight - footerHeight
|| document.documentElement.clientHeight - footerHeight) || document.documentElement.clientHeight - footerHeight)
); );
} },
toggleCollapse() {
this.collapsed = !this.collapsed;
if (this.collapsed) {
this.collapseElement.hide();
} else {
this.collapseElement.show();
}
},
} }
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.alert { .alert {
border: 1px solid;
padding: 0rem;
margin-bottom: 2rem;
.alert-header {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
padding: .75rem 1rem .75rem 1rem;
font-weight: 600;
color: $black;
}
.alert-icon {
min-width: 1rem;
max-height: 1rem;
}
.alert-header-text {
flex-grow: 1;
padding: 0rem .625rem;
}
.btn-collapse {
border: none;
background: none;
min-width: 1rem;
max-height: 1rem;
padding: 0;
}
.btn-collapse-icon {
height: 100%;
width: 100%;
transition: transform 0.3s;
transform: rotate(180deg);
&.rotated {
transform: none;
}
}
a { a {
text-decoration: none; text-decoration: none;
} }
border-color: transparent; border-color: transparent;
button {
display: none;
}
.btn-close { .btn-close {
background: none; background: none;
opacity: 1; opacity: 1;
width: 0.75rem; width: 0.75rem;
height: 0.75rem; height: 0.75rem;
top: 2px;
right: 2px;
} }
&.alert-dismissible { .alert-body {
button { margin: 0 1rem;
display: flex; padding: .5rem 1rem .75rem 1rem;
top: 2px; border-top: 1px solid;
right: 2px; color: $darker-gray;
} line-height: 26px;
font-weight: 400;
} }
&.alert-info { &.alert-info {
background-color: $blue-100; background-color: $blue-100;
.alert-heading { border-color: $blue-200;
color: $blue-700; .alert-body {
border-top-color: $blue-200;
} }
svg { svg {
fill: $blue-700; fill: $blue-700;
@ -191,9 +274,10 @@ export default {
} }
} }
&.alert-danger { &.alert-danger {
background-color: $red-100; background-color: $alert-red-bg;
.alert-heading { border-color: $alert-red-color;
color: $red-600; .alert-body {
border-top-color: $alert-red-color;
} }
svg { svg {
fill: $red-600; fill: $red-600;
@ -202,20 +286,20 @@ export default {
} }
} }
&.alert-warning { &.alert-warning {
background-color: $yellow-100; background-color: $alert-yellow-bg;
.alert-heading { border-color: $alert-yellow-color;
color: $yellow-600; .alert-body {
border-top-color: $alert-yellow-color;
} }
svg { svg {
fill: $yellow-600; fill: $alert-yellow-color;
width: 1rem;
height: 1rem;
} }
} }
&.alert-success { &.alert-success {
background-color: $green-100; background-color: $green-100;
.alert-heading { border-color: $green-200;
color: $green-700; .alert-body {
border-top-color: $green-200;
} }
svg { svg {
fill: $green-700; fill: $green-700;
@ -223,9 +307,5 @@ export default {
height: 1rem; height: 1rem;
} }
} }
& p {
font-size: 0.875rem;
margin-bottom: 0.25rem !important;
}
} }
</style> </style>

View file

@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { getMountOptions } from '@/helpers/unit-test-helper.js'; import { getMountOptions } from '@/helpers/unit-test-helper.js';
import buttonMain from '@/ux-components/button-main/button-main.vue'; import buttonMain from '@/ux-components/button-main/button-main.vue';
import buttonVariants from '@/constants/button-variants'; import { buttonVariants } from '@/constants/component-variants';
import buttonSizes from '@/constants/button-sizes'; import buttonSizes from '@/constants/button-sizes';
/** @ignore */ /** @ignore */

View file

@ -9,7 +9,7 @@
</template> </template>
<script> <script>
import buttonVariants from '@/constants/button-variants'; import { buttonVariants } from '@/constants/component-variants';
import buttonSizes from '@/constants/button-sizes'; import buttonSizes from '@/constants/button-sizes';
export default { export default {

View file

@ -5,7 +5,10 @@
buttonWrapperClasses="list-group base-input-button list-button no-hover d-flex flex-column w-100 mb-2"> buttonWrapperClasses="list-group base-input-button list-button no-hover d-flex flex-column w-100 mb-2">
<div <div
:aria-label="buttonLabel" :aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4"> class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4"
:class="{
'has-sub-copy': buttonLabelSubCopy,
}">
<span <span
class="m-0" class="m-0"
:class="textPosition"> :class="textPosition">
@ -85,6 +88,10 @@ $heritage-checked-border-color: #0070d1;
background: $heritage-checked-background-color; background: $heritage-checked-background-color;
border-color: $heritage-checked-border-color; border-color: $heritage-checked-border-color;
box-shadow: 0 0 0 1px $blue; box-shadow: 0 0 0 1px $blue;
&:not(.has-sub-copy) {
font-weight: 500;
color: $black;
}
} }
&:checked + .list-button-content span:nth-child(2) { &:checked + .list-button-content span:nth-child(2) {
font-weight: 400; font-weight: 400;
@ -104,6 +111,11 @@ $heritage-checked-border-color: #0070d1;
width: 100%; width: 100%;
outline: none; outline: none;
&:not(.has-sub-copy) {
font-weight: 400;
color: $darker-gray;
}
span { span {
&.small { &.small {
color: $darker-gray; color: $darker-gray;

View file

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