feat: Implement complete ticket purchasing flow with new TicketsController
- Create new TicketsController with actions for name collection, creation, and checkout - Add dedicated ticket views (new.html.erb, checkout.html.erb, show.html.erb) - Update ticket_selection_controller.js to handle form submission via AJAX - Add store_cart endpoint in EventsController for session-based cart management - Update routes to support new ticket flow: /tickets/new, /create, /checkout - Fix attribute name consistency across views (title→name, starts_at→start_time) - Add Stripe checkout integration with proper error handling - Remove deprecated collect_names flow in favor of streamlined approach The flow is now: Event selection → AJAX cart storage → Name collection → Checkout → Payment
This commit is contained in:
18
app/controllers/concerns/stripe_concern.rb
Normal file
18
app/controllers/concerns/stripe_concern.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module StripeConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Check if Stripe is properly configured
|
||||
def stripe_configured?
|
||||
Rails.application.config.stripe[:secret_key].present?
|
||||
end
|
||||
|
||||
# Stripe is now initialized at application startup, so this method is no longer needed
|
||||
# but kept for backward compatibility
|
||||
def initialize_stripe
|
||||
return false unless stripe_configured?
|
||||
|
||||
# Stripe is already initialized at application startup
|
||||
Rails.logger.debug "Stripe already initialized at application startup"
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,12 @@
|
||||
# Events controller
|
||||
#
|
||||
# This controller manages all events. It load events for homepage
|
||||
# and display for pagination.
|
||||
class EventsController < ApplicationController
|
||||
before_action :authenticate_user!, only: [ :checkout, :collect_names, :process_names, :payment_success, :download_ticket ]
|
||||
before_action :set_event, only: [ :show, :checkout, :collect_names, :process_names ]
|
||||
include StripeConcern
|
||||
|
||||
before_action :authenticate_user!, only: [ :checkout, :process_names, :payment_success, :download_ticket ]
|
||||
before_action :set_event, only: [ :show, :checkout, :process_names, :store_cart ]
|
||||
|
||||
# Display all events
|
||||
def index
|
||||
@@ -8,6 +14,8 @@ class EventsController < ApplicationController
|
||||
end
|
||||
|
||||
# Display desired event
|
||||
#
|
||||
# Find requested event and display it to the user
|
||||
def show
|
||||
# Event is set by set_event callback
|
||||
end
|
||||
@@ -17,12 +25,12 @@ class EventsController < ApplicationController
|
||||
# Convert cart parameter to proper hash
|
||||
cart_param = params[:cart]
|
||||
cart_data = if cart_param.is_a?(String)
|
||||
JSON.parse(cart_param)
|
||||
elsif cart_param.is_a?(ActionController::Parameters)
|
||||
cart_param.to_unsafe_h
|
||||
else
|
||||
{}
|
||||
end
|
||||
JSON.parse(cart_param)
|
||||
elsif cart_param.is_a?(ActionController::Parameters)
|
||||
cart_param.to_unsafe_h
|
||||
else
|
||||
{}
|
||||
end
|
||||
|
||||
if cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
||||
@@ -55,41 +63,15 @@ class EventsController < ApplicationController
|
||||
process_payment(cart_data)
|
||||
end
|
||||
|
||||
# Display form to collect names for tickets
|
||||
def collect_names
|
||||
@cart_data = session[:pending_cart] || {}
|
||||
|
||||
if @cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
||||
return
|
||||
end
|
||||
|
||||
# Build list of tickets requiring names
|
||||
@tickets_needing_names = []
|
||||
@cart_data.each do |ticket_type_id, item|
|
||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||
next unless ticket_type
|
||||
|
||||
quantity = item["quantity"].to_i
|
||||
next if quantity <= 0
|
||||
|
||||
if ticket_type.requires_id
|
||||
quantity.times do |i|
|
||||
@tickets_needing_names << {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
index: i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Process submitted names and create Stripe session
|
||||
def process_names
|
||||
Rails.logger.debug "Processing names for event: #{@event.id}"
|
||||
|
||||
cart_data = session[:pending_cart] || {}
|
||||
|
||||
if cart_data.empty?
|
||||
Rails.logger.debug "Cart data is empty"
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
||||
return
|
||||
end
|
||||
@@ -104,26 +86,38 @@ class EventsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.debug "Proceeding to payment with cart data: #{cart_data}"
|
||||
# Proceed to payment
|
||||
process_payment(cart_data)
|
||||
end
|
||||
|
||||
# Store cart data in session (AJAX endpoint)
|
||||
def store_cart
|
||||
cart_data = params[:cart] || {}
|
||||
session[:pending_cart] = cart_data
|
||||
|
||||
render json: { status: "success", message: "Cart stored successfully" }
|
||||
rescue => e
|
||||
Rails.logger.error "Error storing cart: #{e.message}"
|
||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||
end
|
||||
|
||||
# Handle successful payment
|
||||
def payment_success
|
||||
session_id = params[:session_id]
|
||||
event_id = params[:event_id]
|
||||
|
||||
# Check if Stripe is properly configured
|
||||
unless stripe_configured?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||
|
||||
unless stripe_configured
|
||||
redirect_to dashboard_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||
return
|
||||
end
|
||||
|
||||
# Initialize Stripe during checkout
|
||||
unless initialize_stripe
|
||||
redirect_to event_path(@event.slug, @event), alert: "Impossible d'initialiser le système de paiement. Veuillez réessayer plus tard."
|
||||
return
|
||||
end
|
||||
# Stripe is now initialized at application startup, no need to initialize here
|
||||
Rails.logger.debug "Payment success - Using globally initialized Stripe"
|
||||
|
||||
begin
|
||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
@@ -203,6 +197,9 @@ class EventsController < ApplicationController
|
||||
|
||||
# Process payment and create Stripe session
|
||||
def process_payment(cart_data)
|
||||
Rails.logger.debug "Starting process_payment method"
|
||||
Rails.logger.debug "Cart data: #{cart_data}"
|
||||
|
||||
# Create order items from cart
|
||||
line_items = []
|
||||
order_items = []
|
||||
@@ -254,19 +251,27 @@ class EventsController < ApplicationController
|
||||
# Get ticket names from session if they exist
|
||||
ticket_names = session[:ticket_names] || {}
|
||||
|
||||
# Debug: Log Stripe configuration status
|
||||
Rails.logger.debug "Stripe configuration check:"
|
||||
Rails.logger.debug " Config: #{Rails.application.config.stripe}"
|
||||
Rails.logger.debug " Secret key present: #{Rails.application.config.stripe[:secret_key].present?}"
|
||||
Rails.logger.debug " stripe_configured? method exists: #{respond_to?(:stripe_configured?)}"
|
||||
|
||||
# Check if Stripe is properly configured
|
||||
unless stripe_configured?
|
||||
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||
Rails.logger.debug " Direct stripe_configured check: #{stripe_configured}"
|
||||
|
||||
unless stripe_configured
|
||||
Rails.logger.error "Stripe not configured properly - redirecting to event page"
|
||||
redirect_to event_path(@event.slug, @event), alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||
return
|
||||
end
|
||||
|
||||
# Initialize Stripe during checkout
|
||||
unless initialize_stripe
|
||||
redirect_to event_path(@event.slug, @event), alert: "Impossible d'initialiser le système de paiement. Veuillez réessayer plus tard."
|
||||
return
|
||||
end
|
||||
# Stripe is now initialized at application startup, no need to initialize here
|
||||
Rails.logger.debug " Using globally initialized Stripe"
|
||||
|
||||
begin
|
||||
Rails.logger.debug "Creating Stripe Checkout Session"
|
||||
# Create Stripe Checkout Session
|
||||
session = Stripe::Checkout::Session.create({
|
||||
payment_method_types: [ "card" ],
|
||||
@@ -283,8 +288,10 @@ class EventsController < ApplicationController
|
||||
}
|
||||
})
|
||||
|
||||
Rails.logger.debug "Redirecting to Stripe session URL: #{session.url}"
|
||||
redirect_to session.url, allow_other_host: true
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Stripe error: #{e.message}"
|
||||
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
171
app/controllers/tickets_controller.rb
Normal file
171
app/controllers/tickets_controller.rb
Normal file
@@ -0,0 +1,171 @@
|
||||
# Manage tickets creation
|
||||
#
|
||||
# This controller permit users to create a new ticket for an event,
|
||||
# complete their details and proceed to payment
|
||||
class TicketsController < ApplicationController
|
||||
before_action :authenticate_user!, only: [ :new ]
|
||||
before_action :set_event, only: [ :new ]
|
||||
|
||||
# Handle new ticket creation
|
||||
#
|
||||
# Once user selected ticket types he wans for an event
|
||||
# he cames here where he can complete his details (first_name, last_name)
|
||||
# for each ticket ordered
|
||||
def new
|
||||
@cart_data = session[:pending_cart] || {}
|
||||
|
||||
if @cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
||||
return
|
||||
end
|
||||
|
||||
# Build list of tickets requiring names
|
||||
@tickets_needing_names = []
|
||||
@cart_data.each do |ticket_type_id, item|
|
||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||
next unless ticket_type
|
||||
|
||||
quantity = item["quantity"].to_i
|
||||
next if quantity <= 0
|
||||
|
||||
quantity.times do |i|
|
||||
@tickets_needing_names << {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
index: i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Create a new ticket
|
||||
#
|
||||
# Here new tickets are created but still in draft state.
|
||||
# When user is ready he can proceed to payment
|
||||
def create
|
||||
@cart_data = session[:pending_cart] || {}
|
||||
|
||||
if @cart_data.empty?
|
||||
redirect_to event_path(params[:slug], params[:id]), alert: "Aucun billet sélectionné"
|
||||
return
|
||||
end
|
||||
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
@tickets = []
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
ticket_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||
|
||||
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||
|
||||
ticket = current_user.tickets.build(
|
||||
ticket_type: ticket_type,
|
||||
first_name: ticket_attrs[:first_name],
|
||||
last_name: ticket_attrs[:last_name],
|
||||
status: "draft"
|
||||
)
|
||||
|
||||
if ticket.save
|
||||
@tickets << ticket
|
||||
else
|
||||
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
if @tickets.present?
|
||||
session[:draft_ticket_ids] = @tickets.map(&:id)
|
||||
session.delete(:pending_cart)
|
||||
redirect_to ticket_checkout_path(@event.slug, @event.id)
|
||||
else
|
||||
flash[:alert] = "Aucun billet valide créé"
|
||||
redirect_to ticket_new_path(@event.slug, @event.id)
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
flash[:alert] = "Une erreur est survenue: #{e.message}"
|
||||
redirect_to ticket_new_path(params[:slug], params[:id])
|
||||
end
|
||||
|
||||
# Display payment page
|
||||
#
|
||||
# Display a sumup of all tickets ordered by user and permit it
|
||||
# to go to payment page.
|
||||
# Here the user can pay for a ticket a bundle of tickets
|
||||
def checkout
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
draft_ticket_ids = session[:draft_ticket_ids] || []
|
||||
|
||||
if draft_ticket_ids.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
|
||||
return
|
||||
end
|
||||
|
||||
@tickets = current_user.tickets.includes(:ticket_type)
|
||||
.where(id: draft_ticket_ids, status: "draft")
|
||||
|
||||
if @tickets.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Billets non trouvés ou déjà traités"
|
||||
return
|
||||
end
|
||||
|
||||
@total_amount = @tickets.sum(&:price_cents)
|
||||
|
||||
# Create Stripe checkout session if Stripe is configured
|
||||
if Rails.application.config.stripe[:secret_key].present?
|
||||
begin
|
||||
@checkout_session = create_stripe_session
|
||||
rescue => e
|
||||
Rails.logger.error "Stripe checkout session creation failed: #{e.message}"
|
||||
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@ticket = current_user.tickets.includes(:ticket_type, :event).find(params[:ticket_id])
|
||||
@event = @ticket.event
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
end
|
||||
|
||||
def ticket_params
|
||||
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
|
||||
end
|
||||
|
||||
def create_stripe_session
|
||||
line_items = @tickets.map do |ticket|
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "#{@event.name} - #{ticket.ticket_type.name}",
|
||||
description: ticket.ticket_type.description
|
||||
},
|
||||
unit_amount: ticket.price_cents
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
end
|
||||
|
||||
Stripe::Checkout::Session.create(
|
||||
payment_method_types: [ "card" ],
|
||||
line_items: line_items,
|
||||
mode: "payment",
|
||||
success_url: payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: ticket_checkout_url(@event.slug, @event.id),
|
||||
metadata: {
|
||||
event_id: @event.id,
|
||||
user_id: current_user.id,
|
||||
ticket_ids: @tickets.pluck(:id).join(",")
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,11 @@
|
||||
module StripeHelper
|
||||
# Check if Stripe is properly configured
|
||||
def stripe_configured?
|
||||
Rails.application.config.stripe[:secret_key].present?
|
||||
end
|
||||
|
||||
# Initialize Stripe with the configured API key
|
||||
def initialize_stripe
|
||||
return false unless stripe_configured?
|
||||
|
||||
Stripe.api_key = Rails.application.config.stripe[:secret_key]
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to initialize Stripe: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
# Safely call Stripe methods with error handling
|
||||
def safe_stripe_call(&block)
|
||||
return nil unless stripe_configured?
|
||||
# Check if Stripe is properly configured
|
||||
return nil unless Rails.application.config.stripe[:secret_key].present?
|
||||
|
||||
# Initialize Stripe if not already done
|
||||
initialize_stripe unless Stripe.api_key.present?
|
||||
# Stripe is now initialized at application startup
|
||||
Rails.logger.debug "Using globally initialized Stripe"
|
||||
|
||||
begin
|
||||
yield if block_given?
|
||||
|
||||
2
app/helpers/tickets_helper.rb
Normal file
2
app/helpers/tickets_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module TicketsHelper
|
||||
end
|
||||
@@ -18,7 +18,7 @@ export default class extends Controller {
|
||||
// Auto-dismiss after 2 seconds
|
||||
this.timeout = setTimeout(() => {
|
||||
this.close()
|
||||
}, 2000)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Clean up the timeout when the controller disconnects
|
||||
@@ -32,7 +32,7 @@ export default class extends Controller {
|
||||
close() {
|
||||
// Add opacity transition classes
|
||||
this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300')
|
||||
|
||||
|
||||
// Remove element after transition completes
|
||||
setTimeout(() => {
|
||||
this.element.remove()
|
||||
|
||||
@@ -4,37 +4,37 @@ import { Controller } from "@hotwired/stimulus"
|
||||
// Manages mobile menu toggle and user dropdown menu
|
||||
export default class extends Controller {
|
||||
static targets = ["mobileMenu", "mobileMenuButton", "userMenu", "userMenuButton"]
|
||||
|
||||
|
||||
connect() {
|
||||
// Initialize menu states
|
||||
this.mobileMenuOpen = false
|
||||
this.userMenuOpen = false
|
||||
|
||||
|
||||
// Add click outside listener for user menu
|
||||
this.clickOutsideHandler = this.handleClickOutside.bind(this)
|
||||
document.addEventListener("click", this.clickOutsideHandler)
|
||||
}
|
||||
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listener
|
||||
document.removeEventListener("click", this.clickOutsideHandler)
|
||||
}
|
||||
|
||||
|
||||
// Toggle mobile menu visibility
|
||||
toggleMobileMenu() {
|
||||
this.mobileMenuOpen = !this.mobileMenuOpen
|
||||
this.mobileMenuTarget.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||
|
||||
|
||||
// Update button icon based on state
|
||||
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||
|
||||
|
||||
if (iconOpen && iconClose) {
|
||||
iconOpen.classList.toggle("hidden", this.mobileMenuOpen)
|
||||
iconClose.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Toggle user dropdown menu visibility
|
||||
toggleUserMenu() {
|
||||
this.userMenuOpen = !this.userMenuOpen
|
||||
@@ -42,32 +42,32 @@ export default class extends Controller {
|
||||
this.userMenuTarget.classList.toggle("hidden", !this.userMenuOpen)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Close menus when clicking outside
|
||||
handleClickOutside(event) {
|
||||
// Close user menu if clicked outside
|
||||
if (this.userMenuOpen && this.hasUserMenuTarget &&
|
||||
!this.userMenuTarget.contains(event.target) &&
|
||||
!this.userMenuButtonTarget.contains(event.target)) {
|
||||
if (this.userMenuOpen && this.hasUserMenuTarget &&
|
||||
!this.userMenuTarget.contains(event.target) &&
|
||||
!this.userMenuButtonTarget.contains(event.target)) {
|
||||
this.userMenuOpen = false
|
||||
this.userMenuTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
|
||||
// Close mobile menu if clicked outside
|
||||
if (this.mobileMenuOpen &&
|
||||
!this.mobileMenuTarget.contains(event.target) &&
|
||||
!this.mobileMenuButtonTarget.contains(event.target)) {
|
||||
if (this.mobileMenuOpen &&
|
||||
!this.mobileMenuTarget.contains(event.target) &&
|
||||
!this.mobileMenuButtonTarget.contains(event.target)) {
|
||||
this.mobileMenuOpen = false
|
||||
this.mobileMenuTarget.classList.add("hidden")
|
||||
|
||||
|
||||
// Update button icon
|
||||
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||
|
||||
|
||||
if (iconOpen && iconClose) {
|
||||
iconOpen.classList.remove("hidden")
|
||||
iconClose.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
||||
// Log when the controller is mounted
|
||||
connect() {
|
||||
// Display a message when the controller is mounted
|
||||
console.log("LogoutController mounted", this.element);
|
||||
// console.log("LogoutController mounted", this.element);
|
||||
}
|
||||
|
||||
// Handle the sign out action
|
||||
|
||||
@@ -3,11 +3,20 @@ import { Controller } from "@hotwired/stimulus"
|
||||
// Controller for handling ticket selection on the event show page
|
||||
// Manages quantity inputs, calculates totals, and enables/disables the checkout button
|
||||
export default class extends Controller {
|
||||
static targets = ["quantityInput", "totalQuantity", "totalAmount", "checkoutButton"]
|
||||
static targets = ["quantityInput", "totalQuantity", "totalAmount", "checkoutButton", "form"]
|
||||
static values = { eventSlug: String, eventId: String }
|
||||
|
||||
// Initialize the controller and update the cart summary
|
||||
connect() {
|
||||
this.updateCartSummary()
|
||||
this.bindFormSubmission()
|
||||
}
|
||||
|
||||
// Bind form submission to handle cart storage
|
||||
bindFormSubmission() {
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.addEventListener('submit', this.submitCart.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
// Increment the quantity for a specific ticket type
|
||||
@@ -76,4 +85,66 @@ export default class extends Controller {
|
||||
this.checkoutButtonTarget.disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission - store cart in session before proceeding
|
||||
async submitCart(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const cartData = this.buildCartData()
|
||||
|
||||
if (Object.keys(cartData).length === 0) {
|
||||
alert('Veuillez sélectionner au moins un billet')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Store cart data in session
|
||||
await this.storeCartInSession(cartData)
|
||||
|
||||
// Redirect to tickets/new page
|
||||
const ticketNewUrl = `/events/${this.eventSlugValue}.${this.eventIdValue}/tickets/new`
|
||||
window.location.href = ticketNewUrl
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error storing cart:', error)
|
||||
alert('Une erreur est survenue. Veuillez réessayer.')
|
||||
}
|
||||
}
|
||||
|
||||
// Build cart data from current form state
|
||||
buildCartData() {
|
||||
const cartData = {}
|
||||
|
||||
this.quantityInputTargets.forEach(input => {
|
||||
const quantity = parseInt(input.value) || 0
|
||||
if (quantity > 0) {
|
||||
const ticketTypeId = input.dataset.target
|
||||
cartData[ticketTypeId] = {
|
||||
quantity: quantity
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cartData
|
||||
}
|
||||
|
||||
// Store cart data in session via AJAX
|
||||
async storeCartInSession(cartData) {
|
||||
const storeCartUrl = `/events/${this.eventSlugValue}.${this.eventIdValue}/store_cart`
|
||||
|
||||
const response = await fetch(storeCartUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ cart: cartData })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to store cart data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
class Ticket < ApplicationRecord
|
||||
# Associations
|
||||
# === Associations ===
|
||||
belongs_to :user
|
||||
belongs_to :ticket_type
|
||||
has_one :event, through: :ticket_type
|
||||
|
||||
# Validations
|
||||
# === Validations ===
|
||||
validates :qr_code, presence: true, uniqueness: true
|
||||
validates :user_id, presence: true
|
||||
validates :ticket_type_id, presence: true
|
||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :status, presence: true, inclusion: { in: %w[active used expired refunded] }
|
||||
validates :first_name, presence: true, if: :requires_names?
|
||||
validates :last_name, presence: true, if: :requires_names?
|
||||
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||
validates :first_name, presence: true
|
||||
validates :last_name, presence: true
|
||||
|
||||
before_validation :set_price_from_ticket_type, on: :create
|
||||
before_validation :generate_qr_code, on: :create
|
||||
@@ -26,11 +26,6 @@ class Ticket < ApplicationRecord
|
||||
price_cents / 100.0
|
||||
end
|
||||
|
||||
# Check if names are required for this ticket type
|
||||
def requires_names?
|
||||
ticket_type&.requires_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_price_from_ticket_type
|
||||
@@ -40,7 +35,7 @@ class Ticket < ApplicationRecord
|
||||
|
||||
def generate_qr_code
|
||||
return if qr_code.present?
|
||||
|
||||
|
||||
loop do
|
||||
self.qr_code = SecureRandom.uuid
|
||||
break unless Ticket.exists?(qr_code: qr_code)
|
||||
|
||||
@@ -10,8 +10,6 @@ class TicketType < ApplicationRecord
|
||||
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :sale_start_at, presence: true
|
||||
validates :sale_end_at, presence: true
|
||||
validate :sale_end_after_start
|
||||
validates :requires_id, inclusion: { in: [ true, false ] }
|
||||
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
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Accueil
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
Événements
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<%= link_to event_path(@event.slug, @event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<%= @event.name %>
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<li class="font-medium text-gray-900" aria-current="page">
|
||||
Informations des participants
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto bg-purple-100 rounded-full p-3 w-16 h-16 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Informations des participants</h1>
|
||||
<p class="text-gray-600 max-w-md mx-auto">Veuillez fournir les prénoms et noms des personnes qui utiliseront les billets.</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: event_process_names_path(@event.slug, @event), method: :post, local: true, class: "space-y-8" do |form| %>
|
||||
<% if @tickets_needing_names.any? %>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<div class="bg-purple-600 rounded-full p-2 mr-3">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Billets nécessitant une identification</h2>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6 text-center">Les billets suivants nécessitent que vous indiquiez le prénom et le nom de chaque participant.</p>
|
||||
|
||||
<% @tickets_needing_names.each_with_index do |ticket, index| %>
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl p-6 border border-purple-100">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="bg-purple-500 rounded-lg p-2 mr-3">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900"><%= ticket[:ticket_type_name] %> #<%= index + 1 %></h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<%= form.label "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][first_name]", "Prénom", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= form.text_field "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][first_name]",
|
||||
required: true,
|
||||
class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm",
|
||||
placeholder: "Entrez le prénom" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][last_name]", "Nom", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= form.text_field "ticket_names[#{ticket[:ticket_type_id]}_#{ticket[:index]}][last_name]",
|
||||
required: true,
|
||||
class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm",
|
||||
placeholder: "Entrez le nom" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-6">
|
||||
<%= link_to "Retour", event_path(@event.slug, @event), class: "px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 text-center font-medium transition-colors duration-200" %>
|
||||
<%= form.submit "Procéder au paiement", class: "flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transform hover:-translate-y-0.5" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,14 @@
|
||||
>
|
||||
<% if event.image.present? %>
|
||||
<div class="h-48 overflow-hidden">
|
||||
<%= image_tag event.image, class: "w-full h-full object-cover" %>
|
||||
<%= link_to event_path(event.slug, event) do %>
|
||||
<img
|
||||
src="<%= event.image %>"
|
||||
alt="<%= event.name %>"
|
||||
class="featured-event-image"
|
||||
data-featured-event-target="animated"
|
||||
>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div
|
||||
|
||||
@@ -208,8 +208,12 @@
|
||||
|
||||
<!-- Right Column: Ticket Selection -->
|
||||
<div class="lg:col-span-1">
|
||||
<%= form_with url: event_checkout_path(@event.slug, @event), method: :post, id: "checkout_form", local: true, data: { controller: "ticket-selection" } do |form| %>
|
||||
|
||||
<%= form_with url: "#", method: :post, id: "checkout_form", local: true, data: {
|
||||
controller: "ticket-selection",
|
||||
ticket_selection_target: "form",
|
||||
ticket_selection_event_slug_value: @event.slug,
|
||||
ticket_selection_event_id_value: @event.id
|
||||
} do |form| %>
|
||||
|
||||
<div class="">
|
||||
<div
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<% if flash.any? %>
|
||||
<div class="flash-messages-container" style="position: relative; width: 100%; display: flex; justify-content: center; padding: var(--space-4); margin-top: var(--space-4);">
|
||||
<div style="width: 100%; max-width: 600px;">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="notification <%= flash_class(type) %>"
|
||||
data-controller="flash-message"
|
||||
style="display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); border-radius: var(--radius-lg); margin-bottom: var(--space-3); font-weight: 500; width: 100%; box-sizing: border-box;">
|
||||
<div class="notification-icon" style="display: flex; align-items: center; flex-shrink: 0;">
|
||||
<%= flash_icon(type) %>
|
||||
<div class="container">
|
||||
<div class="relative w-full flex justify-center p-4 mt-4">
|
||||
<div class="w-full max-w-xl">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="notification <%= flash_class(type) %> flex items-center gap-3 p-4 rounded-lg mb-3 font-medium w-full box-border"
|
||||
data-controller="flash-message">
|
||||
<div class="notification-icon flex items-center shrink-0">
|
||||
<%= flash_icon(type) %>
|
||||
</div>
|
||||
<span class="flex-1"><%= message %></span>
|
||||
<button data-action="click->flash-message#close"
|
||||
class="bg-transparent border-none cursor-pointer p-1 text-inherit opacity-70 transition-opacity duration-200">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span style="flex: 1;"><%= message %></span>
|
||||
<button data-action="click->flash-message#close"
|
||||
style="background: none; border: none; cursor: pointer; padding: var(--space-1); color: inherit; opacity: 0.7; transition: opacity 0.2s;">
|
||||
<i data-lucide="x" style="width: 16px; height: 16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% end %>
|
||||
163
app/views/tickets/checkout.html.erb
Normal file
163
app/views/tickets/checkout.html.erb
Normal file
@@ -0,0 +1,163 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
Accueil
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
Événements
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<%= link_to event_path(@event.slug, @event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<%= @event.name %>
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<li class="font-medium text-gray-900" aria-current="page">Paiement</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Order Summary -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8 h-fit">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto bg-green-100 rounded-full p-3 w-16 h-16 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Récapitulatif de votre commande</h2>
|
||||
<p class="text-gray-600">Vérifiez les détails de vos billets avant le paiement</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Info -->
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl p-6 mb-6 border border-purple-100">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2"><%= @event.name %></h3>
|
||||
<div class="flex items-center text-gray-600 text-sm mb-2">
|
||||
<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"/>
|
||||
</svg>
|
||||
<%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600 text-sm">
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<%= @event.venue_name %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets List -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">Vos billets</h4>
|
||||
<% @tickets.each do |ticket| %>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h5 class="font-medium text-gray-900"><%= ticket.ticket_type.name %></h5>
|
||||
<p class="text-sm text-gray-600"><%= ticket.first_name %> <%= ticket.last_name %></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-900"><%= number_to_currency(ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex items-center justify-between text-xl font-bold text-gray-900">
|
||||
<span>Total</span>
|
||||
<span><%= number_to_currency(@total_amount / 100.0, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Section -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto bg-purple-100 rounded-full p-3 w-16 h-16 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Paiement sécurisé</h2>
|
||||
<p class="text-gray-600">Procédez au paiement de vos billets</p>
|
||||
</div>
|
||||
|
||||
<% if @checkout_session.present? %>
|
||||
<!-- Stripe Checkout -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-blue-800 text-sm">Paiement sécurisé avec Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script>
|
||||
const stripe = Stripe('<%= Rails.application.config.stripe[:publishable_key] %>');
|
||||
|
||||
function redirectToCheckout() {
|
||||
stripe.redirectToCheckout({
|
||||
sessionId: '<%= @checkout_session.id %>'
|
||||
}).then(function (result) {
|
||||
if (result.error) {
|
||||
alert(result.error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick="redirectToCheckout()"
|
||||
class="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-4 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transform hover:-translate-y-0.5">
|
||||
<span class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
Procéder au paiement
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Fallback when Stripe is not configured -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-yellow-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<span class="text-yellow-800 text-sm">Le paiement en ligne n'est pas configuré</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-gray-600 p-6">
|
||||
<p class="mb-4">Veuillez contacter l'organisateur pour finaliser votre réservation.</p>
|
||||
<p class="text-sm">Vos billets ont été créés et sont en attente de paiement.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 pt-4 border-t">
|
||||
<%= link_to "Retour aux détails",
|
||||
ticket_new_path(@event.slug, @event.id),
|
||||
class: "w-full inline-block text-center px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 font-medium transition-colors duration-200" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
2
app/views/tickets/create.html.erb
Normal file
2
app/views/tickets/create.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Tickets#create</h1>
|
||||
<p>Find me in app/views/tickets/create.html.erb</p>
|
||||
195
app/views/tickets/new.html.erb
Executable file
195
app/views/tickets/new.html.erb
Executable file
@@ -0,0 +1,195 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<svg
|
||||
class="w-4 h-4 inline-block mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Accueil
|
||||
<% end %>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
Événements
|
||||
<% end %>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<%= link_to event_path(@event.slug, @event), class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<%= @event.name %>
|
||||
<% end %>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<li class="font-medium text-gray-900" aria-current="page">
|
||||
Informations des participants
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="
|
||||
mx-auto bg-purple-100 rounded-full p-3 w-16 h-16 flex items-center
|
||||
justify-center mb-4
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Informations des participants</h1>
|
||||
<p class="text-gray-600 max-w-md mx-auto">Veuillez fournir les prénoms et noms des personnes qui utiliseront
|
||||
les billets.</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: ticket_create_path(@event.slug, @event), method: :post, local: true, class: "space-y-8" do |form| %>
|
||||
<% if @tickets_needing_names.any? %>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<div class="bg-purple-600 rounded-full p-2 mr-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Billets nécessitant une identification</h2>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6 text-center">Les billets suivants nécessitent que vous indiquiez le prénom
|
||||
et le nom de chaque participant.</p>
|
||||
|
||||
<% @tickets_needing_names.each_with_index do |ticket, index| %>
|
||||
<div
|
||||
class="
|
||||
bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl p-6 border
|
||||
border-purple-100
|
||||
"
|
||||
>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="bg-purple-500 rounded-lg p-2 mr-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900"><%= ticket[:ticket_type_name] %>
|
||||
#<%= index + 1 %></h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<%= form.label "tickets_attributes[#{index}][first_name]",
|
||||
"Prénom",
|
||||
class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= form.text_field "tickets_attributes[#{index}][first_name]",
|
||||
required: true,
|
||||
class:
|
||||
"w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm",
|
||||
placeholder: "Entrez le prénom" %>
|
||||
<%= form.hidden_field "tickets_attributes[#{index}][ticket_type_id]", value: ticket[:ticket_type_id] %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label "tickets_attributes[#{index}][last_name]",
|
||||
"Nom",
|
||||
class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= form.text_field "tickets_attributes[#{index}][last_name]",
|
||||
required: true,
|
||||
class:
|
||||
"w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 shadow-sm",
|
||||
placeholder: "Entrez le nom" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-6">
|
||||
<%= link_to "Retour",
|
||||
event_path(@event.slug, @event),
|
||||
class:
|
||||
"px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 text-center font-medium transition-colors duration-200" %>
|
||||
<%= form.submit "Procéder au paiement",
|
||||
class:
|
||||
"flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transform hover:-translate-y-0.5" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
190
app/views/tickets/show.html.erb
Normal file
190
app/views/tickets/show.html.erb
Normal file
@@ -0,0 +1,190 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-8" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
<%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
Accueil
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<%= link_to dashboard_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
|
||||
Tableau de bord
|
||||
<% end %>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<li class="font-medium text-gray-900" aria-current="page">Billet #<%= @ticket.id %></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Ticket Header -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 px-8 py-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-white mb-2">Billet Électronique</h1>
|
||||
<p class="text-purple-100">ID: #<%= @ticket.id %></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium <%=
|
||||
case @ticket.status
|
||||
when 'active' then 'bg-green-100 text-green-800'
|
||||
when 'draft' then 'bg-yellow-100 text-yellow-800'
|
||||
when 'used' then 'bg-gray-100 text-gray-800'
|
||||
when 'expired' then 'bg-red-100 text-red-800'
|
||||
when 'refunded' then 'bg-blue-100 text-blue-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%=
|
||||
case @ticket.status
|
||||
when 'active' then 'Valide'
|
||||
when 'draft' then 'En attente'
|
||||
when 'used' then 'Utilisé'
|
||||
when 'expired' then 'Expiré'
|
||||
when 'refunded' then 'Remboursé'
|
||||
else @ticket.status.humanize
|
||||
end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Event Details -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Détails de l'événement</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Événement</label>
|
||||
<p class="text-lg font-semibold text-gray-900"><%= @event.name %></p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Date et heure</label>
|
||||
<div class="flex items-center text-gray-900">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" 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"/>
|
||||
</svg>
|
||||
<%= @event.start_time.strftime("%d %B %Y") %><br>
|
||||
<small class="text-gray-600"><%= @event.start_time.strftime("%H:%M") %></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Lieu</label>
|
||||
<div class="flex items-center text-gray-900">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<%= @event.venue_name %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Type de billet</label>
|
||||
<p class="text-gray-900 font-medium"><%= @ticket.ticket_type.name %></p>
|
||||
<p class="text-sm text-gray-600"><%= @ticket.ticket_type.description %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Prix</label>
|
||||
<p class="text-xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Details -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Informations du billet</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Prénom</label>
|
||||
<p class="text-gray-900 font-medium"><%= @ticket.first_name %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Nom</label>
|
||||
<p class="text-gray-900 font-medium"><%= @ticket.last_name %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Date d'achat</label>
|
||||
<p class="text-gray-900"><%= @ticket.created_at.strftime("%d %B %Y à %H:%M") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Code QR</label>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div class="inline-block bg-white p-4 rounded-lg shadow-sm">
|
||||
<!-- QR Code would be generated here -->
|
||||
<div class="w-32 h-32 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2 font-mono"><%= @ticket.qr_code %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<%= link_to dashboard_path,
|
||||
class: "px-6 py-3 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 text-center font-medium transition-colors duration-200" do %>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/>
|
||||
</svg>
|
||||
Retour au tableau de bord
|
||||
<% end %>
|
||||
|
||||
<% if @ticket.status == 'active' %>
|
||||
<%= link_to "#",
|
||||
class: "flex-1 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-6 rounded-xl shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transform hover:-translate-y-0.5 text-center" do %>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
Télécharger le PDF
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Notice -->
|
||||
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-blue-800 font-medium mb-1">Informations importantes</h3>
|
||||
<ul class="text-blue-700 text-sm space-y-1">
|
||||
<li>• Présentez ce billet (ou son code QR) à l'entrée de l'événement</li>
|
||||
<li>• Arrivez en avance pour éviter les files d'attente</li>
|
||||
<li>• En cas de problème, contactez l'organisateur</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user