## Backend Implementation

Enhanced TicketType model with helper methods and better validations

So the full context is:

## Backend Implementation
- Enhanced TicketType model with helper methods and better validations
- New Promoter::TicketTypesController with full authorization
- Sales status tracking (draft, available, upcoming, expired, sold_out)
- New Promoter::TicketTypesController with full authorization
- Safe calculation methods preventing nil value errors
- Sales status tracking (draft, available, upcoming, expired, sold_out)

## Frontend Features
- Modern responsive UI with Tailwind CSS styling
- Interactive forms with Stimulus controller for dynamic calculations
- Revenue calculators showing potential, current, and remaining revenue
- Status indicators with appropriate colors and icons
- Buyer analytics and purchase history display

## JavaScript Enhancements
- New TicketTypeFormController for dynamic pricing calculations
- Real-time total updates as users type price/quantity
- Proper French currency formatting
- Form validation for minimum quantities based on existing sales

## Bug Fixes
 Fixed nil value errors in price_euros method when price_cents is nil
 Added defensive programming for all calculation methods
 Graceful handling of incomplete ticket types during creation
 Proper default values for new ticket type instances

## Files Added/Modified
- app/controllers/promoter/ticket_types_controller.rb (new)
- app/javascript/controllers/ticket_type_form_controller.js (new)
- app/views/promoter/ticket_types/*.html.erb (4 new view files)
- app/models/ticket_type.rb (enhanced with helper methods)
- config/routes.rb (added nested ticket_types routes)
- db/migrate/*_add_requires_id_to_ticket_types.rb (new migration)

## Integration
- Seamless integration with existing event management system
- Updated promoter event show page with ticket management link
- Proper scoping ensuring promoters only manage their own tickets
- Compatible with existing ticket purchasing and checkout flow
This commit is contained in:
kbe
2025-09-01 00:03:35 +02:00
parent aa5dccb508
commit e838e91162
12 changed files with 1057 additions and 3 deletions

View File

@@ -0,0 +1,104 @@
# Promoter Ticket Types Controller
#
# Handles ticket type (bundle) management for promoters
# Allows promoters to create, edit, delete and manage ticket types for their events
class Promoter::TicketTypesController < ApplicationController
before_action :authenticate_user!
before_action :ensure_can_manage_events!
before_action :set_event
before_action :set_ticket_type, only: [:show, :edit, :update, :destroy]
# Display all ticket types for an event
def index
@ticket_types = @event.ticket_types.order(:created_at)
end
# Display a specific ticket type
def show
# Ticket type is set by set_ticket_type callback
end
# Show form to create a new ticket type
def new
@ticket_type = @event.ticket_types.build
# Set default values
@ticket_type.sale_start_at = Time.current
@ticket_type.sale_end_at = @event.start_time || 1.week.from_now
@ticket_type.requires_id = false
end
# Create a new ticket type
def create
@ticket_type = @event.ticket_types.build(ticket_type_params)
if @ticket_type.save
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet créé avec succès!'
else
render :new, status: :unprocessable_entity
end
end
# Show form to edit an existing ticket type
def edit
# Ticket type is set by set_ticket_type callback
end
# Update an existing ticket type
def update
if @ticket_type.update(ticket_type_params)
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet mis à jour avec succès!'
else
render :edit, status: :unprocessable_entity
end
end
# Delete a ticket type
def destroy
if @ticket_type.tickets.any?
redirect_to promoter_event_ticket_types_path(@event), alert: 'Impossible de supprimer ce type de billet car des billets ont déjà été vendus.'
else
@ticket_type.destroy
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet supprimé avec succès!'
end
end
# Duplicate an existing ticket type
def duplicate
original = @event.ticket_types.find(params[:id])
@ticket_type = original.dup
@ticket_type.name = "#{original.name} (Copie)"
if @ticket_type.save
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet dupliqué avec succès!'
else
redirect_to promoter_event_ticket_types_path(@event), alert: 'Erreur lors de la duplication.'
end
end
private
def ensure_can_manage_events!
unless current_user.can_manage_events?
redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.'
end
end
def set_event
@event = current_user.events.find(params[:event_id])
rescue ActiveRecord::RecordNotFound
redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.'
end
def set_ticket_type
@ticket_type = @event.ticket_types.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to promoter_event_ticket_types_path(@event), alert: 'Type de billet non trouvé.'
end
def ticket_type_params
params.require(:ticket_type).permit(
:name, :description, :price_euros, :quantity,
:sale_start_at, :sale_end_at, :minimum_age, :requires_id
)
end
end

View File

@@ -22,6 +22,9 @@ application.register("header", HeaderController);
import EventFormController from "./event_form_controller" import EventFormController from "./event_form_controller"
application.register("event-form", EventFormController); application.register("event-form", EventFormController);
import TicketTypeFormController from "./ticket_type_form_controller"
application.register("ticket-type-form", TicketTypeFormController);

View File

@@ -0,0 +1,61 @@
import { Controller } from "@hotwired/stimulus"
// Ticket Type Form Controller
// Handles dynamic pricing calculations and form interactions
export default class extends Controller {
static targets = ["price", "quantity", "total"]
connect() {
console.log("Ticket type form controller connected")
this.updateTotal()
}
// Update total revenue calculation when price or quantity changes
updateTotal() {
const price = parseFloat(this.priceTarget.value) || 0
const quantity = parseInt(this.quantityTarget.value) || 0
const total = price * quantity
// Format as currency
const formatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2
})
if (this.hasQuantityTarget && this.hasTotalTarget) {
// For new ticket types, calculate potential revenue
this.totalTarget.textContent = formatter.format(total)
} else if (this.hasTotalTarget) {
// For edit forms, calculate remaining potential revenue
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
const remainingQuantity = Math.max(0, quantity - soldTickets)
const remainingRevenue = price * remainingQuantity
this.totalTarget.textContent = formatter.format(remainingRevenue)
}
}
// Validate minimum quantity (for edit forms with sold tickets)
validateQuantity() {
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
const quantity = parseInt(this.quantityTarget.value) || 0
if (quantity < soldTickets) {
this.quantityTarget.value = soldTickets
this.quantityTarget.setCustomValidity(`La quantité ne peut pas être inférieure à ${soldTickets} (billets déjà vendus)`)
} else {
this.quantityTarget.setCustomValidity('')
}
this.updateTotal()
}
// Format price input to ensure proper decimal places
formatPrice() {
const price = parseFloat(this.priceTarget.value)
if (!isNaN(price)) {
this.priceTarget.value = price.toFixed(2)
}
this.updateTotal()
}
}

View File

@@ -12,7 +12,64 @@ class TicketType < ApplicationRecord
validates :sale_end_at, presence: true validates :sale_end_at, presence: true
validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true
validates :event_id, presence: true validates :event_id, presence: true
validates :requires_id, inclusion: { in: [true, false] }
# Custom validations
validate :sale_end_after_start
validate :sale_times_within_event_period
# Scopes
scope :available_now, -> { where("sale_start_at <= ? AND sale_end_at >= ?", Time.current, Time.current) }
scope :upcoming, -> { where("sale_start_at > ?", Time.current) }
scope :expired, -> { where("sale_end_at < ?", Time.current) }
# Helper methods
def price_euros
return 0.0 if price_cents.nil?
price_cents / 100.0
end
def price_euros=(value)
self.price_cents = (value.to_f * 100).to_i
end
def available?
return false if sale_start_at.nil? || sale_end_at.nil?
sale_start_at <= Time.current && sale_end_at >= Time.current
end
def sold_out?
return false if quantity.nil?
tickets.count >= quantity
end
def available_quantity
return 0 if quantity.nil?
[quantity - tickets.count, 0].max
end
def sales_status
return :draft if sale_start_at.nil? || sale_end_at.nil?
return :expired if sale_end_at < Time.current
return :upcoming if sale_start_at > Time.current
return :sold_out if sold_out?
return :available
end
def total_potential_revenue
return 0.0 if quantity.nil? || price_cents.nil?
quantity * price_euros
end
def current_revenue
return 0.0 if price_cents.nil?
tickets.count * price_euros
end
def remaining_potential_revenue
return 0.0 if quantity.nil? || price_cents.nil?
available_quantity * price_euros
end
private private
@@ -20,4 +77,9 @@ class TicketType < ApplicationRecord
return unless sale_start_at && sale_end_at return unless sale_start_at && sale_end_at
errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at
end end
def sale_times_within_event_period
return unless event&.start_time && sale_end_at
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
end
end end

View File

@@ -209,9 +209,9 @@
<div class="bg-white rounded-lg border border-gray-200 p-6"> <div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-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 "#", 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" do %> <%= 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 %>
<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 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 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 %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i> <i data-lucide="users" class="w-4 h-4 mr-2"></i>

View File

@@ -0,0 +1,224 @@
<% content_for(:title, "Modifier #{@ticket_type.name}") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier le type de billet</h1>
<p class="text-gray-600"><%= @ticket_type.name %></p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event, @ticket_type], local: true, class: "space-y-8", data: { controller: "ticket-type-form" } do |form| %>
<% if @ticket_type.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@ticket_type.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @ticket_type.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Warning if tickets sold -->
<% if @ticket_type.tickets.any? %>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex">
<i data-lucide="alert-triangle" class="w-5 h-5 text-yellow-400 mt-0.5 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-yellow-900">Attention</h3>
<p class="text-sm text-yellow-800 mt-1">
<%= pluralize(@ticket_type.tickets.count, 'billet') %> de ce type ont déjà été vendus.
Modifier certains paramètres pourrait impacter les acheteurs existants.
</p>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="space-y-6">
<div>
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Early Bird, VIP, Standard" %>
</div>
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 3, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez ce qui est inclus dans ce type de billet..." %>
</div>
</div>
</div>
<!-- Pricing & Quantity -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="relative">
<%= form.number_field :price_euros,
step: 0.01,
min: 0.01,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-yellow-600">
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Modifier le prix n'affectera pas les billets déjà vendus
</p>
<% end %>
</div>
<div>
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :quantity,
min: @ticket_type.tickets.count,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-gray-500">
Minimum: <%= @ticket_type.tickets.count %> (billets déjà vendus)
</p>
<% else %>
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
<% end %>
</div>
</div>
<!-- Revenue preview -->
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-900">Revenus potentiels restants</span>
<span class="text-lg font-bold text-purple-600" data-ticket-type-form-target="total">
<%= number_to_currency(@ticket_type.remaining_potential_revenue, unit: "€") %>
</span>
</div>
</div>
<div class="p-4 bg-green-50 rounded-lg border border-green-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-green-900">Revenus déjà générés</span>
<span class="text-lg font-bold text-green-600">
<%= number_to_currency(@ticket_type.current_revenue, unit: "€") %>
</span>
</div>
</div>
</div>
</div>
<!-- Sales Period -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
<% if @ticket_type.tickets.any? %>
<p class="mt-1 text-sm text-yellow-600">
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Des ventes ont déjà eu lieu
</p>
<% end %>
</div>
<div>
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
</div>
<% if @event.start_time %>
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
</p>
</div>
</div>
<% end %>
</div>
<!-- Access Requirements -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :minimum_age,
min: 0,
max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %>
</div>
</div>
<div class="mt-6">
<div class="flex items-start">
<%= form.check_box :requires_id, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
<div class="ml-3">
<%= form.label :requires_id, "Vérification d'identité requise", class: "text-sm font-medium text-gray-700" %>
<p class="text-sm text-gray-500 mt-1">
Cochez si une pièce d'identité sera vérifiée à l'entrée.
<% if @ticket_type.tickets.any? && @ticket_type.requires_id != params.dig(:ticket_type, :requires_id) %>
<br><span class="text-yellow-600">Attention: Cette modification affectera l'expérience des acheteurs existants.</span>
<% end %>
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
<% if @ticket_type.tickets.any? %>
<p class="text-sm text-yellow-600">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
<%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
</p>
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Sauvegarder les modifications", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,170 @@
<% content_for(:title, "Types de billets - #{@event.name}") %>
<div class="container py-8">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Types de billets</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
<%= link_to new_promoter_event_ticket_type_path(@event), class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Nouveau type
<% end %>
</div>
<!-- Event status info -->
<% if @event.draft? %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i data-lucide="info" class="w-5 h-5 text-gray-400 mr-3"></i>
<p class="text-sm text-gray-600">
Cet événement est en brouillon. Les types de billets ne seront visibles qu'une fois l'événement publié.
</p>
</div>
</div>
<% end %>
</div>
<% if @ticket_types.any? %>
<div class="grid gap-6">
<% @ticket_types.each do |ticket_type| %>
<div class="bg-white rounded-lg border border-gray-200 p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between">
<!-- Ticket type info -->
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
<%= link_to ticket_type.name, promoter_event_ticket_type_path(@event, ticket_type), class: "hover:text-purple-600 transition-colors" %>
</h3>
<p class="text-gray-600 mb-3"><%= ticket_type.description %></p>
</div>
<!-- Status badge -->
<div class="ml-4">
<% case ticket_type.sales_status %>
<% when :available %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
En vente
</span>
<% when :upcoming %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-blue-100 text-blue-800">
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
Prochainement
</span>
<% when :sold_out %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="users" class="w-4 h-4 mr-1"></i>
Épuisé
</span>
<% when :expired %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
Expiré
</span>
<% end %>
</div>
</div>
<!-- Ticket details grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-purple-600">
<%= number_to_currency(ticket_type.price_euros, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Prix</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= ticket_type.available_quantity %>/<%= ticket_type.quantity %>
</div>
<div class="text-sm text-gray-500">Disponibles</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= ticket_type.tickets.count %>
</div>
<div class="text-sm text-gray-500">Vendus</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(ticket_type.current_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Revenus</div>
</div>
</div>
<!-- Additional info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<span class="flex items-center">
<i data-lucide="calendar" class="w-4 h-4 mr-1"></i>
Vente: <%= ticket_type.sale_start_at.strftime("%d/%m %H:%M") %> - <%= ticket_type.sale_end_at.strftime("%d/%m %H:%M") %>
</span>
<% if ticket_type.minimum_age %>
<span class="flex items-center">
<i data-lucide="user-check" class="w-4 h-4 mr-1"></i>
Âge min: <%= ticket_type.minimum_age %> ans
</span>
<% end %>
<% if ticket_type.requires_id %>
<span class="flex items-center">
<i data-lucide="id-card" class="w-4 h-4 mr-1"></i>
Pièce d'identité requise
</span>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<%= link_to promoter_event_ticket_type_path(@event, ticket_type), class: "text-gray-400 hover:text-gray-600 transition-colors", title: "Voir" do %>
<i data-lucide="eye" class="w-5 h-5"></i>
<% end %>
<%= link_to edit_promoter_event_ticket_type_path(@event, ticket_type), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
<i data-lucide="edit" class="w-5 h-5"></i>
<% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, ticket_type), method: :post, class: "text-gray-400 hover:text-green-600 transition-colors", title: "Dupliquer" do %>
<i data-lucide="copy" class="w-5 h-5"></i>
<% end %>
<% if ticket_type.tickets.empty? %>
<%= button_to promoter_event_ticket_type_path(@event, ticket_type), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-5 h-5"></i>
<% end %>
<% end %>
</div>
<div class="text-sm text-gray-500">
Créé <%= time_ago_in_words(ticket_type.created_at) %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
<i data-lucide="ticket" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun type de billet</h3>
<p class="text-gray-500 mb-6">Créez des types de billets pour permettre aux utilisateurs d'acheter des places pour votre événement.</p>
<%= link_to new_promoter_event_ticket_type_path(@event), class: "inline-flex items-center px-6 py-3 bg-black text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer mon premier type de billet
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,177 @@
<% content_for(:title, "Nouveau type de billet - #{@event.name}") %>
<div class="container py-8">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<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 %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouveau type de billet</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with model: [:promoter, @event, @ticket_type], local: true, class: "space-y-8", data: { controller: "ticket-type-form" } do |form| %>
<% if @ticket_type.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(@ticket_type.errors.count, "erreur") %> à corriger :
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @ticket_type.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- Basic Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="space-y-6">
<div>
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Early Bird, VIP, Standard" %>
<p class="mt-1 text-sm text-gray-500">Nom affiché aux acheteurs</p>
</div>
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_area :description, rows: 3, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Décrivez ce qui est inclus dans ce type de billet..." %>
<p class="mt-1 text-sm text-gray-500">Description visible lors de l'achat</p>
</div>
</div>
</div>
<!-- Pricing & Quantity -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="relative">
<%= form.number_field :price_euros,
step: 0.01,
min: 0.01,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p>
</div>
<div>
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :quantity,
min: 1,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
</div>
</div>
<!-- Revenue preview -->
<div class="mt-6 p-4 bg-purple-50 rounded-lg border border-purple-200">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-purple-900">Revenus potentiels (si tout vendu)</span>
<span class="text-lg font-bold text-purple-600" data-ticket-type-form-target="total">
<%= number_to_currency(0, unit: "€") %>
</span>
</div>
</div>
</div>
<!-- Sales Period -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
<p class="mt-1 text-sm text-gray-500">Les ventes s'arrêtent automatiquement à cette date</p>
</div>
</div>
<% if @event.start_time %>
<div class="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
</p>
</div>
</div>
<% end %>
</div>
<!-- Access Requirements -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :minimum_age,
min: 0,
max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %>
<p class="mt-1 text-sm text-gray-500">Âge minimum requis (optionnel)</p>
</div>
</div>
<div class="mt-6">
<div class="flex items-start">
<%= form.check_box :requires_id, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %>
<div class="ml-3">
<%= form.label :requires_id, "Vérification d'identité requise", class: "text-sm font-medium text-gray-700" %>
<p class="text-sm text-gray-500 mt-1">
Cochez si une pièce d'identité sera vérifiée à l'entrée. Les noms des participants seront collectés lors de l'achat.
</p>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<%= link_to promoter_event_ticket_types_path(@event), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler
<% end %>
</div>
<div class="flex items-center space-x-3">
<%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,240 @@
<% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %>
<div class="container py-8">
<!-- Header with actions -->
<div class="mb-8">
<div class="flex items-center justify-between">
<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 %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2"><%= @ticket_type.name %></h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
<div class="flex items-center space-x-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 %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier
<% 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 %>
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
Dupliquer
<% end %>
</div>
</div>
</div>
<!-- Status banner -->
<div class="mb-8">
<% case @ticket_type.sales_status %>
<% when :available %>
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="check-circle" class="w-5 h-5 text-green-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-green-900">Type de billet en vente</h3>
<p class="text-sm text-green-700">Ce type de billet est actuellement disponible à l'achat.</p>
</div>
</div>
</div>
<% when :upcoming %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="clock" class="w-5 h-5 text-blue-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-blue-900">Ventes à venir</h3>
<p class="text-sm text-blue-700">Les ventes commenceront le <%= @ticket_type.sale_start_at.strftime("%d/%m/%Y à %H:%M") %>.</p>
</div>
</div>
</div>
<% when :sold_out %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 text-red-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-red-900">Type de billet épuisé</h3>
<p class="text-sm text-red-700">Tous les billets de ce type ont été vendus.</p>
</div>
</div>
</div>
<% when :expired %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center">
<i data-lucide="x-circle" class="w-5 h-5 text-gray-400 mr-3"></i>
<div>
<h3 class="text-sm font-medium text-gray-900">Ventes terminées</h3>
<p class="text-sm text-gray-700">La période de vente pour ce type de billet est terminée.</p>
</div>
</div>
</div>
<% end %>
</div>
<!-- Ticket details -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main content -->
<div class="lg:col-span-2 space-y-8">
<!-- Description -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Description</h3>
<p class="text-gray-700 leading-relaxed"><%= simple_format(@ticket_type.description) %></p>
</div>
<!-- Sales Information -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Période de vente</h3>
<div class="space-y-4">
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Début des ventes</span>
<span class="font-medium"><%= @ticket_type.sale_start_at.strftime("%d/%m/%Y à %H:%M") %></span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Fin des ventes</span>
<span class="font-medium"><%= @ticket_type.sale_end_at.strftime("%d/%m/%Y à %H:%M") %></span>
</div>
<% if @ticket_type.minimum_age %>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-gray-600">Âge minimum</span>
<span class="font-medium"><%= @ticket_type.minimum_age %> ans</span>
</div>
<% end %>
<div class="flex items-center justify-between py-3">
<span class="text-gray-600">Vérification d'identité</span>
<span class="font-medium">
<% if @ticket_type.requires_id %>
<span class="text-green-600">Requise</span>
<% else %>
<span class="text-gray-500">Non requise</span>
<% end %>
</span>
</div>
</div>
</div>
<!-- Buyers List (if any) -->
<% if @ticket_type.tickets.any? %>
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Acheteurs récents</h3>
<div class="space-y-3">
<% @ticket_type.tickets.includes(:user).order(created_at: :desc).limit(10).each do |ticket| %>
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
<div>
<p class="font-medium text-gray-900"><%= ticket.first_name %> <%= ticket.last_name %></p>
<p class="text-sm text-gray-500"><%= ticket.user.email %></p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>
</p>
<p class="text-xs text-gray-500">
<%= ticket.created_at.strftime("%d/%m/%Y") %>
</p>
</div>
</div>
<% end %>
<% if @ticket_type.tickets.count > 10 %>
<p class="text-sm text-gray-500 text-center pt-2">
Et <%= @ticket_type.tickets.count - 10 %> autre(s) acheteur(s)...
</p>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Statistics -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
<div class="space-y-4">
<div class="text-center p-4 bg-purple-50 rounded-lg">
<div class="text-3xl font-bold text-purple-600">
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Prix unitaire</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= @ticket_type.tickets.count %>
</div>
<div class="text-xs text-gray-500">Vendus</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= @ticket_type.available_quantity %>
</div>
<div class="text-xs text-gray-500">Restants</div>
</div>
</div>
<div class="text-center p-4 bg-green-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(@ticket_type.current_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Revenus générés</div>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg">
<div class="text-2xl font-bold text-blue-600">
<%= number_to_currency(@ticket_type.total_potential_revenue, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Potentiel total</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3">
<%= link_to edit_promoter_event_ticket_type_path(@event, @ticket_type), 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" do %>
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
Modifier les détails
<% end %>
<%= button_to duplicate_promoter_event_ticket_type_path(@event, @ticket_type), method: :post, 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" do %>
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
Créer une copie
<% end %>
<hr class="border-gray-200">
<% if @ticket_type.tickets.empty? %>
<%= button_to promoter_event_ticket_type_path(@event, @ticket_type), method: :delete,
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 %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer le type
<% end %>
<% else %>
<div class="w-full inline-flex items-center px-4 py-2 text-gray-400 font-medium rounded-lg cursor-not-allowed">
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Impossible de supprimer
</div>
<p class="text-xs text-gray-500">Des billets ont été vendus</p>
<% end %>
</div>
</div>
<!-- Creation info -->
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
<div class="space-y-3 text-sm">
<div>
<span class="text-gray-500">Créé le</span>
<p><%= @ticket_type.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
<div>
<span class="text-gray-500">Dernière modification</span>
<p><%= @ticket_type.updated_at.strftime("%d/%m/%Y à %H:%M") %></p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -43,6 +43,13 @@ Rails.application.routes.draw do
patch :cancel patch :cancel
patch :mark_sold_out patch :mark_sold_out
end end
# Nested ticket types routes
resources :ticket_types do
member do
post :duplicate
end
end
end end
end end

View File

@@ -0,0 +1,5 @@
class AddRequiresIdToTicketTypes < ActiveRecord::Migration[8.0]
def change
add_column :ticket_types, :requires_id, :boolean, default: false, null: false
end
end

3
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do ActiveRecord::Schema[8.0].define(version: 2025_08_31_184955) do
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "slug", null: false t.string "slug", null: false
@@ -44,6 +44,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
t.bigint "event_id", null: false t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "requires_id", default: false, null: false
t.index ["event_id"], name: "index_ticket_types_on_event_id" t.index ["event_id"], name: "index_ticket_types_on_event_id"
t.index ["sale_end_at"], name: "index_ticket_types_on_sale_end_at" t.index ["sale_end_at"], name: "index_ticket_types_on_sale_end_at"
t.index ["sale_start_at"], name: "index_ticket_types_on_sale_start_at" t.index ["sale_start_at"], name: "index_ticket_types_on_sale_start_at"