3 Commits

Author SHA1 Message Date
kbe
55b39e93bf enhance: Implement dynamic message template system with progress tracking
Some checks failed
Ruby on Rails Test / rails-test (push) Failing after 13m31s
- Add comprehensive message template system with 5 distinct message types
- Implement progress tracking for multi-strategy geocoding attempts
- Add dismissible messages with auto-timeout functionality
- Enhance visual design with proper spacing, shadows, and animations
- Add specialized geocoding success messages with location details
- Improve user experience with contextual progress indicators
- Support HTML content in messages for better formatting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 22:01:26 +02:00
kbe
9bebdef5a5 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>
2025-09-10 21:24:28 +02:00
kbe
d5c0276fcc chore: Code lint 2025-09-10 20:51:17 +02:00
4 changed files with 462 additions and 115 deletions

View File

@@ -48,21 +48,21 @@ class PagesController < ApplicationController
# Revenue metrics for promoter
@total_revenue = current_user.events
.joins(:orders)
.where(orders: { status: ['paid', 'completed'] })
.sum('orders.total_amount_cents') / 100.0
.where(orders: { status: [ "paid", "completed" ] })
.sum("orders.total_amount_cents") / 100.0
@total_tickets_sold = current_user.events
.joins(:tickets)
.where(tickets: { status: 'active' })
.where(tickets: { status: "active" })
.count
@active_events_count = current_user.events.where(state: 'published').count
@draft_events_count = current_user.events.where(state: 'draft').count
@active_events_count = current_user.events.where(state: "published").count
@draft_events_count = current_user.events.where(state: "draft").count
# Recent orders for promoter events
@recent_orders = Order.joins(:event)
.where(events: { user: current_user })
.where(status: ['paid', 'completed'])
.where(status: [ "paid", "completed" ])
.includes(:event, :user, tickets: :ticket_type)
.order(created_at: :desc)
.limit(10)
@@ -74,9 +74,9 @@ class PagesController < ApplicationController
revenue = current_user.events
.joins(:orders)
.where(orders: { status: ['paid', 'completed'] })
.where(orders: { status: [ "paid", "completed" ] })
.where(orders: { created_at: start_date..end_date })
.sum('orders.total_amount_cents') / 100.0
.sum("orders.total_amount_cents") / 100.0
{
month: start_date.strftime("%B %Y"),

View File

@@ -2,13 +2,16 @@ 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
this.isManualGeocodingInProgress = false
// Initialize map links if we have an address and coordinates already exist
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
@@ -43,12 +46,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 +75,7 @@ export default class extends Controller {
return
}
this.showGetCurrentLocationLoading()
this.showLocationLoading()
const options = {
@@ -87,8 +104,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 +124,8 @@ export default class extends Controller {
}
this.showLocationError(message)
} finally {
this.hideGetCurrentLocationLoading()
}
}
@@ -118,8 +139,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 +179,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()
})
}
}
@@ -160,6 +196,7 @@ export default class extends Controller {
const address = this.addressTarget.value.trim()
try {
this.isManualGeocodingInProgress = true
this.showLocationLoading()
const result = await this.performGeocode(address)
@@ -167,26 +204,47 @@ 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.")
}
} catch (error) {
this.showLocationError("Erreur lors de la recherche de l'adresse.")
} finally {
this.isManualGeocodingInProgress = false
this.hideLocationLoading()
}
}
// 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 success message based on accuracy
if (result.accuracy === 'exact') {
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
} else {
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
}
} else {
// If auto-geocoding fails, show a subtle warning
this.showGeocodingWarning(address)
@@ -197,17 +255,101 @@ 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}"`)
// Show progress for manual geocoding (not auto-geocoding)
if (this.isManualGeocodingInProgress) {
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
}
try {
const result = await this.tryGeocode(searchAddress)
if (result) {
console.log(`Geocoding successful with strategy ${i + 1}`)
this.hideMessage("geocoding-progress")
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))
}
}
this.hideMessage("geocoding-progress")
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 +378,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 +427,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 +534,115 @@ 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"
// Show geocoding success with location details
showGeocodingSuccess(title, location) {
this.hideMessage("geocoding-success")
const message = `${title}<br><small class="opacity-75">${location}</small>`
this.showMessage("geocoding-success", message, "success")
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
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>
// Show geocoding progress with strategy info
showGeocodingProgress(strategy, attempt) {
this.hideMessage("geocoding-progress")
const message = `Recherche en cours... (${attempt}/${strategy})`
this.showMessage("geocoding-progress", message, "loading")
}
// 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
}
// 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 +660,8 @@ export default class extends Controller {
this.hideMessage("location-success")
this.hideMessage("location-error")
this.hideMessage("geocoding-warning")
this.hideMessage("approximate-location-info")
this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress")
}
}

View File

@@ -1,7 +1,7 @@
# Event model representing nightlife events and events
# Manages event details, location data, and publication state
require 'net/http'
require 'json'
require "net/http"
require "json"
class Event < ApplicationRecord
# Define states for Event lifecycle management
@@ -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,32 +55,88 @@ 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?
# If we already have coordinates and this is an update, try to geocode
# If it fails, keep the existing coordinates
# 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
# 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
if response.code == '200'
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)
if data.any?
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}"
self.latitude = result["lat"].to_f.round(6)
self.longitude = result["lon"].to_f.round(6)
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
@@ -146,16 +182,16 @@ class Event < ApplicationRecord
# Common French cities with approximate coordinates
french_cities = {
'paris' => { lat: 48.8566, lng: 2.3522 },
'lyon' => { lat: 45.7640, lng: 4.8357 },
'marseille' => { lat: 43.2965, lng: 5.3698 },
'toulouse' => { lat: 43.6047, lng: 1.4442 },
'nice' => { lat: 43.7102, lng: 7.2620 },
'nantes' => { lat: 47.2184, lng: -1.5536 },
'montpellier' => { lat: 43.6110, lng: 3.8767 },
'strasbourg' => { lat: 48.5734, lng: 7.7521 },
'bordeaux' => { lat: 44.8378, lng: -0.5792 },
'lille' => { lat: 50.6292, lng: 3.0573 }
"paris" => { lat: 48.8566, lng: 2.3522 },
"lyon" => { lat: 45.7640, lng: 4.8357 },
"marseille" => { lat: 43.2965, lng: 5.3698 },
"toulouse" => { lat: 43.6047, lng: 1.4442 },
"nice" => { lat: 43.7102, lng: 7.2620 },
"nantes" => { lat: 47.2184, lng: -1.5536 },
"montpellier" => { lat: 43.6110, lng: 3.8767 },
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
"lille" => { lat: 50.6292, lng: 3.0573 }
}
# Check if any known city is mentioned in the address
@@ -166,7 +202,7 @@ class Event < ApplicationRecord
end
# Check for common country indicators
if address_lower.include?('france') || address_lower.include?('french')
if address_lower.include?("france") || address_lower.include?("french")
return { lat: 46.603354, lng: 1.888334 } # Center of France
end

View File

@@ -87,6 +87,9 @@
<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>
<!-- 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>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
@@ -96,17 +99,28 @@
<div>
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-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 -->
<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>
Ma position
</span>
<span data-event-form-target="getCurrentLocationText">Ma position</span>
</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>
Prévisualiser
</span>
<span data-event-form-target="previewLocationText">Prévisualiser</span>
</button>
</div>
</div>
@@ -117,7 +131,7 @@
</p>
</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 :longitude, data: { "event-form-target": "longitude" } %>