1 Commits

Author SHA1 Message Date
b580431b12 Merge pull request 'feat/image-upload' (#7) from feat/image-upload into develop
Reviewed-on: #7
2025-09-29 22:54:59 +00:00
18 changed files with 95 additions and 724 deletions

View File

@@ -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

View File

@@ -122,13 +122,6 @@ 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)
@@ -136,9 +129,6 @@ 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)
@@ -187,8 +177,6 @@ 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)
@@ -345,9 +333,6 @@ 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)
@@ -444,7 +429,6 @@ DEPENDENCIES
debug
devise (~> 4.9)
dotenv-rails
image_processing (~> 1.2)
jbuilder
jsbundling-rails
kamal

View File

@@ -73,33 +73,6 @@ 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

View File

@@ -45,8 +45,6 @@ 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
@@ -134,18 +132,10 @@ class Promoter::EventsController < ApplicationController
end
def event_params
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
params.require(:event).permit(
:name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event
)
end
end

View File

@@ -1,11 +1,8 @@
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", "venueName"]
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
}
@@ -30,65 +27,15 @@ export default class extends Controller {
}
}
// Generate slug from name, venue name, and city for better SEO
// Generate slug from name
generateSlug() {
const name = this.nameTarget.value
const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : ""
const address = this.hasAddressTarget ? this.addressTarget.value : ""
// 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 ""
this.slugTarget.value = slug(name)
}
// Handle address changes with debounced geocoding
addressChanged() {
// Regenerate slug when address changes
this.generateSlug()
// Clear any existing timeout
if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout)
@@ -121,11 +68,6 @@ 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) {
@@ -574,16 +516,14 @@ export default class extends Controller {
showLocationSuccess(message) {
this.hideAllLocationMessages()
this.showMessage("location-success", message, "success")
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-success"), 4000)
setTimeout(() => this.hideMessage("location-success"), 4000)
}
// Show error message
showLocationError(message) {
this.hideAllLocationMessages()
this.showMessage("location-error", message, "error")
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-error"), 6000)
setTimeout(() => this.hideMessage("location-error"), 6000)
}
// Show geocoding warning (less intrusive than error)
@@ -591,8 +531,7 @@ 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")
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
}
// Show info about approximate location
@@ -600,8 +539,7 @@ export default class extends Controller {
this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info")
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
}
// Show geocoding success with location details
@@ -609,8 +547,7 @@ export default class extends Controller {
this.hideMessage("geocoding-success")
const message = `${title}<br><small class="opacity-75">${location}</small>`
this.showMessage("geocoding-success", message, "success")
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-success"), 5000)
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
// Show geocoding progress with strategy info
@@ -750,8 +687,8 @@ export default class extends Controller {
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
const previewContainer = document.getElementById('upload-preview')
const previewImg = document.getElementById('upload-preview-img')
const previewContainer = document.getElementById('image-preview')
const previewImg = document.getElementById('preview-img')
if (previewContainer && previewImg) {
previewImg.src = e.target.result
@@ -760,54 +697,4 @@ 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
}
}
}

View File

@@ -24,15 +24,9 @@ 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
@@ -40,11 +34,8 @@ 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 }
@@ -70,120 +61,17 @@ 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
@@ -279,44 +167,8 @@ 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

View File

@@ -194,14 +194,8 @@ class Order < ApplicationRecord
# Prevent duplicate promotion codes on the same order
def no_duplicate_promotion_codes
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?
promotion_code_ids = promotion_codes.map(&:id)
if promotion_code_ids.size != promotion_code_ids.uniq.size
errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
end
end

View File

@@ -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 %>
<div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0">
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.has_image? %>
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.image.attached? %>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200">

View File

@@ -22,13 +22,9 @@
<% @events.each do |event| %>
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
<%= link_to event_path(event.slug, event), class: "block" do %>
<% if event.has_image? %>
<% if event.image.attached? %>
<div class="relative overflow-hidden aspect-[4/3]">
<% 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" %>
<!-- Event featured badge -->
<% if event.featured? %>
<div class="absolute top-4 left-4">

View File

@@ -10,9 +10,9 @@
<!-- Event main wrapper -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Event Header with Image -->
<% if @event.has_image? %>
<% if @event.image.attached? %>
<div class="relative h-96">
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover", alt: @event.name %>
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover" %>
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<div class="max-w-4xl mx-auto">
@@ -88,8 +88,12 @@
%>
<% map_providers.each do |name, url| %>
<%= 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" %>
<%= 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 %>
<span class="mr-1"><%= icons[name] %></span>
<%= name %>
<% end %>
<% end %>
</div>
</div>
<% end %>
@@ -127,7 +131,14 @@
<!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1">
<%= 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| %>
<div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm">
<div class="flex justify-center sm:justify-start mb-6">

View File

@@ -89,7 +89,7 @@
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
<!-- Event Image -->
<div class="relative overflow-hidden aspect-[4/3]">
<% 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 %>
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">

View File

@@ -41,9 +41,24 @@
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div>
<%= 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" %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= 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" } %>
</div>
<div>
<%= 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" } %>
<p class="mt-1 text-sm text-gray-500">
<% if @event.published? %>
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-yellow-500"></i>
Attention: Modifier le slug d'un événement publié peut casser les liens existants.
<% else %>
Utilisé dans l'URL de l'événement
<% end %>
</p>
</div>
</div>
<div class="mt-6">
@@ -53,57 +68,16 @@
<div class="mt-6">
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<div class="space-y-4">
<!-- Current image preview -->
<<<<<<< HEAD
<% if @event.image.attached? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
=======
<% if @event.has_image? %>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<% if @event.event_image_variant(:small).is_a?(String) %>
<!-- Old image field -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% else %>
<!-- Attached image -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% end %>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 mb-1">Image actuelle</p>
<p class="text-sm text-gray-600 mb-2">Uploader une nouvelle image pour la remplacer.</p>
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-lg hover:bg-red-600 transition-colors inline-flex items-center">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
<span>Remplacer l'image</span>
>>>>>>> fix/image-upload
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
Image actuelle
</div>
</div>
<% end %>
@@ -112,64 +86,19 @@
<%= 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" } %>
<div class="mt-1 text-sm text-gray-500">
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
<% if @event.has_image? %>
<% if @event.image.attached? %>
<br>Laissez vide pour conserver l'image actuelle
<% end %>
</div>
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div id="image-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle image
</div>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current URL image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
URL actuelle
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= 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" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
<% if @event.image_url.present? %>
<br>Laissez vide pour conserver l'URL actuelle
<% end %>
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle URL
</div>
</div>
</div>
</div>
@@ -306,27 +235,4 @@
</div>
<% end %>
</div>
</div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>
</div>

View File

@@ -41,14 +41,17 @@
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= 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" } %>
</div>
<!-- Hidden slug field (auto-generated) -->
<%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
<div>
<%= 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" } %>
<p class="mt-1 text-sm text-gray-500">Utilisé dans l'URL de l'événement</p>
</div>
</div>
<div class="mt-6">
@@ -58,31 +61,13 @@
<div class="mt-6">
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<div class="space-y-4">
<!-- Current image preview (for edit mode) -->
<% if @event.has_image? %>
<% if @event.image.attached? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<button type="button" onclick="this.closest('div').previousElementSibling.querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
@@ -98,43 +83,10 @@
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div id="image-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= 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" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
@@ -170,7 +122,7 @@
<div class="space-y-6">
<div>
<%= 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", data: { "event-form-target": "venueName", action: "input->event-form#venueNameChanged" } %>
<%= 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" %>
</div>
<div>
@@ -240,26 +192,3 @@
<% end %>
</div>
</div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>

View File

@@ -174,13 +174,9 @@
<!-- Main content -->
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
<!-- Event image -->
<% if @event.has_image? %>
<% if @event.image.attached? %>
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
<% 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 %>
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
</div>
<% end %>

1
db/schema.rb generated
View File

@@ -158,6 +158,7 @@ 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"

View File

@@ -44,7 +44,7 @@ events_data = [
start_time: 1.day.from_now,
end_time: 1.day.from_now + 6.hours,
featured: true,
image_url: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
image: "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_url: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image: "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_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image: "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_url = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.image = "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-le-patio-rooftop-montreuil")
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events")
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_url = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.image = "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_url = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.image = "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_url = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.image = "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

View File

@@ -36,6 +36,11 @@ 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!

View File

@@ -317,157 +317,4 @@ 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