Compare commits
9 Commits
be7b3d5c18
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d7349a9b | ||
|
|
4ca8d73c8e | ||
|
|
78b675b41d | ||
|
|
d914ae5c4a | ||
|
|
da3522d118 | ||
|
|
20dcee0a5b | ||
|
|
ef3f05661e | ||
|
|
d85996a1bb | ||
|
|
6be8b95ed3 |
@@ -9,14 +9,11 @@
|
|||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||||
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
|
||||||
- [ ] feat: Refund management system
|
- [ ] feat: Refund management system
|
||||||
- [ ] feat: Real-time sales analytics dashboard
|
- [ ] feat: Real-time sales analytics dashboard
|
||||||
- [ ] feat: Guest checkout without account creation
|
- [ ] feat: Guest checkout without account creation
|
||||||
- [ ] feat: Seat selection with interactive venue maps
|
- [ ] feat: Seat selection with interactive venue maps
|
||||||
- [ ] feat: Dynamic pricing based on demand
|
- [ ] feat: Dynamic pricing based on demand
|
||||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
|
||||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
|
||||||
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||||
- [ ] feat: Platform commission tracking and fee structure display
|
- [ ] feat: Platform commission tracking and fee structure display
|
||||||
- [ ] feat: Tax reporting and revenue export for promoters
|
- [ ] feat: Tax reporting and revenue export for promoters
|
||||||
@@ -53,7 +50,6 @@
|
|||||||
|
|
||||||
## 🚧 Doing
|
## 🚧 Doing
|
||||||
|
|
||||||
- [ ] feat: Promotion code on ticket
|
|
||||||
- [ ] feat: Page to display all tickets for an event
|
- [ ] feat: Page to display all tickets for an event
|
||||||
- [ ] feat: Add a link into notification email to order page that display all tickets
|
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||||
|
|
||||||
@@ -65,7 +61,11 @@
|
|||||||
- [x] Add login functionality
|
- [x] Add login functionality
|
||||||
- [x] refactor: Moving checkout to OrdersController
|
- [x] refactor: Moving checkout to OrdersController
|
||||||
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
||||||
|
- [x] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||||
- [x] feat: Digital tickets with QR codes
|
- [x] feat: Digital tickets with QR codes
|
||||||
- [x] feat: Ticket inventory management and capacity limits
|
- [x] feat: Ticket inventory management and capacity limits
|
||||||
- [x] feat: Event discovery with search and filtering
|
- [x] feat: Event discovery with search and filtering
|
||||||
|
- [x] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||||
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||||
|
- [x] feat: Promotion code on ticket
|
||||||
|
- [x] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -40,7 +40,7 @@ gem "kamal", require: false
|
|||||||
gem "thruster", require: false
|
gem "thruster", require: false
|
||||||
|
|
||||||
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
|
# 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
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
|
|||||||
16
Gemfile.lock
16
Gemfile.lock
@@ -122,6 +122,13 @@ GEM
|
|||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.3.0)
|
et-orbi (1.3.0)
|
||||||
tzinfo
|
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)
|
fugit (1.11.2)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
@@ -129,6 +136,9 @@ GEM
|
|||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
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)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
@@ -177,6 +187,8 @@ GEM
|
|||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
|
mini_magick (5.3.1)
|
||||||
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
minitest-reporters (1.7.1)
|
minitest-reporters (1.7.1)
|
||||||
@@ -333,6 +345,9 @@ GEM
|
|||||||
rubocop-performance (>= 1.24)
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-rails (>= 2.30)
|
rubocop-rails (>= 2.30)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby-vips (2.2.5)
|
||||||
|
ffi (~> 1.12)
|
||||||
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.0.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
@@ -429,6 +444,7 @@ DEPENDENCIES
|
|||||||
debug
|
debug
|
||||||
devise (~> 4.9)
|
devise (~> 4.9)
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
|
image_processing (~> 1.2)
|
||||||
jbuilder
|
jbuilder
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
kamal
|
kamal
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ module Api
|
|||||||
# Retrieves all events sorted by creation date (most recent first)
|
# Retrieves all events sorted by creation date (most recent first)
|
||||||
def index
|
def index
|
||||||
@events = Event.all.order(created_at: :desc)
|
@events = Event.all.order(created_at: :desc)
|
||||||
render json: @events, status: :ok
|
render json: @events.map { |e| event_json(e) }, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /api/v1/events/:id
|
# GET /api/v1/events/:id
|
||||||
# Retrieves a single event by its ID
|
# Retrieves a single event by its ID
|
||||||
# Returns 404 if the event is not found
|
# Returns 404 if the event is not found
|
||||||
def show
|
def show
|
||||||
render json: @event, status: :ok
|
render json: event_json(@event), status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /api/v1/events
|
# POST /api/v1/events
|
||||||
@@ -31,7 +31,7 @@ module Api
|
|||||||
def create
|
def create
|
||||||
@event = Event.new(event_params)
|
@event = Event.new(event_params)
|
||||||
if @event.save
|
if @event.save
|
||||||
render json: @event, status: :created
|
render json: event_json(@event), status: :created
|
||||||
else
|
else
|
||||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -43,7 +43,7 @@ module Api
|
|||||||
# Returns 422 Unprocessable Entity with error messages on failure
|
# Returns 422 Unprocessable Entity with error messages on failure
|
||||||
def update
|
def update
|
||||||
if @event.update(event_params)
|
if @event.update(event_params)
|
||||||
render json: @event, status: :ok
|
render json: event_json(@event), status: :ok
|
||||||
else
|
else
|
||||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -73,6 +73,33 @@ module Api
|
|||||||
|
|
||||||
private
|
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
|
# Finds an event by its ID or returns 404 Not Found
|
||||||
# Used as before_action for the show, update, and destroy actions
|
# Used as before_action for the show, update, and destroy actions
|
||||||
def set_event
|
def set_event
|
||||||
@@ -99,6 +126,32 @@ module Api
|
|||||||
:user_id
|
:user_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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, # May be remove public email ?
|
||||||
|
first_name: event.user.first_name, # May be remove public name ?
|
||||||
|
last_name: event.user.last_name # May be remove public name ?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ class OrdersController < ApplicationController
|
|||||||
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
render :checkout
|
||||||
end
|
end
|
||||||
|
|
||||||
# Increment payment attempt - called via AJAX when user clicks pay button
|
# Increment payment attempt - called via AJAX when user clicks pay button
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class Promoter::EventsController < ApplicationController
|
|||||||
if @event.save
|
if @event.save
|
||||||
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
||||||
else
|
else
|
||||||
|
# If validation fails and an image was attached, purge it
|
||||||
|
@event.image.purge if @event.image.attached?
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -43,6 +45,8 @@ class Promoter::EventsController < ApplicationController
|
|||||||
if @event.update(event_params)
|
if @event.update(event_params)
|
||||||
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
|
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
|
||||||
else
|
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
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -130,10 +134,18 @@ class Promoter::EventsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def event_params
|
def event_params
|
||||||
|
if action_name == 'create'
|
||||||
params.require(:event).permit(
|
params.require(:event).permit(
|
||||||
:name, :slug, :description, :image,
|
:name, :slug, :description, :image,
|
||||||
:venue_name, :venue_address, :latitude, :longitude,
|
:venue_name, :venue_address, :latitude, :longitude,
|
||||||
:start_time, :end_time, :featured, :allow_booking_during_event
|
: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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
import slug from 'slug'
|
import slug from 'slug'
|
||||||
|
|
||||||
|
// Configure slug to match Rails parameterize behavior
|
||||||
|
slug.defaults.mode = 'rfc3986'
|
||||||
|
|
||||||
export default class extends Controller {
|
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 = {
|
static values = {
|
||||||
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
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() {
|
generateSlug() {
|
||||||
const name = this.nameTarget.value
|
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
|
// Handle address changes with debounced geocoding
|
||||||
addressChanged() {
|
addressChanged() {
|
||||||
|
// Regenerate slug when address changes
|
||||||
|
this.generateSlug()
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (this.geocodeTimeout) {
|
if (this.geocodeTimeout) {
|
||||||
clearTimeout(this.geocodeTimeout)
|
clearTimeout(this.geocodeTimeout)
|
||||||
@@ -68,6 +121,11 @@ export default class extends Controller {
|
|||||||
}, this.geocodeDelayValue)
|
}, this.geocodeDelayValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle venue name changes to regenerate slug
|
||||||
|
venueNameChanged() {
|
||||||
|
this.generateSlug()
|
||||||
|
}
|
||||||
|
|
||||||
// Get user's current location and reverse geocode to address
|
// Get user's current location and reverse geocode to address
|
||||||
async getCurrentLocation() {
|
async getCurrentLocation() {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
@@ -516,14 +574,16 @@ export default class extends Controller {
|
|||||||
showLocationSuccess(message) {
|
showLocationSuccess(message) {
|
||||||
this.hideAllLocationMessages()
|
this.hideAllLocationMessages()
|
||||||
this.showMessage("location-success", message, "success")
|
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
|
// Show error message
|
||||||
showLocationError(message) {
|
showLocationError(message) {
|
||||||
this.hideAllLocationMessages()
|
this.hideAllLocationMessages()
|
||||||
this.showMessage("location-error", message, "error")
|
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)
|
// Show geocoding warning (less intrusive than error)
|
||||||
@@ -531,7 +591,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("geocoding-warning")
|
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."
|
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")
|
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
|
// Show info about approximate location
|
||||||
@@ -539,7 +600,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("approximate-location-info")
|
this.hideMessage("approximate-location-info")
|
||||||
const message = `Localisation approximative trouvée: ${foundLocation}`
|
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||||
this.showMessage("approximate-location-info", message, "info")
|
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
|
// Show geocoding success with location details
|
||||||
@@ -547,7 +609,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("geocoding-success")
|
this.hideMessage("geocoding-success")
|
||||||
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||||
this.showMessage("geocoding-success", message, "success")
|
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
|
// Show geocoding progress with strategy info
|
||||||
@@ -664,4 +727,87 @@ export default class extends Controller {
|
|||||||
this.hideMessage("geocoding-success")
|
this.hideMessage("geocoding-success")
|
||||||
this.hideMessage("geocoding-progress")
|
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('upload-preview')
|
||||||
|
const previewImg = document.getElementById('upload-preview-img')
|
||||||
|
|
||||||
|
if (previewContainer && previewImg) {
|
||||||
|
previewImg.src = e.target.result
|
||||||
|
previewContainer.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ class Event < ApplicationRecord
|
|||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
has_many :orders
|
has_many :orders
|
||||||
has_many :promotion_codes
|
has_many :promotion_codes
|
||||||
|
has_one_attached :image
|
||||||
|
|
||||||
|
# === Virtual attribute for backward compatibility with image URLs ===
|
||||||
|
attr_accessor :image_url
|
||||||
|
|
||||||
|
|
||||||
# === Callbacks ===
|
# === Callbacks ===
|
||||||
|
before_validation :generate_slug, if: :should_generate_slug?
|
||||||
before_validation :geocode_address, if: :should_geocode_address?
|
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
|
# Validations for Event attributes
|
||||||
# Basic information
|
# Basic information
|
||||||
@@ -32,7 +40,11 @@ class Event < ApplicationRecord
|
|||||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
|
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
|
||||||
validates :state, presence: true, inclusion: { in: states.keys }
|
validates :state, presence: true, inclusion: { in: states.keys }
|
||||||
validates :image, length: { maximum: 500 } # URL or path to image
|
|
||||||
|
# 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
|
# Venue information
|
||||||
validates :venue_name, presence: true, length: { maximum: 100 }
|
validates :venue_name, presence: true, length: { maximum: 100 }
|
||||||
@@ -58,6 +70,123 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# === Instance Methods ===
|
# === 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 ])
|
||||||
|
when :medium
|
||||||
|
image.variant(resize_to_limit: [ 800, 450 ])
|
||||||
|
when :small
|
||||||
|
image.variant(resize_to_limit: [ 400, 225 ])
|
||||||
|
else
|
||||||
|
# Fallback to URL-based image
|
||||||
|
image_url.presence
|
||||||
|
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
|
||||||
|
|
||||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||||
def geocoding_successful?
|
def geocoding_successful?
|
||||||
coordinates_look_valid?
|
coordinates_look_valid?
|
||||||
@@ -131,8 +260,63 @@ class Event < ApplicationRecord
|
|||||||
nil
|
nil
|
||||||
end
|
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
|
||||||
|
|
||||||
|
# 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
|
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
|
# Determine if we should perform server-side geocoding
|
||||||
def should_geocode_address?
|
def should_geocode_address?
|
||||||
# Don't geocode if address is blank
|
# Don't geocode if address is blank
|
||||||
|
|||||||
@@ -194,8 +194,14 @@ class Order < ApplicationRecord
|
|||||||
|
|
||||||
# Prevent duplicate promotion codes on the same order
|
# Prevent duplicate promotion codes on the same order
|
||||||
def no_duplicate_promotion_codes
|
def no_duplicate_promotion_codes
|
||||||
promotion_code_ids = promotion_codes.map(&:id)
|
return if promotion_codes.empty?
|
||||||
if promotion_code_ids.size != promotion_code_ids.uniq.size
|
|
||||||
|
# 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")
|
errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 %>
|
<%= 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="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">
|
<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.has_image? %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
<% @events.each do |event| %>
|
<% @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">
|
<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 %>
|
<%= link_to event_path(event.slug, event), class: "block" do %>
|
||||||
<% if event.image.present? %>
|
<% if event.has_image? %>
|
||||||
<div class="relative overflow-hidden aspect-[4/3]">
|
<div class="relative overflow-hidden aspect-[4/3]">
|
||||||
<img
|
<% if event.image.attached? %>
|
||||||
src="<%= event.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" %>
|
||||||
alt="<%= event.name %>"
|
<% else %>
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
<%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||||
>
|
<% end %>
|
||||||
<!-- Event featured badge -->
|
<!-- Event featured badge -->
|
||||||
<% if event.featured? %>
|
<% if event.featured? %>
|
||||||
<div class="absolute top-4 left-4">
|
<div class="absolute top-4 left-4">
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<!-- Event main wrapper -->
|
<!-- Event main wrapper -->
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
<!-- Event Header with Image -->
|
<!-- Event Header with Image -->
|
||||||
<% if @event.image.present? %>
|
<% if @event.has_image? %>
|
||||||
<div class="relative h-96">
|
<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", alt: @event.name %>
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
|
<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="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
@@ -88,11 +88,7 @@
|
|||||||
%>
|
%>
|
||||||
|
|
||||||
<% map_providers.each do |name, url| %>
|
<% map_providers.each do |name, url| %>
|
||||||
<%= link_to url, target: "_blank", rel: "noopener",
|
<%= 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" %>
|
||||||
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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,14 +127,7 @@
|
|||||||
|
|
||||||
<!-- Right Column: Ticket Selection -->
|
<!-- Right Column: Ticket Selection -->
|
||||||
<div class="lg:col-span-1">
|
<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: {
|
<%= 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| %>
|
||||||
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="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">
|
<div class="flex justify-center sm:justify-start mb-6">
|
||||||
|
|||||||
@@ -89,10 +89,8 @@
|
|||||||
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
|
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
|
||||||
<!-- Event Image -->
|
<!-- Event Image -->
|
||||||
<div class="relative overflow-hidden aspect-[4/3]">
|
<div class="relative overflow-hidden aspect-[4/3]">
|
||||||
<% if event.image.present? %>
|
<% if event.has_image? %>
|
||||||
<img src="<%= event.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" %>
|
||||||
alt="<%= event.name %>"
|
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
<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>
|
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>
|
||||||
|
|||||||
@@ -41,24 +41,9 @@
|
|||||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= 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.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>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -67,9 +52,127 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :image, "Image de couverture", 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>
|
<!-- 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">
|
||||||
|
<!-- 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">
|
||||||
|
<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 %>
|
||||||
|
|
||||||
|
<!-- 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.has_image? %>
|
||||||
|
<br>Laissez vide pour conserver l'image actuelle
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview container -->
|
||||||
|
<div id="upload-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 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,3 +307,26 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -41,17 +41,14 @@
|
|||||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= 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.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>
|
||||||
|
|
||||||
<div>
|
<!-- Hidden slug field (auto-generated) -->
|
||||||
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
|
||||||
<%= 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>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -60,9 +57,89 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :image, "Image de couverture", 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>
|
<!-- 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">
|
||||||
|
<!-- Current image preview (for edit mode) -->
|
||||||
|
<% if @event.has_image? %>
|
||||||
|
<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">
|
||||||
|
<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="upload-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">
|
||||||
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +170,7 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= 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" } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -163,3 +240,26 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -174,9 +174,13 @@
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
||||||
<!-- Event image -->
|
<!-- Event image -->
|
||||||
<% if @event.image.present? %>
|
<% if @event.has_image? %>
|
||||||
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
|
<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">
|
<% 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 %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -50,4 +50,11 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
|
# Configure Stripe for testing
|
||||||
|
config.stripe = {
|
||||||
|
publishable_key: "pk_test_test",
|
||||||
|
secret_key: "sk_test_test",
|
||||||
|
signing_secret: "whsec_test_test"
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# This migration comes from active_storage (originally 20170806125915)
|
||||||
|
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# Use Active Record's configured type for primary and foreign keys
|
||||||
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.string :content_type
|
||||||
|
t.text :metadata
|
||||||
|
t.string :service_name, null: false
|
||||||
|
t.bigint :byte_size, null: false
|
||||||
|
t.string :checksum
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :key ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||||
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
|
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def primary_and_foreign_key_types
|
||||||
|
config = Rails.configuration.generators
|
||||||
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
|
primary_key_type = setting || :primary_key
|
||||||
|
foreign_key_type = setting || :bigint
|
||||||
|
[ primary_key_type, foreign_key_type ]
|
||||||
|
end
|
||||||
|
end
|
||||||
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class AddImageToEvents < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
end
|
||||||
|
end
|
||||||
31
db/schema.rb
generated
31
db/schema.rb
generated
@@ -10,7 +10,35 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_29_222616) do
|
||||||
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "record_type", null: false
|
||||||
|
t.bigint "record_id", null: false
|
||||||
|
t.bigint "blob_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||||
|
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "key", null: false
|
||||||
|
t.string "filename", null: false
|
||||||
|
t.string "content_type"
|
||||||
|
t.text "metadata"
|
||||||
|
t.string "service_name", null: false
|
||||||
|
t.bigint "byte_size", null: false
|
||||||
|
t.string "checksum"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "blob_id", null: false
|
||||||
|
t.string "variation_digest", null: false
|
||||||
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
@@ -130,6 +158,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
|||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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", "orders"
|
||||||
add_foreign_key "order_promotion_codes", "promotion_codes"
|
add_foreign_key "order_promotion_codes", "promotion_codes"
|
||||||
add_foreign_key "promotion_codes", "events"
|
add_foreign_key "promotion_codes", "events"
|
||||||
|
|||||||
16
db/seeds.rb
16
db/seeds.rb
@@ -44,7 +44,7 @@ events_data = [
|
|||||||
start_time: 1.day.from_now,
|
start_time: 1.day.from_now,
|
||||||
end_time: 1.day.from_now + 6.hours,
|
end_time: 1.day.from_now + 6.hours,
|
||||||
featured: true,
|
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
|
user: users.first
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,7 +58,7 @@ events_data = [
|
|||||||
start_time: 3.days.from_now,
|
start_time: 3.days.from_now,
|
||||||
end_time: 3.days.from_now + 4.hours,
|
end_time: 3.days.from_now + 4.hours,
|
||||||
featured: true,
|
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
|
user: users.second
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ events_data = [
|
|||||||
start_time: 1.week.from_now,
|
start_time: 1.week.from_now,
|
||||||
end_time: 1.week.from_now + 8.hours,
|
end_time: 1.week.from_now + 8.hours,
|
||||||
featured: false,
|
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
|
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.start_time = 3.days.from_now
|
||||||
e.end_time = 3.days.from_now + 8.hours
|
e.end_time = 3.days.from_now + 8.hours
|
||||||
e.featured = false
|
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.user = promoter
|
||||||
e.allow_booking_during_event = true
|
e.allow_booking_during_event = true
|
||||||
end
|
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
|
# 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|
|
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"
|
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.start_time = Time.parse("2025-10-03 19:00:00")
|
||||||
e.end_time = Time.parse("2025-10-03 23:00:00")
|
e.end_time = Time.parse("2025-10-03 23:00:00")
|
||||||
e.featured = false
|
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.user = promoter
|
||||||
e.state = :published
|
e.state = :published
|
||||||
end
|
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.start_time = Time.parse("2025-10-03 23:00:00")
|
||||||
e.end_time = Time.parse("2025-10-04 05:00:00")
|
e.end_time = Time.parse("2025-10-04 05:00:00")
|
||||||
e.featured = false
|
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.user = promoter
|
||||||
e.state = :published
|
e.state = :published
|
||||||
end
|
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.start_time = Time.parse("2025-10-04 18:00:00")
|
||||||
e.end_time = Time.parse("2025-10-05 02:00:00")
|
e.end_time = Time.parse("2025-10-05 02:00:00")
|
||||||
e.featured = false
|
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.user = promoter
|
||||||
e.state = :published
|
e.state = :published
|
||||||
e.allow_booking_during_event = true
|
e.allow_booking_during_event = true
|
||||||
|
|||||||
74
debug_promotion_test.rb
Executable file
74
debug_promotion_test.rb
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
# Debug script to understand the test failure
|
||||||
|
require_relative './config/environment'
|
||||||
|
|
||||||
|
# Load test data
|
||||||
|
user = User.find_by(email: 'user1@example.com')
|
||||||
|
event = Event.find_by(name: 'Summer Concert')
|
||||||
|
|
||||||
|
puts "User: #{user.inspect}"
|
||||||
|
puts "Event: #{event.inspect}"
|
||||||
|
|
||||||
|
# Create a new order for the test
|
||||||
|
order = user.orders.create!(event: event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
|
||||||
|
puts "Order: #{order.inspect}"
|
||||||
|
|
||||||
|
# Create ticket type and ticket
|
||||||
|
ticket_type = TicketType.create!(
|
||||||
|
name: "Test Ticket Type",
|
||||||
|
description: "A valid description for the ticket type that is long enough",
|
||||||
|
price_cents: 2000,
|
||||||
|
quantity: 10,
|
||||||
|
sale_start_at: Time.current,
|
||||||
|
sale_end_at: Time.current + 1.day,
|
||||||
|
requires_id: false,
|
||||||
|
event: event
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = Ticket.create!(
|
||||||
|
order: order,
|
||||||
|
ticket_type: ticket_type,
|
||||||
|
status: "draft",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
price_cents: 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Ticket: #{ticket.inspect}"
|
||||||
|
puts "Ticket valid?: #{ticket.valid?}"
|
||||||
|
puts "Order tickets count: #{order.tickets.count}"
|
||||||
|
|
||||||
|
# Recalculate the order total
|
||||||
|
order.calculate_total!
|
||||||
|
puts "Order total: #{order.total_amount_cents}"
|
||||||
|
|
||||||
|
# Create a unique promotion code
|
||||||
|
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
|
||||||
|
puts "Creating promotion code with code: #{unique_code}"
|
||||||
|
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: unique_code,
|
||||||
|
discount_amount_cents: 500,
|
||||||
|
expires_at: 1.month.from_now,
|
||||||
|
active: true,
|
||||||
|
user: user,
|
||||||
|
event: event
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Promotion code: #{promotion_code.inspect}"
|
||||||
|
puts "Promotion code valid?: #{promotion_code.valid?}"
|
||||||
|
|
||||||
|
# Check if order already has promotion codes
|
||||||
|
puts "Order promotion codes before: #{order.promotion_codes.count}"
|
||||||
|
|
||||||
|
# Try to apply the promotion code
|
||||||
|
begin
|
||||||
|
order.promotion_codes << promotion_code
|
||||||
|
puts "Successfully added promotion code to order"
|
||||||
|
rescue => e
|
||||||
|
puts "Error adding promotion code: #{e.message}"
|
||||||
|
puts e.backtrace.first(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Order promotion codes after: #{order.promotion_codes.count}"
|
||||||
67
debug_test.rb
Executable file
67
debug_test.rb
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
# Debug script to understand the test failure
|
||||||
|
require_relative './config/environment'
|
||||||
|
|
||||||
|
# Load test data
|
||||||
|
user = User.find_by(email: 'user1@example.com')
|
||||||
|
event = Event.find_by(name: 'Summer Concert')
|
||||||
|
order = Order.find_by(status: 'draft')
|
||||||
|
|
||||||
|
puts "User: #{user.inspect}"
|
||||||
|
puts "Event: #{event.inspect}"
|
||||||
|
puts "Order: #{order.inspect}"
|
||||||
|
|
||||||
|
# Check if the user can manage events
|
||||||
|
puts "User can manage events: #{user.can_manage_events?}"
|
||||||
|
|
||||||
|
# Create a promotion code
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: "TESTDISCOUNT",
|
||||||
|
discount_amount_cents: 500,
|
||||||
|
expires_at: 1.month.from_now,
|
||||||
|
active: true,
|
||||||
|
user: user,
|
||||||
|
event: event
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Promotion code: #{promotion_code.inspect}"
|
||||||
|
puts "Promotion code valid?: #{promotion_code.valid_for_use?}"
|
||||||
|
|
||||||
|
# Try to create a ticket type and ticket
|
||||||
|
ticket_type = TicketType.create!(
|
||||||
|
name: "Test Ticket Type",
|
||||||
|
description: "A valid description for the ticket type that is long enough",
|
||||||
|
price_cents: 2000,
|
||||||
|
quantity: 10,
|
||||||
|
sale_start_at: Time.current,
|
||||||
|
sale_end_at: Time.current + 1.day,
|
||||||
|
requires_id: false,
|
||||||
|
event: event
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Ticket type: #{ticket_type.inspect}"
|
||||||
|
|
||||||
|
# Create ticket with all required fields
|
||||||
|
ticket = Ticket.create!(
|
||||||
|
order: order,
|
||||||
|
ticket_type: ticket_type,
|
||||||
|
status: "draft",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
price_cents: 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Ticket: #{ticket.inspect}"
|
||||||
|
puts "Ticket valid?: #{ticket.valid?}"
|
||||||
|
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
|
||||||
|
|
||||||
|
# Recalculate order total
|
||||||
|
order.calculate_total!
|
||||||
|
puts "Order total: #{order.total_amount_cents}"
|
||||||
|
|
||||||
|
# Test the promotion code application
|
||||||
|
puts "Applying promotion code..."
|
||||||
|
order.promotion_codes << promotion_code
|
||||||
|
order.calculate_total!
|
||||||
|
puts "Order total after promotion: #{order.total_amount_cents}"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
require "securerandom"
|
||||||
|
|
||||||
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||||
include Devise::Test::IntegrationHelpers
|
include Devise::Test::IntegrationHelpers
|
||||||
@@ -7,7 +8,8 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
|||||||
def setup
|
def setup
|
||||||
@user = users(:one)
|
@user = users(:one)
|
||||||
@event = events(:concert_event)
|
@event = events(:concert_event)
|
||||||
@order = orders(:draft_order)
|
# Create a new order for the test to ensure proper associations
|
||||||
|
@order = @user.orders.create!(event: @event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
|
||||||
sign_in @user
|
sign_in @user
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -25,19 +27,22 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
|||||||
event: @event
|
event: @event
|
||||||
)
|
)
|
||||||
|
|
||||||
Ticket.create!(
|
ticket = Ticket.create!(
|
||||||
order: @order,
|
order: @order,
|
||||||
ticket_type: ticket_type,
|
ticket_type: ticket_type,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
first_name: "John",
|
first_name: "John",
|
||||||
last_name: "Doe"
|
last_name: "Doe",
|
||||||
|
price_cents: 2000
|
||||||
)
|
)
|
||||||
|
|
||||||
# Recalculate the order total
|
# Recalculate the order total
|
||||||
@order.calculate_total!
|
@order.calculate_total!
|
||||||
|
|
||||||
|
# Use a unique code for each test run
|
||||||
|
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
|
||||||
promotion_code = PromotionCode.create(
|
promotion_code = PromotionCode.create(
|
||||||
code: "TESTDISCOUNT",
|
code: unique_code,
|
||||||
discount_amount_cents: 500, # €5.00
|
discount_amount_cents: 500, # €5.00
|
||||||
expires_at: 1.month.from_now,
|
expires_at: 1.month.from_now,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -45,7 +50,9 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
|||||||
event: @event
|
event: @event
|
||||||
)
|
)
|
||||||
|
|
||||||
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
|
get checkout_order_path(@order), params: { promotion_code: unique_code }
|
||||||
|
puts "Response status: #{response.status}"
|
||||||
|
puts "Response body: #{response.body}" if response.status != 200
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_not_nil flash.now[:notice]
|
assert_not_nil flash.now[:notice]
|
||||||
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[:notice]
|
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[:notice]
|
||||||
|
|||||||
1
test/fixtures/users.yml
vendored
1
test/fixtures/users.yml
vendored
@@ -5,6 +5,7 @@ one:
|
|||||||
encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %>
|
encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %>
|
||||||
last_name: Trump
|
last_name: Trump
|
||||||
first_name: Donald
|
first_name: Donald
|
||||||
|
is_professionnal: true
|
||||||
onboarding_completed: true
|
onboarding_completed: true
|
||||||
|
|
||||||
two:
|
two:
|
||||||
|
|||||||
@@ -317,4 +317,157 @@ class EventTest < ActiveSupport::TestCase
|
|||||||
# Check that ticket types were NOT duplicated
|
# Check that ticket types were NOT duplicated
|
||||||
assert_equal 0, duplicated_event.ticket_types.count
|
assert_equal 0, duplicated_event.ticket_types.count
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ module ActiveSupport
|
|||||||
|
|
||||||
# Add more helper methods to be used by all tests here...
|
# Add more helper methods to be used by all tests here...
|
||||||
|
|
||||||
|
# Mock Stripe for tests
|
||||||
|
setup do
|
||||||
|
# Mock Stripe checkout session creation
|
||||||
|
Stripe::Checkout::Session.stubs(:create).returns(
|
||||||
|
Struct.new(:id, :url).new("cs_test_session", "https://checkout.stripe.com/test")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to create users with completed onboarding by default for tests
|
# Helper to create users with completed onboarding by default for tests
|
||||||
def create_test_user(attributes = {})
|
def create_test_user(attributes = {})
|
||||||
User.create!({
|
User.create!({
|
||||||
|
|||||||
Reference in New Issue
Block a user