From 4e2445198fc16af652a13d47d217676c4a7a277b Mon Sep 17 00:00:00 2001 From: kbe Date: Thu, 28 Aug 2025 18:03:48 +0200 Subject: [PATCH] feat: Implement complete event ticketing system with Stripe integration and email confirmations - Enhanced events index page with improved visual design and better information display - Completely redesigned event show page with modern layout, ticket selection, and checkout functionality - Implemented Stripe payment processing for ticket purchases - Created ticket generation system with PDF tickets and QR codes - Added email confirmation system with ticket attachments - Updated database configuration to use SQLite for easier development setup - Fixed gem dependencies and resolved conflicts - Improved error handling throughout the checkout process - Enhanced Stimulus controller for ticket cart management - Added proper redirect handling for successful and cancelled payments --- .gitignore | 3 + AGENT.md | 267 +++++++++++++++ Gemfile.lock | 26 +- README.md | 205 ++++++++++-- app/controllers/events_controller.rb | 122 ++++++- app/controllers/pages_controller.rb | 15 +- .../controllers/logout_controller.js | 8 +- .../controllers/ticket_cart_controller.js | 98 +++++- app/mailers/ticket_mailer.rb | 21 ++ app/models/ticket.rb | 20 ++ app/services/ticket_pdf_generator.rb | 97 ++++++ app/views/components/_header.html.erb | 6 +- app/views/components/_ticket_card.html.erb | 73 +++-- app/views/events/index.html.erb | 74 ++++- app/views/events/payment_success.html.erb | 150 +++++++++ app/views/events/show.html.erb | 308 ++++++++++-------- app/views/layouts/application.html.erb | 2 +- app/views/pages/dashboard.html.erb | 38 ++- .../purchase_confirmation.html.erb | 56 ++++ .../purchase_confirmation.text.erb | 19 ++ config/initializers/stripe.rb | 27 ++ config/routes.rb | 12 +- db/schema.rb | 8 +- 23 files changed, 1376 insertions(+), 279 deletions(-) create mode 100755 AGENT.md create mode 100755 app/mailers/ticket_mailer.rb create mode 100755 app/services/ticket_pdf_generator.rb create mode 100755 app/views/events/payment_success.html.erb create mode 100755 app/views/ticket_mailer/purchase_confirmation.html.erb create mode 100755 app/views/ticket_mailer/purchase_confirmation.text.erb create mode 100755 config/initializers/stripe.rb diff --git a/.gitignore b/.gitignore index e4d802e..6e6249f 100755 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ # Ignore generated reports /test/reports + +# Ignore .fuse file +.fuse_hidden* \ No newline at end of file diff --git a/AGENT.md b/AGENT.md new file mode 100755 index 0000000..de9e238 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,267 @@ +# Aperonight - Technical Documentation for AI Agents + +## đŸ€– Agent Implementation Guide + +This document provides technical details for AI agents working on the Aperonight ticket selling system. + +## đŸ—ïž System Architecture + +### Core Components + +#### 1. User Management (`app/models/user.rb`) +- **Devise Integration**: Complete authentication system with registration, login, password reset +- **Relationships**: Users can create events and purchase tickets +- **Validations**: Email format, password strength, optional name fields + +#### 2. Event System (`app/models/event.rb`) +- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management +- **Geographic Data**: Latitude/longitude for venue mapping +- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types +- **Scopes**: Featured events, published events, upcoming events with proper ordering + +#### 3. Ticket Management +- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods +- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage + +#### 4. Payment Processing (`app/controllers/events_controller.rb`) +- **Stripe Integration**: Complete checkout session creation and payment confirmation +- **Session Management**: Proper handling of payment success/failure with ticket generation +- **Security**: Authentication required, cart validation, availability checking + +### Database Schema Key Points + +```sql +-- Users table (managed by Devise) +CREATE TABLE users ( + id bigint PRIMARY KEY, + email varchar(255) UNIQUE NOT NULL, + encrypted_password varchar(255) NOT NULL, + first_name varchar(255), + last_name varchar(255), + -- Devise fields: confirmation, reset tokens, etc. +); + +-- Events table +CREATE TABLE events ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users(id), + name varchar(100) NOT NULL, + slug varchar(100) NOT NULL, + description text(1000) NOT NULL, + venue_name varchar(100) NOT NULL, + venue_address varchar(200) NOT NULL, + latitude decimal(10,8) NOT NULL, + longitude decimal(11,8) NOT NULL, + start_time datetime NOT NULL, + end_time datetime, + state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3 + featured boolean DEFAULT false, + image varchar(500) +); + +-- Ticket types define pricing and availability +CREATE TABLE ticket_types ( + id bigint PRIMARY KEY, + event_id bigint REFERENCES events(id), + name varchar(255) NOT NULL, + description text, + price_cents integer NOT NULL, + quantity integer NOT NULL, + sale_start_at datetime, + sale_end_at datetime, + requires_id boolean DEFAULT false, + minimum_age integer +); + +-- Individual tickets with QR codes +CREATE TABLE tickets ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users(id), + ticket_type_id bigint REFERENCES ticket_types(id), + qr_code varchar(255) UNIQUE NOT NULL, + price_cents integer NOT NULL, + status varchar(255) DEFAULT 'active' -- active, used, expired, refunded +); +``` + +## 🎯 Key Implementation Details + +### 1. Dashboard Metrics (`app/controllers/pages_controller.rb`) + +```ruby +# User-specific metrics with optimized queries +@booked_events = current_user.tickets + .joins(:ticket_type, :event) + .where(events: { state: :published }) + .count + +# Event counts for different timeframes +@events_today = Event.published + .where("DATE(start_time) = ?", Date.current) + .count + +# User's actual booked events (not just count) +@user_booked_events = Event.joins(ticket_types: :tickets) + .where(tickets: { user: current_user, status: 'active' }) + .distinct + .limit(5) +``` + +### 2. Stripe Payment Flow + +#### Checkout Initiation (`events#checkout`) +1. **Cart Validation**: Parse JSON cart data, validate ticket types and quantities +2. **Availability Check**: Ensure sufficient tickets available before payment +3. **Stripe Session**: Create checkout session with line items, success/cancel URLs +4. **Metadata Storage**: Store order details in Stripe session metadata for later retrieval + +```ruby +# Key Stripe configuration +session = Stripe::Checkout::Session.create({ + payment_method_types: ['card'], + line_items: line_items, + mode: 'payment', + success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'), + cancel_url: event_url(@event.slug, @event), + customer_email: current_user.email, + metadata: { + event_id: @event.id, + user_id: current_user.id, + order_items: order_items.to_json + } +}) +``` + +#### Payment Confirmation (`events#payment_success`) +1. **Session Retrieval**: Get Stripe session with payment status +2. **Ticket Creation**: Generate tickets based on order items from metadata +3. **QR Code Generation**: Automatic unique QR code creation via model callbacks +4. **Success Page**: Display tickets with download links + +### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`) + +```ruby +class TicketPdfGenerator + def generate + Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf| + # Header with branding + pdf.fill_color "2D1B69" + pdf.font "Helvetica", style: :bold, size: 24 + pdf.text "ApĂ©roNight", align: :center + + # Event details + pdf.text ticket.event.name, align: :center + + # QR Code generation + qr_code_data = { + ticket_id: ticket.id, + qr_code: ticket.qr_code, + event_id: ticket.event.id, + user_id: ticket.user.id + }.to_json + + qrcode = RQRCode::QRCode.new(qr_code_data) + pdf.print_qr_code(qrcode, extent: 120, align: :center) + end.render + end +end +``` + +### 4. Frontend Cart Management (`app/javascript/controllers/ticket_cart_controller.js`) + +- **Stimulus Controller**: Manages cart state and interactions +- **Authentication Check**: Validates user login before checkout +- **Session Storage**: Preserves cart when redirecting to login +- **Dynamic Updates**: Real-time cart total and ticket count updates + +## 🔧 Development Patterns + +### Model Validations +```ruby +# Event validations +validates :name, presence: true, length: { minimum: 3, maximum: 100 } +validates :latitude, numericality: { + greater_than_or_equal_to: -90, + less_than_or_equal_to: 90 +} + +# Ticket QR code generation +before_validation :generate_qr_code, on: :create +def generate_qr_code + loop do + self.qr_code = SecureRandom.uuid + break unless Ticket.exists?(qr_code: qr_code) + end +end +``` + +### Controller Patterns +```ruby +# Authentication for sensitive actions +before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket] + +# Strong parameters +private +def event_params + params.require(:event).permit(:name, :description, :venue_name, :venue_address, + :latitude, :longitude, :start_time, :image) +end +``` + +### View Helpers and Partials +- **Metric Cards**: Reusable component for dashboard statistics +- **Event Items**: Consistent event display across pages +- **Flash Messages**: Centralized notification system + +## 🚀 Deployment Considerations + +### Environment Variables +```bash +# Required for production +STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +DATABASE_URL=mysql2://user:pass@host/db +RAILS_MASTER_KEY=... +``` + +### Database Indexes +```sql +-- Performance indexes for common queries +CREATE INDEX idx_events_published_start_time ON events (state, start_time); +CREATE INDEX idx_tickets_user_status ON tickets (user_id, status); +CREATE INDEX idx_ticket_types_event ON ticket_types (event_id); +``` + +### Security Considerations +- **CSRF Protection**: Rails default protection enabled +- **Strong Parameters**: All user inputs filtered +- **Authentication**: Devise handles session security +- **Payment Security**: Stripe handles sensitive payment data + +## đŸ§Ș Testing Strategy + +### Key Test Cases +1. **User Authentication**: Registration, login, logout flows +2. **Event Creation**: Validation, state management, relationships +3. **Booking Process**: Cart validation, payment processing, ticket generation +4. **PDF Generation**: QR code uniqueness, ticket format +5. **Dashboard Metrics**: Query accuracy, performance + +### Seed Data Structure +```ruby +# Creates test users, events, and ticket types +users = User.create!([...]) +events = Event.create!([...]) +ticket_types = TicketType.create!([...]) +``` + +## 📝 Code Style & Conventions + +- **Ruby Style**: Follow Rails conventions and Rubocop rules +- **Database**: Use Rails migrations for all schema changes +- **JavaScript**: Stimulus controllers for interactive behavior +- **CSS**: Tailwind utility classes with custom components +- **Documentation**: Inline comments for complex business logic + +This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience. \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index f99db36..34312f4 100755 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) @@ -181,7 +182,6 @@ GEM minitest (>= 5.0) ruby-progressbar msgpack (1.8.0) - mysql2 (0.5.6) net-imap (0.5.9) date net-protocol @@ -215,8 +215,16 @@ GEM parser (3.3.9.0) ast (~> 2.4.1) racc + pdf-core (0.10.0) pp (0.6.2) prettyprint + prawn (2.5.0) + matrix (~> 0.4) + pdf-core (~> 0.10.0) + ttfunk (~> 1.8) + prawn-qrcode (0.5.2) + prawn (>= 1) + rqrcode (>= 1.0.0) prettyprint (0.2.0) prism (1.4.0) propshaft (1.2.1) @@ -279,8 +287,12 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) - rubocop (1.80.0) + rexml (3.4.2) + rqrcode (3.1.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.0.0) + rubocop (1.80.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -349,11 +361,14 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) + stripe (15.5.0) thor (1.4.0) thruster (0.1.15) thruster (0.1.15-aarch64-linux) thruster (0.1.15-x86_64-linux) timeout (0.4.3) + ttfunk (1.8.0) + bigdecimal (~> 3.1) turbo-rails (2.0.16) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -402,10 +417,12 @@ DEPENDENCIES kaminari (~> 1.2) kaminari-tailwind (~> 0.1.0) minitest-reporters (~> 1.7) - mysql2 (~> 0.5) + prawn (~> 2.5) + prawn-qrcode (~> 0.5) propshaft puma (>= 5.0) rails (~> 8.0.2, >= 8.0.2.1) + rqrcode (~> 3.1) rubocop-rails-omakase selenium-webdriver solid_cable @@ -413,6 +430,7 @@ DEPENDENCIES solid_queue sqlite3 (~> 2.7) stimulus-rails + stripe (~> 15.5) thruster turbo-rails tzinfo-data diff --git a/README.md b/README.md index 8aa646d..c3d5bee 100755 --- a/README.md +++ b/README.md @@ -4,76 +4,209 @@ ## 🌃 Overview -**Aperonight** is a two-sided marketplace connecting event-goers with nightlife promoters in Paris. The platform allows: - -- **Customers** to discover/book tickets for upcoming events -- **Promoters** to create/manage events and validate tickets at venue entrances +**Aperonight** is a comprehensive ticket selling system that connects event-goers with event organizers. The platform provides a complete solution for event booking, payment processing, and ticket management. ## 🎯 Key Features ### For Event-Goers -✔ Browse upcoming events with filters (date, location, music genre) -✔ Book tickets with multiple bundle options (VIP, group passes, etc.) -✔ Secure payment processing (credit cards, Apple/Google Pay) -✔ Mobile-friendly e-tickets with QR codes +✅ **User Dashboard** - Personalized metrics showing booked events, upcoming events, and event statistics +✅ **Event Discovery** - Browse upcoming events with detailed information and venue details +✅ **Secure Booking** - Multiple ticket types per event with quantity selection +✅ **Stripe Integration** - Secure payment processing with credit/debit cards +✅ **PDF Tickets** - Automatically generated tickets with unique QR codes for each purchase +✅ **Download System** - Instant PDF ticket downloads after successful payment -### For Promoters -✔ Event creation dashboard with ticket type customization -✔ Real-time ticket validation via mobile scanning -✔ Sales analytics and attendee tracking -✔ Automatic aggregation of events from partner platforms +### For Event Organizers +✅ **Event Management** - Create and manage events with detailed information +✅ **Ticket Type Configuration** - Set up multiple ticket types with different pricing +✅ **Sales Tracking** - Monitor ticket sales and availability +✅ **User Authentication** - Secure user registration and login system + +### Technical Implementation +✅ **Payment Processing** - Full Stripe Checkout integration with session management +✅ **PDF Generation** - Custom PDF tickets with QR codes using Prawn library +✅ **Responsive Design** - Mobile-friendly interface with Tailwind CSS +✅ **Database Relations** - Proper user-event-ticket relationships ## 🛠 Technical Stack ### Backend -- **Ruby on Rails 7** (API mode) -- **MariaDB** database - -- **ActiveJob** for background processing +- **Ruby on Rails 8.0+** with Hotwire for reactive UI +- **MySQL** database with comprehensive migrations +- **Devise** for user authentication and session management +- **Kaminari** for pagination ### Frontend -- **Hotwire (Turbo + Stimulus)** for reactive UI -- **Tailwind CSS** for styling -- **React Native** for promoter mobile app (ticket scanning) +- **Hotwire (Turbo + Stimulus)** for interactive JavaScript behavior +- **Tailwind CSS** for responsive styling and modern UI +- **JavaScript Controllers** for cart management and checkout flow ### Key Integrations -- **Stripe Connect** for payments & promoter payouts -- **Shogun/Bizouk/Weezevent APIs** for event aggregation - - +- **Stripe** for secure payment processing and checkout sessions +- **Prawn & Prawn-QRCode** for PDF ticket generation +- **RQRCode** for unique QR code generation per ticket -## 📊 Database Schema (Simplified) +## 📊 Database Schema ```mermaid erDiagram - USER ||--o{ BOOKING : makes + USER ||--o{ EVENT : creates + USER ||--o{ TICKET : purchases USER { integer id string email string encrypted_password - } - PROMOTER ||--o{ EVENT : creates - PROMOTER { - integer id - string stripe_account_id + string first_name + string last_name } EVENT ||--o{ TICKET_TYPE : has EVENT { integer id + integer user_id + string name + string slug + text description + string venue_name + string venue_address + decimal latitude + decimal longitude datetime start_time - } - BOOKING ||--o{ TICKET : generates - BOOKING { - integer id - decimal total_price + datetime end_time + string state + boolean featured + string image } TICKET_TYPE ||--o{ TICKET : defines TICKET_TYPE { integer id + integer event_id string name + text description + integer price_cents + integer quantity + datetime sale_start_at + datetime sale_end_at + boolean requires_id + integer minimum_age } TICKET { integer id + integer user_id + integer ticket_type_id string qr_code + integer price_cents + string status } ``` + +## 🚀 Getting Started + +### Prerequisites +- Ruby 3.4+ +- Rails 8.0+ +- MySQL/MariaDB +- Node.js 18+ (for asset compilation) +- Stripe account (for payment processing) + +### Installation + +1. **Clone the repository** +```bash +git clone https://github.com/yourusername/aperonight.git +cd aperonight +``` + +2. **Install dependencies** +```bash +bundle install +npm install +``` + +3. **Database setup** +```bash +rails db:create +rails db:migrate +rails db:seed +``` + +4. **Configure environment variables** +Create a `.env` file or configure Rails credentials: +```bash +# Stripe configuration +STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here +STRIPE_SECRET_KEY=sk_test_your_key_here +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here + +# Database configuration (if not using defaults) +DATABASE_URL=mysql2://username:password@localhost/aperonight_development +``` + +5. **Start the development server** +```bash +rails server +``` + +Visit `http://localhost:3000` to see the application running. + +## 💳 Payment Configuration + +### Setting up Stripe + +1. Create a Stripe account at [stripe.com](https://stripe.com) +2. Get your API keys from the Stripe Dashboard +3. Add your keys to the Rails credentials or environment variables +4. Configure webhook endpoints for payment confirmations: + - Endpoint URL: `your-domain.com/stripe/webhooks` + - Events: `checkout.session.completed`, `payment_intent.succeeded` + +## đŸŽ« Core Functionality + +### User Flow +1. **Registration/Login** - Users create accounts or sign in +2. **Event Discovery** - Browse events from the homepage or events page +3. **Ticket Selection** - Choose ticket types and quantities +4. **Checkout** - Secure payment through Stripe Checkout +5. **Ticket Generation** - Automatic PDF ticket generation with QR codes +6. **Download** - Instant ticket download after payment + +### Event Management +1. **Event Creation** - Create events with full details and images +2. **Ticket Types** - Configure multiple ticket types with pricing +3. **Sales Tracking** - Monitor ticket sales through the dashboard + +### Dashboard Features +- **Personal Metrics** - View booked events and upcoming events +- **Event Sections** - Today's events, tomorrow's events, and upcoming events +- **Quick Actions** - Easy navigation to event discovery and booking + +## 🔧 Development + +### Key Files Structure +``` +app/ +├── controllers/ +│ ├── events_controller.rb # Event listing, booking, checkout +│ └── pages_controller.rb # Dashboard and static pages +├── models/ +│ ├── user.rb # User authentication with Devise +│ ├── event.rb # Event management and states +│ ├── ticket_type.rb # Ticket configuration +│ └── ticket.rb # Ticket generation with QR codes +├── services/ +│ └── ticket_pdf_generator.rb # PDF ticket generation service +└── views/ + ├── events/ + │ ├── show.html.erb # Event details and booking + │ └── payment_success.html.erb # Post-purchase confirmation + └── pages/ + └── dashboard.html.erb # User dashboard with metrics +``` + +### Key Routes +- `GET /` - Homepage +- `GET /dashboard` - User dashboard (authenticated) +- `GET /events` - Event listings +- `GET /events/:slug.:id` - Event details and booking +- `POST /events/:slug.:id/checkout` - Stripe checkout initiation +- `GET /payment/success` - Payment confirmation +- `GET /tickets/:ticket_id/download` - PDF ticket download diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b3fbdfd..8c3812f 100755 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,26 +1,28 @@ class EventsController < ApplicationController + before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket] + before_action :set_event, only: [:show, :checkout] + # Display all events def index - @events = Event.includes(:user).upcoming.page(params[:page]).per(1) - # @events = Event.page(params[:page]).per(12) + @events = Event.includes(:user).upcoming.page(params[:page]).per(12) end # Display desired event def show - @event = Event.find(params[:id]) + # Event is set by set_event callback end - # Handle checkout process + # Handle checkout process - Create Stripe session def checkout - @event = Event.find(params[:id]) cart_data = JSON.parse(params[:cart] || "{}") if cart_data.empty? - redirect_to event_path(@event), alert: "Please select at least one ticket" + redirect_to event_path(@event.slug, @event), alert: "Veuillez sĂ©lectionner au moins un billet" return end # Create order items from cart + line_items = [] order_items = [] total_amount = 0 @@ -34,12 +36,27 @@ class EventsController < ApplicationController # Check availability available = ticket_type.quantity - ticket_type.tickets.count if quantity > available - redirect_to event_path(@event), alert: "Not enough tickets available for #{ticket_type.name}" + redirect_to event_path(@event.slug, @event), alert: "Pas assez de billets disponibles pour #{ticket_type.name}" return end + # Create Stripe line item + line_items << { + price_data: { + currency: 'eur', + product_data: { + name: "#{@event.name} - #{ticket_type.name}", + description: ticket_type.description, + }, + unit_amount: ticket_type.price_cents, + }, + quantity: quantity, + } + + # Store for ticket creation order_items << { - ticket_type: ticket_type, + ticket_type_id: ticket_type.id, + ticket_type_name: ticket_type.name, quantity: quantity, price_cents: ticket_type.price_cents } @@ -48,17 +65,90 @@ class EventsController < ApplicationController end if order_items.empty? - redirect_to event_path(@event), alert: "Invalid order" + redirect_to event_path(@event.slug, @event), alert: "Commande invalide" return end - # Here you would typically: - # 1. Create an Order record - # 2. Create Ticket records for each item - # 3. Redirect to payment processing + begin + # Create Stripe Checkout Session + session = Stripe::Checkout::Session.create({ + payment_method_types: ['card'], + line_items: line_items, + mode: 'payment', + success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'), + cancel_url: event_url(@event.slug, @event), + customer_email: current_user.email, + metadata: { + event_id: @event.id, + user_id: current_user.id, + order_items: order_items.to_json + } + }) - # For now, we'll just redirect with a success message - # In a real app, you'd redirect to a payment page - redirect_to event_path(@event), notice: "Order created successfully! Proceeding to payment..." + redirect_to session.url, allow_other_host: true + rescue Stripe::StripeError => e + redirect_to event_path(@event.slug, @event), alert: "Erreur de traitement du paiement : #{e.message}" + end + end + + # Handle successful payment + def payment_success + session_id = params[:session_id] + event_id = params[:event_id] + + begin + session = Stripe::Checkout::Session.retrieve(session_id) + + if session.payment_status == 'paid' + # Create tickets + @event = Event.find(event_id) + order_items = JSON.parse(session.metadata['order_items']) + @tickets = [] + + order_items.each do |item| + ticket_type = TicketType.find(item['ticket_type_id']) + item['quantity'].times do + ticket = Ticket.create!( + user: current_user, + ticket_type: ticket_type, + status: 'active' + ) + @tickets << ticket + + # Send confirmation email for each ticket + TicketMailer.purchase_confirmation(ticket).deliver_now + end + end + + render 'payment_success' + else + redirect_to event_path(@event.slug, @event), alert: "Le paiement n'a pas Ă©tĂ© complĂ©tĂ© avec succĂšs" + end + rescue Stripe::StripeError => e + redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{e.message}" + rescue => e + redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{e.message}" + end + end + + # Download ticket PDF + def download_ticket + @ticket = current_user.tickets.find(params[:ticket_id]) + + respond_to do |format| + format.pdf do + pdf = @ticket.to_pdf + send_data pdf, + filename: "ticket-#{@ticket.event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf", + type: 'application/pdf', + disposition: 'attachment' + end + end + end + + private + + def set_event + @event = Event.find(params[:id]) end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 88b1c89..44e73fb 100755 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -18,8 +18,19 @@ class PagesController < ApplicationController # User dashboard showing personalized content # Accessible only to authenticated users def dashboard - @available_events = Event.published.count - @events_this_week = Event.published.where("start_time BETWEEN ? AND ?", Date.current.beginning_of_week, Date.current.end_of_week).count + # Metrics for dashboard cards + @booked_events = current_user.tickets.joins(:ticket_type, :event).where(events: { state: :published }).count + @events_today = Event.published.where("DATE(start_time) = ?", Date.current).count + @events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count + @upcoming_events = Event.published.upcoming.count + + # User's booked events + @user_booked_events = Event.joins(ticket_types: :tickets) + .where(tickets: { user: current_user, status: 'active' }) + .distinct + .limit(5) + + # Events sections @today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc) @tomorrow_events = Event.published.where("DATE(start_time) = ?", Date.current + 1).order(start_time: :asc) @other_events = Event.published.upcoming.where.not("DATE(start_time) IN (?)", [Date.current, Date.current + 1]).order(start_time: :asc).page(params[:page]) diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/logout_controller.js index 3819eaa..5c914ec 100755 --- a/app/javascript/controllers/logout_controller.js +++ b/app/javascript/controllers/logout_controller.js @@ -38,14 +38,14 @@ export default class extends Controller { credentials: "same-origin", }) .then((response) => { - // console.log(this.element.dataset.loginUrlValue); // By default, we does not return anything. + // console.log(this.element.dataset.redirectUrlValue); // By default, we does not return anything. // By default the response does not include any url. - // Redirect to default login page (loginUrlValue) + // Redirect to default login page (redirectUrlValue) if (response.redirected) { window.location.href = response.url; - } else if (this.element.dataset.loginUrlValue) { - window.location.href = this.element.dataset.loginUrlValue; + } else if (this.element.dataset.redirectUrlValue) { + window.location.href = this.element.dataset.redirectUrlValue; return; } window.location.href = "/"; diff --git a/app/javascript/controllers/ticket_cart_controller.js b/app/javascript/controllers/ticket_cart_controller.js index 08d8edf..a9eef3b 100755 --- a/app/javascript/controllers/ticket_cart_controller.js +++ b/app/javascript/controllers/ticket_cart_controller.js @@ -7,11 +7,14 @@ export default class extends Controller { connect() { this.cart = {} this.updateCartDisplay() + + // Check for pending cart in session storage (after login) + this.checkForPendingCart() } increaseQuantity(event) { - const ticketTypeId = event.currentTarget.dataset.ticketTypeId - const max = parseInt(event.currentTarget.dataset.max) + const ticketTypeId = event.params.ticketTypeId + const max = parseInt(event.params.max) const input = this.quantityTargetFor(ticketTypeId) const current = parseInt(input.value) || 0 @@ -22,7 +25,7 @@ export default class extends Controller { } decreaseQuantity(event) { - const ticketTypeId = event.currentTarget.dataset.ticketTypeId + const ticketTypeId = event.params.ticketTypeId const input = this.quantityTargetFor(ticketTypeId) const current = parseInt(input.value) || 0 @@ -32,6 +35,22 @@ export default class extends Controller { } } + updateQuantityFromInput(event) { + const input = event.target + const ticketTypeId = input.dataset.ticketTypeId + const max = parseInt(input.max) + const quantity = parseInt(input.value) || 0 + + // Validate input + if (quantity < 0) { + input.value = 0 + } else if (quantity > max) { + input.value = max + } + + this.updateCartItem(ticketTypeId, input) + } + updateCartItem(ticketTypeId, input) { const name = input.dataset.name const price = parseInt(input.dataset.price) @@ -59,28 +78,54 @@ export default class extends Controller { totalPrice += (this.cart[ticketTypeId].price * this.cart[ticketTypeId].quantity) / 100 } - this.cartCountTarget.textContent = totalTickets - this.cartTotalTarget.textContent = totalPrice.toFixed(2) + // Update cart count and total + if (this.hasCartCountTarget) { + this.cartCountTarget.textContent = totalTickets + } + + if (this.hasCartTotalTarget) { + this.cartTotalTarget.textContent = totalPrice.toFixed(2) + } - const checkoutBtn = this.checkoutButtonTarget - if (totalTickets > 0) { - checkoutBtn.disabled = false - } else { - checkoutBtn.disabled = true + // 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) { - alert('Please select at least one ticket') + alert('Veuillez sĂ©lectionner au moins un billet') return } + // Check if user is authenticated + const isAuthenticated = document.body.dataset.userAuthenticated === "true" + + if (!isAuthenticated) { + if (confirm('Vous devez ĂȘtre connectĂ© pour acheter des billets. Souhaitez-vous vous connecter maintenant ?')) { + // Store cart in session storage + sessionStorage.setItem('pending_cart', JSON.stringify({ + eventId: this.eventIdValue, + cart: this.cart + })) + window.location.href = '/auth/sign_in' + } + return + } + + // Create form and submit to checkout const form = document.createElement('form') form.method = 'POST' - form.action = `/events/${this.eventIdValue}/checkout` - form.style.display = 'none' - + 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') @@ -100,6 +145,31 @@ export default class extends Controller { form.submit() } + checkForPendingCart() { + const pendingCart = sessionStorage.getItem('pending_cart') + if (pendingCart) { + try { + const cartData = JSON.parse(pendingCart) + if (cartData.eventId == this.eventIdValue) { + this.cart = cartData.cart + this.updateCartDisplay() + + // Restore quantities in inputs + for (let ticketTypeId in this.cart) { + const input = this.quantityTargetFor(ticketTypeId) + if (input) { + input.value = this.cart[ticketTypeId].quantity + } + } + } + sessionStorage.removeItem('pending_cart') + } catch (e) { + console.error('Error restoring pending cart:', e) + sessionStorage.removeItem('pending_cart') + } + } + } + // Helper method to find quantity input by ticket type ID quantityTargetFor(ticketTypeId) { return document.querySelector(`#quantity_${ticketTypeId}`) diff --git a/app/mailers/ticket_mailer.rb b/app/mailers/ticket_mailer.rb new file mode 100755 index 0000000..9320020 --- /dev/null +++ b/app/mailers/ticket_mailer.rb @@ -0,0 +1,21 @@ +class TicketMailer < ApplicationMailer + default from: 'notifications@aperonight.com' + + def purchase_confirmation(ticket) + @ticket = ticket + @user = ticket.user + @event = ticket.event + + # Generate PDF attachment + pdf = @ticket.to_pdf + attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = { + mime_type: 'application/pdf', + content: pdf + } + + mail( + to: @user.email, + subject: "Confirmation d'achat - #{@event.name}" + ) + end +end \ No newline at end of file diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 2786194..bfaccf5 100755 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -12,6 +12,17 @@ class Ticket < ApplicationRecord validates :status, presence: true, inclusion: { in: %w[active used expired refunded] } before_validation :set_price_from_ticket_type, on: :create + before_validation :generate_qr_code, on: :create + + # Generate PDF ticket + def to_pdf + TicketPdfGenerator.new(self).generate + end + + # Price in euros (formatted) + def price_euros + price_cents / 100.0 + end private @@ -19,4 +30,13 @@ class Ticket < ApplicationRecord return unless ticket_type self.price_cents = ticket_type.price_cents end + + def generate_qr_code + return if qr_code.present? + + loop do + self.qr_code = SecureRandom.uuid + break unless Ticket.exists?(qr_code: qr_code) + end + end end diff --git a/app/services/ticket_pdf_generator.rb b/app/services/ticket_pdf_generator.rb new file mode 100755 index 0000000..71abf35 --- /dev/null +++ b/app/services/ticket_pdf_generator.rb @@ -0,0 +1,97 @@ +require 'prawn' +require 'prawn/qrcode' +require 'rqrcode' + +class TicketPdfGenerator + attr_reader :ticket + + def initialize(ticket) + @ticket = ticket + end + + def generate + Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf| + # Header + pdf.fill_color "2D1B69" + pdf.font "Helvetica", style: :bold, size: 24 + pdf.text "ApĂ©roNight", align: :center + pdf.move_down 10 + + # Event name + pdf.fill_color "000000" + pdf.font "Helvetica", style: :bold, size: 18 + pdf.text ticket.event.name, align: :center + pdf.move_down 20 + + # Ticket info box + pdf.stroke_color "E5E7EB" + pdf.fill_color "F9FAFB" + pdf.rounded_rectangle [0, pdf.cursor], 310, 120, 10 + pdf.fill_and_stroke + + pdf.move_down 10 + pdf.fill_color "000000" + pdf.font "Helvetica", size: 12 + + # Ticket details + pdf.text "Ticket Type:", style: :bold + pdf.text ticket.ticket_type.name + pdf.move_down 8 + + pdf.text "Price:", style: :bold + pdf.text "€#{ticket.price_euros}" + pdf.move_down 8 + + pdf.text "Date & Time:", style: :bold + pdf.text ticket.event.start_time.strftime("%B %d, %Y at %I:%M %p") + pdf.move_down 20 + + # Venue information + pdf.fill_color "374151" + pdf.font "Helvetica", style: :bold, size: 14 + pdf.text "Venue Information" + pdf.move_down 8 + + pdf.font "Helvetica", size: 11 + pdf.text ticket.event.venue_name, style: :bold + pdf.text ticket.event.venue_address + pdf.move_down 20 + + # QR Code + pdf.fill_color "000000" + pdf.font "Helvetica", style: :bold, size: 14 + pdf.text "Ticket QR Code", align: :center + pdf.move_down 10 + + qr_code_data = { + ticket_id: ticket.id, + qr_code: ticket.qr_code, + event_id: ticket.event.id, + user_id: ticket.user.id + }.to_json + + qrcode = RQRCode::QRCode.new(qr_code_data) + pdf.print_qr_code(qrcode, extent: 120, align: :center) + + pdf.move_down 15 + + # QR code text + pdf.font "Helvetica", size: 8 + pdf.fill_color "6B7280" + pdf.text "QR Code: #{ticket.qr_code[0..7]}...", align: :center + + # Footer + pdf.move_down 30 + pdf.stroke_color "E5E7EB" + pdf.horizontal_line 0, 310 + pdf.move_down 10 + + pdf.font "Helvetica", size: 8 + pdf.fill_color "6B7280" + pdf.text "This ticket is valid for one entry only.", align: :center + pdf.text "Present this ticket at the venue entrance.", align: :center + pdf.move_down 5 + pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}", align: :center + end.render + end +end \ No newline at end of file diff --git a/app/views/components/_header.html.erb b/app/views/components/_header.html.erb index 7d0de82..2844294 100755 --- a/app/views/components/_header.html.erb +++ b/app/views/components/_header.html.erb @@ -8,9 +8,9 @@ @@ -34,7 +34,7 @@ class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700" %> <%= link_to t("header.logout"), destroy_user_session_path, data: { controller: "logout", action: "click->logout#signOut", - logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false }, + logout_url_value: destroy_user_session_path, redirect_url_value: "/", turbo: false }, class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700 last:rounded-b-md" %> diff --git a/app/views/components/_ticket_card.html.erb b/app/views/components/_ticket_card.html.erb index fe556d4..98cf58c 100755 --- a/app/views/components/_ticket_card.html.erb +++ b/app/views/components/_ticket_card.html.erb @@ -1,66 +1,69 @@ -
"> -
-
+
shadow-sm overflow-hidden"> +
+
-

"><%= name %>

-

"><%= description %>

+

"><%= name %>

+

"><%= description %>

-

"> +

"> <%= number_to_currency(price_cents / 100.0, unit: "€") %>

-
+
-

- <% if sold_out %> - - - - - Sold Out - - <% else %> - - - - - <%= remaining %> available - - <% end %> -

+ <% if sold_out %> + + + + + ÉpuisĂ© + + <% else %> + + + + + <%= remaining %> disponibles + + <% end %>
<% unless sold_out %>
+ data-price="<%= price_cents %>">
<% else %> -
+
- Unavailable + Indisponible
<% end %>
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 9d0c789..d94fce5 100755 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,53 +1,91 @@
-

ÉvĂ©nements Ă  venir

+
+

ÉvĂ©nements Ă  venir

+
+ <%= @events.total_count %> événements trouvés +
+
<% if @events.any? %>
<% @events.each do |event| %> -
+
+ <% if event.image.present? %> +
+ <%= image_tag event.image, class: "w-full h-full object-cover" %> +
+ <% else %> +
+ + + +
+ <% end %> +
-
+
-

<%= event.name %>

-

<%= event.user.email %>

+

<%= event.name %>

+

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

- <%= event.start_time.strftime("%d/%m/%Y") %> + <%= event.start_time.strftime("%d/%m") %>
-
+

<%= event.description.truncate(100) %>

-
+
<% if event.ticket_types.any? %>

À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€

+

+ + + + <%= event.venue_name.truncate(20) %> +

<% else %>

Pas de billets disponibles

<% end %>
- <%= link_to "Voir les détails", event_path(event.slug, event), class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %> + <%= link_to event_path(event.slug, event), class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200" do %> + Détails + + + + <% end %>
<% end %>
-
- <%# paginate @events, theme: 'twitter_bootstrap' %> - <%= paginate @events %> +
+ <%= paginate @events, theme: "tailwind" %>
<% else %> -
- - - -

Aucun événement disponible

-

Il n'y a aucun événement à venir pour le moment.

+
+
+
+ + + +
+

Aucun événement disponible

+

Il n'y a aucun événement à venir pour le moment.

+ <%= link_to "Retour Ă  l'accueil", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" %> +
<% end %> +
diff --git a/app/views/events/payment_success.html.erb b/app/views/events/payment_success.html.erb new file mode 100755 index 0000000..7728897 --- /dev/null +++ b/app/views/events/payment_success.html.erb @@ -0,0 +1,150 @@ +
+
+
+ +
+
+
+ + + +
+
+

Paiement réussi !

+

Félicitations pour votre achat

+
+ + +
+
+

+ Vos billets pour <%= @event.name %> ont été achetés avec succÚs. +

+

+ Un email de confirmation avec vos billets a été envoyé à <%= current_user.email %> +

+
+ + +
+

+ + + + Détails de l'événement +

+
+
+ + + + +
+

Lieu

+

<%= @event.venue_name %>

+
+
+
+ + + +
+

Date & Heure

+

<%= @event.start_time.strftime("%d %B %Y Ă  %H:%M") %>

+
+
+
+
+ + +
+

+ + + + Vos billets +

+ +
+ <% @tickets.each do |ticket| %> +
+
+
+
+
+ + + +
+
+

<%= ticket.ticket_type.name %>

+

Prix: <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %>

+
+
+
+ +
+ <%= link_to download_ticket_path(ticket, format: :pdf), + class: "inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all duration-200 text-sm font-medium shadow-sm" do %> + + + + Télécharger PDF + <% end %> +
+
+ +
+
+ + + + Code QR: <%= ticket.qr_code[0..7] %> +
+ + Actif + +
+
+ <% end %> +
+
+ + +
+
+ + + +
+

Important

+

+ Veuillez télécharger et sauvegarder vos billets. Présentez-les à l'entrée du lieu pour accéder à l'événement. + Un email de confirmation avec vos billets a été envoyé à votre adresse email. +

+
+
+
+ + +
+ <%= link_to dashboard_path, + class: "inline-flex items-center justify-center px-6 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl hover:from-purple-700 hover:to-indigo-700 transition-all duration-200 font-medium shadow-sm" do %> + + + + Tableau de bord + <% end %> + + <%= link_to events_path, + class: "inline-flex items-center justify-center px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-300 hover:bg-gray-50 transition-all duration-200 font-medium shadow-sm" do %> + + + + Voir plus d'événements + <% end %> +
+
+
+
+
\ No newline at end of file diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 40b4e7d..1ade491 100755 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -1,147 +1,185 @@ -
-
- -