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} + // Create dynamic message HTML using template + createMessageHTML(id, message, type) { + const template = this.getMessageTemplate(type) + const animationClass = template.animated ? 'animate-spin' : '' + + return ` +
+
+ +
+
+

${message}

+
+
` + } - // 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") } } diff --git a/app/models/event.rb b/app/models/event.rb index 81d20e2..3475b40 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -23,27 +23,7 @@ class Event < ApplicationRecord has_many :orders # === Callbacks === - before_validation :geocode_address, if: :venue_address_changed? - - # === Instance Methods === - - # Check if coordinates were successfully geocoded or are fallback coordinates - def geocoding_successful? - return false if latitude.blank? || longitude.blank? - - # Check if coordinates are exactly the fallback coordinates - fallback_lat = 46.603354 - fallback_lng = 1.888334 - - !(latitude == fallback_lat && longitude == fallback_lng) - end - - # Get a user-friendly status message about geocoding - def geocoding_status_message - return nil if geocoding_successful? - - "Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée." - end + before_validation :geocode_address, if: :should_geocode_address? # Validations for Event attributes # Basic information @@ -75,23 +55,79 @@ class Event < ApplicationRecord # Scope for published events ordered by start time scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } + + # === Instance Methods === + + # Check if coordinates were successfully geocoded or are fallback coordinates + def geocoding_successful? + coordinates_look_valid? + end + + # Get a user-friendly status message about geocoding + def geocoding_status_message + return nil if geocoding_successful? + + "Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée." + end + private - # Automatically geocode address to get latitude and longitude - def geocode_address - return if venue_address.blank? + # Determine if we should perform server-side geocoding + def should_geocode_address? + # Don't geocode if address is blank + return false if venue_address.blank? + + # Don't geocode if we already have valid coordinates (likely from frontend) + return false if coordinates_look_valid? + + # Only geocode if address changed and we don't have coordinates + venue_address_changed? + end - # If we already have coordinates and this is an update, try to geocode - # If it fails, keep the existing coordinates + # Check if the current coordinates look like they were set by frontend geocoding + def coordinates_look_valid? + return false if latitude.blank? || longitude.blank? + + lat_f = latitude.to_f + lng_f = longitude.to_f + + # Basic sanity checks for coordinate ranges + return false if lat_f < -90 || lat_f > 90 + return false if lng_f < -180 || lng_f > 180 + + # Check if coordinates are not the default fallback coordinates + fallback_lat = 46.603354 + fallback_lng = 1.888334 + + # Check if coordinates are not exactly 0,0 (common invalid default) + return false if lat_f == 0.0 && lng_f == 0.0 + + # Coordinates are valid if they're not exactly the fallback coordinates + !(lat_f == fallback_lat && lng_f == fallback_lng) + end + + # Automatically geocode address to get latitude and longitude + # This only runs when no valid coordinates are provided (fallback for non-JS users) + def geocode_address + Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)" + + # Store original coordinates in case we need to fall back original_lat = latitude original_lng = longitude begin # Use OpenStreetMap Nominatim API for geocoding encoded_address = URI.encode_www_form_component(venue_address.strip) - uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1") + uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1") - response = Net::HTTP.get_response(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Get.new(uri) + request['User-Agent'] = 'AperoNight Event Platform/1.0 (https://aperonight.com)' + request['Accept'] = 'application/json' + + response = http.request(request) if response.code == "200" data = JSON.parse(response.body) @@ -100,7 +136,7 @@ class Event < ApplicationRecord result = data.first self.latitude = result["lat"].to_f.round(6) self.longitude = result["lon"].to_f.round(6) - Rails.logger.info "Geocoded address '#{venue_address}' to coordinates: #{latitude}, #{longitude}" + Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}" return end end @@ -109,7 +145,7 @@ class Event < ApplicationRecord handle_geocoding_failure(original_lat, original_lng) rescue => e - Rails.logger.error "Geocoding failed for address '#{venue_address}': #{e.message}" + Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}" handle_geocoding_failure(original_lat, original_lng) end end diff --git a/app/views/promoter/events/new.html.erb b/app/views/promoter/events/new.html.erb index ee5794e..a3bd96b 100644 --- a/app/views/promoter/events/new.html.erb +++ b/app/views/promoter/events/new.html.erb @@ -87,6 +87,9 @@

Lieu de l'événement

+ +
+
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %> @@ -96,17 +99,28 @@
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.text_field :venue_address, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %> +
+ <%= form.text_field :venue_address, class: "w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %> + + + +
- -
@@ -117,7 +131,7 @@

- + <%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %> <%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>