Compare commits
5 Commits
bf035de402
...
be3d80e541
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3d80e541 | ||
|
|
0b58768a24 | ||
|
|
911e821948 | ||
|
|
2fd93dc3bf | ||
|
|
a3dce5c363 |
90
app/assets/stylesheets/pages/events.css
Executable file
90
app/assets/stylesheets/pages/events.css
Executable file
@@ -0,0 +1,90 @@
|
||||
/* Events page specific styles */
|
||||
|
||||
.events-page {
|
||||
background: linear-gradient(135deg, var(--color-neutral-50) 0%, var(--color-neutral-100) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.events-page .breadcrumb {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.events-page .event-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all var(--duration-slow) var(--ease-out);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-page .event-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
border-color: var(--color-primary-200);
|
||||
}
|
||||
|
||||
.events-page .event-date-badge {
|
||||
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-accent-100) 100%);
|
||||
color: var(--color-primary-800);
|
||||
font-weight: 700;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.events-page .price-highlight {
|
||||
color: var(--color-primary-600);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.events-page .pagination {
|
||||
margin-top: var(--space-12);
|
||||
}
|
||||
|
||||
.events-page .pagination .page,
|
||||
.events-page .pagination .next,
|
||||
.events-page .pagination .last,
|
||||
.events-page .pagination .prev,
|
||||
.events-page .pagination .first {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: 0 var(--space-1);
|
||||
transition: all var(--duration-normal);
|
||||
}
|
||||
|
||||
.events-page .pagination .page:hover,
|
||||
.events-page .pagination .next:hover,
|
||||
.events-page .pagination .last:hover,
|
||||
.events-page .pagination .prev:hover,
|
||||
.events-page .pagination .first:hover {
|
||||
background: var(--color-primary-100);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.events-page .pagination .current {
|
||||
background: var(--color-primary-600);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.events-page .no-events-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--space-12);
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.events-page .event-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.events-page .no-events-card {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
class EventsController < ApplicationController
|
||||
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
|
||||
before_action :set_event, only: [:show, :checkout]
|
||||
|
||||
before_action :authenticate_user!, only: [ :checkout, :payment_success, :download_ticket ]
|
||||
before_action :set_event, only: [ :show, :checkout ]
|
||||
|
||||
# Display all events
|
||||
def index
|
||||
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
||||
@@ -43,14 +43,14 @@ class EventsController < ApplicationController
|
||||
# Create Stripe line item
|
||||
line_items << {
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "#{@event.name} - #{ticket_type.name}",
|
||||
description: ticket_type.description,
|
||||
description: ticket_type.description
|
||||
},
|
||||
unit_amount: ticket_type.price_cents,
|
||||
unit_amount: ticket_type.price_cents
|
||||
},
|
||||
quantity: quantity,
|
||||
quantity: quantity
|
||||
}
|
||||
|
||||
# Store for ticket creation
|
||||
@@ -72,10 +72,10 @@ class EventsController < ApplicationController
|
||||
begin
|
||||
# Create Stripe Checkout Session
|
||||
session = Stripe::Checkout::Session.create({
|
||||
payment_method_types: ['card'],
|
||||
payment_method_types: [ "card" ],
|
||||
line_items: line_items,
|
||||
mode: 'payment',
|
||||
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
|
||||
mode: "payment",
|
||||
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
|
||||
cancel_url: event_url(@event.slug, @event),
|
||||
customer_email: current_user.email,
|
||||
metadata: {
|
||||
@@ -98,29 +98,29 @@ class EventsController < ApplicationController
|
||||
|
||||
begin
|
||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
|
||||
if session.payment_status == 'paid'
|
||||
|
||||
if session.payment_status == "paid"
|
||||
# Create tickets
|
||||
@event = Event.find(event_id)
|
||||
order_items = JSON.parse(session.metadata['order_items'])
|
||||
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_type = TicketType.find(item["ticket_type_id"])
|
||||
item["quantity"].times do
|
||||
ticket = Ticket.create!(
|
||||
user: current_user,
|
||||
ticket_type: ticket_type,
|
||||
status: 'active'
|
||||
status: "active"
|
||||
)
|
||||
@tickets << ticket
|
||||
|
||||
|
||||
# Send confirmation email for each ticket
|
||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
render 'payment_success'
|
||||
render "payment_success"
|
||||
else
|
||||
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
|
||||
end
|
||||
@@ -134,14 +134,14 @@ class EventsController < ApplicationController
|
||||
# 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,
|
||||
send_data pdf,
|
||||
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
type: "application/pdf",
|
||||
disposition: "attachment"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -102,7 +102,12 @@ export default class extends Controller {
|
||||
|
||||
proceedToCheckout() {
|
||||
if (Object.keys(this.cart).length === 0) {
|
||||
alert('Veuillez sélectionner au moins un billet')
|
||||
this.showNotification('Veuillez sélectionner au moins un billet', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate cart contents
|
||||
if (!this.validateCartAvailability()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,17 +115,13 @@ export default class extends Controller {
|
||||
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (confirm('Vous devez être connecté pour acheter des billets. Souhaitez-vous vous connecter maintenant ?')) {
|
||||
// Store cart in session storage
|
||||
sessionStorage.setItem('pending_cart', JSON.stringify({
|
||||
eventId: this.eventIdValue,
|
||||
cart: this.cart
|
||||
}))
|
||||
window.location.href = '/auth/sign_in'
|
||||
}
|
||||
this.showLoginModal()
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.setCheckoutLoading(true)
|
||||
|
||||
// Create form and submit to checkout
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
@@ -145,6 +146,141 @@ export default class extends Controller {
|
||||
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) {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mt-4 gap-3">
|
||||
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
|
||||
<% if sold_out %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<% unless sold_out %>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-2 order-1 sm:order-2">
|
||||
<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"
|
||||
data-action="click->ticket-cart#decreaseQuantity"
|
||||
@@ -59,7 +59,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
<div class="text-sm text-gray-500 font-medium order-1 sm:order-2">
|
||||
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
|
||||
@@ -1,23 +1,81 @@
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div class="flex justify-between items-center my-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= @events.total_count %> événements trouvés
|
||||
<%= @events.total_count %>
|
||||
événements trouvés
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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 %>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<% if @events.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @events.each do |event| %>
|
||||
<div class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div
|
||||
class="
|
||||
bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all
|
||||
duration-300 transform hover:-translate-y-1
|
||||
"
|
||||
>
|
||||
<% if event.image.present? %>
|
||||
<div class="h-48 overflow-hidden">
|
||||
<%= image_tag event.image, class: "w-full h-full object-cover" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-white opacity-80" 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" />
|
||||
<div
|
||||
class="
|
||||
h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center
|
||||
justify-center
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="w-16 h-16 text-white opacity-80"
|
||||
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>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -26,14 +84,24 @@
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
|
||||
<p class="text-sm text-gray-500 mt-1 flex items-center">
|
||||
<p class="text-xs text-gray-500 flex items-center mt-1">
|
||||
<svg class="w-4 h-4 mr-1" 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" />
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<%= event.user.email.split('@').first %>
|
||||
<%= event.venue_name.truncate(20) %>
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<span
|
||||
class="
|
||||
inline-flex items-center my-2 px-2.5 py-2 rounded-full text-xs font-medium
|
||||
bg-purple-100 text-purple-800
|
||||
"
|
||||
>
|
||||
<%= event.start_time.strftime("%d/%m") %>
|
||||
</span>
|
||||
</div>
|
||||
@@ -46,13 +114,8 @@
|
||||
<div>
|
||||
<% if event.ticket_types.any? %>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 flex items-center mt-1">
|
||||
<svg class="w-4 h-4 mr-1" 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" />
|
||||
</svg>
|
||||
<%= event.venue_name.truncate(20) %>
|
||||
À partir de
|
||||
<%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">Pas de billets disponibles</p>
|
||||
@@ -62,7 +125,12 @@
|
||||
<%= link_to event_path(event.slug, event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %>
|
||||
Détails
|
||||
<svg class="w-4 h-4 ml-2" 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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -77,14 +145,32 @@
|
||||
<% else %>
|
||||
<div class="text-center py-16">
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg class="w-12 h-12 text-purple-600" 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" />
|
||||
<div
|
||||
class="
|
||||
w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full
|
||||
flex items-center justify-center mb-6
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-purple-600"
|
||||
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>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
|
||||
<p class="text-gray-500 mb-6">Il n'y a aucun événement à venir pour le moment.</p>
|
||||
<%= link_to "Retour à l'accueil", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
|
||||
<%= link_to "Retour à l'accueil",
|
||||
root_path,
|
||||
class:
|
||||
"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -151,14 +151,14 @@
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-gray-600">Total billets:</span>
|
||||
<span id="cart-count" class="font-medium">0</span>
|
||||
<span data-ticket-cart-target="cartCount" class="font-medium">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<span class="text-gray-600">Montant total:</span>
|
||||
<span id="cart-total" class="text-xl font-bold text-purple-700">€0.00</span>
|
||||
<span data-ticket-cart-target="cartTotal" class="text-xl font-bold text-purple-700">€0.00</span>
|
||||
</div>
|
||||
<button
|
||||
id="checkout-btn"
|
||||
data-ticket-cart-target="checkoutButton"
|
||||
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
|
||||
data-action="click->ticket-cart#proceedToCheckout">
|
||||
|
||||
322
docs/checkout-handle.md
Executable file
322
docs/checkout-handle.md
Executable file
@@ -0,0 +1,322 @@
|
||||
# Backend Checkout Handling Improvements
|
||||
|
||||
Based on your current Stripe integration, here are key improvements for robust checkout handling:
|
||||
|
||||
## 1. Enhanced Inventory Management with Concurrency Protection
|
||||
|
||||
The current implementation doesn't prevent overselling during concurrent purchases.
|
||||
|
||||
Add database-level concurrency protection:
|
||||
|
||||
```ruby
|
||||
# app/controllers/events_controller.rb
|
||||
def checkout
|
||||
cart_data = JSON.parse(params[:cart] || "{}")
|
||||
|
||||
if cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez sélectionner au moins un billet"
|
||||
return
|
||||
end
|
||||
|
||||
# Use transaction with row-level locking for inventory protection
|
||||
ActiveRecord::Base.transaction do
|
||||
line_items = []
|
||||
order_items = []
|
||||
|
||||
cart_data.each do |ticket_type_id, item|
|
||||
# Lock the ticket type row to prevent race conditions
|
||||
ticket_type = @event.ticket_types.lock.find_by(id: ticket_type_id)
|
||||
next unless ticket_type
|
||||
|
||||
quantity = item["quantity"].to_i
|
||||
next if quantity <= 0
|
||||
|
||||
# Check real-time availability with locked row
|
||||
sold_count = ticket_type.tickets.count
|
||||
available = ticket_type.quantity - sold_count
|
||||
|
||||
if quantity > available
|
||||
redirect_to event_path(@event.slug, @event), alert: "Plus que #{available} billets disponibles pour #{ticket_type.name}"
|
||||
return
|
||||
end
|
||||
|
||||
# Create line items and order data
|
||||
line_items << {
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "#{@event.name} - #{ticket_type.name}",
|
||||
description: ticket_type.description
|
||||
},
|
||||
unit_amount: ticket_type.price_cents
|
||||
},
|
||||
quantity: quantity
|
||||
}
|
||||
|
||||
order_items << {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
quantity: quantity,
|
||||
price_cents: ticket_type.price_cents
|
||||
}
|
||||
end
|
||||
|
||||
if order_items.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Commande invalide"
|
||||
return
|
||||
end
|
||||
|
||||
# Create Stripe session only after inventory validation
|
||||
session = Stripe::Checkout::Session.create({
|
||||
payment_method_types: ["card"],
|
||||
line_items: line_items,
|
||||
mode: "payment",
|
||||
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
|
||||
cancel_url: event_url(@event.slug, @event),
|
||||
customer_email: current_user.email,
|
||||
metadata: {
|
||||
event_id: @event.id,
|
||||
user_id: current_user.id,
|
||||
order_items: order_items.to_json
|
||||
}
|
||||
})
|
||||
|
||||
redirect_to session.url, allow_other_host: true
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to event_path(@event.slug, @event), alert: "Type de billet introuvable"
|
||||
rescue Stripe::StripeError => e
|
||||
redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}"
|
||||
end
|
||||
```
|
||||
|
||||
## 2. Webhook Handler for Reliable Payment Confirmation
|
||||
|
||||
Create a dedicated webhook endpoint for more reliable payment processing:
|
||||
|
||||
### Routes Configuration
|
||||
|
||||
```ruby
|
||||
# config/routes.rb
|
||||
post '/webhooks/stripe', to: 'webhooks#stripe'
|
||||
```
|
||||
|
||||
### Webhooks Controller
|
||||
|
||||
```ruby
|
||||
# app/controllers/webhooks_controller.rb
|
||||
class WebhooksController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :verify_stripe_signature
|
||||
|
||||
def stripe
|
||||
case @event.type
|
||||
when 'checkout.session.completed'
|
||||
handle_successful_payment(@event.data.object)
|
||||
when 'payment_intent.payment_failed'
|
||||
handle_failed_payment(@event.data.object)
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_successful_payment(session)
|
||||
# Process ticket creation in background job for reliability
|
||||
CreateTicketsJob.perform_later(session.id)
|
||||
end
|
||||
|
||||
def handle_failed_payment(session)
|
||||
Rails.logger.error "Payment failed for session: #{session.id}"
|
||||
# Add any additional handling for failed payments
|
||||
end
|
||||
|
||||
def verify_stripe_signature
|
||||
payload = request.body.read
|
||||
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
|
||||
|
||||
begin
|
||||
@event = Stripe::Webhook.construct_event(
|
||||
payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
|
||||
)
|
||||
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
|
||||
Rails.logger.error "Stripe webhook signature verification failed: #{e.message}"
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 3. Background Job for Ticket Creation
|
||||
|
||||
Use background jobs to prevent timeouts and improve reliability:
|
||||
|
||||
```ruby
|
||||
# app/jobs/create_tickets_job.rb
|
||||
class CreateTicketsJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 5
|
||||
|
||||
def perform(session_id)
|
||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
return unless session.payment_status == 'paid'
|
||||
|
||||
# Prevent duplicate processing
|
||||
return if Ticket.exists?(stripe_session_id: session_id)
|
||||
|
||||
order_items = JSON.parse(session.metadata['order_items'])
|
||||
user = User.find(session.metadata['user_id'])
|
||||
event = Event.find(session.metadata['event_id'])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
order_items.each do |item|
|
||||
ticket_type = TicketType.find(item['ticket_type_id'])
|
||||
|
||||
item['quantity'].times do
|
||||
ticket = Ticket.create!(
|
||||
user: user,
|
||||
ticket_type: ticket_type,
|
||||
status: 'active',
|
||||
stripe_session_id: session_id, # Prevent duplicates
|
||||
price_cents: item['price_cents'] # Store historical price
|
||||
)
|
||||
|
||||
# Send email asynchronously
|
||||
TicketMailer.purchase_confirmation(ticket).deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 4. Enhanced Error Handling & Recovery in Payment Success
|
||||
|
||||
Improve the payment success handler with better error recovery:
|
||||
|
||||
```ruby
|
||||
# app/controllers/events_controller.rb - Enhanced payment_success method
|
||||
def payment_success
|
||||
session_id = params[:session_id]
|
||||
event_id = params[:event_id]
|
||||
|
||||
# Validate parameters
|
||||
unless session_id.present? && event_id.present?
|
||||
redirect_to dashboard_path, alert: "Paramètres de confirmation manquants"
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
@tickets = Ticket.includes(:ticket_type, :event)
|
||||
.where(stripe_session_id: session_id, user: current_user)
|
||||
|
||||
if @tickets.any?
|
||||
# Tickets already created (webhook processed first)
|
||||
@event = @tickets.first.event
|
||||
render 'payment_success'
|
||||
else
|
||||
# Fallback: create tickets synchronously if webhook failed
|
||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
|
||||
if session.payment_status == 'paid'
|
||||
CreateTicketsJob.perform_now(session_id)
|
||||
redirect_to payment_success_path(session_id: session_id, event_id: event_id)
|
||||
else
|
||||
redirect_to dashboard_path, alert: "Le paiement n'est pas encore confirmé"
|
||||
end
|
||||
end
|
||||
|
||||
rescue Stripe::StripeError => e
|
||||
logger.error "Stripe error in payment_success: #{e.message}"
|
||||
redirect_to dashboard_path, alert: "Erreur de confirmation de paiement"
|
||||
rescue => e
|
||||
logger.error "Unexpected error in payment_success: #{e.message}"
|
||||
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 5. Database Schema Improvements
|
||||
|
||||
Add migration for better payment tracking:
|
||||
|
||||
```ruby
|
||||
# db/migrate/xxx_add_payment_tracking_to_tickets.rb
|
||||
class AddPaymentTrackingToTickets < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :tickets, :stripe_session_id, :string
|
||||
add_column :tickets, :purchased_at, :timestamp, default: -> { 'CURRENT_TIMESTAMP' }
|
||||
|
||||
add_index :tickets, :stripe_session_id, unique: true
|
||||
add_index :tickets, [:user_id, :purchased_at]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 6. Security Considerations
|
||||
|
||||
1. **Rate Limiting**: Add rate limiting to checkout endpoints
|
||||
2. **CSRF Protection**: Already implemented ✅
|
||||
3. **Input Validation**: Validate all cart data thoroughly
|
||||
4. **Audit Logging**: Log all payment attempts and outcomes
|
||||
5. **PCI Compliance**: Never store card data (Stripe handles this) ✅
|
||||
|
||||
## 7. Monitoring & Observability
|
||||
|
||||
Add metrics tracking to monitor checkout performance:
|
||||
|
||||
```ruby
|
||||
# Add to ApplicationController or EventsController
|
||||
around_action :track_checkout_metrics, only: [:checkout]
|
||||
|
||||
private
|
||||
|
||||
def track_checkout_metrics
|
||||
start_time = Time.current
|
||||
begin
|
||||
yield
|
||||
# Log successful checkout
|
||||
Rails.logger.info("Checkout completed", {
|
||||
event_id: @event&.id,
|
||||
user_id: current_user&.id,
|
||||
duration: Time.current - start_time
|
||||
})
|
||||
rescue => e
|
||||
# Log failed checkout
|
||||
Rails.logger.error("Checkout failed", {
|
||||
event_id: @event&.id,
|
||||
user_id: current_user&.id,
|
||||
error: e.message,
|
||||
duration: Time.current - start_time
|
||||
})
|
||||
raise
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Summary of Improvements
|
||||
|
||||
Your ticket checkout system is already well-implemented with Stripe integration! The enhancements above will make it production-ready:
|
||||
|
||||
### Critical Improvements
|
||||
|
||||
1. Add database row locking to prevent overselling during concurrent purchases
|
||||
2. Implement Stripe webhooks for reliable payment processing
|
||||
3. Use background jobs for ticket creation to prevent timeouts
|
||||
4. Add duplicate prevention with stripe_session_id tracking
|
||||
|
||||
### Security & Reliability
|
||||
|
||||
5. Enhanced error recovery with fallback ticket creation
|
||||
6. Comprehensive logging for debugging and monitoring
|
||||
7. Database schema improvements for better payment tracking
|
||||
|
||||
### Key Files to Modify
|
||||
|
||||
- `app/controllers/events_controller.rb` - Add inventory locking
|
||||
- `app/controllers/webhooks_controller.rb` - New webhook handler
|
||||
- `app/jobs/create_tickets_job.rb` - Background ticket creation
|
||||
- Migration for `stripe_session_id` field
|
||||
|
||||
These enhancements will make your checkout system robust for high-traffic scenarios and edge cases.
|
||||
Reference in New Issue
Block a user