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
// This file initializes the Rails application with Turbo and Stimulus controllers
// Import Turbo Rails for SPA-like navigation
import "@hotwired/turbo-rails";
// Import all Stimulus controllers
import "./controllers";

View File

@@ -4,6 +4,7 @@ import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
// Define button styles using class-variance-authority for consistent styling
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",
{
@@ -35,6 +36,7 @@ const buttonVariants = cva(
}
)
// Button component that can render as a regular button or as a Slot (for composition)
function Button({
className,
variant,
@@ -42,6 +44,7 @@ function Button({
asChild = false,
...props
}) {
// Use Slot component if asChild is true, otherwise render as a regular button
const Comp = asChild ? Slot : "button"
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";
// Create and start the Stimulus application
const application = Application.start();
// Configure Stimulus development experience
// Set to false in production to avoid unnecessary logging
application.debug = false;
// Make Stimulus globally available for debugging purposes
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;
// Alpine.start();

View File

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

View File

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

View File

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

View File

@@ -2,22 +2,25 @@
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
// Import the main Stimulus application
import { application } from "./application"
import LogoutController from "./logout_controller"
import FlashMessageController from "./flash_message_controller"
// Import all controllers
import LogoutController from "./logout_controller";
application.register("logout", LogoutController);
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";
// Controller for handling user logout functionality
// Sends a DELETE request to the server to sign out the user
export default class extends Controller {
// Define controller values
static values = {
url: String,
url: String, // Optional URL for logout endpoint
};
// Log when the controller is mounted
connect() {
// Display a message when the controller is mounted
console.log("LogoutController mounted", this.element);
}
// Handle the sign out action
signOut(event) {
event.preventDefault();
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
// 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;
// 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 { 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 {
// Define targets for the controller
static targets = ["container"]
// Initialize and render the React component when the controller connects
connect() {
console.log("Shadcn Button Test Controller connected")
this.renderButton()
}
// Render the React button component inside the target container
renderButton() {
const container = this.containerTarget
const root = createRoot(container)
@@ -32,6 +36,7 @@ export default class extends Controller {
)
}
// Handle button click events
handleClick = () => {
alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
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 { 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) {
return twMerge(clsx(inputs))
}

View File

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

View File

@@ -35,18 +35,25 @@
<div class="flex items-center space-x-2 order-1 sm:order-2">
<button type="button"
class="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition-colors duration-200"
disabled>
data-action="ticket-selection#decrement"
data-target="<%= id %>">
<span class="text-gray-600 font-bold">-</span>
</button>
<input type="number"
id="ticket_quantity_<%= id %>"
name="cart[<%= id %>][quantity]"
min="0"
max="<%= remaining %>"
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"
disabled>
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"
data-ticket-selection-target="quantityInput"
data-action="change->ticket-selection#updateQuantity"
data-price="<%= price_cents %>"
data-target="<%= id %>">
<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"
disabled>
data-action="ticket-selection#increment"
data-target="<%= id %>">
<span class="text-gray-600 font-bold">+</span>
</button>
</div>

View File

@@ -208,6 +208,9 @@
<!-- Right Column: Ticket Selection -->
<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="
@@ -219,7 +222,7 @@
<h2 class="text-lg font-bold text-gray-900">Billets disponibles</h2>
</div>
<div class="space-y-4 mb-6">
<div class="">
<% if @event.ticket_types.any? %>
<div class="space-y-4 mb-6">
<% @event.ticket_types.each do |ticket_type| %>
@@ -262,34 +265,20 @@
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">Quantité :</span>
<span class="font-medium">0</span>
<span class="font-medium" data-ticket-selection-target="totalQuantity">0</span>
</div>
<div class="flex justify-between items-center mb-4">
<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>
<button
disabled
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
"
>
<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>
<%= form.submit "Procéder au paiement",
data: { ticket_selection_target: "checkoutButton" },
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",
disabled: true %>
</div>
</div>
</div>
<% end %>
</div><!-- Right Column: Ticket Selection -->
</div>
</div>