13 Commits

Author SHA1 Message Date
kbe
d7d7349a9b fix: Update Event model to handle image URLs and fix image display
- Relax image URL validation in development environment
- Add callback to store image_url in legacy image field for compatibility
- Fix event_image_variant method to handle virtual image_url attribute
- Simplify event show view to use unified image display method
- Fix seeds slug reference for "La belle époque" event

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 10:10:54 +02:00
kbe
4ca8d73c8e feat: Add comprehensive test coverage for Event model
- Add tests for SEO-friendly slug generation with name, venue, and city
- Add tests for slug uniqueness and fallback behavior
- Add tests for image URL validation and handling
- Add tests for image detection methods (has_image?, display_image)
- Fix duplicate has_image? method in Event model
- Remove duplicate test method to resolve test failures
- All 50 tests now passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:48:03 +02:00
kbe
78b675b41d chore: Remove puts in orders_controller_promotion_test.rb 2025-10-01 08:39:51 +02:00
kbe
d914ae5c4a Merge branch 'fix/image-upload' into feat/image-upload 2025-10-01 08:37:57 +02:00
kbe
da3522d118 feat: Implement SEO-friendly slug generation and improve geocoding UX
- Add Rails parameterize for server-side slug generation (name-venue-city format)
- Configure client-side slug library with RFC3986 mode for consistency
- Remove slug field from edit forms to prevent URL changes after publication
- Enable image_processing gem for Active Storage variants
- Make geocoding notifications visible indefinitely on promoter event forms
- Add server-side slug generation fallback with uniqueness validation
- Update promoter controller to allow slug only for new events

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:33:06 +02:00
kbe
20dcee0a5b fix: Update views and controllers for event image display
Update all event-related view templates and controllers to properly handle and display event images throughout the application.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 08:12:47 +02:00
kbe
ef3f05661e feat: Complete hybrid image upload system with URL compatibility
- Add hybrid image system supporting both file uploads and URL images
- Implement Active Storage for file uploads while preserving existing URL functionality
- Update Event model with both has_one_attached :image and image_url virtual attribute
- Create tabbed interface in event forms for upload/URL selection
- Add JavaScript preview functionality for both upload and URL inputs
- Fix promotion code validation issue in tests using distinct() to prevent duplicates
- Update all views to use hybrid display methods prioritizing uploads over URLs
- Update seeds file to use image_url attribute for compatibility
- Ensure backward compatibility with existing events using URL images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 01:06:12 +02:00
kbe
d85996a1bb chore(api/events_controller): Move helper to the end of file
I moved this helper to the end of file to permit
a better understanability of the controller. Display
order matches execution order.
2025-09-30 00:45:15 +02:00
kbe
6be8b95ed3 feat: Implement event image upload system for promoters
- Add Active Storage migrations for file attachments
- Update Event model to handle image uploads with validation
- Replace image URL fields with file upload in forms
- Add client-side image preview with validation
- Update all views to display uploaded images properly
- Fix JSON serialization to prevent stack overflow in API
- Add custom image validation methods for format and size
- Include image processing variants for different display sizes
- Fix promotion code test infrastructure and Stripe configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 00:41:03 +02:00
be7b3d5c18 Merge pull request 'fix(promotion code): Cap the minimum invoice for Stripe' (#6) from feat/promotion-code into develop
Reviewed-on: #6
2025-09-29 22:02:53 +00:00
kbe
66fffa8676 fix(promotion code): Cap the minimum invoice for Stripe
Stripe does not support negative invoices, so to
allow correct invoice generation, we apply dismiss
negative invoices.
2025-09-29 23:55:21 +02:00
aacc9398d0 Merge pull request 'feat/promotion-code' (#5) from feat/promotion-code into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m40s
Reviewed-on: #5
2025-09-29 18:41:07 +00:00
kbe
635644b55a feat(promotion-code): Complete promotion code integration and testing
- Add comprehensive promotion code methods to Order model
- Implement Stripe invoice integration for promotion code discounts
- Display promotion codes on invoice with proper discount breakdown
- Fix and enhance all unit tests for promotion code functionality
- Add discount calculation with capping to prevent negative totals
- Ensure promotion codes work across entire order lifecycle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 20:34:49 +02:00
33 changed files with 2048 additions and 188 deletions

378
AGENTS.md
View File

@@ -10,38 +10,64 @@ This document provides technical details for AI agents working on the Aperonight
#### 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
- **Professional Users**: `is_professionnal` field for event promoters with enhanced permissions
- **Onboarding System**: Multi-step onboarding process with `onboarding_completed` tracking
- **Stripe Integration**: `stripe_customer_id` for accounting and invoice management
- **Relationships**: Users can create events, purchase tickets, and manage promotion codes
- **Validations**: Email format, password strength, optional name fields, company information
#### 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
- **Relationships**: Belongs to user, has many ticket types, tickets through ticket types, and orders
- **Scopes**: Featured events, published events, upcoming events with proper ordering
- **Duplication**: Event duplication functionality for similar events
#### 3. Ticket Management
#### 3. Order Management (`app/models/order.rb`)
- **Order States**: `draft`, `pending_payment`, `paid`, `completed`, `cancelled`, `expired`
- **Payment Processing**: Stripe integration with payment attempt tracking
- **Platform Fees**: €0.50 fixed + 1.5% per ticket automatic calculation
- **Expiration**: 15-minute draft order expiration with automatic cleanup
- **Promotion Integration**: Support for discount code application
- **Invoice Generation**: Automatic Stripe invoice creation for accounting
#### 4. Promotion Code System (`app/models/promotion_code.rb`)
- **Discount Management**: Fixed amount discounts (stored in cents, displayed in euros)
- **Usage Controls**: Per-event and per-user association with usage limits
- **Expiration**: Date-based expiration with active/inactive status management
- **Validation**: Real-time validation during checkout process
- **Tracking**: Complete usage tracking and analytics
#### 5. 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
- **Order Association**: Tickets now belong to orders for better transaction management
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
#### 6. Payment Processing (`app/controllers/orders_controller.rb`)
- **Order-Based Workflow**: Complete shift from direct ticket purchase to order-based system
- **Stripe Integration**: Complete checkout session creation and payment confirmation
- **Session Management**: Proper handling of payment success/failure with ticket generation
- **Session Management**: Proper handling of payment success/failure with order and ticket generation
- **Security**: Authentication required, cart validation, availability checking
- **Invoice Service**: Post-payment invoice generation with StripeInvoiceService
### Database Schema Key Points
```sql
-- Users table (managed by Devise)
-- Users table (enhanced with professional features)
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),
is_professionnal boolean DEFAULT false,
onboarding_completed boolean DEFAULT false,
stripe_customer_id varchar(255),
company_name varchar(255),
-- Devise fields: confirmation, reset tokens, etc.
);
-- Events table
-- Events table (enhanced with order management)
CREATE TABLE events (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
@@ -59,6 +85,40 @@ CREATE TABLE events (
image varchar(500)
);
-- Order management system (new core table)
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id),
status varchar(255) DEFAULT 'draft',
total_amount_cents integer DEFAULT 0,
platform_fee_cents integer DEFAULT 0,
payment_attempts integer DEFAULT 0,
expires_at timestamp,
last_payment_attempt_at timestamp,
stripe_checkout_session_id varchar(255),
stripe_invoice_id varchar(255)
);
-- Promotion codes table (new discount system)
CREATE TABLE promotion_codes (
id bigint PRIMARY KEY,
code varchar(255) UNIQUE NOT NULL,
discount_amount_cents integer DEFAULT 0,
expires_at datetime,
active boolean DEFAULT true,
usage_limit integer,
uses_count integer DEFAULT 0,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id)
);
-- Order-promotion code join table
CREATE TABLE order_promotion_codes (
order_id bigint REFERENCES orders(id),
promotion_code_id bigint REFERENCES promotion_codes(id)
);
-- Ticket types define pricing and availability
CREATE TABLE ticket_types (
id bigint PRIMARY KEY,
@@ -73,10 +133,11 @@ CREATE TABLE ticket_types (
minimum_age integer
);
-- Individual tickets with QR codes
-- Individual tickets with QR codes (enhanced with order association)
CREATE TABLE tickets (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
order_id bigint REFERENCES orders(id),
ticket_type_id bigint REFERENCES ticket_types(id),
qr_code varchar(255) UNIQUE NOT NULL,
price_cents integer NOT NULL,
@@ -107,38 +168,113 @@ CREATE TABLE tickets (
.limit(5)
```
### 2. Stripe Payment Flow
### 2. Order Management Flow (`app/controllers/orders_controller.rb`)
#### 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
#### Order Creation and Payment
1. **Cart-to-Order Conversion**: Convert shopping cart to draft order with 15-minute expiration
2. **Platform Fee Calculation**: Automatic calculation of €0.50 fixed + 1.5% per ticket
3. **Promotion Code Application**: Real-time discount validation and application
4. **Stripe Checkout Session**: Create payment session with order metadata
5. **Payment Retry**: Support for multiple payment attempts with proper tracking
```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
}
})
# Order creation with platform fees
def create
@order = Order.new(order_params)
@order.user = current_user
@order.calculate_platform_fee
@order.set_expiration
if @order.save
session = create_stripe_checkout_session(@order)
redirect_to session.url, allow_other_host: true
else
render :new, status: :unprocessable_entity
end
end
# Platform fee calculation
def calculate_platform_fee
ticket_count = order_items.sum(:quantity)
self.platform_fee_cents = 50 + (total_amount_cents * 0.015).to_i
end
```
#### 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
#### Payment Confirmation and Invoice Generation
1. **Order Status Update**: Transition from pending_payment to paid
2. **Ticket Generation**: Create tickets associated with the order
3. **Stripe Invoice Creation**: Async invoice generation for accounting
4. **Promotion Code Usage**: Increment usage counters for applied codes
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
### 3. Enhanced Stripe Integration
#### StripeInvoiceService (`app/services/stripe_invoice_service.rb`)
- Post-payment invoice creation with customer management
- Line item processing with promotion discounts
- PDF invoice URL generation for download
- Accounting record synchronization
```ruby
class StripeInvoiceService
def initialize(order)
@order = order
end
def create_invoice
customer = find_or_create_stripe_customer
invoice_items = create_invoice_items(customer)
invoice = Stripe::Invoice.create({
customer: customer.id,
auto_advance: true,
collection_method: 'charge_automatically'
})
@order.update(stripe_invoice_id: invoice.id)
invoice.finalize_invoice
end
end
```
### 4. Promotion Code System (`app/models/promotion_code.rb`)
#### Code Validation and Application
- **Real-time Validation**: Check code validity, expiration, and usage limits
- **Discount Calculation**: Apply fixed amount discounts to order totals
- **Usage Tracking**: Increment usage counters and prevent overuse
- **Event-Specific Codes**: Support for both global and event-specific codes
```ruby
def valid_for_use?(user = nil, event = nil)
return false unless active?
return false if expired?
return false if usage_limit_reached?
return false if user.present? && !valid_for_user?(user)
return false if event.present? && !valid_for_event?(event)
true
end
def apply_discount(total_amount)
[total_amount - discount_amount_cents, 0].max
end
```
### 5. Background Job Architecture
#### StripeInvoiceGenerationJob
- Async invoice creation after successful payment
- Retry logic with exponential backoff
- Error handling and logging
#### ExpiredOrdersCleanupJob
- Automatic cleanup of expired draft orders
- Database maintenance and hygiene
#### EventReminderJob & EventReminderSchedulerJob
- Automated event reminder emails
- Scheduled notifications for upcoming events
### 6. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
```ruby
class TicketPdfGenerator
@@ -148,10 +284,10 @@ class TicketPdfGenerator
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,
@@ -159,7 +295,7 @@ class TicketPdfGenerator
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
@@ -167,12 +303,20 @@ class TicketPdfGenerator
end
```
### 4. Frontend Cart Management (`app/javascript/controllers/ticket_cart_controller.js`)
### 7. Frontend Architecture
- **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
#### Enhanced Stimulus Controllers
- **ticket_selection_controller.js**: Advanced cart management with real-time updates
- **event_form_controller.js**: Dynamic event creation with location services
- **countdown_controller.js**: Order expiration countdown timers
- **event_duplication_controller.js**: Event copying functionality
- **qr_code_controller.js**: QR code display and scanning
#### Order-Based Cart Management
- **Session Storage**: Preserves cart state during authentication flows
- **Real-time Updates**: Dynamic total calculation with promotion codes
- **Validation**: Client-side validation with server-side verification
- **Payment Flow**: Seamless integration with Stripe checkout
## 🔧 Development Patterns
@@ -180,11 +324,21 @@ end
```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
validates :latitude, numericality: {
greater_than_or_equal_to: -90,
less_than_or_equal_to: 90
}
# Order validations with state management
validates :status, presence: true, inclusion: { in: %w[draft pending_payment paid completed cancelled expired] }
validate :order_not_expired, on: :create
before_validation :set_expiration, on: :create
# Promotion code validations
validates :code, presence: true, uniqueness: true
validates :discount_amount_cents, numericality: { greater_than_or_equal_to: 0 }
validate :expiration_date_cannot_be_in_the_past
# Ticket QR code generation
before_validation :generate_qr_code, on: :create
def generate_qr_code
@@ -200,11 +354,47 @@ end
# Authentication for sensitive actions
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
# Strong parameters
# Professional user authorization
before_action :authenticate_professional!, only: [:create_promotion_code]
# Strong parameters with nested attributes
private
def event_params
params.require(:event).permit(:name, :description, :venue_name, :venue_address,
:latitude, :longitude, :start_time, :image)
def order_params
params.require(:order).permit(:promotion_code, order_items_attributes: [:ticket_type_id, :quantity])
end
# Platform fee calculation
def calculate_platform_fee
ticket_count = order_items.sum(:quantity)
self.platform_fee_cents = 50 + (total_amount_cents * 0.015).to_i
end
```
### Service Layer Patterns
```ruby
# Service for complex business logic
class StripeInvoiceService
def initialize(order)
@order = order
end
def call
customer = find_or_create_stripe_customer
create_invoice_items(customer)
generate_invoice
end
private
def find_or_create_stripe_customer
if @order.user.stripe_customer_id.present?
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
else
customer = Stripe::Customer.create(email: @order.user.email)
@order.user.update(stripe_customer_id: customer.id)
customer
end
end
end
```
@@ -212,6 +402,7 @@ end
- **Metric Cards**: Reusable component for dashboard statistics
- **Event Items**: Consistent event display across pages
- **Flash Messages**: Centralized notification system
- **Order Components**: Reusable order display and management components
## 🚀 Deployment Considerations
@@ -223,14 +414,28 @@ STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DATABASE_URL=mysql2://user:pass@host/db
RAILS_MASTER_KEY=...
# Rails 8 Solid Stack
SOLID_QUEUE_IN_PUMA=true
SOLID_CACHE_URL=redis://localhost:6379/0
SOLID_CABLE_URL=redis://localhost:6379/1
# Application Configuration
PLATFORM_FEE_FIXED_CENTS=50
PLATFORM_FEE_PERCENTAGE=1.5
ORDER_EXPIRATION_MINUTES=15
```
### Database Indexes
```sql
-- Performance indexes for common queries
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
CREATE INDEX idx_orders_expires_at ON orders (expires_at) WHERE status = 'draft';
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
CREATE INDEX idx_promotion_codes_code ON promotion_codes (code);
CREATE INDEX idx_promotion_codes_active_expires ON promotion_codes (active, expires_at);
```
### Security Considerations
@@ -238,22 +443,68 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
- **Strong Parameters**: All user inputs filtered
- **Authentication**: Devise handles session security
- **Payment Security**: Stripe handles sensitive payment data
- **Professional User Authorization**: Role-based access control for event promoters
- **Order Expiration**: Automatic cleanup of abandoned orders
- **Promotion Code Validation**: Server-side validation with usage limits
### Background Jobs
```ruby
# Async invoice generation
StripeInvoiceGenerationJob.perform_later(order_id)
# Cleanup expired orders
ExpiredOrdersCleanupJob.perform_later
# Event reminders
EventReminderSchedulerJob.set(wait_until: event.start_time - 2.hours).perform_later(event_id)
```
## 🌐 API Layer
### RESTful Endpoints
```ruby
# API Namespacing for external integrations
namespace :api do
namespace :v1 do
resources :events, only: [:index, :show] do
resources :ticket_types, only: [:index]
end
resources :carts, only: [:create, :show, :update]
resources :orders, only: [:create, :show, :update]
post '/promotion_codes/validate', to: 'promotion_codes#validate'
end
end
```
### API Authentication
- **Token-based authentication**: API tokens for external integrations
- **Rate limiting**: Request throttling for API endpoints
- **Versioning**: Versioned API namespace for backward compatibility
## 🧪 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
2. **Professional User Onboarding**: Multi-step onboarding process
3. **Event Creation**: Validation, state management, relationships
4. **Order Management**: Cart-to-order conversion, payment processing, expiration
5. **Promotion Code System**: Code validation, discount application, usage tracking
6. **PDF Generation**: QR code uniqueness, ticket format
7. **Stripe Integration**: Payment processing, invoice generation
8. **Background Jobs**: Async processing, error handling, retry logic
9. **API Endpoints**: RESTful API functionality and authentication
10. **Dashboard Metrics**: Query accuracy, performance
### Seed Data Structure
```ruby
# Creates test users, events, and ticket types
# Creates comprehensive test data
users = User.create!([...])
events = Event.create!([...])
ticket_types = TicketType.create!([...])
promotion_codes = PromotionCode.create!([...])
orders = Order.create!([...])
```
## 🛠️ Available Development Tools
@@ -280,6 +531,9 @@ ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
# Mass rename across multiple files
ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all
# Find all order-related validations
ast-grep --pattern 'validates :status, inclusion: { in: \%w[...] }' --lang ruby
```
#### Best Practices:
@@ -288,13 +542,25 @@ ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS
- Test changes in a branch before applying to main codebase
- Particularly useful for Rails conventions and ActiveRecord pattern updates
### Modern Rails 8 Stack
- **Solid Queue**: Background job processing
- **Solid Cache**: Fast caching layer
- **Solid Cable**: Action Cable over Redis
- **Propshaft**: Asset pipeline
- **Kamal**: Deployment tooling
- **Thruster**: Performance optimization
## 📝 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
- **Service Layer**: Complex business logic in service objects
- **Background Jobs**: Async processing for long-running tasks
- **API Design**: RESTful principles with versioning
- **Documentation**: Inline comments for complex business logic
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
- **Testing**: Comprehensive test coverage for all business logic
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience, featuring modern Rails 8 capabilities and a comprehensive order management system.

View File

@@ -9,14 +9,11 @@
### Medium Priority
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
- [ ] feat: Refund management system
- [ ] feat: Real-time sales analytics dashboard
- [ ] feat: Guest checkout without account creation
- [ ] feat: Seat selection with interactive venue maps
- [ ] feat: Dynamic pricing based on demand
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
- [ ] feat: Payout system for promoters (automated/manual payment processing)
- [ ] feat: Platform commission tracking and fee structure display
- [ ] feat: Tax reporting and revenue export for promoters
@@ -53,7 +50,6 @@
## 🚧 Doing
- [ ] feat: Promotion code on ticket
- [ ] feat: Page to display all tickets for an event
- [ ] feat: Add a link into notification email to order page that display all tickets
@@ -65,7 +61,11 @@
- [x] Add login functionality
- [x] refactor: Moving checkout to OrdersController
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
- [x] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
- [x] feat: Digital tickets with QR codes
- [x] feat: Ticket inventory management and capacity limits
- [x] feat: Event discovery with search and filtering
- [x] feat: Multiple ticket types (early bird, VIP, general admission)
- [x] feat: Email notifications (purchase confirmations, event reminders)
- [x] feat: Promotion code on ticket
- [x] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events

View File

@@ -40,7 +40,7 @@ gem "kamal", require: false
gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"
gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem

View File

@@ -122,6 +122,13 @@ GEM
erubi (1.13.1)
et-orbi (1.3.0)
tzinfo
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -129,6 +136,9 @@ GEM
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
@@ -177,6 +187,8 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.25.5)
minitest-reporters (1.7.1)
@@ -333,6 +345,9 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.0.2)
securerandom (0.4.1)
@@ -429,6 +444,7 @@ DEPENDENCIES
debug
devise (~> 4.9)
dotenv-rails
image_processing (~> 1.2)
jbuilder
jsbundling-rails
kamal

View File

@@ -14,14 +14,14 @@ module Api
# Retrieves all events sorted by creation date (most recent first)
def index
@events = Event.all.order(created_at: :desc)
render json: @events, status: :ok
render json: @events.map { |e| event_json(e) }, status: :ok
end
# GET /api/v1/events/:id
# Retrieves a single event by its ID
# Returns 404 if the event is not found
def show
render json: @event, status: :ok
render json: event_json(@event), status: :ok
end
# POST /api/v1/events
@@ -31,7 +31,7 @@ module Api
def create
@event = Event.new(event_params)
if @event.save
render json: @event, status: :created
render json: event_json(@event), status: :created
else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end
@@ -43,7 +43,7 @@ module Api
# Returns 422 Unprocessable Entity with error messages on failure
def update
if @event.update(event_params)
render json: @event, status: :ok
render json: event_json(@event), status: :ok
else
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
end
@@ -73,6 +73,33 @@ module Api
private
# Helper method to serialize event data safely
def event_json(event)
{
id: event.id,
name: event.name,
slug: event.slug,
description: event.description,
state: event.state,
venue_name: event.venue_name,
venue_address: event.venue_address,
start_time: event.start_time,
end_time: event.end_time,
latitude: event.latitude,
longitude: event.longitude,
featured: event.featured,
image_url: event.display_image_url,
created_at: event.created_at,
updated_at: event.updated_at,
user: {
id: event.user.id,
email: event.user.email,
first_name: event.user.first_name,
last_name: event.user.last_name
}
}
end
# Finds an event by its ID or returns 404 Not Found
# Used as before_action for the show, update, and destroy actions
def set_event
@@ -99,6 +126,32 @@ module Api
:user_id
)
end
# Helper method to serialize event data safely
def event_json(event)
{
id: event.id,
name: event.name,
slug: event.slug,
description: event.description,
state: event.state,
venue_name: event.venue_name,
venue_address: event.venue_address,
start_time: event.start_time,
end_time: event.end_time,
latitude: event.latitude,
longitude: event.longitude,
featured: event.featured,
created_at: event.created_at,
updated_at: event.updated_at,
user: {
id: event.user.id,
email: event.user.email, # May be remove public email ?
first_name: event.user.first_name, # May be remove public name ?
last_name: event.user.last_name # May be remove public name ?
}
}
end
end
end
end

View File

@@ -130,11 +130,16 @@ class OrdersController < ApplicationController
if params[:promotion_code].present?
promotion_code = PromotionCode.valid.find_by(code: params[:promotion_code].upcase)
if promotion_code
# Apply the promotion code to the order
@order.promotion_codes << promotion_code
@order.calculate_total!
@total_amount = @order.total_amount_cents
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
# Check if promotion code is already applied to this order
if @order.promotion_codes.include?(promotion_code)
flash.now[:alert] = "Ce code promotionnel est déjà appliqué à cette commande"
else
# Apply the promotion code to the order
@order.promotion_codes << promotion_code
@order.calculate_total!
@total_amount = @order.total_amount_cents
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
end
else
flash.now[:alert] = "Code promotionnel invalide"
end
@@ -159,6 +164,8 @@ class OrdersController < ApplicationController
flash[:alert] = "Erreur lors de la création de la session de paiement"
end
end
render :checkout
end
# Increment payment attempt - called via AJAX when user clicks pay button
@@ -302,7 +309,14 @@ class OrdersController < ApplicationController
end
def create_stripe_session
# Calculate the discount amount per ticket to distribute the promotion evenly
total_tickets = @order.tickets.count
discount_per_ticket = @order.discount_amount_cents / total_tickets if total_tickets > 0
line_items = @order.tickets.map do |ticket|
# Apply discount proportionally to each ticket
discounted_price = [ticket.price_cents - discount_per_ticket.to_i, 0].max
{
price_data: {
currency: "eur",
@@ -310,7 +324,7 @@ class OrdersController < ApplicationController
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
description: ticket.ticket_type.description
},
unit_amount: ticket.price_cents
unit_amount: discounted_price
},
quantity: 1
}

View File

@@ -29,6 +29,8 @@ class Promoter::EventsController < ApplicationController
if @event.save
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
else
# If validation fails and an image was attached, purge it
@event.image.purge if @event.image.attached?
render :new, status: :unprocessable_entity
end
end
@@ -43,6 +45,8 @@ class Promoter::EventsController < ApplicationController
if @event.update(event_params)
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
else
# If validation fails and a new image was attached, purge it
@event.image.purge if @event.image.attached? && @event.changed.include?('image')
render :edit, status: :unprocessable_entity
end
end
@@ -130,10 +134,18 @@ class Promoter::EventsController < ApplicationController
end
def event_params
params.require(:event).permit(
:name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event
)
if action_name == 'create'
params.require(:event).permit(
:name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event
)
else
params.require(:event).permit(
:name, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured, :allow_booking_during_event
)
end
end
end

View File

@@ -9,7 +9,7 @@ class Promoter::PromotionCodesController < ApplicationController
@promotion_codes = @event.promotion_codes.includes(:user)
end
# GET /promoter/events/:event_id/promotion_codes/new
# Show form to create a new promotion code
def new

View File

@@ -1,8 +1,11 @@
import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
// Configure slug to match Rails parameterize behavior
slug.defaults.mode = 'rfc3986'
export default class extends Controller {
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer", "venueName"]
static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
}
@@ -27,15 +30,65 @@ export default class extends Controller {
}
}
// Generate slug from name
// Generate slug from name, venue name, and city for better SEO
generateSlug() {
const name = this.nameTarget.value
const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : ""
const address = this.hasAddressTarget ? this.addressTarget.value : ""
this.slugTarget.value = slug(name)
// Extract city from address
const city = this.extractCity(address)
// Build SEO-friendly slug: name-venue-city
let slugParts = []
if (name) slugParts.push(name)
if (venueName) slugParts.push(venueName)
if (city) slugParts.push(city)
let slugValue = slugParts.join('-')
// If no slug parts, generate a fallback slug
if (!slugValue) {
slugValue = `event-${Date.now()}`
}
// Generate slug with proper character handling (matches Rails parameterize)
this.slugTarget.value = slug(slugValue, { lower: true })
}
// Extract city from address
extractCity(address) {
if (!address) return ""
// Look for French postal code pattern (5 digits) + city
const match = address.match(/(\d{5})\s+([^,]+)/)
if (match) {
return match[2].trim()
}
// Fallback: extract last part after comma (assume it's city)
const parts = address.split(',')
if (parts.length > 1) {
return parts[parts.length - 1].trim()
}
// Another fallback: look for common French city indicators
const cityIndicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"]
for (const city of cityIndicators) {
if (address.toLowerCase().includes(city.toLowerCase())) {
return city
}
}
return ""
}
// Handle address changes with debounced geocoding
addressChanged() {
// Regenerate slug when address changes
this.generateSlug()
// Clear any existing timeout
if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout)
@@ -68,6 +121,11 @@ export default class extends Controller {
}, this.geocodeDelayValue)
}
// Handle venue name changes to regenerate slug
venueNameChanged() {
this.generateSlug()
}
// Get user's current location and reverse geocode to address
async getCurrentLocation() {
if (!navigator.geolocation) {
@@ -516,14 +574,16 @@ export default class extends Controller {
showLocationSuccess(message) {
this.hideAllLocationMessages()
this.showMessage("location-success", message, "success")
setTimeout(() => this.hideMessage("location-success"), 4000)
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-success"), 4000)
}
// Show error message
showLocationError(message) {
this.hideAllLocationMessages()
this.showMessage("location-error", message, "error")
setTimeout(() => this.hideMessage("location-error"), 6000)
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("location-error"), 6000)
}
// Show geocoding warning (less intrusive than error)
@@ -531,7 +591,8 @@ export default class extends Controller {
this.hideMessage("geocoding-warning")
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
this.showMessage("geocoding-warning", message, "warning")
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
}
// Show info about approximate location
@@ -539,7 +600,8 @@ export default class extends Controller {
this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info")
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
}
// Show geocoding success with location details
@@ -547,7 +609,8 @@ export default class extends Controller {
this.hideMessage("geocoding-success")
const message = `${title}<br><small class="opacity-75">${location}</small>`
this.showMessage("geocoding-success", message, "success")
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
// Keep notification visible indefinitely
// setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
// Show geocoding progress with strategy info
@@ -664,4 +727,87 @@ export default class extends Controller {
this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress")
}
// Preview selected image
previewImage(event) {
const file = event.target.files[0]
if (!file) return
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Veuillez sélectionner une image valide.')
event.target.value = ''
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
alert('L\'image ne doit pas dépasser 5MB.')
event.target.value = ''
return
}
// Show preview
const reader = new FileReader()
reader.onload = (e) => {
const previewContainer = document.getElementById('upload-preview')
const previewImg = document.getElementById('upload-preview-img')
if (previewContainer && previewImg) {
previewImg.src = e.target.result
previewContainer.classList.remove('hidden')
}
}
reader.readAsDataURL(file)
}
// Preview image from URL
previewImageUrl(event) {
const url = event.target.value.trim()
const previewContainer = document.getElementById('url-preview')
const previewImg = document.getElementById('url-preview-img')
if (!url) {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
return
}
// Basic URL validation
if (!this.isValidImageUrl(url)) {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
return
}
// Show preview with error handling
if (previewImg) {
previewImg.onload = () => {
if (previewContainer) {
previewContainer.classList.remove('hidden')
}
}
previewImg.onerror = () => {
if (previewContainer) {
previewContainer.classList.add('hidden')
}
}
previewImg.src = url
}
}
// Validate image URL format
isValidImageUrl(url) {
try {
new URL(url)
// Check if it looks like an image URL
return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url)
} catch {
return false
}
}
}

View File

@@ -22,9 +22,17 @@ class Event < ApplicationRecord
has_many :tickets, through: :ticket_types
has_many :orders
has_many :promotion_codes
has_one_attached :image
# === Virtual attribute for backward compatibility with image URLs ===
attr_accessor :image_url
# === Callbacks ===
before_validation :generate_slug, if: :should_generate_slug?
before_validation :geocode_address, if: :should_geocode_address?
before_validation :handle_image_url, if: :should_handle_image_url?
before_update :handle_image_replacement, if: :image_attached?
# Validations for Event attributes
# Basic information
@@ -32,7 +40,11 @@ class Event < ApplicationRecord
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
validates :state, presence: true, inclusion: { in: states.keys }
validates :image, length: { maximum: 500 } # URL or path to image
# Image validation - handles both attachments and URLs
validate :image_format, if: -> { image.attached? }
validate :image_size, if: -> { image.attached? }
validate :image_url_format, if: -> { image_url.present? && !image.attached? }
# Venue information
validates :venue_name, presence: true, length: { maximum: 100 }
@@ -58,6 +70,123 @@ class Event < ApplicationRecord
# === Instance Methods ===
# Generate SEO-friendly slug from name, venue name, and city
def generate_slug
return if name.blank? && venue_name.blank? && venue_address.blank?
# Extract city from venue address
city = extract_city_from_address(venue_address)
# Build slug parts
slug_parts = []
slug_parts << name if name.present?
slug_parts << venue_name if venue_name.present?
slug_parts << city if city.present?
# Generate slug using Rails' parameterize
slug_value = slug_parts.join("-").parameterize
# Ensure minimum length
if slug_value.length < 3
slug_value = "event-#{Time.current.to_i}".parameterize
end
# Make sure slug is unique
base_slug = slug_value
counter = 1
while Event.where.not(id: id).where(slug: slug_value).exists?
slug_value = "#{base_slug}-#{counter}".parameterize
counter += 1
end
self.slug = slug_value
end
# Check if slug should be generated
def should_generate_slug?
# Generate slug if it's blank or if it's a new record
slug.blank? || new_record?
end
# Extract city from address
def extract_city_from_address(address)
return "" if address.blank?
# Look for French postal code pattern (5 digits) + city
match = address.match(/(\d{5})\s+([^,]+)/)
if match
return match[2].strip
end
# Fallback: extract last part after comma (assume it's city)
parts = address.split(",")
if parts.length > 1
return parts[parts.length - 1].strip
end
# Another fallback: look for common French city indicators
city_indicators = [ "Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille" ]
for city in city_indicators
if address.downcase.include?(city.downcase)
return city
end
end
""
end
# Get image URL prioritizing old image field if it exists
def display_image_url
# First check if old image field exists and has a value
return self[:image] if self[:image].present?
# Fall back to attached image
return nil unless image.attached?
# Return the URL for the attached image
Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
end
# Get image variants for different display sizes
def event_image_variant(size = :medium)
# For old image field, return the URL directly
return self[:image] if self[:image].present?
# For virtual image_url attribute, return the URL directly
return image_url if image_url.present?
# For attached images, process variants
return nil unless image.attached?
case size
when :large
image.variant(resize_to_limit: [ 1200, 630 ])
when :medium
image.variant(resize_to_limit: [ 800, 450 ])
when :small
image.variant(resize_to_limit: [ 400, 225 ])
else
# Fallback to URL-based image
image_url.presence
end
end
# Check if event has any image (old field, attached, or URL)
def has_image?
self[:image].present? || image.attached? || image_url.present?
end
# Get display image source (uploaded or URL)
def display_image
if image.attached?
image
elsif image_url.present?
image_url
else
self[:image]
end
end
# Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful?
coordinates_look_valid?
@@ -131,8 +260,63 @@ class Event < ApplicationRecord
nil
end
# Validate image format
def image_format
return unless image.attached?
allowed_types = %w[image/jpeg image/jpg image/png image/webp]
unless allowed_types.include?(image.content_type)
errors.add(:image, "doit être au format JPG, PNG ou WebP")
end
end
# Validate image size
def image_size
return unless image.attached?
if image.byte_size > 5.megabytes
errors.add(:image, "doit faire moins de 5MB")
end
end
# Validate image URL format - relaxed for development
def image_url_format
return unless image_url.present?
return if Rails.env.development? # Skip validation in development
unless image_url.match?(/\Ahttps?:\/\/.+\.(jpg|jpeg|png|gif|webp)(\?.*)?\z/i)
errors.add(:image_url, "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)")
end
end
private
# Check if image is attached for the callback
def image_attached?
image.attached?
end
# Handle image replacement when a new image is uploaded
def handle_image_replacement
# Clear the old image field if a new image is being attached
if image.attached?
self[:image] = nil
end
end
# Determine if we should handle image_url
def should_handle_image_url?
image_url.present? && new_record?
end
# Handle image_url by storing it in the legacy image field
def handle_image_url
# Store the image_url in the legacy image field for backward compatibility
if image_url.present?
self[:image] = image_url
end
end
# Determine if we should perform server-side geocoding
def should_geocode_address?
# Don't geocode if address is blank

View File

@@ -21,6 +21,9 @@ class Order < ApplicationRecord
validates :payment_attempts, presence: true,
numericality: { greater_than_or_equal_to: 0 }
# Custom validation to prevent duplicate promotion codes
validate :no_duplicate_promotion_codes
# Stripe invoice ID for accounting records
attr_accessor :stripe_invoice_id
@@ -96,7 +99,7 @@ class Order < ApplicationRecord
discount_total = promotion_codes.sum(:discount_amount_cents)
# Ensure total doesn't go below zero
final_total = [ticket_total - discount_total, 0].max
final_total = [ ticket_total - discount_total, 0 ].max
update!(total_amount_cents: final_total)
end
@@ -110,9 +113,9 @@ class Order < ApplicationRecord
subtotal_amount_cents / 100.0
end
# Total discount amount from all promotion codes
# Total discount amount from all promotion codes (capped at subtotal)
def discount_amount_cents
promotion_codes.sum(:discount_amount_cents)
[ promotion_codes.sum(:discount_amount_cents), subtotal_amount_cents ].min
end
# Discount amount in euros
@@ -188,4 +191,18 @@ class Order < ApplicationRecord
def draft?
status == "draft"
end
# Prevent duplicate promotion codes on the same order
def no_duplicate_promotion_codes
return if promotion_codes.empty?
# Use distinct to avoid association loading issues
unique_codes = promotion_codes.distinct
code_counts = unique_codes.group_by(&:code).transform_values(&:count)
duplicates = code_counts.select { |_, count| count > 1 }
if duplicates.any?
errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
end
end
end

View File

@@ -166,6 +166,23 @@ class StripeInvoiceService
})
end
# Add promotion code discounts as negative line items
@order.promotion_codes.each do |promo_code|
Stripe::InvoiceItem.create({
customer: customer.id,
invoice: invoice.id,
amount: -promo_code.discount_amount_cents, # Negative amount for discount
currency: "eur",
description: "Réduction promotionnelle (Code: #{promo_code.code})",
metadata: {
promotion_code_id: promo_code.id,
promotion_code: promo_code.code,
discount_amount_cents: promo_code.discount_amount_cents,
type: "promotion_discount"
}
})
end
# No service fee on customer invoice; platform fee deducted from promoter payout
end

View File

@@ -1,7 +1,7 @@
<%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %>
<div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0">
<%= image_tag event.image, alt: event.name, class: "w-full h-full object-cover" if event.image.present? %>
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.has_image? %>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200">

View File

@@ -22,13 +22,13 @@
<% @events.each do |event| %>
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
<%= link_to event_path(event.slug, event), class: "block" do %>
<% if event.image.present? %>
<% if event.has_image? %>
<div class="relative overflow-hidden aspect-[4/3]">
<img
src="<%= event.image %>"
alt="<%= event.name %>"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
>
<% if event.image.attached? %>
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
<% else %>
<%= image_tag event.image_url, alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
<% end %>
<!-- Event featured badge -->
<% if event.featured? %>
<div class="absolute top-4 left-4">

View File

@@ -10,9 +10,9 @@
<!-- Event main wrapper -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Event Header with Image -->
<% if @event.image.present? %>
<% if @event.has_image? %>
<div class="relative h-96">
<%= image_tag @event.image, class: "w-full h-full object-cover" %>
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover", alt: @event.name %>
<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 md:p-8">
<div class="max-w-4xl mx-auto">
@@ -88,12 +88,8 @@
%>
<% map_providers.each do |name, url| %>
<%= link_to url, target: "_blank", rel: "noopener",
class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" do %>
<span class="mr-1"><%= icons[name] %></span>
<%= name %>
<%= link_to "#{icons[name]} #{name}".html_safe, url, target: "_blank", rel: "noopener", class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" %>
<% end %>
<% end %>
</div>
</div>
<% end %>
@@ -131,14 +127,7 @@
<!-- Right Column: Ticket Selection -->
<div class="lg:col-span-1">
<%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: {
controller: "ticket-selection",
ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug,
ticket_selection_event_id_value: @event.id,
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
ticket_selection_store_cart_url_value: api_v1_store_cart_path
} do |form| %>
<%= form_with url: event_order_new_path(@event.slug, @event.id), method: :get, id: "checkout_form", local: true, data: { controller: "ticket-selection", ticket_selection_target: "form", ticket_selection_event_slug_value: @event.slug, ticket_selection_event_id_value: @event.id, ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id), ticket_selection_store_cart_url_value: api_v1_store_cart_path } do |form| %>
<div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm">
<div class="flex justify-center sm:justify-start mb-6">

View File

@@ -121,13 +121,56 @@
<% end %>
</tbody>
<tfoot class="bg-gray-50">
<!-- Subtotal -->
<tr>
<th colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-900 uppercase tracking-wider">Total</th>
<th scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900"><%= "%.2f" % @order.total_amount_euros %>€</th>
<td colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-600">Sous-total</td>
<td scope="col" class="px-6 py-3 text-right text-sm font-medium text-gray-600"><%= "%.2f" % @order.subtotal_amount_euros %>€</td>
</tr>
<!-- Promotion Code Discounts -->
<% if @order.promotion_codes.any? %>
<% @order.promotion_codes.each do |promo_code| %>
<tr>
<td colspan="3" scope="col" class="px-6 py-3 text-right text-sm font-medium text-green-600">
Réduction (Code: <%= promo_code.code %>)
</td>
<td scope="col" class="px-6 py-3 text-right text-sm font-semibold text-green-600">-<%= "%.2f" % promo_code.discount_amount_euros %>€</td>
</tr>
<% end %>
<% end %>
<!-- Total -->
<tr class="border-t-2 border-gray-300">
<td colspan="3" scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900 uppercase tracking-wider">Total</td>
<td scope="col" class="px-6 py-3 text-right text-lg font-bold text-gray-900">
<% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= "%.2f" % @order.total_amount_euros %>€
<% end %>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Promotion Code Summary -->
<% if @order.promotion_codes.any? %>
<div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 class="text-sm font-semibold text-green-900 mb-2 flex items-center">
<i data-lucide="tag" class="w-4 h-4 mr-2"></i>
Codes promotionnels appliqués
</h4>
<div class="text-xs text-green-700">
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between">
<span><%= promo_code.code %></span>
<span class="font-semibold">-<%= "%.2f" % promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Payment Information -->

View File

@@ -89,10 +89,8 @@
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
<!-- Event Image -->
<div class="relative overflow-hidden aspect-[4/3]">
<% if event.image.present? %>
<img src="<%= event.image %>"
alt="<%= event.name %>"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
<% if event.has_image? %>
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
<% else %>
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>

View File

@@ -41,24 +41,9 @@
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
</div>
<div>
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
<p class="mt-1 text-sm text-gray-500">
<% if @event.published? %>
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-yellow-500"></i>
Attention: Modifier le slug d'un événement publié peut casser les liens existants.
<% else %>
Utilisé dans l'URL de l'événement
<% end %>
</p>
</div>
<div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture" %>
</div>
<div class="mt-6">
@@ -67,9 +52,127 @@
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<!-- Current image preview -->
<<<<<<< HEAD
<% if @event.image.attached? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
=======
<% if @event.has_image? %>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<% if @event.event_image_variant(:small).is_a?(String) %>
<!-- Old image field -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% else %>
<!-- Attached image -->
<%= image_tag @event.event_image_variant(:small), class: "w-32 h-24 object-cover rounded-lg border border-gray-200" %>
<% end %>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 mb-1">Image actuelle</p>
<p class="text-sm text-gray-600 mb-2">Uploader une nouvelle image pour la remplacer.</p>
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-lg hover:bg-red-600 transition-colors inline-flex items-center">
<i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
<span>Remplacer l'image</span>
>>>>>>> fix/image-upload
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
Image actuelle
</div>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
<div class="mt-1 text-sm text-gray-500">
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
<% if @event.has_image? %>
<br>Laissez vide pour conserver l'image actuelle
<% end %>
</div>
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle image
</div>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current URL image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
<div class="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
URL actuelle
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", value: @event.image_url, data: { action: "input->event-form#previewImageUrl" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
<% if @event.image_url.present? %>
<br>Laissez vide pour conserver l'URL actuelle
<% end %>
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
<div class="absolute bottom-2 left-2 bg-purple-600 text-white px-2 py-1 rounded text-xs">
Nouvelle URL
</div>
</div>
</div>
</div>
</div>
</div>
@@ -203,4 +306,27 @@
</div>
<% end %>
</div>
</div>
</div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>

View File

@@ -41,17 +41,14 @@
<div class="bg-white rounded-lg border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-6">
<div>
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
</div>
<div>
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
<p class="mt-1 text-sm text-gray-500">Utilisé dans l'URL de l'événement</p>
</div>
<!-- Hidden slug field (auto-generated) -->
<%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
</div>
<div class="mt-6">
@@ -60,9 +57,89 @@
</div>
<div class="mt-6">
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
<!-- Image type selection tabs -->
<div class="mb-4">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button type="button" onclick="switchImageTab('upload')" id="upload-tab" class="tab-button active border-purple-500 text-purple-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="upload" class="w-4 h-4 inline mr-2"></i>
Télécharger un fichier
</button>
<button type="button" onclick="switchImageTab('url')" id="url-tab" class="tab-button border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<i data-lucide="link" class="w-4 h-4 inline mr-2"></i>
Utiliser une URL
</button>
</nav>
</div>
</div>
<!-- Upload tab content -->
<div id="upload-content" class="tab-content space-y-4">
<!-- Current image preview (for edit mode) -->
<% if @event.has_image? %>
<div class="relative">
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>
<!-- File upload field -->
<div class="relative">
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
<div class="mt-1 text-sm text-gray-500">
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
</div>
</div>
<!-- Image preview container -->
<div id="upload-preview" class="hidden">
<div class="relative">
<img id="upload-preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('upload-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- URL tab content -->
<div id="url-content" class="tab-content space-y-4 hidden">
<!-- Current URL image preview -->
<% if @event.image_url.present? && !@event.image.attached? %>
<div class="relative">
<%= image_tag @event.image_url, class: "w-full h-48 object-cover rounded-lg border border-gray-200", alt: "Current image" %>
<div class="absolute top-2 right-2">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>
<!-- URL input field -->
<div class="relative">
<%= form.text_field :image_url, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg", data: { action: "input->event-form#previewImageUrl" } %>
<div class="mt-1 text-sm text-gray-500">
Entrez l'URL d'une image (JPG, PNG, GIF, WebP)
</div>
</div>
<!-- URL preview container -->
<div id="url-preview" class="hidden">
<div class="relative">
<img id="url-preview-img" src="" alt="URL Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
<button type="button" onclick="document.getElementById('event_image_url').value = ''; document.getElementById('url-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -93,7 +170,7 @@
<div class="space-y-6">
<div>
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex", data: { "event-form-target": "venueName", action: "input->event-form#venueNameChanged" } %>
</div>
<div>
@@ -163,3 +240,26 @@
<% end %>
</div>
</div>
<script>
function switchImageTab(tab) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Remove active class from all tabs
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-purple-500', 'text-purple-600');
button.classList.add('border-transparent', 'text-gray-500');
});
// Show selected tab content
document.getElementById(tab + '-content').classList.remove('hidden');
// Add active class to selected tab
const activeTab = document.getElementById(tab + '-tab');
activeTab.classList.add('active', 'border-purple-500', 'text-purple-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
</script>

View File

@@ -174,9 +174,13 @@
<!-- Main content -->
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
<!-- Event image -->
<% if @event.image.present? %>
<% if @event.has_image? %>
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
<img src="<%= @event.image %>" alt="<%= @event.name %>" class="w-full h-full object-cover">
<% if @event.image.attached? %>
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
<% else %>
<%= image_tag @event.image_url, alt: @event.name, class: "w-full h-full object-cover" %>
<% end %>
</div>
<% end %>

View File

@@ -50,4 +50,11 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Configure Stripe for testing
config.stripe = {
publishable_key: "pk_test_test",
secret_key: "sk_test_test",
signing_secret: "whsec_test_test"
}
end

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

View File

@@ -0,0 +1,4 @@
class AddImageToEvents < ActiveRecord::Migration[8.0]
def change
end
end

31
db/schema.rb generated
View File

@@ -10,7 +10,35 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
ActiveRecord::Schema[8.0].define(version: 2025_09_29_222616) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "slug", null: false
@@ -130,6 +158,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "order_promotion_codes", "orders"
add_foreign_key "order_promotion_codes", "promotion_codes"
add_foreign_key "promotion_codes", "events"

View File

@@ -44,7 +44,7 @@ events_data = [
start_time: 1.day.from_now,
end_time: 1.day.from_now + 6.hours,
featured: true,
image: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
image_url: "https://fastly.picsum.photos/id/407/300/200.jpg?hmac=9EhoXMZ1QdwJue90vzxcjBg2YzsZsAWCjJ7oxOhtcU0",
user: users.first
},
{
@@ -58,7 +58,7 @@ events_data = [
start_time: 3.days.from_now,
end_time: 3.days.from_now + 4.hours,
featured: true,
image: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image_url: "https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.second
},
{
@@ -72,7 +72,7 @@ events_data = [
start_time: 1.week.from_now,
end_time: 1.week.from_now + 8.hours,
featured: false,
image: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
image_url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
user: users.third
}
]
@@ -147,7 +147,7 @@ belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY
e.start_time = 3.days.from_now
e.end_time = 3.days.from_now + 8.hours
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.image_url = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.user = promoter
e.allow_booking_during_event = true
end
@@ -156,7 +156,7 @@ belle_epoque_event.update!(start_time: 3.days.from_now, end_time: 3.days.from_no
# Create ticket types for "La belle époque" event
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events")
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events-le-patio-rooftop-montreuil")
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Free invitation valid before 7 p.m.") do |tt|
tt.description = "Free invitation ticket valid before 7 p.m. for La Belle Époque"
@@ -201,7 +201,7 @@ konpa_event = Event.find_or_create_by!(name: "Konpa With Bev - Cours De Konpa Go
e.start_time = Time.parse("2025-10-03 19:00:00")
e.end_time = Time.parse("2025-10-03 23:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.image_url = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
@@ -216,7 +216,7 @@ caribbean_groove_event = Event.find_or_create_by!(name: "La Plus Grosse Soirée
e.start_time = Time.parse("2025-10-03 23:00:00")
e.end_time = Time.parse("2025-10-04 05:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.image_url = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
@@ -231,7 +231,7 @@ belle_epoque_october_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PA
e.start_time = Time.parse("2025-10-04 18:00:00")
e.end_time = Time.parse("2025-10-05 02:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.image_url = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.user = promoter
e.state = :published
e.allow_booking_during_event = true

74
debug_promotion_test.rb Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env ruby
# Debug script to understand the test failure
require_relative './config/environment'
# Load test data
user = User.find_by(email: 'user1@example.com')
event = Event.find_by(name: 'Summer Concert')
puts "User: #{user.inspect}"
puts "Event: #{event.inspect}"
# Create a new order for the test
order = user.orders.create!(event: event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
puts "Order: #{order.inspect}"
# Create ticket type and ticket
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: event
)
ticket = Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
puts "Ticket: #{ticket.inspect}"
puts "Ticket valid?: #{ticket.valid?}"
puts "Order tickets count: #{order.tickets.count}"
# Recalculate the order total
order.calculate_total!
puts "Order total: #{order.total_amount_cents}"
# Create a unique promotion code
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
puts "Creating promotion code with code: #{unique_code}"
promotion_code = PromotionCode.create(
code: unique_code,
discount_amount_cents: 500,
expires_at: 1.month.from_now,
active: true,
user: user,
event: event
)
puts "Promotion code: #{promotion_code.inspect}"
puts "Promotion code valid?: #{promotion_code.valid?}"
# Check if order already has promotion codes
puts "Order promotion codes before: #{order.promotion_codes.count}"
# Try to apply the promotion code
begin
order.promotion_codes << promotion_code
puts "Successfully added promotion code to order"
rescue => e
puts "Error adding promotion code: #{e.message}"
puts e.backtrace.first(5)
end
puts "Order promotion codes after: #{order.promotion_codes.count}"

67
debug_test.rb Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env ruby
# Debug script to understand the test failure
require_relative './config/environment'
# Load test data
user = User.find_by(email: 'user1@example.com')
event = Event.find_by(name: 'Summer Concert')
order = Order.find_by(status: 'draft')
puts "User: #{user.inspect}"
puts "Event: #{event.inspect}"
puts "Order: #{order.inspect}"
# Check if the user can manage events
puts "User can manage events: #{user.can_manage_events?}"
# Create a promotion code
promotion_code = PromotionCode.create(
code: "TESTDISCOUNT",
discount_amount_cents: 500,
expires_at: 1.month.from_now,
active: true,
user: user,
event: event
)
puts "Promotion code: #{promotion_code.inspect}"
puts "Promotion code valid?: #{promotion_code.valid_for_use?}"
# Try to create a ticket type and ticket
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: event
)
puts "Ticket type: #{ticket_type.inspect}"
# Create ticket with all required fields
ticket = Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
puts "Ticket: #{ticket.inspect}"
puts "Ticket valid?: #{ticket.valid?}"
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
# Recalculate order total
order.calculate_total!
puts "Order total: #{order.total_amount_cents}"
# Test the promotion code application
puts "Applying promotion code..."
order.promotion_codes << promotion_code
order.calculate_total!
puts "Order total after promotion: #{order.total_amount_cents}"

View File

@@ -1,4 +1,5 @@
require "test_helper"
require "securerandom"
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
@@ -6,32 +7,63 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
# Setup test data
def setup
@user = users(:one)
@event = events(:one)
@order = orders(:one)
@event = events(:concert_event)
# Create a new order for the test to ensure proper associations
@order = @user.orders.create!(event: @event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
sign_in @user
end
# Test applying a valid promotion code
def test_apply_valid_promotion_code
promotion_code = PromotionCode.create(
code: "TESTDISCOUNT",
discount_amount_cents: 1000, # €10.00
expires_at: 1.month.from_now,
active: true
# Create ticket type and tickets for the order
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
ticket = Ticket.create!(
order: @order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe",
price_cents: 2000
)
# Recalculate the order total
@order.calculate_total!
# Use a unique code for each test run
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
promotion_code = PromotionCode.create(
code: unique_code,
discount_amount_cents: 500, # €5.00
expires_at: 1.month.from_now,
active: true,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: unique_code }
puts "Response status: #{response.status}"
puts "Response body: #{response.body}" if response.status != 200
assert_response :success
assert_not_nil flash[:notice]
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash[:notice]
assert_not_nil flash.now[:notice]
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[:notice]
end
# Test applying an invalid promotion code
def test_apply_invalid_promotion_code
get checkout_order_path(@order), params: { promotion_code: "INVALIDCODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
# Test applying an expired promotion code
@@ -40,13 +72,15 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
code: "EXPIREDCODE",
discount_amount_cents: 1000,
expires_at: 1.day.ago,
active: true
active: true,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
# Test applying an inactive promotion code
@@ -55,12 +89,14 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
code: "INACTIVECODE",
discount_amount_cents: 1000,
expires_at: 1.month.from_now,
active: false
active: false,
user: @user,
event: @event
)
get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
assert_not_nil flash.now[:alert]
assert_equal "Code promotionnel invalide", flash.now[:alert]
end
end

View File

@@ -5,6 +5,7 @@ one:
encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %>
last_name: Trump
first_name: Donald
is_professionnal: true
onboarding_completed: true
two:

View File

@@ -317,4 +317,157 @@ class EventTest < ActiveSupport::TestCase
# Check that ticket types were NOT duplicated
assert_equal 0, duplicated_event.ticket_types.count
end
# Test slug generation functionality
test "should generate slug from name and venue" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Soirée d'ouverture",
description: "Valid description for the event that is long enough",
venue_name: "Test Venue",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0
)
event.save
assert_equal "soiree-d-ouverture-test-venue", event.slug
end
test "should generate slug from name, venue, and city" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Fête de la Musique",
venue_name: "Théâtre Principal",
venue_address: "15 Rue de la Paix, 75002 Paris",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
event.save
assert_equal "fete-de-la-musique-theatre-principal-paris", event.slug
end
test "should generate fallback slug when no data available" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
description: "Valid description for the event that is long enough",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0
)
event.save
assert_match /^event-\d+$/, event.slug
end
test "should ensure slug uniqueness" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
# Create first event
event1 = Event.create!(
name: "Test Event",
venue_name: "Venue",
venue_address: "123 Test Street",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
# Create second event with same details
event2 = Event.create!(
name: "Test Event",
venue_name: "Venue",
venue_address: "123 Test Street",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
assert_not_equal event1.slug, event2.slug
assert_match /^test-event-venue-1$/, event2.slug
end
test "should extract city from French postal code" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Concert",
venue_address: "5 Avenue des Champs-Élysées, 75008 Paris",
description: "Valid description for the event that is long enough",
user: user,
latitude: 48.0,
longitude: 2.0
)
event.save
assert event.slug.include?("paris")
end
# Test image URL functionality
test "should accept valid image URL" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Event with URL Image",
slug: "event-url-image",
description: "Valid description for the event that is long enough",
venue_name: "Venue",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0,
image_url: "https://example.com/image.jpg"
)
assert event.valid?
end
test "should reject invalid image URL" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Event with Invalid URL",
slug: "event-invalid-url",
description: "Valid description for the event that is long enough",
venue_name: "Venue",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0,
image_url: "not-a-valid-url"
)
assert_not event.valid?
assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
end
test "should reject URL with non-image extension" do
user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123")
event = Event.new(
name: "Event with Non-image URL",
slug: "event-non-image-url",
description: "Valid description for the event that is long enough",
venue_name: "Venue",
venue_address: "123 Test Street",
user: user,
latitude: 48.0,
longitude: 2.0,
image_url: "https://example.com/document.pdf"
)
assert_not event.valid?
assert_includes event.errors[:image_url], "doit être une URL valide vers une image (JPG, PNG, GIF, WebP)"
end
test "has_image? should return true for URL image" do
event = Event.new(image_url: "https://example.com/image.jpg")
assert event.has_image?
end
test "has_image? should return false without image" do
event = Event.new
assert_not event.has_image?
end
test "display_image should return image URL when no attached image" do
event = Event.new(image_url: "https://example.com/image.jpg")
assert_equal "https://example.com/image.jpg", event.display_image
end
end

View File

@@ -582,6 +582,243 @@ class OrderTest < ActiveSupport::TestCase
assert_equal 95.0, order.promoter_payout_euros
end
# === Promotion Code Tests ===
test "subtotal_amount_cents should calculate total without discounts" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 1500,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe"
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "Jane",
last_name: "Doe"
)
# Create promotion code
promotion_code = PromotionCode.create!(
code: "TESTCODE",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 3000, order.subtotal_amount_cents # 2 tickets * 1500 cents
assert_equal 2500, order.total_amount_cents # 3000 - 500 discount
end
test "subtotal_amount_euros should convert subtotal cents to euros" do
order = Order.new(total_amount_cents: 2500)
def order.subtotal_amount_cents; 3000; end
assert_equal 30.0, order.subtotal_amount_euros
end
test "discount_amount_cents should calculate total discount from promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets for subtotal
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe"
)
# Create multiple promotion codes
promo1 = PromotionCode.create!(
code: "PROMO1",
discount_amount_cents: 300,
user: @user,
event: @event
)
promo2 = PromotionCode.create!(
code: "PROMO2",
discount_amount_cents: 700,
user: @user,
event: @event
)
order.promotion_codes << [ promo1, promo2 ]
order.calculate_total!
assert_equal 1000, order.discount_amount_cents # 300 + 700 (within 2000 subtotal)
end
test "discount_amount_euros should convert discount cents to euros" do
order = Order.new(total_amount_cents: 2000)
def order.discount_amount_cents; 1000; end
assert_equal 10.0, order.discount_amount_euros
end
test "calculate_total! should apply promotion code discounts" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 2000,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code
promotion_code = PromotionCode.create!(
code: "TESTCODE",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 2000, order.subtotal_amount_cents
assert_equal 500, order.discount_amount_cents
assert_equal 1500, order.total_amount_cents
end
test "calculate_total! should handle zero total after promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 500,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code that covers the entire amount
promotion_code = PromotionCode.create!(
code: "FULLDISCOUNT",
discount_amount_cents: 500,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 500, order.subtotal_amount_cents
assert_equal 500, order.discount_amount_cents
assert_equal 0, order.total_amount_cents
assert order.free?
end
test "calculate_total! should not allow negative totals with promotion codes" do
order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
# Create ticket type and tickets
ticket_type = TicketType.create!(
name: "Test Ticket Type",
description: "A valid description for the ticket type that is long enough",
price_cents: 300,
quantity: 10,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
requires_id: false,
event: @event
)
Ticket.create!(
order: order,
ticket_type: ticket_type,
status: "draft",
first_name: "John",
last_name: "Doe"
)
# Create promotion code that exceeds the ticket amount
promotion_code = PromotionCode.create!(
code: "TOOMUCH",
discount_amount_cents: 1000,
user: @user,
event: @event
)
order.promotion_codes << promotion_code
order.calculate_total!
assert_equal 300, order.subtotal_amount_cents
assert_equal 300, order.discount_amount_cents # Capped at subtotal
assert_equal 0, order.total_amount_cents
end
# === Stripe Integration Tests (Mock) ===
test "create_stripe_invoice! should return nil for non-paid orders" do

View File

@@ -1,13 +1,34 @@
require "test_helper"
class PromotionCodeTest < ActiveSupport::TestCase
def setup
@user = User.create!(
email: "test@example.com",
password: "password123",
password_confirmation: "password123"
)
@event = Event.create!(
name: "Test Event",
slug: "test-event",
description: "A valid description for the test event that is long enough",
latitude: 48.8566,
longitude: 2.3522,
venue_name: "Test Venue",
venue_address: "123 Test Street",
user: @user
)
end
# Test valid promotion code creation
def test_valid_promotion_code
promotion_code = PromotionCode.create(
code: "DISCOUNT10",
discount_amount_cents: 1000, # €10.00
expires_at: 1.month.from_now,
active: true
active: true,
user: @user,
event: @event
)
assert promotion_code.valid?
@@ -25,23 +46,23 @@ class PromotionCodeTest < ActiveSupport::TestCase
# Test unique code validation
def test_unique_code_validation
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500)
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500)
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event)
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event)
refute duplicate_code.valid?
assert_not_nil duplicate_code.errors[:code]
end
# Test discount amount validation
def test_discount_amount_validation
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100)
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100, user: @user, event: @event)
refute promotion_code.valid?
assert_not_nil promotion_code.errors[:discount_amount_cents]
end
# Test active scope
def test_active_scope
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true)
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false)
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true, user: @user, event: @event)
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false, user: @user, event: @event)
assert_includes PromotionCode.active, active_code
refute_includes PromotionCode.active, inactive_code
@@ -49,8 +70,8 @@ class PromotionCodeTest < ActiveSupport::TestCase
# Test expired scope
def test_expired_scope
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago)
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now)
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago, user: @user, event: @event)
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now, user: @user, event: @event)
assert_includes PromotionCode.expired, expired_code
refute_includes PromotionCode.expired, future_code
@@ -58,10 +79,191 @@ class PromotionCodeTest < ActiveSupport::TestCase
# Test valid scope
def test_valid_scope
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now)
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago)
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now, user: @user, event: @event)
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago, user: @user, event: @event)
assert_includes PromotionCode.valid, valid_code
refute_includes PromotionCode.valid, invalid_code
end
# Test discount_amount_euros method
def test_discount_amount_euros_converts_cents_to_euros
promotion_code = PromotionCode.new(discount_amount_cents: 1000)
assert_equal 10.0, promotion_code.discount_amount_euros
promotion_code = PromotionCode.new(discount_amount_cents: 550)
assert_equal 5.5, promotion_code.discount_amount_euros
end
# Test active? method
def test_active_method
# Active and not expired
active_code = PromotionCode.create(
code: "ACTIVE1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert active_code.active?
# Active but expired
expired_active_code = PromotionCode.create(
code: "ACTIVE2",
discount_amount_cents: 500,
active: true,
expires_at: 1.day.ago,
user: @user,
event: @event
)
assert_not expired_active_code.active?
# Inactive but not expired
inactive_code = PromotionCode.create(
code: "INACTIVE1",
discount_amount_cents: 500,
active: false,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert_not inactive_code.active?
# Active with no expiration
no_expiry_code = PromotionCode.create(
code: "NOEXPIRY",
discount_amount_cents: 500,
active: true,
expires_at: nil,
user: @user,
event: @event
)
assert no_expiry_code.active?
end
# Test expired? method
def test_expired_method
# Expired code
expired_code = PromotionCode.create(
code: "EXPIRED1",
discount_amount_cents: 500,
expires_at: 1.day.ago,
user: @user,
event: @event
)
assert expired_code.expired?
# Future code
future_code = PromotionCode.create(
code: "FUTURE1",
discount_amount_cents: 500,
expires_at: 1.month.from_now,
user: @user,
event: @event
)
assert_not future_code.expired?
# No expiration
no_expiry_code = PromotionCode.create(
code: "NOEXPIRY1",
discount_amount_cents: 500,
expires_at: nil,
user: @user,
event: @event
)
assert_not no_expiry_code.expired?
end
# Test can_be_used? method
def test_can_be_used_method
# Can be used: active, not expired, under usage limit
usable_code = PromotionCode.create(
code: "USABLE1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert usable_code.can_be_used?
# Cannot be used: inactive
inactive_code = PromotionCode.create(
code: "INACTIVE2",
discount_amount_cents: 500,
active: false,
expires_at: 1.month.from_now,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert_not inactive_code.can_be_used?
# Cannot be used: expired
expired_code = PromotionCode.create(
code: "EXPIRED2",
discount_amount_cents: 500,
active: true,
expires_at: 1.day.ago,
usage_limit: 10,
uses_count: 0,
user: @user,
event: @event
)
assert_not expired_code.can_be_used?
# Cannot be used: at usage limit
limit_reached_code = PromotionCode.create(
code: "LIMIT1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: 5,
uses_count: 5,
user: @user,
event: @event
)
assert_not limit_reached_code.can_be_used?
# Can be used: no usage limit
no_limit_code = PromotionCode.create(
code: "NOLIMIT1",
discount_amount_cents: 500,
active: true,
expires_at: 1.month.from_now,
usage_limit: nil,
uses_count: 100,
user: @user,
event: @event
)
assert no_limit_code.can_be_used?
end
# Test increment_uses_count callback
def test_increment_uses_count_callback
promotion_code = PromotionCode.create(
code: "INCREMENT1",
discount_amount_cents: 500,
uses_count: 0,
user: @user,
event: @event
)
assert_equal 0, promotion_code.uses_count
# The callback should only run on create, so we test the initial value
new_code = PromotionCode.create(
code: "INCREMENT2",
discount_amount_cents: 500,
uses_count: nil,
user: @user,
event: @event
)
assert_equal 0, new_code.uses_count
end
end

View File

@@ -19,6 +19,14 @@ module ActiveSupport
# Add more helper methods to be used by all tests here...
# Mock Stripe for tests
setup do
# Mock Stripe checkout session creation
Stripe::Checkout::Session.stubs(:create).returns(
Struct.new(:id, :url).new("cs_test_session", "https://checkout.stripe.com/test")
)
end
# Helper to create users with completed onboarding by default for tests
def create_test_user(attributes = {})
User.create!({