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

@@ -44,40 +44,40 @@ class PagesController < ApplicationController
@promoter_events = current_user.events.includes(:orders, :tickets) @promoter_events = current_user.events.includes(:orders, :tickets)
.order(created_at: :desc) .order(created_at: :desc)
.limit(5) .limit(5)
# Revenue metrics for promoter # Revenue metrics for promoter
@total_revenue = current_user.events @total_revenue = current_user.events
.joins(:orders) .joins(:orders)
.where(orders: { status: ['paid', 'completed'] }) .where(orders: { status: [ "paid", "completed" ] })
.sum('orders.total_amount_cents') / 100.0 .sum("orders.total_amount_cents") / 100.0
@total_tickets_sold = current_user.events @total_tickets_sold = current_user.events
.joins(:tickets) .joins(:tickets)
.where(tickets: { status: 'active' }) .where(tickets: { status: "active" })
.count .count
@active_events_count = current_user.events.where(state: 'published').count @active_events_count = current_user.events.where(state: "published").count
@draft_events_count = current_user.events.where(state: 'draft').count @draft_events_count = current_user.events.where(state: "draft").count
# Recent orders for promoter events # Recent orders for promoter events
@recent_orders = Order.joins(:event) @recent_orders = Order.joins(:event)
.where(events: { user: current_user }) .where(events: { user: current_user })
.where(status: ['paid', 'completed']) .where(status: [ "paid", "completed" ])
.includes(:event, :user, tickets: :ticket_type) .includes(:event, :user, tickets: :ticket_type)
.order(created_at: :desc) .order(created_at: :desc)
.limit(10) .limit(10)
# Monthly revenue trend (last 6 months) # Monthly revenue trend (last 6 months)
@monthly_revenue = (0..5).map do |months_ago| @monthly_revenue = (0..5).map do |months_ago|
start_date = months_ago.months.ago.beginning_of_month start_date = months_ago.months.ago.beginning_of_month
end_date = months_ago.months.ago.end_of_month end_date = months_ago.months.ago.end_of_month
revenue = current_user.events revenue = current_user.events
.joins(:orders) .joins(:orders)
.where(orders: { status: ['paid', 'completed'] }) .where(orders: { status: [ "paid", "completed" ] })
.where(orders: { created_at: start_date..end_date }) .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"), month: start_date.strftime("%B %Y"),
revenue: revenue revenue: revenue

View File

@@ -2,13 +2,16 @@ 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
this.isManualGeocodingInProgress = false
// Initialize map links if we have an address and coordinates already exist // Initialize map links if we have an address and coordinates already exist
if (this.hasAddressTarget && this.addressTarget.value.trim() && if (this.hasAddressTarget && this.addressTarget.value.trim() &&
@@ -43,12 +46,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 +75,7 @@ export default class extends Controller {
return return
} }
this.showGetCurrentLocationLoading()
this.showLocationLoading() this.showLocationLoading()
const options = { const options = {
@@ -87,8 +104,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 +124,8 @@ export default class extends Controller {
} }
this.showLocationError(message) this.showLocationError(message)
} finally {
this.hideGetCurrentLocationLoading()
} }
} }
@@ -118,8 +139,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 +179,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()
})
} }
} }
@@ -160,6 +196,7 @@ export default class extends Controller {
const address = this.addressTarget.value.trim() const address = this.addressTarget.value.trim()
try { try {
this.isManualGeocodingInProgress = true
this.showLocationLoading() this.showLocationLoading()
const result = await this.performGeocode(address) const result = await this.performGeocode(address)
@@ -167,26 +204,47 @@ 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.")
} }
} catch (error) { } catch (error) {
this.showLocationError("Erreur lors de la recherche de l'adresse.") this.showLocationError("Erreur lors de la recherche de l'adresse.")
} finally { } finally {
this.isManualGeocodingInProgress = false
this.hideLocationLoading() this.hideLocationLoading()
} }
} }
// 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 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 { } 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 +255,101 @@ 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}"`)
// 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() 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 +378,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 +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 // 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 +534,115 @@ 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 = { // Show geocoding success with location details
info: "info", showGeocodingSuccess(title, location) {
success: "check-circle", this.hideMessage("geocoding-success")
error: "alert-circle", const message = `${title}<br><small class="opacity-75">${location}</small>`
warning: "alert-triangle" this.showMessage("geocoding-success", message, "success")
} setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
const messageHtml = ` // Show geocoding progress with strategy info
<div id="${id}" class="flex items-center space-x-2 p-3 ${colors[type]} border rounded-lg mb-4"> showGeocodingProgress(strategy, attempt) {
<i data-lucide="${icons[type]}" class="w-4 h-4 flex-shrink-0"></i> this.hideMessage("geocoding-progress")
<span class="text-sm font-medium">${message}</span> 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> </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 +660,8 @@ 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")
this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress")
} }
} }

View File

@@ -1,7 +1,7 @@
# Event model representing nightlife events and events # Event model representing nightlife events and events
# Manages event details, location data, and publication state # Manages event details, location data, and publication state
require 'net/http' require "net/http"
require 'json' require "json"
class Event < ApplicationRecord class Event < ApplicationRecord
# Define states for Event lifecycle management # Define states for Event lifecycle management
@@ -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,41 +55,97 @@ 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")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = Net::HTTP.get_response(uri) request = Net::HTTP::Get.new(uri)
request['User-Agent'] = 'AperoNight Event Platform/1.0 (https://aperonight.com)'
request['Accept'] = 'application/json'
if response.code == '200' response = http.request(request)
if response.code == "200"
data = JSON.parse(response.body) data = JSON.parse(response.body)
if data.any? if data.any?
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
# If we reach here, geocoding failed # If we reach here, geocoding failed
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
@@ -143,33 +179,33 @@ class Event < ApplicationRecord
# Extract country/city from address and return approximate coordinates # Extract country/city from address and return approximate coordinates
def get_fallback_coordinates_from_address def get_fallback_coordinates_from_address
address_lower = venue_address.downcase address_lower = venue_address.downcase
# Common French cities with approximate coordinates # Common French cities with approximate coordinates
french_cities = { french_cities = {
'paris' => { lat: 48.8566, lng: 2.3522 }, "paris" => { lat: 48.8566, lng: 2.3522 },
'lyon' => { lat: 45.7640, lng: 4.8357 }, "lyon" => { lat: 45.7640, lng: 4.8357 },
'marseille' => { lat: 43.2965, lng: 5.3698 }, "marseille" => { lat: 43.2965, lng: 5.3698 },
'toulouse' => { lat: 43.6047, lng: 1.4442 }, "toulouse" => { lat: 43.6047, lng: 1.4442 },
'nice' => { lat: 43.7102, lng: 7.2620 }, "nice" => { lat: 43.7102, lng: 7.2620 },
'nantes' => { lat: 47.2184, lng: -1.5536 }, "nantes" => { lat: 47.2184, lng: -1.5536 },
'montpellier' => { lat: 43.6110, lng: 3.8767 }, "montpellier" => { lat: 43.6110, lng: 3.8767 },
'strasbourg' => { lat: 48.5734, lng: 7.7521 }, "strasbourg" => { lat: 48.5734, lng: 7.7521 },
'bordeaux' => { lat: 44.8378, lng: -0.5792 }, "bordeaux" => { lat: 44.8378, lng: -0.5792 },
'lille' => { lat: 50.6292, lng: 3.0573 } "lille" => { lat: 50.6292, lng: 3.0573 }
} }
# Check if any known city is mentioned in the address # Check if any known city is mentioned in the address
french_cities.each do |city, coords| french_cities.each do |city, coords|
if address_lower.include?(city) if address_lower.include?(city)
return coords return coords
end end
end end
# Check for common country indicators # 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 return { lat: 46.603354, lng: 1.888334 } # Center of France
end end
nil nil
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">
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i> <span data-event-form-target="getCurrentLocationIcon">
Ma position <i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
</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">
<i data-lucide="map" class="w-3 h-3 mr-1"></i> <span data-event-form-target="previewLocationIcon">
Prévisualiser <i data-lucide="map" class="w-3 h-3 mr-1"></i>
</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" } %>