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' import slug from 'slug'
export default class extends Controller { 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 = { static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
} }
static lastGeocodingRequest = 0
connect() { connect() {
this.geocodeTimeout = null this.geocodeTimeout = null
@@ -43,12 +45,25 @@ export default class extends Controller {
if (!address) { if (!address) {
this.clearCoordinates() this.clearCoordinates()
this.clearMapLinks() this.clearMapLinks()
this.hideGeocodingSpinner()
return 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 // Debounce geocoding to avoid too many API calls
this.geocodeTimeout = setTimeout(() => { this.geocodeTimeout = setTimeout(async () => {
this.geocodeAddressQuiet(address) 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) }, this.geocodeDelayValue)
} }
@@ -59,6 +74,7 @@ export default class extends Controller {
return return
} }
this.showGetCurrentLocationLoading()
this.showLocationLoading() this.showLocationLoading()
const options = { const options = {
@@ -87,8 +103,10 @@ export default class extends Controller {
} }
this.updateMapLinks() this.updateMapLinks()
this.hideGetCurrentLocationLoading()
} catch (error) { } catch (error) {
this.hideGetCurrentLocationLoading()
this.hideLocationLoading() this.hideLocationLoading()
let message = "Erreur lors de la récupération de la localisation." let message = "Erreur lors de la récupération de la localisation."
@@ -105,6 +123,8 @@ export default class extends Controller {
} }
this.showLocationError(message) this.showLocationError(message)
} finally {
this.hideGetCurrentLocationLoading()
} }
} }
@@ -118,8 +138,20 @@ export default class extends Controller {
// Reverse geocode coordinates to get address // Reverse geocode coordinates to get address
async reverseGeocode(lat, lng) { async reverseGeocode(lat, lng) {
try { 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() const data = await response.json()
console.log('Reverse geocoding response:', data) // Debug log
if (data && data.display_name) { if (data && data.display_name) {
return data.display_name return data.display_name
@@ -146,7 +178,10 @@ export default class extends Controller {
this.showLocationSuccess("Liens de carte mis à jour!") this.showLocationSuccess("Liens de carte mis à jour!")
} else { } else {
// Otherwise geocode the address first // 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.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng this.longitudeTarget.value = result.lng
this.updateMapLinks() 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 { } else {
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.") 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) // Geocode address quietly (no user feedback, for auto-geocoding)
async geocodeAddressQuiet(address) { async geocodeAddressQuiet(address) {
// Skip if address is too short or invalid
if (!address || address.length < 5) {
this.clearCoordinates()
this.clearMapLinks()
return
}
try { try {
const result = await this.performGeocode(address) const result = await this.performGeocode(address)
if (result) { if (result && result.lat && result.lng) {
this.latitudeTarget.value = result.lat this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng this.longitudeTarget.value = result.lng
this.updateMapLinks() 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 { } else {
// If auto-geocoding fails, show a subtle warning // If auto-geocoding fails, show a subtle warning
this.showGeocodingWarning(address) 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) { async performGeocode(address) {
const encodedAddress = encodeURIComponent(address) // Rate limiting: ensure at least 1 second between requests
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`) 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() const data = await response.json()
if (data && data.length > 0) { if (data && data.length > 0) {
const result = data[0] const result = data[0]
return { return {
lat: parseFloat(result.lat).toFixed(6), 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 encodedAddress = encodeURIComponent(address)
const providers = { const providers = {
google: {
name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
icon: "🔍"
},
openstreetmap: { openstreetmap: {
name: "OpenStreetMap", name: "OpenStreetMap",
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`, url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
icon: "🗺️" icon: "🗺️"
}, },
google: {
name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`,
icon: "🔍"
},
apple: { apple: {
name: "Apple Plans", name: "Apple Plans",
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`, 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 // Show loading state
showLocationLoading() { showLocationLoading() {
this.hideAllLocationMessages() this.hideAllLocationMessages()
this.showMessage("location-loading", "Géolocalisation en cours...", "info") this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
} }
// Hide loading state // Hide loading state
@@ -318,37 +520,100 @@ export default class extends Controller {
setTimeout(() => this.hideMessage("geocoding-warning"), 8000) setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
} }
// Show a message with given type // Show info about approximate location
showMessage(id, message, type) { showApproximateLocationInfo(foundLocation) {
const colors = { this.hideMessage("approximate-location-info")
info: "bg-blue-50 border-blue-200 text-blue-800", const message = `Localisation approximative trouvée: ${foundLocation}`
success: "bg-green-50 border-green-200 text-green-800", this.showMessage("approximate-location-info", message, "info")
error: "bg-red-50 border-red-200 text-red-800", setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
warning: "bg-yellow-50 border-yellow-200 text-yellow-800"
} }
const icons = { // Message template configurations
info: "info", getMessageTemplate(type) {
success: "check-circle", const templates = {
error: "alert-circle", info: {
warning: "alert-triangle" 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 = ` // Create dynamic message HTML using template
<div id="${id}" class="flex items-center space-x-2 p-3 ${colors[type]} border rounded-lg mb-4"> createMessageHTML(id, message, type) {
<i data-lucide="${icons[type]}" class="w-4 h-4 flex-shrink-0"></i> const template = this.getMessageTemplate(type)
<span class="text-sm font-medium">${message}</span> 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> </div>
` `
}
// Insert after the venue section header // Show a message with given type using template system
const venueSection = this.element.querySelector('h3') showMessage(id, message, type) {
if (venueSection) { // Remove existing message with same ID first
venueSection.insertAdjacentHTML('afterend', messageHtml) 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 // Re-initialize Lucide icons for the new elements
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons() 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-success")
this.hideMessage("location-error") this.hideMessage("location-error")
this.hideMessage("geocoding-warning") this.hideMessage("geocoding-warning")
this.hideMessage("approximate-location-info")
} }
} }

View File

@@ -23,27 +23,7 @@ class Event < ApplicationRecord
has_many :orders has_many :orders
# === Callbacks === # === Callbacks ===
before_validation :geocode_address, if: :venue_address_changed? before_validation :geocode_address, if: :should_geocode_address?
# === 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
# Validations for Event attributes # Validations for Event attributes
# Basic information # Basic information
@@ -75,23 +55,79 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time # Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } 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 private
# Automatically geocode address to get latitude and longitude # Determine if we should perform server-side geocoding
def geocode_address def should_geocode_address?
return if venue_address.blank? # Don't geocode if address is blank
return false if venue_address.blank?
# If we already have coordinates and this is an update, try to geocode # Don't geocode if we already have valid coordinates (likely from frontend)
# If it fails, keep the existing coordinates return false if coordinates_look_valid?
# Only geocode if address changed and we don't have coordinates
venue_address_changed?
end
# 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_lat = latitude
original_lng = longitude original_lng = longitude
begin begin
# Use OpenStreetMap Nominatim API for geocoding # Use OpenStreetMap Nominatim API for geocoding
encoded_address = URI.encode_www_form_component(venue_address.strip) 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" if response.code == "200"
data = JSON.parse(response.body) data = JSON.parse(response.body)
@@ -100,7 +136,7 @@ class Event < ApplicationRecord
result = data.first result = data.first
self.latitude = result["lat"].to_f.round(6) self.latitude = result["lat"].to_f.round(6)
self.longitude = result["lon"].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 return
end end
end end
@@ -109,7 +145,7 @@ class Event < ApplicationRecord
handle_geocoding_failure(original_lat, original_lng) handle_geocoding_failure(original_lat, original_lng)
rescue => e 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) handle_geocoding_failure(original_lat, original_lng)
end end
end end

View File

@@ -87,6 +87,9 @@
<div class="bg-white rounded-lg border border-gray-200 p-6"> <div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3> <h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3>
<!-- Geocoding Messages Container -->
<div data-event-form-target="messagesContainer" class="space-y-3 mb-6 empty:mb-0 empty:hidden"></div>
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
@@ -96,17 +99,28 @@
<div> <div>
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="space-y-2"> <div class="space-y-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" } %> <div class="relative">
<%= 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" } %>
<!-- Geocoding Loading Spinner -->
<div data-event-form-target="geocodingSpinner" class="absolute right-3 top-1/2 transform -translate-y-1/2 hidden">
<div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
</div>
</div>
<!-- Location Actions --> <!-- Location Actions -->
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button type="button" data-action="click->event-form#getCurrentLocation" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"> <button type="button" data-action="click->event-form#getCurrentLocation" data-event-form-target="getCurrentLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<span data-event-form-target="getCurrentLocationIcon">
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i> <i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
Ma position </span>
<span data-event-form-target="getCurrentLocationText">Ma position</span>
</button> </button>
<button type="button" data-action="click->event-form#previewLocation" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"> <button type="button" data-action="click->event-form#previewLocation" data-event-form-target="previewLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<span data-event-form-target="previewLocationIcon">
<i data-lucide="map" class="w-3 h-3 mr-1"></i> <i data-lucide="map" class="w-3 h-3 mr-1"></i>
Prévisualiser </span>
<span data-event-form-target="previewLocationText">Prévisualiser</span>
</button> </button>
</div> </div>
</div> </div>
@@ -117,7 +131,7 @@
</p> </p>
</div> </div>
<!-- Hidden coordinate fields for form submission --> <!-- Hidden coordinate fields populated by JavaScript geocoding -->
<%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %> <%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
<%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %> <%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>