3 Commits

15 changed files with 814 additions and 441 deletions

View File

@@ -22,4 +22,7 @@
- When modifying files, preserve existing code style and patterns - When modifying files, preserve existing code style and patterns
- When implementing new features, suggest appropriate file locations and naming conventions - When implementing new features, suggest appropriate file locations and naming conventions
- When debugging, suggest using the project's existing test suite and development tools - When debugging, suggest using the project's existing test suite and development tools
- When suggesting changes, provide clear explanations of why the change is beneficial - When suggesting changes, provide clear explanations of why the change is beneficial
## Qwen Added Memories
- We've implemented the checkout process with name collection for tickets that require identification. We've added first_name and last_name fields to the tickets table, updated the Ticket model with validations, added new routes and controller actions, created a view for collecting names, and updated the JavaScript controller. The database migration needs to be run in the Docker environment when the gem issues are resolved.

View File

@@ -0,0 +1,45 @@
# Checkout Process Implementation
This document describes the implementation of the checkout process with name collection for tickets that require identification.
## Implementation Details
The implementation includes:
1. Database migration to add first_name and last_name fields to tickets
2. Updates to the Ticket model to validate names when required
3. New routes and controller actions for name collection
4. A new view for collecting ticket holder names
5. Updates to the existing JavaScript controller
## Running the Migration
Once the Docker environment is fixed, run the following command to apply the database migration:
```bash
docker compose exec rails bundle exec rails db:migrate
```
## Testing the Implementation
1. Start the Docker containers:
```bash
docker compose up -d
```
2. Visit an event page and select tickets that require identification
3. The checkout process should redirect to the name collection page
4. After submitting names, the user should be redirected to the payment page
5. After successful payment, tickets should be created with the provided names
## Code Structure
- Migration: `db/migrate/20250828143000_add_names_to_tickets.rb`
- Model: `app/models/ticket.rb`
- Controller: `app/controllers/events_controller.rb`
- Views:
- `app/views/events/collect_names.html.erb` (new)
- `app/views/events/show.html.erb` (updated)
- `app/views/components/_ticket_card.html.erb` (updated)
- Routes: `config/routes.rb` (updated)
- JavaScript: `app/javascript/controllers/ticket_cart_controller.js` (no changes needed)

View File

@@ -1,6 +1,6 @@
class EventsController < ApplicationController class EventsController < ApplicationController
before_action :authenticate_user!, only: [ :checkout, :payment_success, :download_ticket ] before_action :authenticate_user!, only: [ :checkout, :collect_names, :process_names, :payment_success, :download_ticket ]
before_action :set_event, only: [ :show, :checkout ] before_action :set_event, only: [ :show, :checkout, :collect_names, :process_names ]
# Display all events # Display all events
def index def index
@@ -12,7 +12,7 @@ class EventsController < ApplicationController
# Event is set by set_event callback # Event is set by set_event callback
end end
# Handle checkout process - Create Stripe session # Handle checkout process - Collect names if needed or create Stripe session
def checkout def checkout
cart_data = JSON.parse(params[:cart] || "{}") cart_data = JSON.parse(params[:cart] || "{}")
@@ -21,6 +21,161 @@ class EventsController < ApplicationController
return return
end end
# Check if any ticket types require names
requires_names = false
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
requires_names = true
break
end
end
# If names are required, redirect to name collection
if requires_names
session[:pending_cart] = cart_data
redirect_to event_collect_names_path(@event.slug, @event)
return
end
# Otherwise proceed directly to payment
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
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
# Store names in session for later use
session[:ticket_names] = params[:ticket_names] if params[:ticket_names]
# Proceed to payment
process_payment(cart_data)
end
# Handle successful payment
def payment_success
session_id = params[:session_id]
event_id = params[:event_id]
begin
session = Stripe::Checkout::Session.retrieve(session_id)
if session.payment_status == "paid"
# Create tickets
@event = Event.find(event_id)
order_items = JSON.parse(session.metadata["order_items"])
@tickets = []
# Get names from session if they exist
ticket_names = session[:ticket_names] || {}
order_items.each do |item|
ticket_type = TicketType.find(item["ticket_type_id"])
item["quantity"].times do |i|
# Get names if this ticket type requires them
first_name = nil
last_name = nil
if ticket_type.requires_id
name_key = "#{ticket_type.id}_#{i}"
names = ticket_names[name_key] || {}
first_name = names["first_name"]
last_name = names["last_name"]
end
ticket = Ticket.create!(
user: current_user,
ticket_type: ticket_type,
status: "active",
first_name: first_name,
last_name: last_name
)
@tickets << ticket
# Send confirmation email for each ticket
TicketMailer.purchase_confirmation(ticket).deliver_now
end
end
# Clear session data
session.delete(:pending_cart)
session.delete(:ticket_names)
render "payment_success"
else
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
end
rescue Stripe::StripeError => e
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{e.message}"
rescue => e
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{e.message}"
end
end
# Download ticket PDF
def download_ticket
@ticket = current_user.tickets.find(params[:ticket_id])
respond_to do |format|
format.pdf do
pdf = @ticket.to_pdf
send_data pdf,
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
end
private
def set_event
@event = Event.find(params[:id])
end
# Process payment and create Stripe session
def process_payment(cart_data)
# Create order items from cart # Create order items from cart
line_items = [] line_items = []
order_items = [] order_items = []
@@ -90,65 +245,4 @@ class EventsController < ApplicationController
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}" redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
end end
end end
# Handle successful payment
def payment_success
session_id = params[:session_id]
event_id = params[:event_id]
begin
session = Stripe::Checkout::Session.retrieve(session_id)
if session.payment_status == "paid"
# Create tickets
@event = Event.find(event_id)
order_items = JSON.parse(session.metadata["order_items"])
@tickets = []
order_items.each do |item|
ticket_type = TicketType.find(item["ticket_type_id"])
item["quantity"].times do
ticket = Ticket.create!(
user: current_user,
ticket_type: ticket_type,
status: "active"
)
@tickets << ticket
# Send confirmation email for each ticket
TicketMailer.purchase_confirmation(ticket).deliver_now
end
end
render "payment_success"
else
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
end
rescue Stripe::StripeError => e
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{e.message}"
rescue => e
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{e.message}"
end
end
# Download ticket PDF
def download_ticket
@ticket = current_user.tickets.find(params[:ticket_id])
respond_to do |format|
format.pdf do
pdf = @ticket.to_pdf
send_data pdf,
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
end
private
def set_event
@event = Event.find(params[:id])
end
end end

View File

@@ -1,3 +1,7 @@
// Entry point for the build script in your package.json // Entry point for the build script in your package.json
import "@hotwired/turbo-rails"; import "@hotwired/turbo-rails";
import Alpine from 'alpinejs';
import "./controllers"; import "./controllers";
window.Alpine = Alpine;
Alpine.start();

View File

@@ -1,5 +1,4 @@
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
import Alpine from "alpinejs";
const application = Application.start(); const application = Application.start();
@@ -7,8 +6,4 @@ const application = Application.start();
application.debug = false; application.debug = false;
window.Stimulus = application; window.Stimulus = application;
// Configure and load Alpine
window.Alpine = Alpine;
Alpine.start();
export { application }; export { application };

View File

@@ -9,11 +9,13 @@ import FlashMessageController from "./flash_message_controller"
import CounterController from "./counter_controller" import CounterController from "./counter_controller"
import FeaturedEventController from "./featured_event_controller" import FeaturedEventController from "./featured_event_controller"
import ShadcnTestController from "./shadcn_test_controller"
application.register("logout", LogoutController) // Allow logout using js application.register("logout", LogoutController) // Allow logout using js
application.register("flash-message", FlashMessageController) // Dismiss notification after 5 secondes application.register("flash-message", FlashMessageController) // Dismiss notification after 5 secondes
application.register("counter", CounterController) // Simple counter for homepage application.register("counter", CounterController) // Simple counter for homepage
application.register("featured-event", FeaturedEventController) // Featured event controller for homepage application.register("featured-event", FeaturedEventController) // Featured event controller for homepage
application.register("shadcn-test", ShadcnTestController) // Test controller for Shadcn
// import ShadcnTestController from "./shadcn_test_controller"
// application.register("shadcn-test", ShadcnTestController) // Test controller for Shadcn

View File

@@ -1,313 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["quantity", "cartCount", "cartTotal", "checkoutButton"]
static values = { eventId: String }
connect() {
this.cart = {}
this.updateCartDisplay()
// Check for pending cart in session storage (after login)
this.checkForPendingCart()
}
increaseQuantity(event) {
const ticketTypeId = event.params.ticketTypeId
const max = parseInt(event.params.max)
const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0
if (current < max) {
input.value = current + 1
this.updateCartItem(ticketTypeId, input)
}
}
decreaseQuantity(event) {
const ticketTypeId = event.params.ticketTypeId
const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0
if (current > 0) {
input.value = current - 1
this.updateCartItem(ticketTypeId, input)
}
}
updateQuantityFromInput(event) {
const input = event.target
const ticketTypeId = input.dataset.ticketTypeId
const max = parseInt(input.max)
const quantity = parseInt(input.value) || 0
// Validate input
if (quantity < 0) {
input.value = 0
} else if (quantity > max) {
input.value = max
}
this.updateCartItem(ticketTypeId, input)
}
updateCartItem(ticketTypeId, input) {
const name = input.dataset.name
const price = parseInt(input.dataset.price)
const quantity = parseInt(input.value) || 0
if (quantity > 0) {
this.cart[ticketTypeId] = {
name: name,
price: price,
quantity: quantity
}
} else {
delete this.cart[ticketTypeId]
}
this.updateCartDisplay()
}
updateCartDisplay() {
let totalTickets = 0
let totalPrice = 0
for (let ticketTypeId in this.cart) {
totalTickets += this.cart[ticketTypeId].quantity
totalPrice += (this.cart[ticketTypeId].price * this.cart[ticketTypeId].quantity) / 100
}
// Update cart count and total
if (this.hasCartCountTarget) {
this.cartCountTarget.textContent = totalTickets
}
if (this.hasCartTotalTarget) {
this.cartTotalTarget.textContent = totalPrice.toFixed(2)
}
// Update checkout button state
if (this.hasCheckoutButtonTarget) {
const checkoutBtn = this.checkoutButtonTarget
if (totalTickets > 0) {
checkoutBtn.disabled = false
checkoutBtn.classList.remove('opacity-50', 'cursor-not-allowed')
} else {
checkoutBtn.disabled = true
checkoutBtn.classList.add('opacity-50', 'cursor-not-allowed')
}
}
}
proceedToCheckout() {
if (Object.keys(this.cart).length === 0) {
this.showNotification('Veuillez sélectionner au moins un billet', 'warning')
return
}
// Validate cart contents
if (!this.validateCartAvailability()) {
return
}
// Check if user is authenticated
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
if (!isAuthenticated) {
this.showLoginModal()
return
}
// Show loading state
this.setCheckoutLoading(true)
// Create form and submit to checkout
const form = document.createElement('form')
form.method = 'POST'
form.action = `/events/${document.body.dataset.eventSlug}.${this.eventIdValue}/checkout`
// Add CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
const csrfInput = document.createElement('input')
csrfInput.type = 'hidden'
csrfInput.name = 'authenticity_token'
csrfInput.value = csrfToken
form.appendChild(csrfInput)
// Add cart data
const cartInput = document.createElement('input')
cartInput.type = 'hidden'
cartInput.name = 'cart'
cartInput.value = JSON.stringify(this.cart)
form.appendChild(cartInput)
document.body.appendChild(form)
form.submit()
}
validateCartAvailability() {
// Check each ticket type availability before checkout
for (let ticketTypeId in this.cart) {
const input = this.quantityTargetFor(ticketTypeId)
if (input) {
const maxAvailable = parseInt(input.max)
const requested = this.cart[ticketTypeId].quantity
if (requested > maxAvailable) {
this.showNotification(`Seulement ${maxAvailable} billets disponibles pour ${this.cart[ticketTypeId].name}`, 'error')
// Adjust cart to maximum available
input.value = maxAvailable
this.updateCartItem(ticketTypeId, input)
return false
}
}
}
return true
}
showLoginModal() {
// Create and show modern login modal
const modal = document.createElement('div')
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'
modal.innerHTML = `
<div class="bg-white rounded-2xl p-8 max-w-md mx-4 shadow-2xl">
<div class="text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-purple-100 mb-4">
<svg class="h-6 w-6 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>
<h3 class="text-lg font-medium text-gray-900 mb-2">Connexion requise</h3>
<p class="text-sm text-gray-500 mb-6">Vous devez être connecté pour acheter des billets. Votre panier sera conservé.</p>
<div class="flex flex-col sm:flex-row gap-3">
<button id="login-btn" class="flex-1 bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
Se connecter
</button>
<button id="cancel-login" class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
Annuler
</button>
</div>
</div>
</div>
`
document.body.appendChild(modal)
// Handle login button
modal.querySelector('#login-btn').addEventListener('click', () => {
// Store cart in session storage
sessionStorage.setItem('pending_cart', JSON.stringify({
eventId: this.eventIdValue,
cart: this.cart
}))
window.location.href = '/auth/sign_in'
})
// Handle cancel button
modal.querySelector('#cancel-login').addEventListener('click', () => {
document.body.removeChild(modal)
})
// Handle backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal)
}
})
}
setCheckoutLoading(loading) {
const checkoutBtn = this.checkoutButtonTarget
if (loading) {
checkoutBtn.disabled = true
checkoutBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Redirection...
`
} else {
checkoutBtn.disabled = false
checkoutBtn.innerHTML = `
<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
`
}
}
showNotification(message, type = 'info') {
// Create toast notification
const toast = document.createElement('div')
const colors = {
success: 'bg-green-50 text-green-800 border-green-200',
error: 'bg-red-50 text-red-800 border-red-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
info: 'bg-blue-50 text-blue-800 border-blue-200'
}
toast.className = `fixed top-4 right-4 z-50 max-w-sm p-4 border rounded-lg shadow-lg ${colors[type]} transform transition-all duration-300 translate-x-full`
toast.innerHTML = `
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium">${message}</p>
</div>
<button class="ml-3 text-sm font-medium opacity-70 hover:opacity-100" onclick="this.parentElement.parentElement.remove()">
×
</button>
</div>
`
document.body.appendChild(toast)
// Animate in
setTimeout(() => {
toast.classList.remove('translate-x-full')
}, 10)
// Auto remove after 5 seconds
setTimeout(() => {
if (document.body.contains(toast)) {
toast.classList.add('translate-x-full')
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast)
}
}, 300)
}
}, 5000)
}
checkForPendingCart() {
const pendingCart = sessionStorage.getItem('pending_cart')
if (pendingCart) {
try {
const cartData = JSON.parse(pendingCart)
if (cartData.eventId == this.eventIdValue) {
this.cart = cartData.cart
this.updateCartDisplay()
// Restore quantities in inputs
for (let ticketTypeId in this.cart) {
const input = this.quantityTargetFor(ticketTypeId)
if (input) {
input.value = this.cart[ticketTypeId].quantity
}
}
}
sessionStorage.removeItem('pending_cart')
} catch (e) {
console.error('Error restoring pending cart:', e)
sessionStorage.removeItem('pending_cart')
}
}
}
// Helper method to find quantity input by ticket type ID
quantityTargetFor(ticketTypeId) {
return document.querySelector(`#quantity_${ticketTypeId}`)
}
}

View File

@@ -10,6 +10,8 @@ class Ticket < ApplicationRecord
validates :ticket_type_id, presence: true validates :ticket_type_id, presence: true
validates :price_cents, presence: true, numericality: { greater_than: 0 } validates :price_cents, presence: true, numericality: { greater_than: 0 }
validates :status, presence: true, inclusion: { in: %w[active used expired refunded] } 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?
before_validation :set_price_from_ticket_type, on: :create before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create before_validation :generate_qr_code, on: :create
@@ -24,6 +26,11 @@ class Ticket < ApplicationRecord
price_cents / 100.0 price_cents / 100.0
end end
# Check if names are required for this ticket type
def requires_names?
ticket_type&.requires_id
end
private private
def set_price_from_ticket_type def set_price_from_ticket_type

View File

@@ -1,4 +1,41 @@
<div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden"> <div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden"
x-data="{
quantity: 0,
max: <%= remaining %>,
ticketTypeId: <%= id %>,
name: '<%= name %>',
price: <%= price_cents %>
}"
x-init="
$watch('quantity', (value) => {
$dispatch('update-cart-item', {
ticketTypeId: ticketTypeId,
name: name,
price: price,
quantity: value,
max: max
});
});
$nextTick(() => {
$dispatch('cart-initialized', { ticketTypeId: ticketTypeId });
});
"
@update-cart-item.window="
if ($event.detail.ticketTypeId === ticketTypeId) {
quantity = $event.detail.quantity;
}
"
@cart-change.window="
if (!$event.detail.cart[ticketTypeId]) {
quantity = 0;
}
"
@restore-cart.window="
if ($event.detail.cart[ticketTypeId]) {
quantity = $event.detail.cart[ticketTypeId].quantity;
}
">
<div class="p-5"> <div class="p-5">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div> <div>
@@ -35,26 +72,19 @@
<div class="flex items-center space-x-2 order-1 sm:order-2"> <div class="flex items-center space-x-2 order-1 sm:order-2">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200" class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="click->ticket-cart#decreaseQuantity" @click="quantity = Math.max(0, quantity - 1)"
data-ticket-cart-ticket-type-id-param="<%= id %>" :disabled="quantity <= 0">
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">-</span> <span class="text-gray-600 font-bold">-</span>
</button> </button>
<input type="number" <input type="number"
id="quantity_<%= id %>"
min="0" min="0"
max="<%= remaining %>" :max="max"
value="0" x-model="quantity"
class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1" class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1">
data-ticket-cart-target="quantity"
data-ticket-type-id="<%= id %>"
data-name="<%= name %>"
data-price="<%= price_cents %>">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200" class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="click->ticket-cart#increaseQuantity" @click="quantity = Math.min(max, quantity + 1)"
data-ticket-cart-ticket-type-id-param="<%= id %>" :disabled="quantity >= max">
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">+</span> <span class="text-gray-600 font-bold">+</span>
</button> </button>
</div> </div>

View File

@@ -0,0 +1,78 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-6" 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">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Informations des participants</h1>
<p class="text-gray-600">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-6" do |form| %>
<% if @tickets_needing_names.any? %>
<div class="space-y-6">
<h2 class="text-xl font-semibold text-gray-900">Billets nécessitant une identification</h2>
<p class="text-gray-600 mb-4">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-gray-50 rounded-xl p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4"><%= ticket[:ticket_type_name] %> #<%= index + 1 %></h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" %>
</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-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500" %>
</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" %>
<%= 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" %>
</div>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,22 +1,56 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" data-controller="ticket-cart" data-ticket-cart-event-id-value="<%= params[:id] %>"> <div
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"
x-data="ticketCart(<%= @event.id %>)"
x-init="init()"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm"> <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 %> <%= 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"> <svg
<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" /> 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> </svg>
Accueil Accueil
<% end %> <% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 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> </svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %> <%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
Événements Événements
<% end %> <% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 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> </svg>
<li class="font-medium text-gray-900 truncate max-w-xs" aria-current="page"> <li class="font-medium text-gray-900 truncate max-w-xs" aria-current="page">
<%= @event.name %> <%= @event.name %>
@@ -29,7 +63,11 @@
<% if @event.image.present? %> <% if @event.image.present? %>
<div class="relative h-96"> <div class="relative h-96">
<%= image_tag @event.image, class: "w-full h-full object-cover" %> <%= image_tag @event.image, class: "w-full h-full object-cover" %>
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div> <div
class="
absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent
"
></div>
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8"> <div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h1 class="text-3xl md:text-4xl font-bold text-white mb-2"><%= @event.name %></h1> <h1 class="text-3xl md:text-4xl font-bold text-white mb-2"><%= @event.name %></h1>
@@ -41,22 +79,46 @@
<h1 class="text-3xl md:text-4xl font-bold text-white mb-4"><%= @event.name %></h1> <h1 class="text-3xl md:text-4xl font-bold text-white mb-4"><%= @event.name %></h1>
<div class="flex flex-wrap items-center gap-4 text-white/90"> <div class="flex flex-wrap items-center gap-4 text-white/90">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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> class="w-5 h-5 mr-2 text-purple-200"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path> 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>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg> </svg>
<span><%= @event.venue_name %></span> <span><%= @event.venue_name %></span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> class="w-5 h-5 mr-2 text-purple-200"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg> </svg>
<span><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></span> <span><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></span>
</div> </div>
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Event Content --> <!-- Event Content -->
<div class="p-6 md:p-8"> <div class="p-6 md:p-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
@@ -72,9 +134,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-gray-50 rounded-xl p-6"> <div class="bg-gray-50 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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> class="w-5 h-5 mr-2 text-purple-600"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path> 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>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg> </svg>
Lieu Lieu
</h3> </h3>
@@ -84,27 +161,44 @@
<div class="bg-gray-50 rounded-xl p-6"> <div class="bg-gray-50 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> class="w-5 h-5 mr-2 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg> </svg>
Date & Heure Date & Heure
</h3> </h3>
<p class="text-gray-700 font-medium"><%= @event.start_time.strftime("%A %d %B %Y") %></p> <p class="text-gray-700 font-medium"><%= @event.start_time.strftime("%A %d %B %Y") %></p>
<p class="text-gray-600 mt-1">À <%= @event.start_time.strftime("%H:%M") %></p> <p class="text-gray-600 mt-1">À
<%= @event.start_time.strftime("%H:%M") %></p>
</div> </div>
</div> </div>
<div class="mb-8 bg-gray-50 rounded-xl p-6"> <div class="mb-8 bg-gray-50 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Organisateur</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Organisateur</h3>
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center text-white font-bold"> <div
class="
w-12 h-12 rounded-full bg-gradient-to-r from-purple-500 to-indigo-600 flex
items-center justify-center text-white font-bold
"
>
<%= @event.user.email.first.upcase %> <%= @event.user.email.first.upcase %>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<% if @event.user.first_name.present? && @event.user.last_name.present? %> <% if @event.user.first_name.present? && @event.user.last_name.present? %>
<p class="font-medium text-gray-900"><%= @event.user.first_name %> <%= @event.user.last_name %></p> <p class="font-medium text-gray-900"><%= @event.user.first_name %>
<%= @event.user.last_name %></p>
<% else %> <% else %>
<p class="font-medium text-gray-900"><%= @event.user.email.split('@').first %></p> <p class="font-medium text-gray-900"><%= @event.user.email.split("@").first %></p>
<% end %> <% end %>
<% if @event.user.company_name.present? %> <% if @event.user.company_name.present? %>
<p class="text-sm text-gray-500"><%= @event.user.company_name %></p> <p class="text-sm text-gray-500"><%= @event.user.company_name %></p>
@@ -113,11 +207,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Right Column: Ticket Selection --> <!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<div class="sticky top-6"> <div class="sticky top-6">
<div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm"> <div
class="
bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border
border-purple-100 p-6 shadow-sm
"
>
<h2 class="text-2xl font-bold text-gray-900 mb-6">Billets disponibles</h2> <h2 class="text-2xl font-bold text-gray-900 mb-6">Billets disponibles</h2>
<% if @event.ticket_types.any? %> <% if @event.ticket_types.any? %>
@@ -126,44 +224,66 @@
<% sold_out = ticket_type.quantity <= ticket_type.tickets.count %> <% sold_out = ticket_type.quantity <= ticket_type.tickets.count %>
<% remaining = ticket_type.quantity - ticket_type.tickets.count %> <% remaining = ticket_type.quantity - ticket_type.tickets.count %>
<%= render 'components/ticket_card', { <%= render "components/ticket_card",
{
id: ticket_type.id, id: ticket_type.id,
name: ticket_type.name, name: ticket_type.name,
description: ticket_type.description, description: ticket_type.description,
price_cents: ticket_type.price_cents, price_cents: ticket_type.price_cents,
quantity: ticket_type.quantity, quantity: ticket_type.quantity,
sold_out: sold_out, sold_out: sold_out,
remaining: remaining remaining: remaining,
} %> } %>
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<div class="text-center py-8"> <div class="text-center py-8">
<svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" /> class="w-12 h-12 mx-auto text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
/>
</svg> </svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">Aucun billet disponible</h3> <h3 class="mt-4 text-lg font-medium text-gray-900">Aucun billet disponible</h3>
<p class="mt-2 text-gray-500">Les billets pour cet événement ne sont pas encore disponibles ou sont épuisés.</p> <p class="mt-2 text-gray-500">Les billets pour cet événement ne sont pas encore
disponibles ou sont épuisés.</p>
</div> </div>
<% end %> <% end %>
<!-- Cart Summary --> <!-- Cart Summary -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<span class="text-gray-600">Total billets:</span> <span class="text-gray-600">Quantité :</span>
<span data-ticket-cart-target="cartCount" class="font-medium">0</span> <span x-text="totalTickets" class="font-medium">0</span>
</div> </div>
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<span class="text-gray-600">Montant total:</span> <span class="text-gray-600">Montant total:</span>
<span data-ticket-cart-target="cartTotal" class="text-xl font-bold text-purple-700">€0.00</span> <span x-text="formattedTotalPrice" class="text-xl font-bold text-purple-700">€0.00</span>
</div> </div>
<button <button
data-ticket-cart-target="checkoutButton" @click="proceedToCheckout"
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-3 px-4 rounded-xl shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 flex items-center justify-center" :disabled="totalTickets === 0"
disabled :class="totalTickets === 0 ? "opacity-50 cursor-not-allowed" : "hover:from-purple-700 hover:to-indigo-700""
data-action="click->ticket-cart#proceedToCheckout"> class="
w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-medium
py-3 px-4 rounded-xl shadow-sm transition-all duration-200 focus:outline-none
focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 flex items-center
justify-center
"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
Procéder au paiement Procéder au paiement
</button> </button>
@@ -176,3 +296,252 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("ticketCart", (eventId) => ({
cart: {},
eventId: eventId,
init() {
// Listen for cart item updates from ticket cards
this.$nextTick(() => {
window.addEventListener("update-cart-item", (event) => {
const { ticketTypeId, name, price, quantity, max } = event.detail;
this.updateCartItem(ticketTypeId, name, price, quantity, max);
});
});
// Check for pending cart in session storage (after login)
this.checkForPendingCart();
},
get totalTickets() {
return Object.values(this.cart).reduce((total, item) => total + item.quantity, 0);
},
get totalPrice() {
return Object.values(this.cart).reduce((total, item) => total + (item.price * item.quantity), 0);
},
get formattedTotalPrice() {
return `€${(this.totalPrice / 100).toFixed(2)}`;
},
updateCartItem(ticketTypeId, name, price, quantity, max) {
// Validate quantity
if (quantity < 0) quantity = 0;
if (quantity > max) quantity = max;
if (quantity > 0) {
this.cart[ticketTypeId] = {
name: name,
price: price,
quantity: quantity
};
} else {
delete this.cart[ticketTypeId];
}
},
validateCartAvailability() {
// This would need to be implemented with a server check in a real app
// For now, we"ll assume availability is correct
return true;
},
proceedToCheckout() {
if (this.totalTickets === 0) {
this.showNotification("Veuillez sélectionner au moins un billet", "warning");
return;
}
// Validate cart contents
if (!this.validateCartAvailability()) {
return;
}
// Check if user is authenticated
const isAuthenticated = document.body.dataset.userAuthenticated === "true";
if (!isAuthenticated) {
this.showLoginModal();
return;
}
// Show loading state
this.setCheckoutLoading(true);
// Create form and submit to checkout
const form = document.createElement("form");
form.method = "POST";
form.action = "/events/" + document.body.dataset.eventSlug + "." + this.eventId + "/checkout";
// Add CSRF token
const csrfToken = document.querySelector("meta[name="csrf-token"]").content;
const csrfInput = document.createElement("input");
csrfInput.type = "hidden";
csrfInput.name = "authenticity_token";
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
// Add cart data
const cartInput = document.createElement("input");
cartInput.type = "hidden";
cartInput.name = "cart";
cartInput.value = JSON.stringify(this.cart);
form.appendChild(cartInput);
document.body.appendChild(form);
form.submit();
},
showLoginModal() {
// Create and show modern login modal
const modal = document.createElement("div");
modal.className = "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50";
modal.innerHTML = `
<div class="bg-white rounded-2xl p-8 max-w-md mx-4 shadow-2xl">
<div class="text-center">
<div
class="
mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-purple-100
mb-4
"
>
<svg
class="h-6 w-6 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>
<h3 class="text-lg font-medium text-gray-900 mb-2">Connexion requise</h3>
<p class="text-sm text-gray-500 mb-6">Vous devez être connecté pour acheter des billets. Votre panier sera
conservé.</p>
<div class="flex flex-col sm:flex-row gap-3">
<button
id="login-btn"
class="
flex-1 bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg
font-medium transition-colors
"
>
Se connecter
</button>
<button
id="cancel-login"
class="
flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg
font-medium transition-colors
"
>
Annuler
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Handle login button
modal.querySelector("#login-btn").addEventListener("click", () => {
// Store cart in session storage
sessionStorage.setItem("pending_cart", JSON.stringify({
eventId: this.eventId,
cart: this.cart
}));
window.location.href = "/auth/sign_in";
});
// Handle cancel button
modal.querySelector("#cancel-login").addEventListener("click", () => {
document.body.removeChild(modal);
});
// Handle backdrop click
modal.addEventListener("click", (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
},
setCheckoutLoading(loading) {
// This would update the checkout button UI
// Implementation depends on how you want to show loading state
},
showNotification(message, type = "info") {
// Create toast notification
const toast = document.createElement("div");
const colors = {
success: "bg-green-50 text-green-800 border-green-200",
error: "bg-red-50 text-red-800 border-red-200",
warning: "bg-yellow-50 text-yellow-800 border-yellow-200",
info: "bg-blue-50 text-blue-800 border-blue-200"
};
toast.className = `fixed top-4 right-4 z-50 max-w-sm p-4 border rounded-lg shadow-lg ${colors[type]} transform transition-all duration-300 translate-x-full`;
toast.innerHTML = `
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium">${message}</p>
</div>
<button
class="ml-3 text-sm font-medium opacity-70 hover:opacity-100"
onclick="this.parentElement.parentElement.remove()"
>
×
</button>
</div>
`;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.classList.remove("translate-x-full");
}, 10);
// Auto remove after 5 seconds
setTimeout(() => {
if (document.body.contains(toast)) {
toast.classList.add("translate-x-full");
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}
}, 5000);
},
checkForPendingCart() {
const pendingCart = sessionStorage.getItem("pending_cart");
if (pendingCart) {
try {
const cartData = JSON.parse(pendingCart);
if (cartData.eventId == this.eventId) {
this.cart = cartData.cart;
// Dispatch event to update quantities in ticket cards
this.$dispatch("restore-cart", { cart: this.cart });
}
sessionStorage.removeItem("pending_cart");
} catch (e) {
console.error("Error restoring pending cart:", e);
sessionStorage.removeItem("pending_cart");
}
}
}
}));
});
</script>

View File

@@ -0,0 +1,47 @@
# Checkout Process Implementation Summary
## Database Changes
1. **Migration**: Added `first_name` and `last_name` fields to the `tickets` table
- File: `db/migrate/20250828143000_add_names_to_tickets.rb`
## Model Changes
1. **Ticket Model**:
- Added validations for `first_name` and `last_name` when required by ticket type
- Added `requires_names?` method to check if names are required based on ticket type
## Controller Changes
1. **Events Controller**:
- Modified `checkout` action to redirect to name collection when tickets require names
- Added `collect_names` action to display form for collecting ticket holder names
- Added `process_names` action to handle submitted names and proceed to payment
- Updated `payment_success` action to create tickets with names when provided
## View Changes
1. **Events Show View**:
- Added `change` event listener to quantity inputs in ticket cards
2. **Ticket Card Component**:
- Added `change` event listener to quantity inputs
3. **New View**:
- Created `app/views/events/collect_names.html.erb` for collecting ticket holder names
## Route Changes
1. **New Routes**:
- `GET events/:slug.:id/names` - Collect names for tickets requiring identification
- `POST events/:slug.:id/names` - Process submitted names and proceed to payment
## JavaScript Changes
1. **Ticket Cart Controller**:
- No changes needed as name collection is handled server-side
## Outstanding Tasks
1. Run the database migration in the Docker environment once gem issues are resolved
2. Test the complete checkout flow with name collection

View File

@@ -19,6 +19,8 @@ Rails.application.routes.draw do
get "events", to: "events#index", as: "events" get "events", to: "events#index", as: "events"
get "events/:slug.:id", to: "events#show", as: "event" get "events/:slug.:id", to: "events#show", as: "event"
post "events/:slug.:id/checkout", to: "events#checkout", as: "event_checkout" post "events/:slug.:id/checkout", to: "events#checkout", as: "event_checkout"
get "events/:slug.:id/names", to: "events#collect_names", as: "event_collect_names"
post "events/:slug.:id/names", to: "events#process_names", as: "event_process_names"
# Payment success # Payment success
get "payments/success", to: "events#payment_success", as: "payment_success" get "payments/success", to: "events#payment_success", as: "payment_success"

View File

@@ -5,7 +5,11 @@ class CreateTickets < ActiveRecord::Migration[8.0]
t.integer :price_cents t.integer :price_cents
t.string :status, default: "active" t.string :status, default: "active"
t.references :user, null: false, foreign_key: false # Add names to ticket
t.string :first_name
t.string :last_name
t.references :user, null: true, foreign_key: false
t.references :ticket_type, null: false, foreign_key: false t.references :ticket_type, null: false, foreign_key: false
t.timestamps t.timestamps
@@ -14,5 +18,9 @@ class CreateTickets < ActiveRecord::Migration[8.0]
add_index :tickets, :qr_code, unique: true add_index :tickets, :qr_code, unique: true
add_index :tickets, :user_id unless index_exists?(:tickets, :user_id) add_index :tickets, :user_id unless index_exists?(:tickets, :user_id)
add_index :tickets, :ticket_type_id unless index_exists?(:tickets, :ticket_type_id) add_index :tickets, :ticket_type_id unless index_exists?(:tickets, :ticket_type_id)
# Add indexes for better performance
# add_index :tickets, :first_name unless index_exists?(:tickets, :first_name)
# add_index :tickets, :last_name unless index_exists?(:tickets, :last_name)
end end
end end

4
db/schema.rb generated
View File

@@ -54,7 +54,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
t.string "qr_code" t.string "qr_code"
t.integer "price_cents" t.integer "price_cents"
t.string "status", default: "active" t.string "status", default: "active"
t.bigint "user_id", null: false t.string "first_name"
t.string "last_name"
t.bigint "user_id"
t.bigint "ticket_type_id", null: false t.bigint "ticket_type_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