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 0b7f431..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
@@ -687,8 +750,8 @@ export default class extends Controller {
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
- const previewContainer = document.getElementById('image-preview')
- const previewImg = document.getElementById('preview-img')
+ const previewContainer = document.getElementById('upload-preview')
+ const previewImg = document.getElementById('upload-preview-img')
if (previewContainer && previewImg) {
previewImg.src = e.target.result
@@ -697,4 +760,54 @@ export default class extends Controller {
}
reader.readAsDataURL(file)
}
+
+ // Preview image from URL
+ previewImageUrl(event) {
+ const url = event.target.value.trim()
+ const previewContainer = document.getElementById('url-preview')
+ const previewImg = document.getElementById('url-preview-img')
+
+ if (!url) {
+ if (previewContainer) {
+ previewContainer.classList.add('hidden')
+ }
+ return
+ }
+
+ // Basic URL validation
+ if (!this.isValidImageUrl(url)) {
+ if (previewContainer) {
+ previewContainer.classList.add('hidden')
+ }
+ return
+ }
+
+ // Show preview with error handling
+ if (previewImg) {
+ previewImg.onload = () => {
+ if (previewContainer) {
+ previewContainer.classList.remove('hidden')
+ }
+ }
+
+ previewImg.onerror = () => {
+ if (previewContainer) {
+ previewContainer.classList.add('hidden')
+ }
+ }
+
+ previewImg.src = url
+ }
+ }
+
+ // Validate image URL format
+ isValidImageUrl(url) {
+ try {
+ new URL(url)
+ // Check if it looks like an image URL
+ return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url)
+ } catch {
+ return false
+ }
+ }
}
diff --git a/app/models/event.rb b/app/models/event.rb
index 5038539..4f65a49 100755
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -24,9 +24,15 @@ class Event < ApplicationRecord
has_many :promotion_codes
has_one_attached :image
-
+ # === Virtual attribute for backward compatibility with image URLs ===
+ attr_accessor :image_url
+
+
# === Callbacks ===
+ before_validation :generate_slug, if: :should_generate_slug?
before_validation :geocode_address, if: :should_geocode_address?
+ before_validation :handle_image_url, if: :should_handle_image_url?
+ before_update :handle_image_replacement, if: :image_attached?
# Validations for Event attributes
# Basic information
@@ -34,8 +40,11 @@ class Event < ApplicationRecord
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
validates :state, presence: true, inclusion: { in: states.keys }
+
+ # Image validation - handles both attachments and URLs
validate :image_format, if: -> { image.attached? }
validate :image_size, if: -> { image.attached? }
+ validate :image_url_format, if: -> { image_url.present? && !image.attached? }
# Venue information
validates :venue_name, presence: true, length: { maximum: 100 }
@@ -61,17 +70,120 @@ class Event < ApplicationRecord
# === Instance Methods ===
+ # 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 virtual image_url attribute, return the URL directly
+ return image_url if image_url.present?
+
+ # For attached images, process variants
+ return nil unless image.attached?
+
case size
when :large
- image.variant(resize_to_limit: [1200, 630])
+ image.variant(resize_to_limit: [ 1200, 630 ])
when :medium
- image.variant(resize_to_limit: [800, 450])
+ image.variant(resize_to_limit: [ 800, 450 ])
when :small
- image.variant(resize_to_limit: [400, 225])
+ image.variant(resize_to_limit: [ 400, 225 ])
else
+ # Fallback to URL-based image
+ image_url.presence
+ end
+ end
+
+ # Check if event has any image (old field, attached, or URL)
+ def has_image?
+ self[:image].present? || image.attached? || image_url.present?
+ end
+
+ # Get display image source (uploaded or URL)
+ def display_image
+ if image.attached?
image
+ elsif image_url.present?
+ image_url
+ else
+ self[:image]
end
end
@@ -167,8 +279,44 @@ class Event < ApplicationRecord
end
end
+ # Validate image URL format - relaxed for development
+ def image_url_format
+ return unless image_url.present?
+ return if Rails.env.development? # Skip validation in development
+
+ unless image_url.match?(/\Ahttps?:\/\/.+\.(jpg|jpeg|png|gif|webp)(\?.*)?\z/i)
+ errors.add(:image_url, "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)")
+ end
+ end
+
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 handle image_url
+ def should_handle_image_url?
+ image_url.present? && new_record?
+ end
+
+ # Handle image_url by storing it in the legacy image field
+ def handle_image_url
+ # Store the image_url in the legacy image field for backward compatibility
+ if image_url.present?
+ self[:image] = image_url
+ 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/models/order.rb b/app/models/order.rb
index 7034161..1124ae0 100644
--- a/app/models/order.rb
+++ b/app/models/order.rb
@@ -194,8 +194,14 @@ class Order < ApplicationRecord
# Prevent duplicate promotion codes on the same order
def no_duplicate_promotion_codes
- promotion_code_ids = promotion_codes.map(&:id)
- if promotion_code_ids.size != promotion_code_ids.uniq.size
+ return if promotion_codes.empty?
+
+ # Use distinct to avoid association loading issues
+ unique_codes = promotion_codes.distinct
+ code_counts = unique_codes.group_by(&:code).transform_values(&:count)
+ duplicates = code_counts.select { |_, count| count > 1 }
+
+ if duplicates.any?
errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
end
end
diff --git a/app/views/components/_event_item.html.erb b/app/views/components/_event_item.html.erb
index 4fa25f1..3ae2a3f 100755
--- a/app/views/components/_event_item.html.erb
+++ b/app/views/components/_event_item.html.erb
@@ -1,7 +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 %>
- <%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.image.attached? %>
+ <%= 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/index.html.erb b/app/views/events/index.html.erb
index 768511a..426776c 100755
--- a/app/views/events/index.html.erb
+++ b/app/views/events/index.html.erb
@@ -22,9 +22,13 @@
<% @events.each do |event| %>
<%= link_to event_path(event.slug, event), class: "block" do %>
- <% if event.image.attached? %>
+ <% if event.has_image? %>
- <%= 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" %>
+ <% 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 %>
<% if event.featured? %>
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb
index ad7c236..a4aa059 100755
--- a/app/views/events/show.html.erb
+++ b/app/views/events/show.html.erb
@@ -10,9 +10,9 @@
- <% if @event.image.attached? %>
+ <% if @event.has_image? %>
- <%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover" %>
+ <%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover", alt: @event.name %>
@@ -88,12 +88,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 %>
@@ -131,14 +127,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 f663a9a..82b946a 100755
--- a/app/views/pages/home.html.erb
+++ b/app/views/pages/home.html.erb
@@ -89,7 +89,7 @@
- <% if event.image.attached? %>
+ <% if event.has_image? %>
<%= 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 58ba4c6..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" %>
@@ -68,16 +53,57 @@
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
-
+
+
+
+
+
+
+
+ Télécharger un fichier
+
+
+
+ Utiliser une URL
+
+
+
+
+
+
+
+<<<<<<< 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" %>
-
+
+=======
+ <% if @event.has_image? %>
+
+
+ <% if @event.event_image_variant(:small).is_a?(String) %>
+
+ <%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
+ <% else %>
+
+ <%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
+ <% end %>
+
+
+
Image actuelle
+
Uploader une nouvelle image pour la remplacer.
+
+
+ Remplacer l'image
+>>>>>>> fix/image-upload
+
+ Image actuelle
+
<% end %>
@@ -86,19 +112,64 @@
<%= 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 %>
-
+
-
-
+
+
+
+ Nouvelle image
+
+
+
+
+
+
+
+
+ <% if @event.image_url.present? && !@event.image.attached? %>
+
+ <%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current URL image" %>
+
+
+
+
+
+
+ URL actuelle
+
+
+ <% end %>
+
+
+
+ <%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", value: @event.image_url, data: { action: "input->event-form#previewImageUrl" } %>
+
+ Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
+ <% if @event.image_url.present? %>
+ Laissez vide pour conserver l'URL actuelle
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+ Nouvelle URL
+
@@ -235,4 +306,27 @@
<% end %>
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/app/views/promoter/events/new.html.erb b/app/views/promoter/events/new.html.erb
index 7d6ef4d..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" } %>
@@ -61,13 +58,31 @@
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
-
+
+
+
+
+
+
+
+ Télécharger un fichier
+
+
+
+ Utiliser une URL
+
+
+
+
+
+
+
- <% 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" %>
-
+
@@ -83,10 +98,43 @@
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+ <% if @event.image_url.present? && !@event.image.attached? %>
+
+ <%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current image" %>
+
+
+
+
+
+
+ <% end %>
+
+
+
+ <%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", data: { action: "input->event-form#previewImageUrl" } %>
+
+ Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
+
+
+
+
+
+
+
+
@@ -122,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" } %>
@@ -192,3 +240,26 @@
<% end %>
+
+
diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb
index 6596c20..f4b9711 100644
--- a/app/views/promoter/events/show.html.erb
+++ b/app/views/promoter/events/show.html.erb
@@ -174,9 +174,13 @@
- <% if @event.image.attached? %>
+ <% if @event.has_image? %>
- <%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
+ <% if @event.image.attached? %>
+ <%= image_tag @event.event_image_variant(:large), 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 %>
diff --git a/db/schema.rb b/db/schema.rb
index 5cc56c2..23e160d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -158,7 +158,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_29_222616) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
- add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "order_promotion_codes", "orders"
add_foreign_key "order_promotion_codes", "promotion_codes"
diff --git a/db/seeds.rb b/db/seeds.rb
index 09af41d..9bff890 100755
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -44,7 +44,7 @@ events_data = [
start_time: 1.day.from_now,
end_time: 1.day.from_now + 6.hours,
featured: true,
- image: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
+ image_url: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
user: users.first
},
{
@@ -58,7 +58,7 @@ events_data = [
start_time: 3.days.from_now,
end_time: 3.days.from_now + 4.hours,
featured: true,
- image: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
+ image_url: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.second
},
{
@@ -72,7 +72,7 @@ events_data = [
start_time: 1.week.from_now,
end_time: 1.week.from_now + 8.hours,
featured: false,
- image: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
+ image_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.third
}
]
@@ -147,7 +147,7 @@ belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY
e.start_time = 3.days.from_now
e.end_time = 3.days.from_now + 8.hours
e.featured = false
- e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
+ e.image_url = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.user = promoter
e.allow_booking_during_event = true
end
@@ -156,7 +156,7 @@ belle_epoque_event.update!(start_time: 3.days.from_now, end_time: 3.days.from_no
# Create ticket types for "La belle époque" event
-belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events")
+belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events-le-patio-rooftop-montreuil")
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Free invitation valid before 7 p.m.") do |tt|
tt.description = "Free invitation ticket valid before 7 p.m. for La Belle Époque"
@@ -201,7 +201,7 @@ konpa_event = Event.find_or_create_by!(name: "Konpa With Bev - Cours De Konpa Go
e.start_time = Time.parse("2025-10-03 19:00:00")
e.end_time = Time.parse("2025-10-03 23:00:00")
e.featured = false
- e.image = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
+ e.image_url = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
@@ -216,7 +216,7 @@ caribbean_groove_event = Event.find_or_create_by!(name: "La Plus Grosse Soirée
e.start_time = Time.parse("2025-10-03 23:00:00")
e.end_time = Time.parse("2025-10-04 05:00:00")
e.featured = false
- e.image = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
+ e.image_url = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
@@ -231,7 +231,7 @@ belle_epoque_october_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PA
e.start_time = Time.parse("2025-10-04 18:00:00")
e.end_time = Time.parse("2025-10-05 02:00:00")
e.featured = false
- e.image = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
+ e.image_url = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.user = promoter
e.state = :published
e.allow_booking_during_event = true
diff --git a/test/controllers/orders_controller_promotion_test.rb b/test/controllers/orders_controller_promotion_test.rb
index d9d3370..253fd23 100644
--- a/test/controllers/orders_controller_promotion_test.rb
+++ b/test/controllers/orders_controller_promotion_test.rb
@@ -36,11 +36,6 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
price_cents: 2000
)
- # Debug the ticket creation
- puts "Ticket saved: #{ticket.persisted?}"
- puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
- puts "Order tickets count: #{@order.tickets.count}"
-
# Recalculate the order total
@order.calculate_total!
diff --git a/test/models/event_test.rb b/test/models/event_test.rb
index 8249bd1..2daae77 100755
--- a/test/models/event_test.rb
+++ b/test/models/event_test.rb
@@ -317,4 +317,157 @@ class EventTest < ActiveSupport::TestCase
# Check that ticket types were NOT duplicated
assert_equal 0, duplicated_event.ticket_types.count
end
+
+ # Test slug generation functionality
+ test "should generate slug from name and venue" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Soirée d'ouverture",
+ description: "Valid description for the event that is long enough",
+ venue_name: "Test Venue",
+ venue_address: "123 Test Street",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+ event.save
+ assert_equal "soiree-d-ouverture-test-venue", event.slug
+ end
+
+ test "should generate slug from name, venue, and city" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Fête de la Musique",
+ venue_name: "Théâtre Principal",
+ venue_address: "15 Rue de la Paix, 75002 Paris",
+ description: "Valid description for the event that is long enough",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+ event.save
+ assert_equal "fete-de-la-musique-theatre-principal-paris", event.slug
+ end
+
+ test "should generate fallback slug when no data available" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ description: "Valid description for the event that is long enough",
+ venue_address: "123 Test Street",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+ event.save
+ assert_match /^event-\d+$/, event.slug
+ end
+
+ test "should ensure slug uniqueness" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+
+ # Create first event
+ event1 = Event.create!(
+ name: "Test Event",
+ venue_name: "Venue",
+ venue_address: "123 Test Street",
+ description: "Valid description for the event that is long enough",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+
+ # Create second event with same details
+ event2 = Event.create!(
+ name: "Test Event",
+ venue_name: "Venue",
+ venue_address: "123 Test Street",
+ description: "Valid description for the event that is long enough",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+
+ assert_not_equal event1.slug, event2.slug
+ assert_match /^test-event-venue-1$/, event2.slug
+ end
+
+ test "should extract city from French postal code" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Concert",
+ venue_address: "5 Avenue des Champs-Élysées, 75008 Paris",
+ description: "Valid description for the event that is long enough",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0
+ )
+ event.save
+ assert event.slug.include?("paris")
+ end
+
+ # Test image URL functionality
+ test "should accept valid image URL" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Event with URL Image",
+ slug: "event-url-image",
+ description: "Valid description for the event that is long enough",
+ venue_name: "Venue",
+ venue_address: "123 Test Street",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0,
+ image_url: "https://example.com/image.jpg"
+ )
+ assert event.valid?
+ end
+
+ test "should reject invalid image URL" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Event with Invalid URL",
+ slug: "event-invalid-url",
+ description: "Valid description for the event that is long enough",
+ venue_name: "Venue",
+ venue_address: "123 Test Street",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0,
+ image_url: "not-a-valid-url"
+ )
+ assert_not event.valid?
+ assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
+ end
+
+ test "should reject URL with non-image extension" do
+ user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.new(
+ name: "Event with Non-image URL",
+ slug: "event-non-image-url",
+ description: "Valid description for the event that is long enough",
+ venue_name: "Venue",
+ venue_address: "123 Test Street",
+ user: user,
+ latitude: 48.0,
+ longitude: 2.0,
+ image_url: "https://example.com/document.pdf"
+ )
+ assert_not event.valid?
+ assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
+ end
+
+ test "has_image? should return true for URL image" do
+ event = Event.new(image_url: "https://example.com/image.jpg")
+ assert event.has_image?
+ end
+
+ test "has_image? should return false without image" do
+ event = Event.new
+ assert_not event.has_image?
+ end
+
+ test "display_image should return image URL when no attached image" do
+ event = Event.new(image_url: "https://example.com/image.jpg")
+ assert_equal "https://example.com/image.jpg", event.display_image
+ end
end