develop #3

Merged
kbe merged 227 commits from develop into main 2025-09-16 14:35:23 +00:00
6 changed files with 457 additions and 395 deletions
Showing only changes of commit 2a2c249a54 - Show all commits

View File

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

View File

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

View File

@@ -8,14 +8,12 @@ import LogoutController from "./logout_controller"
import FlashMessageController from "./flash_message_controller" 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 TicketCartController from "./ticket_cart_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("ticket-cart-controller", TicketCartController) // Handle ticket checkout

View File

@@ -1,333 +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)
if (!input) {
console.error(`Could not find input for ticket type ID: ${ticketTypeId}`)
return
}
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)
if (!input) {
console.error(`Could not find input for ticket type ID: ${ticketTypeId}`)
return
}
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
if (!ticketTypeId) {
console.error('Missing ticket type ID on input element')
return
}
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) {
const element = document.querySelector(`#quantity_${ticketTypeId}`)
if (!element) {
console.warn(`Could not find quantity input for ticket type ID: ${ticketTypeId}`)
}
return element
}
}

View File

@@ -1,4 +1,41 @@
<div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden"> <div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden"
x-data="{
quantity: 0,
max: <%= remaining %>,
ticketTypeId: <%= id %>,
name: '<%= name %>',
price: <%= price_cents %>
}"
x-init="
$watch('quantity', (value) => {
$dispatch('update-cart-item', {
ticketTypeId: ticketTypeId,
name: name,
price: price,
quantity: value,
max: max
});
});
$nextTick(() => {
$dispatch('cart-initialized', { ticketTypeId: ticketTypeId });
});
"
@update-cart-item.window="
if ($event.detail.ticketTypeId === ticketTypeId) {
quantity = $event.detail.quantity;
}
"
@cart-change.window="
if (!$event.detail.cart[ticketTypeId]) {
quantity = 0;
}
"
@restore-cart.window="
if ($event.detail.cart[ticketTypeId]) {
quantity = $event.detail.cart[ticketTypeId].quantity;
}
">
<div class="p-5"> <div class="p-5">
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div> <div>
@@ -35,27 +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 %>"
data-action="change->ticket-cart#updateQuantityFromInput">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200" class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
data-action="click->ticket-cart#increaseQuantity" @click="quantity = Math.min(max, quantity + 1)"
data-ticket-cart-ticket-type-id-param="<%= id %>" :disabled="quantity >= max">
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">+</span> <span class="text-gray-600 font-bold">+</span>
</button> </button>
</div> </div>

View File

@@ -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>