685 lines
19 KiB
Vue
685 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import type { Category, CreateReportRequest } from '~/types/api'
|
|
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
|
import { reverseGeocode, searchAddresses } from '~/utils/geocoding'
|
|
|
|
interface Props {
|
|
isLoading?: boolean
|
|
categories?: Category[]
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
|
}
|
|
|
|
interface GeocodingSuggestion {
|
|
label: string
|
|
latitude: number
|
|
longitude: number
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
isLoading: false,
|
|
categories: () => []
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const form = ref({
|
|
category_id: null as number | null,
|
|
latitude: null as number | null,
|
|
longitude: null as number | null,
|
|
address: '',
|
|
description: '',
|
|
})
|
|
|
|
const media = ref<File[]>([])
|
|
const errors = ref<Record<string, string>>({})
|
|
const successMessage = ref(false)
|
|
const isGettingLocation = ref(false)
|
|
const addressSuggestions = ref<GeocodingSuggestion[]>([])
|
|
const isSearchingAddress = ref(false)
|
|
const isResolvingAddress = ref(false)
|
|
const addressSearchError = ref('')
|
|
const showAddressSuggestions = ref(false)
|
|
|
|
const mapContainer = ref<HTMLElement | null>(null)
|
|
let leaflet: typeof import('leaflet') | null = null
|
|
let map: LeafletMap | null = null
|
|
let marker: LeafletMarker | null = null
|
|
let addressSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let manualLocationSyncTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let addressSearchRequestId = 0
|
|
let reverseGeocodeRequestId = 0
|
|
let lastSyncedLocationKey = ''
|
|
let skipNextAddressSearch = false
|
|
|
|
const loadLeaflet = async () => {
|
|
if (leaflet) return leaflet
|
|
|
|
const leafletModule = await import('leaflet')
|
|
|
|
const defaultIcon = leafletModule.icon({
|
|
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
|
|
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
|
|
shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
shadowSize: [41, 41]
|
|
})
|
|
leafletModule.Marker.prototype.options.icon = defaultIcon
|
|
|
|
leaflet = leafletModule
|
|
return leaflet
|
|
}
|
|
|
|
const toLocationKey = (lat: number, lng: number) => `${lat.toFixed(6)}:${lng.toFixed(6)}`
|
|
|
|
const clearAddressSuggestions = () => {
|
|
addressSuggestions.value = []
|
|
showAddressSuggestions.value = false
|
|
}
|
|
|
|
const cancelPendingAddressSearch = () => {
|
|
addressSearchRequestId += 1
|
|
isSearchingAddress.value = false
|
|
|
|
if (addressSearchTimeout) {
|
|
clearTimeout(addressSearchTimeout)
|
|
addressSearchTimeout = null
|
|
}
|
|
}
|
|
|
|
const cancelPendingReverseGeocode = () => {
|
|
reverseGeocodeRequestId += 1
|
|
isResolvingAddress.value = false
|
|
}
|
|
|
|
const setAddressValue = (address: string) => {
|
|
cancelPendingAddressSearch()
|
|
skipNextAddressSearch = true
|
|
form.value.address = address
|
|
|
|
if (errors.value.address && address.trim()) {
|
|
errors.value.address = ''
|
|
}
|
|
}
|
|
|
|
const fetchAddressSuggestions = async (query: string) => {
|
|
const requestId = ++addressSearchRequestId
|
|
isSearchingAddress.value = true
|
|
addressSearchError.value = ''
|
|
|
|
try {
|
|
if (requestId !== addressSearchRequestId) {
|
|
return
|
|
}
|
|
|
|
const suggestions = await searchAddresses(query)
|
|
|
|
if (requestId !== addressSearchRequestId) {
|
|
return
|
|
}
|
|
|
|
addressSuggestions.value = suggestions
|
|
showAddressSuggestions.value = suggestions.length > 0
|
|
|
|
if (!suggestions.length) {
|
|
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
|
|
}
|
|
} catch {
|
|
if (requestId !== addressSearchRequestId) {
|
|
return
|
|
}
|
|
|
|
clearAddressSuggestions()
|
|
addressSearchError.value = 'Ricerca indirizzi non disponibile al momento.'
|
|
} finally {
|
|
if (requestId === addressSearchRequestId) {
|
|
isSearchingAddress.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackAddress = '') => {
|
|
const requestId = ++reverseGeocodeRequestId
|
|
isResolvingAddress.value = true
|
|
addressSearchError.value = ''
|
|
|
|
try {
|
|
if (requestId !== reverseGeocodeRequestId) {
|
|
return
|
|
}
|
|
|
|
const resolvedAddress = (await reverseGeocode(lat, lng))?.trim() || fallbackAddress.trim()
|
|
|
|
if (resolvedAddress) {
|
|
setAddressValue(resolvedAddress)
|
|
clearAddressSuggestions()
|
|
return
|
|
}
|
|
|
|
if (!form.value.address.trim()) {
|
|
addressSearchError.value = 'Indirizzo non trovato per coordinate selezionate.'
|
|
}
|
|
} catch {
|
|
if (requestId !== reverseGeocodeRequestId) {
|
|
return
|
|
}
|
|
|
|
if (fallbackAddress.trim()) {
|
|
setAddressValue(fallbackAddress.trim())
|
|
clearAddressSuggestions()
|
|
return
|
|
}
|
|
|
|
if (!form.value.address.trim()) {
|
|
addressSearchError.value = 'Impossibile recuperare indirizzo da coordinate.'
|
|
}
|
|
} finally {
|
|
if (requestId === reverseGeocodeRequestId) {
|
|
isResolvingAddress.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleAddressFocus = () => {
|
|
if (addressSuggestions.value.length > 0) {
|
|
showAddressSuggestions.value = true
|
|
}
|
|
}
|
|
|
|
const handleAddressBlur = () => {
|
|
setTimeout(() => {
|
|
showAddressSuggestions.value = false
|
|
}, 150)
|
|
}
|
|
|
|
const updateLocation = async (
|
|
lat: number,
|
|
lng: number,
|
|
options: {
|
|
zoomTo?: boolean
|
|
syncAddress?: boolean
|
|
fallbackAddress?: string
|
|
} = {}
|
|
) => {
|
|
const { zoomTo = false, syncAddress = true, fallbackAddress = '' } = options
|
|
|
|
if (!isWithinBariBounds(lat, lng)) {
|
|
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
|
return
|
|
}
|
|
|
|
const roundedLat = Number(lat.toFixed(6))
|
|
const roundedLng = Number(lng.toFixed(6))
|
|
|
|
lastSyncedLocationKey = toLocationKey(roundedLat, roundedLng)
|
|
form.value.latitude = roundedLat
|
|
form.value.longitude = roundedLng
|
|
|
|
if (map && leaflet) {
|
|
if (!marker) {
|
|
marker = leaflet.marker([roundedLat, roundedLng]).addTo(map)
|
|
} else {
|
|
marker.setLatLng([roundedLat, roundedLng])
|
|
}
|
|
if (zoomTo) {
|
|
map.setView([roundedLat, roundedLng], 16)
|
|
}
|
|
}
|
|
|
|
if (errors.value.location) {
|
|
errors.value.location = ''
|
|
}
|
|
|
|
if (syncAddress) {
|
|
await resolveAddressFromCoordinates(roundedLat, roundedLng, fallbackAddress)
|
|
} else if (fallbackAddress.trim()) {
|
|
setAddressValue(fallbackAddress.trim())
|
|
}
|
|
}
|
|
|
|
const initMap = async () => {
|
|
if (!import.meta.client || !mapContainer.value || map) return
|
|
|
|
const L = await loadLeaflet()
|
|
if (!mapContainer.value || map) return
|
|
|
|
const bariBounds = L.latLngBounds(
|
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
|
)
|
|
|
|
map = L.map(mapContainer.value, {
|
|
maxBounds: bariBounds,
|
|
maxBoundsViscosity: 1,
|
|
minZoom: 12
|
|
}).setView(BARI_CENTER, 12)
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map)
|
|
map.fitBounds(bariBounds)
|
|
|
|
map.on('click', (e: LeafletMouseEvent) => {
|
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
|
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
|
return
|
|
}
|
|
|
|
void updateLocation(e.latlng.lat, e.latlng.lng)
|
|
})
|
|
|
|
if (form.value.latitude && form.value.longitude) {
|
|
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
|
|
}
|
|
}
|
|
|
|
const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => {
|
|
addressSearchError.value = ''
|
|
clearAddressSuggestions()
|
|
setAddressValue(suggestion.label)
|
|
await updateLocation(suggestion.latitude, suggestion.longitude, {
|
|
zoomTo: true,
|
|
syncAddress: false
|
|
})
|
|
}
|
|
|
|
const handleAddressEnter = async () => {
|
|
if (addressSuggestions.value.length === 0) {
|
|
return
|
|
}
|
|
|
|
await selectAddressSuggestion(addressSuggestions.value[0])
|
|
}
|
|
|
|
onMounted(() => {
|
|
void initMap()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
cancelPendingAddressSearch()
|
|
cancelPendingReverseGeocode()
|
|
|
|
if (manualLocationSyncTimeout) {
|
|
clearTimeout(manualLocationSyncTimeout)
|
|
manualLocationSyncTimeout = null
|
|
}
|
|
|
|
if (map) {
|
|
map.remove()
|
|
map = null
|
|
}
|
|
})
|
|
|
|
const validateForm = (): boolean => {
|
|
errors.value = {}
|
|
|
|
if (!form.value.category_id) {
|
|
errors.value.category_id = 'Seleziona una categoria'
|
|
}
|
|
if (!form.value.latitude || !form.value.longitude) {
|
|
errors.value.location = 'Imposta la posizione sulla mappa'
|
|
} else if (!isWithinBariBounds(form.value.latitude, form.value.longitude)) {
|
|
errors.value.location = 'La posizione deve essere nel Comune di Bari'
|
|
}
|
|
if (!form.value.address?.trim()) {
|
|
errors.value.address = 'Inserisci l\'indirizzo'
|
|
}
|
|
if (!form.value.description?.trim() || form.value.description.length < 10) {
|
|
errors.value.description = 'Descrivi il problema (minimo 10 caratteri)'
|
|
}
|
|
|
|
return Object.keys(errors.value).length === 0
|
|
}
|
|
|
|
const getGeolocation = async () => {
|
|
if (!navigator.geolocation) {
|
|
errors.value.location = 'Geolocalizzazione non disponibile nel tuo browser'
|
|
return
|
|
}
|
|
|
|
isGettingLocation.value = true
|
|
errors.value.location = ''
|
|
|
|
try {
|
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject)
|
|
})
|
|
|
|
await updateLocation(position.coords.latitude, position.coords.longitude, { zoomTo: true })
|
|
} catch (error: any) {
|
|
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
|
|
} finally {
|
|
isGettingLocation.value = false
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!validateForm()) return
|
|
|
|
const reportData: CreateReportRequest = {
|
|
category_id: form.value.category_id!,
|
|
latitude: form.value.latitude!,
|
|
longitude: form.value.longitude!,
|
|
address: form.value.address,
|
|
description: form.value.description,
|
|
}
|
|
|
|
emit('submit', reportData, media.value)
|
|
}
|
|
|
|
const resetForm = () => {
|
|
form.value = {
|
|
category_id: null,
|
|
latitude: null,
|
|
longitude: null,
|
|
address: '',
|
|
description: '',
|
|
}
|
|
media.value = []
|
|
errors.value = {}
|
|
addressSearchError.value = ''
|
|
clearAddressSuggestions()
|
|
cancelPendingAddressSearch()
|
|
cancelPendingReverseGeocode()
|
|
lastSyncedLocationKey = ''
|
|
skipNextAddressSearch = false
|
|
|
|
if (marker && map) {
|
|
map.removeLayer(marker)
|
|
marker = null
|
|
map.setView(BARI_CENTER, 12)
|
|
}
|
|
}
|
|
|
|
watch(() => form.value.category_id, () => {
|
|
if (errors.value.category_id) {
|
|
errors.value.category_id = ''
|
|
}
|
|
})
|
|
|
|
watch(() => form.value.address, (newAddress) => {
|
|
if (skipNextAddressSearch) {
|
|
skipNextAddressSearch = false
|
|
return
|
|
}
|
|
|
|
cancelPendingReverseGeocode()
|
|
|
|
if (errors.value.address && newAddress.trim()) {
|
|
errors.value.address = ''
|
|
}
|
|
|
|
if (addressSearchTimeout) {
|
|
clearTimeout(addressSearchTimeout)
|
|
addressSearchTimeout = null
|
|
}
|
|
|
|
if (newAddress.trim().length < 3) {
|
|
isSearchingAddress.value = false
|
|
addressSearchError.value = ''
|
|
clearAddressSuggestions()
|
|
return
|
|
}
|
|
|
|
addressSearchTimeout = setTimeout(() => {
|
|
void fetchAddressSuggestions(newAddress.trim())
|
|
}, 350)
|
|
})
|
|
|
|
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
|
|
if (newLat && newLng && map && leaflet) {
|
|
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
|
|
if (!marker) {
|
|
marker = leaflet.marker([newLat, newLng]).addTo(map)
|
|
} else {
|
|
marker.setLatLng([newLat, newLng])
|
|
}
|
|
map.setView([newLat, newLng], 16)
|
|
}
|
|
}
|
|
|
|
if (!newLat || !newLng) {
|
|
return
|
|
}
|
|
|
|
const locationKey = toLocationKey(newLat, newLng)
|
|
if (locationKey === lastSyncedLocationKey) {
|
|
return
|
|
}
|
|
|
|
if (manualLocationSyncTimeout) {
|
|
clearTimeout(manualLocationSyncTimeout)
|
|
}
|
|
|
|
manualLocationSyncTimeout = setTimeout(() => {
|
|
void updateLocation(newLat, newLng, { zoomTo: true })
|
|
}, 500)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="report-form">
|
|
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
|
|
Segnalazione creata con successo! Reindirizzamento...
|
|
<button type="button" class="btn-close" @click="successMessage = false"></button>
|
|
</div>
|
|
|
|
<form @submit.prevent="handleSubmit">
|
|
<div class="mb-4">
|
|
<label for="category" class="form-label fw-bold">
|
|
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
|
Categoria del problema *
|
|
</label>
|
|
<select
|
|
id="category"
|
|
v-model.number="form.category_id"
|
|
class="form-select"
|
|
:class="{ 'is-invalid': errors.category_id }"
|
|
required
|
|
>
|
|
<option :value="null">-- Seleziona una categoria --</option>
|
|
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
|
{{ cat.name }}
|
|
</option>
|
|
</select>
|
|
<div v-if="errors.category_id" class="invalid-feedback">
|
|
{{ errors.category_id }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="form-label fw-bold">
|
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
|
Posizione *
|
|
</label>
|
|
<p class="small text-muted mb-2">Le segnalazioni sono abilitate solo entro confini del Comune di Bari.</p>
|
|
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-primary w-100 mb-3"
|
|
@click="getGeolocation"
|
|
:disabled="isGettingLocation"
|
|
>
|
|
<span v-if="isGettingLocation" class="spinner-border spinner-border-sm me-2"></span>
|
|
<BIIcon v-else name="it-location" size="sm" class="me-2" />
|
|
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
|
|
</button>
|
|
|
|
<ClientOnly>
|
|
<div
|
|
ref="mapContainer"
|
|
class="map-container mb-3 border rounded"
|
|
:class="{ 'border-danger': errors.location }"
|
|
@vue:mounted="initMap"
|
|
></div>
|
|
</ClientOnly>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<input
|
|
v-model.number="form.latitude"
|
|
type="number"
|
|
step="0.000001"
|
|
class="form-control form-control-sm"
|
|
:class="{ 'is-invalid': errors.location }"
|
|
placeholder="Latitudine (es. 45.5)"
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<input
|
|
v-model.number="form.longitude"
|
|
type="number"
|
|
step="0.000001"
|
|
class="form-control form-control-sm"
|
|
:class="{ 'is-invalid': errors.location }"
|
|
placeholder="Longitudine (es. 10.2)"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-if="errors.location" class="invalid-feedback d-block mt-0 mb-3">
|
|
{{ errors.location }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label for="address" class="form-label fw-bold">
|
|
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
|
Indirizzo *
|
|
</label>
|
|
<div class="position-relative">
|
|
<input
|
|
id="address"
|
|
v-model="form.address"
|
|
type="text"
|
|
class="form-control"
|
|
:class="{ 'is-invalid': errors.address }"
|
|
placeholder="Es. Via Sparano 12, Bari"
|
|
autocomplete="street-address"
|
|
required
|
|
@focus="handleAddressFocus"
|
|
@blur="handleAddressBlur"
|
|
@keydown.enter.prevent="handleAddressEnter"
|
|
/>
|
|
<div
|
|
v-if="showAddressSuggestions && addressSuggestions.length > 0"
|
|
class="list-group position-absolute w-100 shadow-sm rounded-3 overflow-hidden address-suggestions"
|
|
>
|
|
<button
|
|
v-for="suggestion in addressSuggestions"
|
|
:key="`${suggestion.latitude}-${suggestion.longitude}-${suggestion.label}`"
|
|
type="button"
|
|
class="list-group-item list-group-item-action text-start"
|
|
@mousedown.prevent="selectAddressSuggestion(suggestion)"
|
|
>
|
|
{{ suggestion.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="isSearchingAddress" class="form-text">
|
|
Ricerca indirizzi in corso...
|
|
</div>
|
|
<div v-else-if="isResolvingAddress" class="form-text">
|
|
Recupero indirizzo da coordinate...
|
|
</div>
|
|
<div v-else-if="addressSearchError" class="form-text text-muted">
|
|
{{ addressSearchError }}
|
|
</div>
|
|
<div v-if="errors.address" class="invalid-feedback">
|
|
{{ errors.address }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label for="description" class="form-label fw-bold">
|
|
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
|
Descrizione del problema *
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
v-model="form.description"
|
|
class="form-control"
|
|
:class="{ 'is-invalid': errors.description }"
|
|
rows="5"
|
|
placeholder="Descrivi dettagliatamente il problema (tipo buca stradale, pericolo, ecc.)"
|
|
required
|
|
/>
|
|
<small class="form-text text-muted d-block mt-1">
|
|
{{ form.description.length }} caratteri (minimo 10)
|
|
</small>
|
|
<div v-if="errors.description" class="invalid-feedback d-block">
|
|
{{ errors.description }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<label class="form-label fw-bold">
|
|
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
|
Allega foto/video (opzionale)
|
|
</label>
|
|
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
|
|
</div>
|
|
|
|
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline-secondary"
|
|
@click="resetForm"
|
|
:disabled="isLoading"
|
|
>
|
|
Annulla
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary btn-lg"
|
|
:disabled="isLoading"
|
|
>
|
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
|
{{ isLoading ? 'Invio segnalazione...' : 'Invia segnalazione' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.report-form {
|
|
background-color: #fff;
|
|
border-radius: 0.5rem;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.map-container {
|
|
height: 300px;
|
|
width: 100%;
|
|
z-index: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.address-suggestions {
|
|
top: calc(100% + 0.25rem);
|
|
left: 0;
|
|
z-index: 10;
|
|
max-height: 16rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
textarea {
|
|
font-family: inherit;
|
|
resize: vertical;
|
|
}
|
|
|
|
.form-label {
|
|
margin-bottom: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
</style>
|