Compare commits
3 Commits
be3d80e541
...
2a2c249a54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2c249a54 | ||
|
|
3fa9249bc8 | ||
|
|
b9576b91f5 |
5
QWEN.md
5
QWEN.md
@@ -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.
|
||||||
|
|||||||
45
README-checkout-implementation.md
Executable file
45
README-checkout-implementation.md
Executable 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
78
app/views/events/collect_names.html.erb
Executable file
78
app/views/events/collect_names.html.erb
Executable 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
47
checkout-implementation-summary.md
Executable file
47
checkout-implementation-summary.md
Executable 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
4
db/schema.rb
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user