feat: Add comprehensive address-first geolocation system for events
This implementation provides automatic geocoding and map integration: - **Event Model Enhancements:** - Automatic geocoding callback using OpenStreetMap Nominatim API - 3-tier fallback system: exact coordinates → city-based → country default - Fallback coordinates for major French cities (Paris, Lyon, Marseille, etc.) - Robust error handling that prevents event creation failures - **User-Friendly Event Forms:** - Address-first approach - users just enter addresses - Hidden coordinate fields (auto-generated behind scenes) - Real-time geocoding with 1.5s debounce - "Ma position" button for current location with reverse geocoding - "Prévisualiser" button to show map links - Smart feedback system (loading, success, warnings, errors) - **Enhanced Event Show Page:** - Map provider links (OpenStreetMap, Google Maps, Apple Plans) - Warning badges when approximate coordinates are used - Address-based URLs for better map integration - **Comprehensive JavaScript Controller:** - Debounced auto-geocoding to minimize API calls - Multiple geocoding strategies (manual vs automatic) - Promise-based geolocation with proper error handling - Dynamic map link generation with address + coordinates - **Failure Handling:** - Events never fail to save due to missing coordinates - Fallback to city-based coordinates when exact geocoding fails - User-friendly warnings when approximate locations are used - Maintains existing coordinates on update failures 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
375
app/javascript/controllers/event_form_controller.js
Normal file
375
app/javascript/controllers/event_form_controller.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
|
||||
static values = {
|
||||
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.geocodeTimeout = null
|
||||
|
||||
// Initialize map links if we have an address and coordinates already exist
|
||||
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||
this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||
this.updateMapLinks()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.geocodeTimeout) {
|
||||
clearTimeout(this.geocodeTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug from name
|
||||
generateSlug() {
|
||||
const name = this.nameTarget.value
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
|
||||
this.slugTarget.value = slug
|
||||
}
|
||||
|
||||
// Handle address changes with debounced geocoding
|
||||
addressChanged() {
|
||||
// Clear any existing timeout
|
||||
if (this.geocodeTimeout) {
|
||||
clearTimeout(this.geocodeTimeout)
|
||||
}
|
||||
|
||||
const address = this.addressTarget.value.trim()
|
||||
|
||||
if (!address) {
|
||||
this.clearCoordinates()
|
||||
this.clearMapLinks()
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce geocoding to avoid too many API calls
|
||||
this.geocodeTimeout = setTimeout(() => {
|
||||
this.geocodeAddressQuiet(address)
|
||||
}, this.geocodeDelayValue)
|
||||
}
|
||||
|
||||
// Get user's current location and reverse geocode to address
|
||||
async getCurrentLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
|
||||
return
|
||||
}
|
||||
|
||||
this.showLocationLoading()
|
||||
|
||||
const options = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000
|
||||
}
|
||||
|
||||
try {
|
||||
const position = await this.getCurrentPositionPromise(options)
|
||||
const lat = position.coords.latitude
|
||||
const lng = position.coords.longitude
|
||||
|
||||
// Set coordinates first
|
||||
this.latitudeTarget.value = lat.toFixed(6)
|
||||
this.longitudeTarget.value = lng.toFixed(6)
|
||||
|
||||
// Then reverse geocode to get address
|
||||
const address = await this.reverseGeocode(lat, lng)
|
||||
|
||||
if (address) {
|
||||
this.addressTarget.value = address
|
||||
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
|
||||
} else {
|
||||
this.showLocationSuccess("Position actuelle détectée!")
|
||||
}
|
||||
|
||||
this.updateMapLinks()
|
||||
|
||||
} catch (error) {
|
||||
this.hideLocationLoading()
|
||||
let message = "Erreur lors de la récupération de la localisation."
|
||||
|
||||
switch(error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
message = "L'accès à la localisation a été refusé."
|
||||
break
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
message = "Les informations de localisation ne sont pas disponibles."
|
||||
break
|
||||
case error.TIMEOUT:
|
||||
message = "La demande de localisation a expiré."
|
||||
break
|
||||
}
|
||||
|
||||
this.showLocationError(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Promise wrapper for geolocation
|
||||
getCurrentPositionPromise(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||
})
|
||||
}
|
||||
|
||||
// Reverse geocode coordinates to get address
|
||||
async reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.display_name) {
|
||||
return data.display_name
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("Reverse geocoding failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Preview location - same as updating map links but with user feedback
|
||||
previewLocation() {
|
||||
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
|
||||
return
|
||||
}
|
||||
|
||||
// If we already have coordinates, just update map links
|
||||
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||
this.updateMapLinks()
|
||||
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||
} else {
|
||||
// Otherwise geocode the address first
|
||||
this.geocodeAddress()
|
||||
}
|
||||
}
|
||||
|
||||
// Geocode address manually (with user feedback)
|
||||
async geocodeAddress() {
|
||||
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||
this.showLocationError("Veuillez saisir une adresse.")
|
||||
return
|
||||
}
|
||||
|
||||
const address = this.addressTarget.value.trim()
|
||||
|
||||
try {
|
||||
this.showLocationLoading()
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
if (result) {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
this.showLocationSuccess("Coordonnées trouvées pour cette adresse!")
|
||||
} else {
|
||||
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
|
||||
}
|
||||
} catch (error) {
|
||||
this.showLocationError("Erreur lors de la recherche de l'adresse.")
|
||||
} finally {
|
||||
this.hideLocationLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||
async geocodeAddressQuiet(address) {
|
||||
try {
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
if (result) {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
} else {
|
||||
// If auto-geocoding fails, show a subtle warning
|
||||
this.showGeocodingWarning(address)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Auto-geocoding failed:", error)
|
||||
this.showGeocodingWarning(address)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the actual geocoding request
|
||||
async performGeocode(address) {
|
||||
const encodedAddress = encodeURIComponent(address)
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = data[0]
|
||||
return {
|
||||
lat: parseFloat(result.lat).toFixed(6),
|
||||
lng: parseFloat(result.lon).toFixed(6)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Update map links based on current coordinates
|
||||
updateMapLinks() {
|
||||
if (!this.hasMapLinksContainerTarget) return
|
||||
|
||||
const lat = parseFloat(this.latitudeTarget.value)
|
||||
const lng = parseFloat(this.longitudeTarget.value)
|
||||
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
|
||||
|
||||
if (isNaN(lat) || isNaN(lng) || !address) {
|
||||
this.clearMapLinks()
|
||||
return
|
||||
}
|
||||
|
||||
const links = this.generateMapLinks(lat, lng, address)
|
||||
this.mapLinksContainerTarget.innerHTML = links
|
||||
}
|
||||
|
||||
// Generate map links HTML
|
||||
generateMapLinks(lat, lng, address) {
|
||||
const encodedAddress = encodeURIComponent(address)
|
||||
|
||||
const providers = {
|
||||
openstreetmap: {
|
||||
name: "OpenStreetMap",
|
||||
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
|
||||
icon: "🗺️"
|
||||
},
|
||||
google: {
|
||||
name: "Google Maps",
|
||||
url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`,
|
||||
icon: "🔍"
|
||||
},
|
||||
apple: {
|
||||
name: "Apple Plans",
|
||||
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
|
||||
icon: "🍎"
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
||||
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${Object.entries(providers).map(([key, provider]) => `
|
||||
<a href="${provider.url}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span class="mr-2">${provider.icon}</span>
|
||||
${provider.name}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Clear coordinates
|
||||
clearCoordinates() {
|
||||
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
|
||||
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
|
||||
}
|
||||
|
||||
// Clear map links
|
||||
clearMapLinks() {
|
||||
if (this.hasMapLinksContainerTarget) {
|
||||
this.mapLinksContainerTarget.innerHTML = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
showLocationLoading() {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-loading", "Géolocalisation en cours...", "info")
|
||||
}
|
||||
|
||||
// Hide loading state
|
||||
hideLocationLoading() {
|
||||
this.hideMessage("location-loading")
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showLocationSuccess(message) {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-success", message, "success")
|
||||
setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||
}
|
||||
|
||||
// Show error message
|
||||
showLocationError(message) {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-error", message, "error")
|
||||
setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||
}
|
||||
|
||||
// Show geocoding warning (less intrusive than error)
|
||||
showGeocodingWarning(address) {
|
||||
this.hideMessage("geocoding-warning")
|
||||
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||
this.showMessage("geocoding-warning", message, "warning")
|
||||
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||
}
|
||||
|
||||
// Show a message with given type
|
||||
showMessage(id, message, type) {
|
||||
const colors = {
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
success: "bg-green-50 border-green-200 text-green-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
warning: "bg-yellow-50 border-yellow-200 text-yellow-800"
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: "info",
|
||||
success: "check-circle",
|
||||
error: "alert-circle",
|
||||
warning: "alert-triangle"
|
||||
}
|
||||
|
||||
const messageHtml = `
|
||||
<div id="${id}" class="flex items-center space-x-2 p-3 ${colors[type]} border rounded-lg mb-4">
|
||||
<i data-lucide="${icons[type]}" class="w-4 h-4 flex-shrink-0"></i>
|
||||
<span class="text-sm font-medium">${message}</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Insert after the venue section header
|
||||
const venueSection = this.element.querySelector('h3')
|
||||
if (venueSection) {
|
||||
venueSection.insertAdjacentHTML('afterend', messageHtml)
|
||||
// Re-initialize Lucide icons for the new elements
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide a specific message
|
||||
hideMessage(id) {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// Hide all location messages
|
||||
hideAllLocationMessages() {
|
||||
this.hideMessage("location-loading")
|
||||
this.hideMessage("location-success")
|
||||
this.hideMessage("location-error")
|
||||
this.hideMessage("geocoding-warning")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user