diff --git a/app/javascript/controllers/event_form_controller.js b/app/javascript/controllers/event_form_controller.js index e61ff29..567e289 100644 --- a/app/javascript/controllers/event_form_controller.js +++ b/app/javascript/controllers/event_form_controller.js @@ -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 = '
' + } + 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 = '' + // 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 = '' + } + 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 = '' + // 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 = ` -${message}
+