511 lines
14 KiB
Vue
511 lines
14 KiB
Vue
<template>
|
|
<div class="address-autocomplete">
|
|
<div class="">
|
|
<o-field
|
|
:label-for="id"
|
|
:message="fieldErrors"
|
|
:variant="fieldErrors ? 'danger' : ''"
|
|
class="!-mt-2"
|
|
:labelClass="labelClass"
|
|
>
|
|
<template #label>
|
|
{{ actualLabel }}
|
|
</template>
|
|
<o-button
|
|
v-if="canShowLocateMeButton"
|
|
class="!h-auto"
|
|
ref="mapMarker"
|
|
icon-right="map-marker"
|
|
@click="locateMe"
|
|
:title="t('Use my location')"
|
|
/>
|
|
<o-autocomplete
|
|
:data="addressData"
|
|
v-model="queryTextWithDefault"
|
|
:placeholder="placeholderWithDefault"
|
|
:customFormatter="(elem: IAddress) => addressFullName(elem)"
|
|
:debounceTyping="debounceDelay"
|
|
@typing="asyncData"
|
|
:icon="canShowLocateMeButton ? null : 'map-marker'"
|
|
expanded
|
|
@select="setSelected"
|
|
:id="id"
|
|
:disabled="disabled"
|
|
dir="auto"
|
|
class="!mt-0"
|
|
>
|
|
<template #default="{ option }">
|
|
<p class="flex gap-1">
|
|
<o-icon :icon="addressToPoiInfos(option).poiIcon.icon" />
|
|
<b>{{ addressToPoiInfos(option).name }}</b>
|
|
</p>
|
|
<small>{{ addressToPoiInfos(option).alternativeName }}</small>
|
|
</template>
|
|
<template #empty>
|
|
<template v-if="isFetching">{{ t("Searching…") }}</template>
|
|
<template v-else-if="queryTextWithDefault.length >= 3">
|
|
<p>
|
|
{{
|
|
t('No results for "{queryText}"', {
|
|
queryText: queryTextWithDefault,
|
|
})
|
|
}}
|
|
</p>
|
|
<p>
|
|
{{
|
|
t(
|
|
"You can try another search term or add the address details manually below."
|
|
)
|
|
}}
|
|
</p>
|
|
</template>
|
|
</template>
|
|
</o-autocomplete>
|
|
<o-button
|
|
:disabled="!queryTextWithDefault"
|
|
@click="resetAddress"
|
|
class="reset-area !h-auto"
|
|
icon-left="close"
|
|
:title="t('Clear address field')"
|
|
/>
|
|
</o-field>
|
|
<p v-if="gettingLocation" class="flex gap-2">
|
|
<Loading class="animate-spin" />
|
|
{{ t("Getting location") }}
|
|
</p>
|
|
<div
|
|
class="mt-2 p-2 rounded-lg shadow-md bg-white dark:bg-violet-3"
|
|
v-if="!hideSelected && (selected?.originId || selected?.url)"
|
|
>
|
|
<div class="">
|
|
<address-info
|
|
:address="selected"
|
|
:show-icon="true"
|
|
:show-timezone="true"
|
|
:user-timezone="userTimezone"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<o-collapse
|
|
v-model:open="detailsAddress"
|
|
:aria-id="`${id}-address-details`"
|
|
class="my-3"
|
|
v-if="allowManualDetails"
|
|
>
|
|
<template #trigger>
|
|
<o-button
|
|
variant="primary"
|
|
outlined
|
|
:aria-controls="`${id}-address-details`"
|
|
:icon-right="detailsAddress ? 'chevron-up' : 'chevron-down'"
|
|
>
|
|
{{ t("Details") }}
|
|
</o-button>
|
|
</template>
|
|
<form @submit.prevent="saveManualAddress">
|
|
<header>
|
|
<h2>{{ t("Manually enter address") }}</h2>
|
|
</header>
|
|
<section>
|
|
<o-field :label="t('Name')" labelFor="addressNameInput">
|
|
<o-input
|
|
aria-required="true"
|
|
required
|
|
v-model="selected.description"
|
|
id="addressNameInput"
|
|
/>
|
|
</o-field>
|
|
|
|
<o-field :label="t('Street')" labelFor="streetInput">
|
|
<o-input v-model="selected.street" id="streetInput" />
|
|
</o-field>
|
|
|
|
<o-field grouped>
|
|
<o-field :label="t('Postal Code')" labelFor="postalCodeInput">
|
|
<o-input v-model="selected.postalCode" id="postalCodeInput" />
|
|
</o-field>
|
|
|
|
<o-field :label="t('Locality')" labelFor="localityInput">
|
|
<o-input v-model="selected.locality" id="localityInput" />
|
|
</o-field>
|
|
</o-field>
|
|
|
|
<o-field grouped>
|
|
<o-field :label="t('Region')" labelFor="regionInput">
|
|
<o-input v-model="selected.region" id="regionInput" />
|
|
</o-field>
|
|
|
|
<o-field :label="t('Country')" labelFor="countryInput">
|
|
<o-input v-model="selected.country" id="countryInput" />
|
|
</o-field>
|
|
</o-field>
|
|
</section>
|
|
<footer class="mt-3 flex gap-2 items-center">
|
|
<o-button native-type="submit">
|
|
{{ t("Save") }}
|
|
</o-button>
|
|
<o-button outlined type="button" @click="resetAddress">
|
|
{{ t("Clear") }}
|
|
</o-button>
|
|
<p>
|
|
{{
|
|
t(
|
|
"You can drag and drop the marker below to the desired location"
|
|
)
|
|
}}
|
|
</p>
|
|
</footer>
|
|
</form>
|
|
</o-collapse>
|
|
<div
|
|
class="map"
|
|
v-if="!hideMap && !disabled && (selected.geom || detailsAddress)"
|
|
>
|
|
<map-leaflet
|
|
:coords="selected.geom ?? defaultCoords"
|
|
:marker="mapMarkerValue"
|
|
:updateDraggableMarkerCallback="reverseGeoCode"
|
|
:options="{ zoom: mapDefaultZoom }"
|
|
:readOnly="false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<script lang="ts" setup>
|
|
import { LatLng } from "leaflet";
|
|
import {
|
|
Address,
|
|
IAddress,
|
|
addressFullName,
|
|
addressToPoiInfos,
|
|
resetAddress as resetAddressAction,
|
|
} from "../../types/address.model";
|
|
import AddressInfo from "../../components/Address/AddressInfo.vue";
|
|
import {
|
|
computed,
|
|
ref,
|
|
watch,
|
|
defineAsyncComponent,
|
|
onMounted,
|
|
reactive,
|
|
onBeforeMount,
|
|
} from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { useGeocodingAutocomplete } from "@/composition/apollo/config";
|
|
import { ADDRESS } from "@/graphql/address";
|
|
import { useReverseGeocode } from "@/composition/apollo/address";
|
|
import { useLazyQuery } from "@vue/apollo-composable";
|
|
import { AddressSearchType } from "@/types/enums";
|
|
import Loading from "vue-material-design-icons/Loading.vue";
|
|
const MapLeaflet = defineAsyncComponent(
|
|
() => import("@/components/LeafletMap.vue")
|
|
);
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: IAddress | null;
|
|
defaultText?: string | null;
|
|
label?: string;
|
|
labelClass?: string;
|
|
userTimezone?: string;
|
|
disabled?: boolean;
|
|
hideMap?: boolean;
|
|
hideSelected?: boolean;
|
|
placeholder?: string;
|
|
resultType?: AddressSearchType;
|
|
defaultCoords?: string;
|
|
allowManualDetails?: boolean;
|
|
}>(),
|
|
{
|
|
defaultCoords: "0;0",
|
|
labelClass: "",
|
|
disabled: false,
|
|
hideMap: false,
|
|
hideSelected: false,
|
|
allowManualDetails: false,
|
|
}
|
|
);
|
|
|
|
const componentId = ref(0);
|
|
|
|
const emit = defineEmits(["update:modelValue"]);
|
|
|
|
const gettingLocationError = ref<string | null>(null);
|
|
const gettingLocation = ref(false);
|
|
const mapDefaultZoom = computed(() => {
|
|
if (selected.description) {
|
|
return 15;
|
|
}
|
|
return 5;
|
|
});
|
|
|
|
const addressData = ref<IAddress[]>([]);
|
|
|
|
const defaultAddress = new Address();
|
|
defaultAddress.geom = undefined;
|
|
defaultAddress.id = undefined;
|
|
const selected = reactive<IAddress>(defaultAddress);
|
|
|
|
const detailsAddress = ref(false);
|
|
|
|
const isFetching = ref(false);
|
|
|
|
const mapMarker = ref();
|
|
|
|
const placeholderWithDefault = computed(
|
|
() => props.placeholder ?? t("e.g. 10 Rue Jangot")
|
|
);
|
|
|
|
onBeforeMount(() => {
|
|
componentId.value += 1;
|
|
});
|
|
|
|
const id = computed((): string => {
|
|
return `full-address-autocomplete-${componentId.value}`;
|
|
});
|
|
|
|
const modelValue = computed(() => props.modelValue);
|
|
|
|
watch(modelValue, () => {
|
|
console.debug("modelValue changed");
|
|
setSelected(modelValue.value);
|
|
});
|
|
|
|
onMounted(() => {
|
|
setSelected(modelValue.value);
|
|
});
|
|
|
|
const setSelected = (newValue: IAddress | null) => {
|
|
if (!newValue) return;
|
|
console.debug("setting selected to model value");
|
|
Object.assign(selected, newValue);
|
|
emit("update:modelValue", selected);
|
|
};
|
|
|
|
const saveManualAddress = (): void => {
|
|
console.debug("saving address");
|
|
selected.id = undefined;
|
|
selected.originId = undefined;
|
|
selected.url = undefined;
|
|
emit("update:modelValue", selected);
|
|
detailsAddress.value = false;
|
|
};
|
|
|
|
const checkCurrentPosition = (e: LatLng): boolean => {
|
|
console.debug("checkCurrentPosition");
|
|
if (!selected?.geom || !e) return false;
|
|
const lat = parseFloat(selected?.geom.split(";")[1]);
|
|
const lon = parseFloat(selected?.geom.split(";")[0]);
|
|
|
|
return e.lat === lat && e.lng === lon;
|
|
};
|
|
|
|
const { t, locale } = useI18n({ useScope: "global" });
|
|
|
|
const actualLabel = computed((): string => {
|
|
return props.label ?? t("Find an address");
|
|
});
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
const canShowLocateMeButton = computed((): boolean => {
|
|
return window.isSecureContext;
|
|
});
|
|
|
|
const { geocodingAutocomplete } = useGeocodingAutocomplete();
|
|
|
|
const debounceDelay = computed(() =>
|
|
geocodingAutocomplete.value === true ? 200 : 2000
|
|
);
|
|
|
|
const { load: searchAddressLoad, refetch: searchAddressRefetch } =
|
|
useLazyQuery<{
|
|
searchAddress: IAddress[];
|
|
}>(ADDRESS);
|
|
|
|
const asyncData = async (query: string): Promise<void> => {
|
|
console.debug("Finding addresses");
|
|
if (!query.length) {
|
|
addressData.value = [];
|
|
Object.assign(selected, defaultAddress);
|
|
return;
|
|
}
|
|
|
|
if (query.length < 3) {
|
|
addressData.value = [];
|
|
return;
|
|
}
|
|
|
|
isFetching.value = true;
|
|
|
|
try {
|
|
const queryVars = {
|
|
query,
|
|
locale: locale,
|
|
type: props.resultType,
|
|
};
|
|
|
|
const result =
|
|
(await searchAddressLoad(undefined, queryVars)) ||
|
|
(await searchAddressRefetch(queryVars))?.data
|
|
|
|
if (!result) {
|
|
isFetching.value = false;
|
|
return;
|
|
}
|
|
console.debug("onAddressSearchResult", result.searchAddress);
|
|
addressData.value = result.searchAddress;
|
|
isFetching.value = false;
|
|
} catch (e) {
|
|
console.error(e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const selectedAddressText = computed(() => {
|
|
if (!selected) return undefined;
|
|
return addressFullName(selected);
|
|
});
|
|
|
|
const queryText = ref();
|
|
|
|
const queryTextWithDefault = computed({
|
|
get() {
|
|
return (
|
|
queryText.value ?? selectedAddressText.value ?? props.defaultText ?? ""
|
|
);
|
|
},
|
|
set(newValue: string) {
|
|
queryText.value = newValue;
|
|
},
|
|
});
|
|
|
|
const resetAddress = (): void => {
|
|
console.debug("resetting address");
|
|
emit("update:modelValue", null);
|
|
resetAddressAction(selected);
|
|
queryTextWithDefault.value = "";
|
|
};
|
|
|
|
const locateMe = async (): Promise<void> => {
|
|
gettingLocation.value = true;
|
|
gettingLocationError.value = null;
|
|
try {
|
|
const location = await getLocation();
|
|
// mapDefaultZoom.value = 12;
|
|
reverseGeoCode(
|
|
new LatLng(location.coords.latitude, location.coords.longitude),
|
|
12
|
|
);
|
|
} catch (e: any) {
|
|
gettingLocationError.value = e.message;
|
|
}
|
|
gettingLocation.value = false;
|
|
};
|
|
|
|
const { load: loadReverseGeocode } = useReverseGeocode();
|
|
|
|
const reverseGeoCode = async (e: LatLng, zoom: number) => {
|
|
console.debug("reverse geocode");
|
|
|
|
// If the details is opened, just update coords, don't reverse geocode
|
|
if (e && detailsAddress.value) {
|
|
selected.geom = `${e.lng};${e.lat}`;
|
|
console.debug("no reverse geocode, just setting new coords");
|
|
return;
|
|
}
|
|
|
|
// If the position has been updated through autocomplete selection, no need to geocode it!
|
|
if (!e || checkCurrentPosition(e)) return;
|
|
|
|
try {
|
|
const result = await loadReverseGeocode(undefined, {
|
|
latitude: e.lat,
|
|
longitude: e.lng,
|
|
zoom,
|
|
locale: locale as unknown as string,
|
|
});
|
|
if (!result) return;
|
|
addressData.value = result.reverseGeocode;
|
|
|
|
if (addressData.value.length > 0) {
|
|
const foundAddress = addressData.value[0];
|
|
Object.assign(selected, foundAddress);
|
|
console.debug("reverse geocode succeded, setting new address");
|
|
queryTextWithDefault.value = addressFullName(foundAddress);
|
|
emit("update:modelValue", selected);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load reverse geocode", err);
|
|
}
|
|
};
|
|
|
|
// eslint-disable-next-line no-undef
|
|
const getLocation = async (): Promise<GeolocationPosition> => {
|
|
let errorMessage = t("Failed to get location.");
|
|
return new Promise((resolve, reject) => {
|
|
if (!("geolocation" in navigator)) {
|
|
reject(new Error(errorMessage as string));
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
resolve(pos);
|
|
},
|
|
(err) => {
|
|
switch (err.code) {
|
|
case GeolocationPositionError.PERMISSION_DENIED:
|
|
errorMessage = t("The geolocation prompt was denied.");
|
|
break;
|
|
case GeolocationPositionError.POSITION_UNAVAILABLE:
|
|
errorMessage = t("Your position was not available.");
|
|
break;
|
|
case GeolocationPositionError.TIMEOUT:
|
|
errorMessage = t("Geolocation was not determined in time.");
|
|
break;
|
|
default:
|
|
errorMessage = err.message;
|
|
}
|
|
reject(new Error(errorMessage as string));
|
|
}
|
|
);
|
|
});
|
|
};
|
|
|
|
const mapMarkerValue = computed(() => {
|
|
if (!selected.description) return undefined;
|
|
return {
|
|
text: [
|
|
addressToPoiInfos(selected).name,
|
|
addressToPoiInfos(selected).alternativeName,
|
|
],
|
|
icon: addressToPoiInfos(selected).poiIcon.icon,
|
|
};
|
|
});
|
|
|
|
const fieldErrors = computed(() => {
|
|
return gettingLocationError.value;
|
|
});
|
|
</script>
|
|
<style lang="scss">
|
|
.autocomplete {
|
|
.dropdown-menu {
|
|
z-index: 2000;
|
|
}
|
|
|
|
.dropdown-item.is-disabled {
|
|
opacity: 1 !important;
|
|
cursor: auto;
|
|
}
|
|
}
|
|
|
|
.read-only {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.map {
|
|
height: 400px;
|
|
width: 100%;
|
|
}
|
|
</style>
|