18 Commits

Author SHA1 Message Date
580b24bbed Merge pull request 'develop' (#3) from develop into main
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m43s
Reviewed-on: #3
2025-09-16 14:34:28 +00:00
a8d3bc12ae Merge pull request 'feat/free-ticket' (#2) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m56s
Reviewed-on: #2
2025-09-16 14:31:43 +00:00
kbe
b228d5a174 chore: Breadcrumb on ticket edit page 2025-09-16 16:22:09 +02:00
kbe
61ad8c64d4 Fix modal overlay issue and improve modal structure
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 08:44:21 +02:00
kbe
4e06f91acb Fix modal positioning and improve Stimulus controller
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 08:43:00 +02:00
kbe
28eddb22ab Refactor duplication feature to use Stimulus controller and fix modal issues
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:23:29 +02:00
kbe
a34eb7aa38 Add duplication options with JavaScript modal and conditional ticket type cloning
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:20:22 +02:00
kbe
aa68885b84 Add event duplication feature with ticket types cloning
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:17:24 +02:00
kbe
c1dde7914c refactor: remove 1€ fees on payment 2025-09-15 21:09:57 +02:00
kbe
dbb972e490 feat: Add countdown when order expire in less than 5 minutes 2025-09-15 21:09:19 +02:00
kbe
049e5505ef refactor(pricing): implement hybrid fee model (€0.50 + 1.5%) deducted from promoter payout
- Remove 1€ fixed fee from orders and Stripe invoices
- Add platform_fee_cents, promoter_payout_cents methods to Order model
- Update views to show clean ticket totals without added fees
- Update tests for new fee calculation logic
- Update pricing docs with implemented model
2025-09-15 20:07:51 +02:00
kbe
d6184b6c84 refactor: extract cart storage to dedicated API controller with dynamic frontend URLs
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m7s
- Added dedicated CartsController for session-based cart storage
- Refactored routes to use POST /api/v1/carts/store
- Updated ticket selection JS to use dynamic data attributes for URLs
- Fixed CSRF protection in API and checkout payment increment
- Made checkout button URLs dynamic via data attributes
- Updated tests for new cart storage endpoint
- Removed obsolete store_cart from EventsController
2025-09-15 19:52:01 +02:00
kbe
4cde466f9a Add comprehensive unit test coverage for controllers, models, and services
- Translate French comments to English in controllers and tests
- Fix test failures: route helpers, validations, MySQL transaction issues
- Add Timecop for time-dependent tests and update database config for isolation
2025-09-15 19:27:06 +02:00
kbe
ee43996a77 feat(book after start): prepare to rework event to allow ticket sell
after start
2025-09-15 19:07:19 +02:00
kbe
f0d32bf3f1 Improve mobile responsiveness 2025-09-15 19:06:15 +02:00
kbe
20f926cd7a Move `allow_booking_during_event` into base migration 2025-09-15 19:06:05 +02:00
kbe
d1ef962f74 feat: Improve mobile responsiveness for promoter event detail page
- Restructure header layout with separated title and action buttons
- Make all action buttons full-width on mobile (w-full sm:w-auto)
- Add responsive text sizing and proper truncation for long titles
- Improve status banners with flexible layouts for mobile
- Enhance content cards with responsive padding (p-4 sm:p-6)
- Add better text wrapping and overflow handling throughout
- Optimize sidebar with responsive font sizes and spacing
- Ensure consistent touch targets and button accessibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 18:23:45 +02:00
kbe
e84d9aad5b feat: Improve mobile responsiveness for promoter events page
- Add responsive header with stacked layout on mobile
- Implement dual layout system: table for desktop, cards for mobile
- Make all action buttons full-width and accessible on mobile
- Add proper spacing and touch targets for mobile UX
- Ensure "Créer un événement" button is full-width on mobile
- Improve empty state responsiveness

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 18:08:05 +02:00
45 changed files with 1254 additions and 374 deletions

View File

@@ -75,6 +75,7 @@ group :test do
gem "rails-controller-testing" gem "rails-controller-testing"
# For mocking and stubbing # For mocking and stubbing
gem "mocha" gem "mocha"
gem "timecop"
end end
gem "devise", "~> 4.9" gem "devise", "~> 4.9"

View File

@@ -381,6 +381,7 @@ GEM
thruster (0.1.15-aarch64-linux) thruster (0.1.15-aarch64-linux)
thruster (0.1.15-x86_64-darwin) thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux) thruster (0.1.15-x86_64-linux)
timecop (0.9.10)
timeout (0.4.3) timeout (0.4.3)
ttfunk (1.8.0) ttfunk (1.8.0)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
@@ -452,6 +453,7 @@ DEPENDENCIES
stimulus-rails stimulus-rails
stripe (~> 15.5) stripe (~> 15.5)
thruster thruster
timecop
turbo-rails turbo-rails
tzinfo-data tzinfo-data
web-console web-console

View File

@@ -0,0 +1,25 @@
module Api
module V1
class CartsController < ApiController
# Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :store ]
def store
event_id = params[:event_id]
@event = Event.find(event_id)
cart_data = params[:cart] || {}
session[:pending_cart] = cart_data
session[:event_id] = @event.id
render json: { status: "success", message: "Cart stored successfully" }
rescue ActiveRecord::RecordNotFound
render json: { status: "error", message: "Event not found" }, status: :not_found
rescue => e
error_message = e.message.present? ? e.message : "Unknown error"
Rails.logger.error "Error storing cart: #{error_message}"
render json: { status: "error", message: "Failed to store cart" }, status: 500
end
end
end
end

View File

@@ -1,5 +1,5 @@
# Contrôleur API pour la gestion des ressources d'événements # API Controller for managing event resources
# Fournit des points de terminaison RESTful pour les opérations CRUD sur le modèle Event # Provides RESTful endpoints for CRUD operations on the Event model
module Api module Api
module V1 module V1
@@ -7,27 +7,27 @@ module Api
# Skip API key authentication for store_cart action (used by frontend forms) # Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :store_cart ] skip_before_action :authenticate_api_key, only: [ :store_cart ]
# Charge l'évén avant certaines actions pour réduire les duplications # Loads the event before certain actions to reduce duplications
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ] before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
# GET /api/v1/events # GET /api/v1/events
# Récupère tous les événements triés par date de création (du plus récent au plus ancien) # 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, status: :ok
end end
# GET /api/v1/events/:id # GET /api/v1/events/:id
# Récupère un seul événement par son ID # Retrieves a single event by its ID
# Retourne 404 si l'événement n'est pas trouvé # Returns 404 if the event is not found
def show def show
render json: @event, status: :ok render json: @event, status: :ok
end end
# POST /api/v1/events # POST /api/v1/events
# Crée un nouvel événement avec les attributs fournis # Creates a new event with the provided attributes
# Retourne 201 Created en cas de succès avec les données de l'événement # Returns 201 Created on success with the event data
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec # Returns 422 Unprocessable Entity with error messages on failure
def create def create
@event = Event.new(event_params) @event = Event.new(event_params)
if @event.save if @event.save
@@ -38,9 +38,9 @@ module Api
end end
# PATCH/PUT /api/v1/events/:id # PATCH/PUT /api/v1/events/:id
# Met à jour un événement existant avec les attributs fournis # Updates an existing event with the provided attributes
# Retourne 200 OK avec les données mises à jour en cas de succès # Returns 200 OK with updated data on success
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec # 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, status: :ok
@@ -50,8 +50,8 @@ module Api
end end
# DELETE /api/v1/events/:id # DELETE /api/v1/events/:id
# Supprime définitivement un événement # Permanently deletes an event
# Retourne 204 No Content en cas de succès # Returns 204 No Content on success
def destroy def destroy
@event.destroy @event.destroy
head :no_content head :no_content
@@ -66,33 +66,37 @@ module Api
render json: { status: "success", message: "Cart stored successfully" } render json: { status: "success", message: "Cart stored successfully" }
rescue => e rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue" error_message = e.message.present? ? e.message : "Unknown error"
Rails.logger.error "Error storing cart: #{error_message}" Rails.logger.error "Error storing cart: #{error_message}"
render json: { status: "error", message: "Failed to store cart" }, status: 500 render json: { status: "error", message: "Failed to store cart" }, status: 500
end end
private private
# Trouve un événement par son ID ou retourne 404 Introuvable # Finds an event by its ID or returns 404 Not Found
# Utilisé comme before_action pour les actions show, update et destroy # Used as before_action for the show, update, and destroy actions
def set_event def set_event
@event = Event.find(params[:id]) @event = Event.find(params[:id])
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: { error: "Événement non trouvé" }, status: :not_found render json: { error: "Event not found" }, status: :not_found
end end
# Paramètres forts pour la création et la mise à jour des événements # Strong parameters for creating and updating events
# Liste blanche des attributs autorisés pour éviter les vulnérabilités de mass assignment # Whitelist of allowed attributes to avoid mass assignment vulnerabilities
def event_params def event_params
params.require(:event).permit( params.require(:event).permit(
:name, :name,
:slug,
:description, :description,
:state, :state,
:venue_name, :venue_name,
:venue_address, :venue_address,
:start_time,
:end_time,
:latitude, :latitude,
:longitude, :longitude,
:featured :featured,
:user_id
) )
end end
end end

View File

@@ -4,9 +4,6 @@
module Api module Api
module V1 module V1
class OrdersController < ApiController class OrdersController < ApiController
# Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :store_cart ]
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ] before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
before_action :set_event, only: [ :new, :create ] before_action :set_event, only: [ :new, :create ]

View File

@@ -2,7 +2,7 @@
# Provides authentication and common functionality for API controllers # Provides authentication and common functionality for API controllers
class ApiController < ApplicationController class ApiController < ApplicationController
# Disable CSRF protection for API requests (token-based authentication instead) # Disable CSRF protection for API requests (token-based authentication instead)
protect_from_forgery with: :null_session protect_from_forgery prepend: true
# Authenticate all API requests using API key # Authenticate all API requests using API key
# Must be called before any API action # Must be called before any API action

View File

@@ -38,8 +38,6 @@ class ApplicationController < ActionController::Base
# Skip for API endpoints # Skip for API endpoints
controller_name.start_with?("api/") || controller_name.start_with?("api/") ||
# Skip for health checks # Skip for health checks
controller_name == "rails/health" || controller_name == "rails/health"
# Skip for home page (when not signed in)
(controller_name == "pages" && action_name == "home")
end end
end end

View File

@@ -293,18 +293,7 @@ class OrdersController < ApplicationController
} }
end end
# Add service fee as a separate line item # No service fee added to customer; deducted from promoter payout
line_items << {
price_data: {
currency: "eur",
product_data: {
name: "Frais de service",
description: "Frais de traitement de la commande"
},
unit_amount: 100 # 1€ in cents
},
quantity: 1
}
Stripe::Checkout::Session.create( Stripe::Checkout::Session.create(
payment_method_types: [ "card" ], payment_method_types: [ "card" ],

View File

@@ -5,7 +5,7 @@
class Promoter::EventsController < ApplicationController class Promoter::EventsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :ensure_can_manage_events! before_action :ensure_can_manage_events!
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out ] before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
# Display all events for the current promoter # Display all events for the current promoter
def index def index
@@ -93,6 +93,18 @@ class Promoter::EventsController < ApplicationController
end end
end end
# Duplicate an event and all its ticket types
def duplicate
clone_ticket_types = params[:clone_ticket_types] == "true"
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
if @new_event
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
else
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
end
end
private private
def ensure_can_manage_events! def ensure_can_manage_events!

View File

@@ -0,0 +1,71 @@
import { Controller } from "@hotwired/stimulus"
// Countdown controller for displaying remaining time until order expiration
export default class extends Controller {
static values = {
expiresAt: String, // ISO timestamp when the order expires
orderId: Number // Order ID for identification
}
connect() {
// Parse the expiration timestamp
this.expirationTime = new Date(this.expiresAtValue).getTime()
// Find the countdown element
this.countdownElement = this.element.querySelector('.countdown-timer')
if (this.countdownElement && !isNaN(this.expirationTime)) {
// Start the countdown
this.updateCountdown()
this.timer = setInterval(() => this.updateCountdown(), 1000)
}
}
disconnect() {
// Clean up the interval when the controller disconnects
if (this.timer) {
clearInterval(this.timer)
}
}
updateCountdown() {
const now = new Date().getTime()
const distance = this.expirationTime - now
// If the countdown is finished
if (distance < 0) {
this.countdownElement.innerHTML = "EXPIRÉ"
this.countdownElement.classList.add("text-red-600", "font-bold")
this.countdownElement.classList.remove("text-orange-600")
// Add a more urgent visual indicator
this.element.classList.add("bg-red-50", "border-red-200")
this.element.classList.remove("bg-orange-50", "border-orange-200")
// Stop the timer
if (this.timer) {
clearInterval(this.timer)
}
return
}
// Calculate time components
const seconds = Math.floor(distance / 1000)
// Display the result
this.countdownElement.innerHTML = `${seconds} secondes`
// Add urgency styling when time is running low
if (seconds < 60) {
this.countdownElement.classList.add("text-red-600", "font-bold")
this.countdownElement.classList.remove("text-orange-600")
// Add background warning for extra urgency
this.element.classList.add("bg-red-50", "border-red-200")
this.element.classList.remove("bg-orange-50", "border-orange-200")
} else if (seconds < 300) { // Less than 5 minutes
this.countdownElement.classList.add("text-orange-600", "font-bold")
this.element.classList.add("bg-orange-50", "border-orange-200")
}
}
}

View File

@@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["cloneTicketTypes"]
static values = {
duplicateUrl: String
}
connect() {
// Get modal element from the document
this.modalElement = document.querySelector('[data-event-duplication-target="modal"]')
}
open() {
this.modalElement.classList.remove('hidden')
document.body.classList.add('overflow-hidden')
}
close() {
this.modalElement.classList.add('hidden')
document.body.classList.remove('overflow-hidden')
}
duplicate() {
const cloneTicketTypes = this.cloneTicketTypesTarget.checked
// Create form data
const formData = new FormData()
formData.append('clone_ticket_types', cloneTicketTypes)
formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'))
// Send request to duplicate endpoint
fetch(this.duplicateUrlValue, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url
} else {
return response.json()
}
})
.catch(error => {
console.error('Error:', error)
alert('Erreur lors de la duplication de l\'événement.')
this.close()
})
}
}

View File

@@ -24,3 +24,9 @@ application.register("qr-code", QrCodeController);
import EventFormController from "./event_form_controller"; import EventFormController from "./event_form_controller";
application.register("event-form", EventFormController); application.register("event-form", EventFormController);
import CountdownController from "./countdown_controller";
application.register("countdown", CountdownController);
import EventDuplicationController from "./event_duplication_controller";
application.register("event-duplication", EventDuplicationController);

View File

@@ -10,7 +10,7 @@ export default class extends Controller {
"checkoutButton", "checkoutButton",
"form", "form",
]; ];
static values = { eventSlug: String, eventId: String }; static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
// Initialize the controller and update the cart summary // Initialize the controller and update the cart summary
connect() { connect() {
@@ -117,9 +117,9 @@ export default class extends Controller {
// Store cart data in session // Store cart data in session
await this.storeCartInSession(cartData); await this.storeCartInSession(cartData);
// Redirect to event-scoped orders/new page // Redirect to event-scoped orders/new page
const OrderNewUrl = `/orders/new/events/${this.eventSlugValue}.${this.eventIdValue}`; const orderNewUrl = this.orderNewUrlValue;
window.location.href = OrderNewUrl; window.location.href = orderNewUrl;
} catch (error) { } catch (error) {
console.error("Error storing cart:", error); console.error("Error storing cart:", error);
alert("Une erreur est survenue. Veuillez réessayer."); alert("Une erreur est survenue. Veuillez réessayer.");
@@ -145,7 +145,7 @@ export default class extends Controller {
// Store cart data in session via AJAX // Store cart data in session via AJAX
async storeCartInSession(cartData) { async storeCartInSession(cartData) {
const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart`; const storeCartUrl = this.storeCartUrlValue;
const response = await fetch(storeCartUrl, { const response = await fetch(storeCartUrl, {
method: "POST", method: "POST",
@@ -155,7 +155,7 @@ export default class extends Controller {
.querySelector('meta[name="csrf-token"]') .querySelector('meta[name="csrf-token"]')
.getAttribute("content"), .getAttribute("content"),
}, },
body: JSON.stringify({ cart: cartData }), body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -96,6 +96,41 @@ class Event < ApplicationRecord
Time.current >= end_time Time.current >= end_time
end end
# Check if booking is allowed during the event
# This is a simple attribute reader that defaults to false if nil
def allow_booking_during_event?
!!allow_booking_during_event
end
# Duplicate an event with all its ticket types
def duplicate(clone_ticket_types: true)
# Duplicate the event
new_event = self.dup
new_event.name = "Copie de #{name}"
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
new_event.state = :draft
new_event.created_at = Time.current
new_event.updated_at = Time.current
Event.transaction do
if new_event.save
# Duplicate all ticket types if requested
if clone_ticket_types
ticket_types.each do |ticket_type|
new_ticket_type = ticket_type.dup
new_ticket_type.event = new_event
new_ticket_type.save!
end
end
new_event
else
nil
end
end
rescue
nil
end
private private
# Determine if we should perform server-side geocoding # Determine if we should perform server-side geocoding

View File

@@ -88,11 +88,32 @@ class Order < ApplicationRecord
end end
end end
# Calculate total from tickets plus 1€ service fee # Calculate total from ticket prices only (platform fee deducted from promoter payout)
def calculate_total! def calculate_total!
ticket_total = tickets.sum(:price_cents) ticket_total = tickets.sum(:price_cents)
fee_cents = 100 # 1€ in cents update!(total_amount_cents: ticket_total)
update!(total_amount_cents: ticket_total + fee_cents) end
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
def platform_fee_cents
tickets.sum do |ticket|
fixed_fee = 50 # €0.50 in cents
percentage_fee = (ticket.price_cents * 0.015).to_i
fixed_fee + percentage_fee
end
end
# Promoter payout amount after platform fee deduction
def promoter_payout_cents
total_amount_cents - platform_fee_cents
end
def platform_fee_euros
platform_fee_cents / 100.0
end
def promoter_payout_euros
promoter_payout_cents / 100.0
end end
# Create Stripe invoice for accounting records # Create Stripe invoice for accounting records

View File

@@ -1,7 +1,7 @@
class TicketType < ApplicationRecord class TicketType < ApplicationRecord
# Associations # Associations
belongs_to :event belongs_to :event
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold
# Validations # Validations
validates :name, presence: true, length: { minimum: 3, maximum: 50 } validates :name, presence: true, length: { minimum: 3, maximum: 50 }
@@ -80,6 +80,10 @@ class TicketType < ApplicationRecord
def sale_times_within_event_period def sale_times_within_event_period
return unless event&.start_time && sale_end_at return unless event&.start_time && sale_end_at
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
# Only enforce this restriction if booking during event is not allowed
unless event.allow_booking_during_event?
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
end
end end
end end

View File

@@ -166,19 +166,7 @@ class StripeInvoiceService
}) })
end end
# Add service fee line item # No service fee on customer invoice; platform fee deducted from promoter payout
service_fee_cents = 100 # 1€ service fee
Stripe::InvoiceItem.create({
customer: customer.id,
invoice: invoice.id,
amount: service_fee_cents,
currency: "eur",
description: "Frais de service - Frais de traitement de la commande",
metadata: {
item_type: "service_fee",
amount_cents: service_fee_cents
}
})
end end
def build_line_item_description(ticket_type, tickets) def build_line_item_description(ticket_type, tickets)

View File

@@ -6,26 +6,41 @@
<%# ] %> <%# ] %>
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="inline-flex items-center gap-2 bg-white px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-8" aria-label="Breadcrumb"> <nav class="w-full bg-white px-3 sm:px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-6 sm:mb-8 overflow-hidden" aria-label="Breadcrumb">
<% crumbs.each_with_index do |crumb, index| %> <div class="flex items-center gap-1 sm:gap-2 min-w-0">
<% if crumb[:path].present? %> <% crumbs.each_with_index do |crumb, index| %>
<%# Crumb with link %> <% if crumb[:path].present? %>
<%= link_to crumb[:path], class: "inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200" do %> <%# Crumb with link %>
<% if index == 0 %> <%= link_to crumb[:path], class: "inline-flex items-center text-xs sm:text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200 flex-shrink-0" do %>
<i data-lucide="home" class="w-4 h-4 mr-2"></i> <% if index == 0 %>
<i data-lucide="home" class="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0"></i>
<% end %>
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
<%= crumb[:name] %>
</span>
<% end %>
<% else %>
<%# Current page (no link) %>
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
<%= crumb[:name] %>
</span>
<% end %>
<%# Separator (except for the last item) %>
<% if index < crumbs.length - 1 %>
<% if index == 0 || index >= crumbs.length - 2 %>
<i data-lucide="chevron-right" class="w-3 h-3 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0"></i>
<% else %>
<span class="hidden sm:inline">
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400 flex-shrink-0"></i>
</span>
<% end %> <% end %>
<%= crumb[:name] %>
<% end %> <% end %>
<% else %>
<%# Current page (no link) %>
<span class="text-sm font-medium text-primary-600 truncate max-w-xs" aria-current="page">
<%= crumb[:name] %>
</span>
<% end %> <% end %>
<%# Separator (except for the last item) %> <%# Show ellipsis on mobile when there are more than 3 items %>
<% if index < crumbs.length - 1 %> <% if crumbs.length > 3 %>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i> <span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
<% end %> <% end %>
<% end %> </div>
</nav> </nav>

View File

@@ -107,10 +107,7 @@
</div> </div>
<h3 class="text-2xl font-bold text-gray-900 mb-4">Aucun événement disponible</h3> <h3 class="text-2xl font-bold text-gray-900 mb-4">Aucun événement disponible</h3>
<p class="text-gray-600 mb-8 max-w-md mx-auto">Il n'y a aucun événement à venir pour le moment. Revenez bientôt pour découvrir de nouvelles sorties!</p> <p class="text-gray-600 mb-8 max-w-md mx-auto">Il n'y a aucun événement à venir pour le moment. Revenez bientôt pour découvrir de nouvelles sorties!</p>
<%= link_to "Retour à l'accueil", root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %> <%= link_to "<i data-lucide=\"home\" class=\"w-4 h-4 mr-2\"></i> Retour à l'accueil".html_safe, root_path, class: "inline-flex items-center bg-purple-600 text-white px-6 py-3 rounded-full font-semibold hover:bg-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" %>
<i data-lucide="home" class="w-4 h-4 mr-2"></i>
Retour à l'accueil
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -135,8 +135,12 @@
controller: "ticket-selection", controller: "ticket-selection",
ticket_selection_target: "form", ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug, ticket_selection_event_slug_value: @event.slug,
ticket_selection_event_id_value: @event.id ticket_selection_event_id_value: @event.id,
} do |form| %> 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,
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">

View File

@@ -100,16 +100,8 @@
</div> </div>
<!-- Order Total --> <!-- Order Total -->
<div class="border-t border-gray-200 pt-6"> <div class=" pt-12">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-gray-600">Sous-total</span>
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Frais de service</span>
<span class="text-gray-900">1.00€</span>
</div>
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total</span> <span class="font-medium text-gray-900">Total</span>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span> <span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
@@ -139,10 +131,13 @@
</div> </div>
</div> </div>
<button <button
id="checkout-button" id="checkout-button"
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl" data-order-id="<%= @order.id %>"
> data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt"
data-session-id="<%= @checkout_session.id if @checkout_session.present? %>"
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i> <i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
Payer <%= @order.total_amount_euros %>€ Payer <%= @order.total_amount_euros %>€
@@ -199,13 +194,16 @@
try { try {
// Increment payment attempt counter // Increment payment attempt counter
console.log('Incrementing payment attempt for order:', '<%= @order.id %>'); const orderId = checkoutButton.dataset.orderId;
const response = await fetch('/api/v1/orders/<%= @order.id %>/increment_payment_attempt', { const incrementUrl = checkoutButton.dataset.incrementUrl;
method: 'PATCH', console.log('Incrementing payment attempt for order:', orderId);
headers: { const response = await fetch(incrementUrl, {
'Content-Type': 'application/json' method: 'PATCH',
} headers: {
}); 'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
}
});
if (!response.ok) { if (!response.ok) {
console.error('Payment attempt increment failed:', response.status, response.statusText); console.error('Payment attempt increment failed:', response.status, response.statusText);
@@ -226,10 +224,11 @@
`; `;
// Redirect to Stripe // Redirect to Stripe
console.log('Redirecting to Stripe with session ID:', '<%= @checkout_session&.id %>'); const sessionId = checkoutButton.dataset.sessionId;
const stripeResult = await stripe.redirectToCheckout({ console.log('Redirecting to Stripe with session ID:', sessionId);
sessionId: '<%= @checkout_session.id %>' const stripeResult = await stripe.redirectToCheckout({
}); sessionId: sessionId
});
if (stripeResult.error) { if (stripeResult.error) {
throw new Error(stripeResult.error.message); throw new Error(stripeResult.error.message);

View File

@@ -119,12 +119,6 @@
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right"><%= "%.2f" % (tickets.count * ticket_type.price_cents / 100.0) %>€</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right"><%= "%.2f" % (tickets.count * ticket_type.price_cents / 100.0) %>€</td>
</tr> </tr>
<% end %> <% end %>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">Frais de service</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1.00€</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right">1.00€</td>
</tr>
</tbody> </tbody>
<tfoot class="bg-gray-50"> <tfoot class="bg-gray-50">
<tr> <tr>

View File

@@ -1,5 +1,5 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"> <div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<%= render 'components/breadcrumb', crumbs: [ <%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path }, { name: 'Accueil', path: root_path },
{ name: 'Événements', path: events_path }, { name: 'Événements', path: events_path },

View File

@@ -135,10 +135,6 @@
<span class="text-gray-600">Sous-total</span> <span class="text-gray-600">Sous-total</span>
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span> <span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
</div> </div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Frais de service</span>
<span class="text-gray-900">1.00€</span>
</div>
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total à payer</span> <span class="font-medium text-gray-900">Total à payer</span>
<span class="font-bold text-2xl text-red-600"> <span class="font-bold text-2xl text-red-600">

View File

@@ -124,16 +124,8 @@
</div> </div>
<!-- Total --> <!-- Total -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="mt-6">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-gray-600">Sous-total</span>
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Frais de service</span>
<span class="text-gray-900">1.00€</span>
</div>
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total payé</span> <span class="font-medium text-gray-900">Total payé</span>
<span class="font-bold text-2xl text-green-600"> <span class="font-bold text-2xl text-green-600">
@@ -205,7 +197,7 @@
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg> </svg>
Retour au Tableau de Bord Retour au tableau de bord
</div> </div>
<% end %> <% end %>
<%= link_to event_path(@order.event.slug, @order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %> <%= link_to event_path(@order.event.slug, @order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %>
@@ -213,7 +205,7 @@
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
Voir l'Événement Complet Voir la fiche de l'événement
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -96,22 +96,12 @@
<!-- Total --> <!-- Total -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="border-t border-gray-200 pt-6 mt-6">
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-gray-600">Sous-total</span>
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Frais de service</span>
<span class="text-gray-900">1.00€</span>
</div>
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span> <span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span>
<span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>"> <span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
<%= @order.total_amount_euros %>€ <%= @order.total_amount_euros %>€
</span> </span>
</div> </div>
</div>
</div> </div>
<!-- View Invoice --> <!-- View Invoice -->

View File

@@ -129,7 +129,10 @@
<% @promoter_events.each do |event| %> <% @promoter_events.each do |event| %>
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow"> <div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-2"> <div class="flex items-start justify-between mb-2">
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4> <%= link_to promoter_event_path(event) do %>
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
<% end %>
<span class="text-xs px-2 py-1 rounded-full <%= event.state == 'published' ? 'bg-green-100 text-green-800' : event.state == 'draft' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' %>"> <span class="text-xs px-2 py-1 rounded-full <%= event.state == 'published' ? 'bg-green-100 text-green-800' : event.state == 'draft' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' %>">
<%= event.state.humanize %> <%= event.state.humanize %>
</span> </span>
@@ -246,7 +249,12 @@
Tentatives: <%= order.payment_attempts %>/3 Tentatives: <%= order.payment_attempts %>/3
</div> </div>
<% if order.expiring_soon? %> <% if order.expiring_soon? %>
<span class="text-orange-600 font-medium">⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %></span> <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-50 border border-orange-200 text-orange-600"
data-controller="countdown"
data-countdown-expires-at-value="<%= order.expires_at.iso8601 %>"
data-countdown-order-id-value="<%= order.id %>">
⚠️ Expire dans <span class="countdown-timer ml-1 font-bold"></span>
</span>
<% else %> <% else %>
<span class="text-gray-500">Expire dans <%= time_ago_in_words(order.expires_at) %></span> <span class="text-gray-500">Expire dans <%= time_ago_in_words(order.expires_at) %></span>
<% end %> <% end %>
@@ -385,6 +393,7 @@
<% @upcoming_preview_events.each do |event| %> <% @upcoming_preview_events.each do |event| %>
<div class="bg-gray-50 rounded-xl p-4 hover:shadow-md transition-shadow"> <div class="bg-gray-50 rounded-xl p-4 hover:shadow-md transition-shadow">
<h4 class="font-medium text-gray-900 mb-2 text-base"><%= event.name %></h4> <h4 class="font-medium text-gray-900 mb-2 text-base"><%= event.name %></h4>
<div class="text-sm text-gray-600 space-y-1"> <div class="text-sm text-gray-600 space-y-1">
<div class="flex items-center"> <div class="flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-2"></i> <i data-lucide="calendar" class="w-4 h-4 mr-2"></i>

View File

@@ -9,19 +9,22 @@
{ name: 'Mes événements' } { name: 'Mes événements' }
] %> ] %>
<div class="mb-8 flex items-center justify-between"> <div class="mb-8">
<div> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Mes événements</h1> <div>
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p>
</div>
<%= link_to new_promoter_event_path, class: "inline-flex items-center justify-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200 w-full sm:w-auto" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer un événement
<% end %>
</div> </div>
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer un événement
<% end %>
</div> </div>
<% if @events.any? %> <% if @events.any? %>
<div class="bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden"> <!-- Desktop Table View -->
<div class="hidden lg:block bg-white rounded-2xl shadow-xl border border-gray-200 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200"> <thead class="bg-gray-50 border-b border-gray-200">
@@ -30,7 +33,7 @@
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th> <th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th> <th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lieu</th>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48 lg:w-auto">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
@@ -94,27 +97,32 @@
<div><%= event.venue_name %></div> <div><%= event.venue_name %></div>
<div class="text-xs text-gray-400 truncate max-w-xs"><%= event.venue_address %></div> <div class="text-xs text-gray-400 truncate max-w-xs"><%= event.venue_address %></div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4 w-48 lg:w-auto">
<div class="flex items-center space-x-2"> <div class="flex flex-col lg:flex-row items-stretch lg:items-center space-y-2 lg:space-y-0 lg:space-x-2 min-w-0">
<%= link_to promoter_event_path(event), class: "text-gray-400 hover:text-gray-600 transition-colors", title: "Voir" do %> <%= link_to promoter_event_path(event), class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-gray-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Voir" do %>
<i data-lucide="eye" class="w-4 h-4"></i> <i data-lucide="eye" class="w-4 h-4 lg:mr-0 mr-2"></i>
<span class="lg:hidden">Voir</span>
<% end %> <% end %>
<%= link_to edit_promoter_event_path(event), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %> <%= link_to edit_promoter_event_path(event), class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-blue-100 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-blue-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Modifier" do %>
<i data-lucide="edit" class="w-4 h-4"></i> <i data-lucide="edit" class="w-4 h-4 lg:mr-0 mr-2"></i>
<span class="lg:hidden">Modifier</span>
<% end %> <% end %>
<% if event.draft? %> <% if event.draft? %>
<%= button_to publish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-green-600 transition-colors", title: "Publier" do %> <%= button_to publish_promoter_event_path(event), method: :patch, class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-green-100 text-green-700 text-sm font-medium rounded-lg hover:bg-green-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-green-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Publier" do %>
<i data-lucide="upload" class="w-4 h-4"></i> <i data-lucide="upload" class="w-4 h-4 lg:mr-0 mr-2"></i>
<span class="lg:hidden">Publier</span>
<% end %> <% end %>
<% elsif event.published? %> <% elsif event.published? %>
<%= button_to unpublish_promoter_event_path(event), method: :patch, class: "text-gray-400 hover:text-yellow-600 transition-colors", title: "Dépublier" do %> <%= button_to unpublish_promoter_event_path(event), method: :patch, class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-yellow-100 text-yellow-700 text-sm font-medium rounded-lg hover:bg-yellow-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-yellow-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Dépublier" do %>
<i data-lucide="download" class="w-4 h-4"></i> <i data-lucide="download" class="w-4 h-4 lg:mr-0 mr-2"></i>
<span class="lg:hidden">Dépublier</span>
<% end %> <% end %>
<% end %> <% end %>
<%= button_to promoter_event_path(event), method: :delete, <%= button_to promoter_event_path(event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ?" }, data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %> class: "flex-1 lg:flex-initial inline-flex items-center justify-center px-3 py-2 bg-red-100 text-red-700 text-sm font-medium rounded-lg hover:bg-red-200 transition-colors lg:bg-transparent lg:text-gray-400 lg:hover:text-red-600 lg:p-0 lg:rounded-none whitespace-nowrap", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-4 h-4"></i> <i data-lucide="trash-2" class="w-4 h-4 lg:mr-0 mr-2"></i>
<span class="lg:hidden">Supprimer</span>
<% end %> <% end %>
</div> </div>
</td> </td>
@@ -125,17 +133,108 @@
</div> </div>
</div> </div>
<!-- Mobile Card View -->
<div class="lg:hidden space-y-4">
<% @events.each do |event| %>
<div class="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
<div class="p-4">
<!-- Event Header -->
<div class="flex items-start space-x-4 mb-4">
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center flex-shrink-0">
<i data-lucide="calendar" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 mb-1">
<%= link_to event.name, promoter_event_path(event), class: "hover:text-purple-600 transition-colors" %>
</h3>
<p class="text-sm text-gray-500 line-clamp-2">
<%= event.description.truncate(100) %>
</p>
</div>
</div>
<!-- Status -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<% case event.state %>
<% when "draft" %>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="edit-3" class="w-3 h-3 mr-1"></i>
Brouillon
</span>
<% when "published" %>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="eye" class="w-3 h-3 mr-1"></i>
Publié
</span>
<% when "canceled" %>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
Annulé
</span>
<% when "sold_out" %>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
Complet
</span>
<% end %>
<% if event.featured? %>
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
<i data-lucide="star" class="w-3 h-3 mr-1"></i>
À la une
</span>
<% end %>
</div>
<!-- Details Grid -->
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
<div>
<dt class="font-medium text-gray-500 mb-1">Date</dt>
<dd class="text-gray-900">
<% if event.start_time %>
<div><%= event.start_time.strftime("%d/%m/%Y") %></div>
<div class="text-xs text-gray-500"><%= event.start_time.strftime("%H:%M") %></div>
<% else %>
<span class="text-gray-400">Non définie</span>
<% end %>
</dd>
</div>
<div>
<dt class="font-medium text-gray-500 mb-1">Lieu</dt>
<dd class="text-gray-900">
<div class="truncate"><%= event.venue_name %></div>
<div class="text-xs text-gray-500 truncate"><%= event.venue_address %></div>
</dd>
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2 pt-4 border-t border-gray-100">
<%= link_to promoter_event_path(event), class: "w-full inline-flex items-center justify-center px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors" do %>
<i data-lucide="eye" class="w-4 h-4 mr-2"></i>
Voir
<% end %>
<%= link_to edit_promoter_event_path(event), class: "w-full inline-flex items-center justify-center px-3 py-2 bg-blue-100 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-200 transition-colors" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<div class="mt-6"> <div class="mt-6">
<%= paginate @events if respond_to?(:paginate) %> <%= paginate @events if respond_to?(:paginate) %>
</div> </div>
<% else %> <% else %>
<div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-12 text-center"> <div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-6 sm:p-12 text-center">
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6"> <div class="mx-auto h-20 w-20 sm:h-24 sm:w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
<i data-lucide="calendar-plus" class="w-12 h-12 text-gray-400"></i> <i data-lucide="calendar-plus" class="w-10 h-10 sm:w-12 sm:h-12 text-gray-400"></i>
</div> </div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun événement</h3> <h3 class="text-lg sm:text-xl font-semibold text-gray-900 mb-2">Aucun événement</h3>
<p class="text-gray-500 mb-6">Vous n'avez pas encore créé d'événement. Commencez dès maintenant !</p> <p class="text-gray-500 mb-6 px-4">Vous n'avez pas encore créé d'événement. Commencez dès maintenant !</p>
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %> <%= link_to new_promoter_event_path, class: "inline-flex items-center justify-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200 w-full sm:w-auto" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i> <i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer mon premier événement Créer mon premier événement
<% end %> <% end %>

View File

@@ -107,22 +107,6 @@
<div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div> <div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
</div> </div>
</div> </div>
<!-- Location Actions -->
<div class="flex flex-wrap gap-2">
<button type="button" data-action="click->event-form#getCurrentLocation" data-event-form-target="getCurrentLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<span data-event-form-target="getCurrentLocationIcon">
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
</span>
<span data-event-form-target="getCurrentLocationText">Ma position</span>
</button>
<button type="button" data-action="click->event-form#previewLocation" data-event-form-target="previewLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<span data-event-form-target="previewLocationIcon">
<i data-lucide="map" class="w-3 h-3 mr-1"></i>
</span>
<span data-event-form-target="previewLocationText">Prévisualiser</span>
</button>
</div>
</div> </div>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">
@@ -144,11 +128,24 @@
<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">Options</h3> <h3 class="text-lg font-semibold text-gray-900 mb-6">Options</h3>
<div class="flex items-center"> <div class="space-y-4">
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %> <div class="flex items-center">
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %> <%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
</div>
<p class="text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
<div class="flex items-start">
<%= form.check_box :allow_booking_during_event, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
<div class="ml-2">
<%= form.label :allow_booking_during_event, "Autoriser la réservation pendant l'événement", class: "text-sm text-gray-700 font-medium" %>
<p class="text-sm text-gray-500 mt-1">
Si activé, les participants pourront acheter des billets même après le début de l'événement.
Si désactivé, la vente de billets s'arrêtera automatiquement à l'heure de début.
</p>
</div>
</div>
</div> </div>
<p class="mt-2 text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
</div> </div>
<!-- Actions --> <!-- Actions -->
@@ -166,4 +163,3 @@
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<% content_for(:title, @event.name) %> <% content_for(:title, @event.name) %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div data-controller="event-duplication" data-event-duplication-duplicate-url-value="<%= duplicate_promoter_event_path(@event) %>">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [ <%= render 'components/breadcrumb', crumbs: [
@@ -12,57 +13,63 @@
<!-- Header with actions --> <!-- Header with actions -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between"> <!-- Back button and title -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4 mb-6">
<%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors" do %> <%= link_to promoter_events_path, class: "text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i> <i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %> <% end %>
<div> <div class="min-w-0 flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @event.name %></h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2 truncate"><%= @event.name %></h1>
<div class="flex items-center space-x-4 text-sm text-gray-500"> <div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-gray-500">
<span class="flex items-center"> <span class="flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i> <i data-lucide="calendar" class="w-4 h-4 mr-1 flex-shrink-0"></i>
<%= @event.start_time&.strftime("%d/%m/%Y à %H:%M") || "Date non définie" %> <span class="truncate"><%= @event.start_time&.strftime("%d/%m/%Y à %H:%M") || "Date non définie" %></span>
</span> </span>
<span class="flex items-center"> <span class="flex items-center">
<i data-lucide="map-pin" class="w-4 h-4 mr-1"></i> <i data-lucide="map-pin" class="w-4 h-4 mr-1 flex-shrink-0"></i>
<%= @event.venue_name %> <span class="truncate"><%= @event.venue_name %></span>
</span> </span>
</div>
</div> </div>
</div> </div>
<div class="flex items-center space-x-3"> </div>
<%= link_to edit_promoter_event_path(@event), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier
<% end %>
<% if @event.draft? %> <!-- Action buttons -->
<% if @event.ticket_types.blank? %> <div class="flex flex-col sm:flex-row gap-3">
<%= button_to publish_promoter_event_path(@event), method: :patch, disabled: true, class: "inline-flex items-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Vous devez créer au moins un type de billet avant de publier" do %> <%= link_to edit_promoter_event_path(@event), class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="upload" class="w-4 h-4 mr-2"></i> <i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Publier Modifier
<% end %> <% end %>
<% else %>
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
Publier
<% end %>
<% end %>
<% elsif @event.published? %>
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
Dépublier
<% end %>
<% end %>
<% if @event.published? %> <button type="button" data-action="click->event-duplication#open" class="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %> <i data-lucide="copy" class="w-4 h-4 mr-2"></i>
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i> Dupliquer
Annuler </button>
<% if @event.draft? %>
<% if @event.ticket_types.blank? %>
<%= button_to publish_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Vous devez créer au moins un type de billet avant de publier" do %>
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
Publier
<% end %>
<% else %>
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
Publier
<% end %> <% end %>
<% end %> <% end %>
</div> <% elsif @event.published? %>
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
Dépublier
<% end %>
<% end %>
<% if @event.published? %>
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<% end %>
</div> </div>
</div> </div>
@@ -82,14 +89,14 @@
<% if @event.ticket_types.blank? %> <% if @event.ticket_types.blank? %>
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mt-4"> <div class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mt-4">
<div class="flex items-center"> <div class="flex flex-col sm:flex-row sm:items-center gap-3">
<i data-lucide="alert-triangle" class="w-5 h-5 text-amber-400 mr-3"></i> <i data-lucide="alert-triangle" class="w-5 h-5 text-amber-400 flex-shrink-0"></i>
<div> <div class="flex-1 min-w-0">
<h3 class="text-sm font-medium text-amber-900">Aucun type de billet configuré</h3> <h3 class="text-sm font-medium text-amber-900">Aucun type de billet configuré</h3>
<p class="text-sm text-amber-700">Vous devez créer au moins un type de billet avant de pouvoir publier cet événement.</p> <p class="text-sm text-amber-700">Vous devez créer au moins un type de billet avant de pouvoir publier cet événement.</p>
</div> </div>
<div class="ml-auto"> <div class="flex-shrink-0">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-amber-600 hover:text-amber-800 font-medium text-sm" do %> <%= link_to promoter_event_ticket_types_path(@event), class: "text-amber-600 hover:text-amber-800 font-medium text-sm whitespace-nowrap" do %>
Configurer les billets <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i> Configurer les billets <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
<% end %> <% end %>
</div> </div>
@@ -98,15 +105,15 @@
<% end %> <% end %>
<% when "published" %> <% when "published" %>
<div class="bg-green-50 border border-green-200 rounded-2xl p-4"> <div class="bg-green-50 border border-green-200 rounded-2xl p-4">
<div class="flex items-center"> <div class="flex flex-col sm:flex-row sm:items-center gap-3">
<i data-lucide="eye" class="w-5 h-5 text-green-400 mr-3"></i> <i data-lucide="eye" class="w-5 h-5 text-green-400 flex-shrink-0"></i>
<div> <div class="flex-1 min-w-0">
<h3 class="text-sm font-medium text-green-900">Événement publié</h3> <h3 class="text-sm font-medium text-green-900">Événement publié</h3>
<p class="text-sm text-green-700">Cet événement est visible publiquement et les utilisateurs peuvent acheter des billets.</p> <p class="text-sm text-green-700">Cet événement est visible publiquement et les utilisateurs peuvent acheter des billets.</p>
</div> </div>
<div class="ml-auto"> <div class="flex-shrink-0">
<%= link_to event_path(@event.slug, @event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm" do %> <%= link_to event_path(@event.slug, @event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm whitespace-nowrap" do %>
Voir publiquement <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i> Voir la fiche publique <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -159,9 +166,9 @@
</div> </div>
<!-- Event details --> <!-- Event details -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
<!-- Main content --> <!-- Main content -->
<div class="lg:col-span-2 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.image.present? %>
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden"> <div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
@@ -170,27 +177,27 @@
<% end %> <% end %>
<!-- Description --> <!-- Description -->
<div class="bg-white rounded-2xl border border-gray-200 p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3>
<div class="prose prose-gray max-w-none"> <div class="prose prose-gray prose-sm sm:prose-base max-w-none">
<%= simple_format(@event.description) %> <%= simple_format(@event.description) %>
</div> </div>
</div> </div>
<!-- Location details --> <!-- Location details -->
<div class="bg-white rounded-2xl border border-gray-200 p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Lieu</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Lieu</h3>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<i data-lucide="building" class="w-5 h-5 text-gray-400 mt-0.5"></i> <i data-lucide="building" class="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0"></i>
<div> <div class="min-w-0 flex-1">
<p class="font-medium text-gray-900"><%= @event.venue_name %></p> <p class="font-medium text-gray-900 break-words"><%= @event.venue_name %></p>
<p class="text-gray-500"><%= @event.venue_address %></p> <p class="text-gray-500 break-words"><%= @event.venue_address %></p>
</div> </div>
</div> </div>
<div class="flex items-center space-x-3 text-sm text-gray-500"> <div class="flex items-center space-x-3 text-sm text-gray-500">
<i data-lucide="map-pin" class="w-4 h-4"></i> <i data-lucide="map-pin" class="w-4 h-4 flex-shrink-0"></i>
<span><%= @event.latitude %>, <%= @event.longitude %></span> <span class="break-all"><%= @event.latitude %>, <%= @event.longitude %></span>
</div> </div>
</div> </div>
</div> </div>
@@ -199,20 +206,20 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Event stats --> <!-- Event stats -->
<div class="bg-white rounded-2xl border border-gray-200 p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-500">Types de billets</span> <span class="text-gray-500 text-sm sm:text-base">Types de billets</span>
<span class="font-medium"><%= @event.ticket_types.count %></span> <span class="font-medium"><%= @event.ticket_types.count %></span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-500">Billets vendus</span> <span class="text-gray-500 text-sm sm:text-base">Billets vendus</span>
<span class="font-medium"><%= @event.tickets.count %></span> <span class="font-medium"><%= @event.tickets.count %></span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-500">Revenus</span> <span class="text-gray-500 text-sm sm:text-base">Revenus</span>
<span class="font-medium"> <span class="font-medium text-sm sm:text-base">
<%= number_to_currency(@event.tickets.sum(:price_cents) / 100.0, unit: "€") %> <%= number_to_currency(@event.tickets.sum(:price_cents) / 100.0, unit: "€") %>
</span> </span>
</div> </div>
@@ -220,25 +227,25 @@
</div> </div>
<!-- Event info --> <!-- Event info -->
<div class="bg-white rounded-2xl border border-gray-200 p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<span class="text-sm text-gray-500">Créé le</span> <span class="text-sm text-gray-500">Créé le</span>
<p class="text-sm"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p> <p class="text-sm break-words"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div> </div>
<div> <div>
<span class="text-sm text-gray-500">Modifié le</span> <span class="text-sm text-gray-500">Modifié le</span>
<p class="text-sm"><%= @event.updated_at.strftime("%d/%m/%Y à %H:%M") %></p> <p class="text-sm break-words"><%= @event.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div> </div>
<div> <div>
<span class="text-sm text-gray-500">Réservation pendant l'événement</span> <span class="text-sm text-gray-500">Réservation pendant l'événement</span>
<p class="text-sm flex items-center"> <p class="text-sm flex items-center">
<% if @event.allow_booking_during_event? %> <% if @event.allow_booking_during_event? %>
<i data-lucide="check-circle" class="w-4 h-4 text-green-500 mr-1"></i> <i data-lucide="check-circle" class="w-4 h-4 text-green-500 mr-1 flex-shrink-0"></i>
Autorisée Autorisée
<% else %> <% else %>
<i data-lucide="x-circle" class="w-4 h-4 text-red-500 mr-1"></i> <i data-lucide="x-circle" class="w-4 h-4 text-red-500 mr-1 flex-shrink-0"></i>
Interdite Interdite
<% end %> <% end %>
</p> </p>
@@ -246,34 +253,34 @@
<% if @event.start_time %> <% if @event.start_time %>
<div> <div>
<span class="text-sm text-gray-500">Début</span> <span class="text-sm text-gray-500">Début</span>
<p class="text-sm"><%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %></p> <p class="text-sm break-words"><%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %></p>
</div> </div>
<% end %> <% end %>
<% if @event.end_time %> <% if @event.end_time %>
<div> <div>
<span class="text-sm text-gray-500">Fin</span> <span class="text-sm text-gray-500">Fin</span>
<p class="text-sm"><%= @event.end_time.strftime("%d/%m/%Y à %H:%M") %></p> <p class="text-sm break-words"><%= @event.end_time.strftime("%d/%m/%Y à %H:%M") %></p>
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </div>
<!-- Quick actions --> <!-- Quick actions -->
<div class="bg-white rounded-2xl border border-gray-200 p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3"> <div class="space-y-3">
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center px-4 py-2 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" do %> <%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i> <i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
Gérer les types de billets Gérer les types de billets
<% end %> <% end %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center px-4 py-2 bg-gray-50 text-gray-700 font-medium rounded-lg hover:bg-gray-100 transition-colors duration-200", disabled: !@event.published? do %> <%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200", disabled: !@event.published? do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i> <i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet Marquer comme complet
<% end %> <% end %>
<hr class="border-gray-200"> <hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete, <%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." }, data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %> class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i> <i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer l'événement Supprimer l'événement
<% end %> <% end %>
@@ -281,4 +288,52 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Modal -->
<div data-event-duplication-target="modal" class="hidden relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<!-- Background backdrop, show/hide based on modal state -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<!-- Modal container -->
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<i data-lucide="copy" class="h-6 w-6 text-blue-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg font-medium leading-6 text-gray-900" id="modal-title">
Dupliquer l'événement
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Choisissez les options de duplication pour "<%= @event.name %>".
</p>
<div class="mt-4">
<div class="flex items-center">
<input data-event-duplication-target="cloneTicketTypes" id="cloneTicketTypes" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" checked>
<label for="cloneTicketTypes" class="ml-2 block text-sm text-gray-900">
Dupliquer également les types de billets (<%= @event.ticket_types.count %> type(s))
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button type="button" data-action="click->event-duplication#duplicate" class="inline-flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm">
Dupliquer
</button>
<button type="button" data-action="click->event-duplication#close" class="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>

View File

@@ -1,27 +1,36 @@
<% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %> <% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %>
<div class="container py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Dashboard', path: dashboard_path },
{ name: 'Événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Types de billets', path: promoter_event_ticket_types_path(@event) },
{ name: @ticket_type.name, path: nil }
] %>
<!-- Header with actions --> <!-- Header with actions -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %> <%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i> <i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %> <% end %>
<div> <div>
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1> <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1>
<p class="text-gray-600"> <p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p> </p>
</div> </div>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %> <%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), class: "w-full inline-flex items-center justify-center px-4 py-2 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i> <i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier Modifier
<% end %> <% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %> <%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, class: "w-full inline-flex items-center justify-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="copy" class="w-4 h-4 mr-2"></i> <i data-lucide="copy" class="w-4 h-4 mr-2"></i>
Dupliquer Dupliquer
<% end %> <% end %>
@@ -209,7 +218,7 @@
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ? Cette action est irréversible." }, data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ? Cette action est irréversible." },
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %> class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i> <i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer le type Supprimer le type de billet
<% end %> <% end %>
<% else %> <% else %>
<div class="w-full inline-flex items-center px-4 py-2 text-gray-400 font-medium rounded-lg cursor-not-allowed"> <div class="w-full inline-flex items-center px-4 py-2 text-gray-400 font-medium rounded-lg cursor-not-allowed">

View File

@@ -28,6 +28,7 @@ development:
test: test:
<<: *default <<: *default
database: aperonight_test database: aperonight_test
isolation_level: READ UNCOMMITTED
# adapter: sqlite3 # adapter: sqlite3
# pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
# database: data/test.sqlite3 # database: data/test.sqlite3

View File

@@ -81,6 +81,7 @@ Rails.application.routes.draw do
patch :unpublish patch :unpublish
patch :cancel patch :cancel
patch :mark_sold_out patch :mark_sold_out
post :duplicate
end end
# Nested ticket types routes # Nested ticket types routes
@@ -96,11 +97,8 @@ Rails.application.routes.draw do
namespace :api do namespace :api do
namespace :v1 do namespace :v1 do
# RESTful routes for event management # RESTful routes for event management
resources :events, only: [ :index, :show, :create, :update, :destroy ] do resources :events, only: [ :index, :show, :create, :update, :destroy ]
member do post "carts/store", to: "carts#store", as: "store_cart"
post :store_cart
end
end
# RESTful routes for order management # RESTful routes for order management
resources :orders, only: [] do resources :orders, only: [] do

View File

@@ -19,6 +19,9 @@ class CreateEvents < ActiveRecord::Migration[8.0]
t.boolean :featured, default: false, null: false t.boolean :featured, default: false, null: false
t.references :user, null: false, foreign_key: false t.references :user, null: false, foreign_key: false
# Allow ticket sell during the event
t.boolean :allow_booking_during_event, default: false, null: false
t.timestamps t.timestamps
end end

View File

@@ -1,5 +0,0 @@
class AddAllowBookingDuringEventToEvents < ActiveRecord::Migration[8.0]
def change
add_column :events, :allow_booking_during_event, :boolean, default: false, null: false
end
end

View File

@@ -3,28 +3,32 @@
## Current Problem Analysis ## Current Problem Analysis
### Current Model: €1 Fixed Fee Per Order ### Current Model: €1 Fixed Fee Per Order
- **Revenue**: €1.00 per order (regardless of ticket price) - **Revenue**: €1.00 per order (regardless of ticket price)
- **Stripe Costs**: 1.15% + €0.25 per transaction - **Stripe Costs**: 1.15% + €0.25 per transaction
- **Result**: Losing money on higher-priced tickets - **Result**: Losing money on higher-priced tickets
### Financial Impact Examples ### Financial Impact Examples
| Ticket Price | Current Revenue | Stripe Fees | Net Profit/Loss | | Ticket Price | Current Revenue | Stripe Fees | Net Profit/Loss |
|--------------|----------------|-------------|-----------------| | ------------ | --------------- | ----------- | --------------- |
| €20 | €1.00 | €0.48 | **+€0.52** ✅ | | €20 | €1.00 | €0.48 | **+€0.52** ✅ |
| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ | | €50 | €1.00 | €0.83 | **+€0.17** ⚠️ |
| €100 | €1.00 | €1.40 | **-€0.40** ❌ | | €100 | €1.00 | €1.40 | **-€0.40** ❌ |
| €200 | €1.00 | €2.55 | **-€1.55** ❌ | | €200 | €1.00 | €2.55 | **-€1.55** ❌ |
**Critical Issue**: We lose money on any ticket above €65 (€1.00 - (€65 × 1.15% + €0.25) = -€0.00) **Critical Issue**: We lose money on any ticket above €65 (€1.00 - (€65 × 1.15% + €0.25) = -€0.00)
## Recommended Pricing Models ## Recommended Pricing Models
### Model 1: Percentage-Based (Recommended) ### Model 1: Percentage-Based (Recommended)
``` ```
Platform Fee = 3-5% of ticket price Platform Fee = 3-5% of ticket price
``` ```
**Advantages:** **Advantages:**
- Always profitable regardless of ticket price - Always profitable regardless of ticket price
- Scales naturally with event value - Scales naturally with event value
- Simple for promoters to understand - Simple for promoters to understand
@@ -33,17 +37,19 @@ Platform Fee = 3-5% of ticket price
**Financial Impact:** **Financial Impact:**
| Ticket Price | 3.5% Fee | Stripe Costs | Net Profit | | Ticket Price | 3.5% Fee | Stripe Costs | Net Profit |
|--------------|----------|--------------|------------| |--------------|----------|--------------|------------|
| €20 | €0.70 | €0.48 | €0.22 | | €20 | €0.70 | €0.48 | €0.22 |
| €50 | €1.75 | €0.83 | €0.92 | | €50 | €1.75 | €0.83 | €0.92 |
| €100 | €3.50 | €1.40 | €2.10 | | €100 | €3.50 | €1.40 | €2.10 |
| €200 | €7.00 | €2.55 | €4.45 | | €200 | €7.00 | €2.55 | €4.45 |
### Model 2: Hybrid Model (Best) ### Model 2: Hybrid Model (Best)
``` ```
Platform Fee = €1.50 + 2% of ticket price Platform Fee = €1.50 + 2% of ticket price
``` ```
**Advantages:** **Advantages:**
- Higher base fee covers Stripe fixed costs - Higher base fee covers Stripe fixed costs
- Percentage component scales with value - Percentage component scales with value
- Better margins on low-priced tickets - Better margins on low-priced tickets
@@ -52,12 +58,13 @@ Platform Fee = €1.50 + 2% of ticket price
**Financial Impact:** **Financial Impact:**
| Ticket Price | Hybrid Fee | Stripe Costs | Net Profit | | Ticket Price | Hybrid Fee | Stripe Costs | Net Profit |
|--------------|------------|--------------|------------| |--------------|------------|--------------|------------|
| €20 | €1.90 | €0.48 | €1.42 | | €20 | €1.90 | €0.48 | €1.42 |
| €50 | €2.50 | €0.83 | €1.67 | | €50 | €2.50 | €0.83 | €1.67 |
| €100 | €3.50 | €1.40 | €2.10 | | €100 | €3.50 | €1.40 | €2.10 |
| €200 | €5.50 | €2.55 | €2.95 | | €200 | €5.50 | €2.55 | €2.95 |
### Model 3: Tiered Flat Fees ### Model 3: Tiered Flat Fees
``` ```
€0-25: €1.50 fee €0-25: €1.50 fee
€26-75: €2.50 fee €26-75: €2.50 fee
@@ -66,32 +73,36 @@ Platform Fee = €1.50 + 2% of ticket price
``` ```
**Advantages:** **Advantages:**
- Predictable fees for promoters - Predictable fees for promoters
- Simple pricing tiers - Simple pricing tiers
- Better than current model - Better than current model
**Disadvantages:** **Disadvantages:**
- Less scalable than percentage - Less scalable than percentage
- Requires tier management - Requires tier management
## Industry Benchmarks ## Industry Benchmarks
| Platform | Fee Structure | Effective Rate (€50 ticket) | | Platform | Fee Structure | Effective Rate (€50 ticket) |
|-------------|----------------------------|------------------------------| | --------------- | ------------- | --------------------------- |
| Eventbrite | 3.7% + €0.59 | €2.44 | | Eventbrite | 3.7% + €0.59 | €2.44 |
| Universe | 2.5% + €0.49 | €1.74 | | Universe | 2.5% + €0.49 | €1.74 |
| Ticketfly | 4% + €0.99 | €2.99 | | Ticketfly | 4% + €0.99 | €2.99 |
| **Recommended** | **3.5%** | **€1.75** | | **Recommended** | **3.5%** | **€1.75** |
## Implementation Strategy ## Implementation Strategy
### Phase 1: Immediate Implementation (Week 1-2) ### Phase 1: Immediate Implementation (Week 1-2)
1. **Switch to 3.5% percentage model** for new events 1. **Switch to 3.5% percentage model** for new events
2. **Grandfathering**: Existing published events keep €1 pricing 2. **Grandfathering**: Existing published events keep €1 pricing
3. **Communication**: Notify promoters 1 week before change 3. **Communication**: Notify promoters 1 week before change
4. **Update pricing display** on event creation and checkout pages 4. **Update pricing display** on event creation and checkout pages
### Phase 2: Optimization (Month 2-3) ### Phase 2: Optimization (Month 2-3)
1. **A/B Test different rates**: 3%, 3.5%, 4% 1. **A/B Test different rates**: 3%, 3.5%, 4%
2. **Monitor metrics**: 2. **Monitor metrics**:
- Promoter signup rate - Promoter signup rate
@@ -101,6 +112,7 @@ Platform Fee = €1.50 + 2% of ticket price
3. **Adjust based on data** 3. **Adjust based on data**
### Phase 3: Premium Tiers (Month 4-6) ### Phase 3: Premium Tiers (Month 4-6)
1. **Volume discounts** for high-performing promoters: 1. **Volume discounts** for high-performing promoters:
- Standard: 3.5% - Standard: 3.5%
- Bronze (€5K+ monthly): 3% - Bronze (€5K+ monthly): 3%
@@ -112,6 +124,7 @@ Platform Fee = €1.50 + 2% of ticket price
## Revenue Projections ## Revenue Projections
### Current State (€1 fixed) ### Current State (€1 fixed)
- Average ticket price: €35 - Average ticket price: €35
- Monthly orders: 1,000 - Monthly orders: 1,000
- Monthly revenue: €1,000 - Monthly revenue: €1,000
@@ -119,11 +132,13 @@ Platform Fee = €1.50 + 2% of ticket price
- **Net monthly profit: €372** - **Net monthly profit: €372**
### With 3.5% Model ### With 3.5% Model
- Monthly revenue: €1,225 (3.5% × €35 × 1,000) - Monthly revenue: €1,225 (3.5% × €35 × 1,000)
- Monthly Stripe costs: €628 - Monthly Stripe costs: €628
- **Net monthly profit: €597** (+60% increase) - **Net monthly profit: €597** (+60% increase)
### With Growth (3.5% model + 20% more events) ### With Growth (3.5% model + 20% more events)
- Monthly orders: 1,200 (20% growth from competitive pricing) - Monthly orders: 1,200 (20% growth from competitive pricing)
- Monthly revenue: €1,470 - Monthly revenue: €1,470
- **Net monthly profit: €842** (+126% increase) - **Net monthly profit: €842** (+126% increase)
@@ -131,6 +146,7 @@ Platform Fee = €1.50 + 2% of ticket price
## Technical Implementation ## Technical Implementation
### Database Changes ### Database Changes
```ruby ```ruby
# Add to events table # Add to events table
add_column :events, :commission_rate, :decimal, precision: 5, scale: 4, default: 0.035 add_column :events, :commission_rate, :decimal, precision: 5, scale: 4, default: 0.035
@@ -140,6 +156,7 @@ add_column :users, :commission_tier, :string, default: 'standard'
``` ```
### Fee Calculation Logic ### Fee Calculation Logic
```ruby ```ruby
class Order < ApplicationRecord class Order < ApplicationRecord
def calculate_platform_fee def calculate_platform_fee
@@ -156,6 +173,7 @@ end
``` ```
### Promoter Dashboard Updates ### Promoter Dashboard Updates
- Show fee breakdown on event creation - Show fee breakdown on event creation
- Display projected fees during ticket setup - Display projected fees during ticket setup
- Add revenue vs. fees analytics - Add revenue vs. fees analytics
@@ -164,9 +182,11 @@ end
## Communication Plan ## Communication Plan
### Email to Existing Promoters ### Email to Existing Promoters
**Subject**: "Important Pricing Update - New Fair Fee Structure" **Subject**: "Important Pricing Update - New Fair Fee Structure"
**Key Points**: **Key Points**:
- Current model loses money on higher-priced tickets - Current model loses money on higher-priced tickets
- New model ensures platform sustainability - New model ensures platform sustainability
- Better features and support with improved revenue - Better features and support with improved revenue
@@ -174,6 +194,7 @@ end
- Competitive with industry standards - Competitive with industry standards
### Website Updates ### Website Updates
- Update pricing page with clear fee calculator - Update pricing page with clear fee calculator
- Add FAQ about fee structure - Add FAQ about fee structure
- Transparency about what fees cover (development, support, payment processing) - Transparency about what fees cover (development, support, payment processing)
@@ -181,11 +202,13 @@ end
## Risk Mitigation ## Risk Mitigation
### Potential Issues ### Potential Issues
1. **Promoter backlash**: Higher fees on expensive tickets 1. **Promoter backlash**: Higher fees on expensive tickets
2. **Competitor advantage**: Other platforms with lower fees 2. **Competitor advantage**: Other platforms with lower fees
3. **Reduced event creation**: Promoters may create fewer events 3. **Reduced event creation**: Promoters may create fewer events
### Mitigation Strategies ### Mitigation Strategies
1. **Value communication**: Emphasize platform improvements and reliability 1. **Value communication**: Emphasize platform improvements and reliability
2. **Competitive analysis**: Position as "fair and sustainable" vs. competitors 2. **Competitive analysis**: Position as "fair and sustainable" vs. competitors
3. **Volume incentives**: Quick path to reduced rates for active promoters 3. **Volume incentives**: Quick path to reduced rates for active promoters
@@ -194,12 +217,14 @@ end
## Success Metrics ## Success Metrics
### Financial KPIs ### Financial KPIs
- Monthly recurring revenue growth - Monthly recurring revenue growth
- Average revenue per transaction - Average revenue per transaction
- Profit margin improvement - Profit margin improvement
- Customer acquisition cost vs. lifetime value - Customer acquisition cost vs. lifetime value
### Product KPIs ### Product KPIs
- Promoter retention rate - Promoter retention rate
- New event creation volume - New event creation volume
- Average ticket prices - Average ticket prices
@@ -221,6 +246,7 @@ The current €1 fixed fee model is financially unsustainable and actually costs
### Current Approach vs Industry Standard ### Current Approach vs Industry Standard
**Current Model (Not Recommended)**: **Current Model (Not Recommended)**:
``` ```
Customer pays: €50 + €1 fee = €51 Customer pays: €50 + €1 fee = €51
Promoter receives: €50 Promoter receives: €50
@@ -228,6 +254,7 @@ Platform keeps: €1
``` ```
**Industry Standard (Recommended)**: **Industry Standard (Recommended)**:
``` ```
Customer pays: €50 (clean price) Customer pays: €50 (clean price)
Platform keeps: €1.75 (3.5% of €50) Platform keeps: €1.75 (3.5% of €50)
@@ -237,18 +264,21 @@ Promoter receives: €48.25
### How Major Platforms Handle Fees ### How Major Platforms Handle Fees
**Eventbrite:** **Eventbrite:**
- Promoter sets: €50 ticket - Promoter sets: €50 ticket
- Customer pays: €50 - Customer pays: €50
- Eventbrite keeps: €1.85 (3.7%) - Eventbrite keeps: €1.85 (3.7%)
- Promoter receives: €48.15 - Promoter receives: €48.15
**Ticketmaster:** **Ticketmaster:**
- Promoter sets: €50 ticket - Promoter sets: €50 ticket
- Customer pays: €50 - Customer pays: €50
- Ticketmaster keeps: €5-7.50 (10-15%) - Ticketmaster keeps: €5-7.50 (10-15%)
- Promoter receives: €42.50-45 - Promoter receives: €42.50-45
**Universe (by Ticketmaster):** **Universe (by Ticketmaster):**
- Promoter sets: €50 ticket - Promoter sets: €50 ticket
- Customer pays: €50 - Customer pays: €50
- Universe keeps: €1.74 (2.5% + €0.49) - Universe keeps: €1.74 (2.5% + €0.49)
@@ -257,16 +287,19 @@ Promoter receives: €48.25
### Why Deducting from Payout is Better ### Why Deducting from Payout is Better
#### 1. Customer Experience #### 1. Customer Experience
- **Price transparency**: Customer sees exactly what they expect to pay - **Price transparency**: Customer sees exactly what they expect to pay
- **No surprise fees**: Reduces cart abandonment - **No surprise fees**: Reduces cart abandonment
- **Competitive pricing**: Easier to compare with other events - **Competitive pricing**: Easier to compare with other events
#### 2. Promoter Benefits #### 2. Promoter Benefits
- **Marketing simplicity**: Can advertise clean prices - **Marketing simplicity**: Can advertise clean prices
- **Psychological pricing**: €50 sounds better than €51.75 - **Psychological pricing**: €50 sounds better than €51.75
- **Competitive advantage**: Not adding extra fees to customer - **Competitive advantage**: Not adding extra fees to customer
#### 3. Platform Benefits #### 3. Platform Benefits
- **Higher conversion rates**: No fee-shock at checkout - **Higher conversion rates**: No fee-shock at checkout
- **Better promoter adoption**: Easier to sell to event organizers - **Better promoter adoption**: Easier to sell to event organizers
- **Industry standard**: Follows established practices - **Industry standard**: Follows established practices
@@ -274,14 +307,17 @@ Promoter receives: €48.25
### Psychological Impact ### Psychological Impact
**Adding Fees to Customer (Current)**: **Adding Fees to Customer (Current)**:
- Customer thinks: "€50 ticket... oh wait, €51.75 total" 😤 - Customer thinks: "€50 ticket... oh wait, €51.75 total" 😤
- Cart abandonment risk - Cart abandonment risk
**Deducting from Payout (Recommended)**: **Deducting from Payout (Recommended)**:
- Customer thinks: "€50 ticket, €50 total" 😊 - Customer thinks: "€50 ticket, €50 total" 😊
- Smooth purchase experience - Smooth purchase experience
### Promoter Dashboard Display ### Promoter Dashboard Display
``` ```
Ticket Price: €50.00 Ticket Price: €50.00
Platform Fee (3.5%): -€1.75 Platform Fee (3.5%): -€1.75
@@ -289,10 +325,12 @@ Your Earnings per Ticket: €48.25
``` ```
### Communication to Promoters ### Communication to Promoters
**Before:** "Platform charges €1 per order to customers" **Before:** "Platform charges €1 per order to customers"
**After:** "Set your desired revenue per ticket, we handle the rest" **After:** "Set your desired revenue per ticket, we handle the rest"
**Example:** **Example:**
- Promoter wants €48.25 net per ticket - Promoter wants €48.25 net per ticket
- They should set ticket price at €50 - They should set ticket price at €50
- Customer pays €50, promoter gets €48.25 - Customer pays €50, promoter gets €48.25
@@ -310,45 +348,49 @@ Our main competitor charges a simple €1 flat fee per order. Here's how our mod
**Competitor**: €1.00 flat fee **Competitor**: €1.00 flat fee
| Ticket Price | Competitor Fee | Hybrid Fee | Difference | Competitive Position | | Ticket Price | Competitor Fee | Hybrid Fee | Difference | Competitive Position |
|--------------|----------------|------------|------------|---------------------| | ------------ | -------------- | ---------- | ---------- | -------------------- |
| €10 | €1.00 | €1.70 | +€0.70 | More expensive | | €10 | €1.00 | €1.70 | +€0.70 | More expensive |
| €25 | €1.00 | €2.00 | +€1.00 | More expensive | | €25 | €1.00 | €2.00 | +€1.00 | More expensive |
| €50 | €1.00 | €2.50 | +€1.50 | More expensive | | €50 | €1.00 | €2.50 | +€1.50 | More expensive |
| **€75** | **€1.00** | **€3.00** | **+€2.00** | **Break-even point** | | **€75** | **€1.00** | **€3.00** | **+€2.00** | **Break-even point** |
| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive | | €100 | €1.00 | €3.50 | +€2.50 | Much more expensive |
### Alternative Competitive Models ### Alternative Competitive Models
#### Option 1: Low-End Competitive Model #### Option 1: Low-End Competitive Model
``` ```
Platform Fee = €0.50 + 1.5% of ticket price Platform Fee = €0.50 + 1.5% of ticket price
``` ```
| Ticket Price | Competitor Fee | Our Fee | Difference | Position | | Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|--------------|----------------|---------|------------|----------| | ------------ | -------------- | ------- | ---------- | ------------------ |
| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** | | €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** |
| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** | | €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** |
| €50 | €1.00 | €1.25 | +€0.25 | ⚠️ Slightly higher | | €50 | €1.00 | €1.25 | +€0.25 | ⚠️ Slightly higher |
| €100 | €1.00 | €2.00 | +€1.00 | More expensive | | €100 | €1.00 | €2.00 | +€1.00 | More expensive |
#### Option 2: Modified Hybrid Model #### Option 2: Modified Hybrid Model
``` ```
Platform Fee = €0.75 + 2.5% of ticket price Platform Fee = €0.75 + 2.5% of ticket price
``` ```
| Ticket Price | Competitor Fee | Our Fee | Difference | Position | | Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|--------------|----------------|---------|------------|----------| | ------------ | -------------- | ------- | ---------- | ------------------- |
| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive | | €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive |
| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher | | €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher |
| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** | | €40 | €1.00 | €1.75 | +€0.75 | **Break-even** |
| €75 | €1.00 | €2.63 | +€1.63 | Much more expensive | | €75 | €1.00 | €2.63 | +€1.63 | Much more expensive |
### Competitive Strategy Recommendations ### Competitive Strategy Recommendations
#### 1. Value Differentiation Approach #### 1. Value Differentiation Approach
Since we'll be more expensive on higher-priced tickets, focus on premium positioning: Since we'll be more expensive on higher-priced tickets, focus on premium positioning:
**Value Proposition:** **Value Proposition:**
- "We're not the cheapest, we're the most complete" - "We're not the cheapest, we're the most complete"
- Advanced analytics dashboard - Advanced analytics dashboard
- Real-time sales tracking - Real-time sales tracking
@@ -359,12 +401,14 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
#### 2. Market Segmentation Strategy #### 2. Market Segmentation Strategy
**Target Market Positioning:** **Target Market Positioning:**
- **Competitor**: Best for small, simple events (€10-30 tickets) - **Competitor**: Best for small, simple events (€10-30 tickets)
- **Us**: Best for professional events (€40+ tickets) with serious promoters - **Us**: Best for professional events (€40+ tickets) with serious promoters
#### 3. Hybrid Competitive Approach #### 3. Hybrid Competitive Approach
**Tiered Offering:** **Tiered Offering:**
- **Basic Plan**: Match competitor at €1 flat fee (limited features) - **Basic Plan**: Match competitor at €1 flat fee (limited features)
- **Professional Plan**: Hybrid model with premium features - **Professional Plan**: Hybrid model with premium features
- **Enterprise Plan**: Custom pricing with full feature set - **Enterprise Plan**: Custom pricing with full feature set
@@ -372,6 +416,7 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
#### 4. Volume-Based Competitive Response #### 4. Volume-Based Competitive Response
**Free Tier Strategy:** **Free Tier Strategy:**
- First 3 events per month at competitor's €1 rate - First 3 events per month at competitor's €1 rate
- Volume discounts for high-activity promoters - Volume discounts for high-activity promoters
- Loyalty rewards for long-term customers - Loyalty rewards for long-term customers
@@ -381,15 +426,18 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio
#### "Choose Your Business Model" Campaign #### "Choose Your Business Model" Campaign
**For Simple Events (Under €40):** **For Simple Events (Under €40):**
- "Need basic ticketing? Our competitor works fine" - "Need basic ticketing? Our competitor works fine"
- "Pay €1 flat fee for simple events" - "Pay €1 flat fee for simple events"
**For Professional Events (€40+):** **For Professional Events (€40+):**
- "Serious about your business? You need serious tools" - "Serious about your business? You need serious tools"
- "Fair percentage-based pricing" - "Fair percentage-based pricing"
- "Advanced analytics, marketing tools, priority support" - "Advanced analytics, marketing tools, priority support"
#### Brand Positioning Statement #### Brand Positioning Statement
**"We're the Shopify of Events - Built for Growth"** **"We're the Shopify of Events - Built for Growth"**
This positions us as the premium option for serious promoters while acknowledging the competitor's advantage on small events. This positions us as the premium option for serious promoters while acknowledging the competitor's advantage on small events.
@@ -399,12 +447,14 @@ This positions us as the premium option for serious promoters while acknowledgin
Given the competitive landscape, we recommend **Option 1** (€0.50 + 1.5%): Given the competitive landscape, we recommend **Option 1** (€0.50 + 1.5%):
**Advantages:** **Advantages:**
- Competitive on low-priced tickets - Competitive on low-priced tickets
- Still profitable at all price points - Still profitable at all price points
- Better positioning against main competitor - Better positioning against main competitor
- Appeals to both small and large event organizers - Appeals to both small and large event organizers
**Financial Impact:** **Financial Impact:**
- Lower fees on tickets under €33 - Lower fees on tickets under €33
- Competitive fees on tickets €33-66 - Competitive fees on tickets €33-66
- Premium pricing on high-value tickets justified by features - Premium pricing on high-value tickets justified by features

View File

@@ -0,0 +1,58 @@
require "test_helper"
class Api::V1::EventsControllerTest < ActionDispatch::IntegrationTest
setup do
ENV["API_KEY"] = "test_key"
@user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A description that is long enough for validation", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @user, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :published)
end
test "should get index" do
get api_v1_events_url, headers: headers_api_key
assert_response :success
assert_kind_of Array, json_response
end
test "should show event" do
get api_v1_event_url(@event.id), headers: headers_api_key
assert_response :success
assert_equal @event.id, json_response["id"]
end
test "should create event" do
assert_difference("Event.count") do
post api_v1_events_url, params: { event: { name: "New Event", slug: "new-event", description: "New description that is long enough", latitude: 48.8566, longitude: 2.3522, venue_name: "New Venue", venue_address: "New Address", user_id: @user.id, start_time: "2024-01-01 10:00:00", end_time: "2024-01-01 13:00:00", state: "published" } }, as: :json, headers: headers_api_key
end
assert_response :created
end
test "should update event" do
patch api_v1_event_url(@event.id), params: { event: { name: "Updated Event" } }, as: :json, headers: headers_api_key
assert_response :ok
@event.reload
assert_equal "Updated Event", @event.name
end
test "should destroy event" do
assert_difference("Event.count", -1) do
delete api_v1_event_url(@event.id), headers: headers_api_key
end
assert_response :no_content
end
test "should store cart" do
post api_v1_store_cart_path, params: { cart: { ticket_type_id: 1, quantity: 2 }, event_id: @event.id }, as: :json, headers: headers_api_key
assert_response :success
assert_equal @event.id, session[:event_id]
end
private
def json_response
JSON.parse(response.body)
end
def headers_api_key
{ "X-API-Key" => "test_key" }
end
end

View File

@@ -41,7 +41,7 @@ class ApplicationControllerOnboardingTest < ActionDispatch::IntegrationTest
test "should redirect signed in incomplete users from home to onboarding" do test "should redirect signed in incomplete users from home to onboarding" do
sign_in @user_without_onboarding sign_in @user_without_onboarding
get root_path get root_path
assert_redirected_to dashboard_path # Home redirects to dashboard for signed in users assert_redirected_to onboarding_path
end end
test "should not interfere with devise controllers" do test "should not interfere with devise controllers" do

View File

@@ -0,0 +1,33 @@
require "test_helper"
class StripeConcernTest < ActionDispatch::IntegrationTest
setup do
Rails.application.config.stripe = { secret_key: nil }
end
test "stripe_configured? returns false when no secret key" do
controller = ApplicationController.new
controller.extend StripeConcern
assert_not controller.stripe_configured?
end
test "stripe_configured? returns true when secret key present" do
Rails.application.config.stripe = { secret_key: "sk_test_key" }
controller = ApplicationController.new
controller.extend StripeConcern
assert controller.stripe_configured?
end
test "initialize_stripe returns false when not configured" do
controller = ApplicationController.new
controller.extend StripeConcern
assert_not controller.initialize_stripe
end
test "initialize_stripe returns true when configured" do
Rails.application.config.stripe = { secret_key: "sk_test_key" }
controller = ApplicationController.new
controller.extend StripeConcern
assert controller.initialize_stripe
end
end

View File

@@ -0,0 +1,128 @@
require "test_helper"
class Promoter::EventsControllerTest < ActionDispatch::IntegrationTest
setup do
@promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true, onboarding_completed: true)
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A valid description for the test event that is long enough to meet the minimum character requirement", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @promoter, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :draft)
end
test "should require authentication for index" do
get promoter_events_path
assert_redirected_to new_user_session_path
end
test "should get index for authenticated promoter" do
sign_in @promoter
get promoter_events_path
assert_response :success
end
test "should show promoter's events only" do
sign_in @promoter
other_event = Event.create!(name: "Other Event", slug: "other", description: "Valid description for the event", latitude: 48.0, longitude: 2.0, venue_name: "V", venue_address: "A", user_id: users(:one).id, start_time: 1.day.from_now, end_time: 2.days.from_now, state: :draft)
get promoter_events_path
assert_response :success
assert_includes assigns(:events), @event
assert_not_includes assigns(:events), other_event
end
test "should duplicate an event with ticket types" do
sign_in @promoter
# Create ticket types for the event
ticket_type1 = TicketType.create!(
name: "Standard Ticket",
description: "A standard ticket for the event with all the basic access",
price_cents: 2000,
quantity: 100,
sale_start_at: 1.day.ago,
sale_end_at: @event.start_time - 1.hour,
event: @event
)
ticket_type2 = TicketType.create!(
name: "VIP Ticket",
description: "A VIP ticket for the event with special access",
price_cents: 5000,
quantity: 50,
sale_start_at: 1.day.ago,
sale_end_at: @event.start_time - 1.hour,
event: @event
)
# Verify that ticket types were created successfully
assert ticket_type1.valid?
assert ticket_type2.valid?
# Duplicate the event
assert_difference("Event.count", 1) do
post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "true" }
end
# Check that the new event was created
assert_redirected_to edit_promoter_event_path(Event.last)
assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice]
# Check that the new event has the correct attributes
new_event = Event.last
assert_equal "Copie de #{@event.name}", new_event.name
assert_equal "draft", new_event.state
assert_equal @event.venue_name, new_event.venue_name
assert_equal @event.venue_address, new_event.venue_address
# Check that ticket types were duplicated
assert_equal 2, new_event.ticket_types.count
assert_equal "Standard Ticket", new_event.ticket_types.find_by(name: "Standard Ticket").name
assert_equal "VIP Ticket", new_event.ticket_types.find_by(name: "VIP Ticket").name
end
test "should duplicate an event without ticket types" do
sign_in @promoter
# Create ticket types for the event
ticket_type1 = TicketType.create!(
name: "Standard Ticket",
description: "A standard ticket for the event with all the basic access",
price_cents: 2000,
quantity: 100,
sale_start_at: 1.day.ago,
sale_end_at: @event.start_time - 1.hour,
event: @event
)
ticket_type2 = TicketType.create!(
name: "VIP Ticket",
description: "A VIP ticket for the event with special access",
price_cents: 5000,
quantity: 50,
sale_start_at: 1.day.ago,
sale_end_at: @event.start_time - 1.hour,
event: @event
)
# Verify that ticket types were created successfully
assert ticket_type1.valid?
assert ticket_type2.valid?
# Duplicate the event without ticket types
assert_difference("Event.count", 1) do
post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "false" }
end
# Check that the new event was created
assert_redirected_to edit_promoter_event_path(Event.last)
assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice]
# Check that the new event has the correct attributes
new_event = Event.last
assert_equal "Copie de #{@event.name}", new_event.name
assert_equal "draft", new_event.state
assert_equal @event.venue_name, new_event.venue_name
assert_equal @event.venue_address, new_event.venue_address
# Check that ticket types were NOT duplicated
assert_equal 0, new_event.ticket_types.count
end
# Add tests for new, create, etc. as needed
end

View File

@@ -0,0 +1,22 @@
require "test_helper"
class Promoter::TicketTypesControllerTest < ActionDispatch::IntegrationTest
setup do
@promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true, onboarding_completed: true)
@event = Event.create!(name: "Test Event", slug: "test-event", description: "A valid description for the test event that is long enough to meet the minimum character requirement", latitude: 48.8566, longitude: 2.3522, venue_name: "Venue", venue_address: "Address", user: @promoter, start_time: 1.week.from_now, end_time: 1.week.from_now + 3.hours, state: :draft)
@ticket_type = TicketType.create!(name: "General", description: "General admission", price_cents: 2500, quantity: 100, sale_start_at: Time.current, sale_end_at: @event.start_time, event: @event)
end
test "should require authentication for index" do
get promoter_event_ticket_types_path(@event)
assert_redirected_to new_user_session_path
end
test "should get index for promoter's event" do
sign_in @promoter
get promoter_event_ticket_types_path(@event)
assert_response :success
end
# Add more tests for create, update, destroy
end

View File

@@ -1,4 +1,5 @@
require "test_helper" require "test_helper"
require "timecop"
class EventTest < ActiveSupport::TestCase class EventTest < ActiveSupport::TestCase
# Test that Event model exists # Test that Event model exists
@@ -160,4 +161,160 @@ class EventTest < ActiveSupport::TestCase
test "should respond to search_by_name scope" do test "should respond to search_by_name scope" do
assert_respond_to Event, :search_by_name assert_respond_to Event, :search_by_name
end end
test "upcoming scope should return only published future events" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
future_published = Event.create!(name: "Future", slug: "future", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :published)
past_published = Event.create!(name: "Past", slug: "past", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, state: :published)
future_draft = Event.create!(name: "Draft", slug: "draft", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :draft)
upcoming = Event.upcoming
assert_includes upcoming, future_published
assert_not_includes upcoming, past_published
assert_not_includes upcoming, future_draft
end
test "geocoding_successful? should return true for valid coordinates" do
event = Event.new(latitude: 48.8566, longitude: 2.3522, name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a")
assert event.geocoding_successful?
end
test "geocoding_successful? should return false for fallback coordinates" do
event = Event.new(latitude: 46.603354, longitude: 1.888334, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
assert_not event.geocoding_successful?
end
test "geocoding_status_message should return message when not successful" do
event = Event.new(latitude: 46.603354, longitude: 1.888334, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
assert_match(/coordonnées/, event.geocoding_status_message)
end
test "geocoding_status_message should return nil when successful" do
event = Event.new(latitude: 48.8566, longitude: 2.3522, name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a")
assert_nil event.geocoding_status_message
end
test "booking_allowed? should be true for published future event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :published)
assert event.booking_allowed?
end
test "booking_allowed? should be false for draft event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :draft)
assert_not event.booking_allowed?
end
test "booking_allowed? should be false for canceled event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :canceled)
assert_not event.booking_allowed?
end
test "booking_allowed? should be false for sold_out event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, state: :sold_out)
assert_not event.booking_allowed?
end
test "booking_allowed? should be false during event without allow_booking_during_event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 2.hours.from_now, state: :published, allow_booking_during_event: false)
assert_not event.booking_allowed?
end
test "booking_allowed? should be true during event with allow_booking_during_event" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 2.hours.from_now, state: :published, allow_booking_during_event: true)
assert event.booking_allowed?
end
test "event_started? should be true after start_time" do
Timecop.freeze(1.hour.from_now) do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago)
assert event.event_started?
end
end
test "event_started? should be false before start_time" do
Timecop.freeze(1.hour.ago) do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.from_now)
assert_not event.event_started?
end
end
test "event_ended? should be true after end_time" do
Timecop.freeze(1.hour.from_now) do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "Valid description for the event", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 30.minutes.ago)
assert event.event_ended?
end
end
test "event_ended? should be false before end_time" do
Timecop.freeze(1.hour.ago) do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Test", slug: "test", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.hour.ago, end_time: 1.hour.from_now)
assert_not event.event_ended?
end
end
test "allow_booking_during_event? should return true when set to true" do
event = Event.new(allow_booking_during_event: true)
assert event.allow_booking_during_event?
end
test "allow_booking_during_event? should return false when nil" do
event = Event.new
assert_not event.allow_booking_during_event?
end
test "should duplicate event with ticket types" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published)
# Create ticket types
ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
# Duplicate the event
duplicated_event = event.duplicate
# Check that duplication was successful
assert_not_nil duplicated_event
assert_equal "Copie de #{event.name}", duplicated_event.name
assert_equal "draft", duplicated_event.state
assert_equal event.venue_name, duplicated_event.venue_name
assert_equal event.venue_address, duplicated_event.venue_address
# Check that ticket types were duplicated
assert_equal 2, duplicated_event.ticket_types.count
assert_equal "Standard", duplicated_event.ticket_types.find_by(name: "Standard").name
assert_equal "VIP", duplicated_event.ticket_types.find_by(name: "VIP").name
end
test "should duplicate event without ticket types" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published)
# Create ticket types
ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event)
# Duplicate the event without ticket types
duplicated_event = event.duplicate(clone_ticket_types: false)
# Check that duplication was successful
assert_not_nil duplicated_event
assert_equal "Copie de #{event.name}", duplicated_event.name
assert_equal "draft", duplicated_event.state
assert_equal event.venue_name, duplicated_event.venue_name
assert_equal event.venue_address, duplicated_event.venue_address
# Check that ticket types were NOT duplicated
assert_equal 0, duplicated_event.ticket_types.count
end
end end

View File

@@ -469,7 +469,7 @@ class OrderTest < ActiveSupport::TestCase
assert_equal "active", ticket2.status assert_equal "active", ticket2.status
end end
test "calculate_total! should sum ticket prices plus 1€ service fee" do test "calculate_total! should sum ticket prices only (platform fee deducted from promoter payout)" do
order = Order.create!( order = Order.create!(
user: @user, event: @event, total_amount_cents: 0, user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0 status: "draft", payment_attempts: 0
@@ -506,7 +506,80 @@ class OrderTest < ActiveSupport::TestCase
order.calculate_total! order.calculate_total!
order.reload order.reload
assert_equal 3100, order.total_amount_cents # 2 tickets * 1500 cents + 100 cents (1€ fee) assert_equal 3000, order.total_amount_cents # 2 tickets * 1500 cents (no service fee added to customer)
end
test "platform_fee_cents should calculate €0.50 + 1.5% per ticket" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
ticket_type1 = TicketType.create!(
name: "Cheap Ticket",
description: "Cheap ticket type",
price_cents: 1000, # €10
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
ticket_type2 = TicketType.create!(
name: "Expensive Ticket",
description: "Expensive ticket type",
price_cents: 5000, # €50
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
ticket1 = Ticket.create!(order: order, ticket_type: ticket_type1, status: "draft", first_name: "John", last_name: "Doe")
ticket2 = Ticket.create!(order: order, ticket_type: ticket_type2, status: "draft", first_name: "Jane", last_name: "Doe")
expected_fee = (50 + (1000 * 0.015).to_i) + (50 + (5000 * 0.015).to_i) # 50+15 + 50+75 = 190
assert_equal 190, order.platform_fee_cents
end
test "promoter_payout_cents should be total minus platform fee" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 3000,
status: "paid", payment_attempts: 0
)
ticket_type = TicketType.create!(
name: "Test Ticket",
description: "Test ticket",
price_cents: 1500,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "John", last_name: "Doe")
Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "Jane", last_name: "Doe")
order.calculate_total! # Should still be 3000
expected_payout = 3000 - (50 + (1500 * 0.015).to_i) * 2 # 3000 - (50+22.5≈22)*2 = 3000 - 144 = 2856
assert_equal 2856, order.promoter_payout_cents
end
test "platform_fee_euros should convert cents to euros" do
order = Order.new(total_amount_cents: 0)
# Assuming one €10 ticket: 50 + 150 = 200 cents = €2.00
def order.platform_fee_cents; 200; end
assert_equal 2.0, order.platform_fee_euros
end
test "promoter_payout_euros should convert cents to euros" do
order = Order.new(total_amount_cents: 10000)
def order.platform_fee_cents; 500; end
assert_equal 95.0, order.promoter_payout_euros
end end
# === Stripe Integration Tests (Mock) === # === Stripe Integration Tests (Mock) ===

View File

@@ -151,7 +151,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice) mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
mock_invoice.expects(:pay) mock_invoice.expects(:pay)
Stripe::Invoice.expects(:create).returns(mock_invoice) Stripe::Invoice.expects(:create).returns(mock_invoice)
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
result = @service.create_post_payment_invoice result = @service.create_post_payment_invoice
assert_not_nil result assert_not_nil result
@@ -173,7 +173,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice) mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
mock_invoice.expects(:pay) mock_invoice.expects(:pay)
Stripe::Invoice.expects(:create).returns(mock_invoice) Stripe::Invoice.expects(:create).returns(mock_invoice)
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
result = @service.create_post_payment_invoice result = @service.create_post_payment_invoice
assert_not_nil result assert_not_nil result
@@ -261,7 +261,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
mock_invoice.expects(:pay) mock_invoice.expects(:pay)
Stripe::Invoice.expects(:create).with(expected_invoice_data).returns(mock_invoice) Stripe::Invoice.expects(:create).with(expected_invoice_data).returns(mock_invoice)
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
result = @service.create_post_payment_invoice result = @service.create_post_payment_invoice
assert_not_nil result assert_not_nil result
@@ -300,7 +300,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
}) })
Stripe::Invoice.expects(:create).returns(mock_invoice) Stripe::Invoice.expects(:create).returns(mock_invoice)
Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee
mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice) mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice)
result = @service.create_post_payment_invoice result = @service.create_post_payment_invoice

View File

@@ -12,6 +12,7 @@ module ActiveSupport
class TestCase class TestCase
# Run tests in parallel with specified workers # Run tests in parallel with specified workers
parallelize(workers: :number_of_processors) parallelize(workers: :number_of_processors)
use_transactional_fixtures = true
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all fixtures :all