feat: Implement comprehensive geocoding improvements with loading indicators

- Add multi-strategy geocoding fallback system for better address resolution
- Implement loading spinners and visual feedback for all geocoding operations
- Move geocoding messages to venue section for better visibility
- Add dynamic message template system with proper styling
- Optimize backend to trust frontend coordinates and reduce API calls
- Add rate limiting and proper User-Agent headers for Nominatim compliance
- Improve error handling and user feedback throughout geocoding flow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kbe
2025-09-10 21:24:28 +02:00
parent d5c0276fcc
commit 9bebdef5a5
3 changed files with 392 additions and 76 deletions

View File

@@ -2,11 +2,13 @@ import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
export default class extends Controller {
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
}
static lastGeocodingRequest = 0
connect() {
this.geocodeTimeout = null
@@ -43,12 +45,25 @@ export default class extends Controller {
if (!address) {
this.clearCoordinates()
this.clearMapLinks()
this.hideGeocodingSpinner()
return
}
// Show spinner after a brief delay to avoid flickering for very short typing
const showSpinnerTimeout = setTimeout(() => {
this.showGeocodingSpinner()
}, 300)
// Debounce geocoding to avoid too many API calls
this.geocodeTimeout = setTimeout(() => {
this.geocodeAddressQuiet(address)
this.geocodeTimeout = setTimeout(async () => {
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
this.showGeocodingSpinner() // Show spinner for sure now
try {
await this.geocodeAddressQuiet(address)
} finally {
this.hideGeocodingSpinner()
}
}, this.geocodeDelayValue)
}
@@ -59,6 +74,7 @@ export default class extends Controller {
return
}
this.showGetCurrentLocationLoading()
this.showLocationLoading()
const options = {
@@ -87,8 +103,10 @@ export default class extends Controller {
}
this.updateMapLinks()
this.hideGetCurrentLocationLoading()
} catch (error) {
this.hideGetCurrentLocationLoading()
this.hideLocationLoading()
let message = "Erreur lors de la récupération de la localisation."
@@ -105,6 +123,8 @@ export default class extends Controller {
}
this.showLocationError(message)
} finally {
this.hideGetCurrentLocationLoading()
}
}
@@ -118,8 +138,20 @@ export default class extends Controller {
// 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 response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
method: 'GET',
headers: {
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
console.log('Reverse geocoding response:', data) // Debug log
if (data && data.display_name) {
return data.display_name
@@ -146,7 +178,10 @@ export default class extends Controller {
this.showLocationSuccess("Liens de carte mis à jour!")
} else {
// Otherwise geocode the address first
this.geocodeAddress()
this.showPreviewLocationLoading()
this.geocodeAddress().finally(() => {
this.hidePreviewLocationLoading()
})
}
}
@@ -167,7 +202,12 @@ export default class extends Controller {
this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng
this.updateMapLinks()
this.showLocationSuccess("Coordonnées trouvées pour cette adresse!")
if (result.accuracy === 'exact') {
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
} else {
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
}
} else {
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
}
@@ -180,13 +220,26 @@ export default class extends Controller {
// Geocode address quietly (no user feedback, for auto-geocoding)
async geocodeAddressQuiet(address) {
// Skip if address is too short or invalid
if (!address || address.length < 5) {
this.clearCoordinates()
this.clearMapLinks()
return
}
try {
const result = await this.performGeocode(address)
if (result) {
if (result && result.lat && result.lng) {
this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng
this.updateMapLinks()
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
// Show info if coordinates are approximate
if (result.accuracy === 'approximate') {
this.showApproximateLocationInfo(result.display_name)
}
} else {
// If auto-geocoding fails, show a subtle warning
this.showGeocodingWarning(address)
@@ -197,17 +250,92 @@ export default class extends Controller {
}
}
// Perform the actual geocoding request
// Perform the actual geocoding request with fallback strategies
async performGeocode(address) {
const encodedAddress = encodeURIComponent(address)
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
// Rate limiting: ensure at least 1 second between requests
const now = Date.now()
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
if (timeSinceLastRequest < 1000) {
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
}
this.constructor.lastGeocodingRequest = Date.now()
// Try multiple geocoding strategies
const strategies = [
// Strategy 1: Exact address
address,
// Strategy 2: Street name + city (remove house number)
address.replace(/^\d+\s*/, ''),
// Strategy 3: Just city and postal code
this.extractCityAndPostalCode(address)
].filter(Boolean) // Remove null/undefined values
for (let i = 0; i < strategies.length; i++) {
const searchAddress = strategies[i]
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
try {
const result = await this.tryGeocode(searchAddress)
if (result) {
console.log(`Geocoding successful with strategy ${i + 1}`)
return result
}
} catch (error) {
console.log(`Strategy ${i + 1} failed:`, error.message)
}
// Add small delay between attempts
if (i < strategies.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500))
}
}
console.log('All geocoding strategies failed')
return null
}
// Extract city and postal code from address
extractCityAndPostalCode(address) {
// Look for French postal code pattern (5 digits) + city
const match = address.match(/(\d{5})\s+([^,]+)/);
if (match) {
return `${match[1]} ${match[2].trim()}`
}
// Fallback: extract last part after comma (assume it's city)
const parts = address.split(',')
if (parts.length > 1) {
return parts[parts.length - 1].trim()
}
return null
}
// Try a single geocoding request
async tryGeocode(address) {
const encodedAddress = encodeURIComponent(address.trim())
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
method: 'GET',
headers: {
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
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)
lng: parseFloat(result.lon).toFixed(6),
display_name: result.display_name,
accuracy: address === result.display_name ? 'exact' : 'approximate'
}
}
@@ -236,16 +364,16 @@ export default class extends Controller {
const encodedAddress = encodeURIComponent(address)
const providers = {
google: {
name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
icon: "🔍"
},
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}`,
@@ -285,10 +413,84 @@ export default class extends Controller {
}
}
// Show geocoding spinner in address input
showGeocodingSpinner() {
if (this.hasGeocodingSpinnerTarget) {
this.geocodingSpinnerTarget.classList.remove('hidden')
}
}
// Hide geocoding spinner in address input
hideGeocodingSpinner() {
if (this.hasGeocodingSpinnerTarget) {
this.geocodingSpinnerTarget.classList.add('hidden')
}
}
// Show loading state on "Ma position" button
showGetCurrentLocationLoading() {
if (this.hasGetCurrentLocationBtnTarget) {
this.getCurrentLocationBtnTarget.disabled = true
}
if (this.hasGetCurrentLocationIconTarget) {
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
}
if (this.hasGetCurrentLocationTextTarget) {
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
}
}
// Hide loading state on "Ma position" button
hideGetCurrentLocationLoading() {
if (this.hasGetCurrentLocationBtnTarget) {
this.getCurrentLocationBtnTarget.disabled = false
}
if (this.hasGetCurrentLocationIconTarget) {
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
// Re-initialize Lucide icons
if (window.lucide) {
window.lucide.createIcons()
}
}
if (this.hasGetCurrentLocationTextTarget) {
this.getCurrentLocationTextTarget.textContent = 'Ma position'
}
}
// Show loading state on "Prévisualiser" button
showPreviewLocationLoading() {
if (this.hasPreviewLocationBtnTarget) {
this.previewLocationBtnTarget.disabled = true
}
if (this.hasPreviewLocationIconTarget) {
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
}
if (this.hasPreviewLocationTextTarget) {
this.previewLocationTextTarget.textContent = 'Recherche...'
}
}
// Hide loading state on "Prévisualiser" button
hidePreviewLocationLoading() {
if (this.hasPreviewLocationBtnTarget) {
this.previewLocationBtnTarget.disabled = false
}
if (this.hasPreviewLocationIconTarget) {
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
// Re-initialize Lucide icons
if (window.lucide) {
window.lucide.createIcons()
}
}
if (this.hasPreviewLocationTextTarget) {
this.previewLocationTextTarget.textContent = 'Prévisualiser'
}
}
// Show loading state
showLocationLoading() {
this.hideAllLocationMessages()
this.showMessage("location-loading", "Géolocalisation en cours...", "info")
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
}
// Hide loading state
@@ -318,37 +520,100 @@ export default class extends Controller {
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"
}
// Show info about approximate location
showApproximateLocationInfo(foundLocation) {
this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info")
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
}
const icons = {
info: "info",
success: "check-circle",
error: "alert-circle",
warning: "alert-triangle"
// Message template configurations
getMessageTemplate(type) {
const templates = {
info: {
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
textColor: "text-blue-800",
icon: "info",
iconColor: "text-blue-500"
},
success: {
bgColor: "bg-green-50",
borderColor: "border-green-200",
textColor: "text-green-800",
icon: "check-circle",
iconColor: "text-green-500"
},
error: {
bgColor: "bg-red-50",
borderColor: "border-red-200",
textColor: "text-red-800",
icon: "alert-circle",
iconColor: "text-red-500"
},
warning: {
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
textColor: "text-yellow-800",
icon: "alert-triangle",
iconColor: "text-yellow-500"
},
loading: {
bgColor: "bg-purple-50",
borderColor: "border-purple-200",
textColor: "text-purple-800",
icon: "loader-2",
iconColor: "text-purple-500",
animated: true
}
}
return templates[type] || templates.info
}
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>
// Create dynamic message HTML using template
createMessageHTML(id, message, type) {
const template = this.getMessageTemplate(type)
const animationClass = template.animated ? 'animate-spin' : ''
return `
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
<div class="flex-shrink-0">
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
</div>
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
`
}
// Insert after the venue section header
const venueSection = this.element.querySelector('h3')
if (venueSection) {
venueSection.insertAdjacentHTML('afterend', messageHtml)
// Show a message with given type using template system
showMessage(id, message, type) {
// Remove existing message with same ID first
this.hideMessage(id)
const messageHtml = this.createMessageHTML(id, message, type)
// Insert into the dedicated messages container in the venue section
if (this.hasMessagesContainerTarget) {
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
// Re-initialize Lucide icons for the new elements
if (window.lucide) {
window.lucide.createIcons()
}
} else {
// Fallback: insert before the address input if messages container not found
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
if (addressInput) {
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
if (window.lucide) {
window.lucide.createIcons()
}
}
}
}
@@ -366,5 +631,6 @@ export default class extends Controller {
this.hideMessage("location-success")
this.hideMessage("location-error")
this.hideMessage("geocoding-warning")
this.hideMessage("approximate-location-info")
}
}