diff --git a/Gemfile b/Gemfile index c06c5d7..9a8aaa6 100755 --- a/Gemfile +++ b/Gemfile @@ -40,7 +40,7 @@ gem "kamal", require: false gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] -# gem "image_processing", "~> 1.2" +gem "image_processing", "~> 1.2" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem diff --git a/Gemfile.lock b/Gemfile.lock index 12296b9..8525c29 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,6 +122,13 @@ GEM erubi (1.13.1) et-orbi (1.3.0) tzinfo + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) fugit (1.11.2) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -129,6 +136,9 @@ GEM activesupport (>= 6.1) i18n (1.14.7) concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) @@ -177,6 +187,8 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.3) + mini_magick (5.3.1) + logger mini_mime (1.1.5) minitest (5.25.5) minitest-reporters (1.7.1) @@ -333,6 +345,9 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) + ruby-vips (2.2.5) + ffi (~> 1.12) + logger ruby2_keywords (0.0.5) rubyzip (3.0.2) securerandom (0.4.1) @@ -429,6 +444,7 @@ DEPENDENCIES debug devise (~> 4.9) dotenv-rails + image_processing (~> 1.2) jbuilder jsbundling-rails kamal diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 5f00625..cd2bcb1 100755 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -73,6 +73,33 @@ module Api private + # Helper method to serialize event data safely + def event_json(event) + { + id: event.id, + name: event.name, + slug: event.slug, + description: event.description, + state: event.state, + venue_name: event.venue_name, + venue_address: event.venue_address, + start_time: event.start_time, + end_time: event.end_time, + latitude: event.latitude, + longitude: event.longitude, + featured: event.featured, + image_url: event.display_image_url, + created_at: event.created_at, + updated_at: event.updated_at, + user: { + id: event.user.id, + email: event.user.email, + first_name: event.user.first_name, + last_name: event.user.last_name + } + } + end + # Finds an event by its ID or returns 404 Not Found # Used as before_action for the show, update, and destroy actions def set_event diff --git a/app/controllers/promoter/events_controller.rb b/app/controllers/promoter/events_controller.rb index 72af78d..341ccff 100644 --- a/app/controllers/promoter/events_controller.rb +++ b/app/controllers/promoter/events_controller.rb @@ -45,6 +45,8 @@ class Promoter::EventsController < ApplicationController if @event.update(event_params) redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!" else + # If validation fails and a new image was attached, purge it + @event.image.purge if @event.image.attached? && @event.changed.include?('image') render :edit, status: :unprocessable_entity end end @@ -132,10 +134,18 @@ class Promoter::EventsController < ApplicationController end def event_params - params.require(:event).permit( - :name, :slug, :description, :image, - :venue_name, :venue_address, :latitude, :longitude, - :start_time, :end_time, :featured, :allow_booking_during_event - ) + if action_name == 'create' + params.require(:event).permit( + :name, :slug, :description, :image, + :venue_name, :venue_address, :latitude, :longitude, + :start_time, :end_time, :featured, :allow_booking_during_event + ) + else + params.require(:event).permit( + :name, :description, :image, + :venue_name, :venue_address, :latitude, :longitude, + :start_time, :end_time, :featured, :allow_booking_during_event + ) + end end end diff --git a/app/javascript/controllers/event_form_controller.js b/app/javascript/controllers/event_form_controller.js index a9d4248..8775418 100644 --- a/app/javascript/controllers/event_form_controller.js +++ b/app/javascript/controllers/event_form_controller.js @@ -1,8 +1,11 @@ import { Controller } from "@hotwired/stimulus" import slug from 'slug' +// Configure slug to match Rails parameterize behavior +slug.defaults.mode = 'rfc3986' + export default class extends Controller { - static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"] + static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer", "venueName"] static values = { geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding } @@ -27,15 +30,65 @@ export default class extends Controller { } } - // Generate slug from name + // Generate slug from name, venue name, and city for better SEO generateSlug() { const name = this.nameTarget.value + const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : "" + const address = this.hasAddressTarget ? this.addressTarget.value : "" - this.slugTarget.value = slug(name) + // Extract city from address + const city = this.extractCity(address) + + // Build SEO-friendly slug: name-venue-city + let slugParts = [] + + if (name) slugParts.push(name) + if (venueName) slugParts.push(venueName) + if (city) slugParts.push(city) + + let slugValue = slugParts.join('-') + + // If no slug parts, generate a fallback slug + if (!slugValue) { + slugValue = `event-${Date.now()}` + } + + // Generate slug with proper character handling (matches Rails parameterize) + this.slugTarget.value = slug(slugValue, { lower: true }) + } + + // Extract city from address + extractCity(address) { + if (!address) return "" + + // Look for French postal code pattern (5 digits) + city + const match = address.match(/(\d{5})\s+([^,]+)/) + if (match) { + return 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() + } + + // Another fallback: look for common French city indicators + const cityIndicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"] + for (const city of cityIndicators) { + if (address.toLowerCase().includes(city.toLowerCase())) { + return city + } + } + + return "" } // Handle address changes with debounced geocoding addressChanged() { + // Regenerate slug when address changes + this.generateSlug() + // Clear any existing timeout if (this.geocodeTimeout) { clearTimeout(this.geocodeTimeout) @@ -68,6 +121,11 @@ export default class extends Controller { }, this.geocodeDelayValue) } + // Handle venue name changes to regenerate slug + venueNameChanged() { + this.generateSlug() + } + // Get user's current location and reverse geocode to address async getCurrentLocation() { if (!navigator.geolocation) { @@ -516,14 +574,16 @@ export default class extends Controller { showLocationSuccess(message) { this.hideAllLocationMessages() this.showMessage("location-success", message, "success") - setTimeout(() => this.hideMessage("location-success"), 4000) + // Keep notification visible indefinitely + // 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) + // Keep notification visible indefinitely + // setTimeout(() => this.hideMessage("location-error"), 6000) } // Show geocoding warning (less intrusive than error) @@ -531,7 +591,8 @@ export default class extends Controller { 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) + // Keep notification visible indefinitely + // setTimeout(() => this.hideMessage("geocoding-warning"), 8000) } // Show info about approximate location @@ -539,7 +600,8 @@ export default class extends Controller { 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) + // Keep notification visible indefinitely + // setTimeout(() => this.hideMessage("approximate-location-info"), 6000) } // Show geocoding success with location details @@ -547,7 +609,8 @@ export default class extends Controller { this.hideMessage("geocoding-success") const message = `${title}
${location}` this.showMessage("geocoding-success", message, "success") - setTimeout(() => this.hideMessage("geocoding-success"), 5000) + // Keep notification visible indefinitely + // setTimeout(() => this.hideMessage("geocoding-success"), 5000) } // Show geocoding progress with strategy info diff --git a/app/models/event.rb b/app/models/event.rb index 7a07beb..e2cb748 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -29,7 +29,9 @@ class Event < ApplicationRecord # === Callbacks === + before_validation :generate_slug, if: :should_generate_slug? before_validation :geocode_address, if: :should_geocode_address? + before_update :handle_image_replacement, if: :image_attached? # Validations for Event attributes # Basic information @@ -67,19 +69,98 @@ class Event < ApplicationRecord # === Instance Methods === - # Get image for display - handles both uploaded files and URLs - def event_image_variant(size = :medium) - if image.attached? - case size - when :large - image.variant(resize_to_limit: [1200, 630]) - when :medium - image.variant(resize_to_limit: [800, 450]) - when :small - image.variant(resize_to_limit: [400, 225]) - else - image + # Generate SEO-friendly slug from name, venue name, and city + def generate_slug + return if name.blank? && venue_name.blank? && venue_address.blank? + + # Extract city from venue address + city = extract_city_from_address(venue_address) + + # Build slug parts + slug_parts = [] + slug_parts << name if name.present? + slug_parts << venue_name if venue_name.present? + slug_parts << city if city.present? + + # Generate slug using Rails' parameterize + slug_value = slug_parts.join("-").parameterize + + # Ensure minimum length + if slug_value.length < 3 + slug_value = "event-#{Time.current.to_i}".parameterize + end + + # Make sure slug is unique + base_slug = slug_value + counter = 1 + while Event.where.not(id: id).where(slug: slug_value).exists? + slug_value = "#{base_slug}-#{counter}".parameterize + counter += 1 + end + + self.slug = slug_value + end + + # Check if slug should be generated + def should_generate_slug? + # Generate slug if it's blank or if it's a new record + slug.blank? || new_record? + end + + # Extract city from address + def extract_city_from_address(address) + return "" if address.blank? + + # Look for French postal code pattern (5 digits) + city + match = address.match(/(\d{5})\s+([^,]+)/) + if match + return match[2].strip + end + + # Fallback: extract last part after comma (assume it's city) + parts = address.split(",") + if parts.length > 1 + return parts[parts.length - 1].strip + end + + # Another fallback: look for common French city indicators + city_indicators = [ "Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille" ] + for city in city_indicators + if address.downcase.include?(city.downcase) + return city end + end + + "" + end + + # Get image URL prioritizing old image field if it exists + def display_image_url + # First check if old image field exists and has a value + return self[:image] if self[:image].present? + + # Fall back to attached image + return nil unless image.attached? + + # Return the URL for the attached image + Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true) + end + + # Get image variants for different display sizes + def event_image_variant(size = :medium) + # For old image field, return the URL directly + return self[:image] if self[:image].present? + + # For attached images, process variants + return nil unless image.attached? + + case size + when :large + image.variant(resize_to_limit: [ 1200, 630 ]) + when :medium + image.variant(resize_to_limit: [ 800, 450 ]) + when :small + image.variant(resize_to_limit: [ 400, 225 ]) else # Fallback to URL-based image image_url.presence @@ -100,6 +181,11 @@ class Event < ApplicationRecord end end + # Check if event has any image (old field or attached) + def has_image? + self[:image].present? || image.attached? + end + # Check if coordinates were successfully geocoded or are fallback coordinates def geocoding_successful? coordinates_look_valid? @@ -203,6 +289,19 @@ class Event < ApplicationRecord private + # Check if image is attached for the callback + def image_attached? + image.attached? + end + + # Handle image replacement when a new image is uploaded + def handle_image_replacement + # Clear the old image field if a new image is being attached + if image.attached? + self[:image] = nil + end + end + # Determine if we should perform server-side geocoding def should_geocode_address? # Don't geocode if address is blank diff --git a/app/views/components/_event_item.html.erb b/app/views/components/_event_item.html.erb index 8a02f12..3ae2a3f 100755 --- a/app/views/components/_event_item.html.erb +++ b/app/views/components/_event_item.html.erb @@ -1,13 +1,7 @@ <%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %>
- <% if event.has_image? %> - <% if event.image.attached? %> - <%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" %> - <% else %> - <%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover" %> - <% end %> - <% end %> + <%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.has_image? %>

diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 46c3f15..e04755a 100755 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -92,12 +92,8 @@ %> <% 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 %> + <%= link_to "#{icons[name]} #{name}".html_safe, 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" %> <% end %> - <% end %>

<% end %> @@ -135,14 +131,7 @@
- <%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: { - controller: "ticket-selection", - ticket_selection_target: "form", - ticket_selection_event_slug_value: @event.slug, - ticket_selection_event_id_value: @event.id, - ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id), - ticket_selection_store_cart_url_value: api_v1_store_cart_path - } do |form| %> + <%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: { controller: "ticket-selection", ticket_selection_target: "form", ticket_selection_event_slug_value: @event.slug, ticket_selection_event_id_value: @event.id, ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id), ticket_selection_store_cart_url_value: api_v1_store_cart_path } do |form| %>
diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index fb0bb7e..82b946a 100755 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -90,11 +90,7 @@
<% if event.has_image? %> - <% if event.image.attached? %> - <%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %> - <% else %> - <%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %> - <% end %> + <%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %> <% else %>
diff --git a/app/views/promoter/events/edit.html.erb b/app/views/promoter/events/edit.html.erb index 037fccd..458b341 100644 --- a/app/views/promoter/events/edit.html.erb +++ b/app/views/promoter/events/edit.html.erb @@ -41,24 +41,9 @@

Informations générales

-
-
- <%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.text_field :name, 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: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %> -
- -
- <%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %> -

- <% if @event.published? %> - - Attention: Modifier le slug d'un événement publié peut casser les liens existants. - <% else %> - Utilisé dans l'URL de l'événement - <% end %> -

-
+
+ <%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.text_field :name, 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: Soirée d'ouverture" %>
@@ -88,12 +73,32 @@
+<<<<<<< HEAD <% if @event.image.attached? %>
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
@@ -107,7 +112,7 @@ <%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB) - <% if @event.image.attached? %> + <% if @event.has_image? %>
Laissez vide pour conserver l'image actuelle <% end %>
diff --git a/app/views/promoter/events/new.html.erb b/app/views/promoter/events/new.html.erb index 20eb59d..d9052b8 100644 --- a/app/views/promoter/events/new.html.erb +++ b/app/views/promoter/events/new.html.erb @@ -41,17 +41,14 @@

Informations générales

-
+
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.text_field :name, 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: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
-
- <%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %> -

Utilisé dans l'URL de l'événement

-
+ + <%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
@@ -81,7 +78,7 @@
- <% if @event.image.attached? %> + <% if @event.has_image? %>
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
@@ -173,7 +170,7 @@
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.text_field :venue_name, 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: Le Grand Rex" %> + <%= form.text_field :venue_name, 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: Le Grand Rex", data: { "event-form-target": "venueName", action: "input->event-form#venueNameChanged" } %>