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 : +
+
+ ${Object.entries(providers).map(([key, provider]) => ` + + ${provider.icon} + ${provider.name} + + `).join('')} +
+
+ ` + } + + // 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" } %> + + +
+ + +
-
- <%= 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" } %> + + +
+ + +
-
- <%= 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" } %> + + +