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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user