From 56b0a4571975907c5b2baac0511becdab4f505d8 Mon Sep 17 00:00:00 2001 From: kbe Date: Sat, 30 Aug 2025 14:26:59 +0200 Subject: [PATCH] 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 --- app/javascript/application.js | 5 + app/javascript/components/button.jsx | 3 + app/javascript/controllers/application.js | 9 +- .../controllers/counter_controller.js | 23 +- .../controllers/featured_event_controller.js | 20 +- .../controllers/flash_message_controller.js | 11 +- app/javascript/controllers/index.js | 21 +- .../controllers/logout_controller.js | 9 +- .../controllers/shadcn_test_controller.js | 7 +- .../ticket_selection_controller.js | 79 +++ app/javascript/lib/utils.js | 3 + app/models/event.rb | 2 +- app/views/components/_ticket_card.html.erb | 15 +- app/views/events/show.html.erb | 505 +++++++++--------- 14 files changed, 428 insertions(+), 284 deletions(-) create mode 100644 app/javascript/controllers/ticket_selection_controller.js diff --git a/app/javascript/application.js b/app/javascript/application.js index 6d426e6..549ca81 100755 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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"; diff --git a/app/javascript/components/button.jsx b/app/javascript/components/button.jsx index 4ab38f7..ddcf318 100755 --- a/app/javascript/components/button.jsx +++ b/app/javascript/components/button.jsx @@ -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 ( diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index f2ead8c..299e827 100755 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -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(); diff --git a/app/javascript/controllers/counter_controller.js b/app/javascript/controllers/counter_controller.js index b463eec..3ee3d51 100755 --- a/app/javascript/controllers/counter_controller.js +++ b/app/javascript/controllers/counter_controller.js @@ -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); } } diff --git a/app/javascript/controllers/featured_event_controller.js b/app/javascript/controllers/featured_event_controller.js index 8d73a81..ab8db20 100755 --- a/app/javascript/controllers/featured_event_controller.js +++ b/app/javascript/controllers/featured_event_controller.js @@ -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) diff --git a/app/javascript/controllers/flash_message_controller.js b/app/javascript/controllers/flash_message_controller.js index 5e887c1..9d1c915 100755 --- a/app/javascript/controllers/flash_message_controller.js +++ b/app/javascript/controllers/flash_message_controller.js @@ -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) diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 161b8fa..cc8d1f4 100755 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -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 diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/logout_controller.js index 5c914ec..40d69c2 100755 --- a/app/javascript/controllers/logout_controller.js +++ b/app/javascript/controllers/logout_controller.js @@ -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 diff --git a/app/javascript/controllers/shadcn_test_controller.js b/app/javascript/controllers/shadcn_test_controller.js index 7562d6c..2c705c6 100755 --- a/app/javascript/controllers/shadcn_test_controller.js +++ b/app/javascript/controllers/shadcn_test_controller.js @@ -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") diff --git a/app/javascript/controllers/ticket_selection_controller.js b/app/javascript/controllers/ticket_selection_controller.js new file mode 100644 index 0000000..d4e4dd4 --- /dev/null +++ b/app/javascript/controllers/ticket_selection_controller.js @@ -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 + } + } +} \ No newline at end of file diff --git a/app/javascript/lib/utils.js b/app/javascript/lib/utils.js index a2b7170..aa770dd 100755 --- a/app/javascript/lib/utils.js +++ b/app/javascript/lib/utils.js @@ -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)) } \ No newline at end of file diff --git a/app/models/event.rb b/app/models/event.rb index 6504dd4..ed13f58 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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 diff --git a/app/views/components/_ticket_card.html.erb b/app/views/components/_ticket_card.html.erb index 3fddcf2..2f5f765 100755 --- a/app/views/components/_ticket_card.html.erb +++ b/app/views/components/_ticket_card.html.erb @@ -35,18 +35,25 @@
+ 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 %>">
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index d93036c..f1283c2 100755 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -1,13 +1,28 @@ -
-
- - -
- - <% if @event.image.present? %> -
- <%= image_tag @event.image, class: "w-full h-full object-cover" %> -
-
-
-

<%= @event.name %>

+
+ + <% if @event.image.present? %> +
+ <%= image_tag @event.image, class: "w-full h-full object-cover" %> +
+
+
+

<%= @event.name %>

+
-
- <% else %> -
-

<%= @event.name %>

-
-
- - - - - <%= @event.venue_name %> -
-
- - - - <%= @event.start_time.strftime("%d %B %Y à %H:%M") %> -
-
-
- <% end %> - -
-
- -
-
-

Description

-
-

<%= @event.description %>

-
-
- -
-
-

- - - - - Lieu -

-

<%= @event.venue_name %>

-

<%= @event.venue_address %>

-
- -
-

- - - - Date & Heure -

-

<%= @event.start_time.strftime("%A %d %B %Y") %>

-

À - <%= @event.start_time.strftime("%H:%M") %>

-
-
- -
-

Organisateur

+ <% else %> +
+

<%= @event.name %>

+
-
- <%= @event.user.email.first.upcase %> -
-
- <% if @event.user.first_name.present? && @event.user.last_name.present? %> -

<%= @event.user.first_name %> - <%= @event.user.last_name %>

- <% else %> -

<%= @event.user.email.split("@").first %>

- <% end %> - <% if @event.user.company_name.present? %> -

<%= @event.user.company_name %>

- <% end %> -
+ + + + <%= @event.venue_name %> +
+
+ + + + <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
-
- - -
-
-
-
-

Billets disponibles

+
+ <% end %> + +
+
+ +
+
+

Description

+
+

<%= @event.description %>

+
-
- <% if @event.ticket_types.any? %> -
- <% @event.ticket_types.each do |ticket_type| %> - <% sold_out = ticket_type.quantity <= ticket_type.tickets.count %> - <% remaining = ticket_type.quantity - ticket_type.tickets.count %> - - <%= render "components/ticket_card", - { - id: ticket_type.id, - name: ticket_type.name, - description: ticket_type.description, - price_cents: ticket_type.price_cents, - quantity: ticket_type.quantity, - sold_out: sold_out, - remaining: remaining, - } %> - <% end %> -
- <% else %> -
+
+
+

- -

Aucun billet disponible

-

Les billets pour cet événement ne sont pas encore - disponibles ou sont épuisés.

-
- <% end %> - -
-
- Quantité : - 0 -
-
- Montant total: - €0.00 -
- + Lieu + +

<%= @event.venue_name %>

+

<%= @event.venue_address %>

+
+ +
+

+ + + + Date & Heure +

+

<%= @event.start_time.strftime("%A %d %B %Y") %>

+

À + <%= @event.start_time.strftime("%H:%M") %>

-
-
+ +
+

Organisateur

+
+
+ <%= @event.user.email.first.upcase %> +
+
+ <% if @event.user.first_name.present? && @event.user.last_name.present? %> +

<%= @event.user.first_name %> + <%= @event.user.last_name %>

+ <% else %> +

<%= @event.user.email.split("@").first %>

+ <% end %> + <% if @event.user.company_name.present? %> +

<%= @event.user.company_name %>

+ <% end %> +
+
+
+
+ + +
+ <%= form_with url: event_checkout_path(@event.slug, @event), method: :post, id: "checkout_form", local: true, data: { controller: "ticket-selection" } do |form| %> + + +
+
+
+

Billets disponibles

+
+ +
+ <% if @event.ticket_types.any? %> +
+ <% @event.ticket_types.each do |ticket_type| %> + <% sold_out = ticket_type.quantity <= ticket_type.tickets.count %> + <% remaining = ticket_type.quantity - ticket_type.tickets.count %> + + <%= render "components/ticket_card", + { + id: ticket_type.id, + name: ticket_type.name, + description: ticket_type.description, + price_cents: ticket_type.price_cents, + quantity: ticket_type.quantity, + sold_out: sold_out, + remaining: remaining, + } %> + <% end %> +
+ <% else %> +
+ + + +

Aucun billet disponible

+

Les billets pour cet événement ne sont pas encore + disponibles ou sont épuisés.

+
+ <% end %> + +
+
+ Quantité : + 0 +
+
+ Montant total : + €0.00 +
+ <%= 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 %> +
+
+
+ <% end %> +
+
-