diff --git a/app/javascript/controllers/event_form_controller.js b/app/javascript/controllers/event_form_controller.js
new file mode 100644
index 0000000..24ae3e2
--- /dev/null
+++ b/app/javascript/controllers/event_form_controller.js
@@ -0,0 +1,375 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
+ static values = {
+ geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
+ }
+
+ connect() {
+ this.geocodeTimeout = null
+
+ // Initialize map links if we have an address and coordinates already exist
+ if (this.hasAddressTarget && this.addressTarget.value.trim() &&
+ this.hasLatitudeTarget && this.hasLongitudeTarget &&
+ this.latitudeTarget.value && this.longitudeTarget.value) {
+ this.updateMapLinks()
+ }
+ }
+
+ disconnect() {
+ if (this.geocodeTimeout) {
+ clearTimeout(this.geocodeTimeout)
+ }
+ }
+
+ // Generate slug from name
+ generateSlug() {
+ const name = this.nameTarget.value
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
+ .replace(/^-|-$/g, '') // Remove leading/trailing hyphens
+
+ this.slugTarget.value = slug
+ }
+
+ // Handle address changes with debounced geocoding
+ addressChanged() {
+ // Clear any existing timeout
+ if (this.geocodeTimeout) {
+ clearTimeout(this.geocodeTimeout)
+ }
+
+ const address = this.addressTarget.value.trim()
+
+ if (!address) {
+ this.clearCoordinates()
+ this.clearMapLinks()
+ return
+ }
+
+ // Debounce geocoding to avoid too many API calls
+ this.geocodeTimeout = setTimeout(() => {
+ this.geocodeAddressQuiet(address)
+ }, this.geocodeDelayValue)
+ }
+
+ // Get user's current location and reverse geocode to address
+ async getCurrentLocation() {
+ if (!navigator.geolocation) {
+ this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
+ return
+ }
+
+ this.showLocationLoading()
+
+ const options = {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 60000
+ }
+
+ try {
+ const position = await this.getCurrentPositionPromise(options)
+ const lat = position.coords.latitude
+ const lng = position.coords.longitude
+
+ // Set coordinates first
+ this.latitudeTarget.value = lat.toFixed(6)
+ this.longitudeTarget.value = lng.toFixed(6)
+
+ // Then reverse geocode to get address
+ const address = await this.reverseGeocode(lat, lng)
+
+ if (address) {
+ this.addressTarget.value = address
+ this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
+ } else {
+ this.showLocationSuccess("Position actuelle détectée!")
+ }
+
+ this.updateMapLinks()
+
+ } catch (error) {
+ this.hideLocationLoading()
+ let message = "Erreur lors de la récupération de la localisation."
+
+ switch(error.code) {
+ case error.PERMISSION_DENIED:
+ message = "L'accès à la localisation a été refusé."
+ break
+ case error.POSITION_UNAVAILABLE:
+ message = "Les informations de localisation ne sont pas disponibles."
+ break
+ case error.TIMEOUT:
+ message = "La demande de localisation a expiré."
+ break
+ }
+
+ this.showLocationError(message)
+ }
+ }
+
+ // Promise wrapper for geolocation
+ getCurrentPositionPromise(options) {
+ return new Promise((resolve, reject) => {
+ navigator.geolocation.getCurrentPosition(resolve, reject, options)
+ })
+ }
+
+ // 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 data = await response.json()
+
+ if (data && data.display_name) {
+ return data.display_name
+ }
+
+ return null
+ } catch (error) {
+ console.log("Reverse geocoding failed:", error)
+ return null
+ }
+ }
+
+ // Preview location - same as updating map links but with user feedback
+ previewLocation() {
+ if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
+ this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
+ return
+ }
+
+ // If we already have coordinates, just update map links
+ if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
+ this.latitudeTarget.value && this.longitudeTarget.value) {
+ this.updateMapLinks()
+ this.showLocationSuccess("Liens de carte mis à jour!")
+ } else {
+ // Otherwise geocode the address first
+ this.geocodeAddress()
+ }
+ }
+
+ // Geocode address manually (with user feedback)
+ async geocodeAddress() {
+ if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
+ this.showLocationError("Veuillez saisir une adresse.")
+ return
+ }
+
+ const address = this.addressTarget.value.trim()
+
+ try {
+ this.showLocationLoading()
+ const result = await this.performGeocode(address)
+
+ if (result) {
+ this.latitudeTarget.value = result.lat
+ this.longitudeTarget.value = result.lng
+ this.updateMapLinks()
+ this.showLocationSuccess("Coordonnées trouvées pour cette adresse!")
+ } 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.hideLocationLoading()
+ }
+ }
+
+ // Geocode address quietly (no user feedback, for auto-geocoding)
+ async geocodeAddressQuiet(address) {
+ try {
+ const result = await this.performGeocode(address)
+
+ if (result) {
+ this.latitudeTarget.value = result.lat
+ this.longitudeTarget.value = result.lng
+ this.updateMapLinks()
+ } else {
+ // If auto-geocoding fails, show a subtle warning
+ this.showGeocodingWarning(address)
+ }
+ } catch (error) {
+ console.log("Auto-geocoding failed:", error)
+ this.showGeocodingWarning(address)
+ }
+ }
+
+ // Perform the actual geocoding request
+ async performGeocode(address) {
+ const encodedAddress = encodeURIComponent(address)
+ const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
+ 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)
+ }
+ }
+
+ return null
+ }
+
+ // Update map links based on current coordinates
+ updateMapLinks() {
+ if (!this.hasMapLinksContainerTarget) return
+
+ const lat = parseFloat(this.latitudeTarget.value)
+ const lng = parseFloat(this.longitudeTarget.value)
+ const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
+
+ if (isNaN(lat) || isNaN(lng) || !address) {
+ this.clearMapLinks()
+ return
+ }
+
+ const links = this.generateMapLinks(lat, lng, address)
+ this.mapLinksContainerTarget.innerHTML = links
+ }
+
+ // Generate map links HTML
+ generateMapLinks(lat, lng, address) {
+ const encodedAddress = encodeURIComponent(address)
+
+ const providers = {
+ 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}`,
+ icon: "🍎"
+ }
+ }
+
+ return `
+
+
+
+ Voir sur la carte :
+
+
+
+ `
+ }
+
+ // Clear coordinates
+ clearCoordinates() {
+ if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
+ if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
+ }
+
+ // Clear map links
+ clearMapLinks() {
+ if (this.hasMapLinksContainerTarget) {
+ this.mapLinksContainerTarget.innerHTML = ""
+ }
+ }
+
+ // Show loading state
+ showLocationLoading() {
+ this.hideAllLocationMessages()
+ this.showMessage("location-loading", "Géolocalisation en cours...", "info")
+ }
+
+ // Hide loading state
+ hideLocationLoading() {
+ this.hideMessage("location-loading")
+ }
+
+ // Show success message
+ showLocationSuccess(message) {
+ this.hideAllLocationMessages()
+ this.showMessage("location-success", message, "success")
+ setTimeout(() => this.hideMessage("location-success"), 4000)
+ }
+
+ // Show error message
+ showLocationError(message) {
+ this.hideAllLocationMessages()
+ this.showMessage("location-error", message, "error")
+ setTimeout(() => this.hideMessage("location-error"), 6000)
+ }
+
+ // Show geocoding warning (less intrusive than error)
+ showGeocodingWarning(address) {
+ this.hideMessage("geocoding-warning")
+ const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
+ this.showMessage("geocoding-warning", message, "warning")
+ 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"
+ }
+
+ const icons = {
+ info: "info",
+ success: "check-circle",
+ error: "alert-circle",
+ warning: "alert-triangle"
+ }
+
+ const messageHtml = `
+
+
+ ${message}
+
+ `
+
+ // Insert after the venue section header
+ const venueSection = this.element.querySelector('h3')
+ if (venueSection) {
+ venueSection.insertAdjacentHTML('afterend', messageHtml)
+ // Re-initialize Lucide icons for the new elements
+ if (window.lucide) {
+ window.lucide.createIcons()
+ }
+ }
+ }
+
+ // Hide a specific message
+ hideMessage(id) {
+ const element = document.getElementById(id)
+ if (element) {
+ element.remove()
+ }
+ }
+
+ // Hide all location messages
+ hideAllLocationMessages() {
+ this.hideMessage("location-loading")
+ this.hideMessage("location-success")
+ this.hideMessage("location-error")
+ this.hideMessage("geocoding-warning")
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index aa5656d..8eee735 100755
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -21,3 +21,6 @@ application.register("header", HeaderController);
import QrCodeController from "./qr_code_controller";
application.register("qr-code", QrCodeController);
+
+import EventFormController from "./event_form_controller";
+application.register("event-form", EventFormController);
diff --git a/app/models/event.rb b/app/models/event.rb
index e036a7a..9764da2 100755
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,5 +1,8 @@
# Event model representing nightlife events and events
# Manages event details, location data, and publication state
+require 'net/http'
+require 'json'
+
class Event < ApplicationRecord
# Define states for Event lifecycle management
# draft: Initial state when Event is being created
@@ -19,6 +22,29 @@ class Event < ApplicationRecord
has_many :tickets, through: :ticket_types
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
+
# Validations for Event attributes
# Basic information
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
@@ -48,4 +74,102 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
+
+ private
+
+ # Automatically geocode address to get latitude and longitude
+ def geocode_address
+ return if venue_address.blank?
+
+ # If we already have coordinates and this is an update, try to geocode
+ # If it fails, keep the existing coordinates
+ 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")
+
+ response = Net::HTTP.get_response(uri)
+
+ 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}"
+ return
+ end
+ end
+
+ # If we reach here, geocoding failed
+ handle_geocoding_failure(original_lat, original_lng)
+
+ rescue => e
+ Rails.logger.error "Geocoding failed for address '#{venue_address}': #{e.message}"
+ handle_geocoding_failure(original_lat, original_lng)
+ end
+ end
+
+ # Handle geocoding failure with fallback strategies
+ def handle_geocoding_failure(original_lat, original_lng)
+ # Strategy 1: Keep existing coordinates if this is an update
+ if original_lat.present? && original_lng.present?
+ self.latitude = original_lat
+ self.longitude = original_lng
+ Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
+ return
+ end
+
+ # Strategy 2: Try to extract country/city and use approximate coordinates
+ fallback_coordinates = get_fallback_coordinates_from_address
+ if fallback_coordinates
+ self.latitude = fallback_coordinates[:lat]
+ self.longitude = fallback_coordinates[:lng]
+ Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
+ return
+ end
+
+ # Strategy 3: Use default coordinates (center of France) as last resort
+ # This ensures the event can still be created
+ self.latitude = 46.603354 # Center of France
+ self.longitude = 1.888334
+ Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
+ end
+
+ # Extract country/city from address and return approximate coordinates
+ def get_fallback_coordinates_from_address
+ address_lower = venue_address.downcase
+
+ # 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 }
+ }
+
+ # Check if any known city is mentioned in the address
+ french_cities.each do |city, coords|
+ if address_lower.include?(city)
+ return coords
+ end
+ end
+
+ # Check for common country indicators
+ if address_lower.include?('france') || address_lower.include?('french')
+ return { lat: 46.603354, lng: 1.888334 } # Center of France
+ end
+
+ nil
+ end
end
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb
index ea06b41..242f596 100755
--- a/app/views/events/show.html.erb
+++ b/app/views/events/show.html.erb
@@ -1,5 +1,5 @@
-
+
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
@@ -55,7 +55,48 @@
Lieu
<%= @event.venue_name %>
-
<%= @event.venue_address %>
+
<%= @event.venue_address %>
+
+ <% if @event.latitude.present? && @event.longitude.present? %>
+
+ <% if @event.geocoding_status_message %>
+
+
+
+
<%= @event.geocoding_status_message %>
+
+
+ <% end %>
+
Ouvrir dans :
+
+ <%
+ encoded_address = URI.encode_www_form_component(@event.venue_address)
+ lat = @event.latitude
+ lng = @event.longitude
+
+ map_providers = {
+ "OpenStreetMap" => "https://www.openstreetmap.org/?mlat=#{lat}&mlon=#{lng}#map=16/#{lat}/#{lng}",
+ "Google Maps" => "https://www.google.com/maps/search/#{encoded_address}/@#{lat},#{lng},16z",
+ "Apple Plans" => "https://maps.apple.com/?address=#{encoded_address}&ll=#{lat},#{lng}"
+ }
+
+ icons = {
+ "OpenStreetMap" => "🗺️",
+ "Google Maps" => "🔍",
+ "Apple Plans" => "🍎"
+ }
+ %>
+
+ <% map_providers.each do |name, url| %>
+ <%= link_to url, target: "_blank", rel: "noopener",
+ class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" do %>
+ <%= icons[name] %>
+ <%= name %>
+ <% end %>
+ <% end %>
+
+
+ <% end %>
@@ -150,4 +191,4 @@
-
\ No newline at end of file
+
diff --git a/app/views/promoter/events/edit.html.erb b/app/views/promoter/events/edit.html.erb
index 8139a5d..57c906b 100644
--- a/app/views/promoter/events/edit.html.erb
+++ b/app/views/promoter/events/edit.html.erb
@@ -116,26 +116,35 @@
- <%= form.label :venue_address, "Adresse", 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" %>
-
-
-
-
- <%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
+ <%= 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" } %>
+
+
+
+
+
+ Ma position
+
+
+
+ Prévisualiser
+
+
-
- <%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
-
+
+
+ Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.
+
-
-
- Utilisez un service comme latlong.net pour obtenir les coordonnées GPS.
-
+
+ <%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
+ <%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
+
+
+
<% if @event.published? && @event.tickets.any? %>
diff --git a/app/views/promoter/events/new.html.erb b/app/views/promoter/events/new.html.erb
index 39b9777..ee5794e 100644
--- a/app/views/promoter/events/new.html.erb
+++ b/app/views/promoter/events/new.html.erb
@@ -94,26 +94,35 @@
- <%= form.label :venue_address, "Adresse", 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" %>
-
-
-
-
- <%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
+ <%= 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" } %>
+
+
+
+
+
+ Ma position
+
+
+
+ Prévisualiser
+
+
-
- <%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
- <%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
-
+
+
+ Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.
+
-
-
- Utilisez un service comme latlong.net pour obtenir les coordonnées GPS.
-
+
+ <%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
+ <%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
+
+
+