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
|
class EventsController < ApplicationController
|
||||||
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
|
before_action :authenticate_user!, only: [ :checkout, :payment_success, :download_ticket ]
|
||||||
before_action :set_event, only: [:show, :checkout]
|
before_action :set_event, only: [ :show, :checkout ]
|
||||||
|
|
||||||
# Display all events
|
# Display all events
|
||||||
def index
|
def index
|
||||||
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
||||||
@@ -43,14 +43,14 @@ class EventsController < ApplicationController
|
|||||||
# Create Stripe line item
|
# Create Stripe line item
|
||||||
line_items << {
|
line_items << {
|
||||||
price_data: {
|
price_data: {
|
||||||
currency: 'eur',
|
currency: "eur",
|
||||||
product_data: {
|
product_data: {
|
||||||
name: "#{@event.name} - #{ticket_type.name}",
|
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
|
# Store for ticket creation
|
||||||
@@ -72,10 +72,10 @@ class EventsController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
# Create Stripe Checkout Session
|
# Create Stripe Checkout Session
|
||||||
session = Stripe::Checkout::Session.create({
|
session = Stripe::Checkout::Session.create({
|
||||||
payment_method_types: ['card'],
|
payment_method_types: [ "card" ],
|
||||||
line_items: line_items,
|
line_items: line_items,
|
||||||
mode: 'payment',
|
mode: "payment",
|
||||||
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
|
success_url: payment_success_url(event_id: @event.id, session_id: "{CHECKOUT_SESSION_ID}"),
|
||||||
cancel_url: event_url(@event.slug, @event),
|
cancel_url: event_url(@event.slug, @event),
|
||||||
customer_email: current_user.email,
|
customer_email: current_user.email,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -98,29 +98,29 @@ class EventsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
session = Stripe::Checkout::Session.retrieve(session_id)
|
session = Stripe::Checkout::Session.retrieve(session_id)
|
||||||
|
|
||||||
if session.payment_status == 'paid'
|
if session.payment_status == "paid"
|
||||||
# Create tickets
|
# Create tickets
|
||||||
@event = Event.find(event_id)
|
@event = Event.find(event_id)
|
||||||
order_items = JSON.parse(session.metadata['order_items'])
|
order_items = JSON.parse(session.metadata["order_items"])
|
||||||
@tickets = []
|
@tickets = []
|
||||||
|
|
||||||
order_items.each do |item|
|
order_items.each do |item|
|
||||||
ticket_type = TicketType.find(item['ticket_type_id'])
|
ticket_type = TicketType.find(item["ticket_type_id"])
|
||||||
item['quantity'].times do
|
item["quantity"].times do
|
||||||
ticket = Ticket.create!(
|
ticket = Ticket.create!(
|
||||||
user: current_user,
|
user: current_user,
|
||||||
ticket_type: ticket_type,
|
ticket_type: ticket_type,
|
||||||
status: 'active'
|
status: "active"
|
||||||
)
|
)
|
||||||
@tickets << ticket
|
@tickets << ticket
|
||||||
|
|
||||||
# Send confirmation email for each ticket
|
# Send confirmation email for each ticket
|
||||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
render 'payment_success'
|
render "payment_success"
|
||||||
else
|
else
|
||||||
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
|
redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas été complété avec succès"
|
||||||
end
|
end
|
||||||
@@ -134,14 +134,14 @@ class EventsController < ApplicationController
|
|||||||
# Download ticket PDF
|
# Download ticket PDF
|
||||||
def download_ticket
|
def download_ticket
|
||||||
@ticket = current_user.tickets.find(params[:ticket_id])
|
@ticket = current_user.tickets.find(params[:ticket_id])
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.pdf do
|
format.pdf do
|
||||||
pdf = @ticket.to_pdf
|
pdf = @ticket.to_pdf
|
||||||
send_data pdf,
|
send_data pdf,
|
||||||
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
|
filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf",
|
||||||
type: 'application/pdf',
|
type: "application/pdf",
|
||||||
disposition: 'attachment'
|
disposition: "attachment"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ export default class extends Controller {
|
|||||||
|
|
||||||
proceedToCheckout() {
|
proceedToCheckout() {
|
||||||
if (Object.keys(this.cart).length === 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,17 +115,13 @@ export default class extends Controller {
|
|||||||
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
|
const isAuthenticated = document.body.dataset.userAuthenticated === "true"
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
if (confirm('Vous devez être connecté pour acheter des billets. Souhaitez-vous vous connecter maintenant ?')) {
|
this.showLoginModal()
|
||||||
// Store cart in session storage
|
|
||||||
sessionStorage.setItem('pending_cart', JSON.stringify({
|
|
||||||
eventId: this.eventIdValue,
|
|
||||||
cart: this.cart
|
|
||||||
}))
|
|
||||||
window.location.href = '/auth/sign_in'
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
this.setCheckoutLoading(true)
|
||||||
|
|
||||||
// Create form and submit to checkout
|
// Create form and submit to checkout
|
||||||
const form = document.createElement('form')
|
const form = document.createElement('form')
|
||||||
form.method = 'POST'
|
form.method = 'POST'
|
||||||
@@ -145,6 +146,141 @@ export default class extends Controller {
|
|||||||
form.submit()
|
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() {
|
checkForPendingCart() {
|
||||||
const pendingCart = sessionStorage.getItem('pending_cart')
|
const pendingCart = sessionStorage.getItem('pending_cart')
|
||||||
if (pendingCart) {
|
if (pendingCart) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mt-4">
|
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center mt-4 gap-3">
|
||||||
<div>
|
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
|
||||||
<% if 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">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<% unless sold_out %>
|
<% 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"
|
<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"
|
data-action="click->ticket-cart#decreaseQuantity"
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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">
|
<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>
|
<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>
|
</svg>
|
||||||
|
|||||||
@@ -1,23 +1,81 @@
|
|||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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>
|
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
<%= @events.total_count %> événements trouvés
|
<%= @events.total_count %>
|
||||||
|
événements trouvés
|
||||||
</div>
|
</div>
|
||||||
</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? %>
|
<% if @events.any? %>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<% @events.each do |event| %>
|
<% @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? %>
|
<% if event.image.present? %>
|
||||||
<div class="h-48 overflow-hidden">
|
<div class="h-48 overflow-hidden">
|
||||||
<%= image_tag event.image, class: "w-full h-full object-cover" %>
|
<%= image_tag event.image, class: "w-full h-full object-cover" %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center">
|
<div
|
||||||
<svg class="w-16 h-16 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -26,14 +84,24 @@
|
|||||||
<div class="flex justify-between items-start mb-3">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
|
<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">
|
<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>
|
</svg>
|
||||||
<%= event.user.email.split('@').first %>
|
<%= event.venue_name.truncate(20) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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") %>
|
<%= event.start_time.strftime("%d/%m") %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,13 +114,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<% if event.ticket_types.any? %>
|
<% if event.ticket_types.any? %>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<p class="text-sm font-medium text-gray-900">
|
||||||
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
À partir de
|
||||||
</p>
|
<%= format_price(event.ticket_types.minimum(:price_cents)) %>€
|
||||||
<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) %>
|
|
||||||
</p>
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-gray-500">Pas de billets disponibles</p>
|
<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 %>
|
<%= 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
|
Détails
|
||||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</svg>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,14 +145,32 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-center py-16">
|
<div class="text-center py-16">
|
||||||
<div class="mx-auto max-w-md">
|
<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">
|
<div
|
||||||
<svg class="w-12 h-12 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -151,14 +151,14 @@
|
|||||||
<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">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>
|
||||||
<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 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>
|
</div>
|
||||||
<button
|
<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"
|
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
|
disabled
|
||||||
data-action="click->ticket-cart#proceedToCheckout">
|
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