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
This commit is contained in:
kbe
2025-08-28 18:03:48 +02:00
parent 49ad935855
commit 4e2445198f
23 changed files with 1376 additions and 279 deletions

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@
# Ignore generated reports # Ignore generated reports
/test/reports /test/reports
# Ignore .fuse file
.fuse_hidden*

267
AGENT.md Executable file
View File

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

View File

@@ -96,6 +96,7 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.3) connection_pool (2.5.3)
crass (1.0.6) crass (1.0.6)
@@ -181,7 +182,6 @@ GEM
minitest (>= 5.0) minitest (>= 5.0)
ruby-progressbar ruby-progressbar
msgpack (1.8.0) msgpack (1.8.0)
mysql2 (0.5.6)
net-imap (0.5.9) net-imap (0.5.9)
date date
net-protocol net-protocol
@@ -215,8 +215,16 @@ GEM
parser (3.3.9.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-core (0.10.0)
pp (0.6.2) pp (0.6.2)
prettyprint 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) prettyprint (0.2.0)
prism (1.4.0) prism (1.4.0)
propshaft (1.2.1) propshaft (1.2.1)
@@ -279,8 +287,12 @@ GEM
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.4.1) rexml (3.4.2)
rubocop (1.80.0) rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.80.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -349,11 +361,14 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) stringio (3.1.7)
stripe (15.5.0)
thor (1.4.0) thor (1.4.0)
thruster (0.1.15) thruster (0.1.15)
thruster (0.1.15-aarch64-linux) thruster (0.1.15-aarch64-linux)
thruster (0.1.15-x86_64-linux) thruster (0.1.15-x86_64-linux)
timeout (0.4.3) timeout (0.4.3)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
turbo-rails (2.0.16) turbo-rails (2.0.16)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
@@ -402,10 +417,12 @@ DEPENDENCIES
kaminari (~> 1.2) kaminari (~> 1.2)
kaminari-tailwind (~> 0.1.0) kaminari-tailwind (~> 0.1.0)
minitest-reporters (~> 1.7) minitest-reporters (~> 1.7)
mysql2 (~> 0.5) prawn (~> 2.5)
prawn-qrcode (~> 0.5)
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.0.2, >= 8.0.2.1) rails (~> 8.0.2, >= 8.0.2.1)
rqrcode (~> 3.1)
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver
solid_cable solid_cable
@@ -413,6 +430,7 @@ DEPENDENCIES
solid_queue solid_queue
sqlite3 (~> 2.7) sqlite3 (~> 2.7)
stimulus-rails stimulus-rails
stripe (~> 15.5)
thruster thruster
turbo-rails turbo-rails
tzinfo-data tzinfo-data

205
README.md
View File

@@ -4,76 +4,209 @@
## 🌃 Overview ## 🌃 Overview
**Aperonight** is a two-sided marketplace connecting event-goers with nightlife promoters in Paris. The platform allows: **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.
- **Customers** to discover/book tickets for upcoming events
- **Promoters** to create/manage events and validate tickets at venue entrances
## 🎯 Key Features ## 🎯 Key Features
### For Event-Goers ### For Event-Goers
✔ Browse upcoming events with filters (date, location, music genre) **User Dashboard** - Personalized metrics showing booked events, upcoming events, and event statistics
✔ Book tickets with multiple bundle options (VIP, group passes, etc.) **Event Discovery** - Browse upcoming events with detailed information and venue details
Secure payment processing (credit cards, Apple/Google Pay) **Secure Booking** - Multiple ticket types per event with quantity selection
✔ Mobile-friendly e-tickets with QR codes **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 ### For Event Organizers
Event creation dashboard with ticket type customization **Event Management** - Create and manage events with detailed information
✔ Real-time ticket validation via mobile scanning **Ticket Type Configuration** - Set up multiple ticket types with different pricing
Sales analytics and attendee tracking **Sales Tracking** - Monitor ticket sales and availability
✔ Automatic aggregation of events from partner platforms **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 ## 🛠 Technical Stack
### Backend ### Backend
- **Ruby on Rails 7** (API mode) - **Ruby on Rails 8.0+** with Hotwire for reactive UI
- **MariaDB** database - **MySQL** database with comprehensive migrations
<!--- **Redis** for caching/background jobs--> - **Devise** for user authentication and session management
- **ActiveJob** for background processing - **Kaminari** for pagination
### Frontend ### Frontend
- **Hotwire (Turbo + Stimulus)** for reactive UI - **Hotwire (Turbo + Stimulus)** for interactive JavaScript behavior
- **Tailwind CSS** for styling - **Tailwind CSS** for responsive styling and modern UI
- **React Native** for promoter mobile app (ticket scanning) - **JavaScript Controllers** for cart management and checkout flow
### Key Integrations ### Key Integrations
- **Stripe Connect** for payments & promoter payouts - **Stripe** for secure payment processing and checkout sessions
- **Shogun/Bizouk/Weezevent APIs** for event aggregation - **Prawn & Prawn-QRCode** for PDF ticket generation
<!--- **Twilio** for SMS ticket delivery--> - **RQRCode** for unique QR code generation per ticket
<!--- **AWS S3** for media storage-->
## 📊 Database Schema (Simplified) ## 📊 Database Schema
```mermaid ```mermaid
erDiagram erDiagram
USER ||--o{ BOOKING : makes USER ||--o{ EVENT : creates
USER ||--o{ TICKET : purchases
USER { USER {
integer id integer id
string email string email
string encrypted_password string encrypted_password
} string first_name
PROMOTER ||--o{ EVENT : creates string last_name
PROMOTER {
integer id
string stripe_account_id
} }
EVENT ||--o{ TICKET_TYPE : has EVENT ||--o{ TICKET_TYPE : has
EVENT { EVENT {
integer id integer id
integer user_id
string name
string slug
text description
string venue_name
string venue_address
decimal latitude
decimal longitude
datetime start_time datetime start_time
} datetime end_time
BOOKING ||--o{ TICKET : generates string state
BOOKING { boolean featured
integer id string image
decimal total_price
} }
TICKET_TYPE ||--o{ TICKET : defines TICKET_TYPE ||--o{ TICKET : defines
TICKET_TYPE { TICKET_TYPE {
integer id integer id
integer event_id
string name string name
text description
integer price_cents
integer quantity
datetime sale_start_at
datetime sale_end_at
boolean requires_id
integer minimum_age
} }
TICKET { TICKET {
integer id integer id
integer user_id
integer ticket_type_id
string qr_code 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

View File

@@ -1,26 +1,28 @@
class EventsController < ApplicationController class EventsController < ApplicationController
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
before_action :set_event, only: [:show, :checkout]
# Display all events # Display all events
def index def index
@events = Event.includes(:user).upcoming.page(params[:page]).per(1) @events = Event.includes(:user).upcoming.page(params[:page]).per(12)
# @events = Event.page(params[:page]).per(12)
end end
# Display desired event # Display desired event
def show def show
@event = Event.find(params[:id]) # Event is set by set_event callback
end end
# Handle checkout process # Handle checkout process - Create Stripe session
def checkout def checkout
@event = Event.find(params[:id])
cart_data = JSON.parse(params[:cart] || "{}") cart_data = JSON.parse(params[:cart] || "{}")
if cart_data.empty? 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 return
end end
# Create order items from cart # Create order items from cart
line_items = []
order_items = [] order_items = []
total_amount = 0 total_amount = 0
@@ -34,12 +36,27 @@ class EventsController < ApplicationController
# Check availability # Check availability
available = ticket_type.quantity - ticket_type.tickets.count available = ticket_type.quantity - ticket_type.tickets.count
if quantity > available 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 return
end 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 << { order_items << {
ticket_type: ticket_type, ticket_type_id: ticket_type.id,
ticket_type_name: ticket_type.name,
quantity: quantity, quantity: quantity,
price_cents: ticket_type.price_cents price_cents: ticket_type.price_cents
} }
@@ -48,17 +65,90 @@ class EventsController < ApplicationController
end end
if order_items.empty? if order_items.empty?
redirect_to event_path(@event), alert: "Invalid order" redirect_to event_path(@event.slug, @event), alert: "Commande invalide"
return return
end end
# Here you would typically: begin
# 1. Create an Order record # Create Stripe Checkout Session
# 2. Create Ticket records for each item session = Stripe::Checkout::Session.create({
# 3. Redirect to payment processing 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 redirect_to session.url, allow_other_host: true
# In a real app, you'd redirect to a payment page rescue Stripe::StripeError => e
redirect_to event_path(@event), notice: "Order created successfully! Proceeding to payment..." 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
end end

View File

@@ -18,8 +18,19 @@ class PagesController < ApplicationController
# User dashboard showing personalized content # User dashboard showing personalized content
# Accessible only to authenticated users # Accessible only to authenticated users
def dashboard def dashboard
@available_events = Event.published.count # Metrics for dashboard cards
@events_this_week = Event.published.where("start_time BETWEEN ? AND ?", Date.current.beginning_of_week, Date.current.end_of_week).count @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) @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) @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]) @other_events = Event.published.upcoming.where.not("DATE(start_time) IN (?)", [Date.current, Date.current + 1]).order(start_time: :asc).page(params[:page])

View File

@@ -38,14 +38,14 @@ export default class extends Controller {
credentials: "same-origin", credentials: "same-origin",
}) })
.then((response) => { .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. // By default the response does not include any url.
// Redirect to default login page (loginUrlValue) // Redirect to default login page (redirectUrlValue)
if (response.redirected) { if (response.redirected) {
window.location.href = response.url; window.location.href = response.url;
} else if (this.element.dataset.loginUrlValue) { } else if (this.element.dataset.redirectUrlValue) {
window.location.href = this.element.dataset.loginUrlValue; window.location.href = this.element.dataset.redirectUrlValue;
return; return;
} }
window.location.href = "/"; window.location.href = "/";

View File

@@ -7,11 +7,14 @@ export default class extends Controller {
connect() { connect() {
this.cart = {} this.cart = {}
this.updateCartDisplay() this.updateCartDisplay()
// Check for pending cart in session storage (after login)
this.checkForPendingCart()
} }
increaseQuantity(event) { increaseQuantity(event) {
const ticketTypeId = event.currentTarget.dataset.ticketTypeId const ticketTypeId = event.params.ticketTypeId
const max = parseInt(event.currentTarget.dataset.max) const max = parseInt(event.params.max)
const input = this.quantityTargetFor(ticketTypeId) const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0 const current = parseInt(input.value) || 0
@@ -22,7 +25,7 @@ export default class extends Controller {
} }
decreaseQuantity(event) { decreaseQuantity(event) {
const ticketTypeId = event.currentTarget.dataset.ticketTypeId const ticketTypeId = event.params.ticketTypeId
const input = this.quantityTargetFor(ticketTypeId) const input = this.quantityTargetFor(ticketTypeId)
const current = parseInt(input.value) || 0 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) { updateCartItem(ticketTypeId, input) {
const name = input.dataset.name const name = input.dataset.name
const price = parseInt(input.dataset.price) const price = parseInt(input.dataset.price)
@@ -59,27 +78,53 @@ export default class extends Controller {
totalPrice += (this.cart[ticketTypeId].price * this.cart[ticketTypeId].quantity) / 100 totalPrice += (this.cart[ticketTypeId].price * this.cart[ticketTypeId].quantity) / 100
} }
// Update cart count and total
if (this.hasCartCountTarget) {
this.cartCountTarget.textContent = totalTickets this.cartCountTarget.textContent = totalTickets
this.cartTotalTarget.textContent = totalPrice.toFixed(2) }
if (this.hasCartTotalTarget) {
this.cartTotalTarget.textContent = totalPrice.toFixed(2)
}
// Update checkout button state
if (this.hasCheckoutButtonTarget) {
const checkoutBtn = this.checkoutButtonTarget const checkoutBtn = this.checkoutButtonTarget
if (totalTickets > 0) { if (totalTickets > 0) {
checkoutBtn.disabled = false checkoutBtn.disabled = false
checkoutBtn.classList.remove('opacity-50', 'cursor-not-allowed')
} else { } else {
checkoutBtn.disabled = true checkoutBtn.disabled = true
checkoutBtn.classList.add('opacity-50', 'cursor-not-allowed')
}
} }
} }
proceedToCheckout() { proceedToCheckout() {
if (Object.keys(this.cart).length === 0) { if (Object.keys(this.cart).length === 0) {
alert('Please select at least one ticket') alert('Veuillez sélectionner au moins un billet')
return 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') const form = document.createElement('form')
form.method = 'POST' form.method = 'POST'
form.action = `/events/${this.eventIdValue}/checkout` form.action = `/events/${document.body.dataset.eventSlug}.${this.eventIdValue}/checkout`
form.style.display = 'none'
// Add CSRF token // Add CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content const csrfToken = document.querySelector('meta[name="csrf-token"]').content
@@ -100,6 +145,31 @@ export default class extends Controller {
form.submit() 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 // Helper method to find quantity input by ticket type ID
quantityTargetFor(ticketTypeId) { quantityTargetFor(ticketTypeId) {
return document.querySelector(`#quantity_${ticketTypeId}`) return document.querySelector(`#quantity_${ticketTypeId}`)

21
app/mailers/ticket_mailer.rb Executable file
View File

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

View File

@@ -12,6 +12,17 @@ class Ticket < ApplicationRecord
validates :status, presence: true, inclusion: { in: %w[active used expired refunded] } validates :status, presence: true, inclusion: { in: %w[active used expired refunded] }
before_validation :set_price_from_ticket_type, on: :create 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 private
@@ -19,4 +30,13 @@ class Ticket < ApplicationRecord
return unless ticket_type return unless ticket_type
self.price_cents = ticket_type.price_cents self.price_cents = ticket_type.price_cents
end 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 end

View File

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

View File

@@ -8,9 +8,9 @@
<div class="hidden sm:flex space-x-6"> <div class="hidden sm:flex space-x-6">
<%= link_to t("header.parties"), events_path, <%= link_to t("header.parties"), events_path,
class: "bg-black text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
<%= link_to t("header.concerts"), "#", <%= link_to t("header.concerts"), "#",
class: "bg-black text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %> class: "text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
</div> </div>
</div> </div>
@@ -34,7 +34,7 @@
class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700" %> 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, <%= link_to t("header.logout"), destroy_user_session_path,
data: { controller: "logout", action: "click->logout#signOut", 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" %> class: "block px-4 py-2 text-sm bg-black text-gray-100 hover:bg-purple-700 last:rounded-b-md" %>
</div> </div>
</div> </div>

View File

@@ -1,66 +1,69 @@
<div class="card rounded-2xl <%= sold_out ? "border border-neutral-200 opacity-75" : "border border-neutral-200 " %>"> <div class="bg-white rounded-xl border <%= sold_out ? "border-gray-200 opacity-75" : "border-purple-200" %> shadow-sm overflow-hidden">
<div class="card-body p-6"> <div class="p-5">
<div class="flex justify-between items-start mb-4"> <div class="flex justify-between items-start mb-3">
<div> <div>
<h3 class="text-xl font-bold text-primary <%= "text-slate-400" if sold_out %>"><%= name %></h3> <h3 class="text-lg font-bold text-gray-900 <%= "text-gray-400" if sold_out %>"><%= name %></h3>
<p class="text-neutral-600 text-sm <%= "text-slate-400" if sold_out %>"><%= description %></p> <p class="text-gray-600 text-sm <%= "text-gray-400" if sold_out %>"><%= description %></p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-2xl font-bold text-primary <%= "text-slate-400" if sold_out %>"> <p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
<%= number_to_currency(price_cents / 100.0, unit: "€") %> <%= number_to_currency(price_cents / 100.0, unit: "€") %>
</p> </p>
</div> </div>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center mt-4">
<div> <div>
<p class="text-sm text-neutral-600 flex items-center">
<% if sold_out %> <% if sold_out %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 ml-2"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8"> <svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" /> <circle cx="4" cy="4" r="3" />
</svg> </svg>
Sold Out Épuisé
</span> </span>
<% else %> <% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8"> <svg class="-ml-0.5 mr-1 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" /> <circle cx="4" cy="4" r="3" />
</svg> </svg>
<%= remaining %> available <%= remaining %> disponibles
</span> </span>
<% end %> <% end %>
</p>
</div> </div>
<% unless sold_out %> <% unless sold_out %>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-slate-200 hover:bg-slate-300 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"
onclick="decreaseQuantity(<%= id %>)"> data-action="click->ticket-cart#decreaseQuantity"
<span class="text-slate-600">-</span> data-ticket-cart-ticket-type-id-param="<%= id %>"
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">-</span>
</button> </button>
<input type="number" <input type="number"
id="quantity_<%= id %>" id="quantity_<%= id %>"
min="0" min="0"
max="<%= remaining %>" max="<%= remaining %>"
value="0" value="0"
class="w-12 text-center border border-slate-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" class="w-12 text-center border border-gray-300 rounded-md px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1"
data-ticket-cart-target="quantity"
data-ticket-type-id="<%= id %>"
data-name="<%= name %>" data-name="<%= name %>"
data-price="<%= price_cents %>" data-price="<%= price_cents %>">
onchange="updateCart(<%= id %>, this.dataset.name, this.dataset.price)">
<button type="button" <button type="button"
class="w-8 h-8 rounded-full bg-slate-200 hover:bg-slate-300 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"
onclick="increaseQuantity(<%= id %>, <%= remaining %>)"> data-action="click->ticket-cart#increaseQuantity"
<span class="text-slate-600">+</span> data-ticket-cart-ticket-type-id-param="<%= id %>"
data-ticket-cart-max-param="<%= remaining %>">
<span class="text-gray-600 font-bold">+</span>
</button> </button>
</div> </div>
<% else %> <% else %>
<div class="text-sm text-slate-500 font-medium"> <div class="text-sm text-gray-500 font-medium">
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg> </svg>
Unavailable Indisponible
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -1,53 +1,91 @@
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Événements à venir</h1> <div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Événements à venir</h1>
<div class="text-sm text-gray-500">
<%= @events.total_count %> événements trouvés
</div>
</div>
<% if @events.any? %> <% if @events.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<% @events.each do |event| %> <% @events.each do |event| %>
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300"> <div class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<% if event.image.present? %>
<div class="h-48 overflow-hidden">
<%= image_tag event.image, class: "w-full h-full object-cover" %>
</div>
<% else %>
<div class="h-48 bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center">
<svg class="w-16 h-16 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<div class="p-6"> <div class="p-6">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start mb-3">
<div> <div>
<h2 class="text-xl font-bold text-gray-900"><%= event.name %></h2> <h2 class="text-xl font-bold text-gray-900 line-clamp-1"><%= event.name %></h2>
<p class="text-sm text-gray-500 mt-1"><%= event.user.email %></p> <p class="text-sm text-gray-500 mt-1 flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<%= event.user.email.split('@').first %>
</p>
</div> </div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<%= event.start_time.strftime("%d/%m/%Y") %> <%= event.start_time.strftime("%d/%m") %>
</span> </span>
</div> </div>
<div class="mt-4"> <div class="mb-4">
<p class="text-gray-600 text-sm line-clamp-2"><%= event.description.truncate(100) %></p> <p class="text-gray-600 text-sm line-clamp-2"><%= event.description.truncate(100) %></p>
</div> </div>
<div class="mt-4 flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<% if event.ticket_types.any? %> <% if event.ticket_types.any? %>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€ À partir de <%= format_price(event.ticket_types.minimum(:price_cents)) %>€
</p> </p>
<p class="text-xs text-gray-500 flex items-center mt-1">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<%= event.venue_name.truncate(20) %>
</p>
<% else %> <% else %>
<p class="text-sm text-gray-500">Pas de billets disponibles</p> <p class="text-sm text-gray-500">Pas de billets disponibles</p>
<% end %> <% end %>
</div> </div>
<%= 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
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<% end %>
</div> </div>
</div> </div>
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="mt-8"> <div class="mt-8 flex justify-center">
<%# paginate @events, theme: 'twitter_bootstrap' %> <%= paginate @events, theme: "tailwind" %>
<%= paginate @events %>
</div> </div>
<% else %> <% else %>
<div class="text-center py-12"> <div class="text-center py-16">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="mx-auto max-w-md">
<div class="w-24 h-24 mx-auto bg-gradient-to-r from-purple-100 to-indigo-100 rounded-full flex items-center justify-center mb-6">
<svg class="w-12 h-12 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucun événement disponible</h3> </div>
<p class="mt-1 text-sm text-gray-500">Il n'y a aucun événement à venir pour le moment.</p> <h3 class="text-lg font-medium text-gray-900 mb-2">Aucun événement disponible</h3>
<p class="text-gray-500 mb-6">Il n'y a aucun événement à venir pour le moment.</p>
<%= 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" %>
</div>
</div> </div>
<% end %> <% end %>
</div>

View File

@@ -0,0 +1,150 @@
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6">
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 px-6 py-8 text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<h1 class="text-3xl font-bold text-white mb-2">Paiement réussi !</h1>
<p class="text-purple-100">Félicitations pour votre achat</p>
</div>
<!-- Content -->
<div class="p-6 sm:p-8">
<div class="text-center mb-8">
<p class="text-xl text-gray-700">
Vos billets pour <span class="font-bold text-purple-700"><%= @event.name %></span> ont été achetés avec succès.
</p>
<p class="text-gray-500 mt-2">
Un email de confirmation avec vos billets a été envoyé à <span class="font-medium"><%= current_user.email %></span>
</p>
</div>
<!-- Event Details -->
<div class="bg-gray-50 rounded-xl p-6 mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Détails de l'événement
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Lieu</p>
<p class="font-medium"><%= @event.venue_name %></p>
</div>
</div>
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Date & Heure</p>
<p class="font-medium"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
</div>
</div>
</div>
</div>
<!-- Tickets -->
<div class="mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
Vos billets
</h2>
<div class="space-y-4">
<% @tickets.each do |ticket| %>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-100 p-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex-1">
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-4">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
</div>
<div>
<h3 class="font-bold text-gray-900"><%= ticket.ticket_type.name %></h3>
<p class="text-sm text-gray-600">Prix: <span class="font-medium"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></span></p>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<%= 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 %>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Télécharger PDF
<% end %>
</div>
</div>
<div class="mt-4 pt-4 border-t border-purple-100 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="text-xs text-gray-500">Code QR: <%= ticket.qr_code[0..7] %></span>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Actif
</span>
</div>
</div>
<% end %>
</div>
</div>
<!-- Important Notice -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
<div class="flex">
<svg class="w-5 h-5 text-blue-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold text-blue-800 mb-1">Important</h3>
<p class="text-sm text-blue-700">
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.
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<%= 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 %>
<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 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
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 %>
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Voir plus d'événements
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,51 +1,43 @@
<div class="min-h-screen bg-neutral-50" data-controller="ticket-cart" data-ticket-cart-event-id-value="<%= params[:id] %>"> <div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100" data-controller="ticket-cart" data-ticket-cart-event-id-value="<%= params[:id] %>">
<div class="max-w-7xl mx-auto md:px-4"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-3 text-sm" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<nav class="mb-3 text-sm" aria-label="Breadcrumb" role="navigation"> <ol class="flex items-center space-x-2 text-sm">
<span class="flex items-center text-slate-700" role="list"> <%= link_to root_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<a href="/" class="hover:text-primary-600 transition-colors duration-200 flex items-center" role="listitem"> <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 mr-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
<span class="sr-only">Home</span> Accueil
</a> <% end %>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 mx-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
<%= link_to events_path, class: "text-gray-500 hover:text-purple-600 transition-colors" do %>
<a href="/events" class="hover:text-primary-600 transition-colors duration-200 mx-2" role="listitem"> Événements
Events <% end %>
</a> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 mx-2 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
<li class="font-medium text-gray-900 truncate max-w-xs" aria-current="page">
<span class="mx-2 font-medium truncate max-w-[150px] sm:max-w-[250px]" role="listitem" aria-current="page">
<%= @event.name %> <%= @event.name %>
</span> </li>
</span> </ol>
</nav>
</nav> </nav>
<div class="bg-white rounded-2xl shadow-lg p-4 sm:p-6 md:p-8 mb-6 sm:mb-8"> <div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="flex flex-col lg:flex-row gap-6 md:gap-8"> <!-- Event Header with Image -->
<!-- Left Column: Event Info & Image -->
<div class="w-full md:w-1/2">
<h1 class="text-4xl font-bold text-primary mb-4"><%= @event.name %></h1>
<% if @event.image.present? %> <% if @event.image.present? %>
<div class="relative rounded-2xl overflow-hidden mb-6"> <div class="relative h-96">
<%= image_tag @event.image, class: "w-full h-96 object-cover" %> <%= image_tag @event.image, class: "w-full h-full object-cover" %>
<div class="absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-50"></div> <div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black"> <div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<h2 class="text-2xl font-semibold text-white mb-2">Event Details</h2> <div class="max-w-4xl mx-auto">
<div class="flex flex-wrap gap-4 text-white"> <h1 class="text-3xl md:text-4xl font-bold text-white mb-2"><%= @event.name %></h1>
<div class="flex flex-wrap items-center gap-4 text-white/90">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg> </svg>
<span><%= @event.venue_name %></span> <span><%= @event.venue_name %></span>
</div> </div>
@@ -53,46 +45,92 @@
<svg class="w-5 h-5 mr-2 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<span><%= @event.start_time.strftime("%B %d, %Y at %I:%M %p") %></span> <span><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<% else %>
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 p-8">
<h1 class="text-3xl md:text-4xl font-bold text-white mb-4"><%= @event.name %></h1>
<div class="flex flex-wrap items-center gap-4 text-white/90">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span><%= @event.venue_name %></span>
</div>
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></span>
</div>
</div>
</div>
<% end %> <% end %>
<div class="space-y-4"> <!-- Event Content -->
<div> <div class="p-6 md:p-8">
<h2 class="text-xl font-semibold text-primary mb-2">Description</h2> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<p class="text-lg text-slate-600 leading-relaxed"><%= @event.description %></p> <!-- Left Column: Event Details -->
<div class="lg:col-span-2">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Description</h2>
<div class="prose max-w-none text-gray-700">
<p class="text-lg leading-relaxed"><%= @event.description %></p>
</div>
</div> </div>
<div class="space-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="flex items-center space-x-4"> <div class="bg-gray-50 rounded-xl p-6">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg> </svg>
<span class="font-medium text-slate-800">Location:</span> Lieu
<span class="text-slate-600"><%= @event.venue_address %></span> </h3>
<p class="text-gray-700 font-medium"><%= @event.venue_name %></p>
<p class="text-gray-600 mt-1"><%= @event.venue_address %></p>
</div> </div>
<div class="flex items-center space-x-4"> <div class="bg-gray-50 rounded-xl p-6">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path> <svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<span class="font-medium text-slate-800">Date:</span> Date & Heure
<span class="text-slate-600"><%= @event.start_time.strftime("%B %d, %Y") %></span> </h3>
<p class="text-gray-700 font-medium"><%= @event.start_time.strftime("%A %d %B %Y") %></p>
<p class="text-gray-600 mt-1">À <%= @event.start_time.strftime("%H:%M") %></p>
</div>
</div>
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Organisateur</h3>
<div class="flex items-center">
<div class="w-12 h-12 rounded-full bg-gradient-to-r from-purple-500 to-indigo-600 flex items-center justify-center text-white font-bold">
<%= @event.user.email.first.upcase %>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900"><%= @event.user.email.split('@').first %></p>
<p class="text-sm text-gray-500">Organisateur de l'événement</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Right Column: Ticket Selection --> <!-- Right Column: Ticket Selection -->
<div class="w-full md:w-1/2"> <div class="lg:col-span-1">
<div class="space-y-6"> <div class="sticky top-6">
<h2 class="text-2xl font-bold text-slate-800 mb-6">Available Tickets</h2> <div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Billets disponibles</h2>
<div class="space-y-4"> <% if @event.ticket_types.any? %>
<div class="space-y-4 mb-6">
<% @event.ticket_types.each do |ticket_type| %> <% @event.ticket_types.each do |ticket_type| %>
<% sold_out = ticket_type.quantity <= ticket_type.tickets.count %> <% sold_out = ticket_type.quantity <= ticket_type.tickets.count %>
<% remaining = ticket_type.quantity - ticket_type.tickets.count %> <% remaining = ticket_type.quantity - ticket_type.tickets.count %>
@@ -107,43 +145,43 @@
remaining: remaining remaining: remaining
} %> } %>
<% end %> <% end %>
<!-- Example of a sold out ticket type for demo purposes -->
<%= render 'components/ticket_card', {
name: "Early Bird Special",
description: "Limited time offer - discounted price for early purchasers",
price_cents: 1999,
quantity: 100,
sold_out: true,
remaining: 0
} %>
</div> </div>
</div> <% else %>
</div> <div class="text-center py-8">
</div> <svg class="w-12 h-12 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
<!-- Sticky Checkout Bar -->
<div class="sticky bottom-0 bg-white border-t border-slate-200 p-6">
<div class="max-w-md mx-auto">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<div class="text-center sm:text-left">
<p class="text-sm text-slate-600">Total: <span id="cart-count" class="font-semibold">0</span> tickets</p>
<p class="text-xl font-bold text-primary">€<span id="cart-total">0.00</span></p>
</div>
<button id="checkout-btn"
class="btn-primary w-full sm:w-auto px-8 py-3 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
disabled
onclick="proceedToCheckout()">
<span class="flex items-center justify-center">
<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="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg> </svg>
Continue to Checkout <h3 class="mt-4 text-lg font-medium text-gray-900">Aucun billet disponible</h3>
</span> <p class="mt-2 text-gray-500">Les billets pour cet événement ne sont pas encore disponibles ou sont épuisés.</p>
</div>
<% end %>
<!-- Cart Summary -->
<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">Total billets:</span>
<span id="cart-count" class="font-medium">0</span>
</div>
<div class="flex justify-between items-center mb-4">
<span class="text-gray-600">Montant total:</span>
<span id="cart-total" class="text-xl font-bold text-purple-700">€0.00</span>
</div>
<button
id="checkout-btn"
class="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-medium py-3 px-4 rounded-xl shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 flex items-center justify-center"
disabled
data-action="click->ticket-cart#proceedToCheckout">
<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> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
</head> </head>
<body> <body data-user-authenticated="<%= user_signed_in? %>" data-event-slug="<%= @event&.slug %>">
<div class="app-wrapper"> <div class="app-wrapper">
<%= render "components/header" %> <%= render "components/header" %>

View File

@@ -2,14 +2,44 @@
<!-- Hero section with metrics --> <!-- Hero section with metrics -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-6">Tableau de bord</h1> <h1 class="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-6">Tableau de bord</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<%= render partial: 'components/metric_card', locals: { title: "Événements disponibles", value: @available_events, classes: "from-purple-100 to-indigo-100" } %> <%= render partial: 'components/metric_card', locals: { title: "Mes réservations", value: @booked_events, classes: "from-green-100 to-emerald-100" } %>
<%= render partial: 'components/metric_card', locals: { title: "Événements aujourd'hui", value: @events_this_week, classes: "from-purple-100 to-indigo-100" } %> <%= render partial: 'components/metric_card', locals: { title: "Événements aujourd'hui", value: @events_today, classes: "from-blue-100 to-sky-100" } %>
<%= render partial: 'components/metric_card', locals: { title: "Événements cette semaine", value: @events_this_week, classes: "from-purple-100 to-indigo-100" } %> <%= render partial: 'components/metric_card', locals: { title: "Événements demain", value: @events_tomorrow, classes: "from-purple-100 to-indigo-100" } %>
<%= render partial: 'components/metric_card', locals: { title: "À venir", value: @upcoming_events, classes: "from-orange-100 to-amber-100" } %>
</div>
</div>
<!-- User's booked events -->
<div class="card hover-lift mb-8">
<div class="card-header">
<h2 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Mes événements réservés</h2>
</div>
<div class="card-body">
<% if @user_booked_events.any? %>
<ul class="space-y-4">
<% @user_booked_events.each do |event| %>
<li>
<%= render partial: 'components/event_item', locals: { event: event } %>
</li>
<% end %>
</ul>
<% if @booked_events > 5 %>
<div class="mt-6 text-center">
<%= link_to "Voir toutes mes réservations", "#", class: "text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium transition-colors duration-200" %>
</div>
<% end %>
<% else %>
<div class="text-center py-8">
<p class="text-slate-600 dark:text-slate-400 mb-4">Vous n'avez encore réservé aucun événement.</p>
<%= link_to "Découvrir les événements", events_path, class: "inline-flex items-center px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div>
<% end %>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,56 @@
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f8f9fa; border-radius: 8px;">
<div style="text-align: center; padding: 20px 0; border-bottom: 1px solid #e9ecef;">
<h1 style="color: #4c1d95; margin: 0; font-size: 28px;">ApéroNight</h1>
<p style="color: #6c757d; margin: 10px 0 0;">Confirmation de votre achat</p>
</div>
<div style="background-color: white; border-radius: 8px; padding: 30px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #212529; margin-top: 0;">Bonjour <%= @user.email.split('@').first %>,</h2>
<p style="color: #495057; line-height: 1.6;">
Merci pour votre achat ! Nous avons le plaisir de vous confirmer votre billet pour l'événement <strong><%= @event.name %></strong>.
</p>
<div style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 25px 0;">
<h3 style="color: #4c1d95; margin-top: 0; border-bottom: 1px solid #e9ecef; padding-bottom: 10px;">Détails de votre billet</h3>
<div style="display: flex; justify-content: space-between; margin-bottom: 15px;">
<div>
<p style="margin: 0; color: #6c757d; font-size: 14px;">Événement</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= @event.name %></p>
</div>
<div style="text-align: right;">
<p style="margin: 0; color: #6c757d; font-size: 14px;">Type de billet</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= @ticket.ticket_type.name %></p>
</div>
</div>
<div style="display: flex; justify-content: space-between;">
<div>
<p style="margin: 0; color: #6c757d; font-size: 14px;">Date & heure</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
</div>
<div style="text-align: right;">
<p style="margin: 0; color: #6c757d; font-size: 14px;">Prix</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %></p>
</div>
</div>
</div>
<div style="text-align: center; margin: 30px 0;">
<p style="color: #495057; margin-bottom: 20px;">Votre billet est attaché à cet email en format PDF.</p>
<p style="color: #495057; margin-bottom: 20px;">Présentez-le à l'entrée de l'événement pour y accéder.</p>
</div>
<div style="background-color: #fff3cd; border-radius: 6px; padding: 15px; border-left: 4px solid #ffc107;">
<p style="margin: 0; color: #856404; font-size: 14px;">
<strong>Important :</strong> Ce billet est valable pour une seule entrée. Conservez-le précieusement.
</p>
</div>
</div>
<div style="text-align: center; color: #6c757d; font-size: 14px; padding: 20px 0;">
<p style="margin: 0;">Si vous avez des questions, contactez-nous à <a href="mailto:support@aperonight.com" style="color: #4c1d95; text-decoration: none;">support@aperonight.com</a></p>
<p style="margin: 10px 0 0;">&copy; <%= Time.current.year %> ApéroNight. Tous droits réservés.</p>
</div>
</div>

View File

@@ -0,0 +1,19 @@
Bonjour <%= @user.email.split('@').first %>,
Merci pour votre achat ! Nous avons le plaisir de vous confirmer votre billet pour l'événement "<%= @event.name %>".
DÉTAILS DE VOTRE BILLET
======================
Événement : <%= @event.name %>
Type de billet : <%= @ticket.ticket_type.name %>
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
Prix : <%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %>
Votre billet est attaché à cet email en format PDF. Présentez-le à l'entrée de l'événement pour y accéder.
Important : Ce billet est valable pour une seule entrée. Conservez-le précieusement.
Si vous avez des questions, contactez-nous à support@aperonight.com
© <%= Time.current.year %> ApéroNight. Tous droits réservés.

27
config/initializers/stripe.rb Executable file
View File

@@ -0,0 +1,27 @@
Rails.application.configure do
# Try to get Stripe keys from environment variables first, then from credentials
stripe_publishable_key = ENV["STRIPE_PUBLISHABLE_KEY"]
stripe_secret_key = ENV["STRIPE_SECRET_KEY"]
stripe_webhook_secret = ENV["STRIPE_WEBHOOK_SECRET"]
# If not in environment variables, try to get from credentials
if Rails.application.respond_to?(:credentials) && Rails.application.credentials.respond_to?(:stripe)
stripe_credentials = Rails.application.credentials.stripe
if stripe_credentials
stripe_publishable_key ||= stripe_credentials[:publishable_key] if stripe_credentials.key?(:publishable_key)
stripe_secret_key ||= stripe_credentials[:secret_key] if stripe_credentials.key?(:secret_key)
stripe_webhook_secret ||= stripe_credentials[:webhook_secret] if stripe_credentials.key?(:webhook_secret)
end
end
config.stripe = {
publishable_key: stripe_publishable_key,
secret_key: stripe_secret_key,
signing_secret: stripe_webhook_secret
}
end
# Only set the API key if it exists
if Rails.application.config.stripe[:secret_key].present?
Stripe.api_key = Rails.application.config.stripe[:secret_key]
end

View File

@@ -20,6 +20,12 @@ Rails.application.routes.draw do
get "events/:slug.:id", to: "events#show", as: "event" get "events/:slug.:id", to: "events#show", as: "event"
post "events/:slug.:id/checkout", to: "events#checkout", as: "event_checkout" post "events/:slug.:id/checkout", to: "events#checkout", as: "event_checkout"
# Payment success
get "payments/success", to: "events#payment_success", as: "payment_success"
# Tickets
get "tickets/:ticket_id/download", to: "events#download_ticket", as: "download_ticket"
# Routes for devise authentication Gem # Routes for devise authentication Gem
# Bind devise to user # Bind devise to user
# devise_for :users # devise_for :users

8
db/schema.rb generated
View File

@@ -11,7 +11,7 @@
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "events", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "slug", null: false t.string "slug", null: false
t.string "image" t.string "image"
@@ -33,7 +33,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
t.index ["user_id"], name: "index_events_on_user_id" t.index ["user_id"], name: "index_events_on_user_id"
end end
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "ticket_types", force: :cascade do |t|
t.string "name" t.string "name"
t.text "description" t.text "description"
t.integer "price_cents" t.integer "price_cents"
@@ -50,7 +50,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
t.index ["sale_start_at"], name: "index_ticket_types_on_sale_start_at" t.index ["sale_start_at"], name: "index_ticket_types_on_sale_start_at"
end end
create_table "tickets", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "tickets", force: :cascade do |t|
t.string "qr_code" t.string "qr_code"
t.integer "price_cents" t.integer "price_cents"
t.string "status", default: "active" t.string "status", default: "active"
@@ -63,7 +63,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
t.index ["user_id"], name: "index_tickets_on_user_id" t.index ["user_id"], name: "index_tickets_on_user_id"
end end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false t.string "encrypted_password", default: "", null: false
t.string "reset_password_token" t.string "reset_password_token"