feat: Implement event image upload system for promoters

- Add Active Storage migrations for file attachments
- Update Event model to handle image uploads with validation
- Replace image URL fields with file upload in forms
- Add client-side image preview with validation
- Update all views to display uploaded images properly
- Fix JSON serialization to prevent stack overflow in API
- Add custom image validation methods for format and size
- Include image processing variants for different display sizes
- Fix promotion code test infrastructure and Stripe configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kbe
2025-09-30 00:41:03 +02:00
parent be7b3d5c18
commit 6be8b95ed3
22 changed files with 450 additions and 36 deletions

View File

@@ -14,14 +14,14 @@ module Api
# Retrieves all events sorted by creation date (most recent first)
def index
@events = Event.all.order(created_at: :desc)
render json: @events, status: :ok
render json: @events.map { |e| event_json(e) }, status: :ok
end
# GET /api/v1/events/:id
# Retrieves a single event by its ID
# Returns 404 if the event is not found
def show
render json: @event, status: :ok
render json: event_json(@event), status: :ok
end
# POST /api/v1/events
@@ -31,7 +31,7 @@ module Api
def create
@event = Event.new(event_params)
if @event.save
render json: @event, status: :created
render json: event_json(@event), status: :created
else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end
@@ -43,7 +43,7 @@ module Api
# Returns 422 Unprocessable Entity with error messages on failure
def update
if @event.update(event_params)
render json: @event, status: :ok
render json: event_json(@event), status: :ok
else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end
@@ -73,6 +73,32 @@ 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,
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

@@ -164,6 +164,8 @@ class OrdersController < ApplicationController
flash[:alert] = "Erreur lors de la création de la session de paiement"
end
end
render :checkout
end
# Increment payment attempt - called via AJAX when user clicks pay button

View File

@@ -29,6 +29,8 @@ class Promoter::EventsController < ApplicationController
if @event.save
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
else
# If validation fails and an image was attached, purge it
@event.image.purge if @event.image.attached?
render :new, status: :unprocessable_entity
end
end

View File

@@ -664,4 +664,37 @@ export default class extends Controller {
this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress")
}
// Preview selected image
previewImage(event) {
const file = event.target.files[0]
if (!file) return
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Veuillez sélectionner une image valide.')
event.target.value = ''
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
alert('L\'image ne doit pas dépasser 5MB.')
event.target.value = ''
return
}
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
const previewContainer = document.getElementById('image-preview')
const previewImg = document.getElementById('preview-img')
if (previewContainer && previewImg) {
previewImg.src = e.target.result
previewContainer.classList.remove('hidden')
}
}
reader.readAsDataURL(file)
}
}

View File

@@ -22,7 +22,9 @@ class Event < ApplicationRecord
has_many :tickets, through: :ticket_types
has_many :orders
has_many :promotion_codes
has_one_attached :image
# === Callbacks ===
before_validation :geocode_address, if: :should_geocode_address?
@@ -32,7 +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 }
validates :image, length: { maximum: 500 } # URL or path to image
validate :image_format, if: -> { image.attached? }
validate :image_size, if: -> { image.attached? }
# Venue information
validates :venue_name, presence: true, length: { maximum: 100 }
@@ -58,6 +61,20 @@ class Event < ApplicationRecord
# === Instance Methods ===
# Get image variants for different display sizes
def event_image_variant(size = :medium)
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
end
end
# Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful?
coordinates_look_valid?
@@ -131,6 +148,25 @@ class Event < ApplicationRecord
nil
end
# Validate image format
def image_format
return unless image.attached?
allowed_types = %w[image/jpeg image/jpg image/png image/webp]
unless allowed_types.include?(image.content_type)
errors.add(:image, "doit être au format JPG, PNG ou WebP")
end
end
# Validate image size
def image_size
return unless image.attached?
if image.byte_size > 5.megabytes
errors.add(:image, "doit faire moins de 5MB")
end
end
private
# Determine if we should perform server-side geocoding

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.image, alt: event.name, class: "w-full h-full object-cover" if event.image.present? %>
<%= 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.image.present? %>
<% if event.image.attached? %>
<div class="relative overflow-hidden aspect-[4/3]">
<img
src="<%= event.image %>"
alt="<%= event.name %>"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
>
<%= 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.image.present? %>
<% if @event.image.attached? %>
<div class="relative h-96">
<%= image_tag @event.image, class: "w-full h-full object-cover" %>
<%= 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">

View File

@@ -89,10 +89,8 @@
<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.image.present? %>
<img src="<%= event.image %>"
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 %>
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>

View File

@@ -67,9 +67,41 @@
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_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", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="space-y-4">
<!-- Current image preview -->
<% 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="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>
</button>
</div>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= 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.image.attached? %>
<br>Laissez vide pour conserver l'image actuelle
<% end %>
</div>
</div>
<!-- Image preview container -->
<div id="image-preview" class="hidden">
<div class="relative">
<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>
</div>
</div>
</div>
</div>

View File

@@ -60,9 +60,38 @@
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_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", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="space-y-4">
<!-- Current image preview (for edit mode) -->
<% 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="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>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= 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)
</div>
</div>
<!-- Image preview container -->
<div id="image-preview" class="hidden">
<div class="relative">
<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>
</div>
</div>
</div>
</div>

View File

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