DigitalConsumer.FixMyGlass/src/fmg-components/address-questions/address-questions.vue
2024-05-22 10:17:19 -04:00

530 lines
20 KiB
Vue

<template>
<div class="address-questions">
<div class="row mb-4" aria-live="polite">
<div class="col">
<textboxQuestion
id="streetAddressField"
cmsWidgetName="StreetAddressQuestionWidget"
v-model="addressModel.streetAddress"
ref="autocomplete"
customInputId="autocomplete"
placeholderText="Search"
aria-haspopup=""
hasIcon
validationRules="street-address-required"
@keydown.enter.prevent />
</div>
</div>
<transition name="fade" mode="out-in">
<div
class="row mb-4"
v-show="showAddressFields && showApartmentNumberOrBusinessNameField"
aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="apartmentNumberOrBusinessName"
cmsWidgetName="ApartmentNumberOrBusinessNameQuestionWidget"
v-model="addressModel.apartmentNumberOrBusinessName"
ref="apartmentNumberOrBusinessName" />
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="city"
cmsWidgetName="CityQuestionWidget"
v-model="addressModel.city"
ref="city"
validationRules="city-required" />
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<dropdownQuestion
customDropdownId="state"
cmsWidgetName="StateQuestionWidget"
v-model="addressModel.state"
ref="state"
:options="stateOptions"
validationRules="state-required" />
</div>
<div class="col">
<textboxQuestion
customInputId="zipCode"
cmsWidgetName="ZipQuestionWidget"
v-model="addressModel.zipCode"
ref="zipCode"
mask="#####"
validationRules="zip-code-required|zip-code-format" />
</div>
</div>
</transition>
<alert
ref="alertVerificationWarning"
v-if="displayVerificationWarning"
class="mb-4"
cmsWidgetName="AlertVerificationWarningWidget"
alertClass="alert-warning"
v-bind:isDismissible="false" />
<alert
ref="alertNoMatchWarning"
v-if="displayNoMatchWarning"
class="mb-4"
cmsWidgetName="AlertNoMatchWarningWidget"
alertClass="alert-warning"
v-bind:isDismissible="false" />
</div>
</template>
<script>
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import dropdownQuestion from "@/digital-components/dropdown-question/dropdown-question";
import alert from "@/ux-components/alert/alert";
import { applicationConfig } from "@/constants/application-config.js";
import { defineRule } from "vee-validate";
import { required, regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// DEFINE VALIDATION RULES
defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQUIRED));
defineRule("city-required", required(errorMessages.CITY_REQUIRED));
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED));
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
export default {
name: "address-questions",
emits: ["update:modelValue"], // The component emits an event
props: {
modelValue: {
type: Object,
default: () => ({
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
}),
},
validationRules: String,
captureApartmentNumberOrBusinessName: {
type: Boolean,
default: false,
},
preserveCityAndStateOnReset: {
type: Boolean,
default: false,
},
},
data() {
return {
autocomplete: null,
autocompleteListener: null,
showAddressFields: false,
matchFound: null, // null = no attempted match, true = match was found, false = match was not found
displayVerificationWarning: false,
displayNoMatchWarning: false,
alertHeadlineVerificationWarning: "",
alertCopyVerificationWarning: "",
alertHeadlineNoMatchWarning: "",
alertCopyNoMatchWarning: "",
};
},
computed: {
stateOptions: {
get: function () {
return {
AL: "Alabama",
AK: "Alaska",
AZ: "Arizona",
AR: "Arkansas",
CA: "California",
CO: "Colorado",
CT: "Connecticut",
DE: "Delaware",
DC: "District Of Columbia",
FL: "Florida",
GA: "Georgia",
HI: "Hawaii",
ID: "Idaho",
IL: "Illinois",
IN: "Indiana",
IA: "Iowa",
KS: "Kansas",
KY: "Kentucky",
LA: "Louisiana",
ME: "Maine",
MD: "Maryland",
MA: "Massachusetts",
MI: "Michigan",
MN: "Minnesota",
MS: "Mississippi",
MO: "Missouri",
MT: "Montana",
NE: "Nebraska",
NV: "Nevada",
NH: "New Hampshire",
NJ: "New Jersey",
NM: "New Mexico",
NY: "New York",
NC: "North Carolina",
ND: "North Dakota",
OH: "Ohio",
OK: "Oklahoma",
OR: "Oregon",
PA: "Pennsylvania",
RI: "Rhode Island",
SC: "South Carolina",
SD: "South Dakota",
TN: "Tennessee",
TX: "Texas",
UT: "Utah",
VT: "Vermont",
VA: "Virginia",
WA: "Washington",
WV: "West Virginia",
WI: "Wisconsin",
WY: "Wyoming",
};
},
},
addressModel: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
showApartmentNumberOrBusinessNameField: {
get: function () {
return this.captureApartmentNumberOrBusinessName;
},
},
addressField1: {
get: function () {
return document.getElementById("autocomplete");
},
},
isTouchDevice: {
get: function () {
return (
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
);
},
},
},
methods: {
loadGooglePlacesAutocompleteScript() {
// Load the Google Places Autocomplete script
const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY;
this.$loadScript(
`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=Function.prototype`
).then(() => {
// When loaded, trigger the setup
this.initializeAutocomplete();
this.addressField1.focus();
});
},
initializeAutocomplete() {
// Initialize the Google Places Autocomplete
this.autocomplete = new window.google.maps.places.Autocomplete(this.addressField1, {
componentRestrictions: { country: ["us"] },
fields: ["address_components"],
types: ["address"],
});
// Set up the Autocomplete place_changed event to call our method to fill in the address
this.autocompleteListener = window.google.maps.event.addListener(
this.autocomplete,
"place_changed",
this.fillInAddress
);
// When the Street Address textbox receives focus,
// append the search results list container to the bottom of the textbox
// and disable browser autofill
this.addressField1.addEventListener("focus", (e) => {
// Make place results box stick to the input on scroll
const streetAddressField = document.getElementById("streetAddressField");
const autocompleteResultsContainer =
document.getElementsByClassName("pac-container")[0];
if (autocompleteResultsContainer) {
streetAddressField.appendChild(autocompleteResultsContainer);
}
});
this.addressField1.addEventListener("keydown", (e) => {
// When the user presses a key in the Street Address field, immediately disable autocomplete for that field.
// For some reason we have to explicitly tell FireFox to set it to "off" which doesn't actually disable autofill
// then set it to "new-password" which does disable it on both WebKit and FireFox. ¯\_(ツ)_/¯
// this.addressField1.setAttribute("autocomplete", "off");
// this.addressField1.setAttribute("autocomplete", "new-password");
// If a match has been previously attempted then do nothing
if (this.matchFound !== null) {
return;
}
// When either of the two enter keys or the tab key are pressed
if (e.code === "Enter" || e.code === "NumpadEnter" || e.code === "Tab") {
window.google.maps.event.trigger(this.autocomplete, "place_changed");
}
});
// // When clicking anywhere outside of the street address field we want to trigger place_changed
// document.addEventListener("mouseup", (e) => {
// if (e.target.id != "autocomplete" && this.addressField1.value !== "") {
// window.google.maps.event.trigger(this.autocomplete, "place_changed");
// }
// });
this.addressField1.addEventListener("input", (e) => {
console.log(e);
switch (e.inputType) {
case "insertText":
case "insertFromPaste":
case "deleteContentBackward":
// If the user enters text by typing, or pasting text, or deleting text already entered, disable autofill
this.disableAutoFill();
break;
default:
// If the user enters text by *autofill* then disable the *autocomplete*
this.unloadAutocomplete();
}
});
},
disableAutoFill() {
// For some reason we have to explicitly tell FireFox to set it to "off" which doesn't actually disable autofill
// then set it to "new-password" which does disable it for both WebKit and FireFox. ¯\_(ツ)_/¯
this.addressField1.setAttribute("autocomplete", "off");
this.addressField1.setAttribute("autocomplete", "new-password");
},
findAddressComponentByType(place, componentName, componentLength) {
const component = place.address_components.find((component) =>
component.types.find((type) => type == componentName)
);
if (component) {
return component[componentLength] ?? "";
} else {
return "";
}
},
fillInAddress(place) {
if (!place) {
place = this.autocomplete.getPlace();
}
const selectedItem = document.querySelector(".pac-item-selected");
if (!place && !selectedItem) {
this.fillInAddressUsingFirstItem();
return;
}
if (place && place.address_components) {
this.matchFound = true;
const self = this;
this.$nextTick(function () {
self.showAddressFields = true;
const streetNumber = this.findAddressComponentByType(
place,
"street_number",
"long_name"
);
const route = this.findAddressComponentByType(place, "route", "short_name");
const city = this.findAddressComponentByType(place, "locality", "long_name");
let state = "";
let zipCode = "";
// If city has an exact match, fill in the state from the autocomplete.
if (city != "") {
state = this.findAddressComponentByType(
place,
"administrative_area_level_1",
"short_name"
);
}
// If state has an exact match, fill in the zipCode from the autocomplete.
if (state != "") {
zipCode = this.findAddressComponentByType(
place,
"postal_code",
"long_name"
);
}
const isAddressComplete =
(streetNumber ?? "") !== "" &&
(route ?? "") !== "" &&
(city ?? "") !== "" &&
(state ?? "") !== "" &&
(zipCode ?? "") !== "";
self.addressModel.streetAddress =
streetNumber && route ? `${streetNumber} ${route}` : `${route}`;
self.addressModel.city = city;
self.addressModel.state = state;
self.addressModel.zipCode = zipCode;
if (!this.displayVerificationWarning) {
this.displayVerificationWarning = place.partial_match || !isAddressComplete;
}
// After filling in the address fields, disable the address autocomplete
this.unloadAutocomplete();
});
}
},
fillInAddressUsingFirstItem() {
// Fill-in the address using first item in the list.
const item = document.querySelector(".pac-container .pac-item > .pac-item-query");
const item2 = document.querySelector(".pac-container .pac-item > span:nth-child(3)");
if (item != null) {
let firstResult = `${item.textContent}, ${item2.textContent}`;
const geocoder = new window.google.maps.Geocoder();
const self = this;
geocoder.geocode(
{
address: firstResult,
},
function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
self.displayVerificationWarning = true;
self.fillInAddress(results[0]);
}
}
);
} else {
this.matchFound = false;
}
},
resetAlerts() {
this.displayVerificationWarning = false;
},
unloadAutocomplete() {
if (this.autocompleteListener && this.autocomplete) {
window.google.maps.event.removeListener(this.autocompleteListener);
this.autocompleteListener = null;
window.google.maps.event.clearInstanceListeners(this.autocomplete);
this.autocomplete = null;
this.addressField1.onchange = null;
const pacContainer = document.querySelector(".pac-container");
if (pacContainer) {
pacContainer.remove();
}
}
},
},
mounted() {
// If we already have a full address, show it
this.showAddressFields =
(this.addressModel.streetAddress ?? "") !== "" &&
(this.addressModel.city ?? "") !== "" &&
(this.addressModel.state ?? "") !== "" &&
(this.addressModel.zipCode ?? "") !== "";
if (!this.showAddressFields) {
this.loadGooglePlacesAutocompleteScript();
}
const element = document.getElementsByClassName("address-questions")[0];
element.addEventListener("change", (e) => {
if (this.showAddressFields) {
return false;
}
let autoFilledInputs = [];
autoFilledInputs = element.querySelectorAll("input:-webkit-autofill");
this.showAddressFields = autoFilledInputs.length > 0;
if (this.showAddressFields) {
this.unloadAutocomplete();
}
this.addressField1.classList.remove("has-icon");
});
},
beforeUpdate() {
// It is necessary to set focus on the street address on this lifecycle hook when this component is used in a modal.
if (!this.addressField1.matches(":focus")) {
this.addressField1.focus();
}
},
unmounted() {
this.unloadAutocomplete();
},
watch: {
matchFound: {
handler(newValue) {
if (newValue === null) {
this.displayNoMatchWarning = false;
this.displayVerificationWarning = false;
this.unwatchAddress();
return;
}
if (!newValue) {
this.displayNoMatchWarning = true;
this.addressModel.city = "";
if (!this.preserveCityAndStateOnReset) {
this.addressModel.state = "";
this.addressModel.zipCode = "";
}
this.displayVerificationWarning = false;
// Only deep watch the Address Model after a failed match
this.unwatchAddress = this.$watch(
"addressModel",
() => {
// When the address model changes reset to "no attempted match"
this.matchFound = null;
this.$nextTick();
},
{ deep: true, flush: "post" }
);
}
},
deep: true,
},
},
components: {
textboxQuestion,
dropdownQuestion,
alert,
},
};
</script>
<style lang="scss">
.address-questions {
margin-top: 0.5rem;
}
#streetAddressField {
position: relative;
.pac-container {
top: 76px !important; // Height of #streetAddressField
left: 0 !important;
}
}
</style>