Files
frontend/app/components/ReportForm.vue
T

415 lines
12 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'
interface Props {
isLoading?: boolean
categories?: Category[]
}
interface Emits {
(e: 'submit', report: CreateReportRequest, media: File[]): void
}
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)
// Map variables
const mapContainer = ref<HTMLElement | null>(null)
let leaflet: typeof import('leaflet') | null = null
let map: LeafletMap | null = null
let marker: LeafletMarker | null = null
const loadLeaflet = async () => {
if (leaflet) return leaflet
const leafletModule = await import('leaflet')
// Fix per le icone di Leaflet in Nuxt/Vite
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 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: '&copy; 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
}
updateLocation(e.latlng.lat, e.latlng.lng)
})
// Se c'è già una posizione, imposta il marker
if (form.value.latitude && form.value.longitude) {
updateLocation(form.value.latitude, form.value.longitude, true)
}
}
const updateLocation = (lat: number, lng: number, zoomTo = false) => {
if (!isWithinBariBounds(lat, lng)) {
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
return
}
form.value.latitude = Number(lat.toFixed(6))
form.value.longitude = Number(lng.toFixed(6))
if (map && leaflet) {
if (!marker) {
marker = leaflet.marker([lat, lng]).addTo(map)
} else {
marker.setLatLng([lat, lng])
}
if (zoomTo) {
map.setView([lat, lng], 16)
}
}
if (errors.value.location) {
errors.value.location = ''
}
}
onMounted(() => {
void initMap()
})
onUnmounted(() => {
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)
})
updateLocation(position.coords.latitude, position.coords.longitude, 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 = {}
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 = ''
}
})
// Sincronizza input manuali con la mappa
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)
}
}
})
</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 (Bari)"
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 (Bari)"
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>
<input
id="address"
v-model="form.address"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.address }"
placeholder="Es. Via Sparano 12, Bari"
required
/>
<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; /* Fix for Bootstrap elements overlapping map */
}
textarea {
font-family: inherit;
resize: vertical;
}
.form-label {
margin-bottom: 0.75rem;
display: flex;
align-items: center;
}
</style>