Implement ticket selection with Stimulus controller and improve code documentation

- Add ticket selection functionality to event show page using Stimulus
- Create ticket_selection_controller.js for handling ticket quantity changes
- Update ticket card component and event show view to work with Stimulus
- Add comprehensive comments to all JavaScript files for better maintainability
- Remove dependent: :destroy from event ticket_types association
This commit is contained in:
kbe
2025-08-30 14:26:59 +02:00
parent 58dbcf3a6a
commit 56b0a45719
14 changed files with 428 additions and 284 deletions

View File

@@ -1,3 +1,8 @@
// Entry point for the build script in your package.json // Entry point for the build script in your package.json
// This file initializes the Rails application with Turbo and Stimulus controllers
// Import Turbo Rails for SPA-like navigation
import "@hotwired/turbo-rails"; import "@hotwired/turbo-rails";
// Import all Stimulus controllers
import "./controllers"; import "./controllers";

View File

@@ -4,6 +4,7 @@ import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Define button styles using class-variance-authority for consistent styling
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
@@ -35,6 +36,7 @@ const buttonVariants = cva(
} }
) )
// Button component that can render as a regular button or as a Slot (for composition)
function Button({ function Button({
className, className,
variant, variant,
@@ -42,6 +44,7 @@ function Button({
asChild = false, asChild = false,
...props ...props
}) { }) {
// Use Slot component if asChild is true, otherwise render as a regular button
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (

View File

@@ -1,12 +1,19 @@
// Main Stimulus application controller
// Initializes the Stimulus framework and makes it available globally
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
// Create and start the Stimulus application
const application = Application.start(); const application = Application.start();
// Configure Stimulus development experience // Configure Stimulus development experience
// Set to false in production to avoid unnecessary logging
application.debug = false; application.debug = false;
// Make Stimulus globally available for debugging purposes
window.Stimulus = application; window.Stimulus = application;
// import Alpine from 'alpinejs'; // Configure Alpine js (commented out as it's not currently used)
// import Alpine from "alpinejs";
// window.Alpine = Alpine; // window.Alpine = Alpine;
// Alpine.start(); // Alpine.start();

View File

@@ -1,31 +1,41 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
// Counter controller for animating number increments
// Used for statistics and numerical displays that animate when they come into view
export default class extends Controller { export default class extends Controller {
// Define controller values with defaults
static values = { static values = {
target: { type: Number, default: 0 }, target: { type: Number, default: 0 }, // Target number to count to
decimal: { type: Boolean, default: false }, decimal: { type: Boolean, default: false }, // Whether to display decimal values
duration: { type: Number, default: 2000 } duration: { type: Number, default: 2000 } // Animation duration in milliseconds
} }
// Set up the intersection observer when the controller connects
connect() { connect() {
// Create an intersection observer to trigger animation when element is visible
this.observer = new IntersectionObserver((entries) => { this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
// Start animation when element is 50% visible
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.animate() this.animate()
// Stop observing after animation starts
this.observer.unobserve(this.element) this.observer.unobserve(this.element)
} }
}) })
}, { threshold: 0.5 }) }, { threshold: 0.5 })
// Begin observing this element
this.observer.observe(this.element) this.observer.observe(this.element)
} }
// Clean up the observer when the controller disconnects
disconnect() { disconnect() {
if (this.observer) { if (this.observer) {
this.observer.disconnect() this.observer.disconnect()
} }
} }
// Animate the counter from 0 to the target value
animate() { animate() {
// Find the target element with data-target-value // Find the target element with data-target-value
const targetElement = this.element.querySelector('.stat-number'); const targetElement = this.element.querySelector('.stat-number');
@@ -37,15 +47,17 @@ export default class extends Controller {
const startValue = 0; const startValue = 0;
const startTime = performance.now(); const startTime = performance.now();
// Update counter function using requestAnimationFrame for smooth animation
const updateCounter = (currentTime) => { const updateCounter = (currentTime) => {
const elapsedTime = currentTime - startTime; const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / this.durationValue, 1); const progress = Math.min(elapsedTime / this.durationValue, 1);
// Easing function for smooth animation // Easing function for smooth animation (ease-out quartic)
const easeOutQuart = 1 - Math.pow(1 - progress, 4); const easeOutQuart = 1 - Math.pow(1 - progress, 4);
let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart; let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart;
// Format value based on decimal setting
if (this.decimalValue && this.targetValue < 10) { if (this.decimalValue && this.targetValue < 10) {
currentValue = currentValue.toFixed(1); currentValue = currentValue.toFixed(1);
} else { } else {
@@ -55,9 +67,11 @@ export default class extends Controller {
// Update only the text content of the target element // Update only the text content of the target element
targetElement.textContent = currentValue; targetElement.textContent = currentValue;
// Continue animation until complete
if (progress < 1) { if (progress < 1) {
requestAnimationFrame(updateCounter); requestAnimationFrame(updateCounter);
} else { } else {
// Ensure final value is exactly the target
const finalValue = this.decimalValue && this.targetValue < 10 const finalValue = this.decimalValue && this.targetValue < 10
? this.targetValue.toFixed(1) ? this.targetValue.toFixed(1)
: this.targetValue; : this.targetValue;
@@ -65,6 +79,7 @@ export default class extends Controller {
} }
} }
// Start the animation
requestAnimationFrame(updateCounter); requestAnimationFrame(updateCounter);
} }
} }

View File

@@ -1,34 +1,47 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
// Controller for handling animations of featured event cards
// Uses intersection observer to trigger animations when cards come into view
export default class extends Controller { export default class extends Controller {
// Define targets for the controller
static targets = ["card"] static targets = ["card"]
// Define CSS classes that can be used with this controller
static classes = ["visible"] static classes = ["visible"]
// Define configurable values with defaults
static values = { static values = {
threshold: { type: Number, default: 0.1 }, threshold: { type: Number, default: 0.1 }, // Percentage of element visibility needed to trigger animation
rootMargin: { type: String, default: '0px 0px -50px 0px' }, rootMargin: { type: String, default: '0px 0px -50px 0px' }, // Margin around root element for intersection detection
staggerDelay: { type: Number, default: 0.2 } staggerDelay: { type: Number, default: 0.2 } // Delay between card animations in seconds
} }
// Initialize the controller when it connects to the DOM
connect() { connect() {
console.log("FeaturedEventController connected") console.log("FeaturedEventController connected")
this.setupIntersectionObserver() this.setupIntersectionObserver()
this.setupStaggeredAnimations() this.setupStaggeredAnimations()
} }
// Clean up observers when the controller disconnects
disconnect() { disconnect() {
if (this.observer) { if (this.observer) {
this.observer.disconnect() this.observer.disconnect()
} }
} }
// Set up intersection observer to detect when cards come into view
setupIntersectionObserver() { setupIntersectionObserver() {
// Configure observer options
const observerOptions = { const observerOptions = {
threshold: this.thresholdValue, threshold: this.thresholdValue,
rootMargin: this.rootMarginValue rootMargin: this.rootMarginValue
} }
// Create intersection observer
this.observer = new IntersectionObserver((entries) => { this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
// Add visible class when card comes into view
if (entry.isIntersecting) { if (entry.isIntersecting) {
entry.target.classList.add('visible') entry.target.classList.add('visible')
} }
@@ -43,6 +56,7 @@ export default class extends Controller {
}) })
} }
// Set up staggered animations for cards with progressive delays
setupStaggeredAnimations() { setupStaggeredAnimations() {
console.log("Setting up staggered animations") console.log("Setting up staggered animations")
console.log("Card targets:", this.cardTargets) console.log("Card targets:", this.cardTargets)

View File

@@ -1,12 +1,16 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
// Controller for handling flash messages
// Automatically dismisses messages after a timeout and handles manual closing
export default class extends Controller { export default class extends Controller {
// Define targets for the controller
static targets = ["message"] static targets = ["message"]
// Initialize the controller when it connects to the DOM
connect() { connect() {
console.log("FlashMessageController mounted", this.element); console.log("FlashMessageController mounted", this.element);
// Initialize Lucide icons for this element // Initialize Lucide icons for this element if available
if (typeof lucide !== 'undefined') { if (typeof lucide !== 'undefined') {
lucide.createIcons({ within: this.element }); lucide.createIcons({ within: this.element });
} }
@@ -17,14 +21,19 @@ export default class extends Controller {
}, 2000) }, 2000)
} }
// Clean up the timeout when the controller disconnects
disconnect() { disconnect() {
if (this.timeout) { if (this.timeout) {
clearTimeout(this.timeout) clearTimeout(this.timeout)
} }
} }
// Close the flash message with a fade-out animation
close() { close() {
// Add opacity transition classes
this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300') this.element.classList.add('opacity-0', 'transition-opacity', 'duration-300')
// Remove element after transition completes
setTimeout(() => { setTimeout(() => {
this.element.remove() this.element.remove()
}, 300) }, 300)

View File

@@ -2,22 +2,25 @@
// Run that command whenever you add a new controller or create them with // Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName // ./bin/rails generate stimulus controllerName
// Import the main Stimulus application
import { application } from "./application" import { application } from "./application"
import LogoutController from "./logout_controller" // Import all controllers
import FlashMessageController from "./flash_message_controller" import LogoutController from "./logout_controller";
application.register("logout", LogoutController);
import CounterController from "./counter_controller" import CounterController from "./counter_controller"
// import FeaturedEventController from "./featured_event_controller" application.register("counter", CounterController);
import FlashMessageController from "./flash_message_controller"
application.register("flash-message", FlashMessageController);
import TicketSelectionController from "./ticket_selection_controller"
application.register("ticket-selection", TicketSelectionController);
// application.register("logout", LogoutController) // Allow logout using js
// application.register("flash-message", FlashMessageController) // Dismiss notification after 5 secondes
// application.register("counter", CounterController) // Simple counter for homepage
// application.register("featured-event", FeaturedEventController) // Featured event controller for homepage
// import ShadcnTestController from "./shadcn_test_controller"
// application.register("shadcn-test", ShadcnTestController) // Test controller for Shadcn

View File

@@ -1,15 +1,20 @@
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
// Controller for handling user logout functionality
// Sends a DELETE request to the server to sign out the user
export default class extends Controller { export default class extends Controller {
// Define controller values
static values = { static values = {
url: String, url: String, // Optional URL for logout endpoint
}; };
// Log when the controller is mounted
connect() { connect() {
// Display a message when the controller is mounted // Display a message when the controller is mounted
console.log("LogoutController mounted", this.element); console.log("LogoutController mounted", this.element);
} }
// Handle the sign out action
signOut(event) { signOut(event) {
event.preventDefault(); event.preventDefault();
console.log("User clicked on logout button."); console.log("User clicked on logout button.");
@@ -17,7 +22,7 @@ export default class extends Controller {
// Ensure user wants to disconnect with a confirmation request // Ensure user wants to disconnect with a confirmation request
// if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; } // if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; }
// Retrieve the csrf token from header // Retrieve the csrf token from header for security
const csrfToken = document.querySelector("[name='csrf-token']").content; const csrfToken = document.querySelector("[name='csrf-token']").content;
// Define url to redirect user when action is valid // Define url to redirect user when action is valid

View File

@@ -3,15 +3,19 @@ import React from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { Button } from "@/components/button" import { Button } from "@/components/button"
// Connects to data-controller="shadcn-test" // Controller for testing shadcn/ui React components within a Stimulus context
// Renders a React button component to verify the PostCSS and component setup
export default class extends Controller { export default class extends Controller {
// Define targets for the controller
static targets = ["container"] static targets = ["container"]
// Initialize and render the React component when the controller connects
connect() { connect() {
console.log("Shadcn Button Test Controller connected") console.log("Shadcn Button Test Controller connected")
this.renderButton() this.renderButton()
} }
// Render the React button component inside the target container
renderButton() { renderButton() {
const container = this.containerTarget const container = this.containerTarget
const root = createRoot(container) const root = createRoot(container)
@@ -32,6 +36,7 @@ export default class extends Controller {
) )
} }
// Handle button click events
handleClick = () => { handleClick = () => {
alert("✅ Le bouton shadcn fonctionne avec PostCSS !") alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
console.log("Shadcn button clicked - PostCSS compilation successful") console.log("Shadcn button clicked - PostCSS compilation successful")

View File

@@ -0,0 +1,79 @@
import { Controller } from "@hotwired/stimulus"
// Controller for handling ticket selection on the event show page
// Manages quantity inputs, calculates totals, and enables/disables the checkout button
export default class extends Controller {
static targets = ["quantityInput", "totalQuantity", "totalAmount", "checkoutButton"]
// Initialize the controller and update the cart summary
connect() {
this.updateCartSummary()
}
// Increment the quantity for a specific ticket type
increment(event) {
const ticketTypeId = event.currentTarget.dataset.target
const input = this.quantityInputTargets.find(input => input.dataset.target === ticketTypeId)
const value = parseInt(input.value) || 0
const max = parseInt(input.max) || 0
if (value < max) {
input.value = value + 1
this.updateCartSummary()
}
}
// Decrement the quantity for a specific ticket type
decrement(event) {
const ticketTypeId = event.currentTarget.dataset.target
const input = this.quantityInputTargets.find(input => input.dataset.target === ticketTypeId)
const value = parseInt(input.value) || 0
if (value > 0) {
input.value = value - 1
this.updateCartSummary()
}
}
// Update quantity when directly edited in the input field
updateQuantity(event) {
const input = event.currentTarget
let value = parseInt(input.value) || 0
const max = parseInt(input.max) || 0
// Ensure value is within valid range (0 to max available)
if (value < 0) value = 0
if (value > max) value = max
input.value = value
this.updateCartSummary()
}
// Calculate and update the cart summary (total quantity and amount)
updateCartSummary() {
let totalQuantity = 0
let totalAmount = 0
// Sum up quantities and calculate total amount
this.quantityInputTargets.forEach(input => {
const quantity = parseInt(input.value) || 0
const price = parseInt(input.dataset.price) || 0
totalQuantity += quantity
totalAmount += quantity * price
})
// Update the displayed total quantity and amount
this.totalQuantityTarget.textContent = totalQuantity
this.totalAmountTarget.textContent = `${(totalAmount / 100).toFixed(2)}`
// Enable/disable checkout button based on whether any tickets are selected
if (totalQuantity > 0) {
this.checkoutButtonTarget.classList.remove('opacity-50', 'cursor-not-allowed')
this.checkoutButtonTarget.disabled = false
} else {
this.checkoutButtonTarget.classList.add('opacity-50', 'cursor-not-allowed')
this.checkoutButtonTarget.disabled = true
}
}
}

View File

@@ -1,6 +1,9 @@
import { clsx } from "clsx" import { clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
// Utility function for conditionally joining CSS classes
// Combines clsx (for conditional classes) with twMerge (for Tailwind CSS conflicts)
// Usage: cn("class1", "class2", conditionalClass && "class3")
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

View File

@@ -15,7 +15,7 @@ class Event < ApplicationRecord
# === Relations === # === Relations ===
belongs_to :user belongs_to :user
has_many :ticket_types, dependent: :destroy has_many :ticket_types
has_many :tickets, through: :ticket_types has_many :tickets, through: :ticket_types
# Validations for Event attributes # Validations for Event attributes

View File

@@ -35,18 +35,25 @@
<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"
disabled> data-action="ticket-selection#decrement"
data-target="<%= id %>">
<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="ticket_quantity_<%= id %>"
name="cart[<%= id %>][quantity]"
min="0" min="0"
max="<%= remaining %>" max="<%= remaining %>"
value="0" value="0"
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 ticket-quantity-input"
disabled> data-ticket-selection-target="quantityInput"
data-action="change->ticket-selection#updateQuantity"
data-price="<%= price_cents %>"
data-target="<%= id %>">
<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"
disabled> data-action="ticket-selection#increment"
data-target="<%= id %>">
<span class="text-gray-600 font-bold">+</span> <span class="text-gray-600 font-bold">+</span>
</button> </button>
</div> </div>

View File

@@ -1,6 +1,6 @@
<div <div
class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"
> >
<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">
@@ -208,6 +208,9 @@
<!-- Right Column: Ticket Selection --> <!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<%= form_with url: event_checkout_path(@event.slug, @event), method: :post, id: "checkout_form", local: true, data: { controller: "ticket-selection" } do |form| %>
<div class=""> <div class="">
<div <div
class=" class="
@@ -219,7 +222,7 @@
<h2 class="text-lg font-bold text-gray-900">Billets disponibles</h2> <h2 class="text-lg font-bold text-gray-900">Billets disponibles</h2>
</div> </div>
<div class="space-y-4 mb-6"> <div class="">
<% if @event.ticket_types.any? %> <% if @event.ticket_types.any? %>
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6">
<% @event.ticket_types.each do |ticket_type| %> <% @event.ticket_types.each do |ticket_type| %>
@@ -262,37 +265,23 @@
<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">Quantité :</span> <span class="text-gray-600">Quantité :</span>
<span class="font-medium">0</span> <span class="font-medium" data-ticket-selection-target="totalQuantity">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 class="text-xl font-bold text-purple-700">€0.00</span> <span class="text-xl font-bold text-purple-700" data-ticket-selection-target="totalAmount">€0.00</span>
</div> </div>
<button <%= form.submit "Procéder au paiement",
disabled data: { ticket_selection_target: "checkoutButton" },
class=" 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 opacity-50 cursor-not-allowed",
w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-medium disabled: true %>
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 opacity-50 cursor-not-allowed
"
>
<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
</button>
</div> </div>
</div> </div>
</div> </div>
<% end %>
</div><!-- Right Column: Ticket Selection --> </div><!-- Right Column: Ticket Selection -->
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>