Compare commits
18 Commits
24126eb834
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 580b24bbed | |||
| a8d3bc12ae | |||
|
|
b228d5a174 | ||
|
|
61ad8c64d4 | ||
|
|
4e06f91acb | ||
|
|
28eddb22ab | ||
|
|
a34eb7aa38 | ||
|
|
aa68885b84 | ||
|
|
c1dde7914c | ||
|
|
dbb972e490 | ||
|
|
049e5505ef | ||
|
|
d6184b6c84 | ||
|
|
4cde466f9a | ||
|
|
ee43996a77 | ||
|
|
f0d32bf3f1 | ||
|
|
20f926cd7a | ||
|
|
d1ef962f74 | ||
|
|
e84d9aad5b |
1
Gemfile
1
Gemfile
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
app/controllers/api/v1/carts_controller.rb
Normal file
25
app/controllers/api/v1/carts_controller.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" ],
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
71
app/javascript/controllers/countdown_controller.js
Normal file
71
app/javascript/controllers/countdown_controller.js
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/javascript/controllers/event_duplication_controller.js
Normal file
53
app/javascript/controllers/event_duplication_controller.js
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -118,8 +118,8 @@ export default class extends Controller {
|
|||||||
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
# 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
|
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||||
<% crumbs.each_with_index do |crumb, index| %>
|
<% crumbs.each_with_index do |crumb, index| %>
|
||||||
<% if crumb[:path].present? %>
|
<% if crumb[:path].present? %>
|
||||||
<%# Crumb with link %>
|
<%# Crumb with link %>
|
||||||
<%= 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 %>
|
<%= 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 %>
|
||||||
<% if index == 0 %>
|
<% if index == 0 %>
|
||||||
<i data-lucide="home" class="w-4 h-4 mr-2"></i>
|
<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 %>
|
<% end %>
|
||||||
|
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
|
||||||
<%= crumb[:name] %>
|
<%= crumb[:name] %>
|
||||||
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%# Current page (no link) %>
|
<%# Current page (no link) %>
|
||||||
<span class="text-sm font-medium text-primary-600 truncate max-w-xs" aria-current="page">
|
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
|
||||||
<%= crumb[:name] %>
|
<%= crumb[:name] %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Separator (except for the last item) %>
|
<%# Separator (except for the last item) %>
|
||||||
<% if index < crumbs.length - 1 %>
|
<% if index < crumbs.length - 1 %>
|
||||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
|
<% 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 %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Show ellipsis on mobile when there are more than 3 items %>
|
||||||
|
<% if crumbs.length > 3 %>
|
||||||
|
<span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -135,7 +135,11 @@
|
|||||||
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,
|
||||||
|
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| %>
|
} 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -141,6 +133,9 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
id="checkout-button"
|
id="checkout-button"
|
||||||
|
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"
|
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">
|
||||||
@@ -199,11 +194,14 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
console.log('Incrementing payment attempt for order:', orderId);
|
||||||
|
const response = await fetch(incrementUrl, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,9 +224,10 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Redirect to Stripe
|
// Redirect to Stripe
|
||||||
console.log('Redirecting to Stripe with session ID:', '<%= @checkout_session&.id %>');
|
const sessionId = checkoutButton.dataset.sessionId;
|
||||||
|
console.log('Redirecting to Stripe with session ID:', sessionId);
|
||||||
const stripeResult = await stripe.redirectToCheckout({
|
const stripeResult = await stripe.redirectToCheckout({
|
||||||
sessionId: '<%= @checkout_session.id %>'
|
sessionId: sessionId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stripeResult.error) {
|
if (stripeResult.error) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -96,15 +96,6 @@
|
|||||||
|
|
||||||
<!-- 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' %>">
|
||||||
@@ -112,7 +103,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Invoice -->
|
<!-- View Invoice -->
|
||||||
<% if @order.status == 'paid' || @order.status == 'completed' %>
|
<% if @order.status == 'paid' || @order.status == 'completed' %>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<%= link_to promoter_event_path(event) do %>
|
||||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
<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>
|
||||||
|
|||||||
@@ -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 class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
|
<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>
|
<p class="text-gray-600">Gérez tous vos événements depuis cette interface</p>
|
||||||
</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 %>
|
<%= 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 un événement
|
Créer un événement
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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 %>
|
||||||
|
|||||||
@@ -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="space-y-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %>
|
<%= 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" %>
|
<%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %>
|
||||||
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
@@ -166,4 +163,3 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,59 +13,65 @@
|
|||||||
|
|
||||||
<!-- 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>
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<%= 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 %>
|
<!-- Action buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<%= 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="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 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">
|
||||||
|
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
|
||||||
|
Dupliquer
|
||||||
|
</button>
|
||||||
|
|
||||||
<% if @event.draft? %>
|
<% if @event.draft? %>
|
||||||
<% if @event.ticket_types.blank? %>
|
<% if @event.ticket_types.blank? %>
|
||||||
<%= 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 %>
|
<%= 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>
|
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||||
Publier
|
Publier
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% 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 %>
|
<%= 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>
|
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||||
Publier
|
Publier
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif @event.published? %>
|
<% 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 %>
|
<%= 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>
|
<i data-lucide="download" class="w-4 h-4 mr-2"></i>
|
||||||
Dépublier
|
Dépublier
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @event.published? %>
|
<% if @event.published? %>
|
||||||
<%= 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 %>
|
<%= 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>
|
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
|
||||||
Annuler
|
Annuler
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status banner -->
|
<!-- Status banner -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
## 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** ❌ |
|
||||||
@@ -20,11 +22,13 @@
|
|||||||
## 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
|
||||||
@@ -39,11 +43,13 @@ Platform Fee = 3-5% of ticket price
|
|||||||
| €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
|
||||||
@@ -58,6 +64,7 @@ Platform Fee = €1.50 + 2% of ticket price
|
|||||||
| €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,18 +73,20 @@ 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 |
|
||||||
@@ -86,12 +95,14 @@ Platform Fee = €1.50 + 2% of ticket price
|
|||||||
## 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,7 +348,7 @@ 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 |
|
||||||
@@ -320,24 +358,26 @@ Our main competitor charges a simple €1 flat fee per order. Here's how our mod
|
|||||||
### 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** |
|
||||||
@@ -346,9 +386,11 @@ Platform Fee = €0.75 + 2.5% of ticket price
|
|||||||
### 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
|
||||||
58
test/controllers/api/v1/events_controller_test.rb
Normal file
58
test/controllers/api/v1/events_controller_test.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
33
test/controllers/concerns/stripe_concern_test.rb
Normal file
33
test/controllers/concerns/stripe_concern_test.rb
Normal 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
|
||||||
128
test/controllers/promoter/events_controller_test.rb
Normal file
128
test/controllers/promoter/events_controller_test.rb
Normal 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
|
||||||
22
test/controllers/promoter/ticket_types_controller_test.rb
Normal file
22
test/controllers/promoter/ticket_types_controller_test.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ===
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user