Compare commits
9 Commits
a4a427ad5c
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| b580431b12 | |||
|
|
d85996a1bb | ||
|
|
6be8b95ed3 | ||
| be7b3d5c18 | |||
|
|
66fffa8676 | ||
| aacc9398d0 | |||
|
|
635644b55a | ||
|
|
87ccebf229 | ||
|
|
72d54e02ab |
378
AGENTS.md
378
AGENTS.md
@@ -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.
|
||||
@@ -8,16 +8,12 @@
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] feat: Promotion code on ticket
|
||||
- [ ] 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
|
||||
@@ -54,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
|
||||
|
||||
@@ -66,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
|
||||
|
||||
@@ -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
|
||||
@@ -99,6 +99,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
82
app/controllers/promoter/promotion_codes_controller.rb
Normal file
82
app/controllers/promoter/promotion_codes_controller.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class Promoter::PromotionCodesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_event
|
||||
before_action :set_promotion_code, only: [ :edit, :update, :destroy ]
|
||||
|
||||
# GET /promoter/events/:event_id/promotion_codes
|
||||
# Display all promotion codes for a specific event
|
||||
def index
|
||||
@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
|
||||
@promotion_code = @event.promotion_codes.new
|
||||
end
|
||||
|
||||
# GET /promoter/events/:event_id/promotion_codes/:id/edit
|
||||
# Show form to edit an existing promotion code
|
||||
def edit
|
||||
end
|
||||
|
||||
# POST /promoter/events/:event_id/promotion_codes
|
||||
# Create a new promotion code for the event
|
||||
def create
|
||||
@promotion_code = @event.promotion_codes.new(promotion_code_params_with_conversion)
|
||||
@promotion_code.user = current_user
|
||||
|
||||
if @promotion_code.save
|
||||
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /promoter/events/:event_id/promotion_codes/:id
|
||||
# Update an existing promotion code
|
||||
def update
|
||||
if @promotion_code.update(promotion_code_params_with_conversion)
|
||||
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /promoter/events/:event_id/promotion_codes/:id
|
||||
# Delete a promotion code
|
||||
def destroy
|
||||
@promotion_code.destroy
|
||||
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully destroyed."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Find the event based on the URL parameter
|
||||
def set_event
|
||||
@event = Event.find(params[:event_id])
|
||||
end
|
||||
|
||||
# Find the promotion code based on the URL parameter
|
||||
def set_promotion_code
|
||||
@promotion_code = @event.promotion_codes.find(params[:id])
|
||||
end
|
||||
|
||||
# Strong parameters for promotion code form (accepts euros for display)
|
||||
def promotion_code_params
|
||||
params.require(:promotion_code).permit(:code, :discount_amount_euros, :expires_at, :active, :usage_limit)
|
||||
end
|
||||
|
||||
# Convert euros to cents for database storage
|
||||
# The form displays euros for user convenience, but the database stores cents
|
||||
def promotion_code_params_with_conversion
|
||||
params = promotion_code_params
|
||||
if params[:discount_amount_euros].present?
|
||||
# Convert euros to cents (e.g., 20.50 -> 2050)
|
||||
params[:discount_amount_cents] = (params[:discount_amount_euros].to_f * 100).to_i
|
||||
params.delete(:discount_amount_euros) # Remove the temporary euro parameter
|
||||
end
|
||||
params
|
||||
end
|
||||
end
|
||||
@@ -664,4 +664,37 @@ 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('image-preview')
|
||||
const previewImg = document.getElementById('preview-img')
|
||||
|
||||
if (previewContainer && previewImg) {
|
||||
previewImg.src = e.target.result
|
||||
previewContainer.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ class Event < ApplicationRecord
|
||||
has_many :ticket_types
|
||||
has_many :tickets, through: :ticket_types
|
||||
has_many :orders
|
||||
has_many :promotion_codes
|
||||
has_one_attached :image
|
||||
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
@@ -31,7 +34,8 @@ 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
|
||||
validate :image_format, if: -> { image.attached? }
|
||||
validate :image_size, if: -> { image.attached? }
|
||||
|
||||
# Venue information
|
||||
validates :venue_name, presence: true, length: { maximum: 100 }
|
||||
@@ -57,6 +61,20 @@ class Event < ApplicationRecord
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Get image variants for different display sizes
|
||||
def event_image_variant(size = :medium)
|
||||
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
|
||||
image
|
||||
end
|
||||
end
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
@@ -130,6 +148,25 @@ 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
|
||||
|
||||
private
|
||||
|
||||
# Determine if we should perform server-side geocoding
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,10 +93,34 @@ class Order < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
|
||||
# Calculate total from ticket prices minus promotion code discounts
|
||||
def calculate_total!
|
||||
ticket_total = tickets.sum(:price_cents)
|
||||
update!(total_amount_cents: ticket_total)
|
||||
discount_total = promotion_codes.sum(:discount_amount_cents)
|
||||
|
||||
# Ensure total doesn't go below zero
|
||||
final_total = [ ticket_total - discount_total, 0 ].max
|
||||
update!(total_amount_cents: final_total)
|
||||
end
|
||||
|
||||
# Subtotal amount before discounts
|
||||
def subtotal_amount_cents
|
||||
tickets.sum(:price_cents)
|
||||
end
|
||||
|
||||
# Subtotal amount in euros
|
||||
def subtotal_amount_euros
|
||||
subtotal_amount_cents / 100.0
|
||||
end
|
||||
|
||||
# Total discount amount from all promotion codes (capped at subtotal)
|
||||
def discount_amount_cents
|
||||
[ promotion_codes.sum(:discount_amount_cents), subtotal_amount_cents ].min
|
||||
end
|
||||
|
||||
# Discount amount in euros
|
||||
def discount_amount_euros
|
||||
discount_amount_cents / 100.0
|
||||
end
|
||||
|
||||
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
|
||||
@@ -164,4 +191,12 @@ class Order < ApplicationRecord
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
# Prevent duplicate promotion codes on the same order
|
||||
def no_duplicate_promotion_codes
|
||||
promotion_code_ids = promotion_codes.map(&:id)
|
||||
if promotion_code_ids.size != promotion_code_ids.uniq.size
|
||||
errors.add(:promotion_codes, "ne peuvent pas contenir de codes en double")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,9 +12,28 @@ class PromotionCode < ApplicationRecord
|
||||
before_create :increment_uses_count
|
||||
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
has_many :order_promotion_codes
|
||||
has_many :orders, through: :order_promotion_codes
|
||||
|
||||
# Instance methods
|
||||
def discount_amount_euros
|
||||
discount_amount_cents / 100.0
|
||||
end
|
||||
|
||||
def active?
|
||||
active && (expires_at.nil? || expires_at > Time.current)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def can_be_used?
|
||||
active? && (usage_limit.nil? || uses_count < usage_limit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def increment_uses_count
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.image.attached? %>
|
||||
</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">
|
||||
|
||||
@@ -22,13 +22,9 @@
|
||||
<% @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.image.attached? %>
|
||||
<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"
|
||||
>
|
||||
<%= 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" %>
|
||||
<!-- Event featured badge -->
|
||||
<% if event.featured? %>
|
||||
<div class="absolute top-4 left-4">
|
||||
|
||||
@@ -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.image.attached? %>
|
||||
<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" %>
|
||||
<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">
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="inline-flex items-center gap-2 bg-white px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-8" aria-label="Breadcrumb">
|
||||
<div class="inline-flex items-center text-sm font-medium">
|
||||
<%= link_to root_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
|
||||
<i data-lucide="home" class="w-4 h-4 mr-2"></i>
|
||||
Accueil
|
||||
<% end %>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
|
||||
<div class="inline-flex items-center text-sm font-medium">
|
||||
<%= link_to events_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
|
||||
Événements
|
||||
<% end %>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
|
||||
<div class="inline-flex items-center text-sm font-medium">
|
||||
<%= link_to event_path(@order.event.slug, @order.event), class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
|
||||
<%= @order.event.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
|
||||
<div class="text-sm font-medium text-purple-600">
|
||||
Commande #<%= @order.id %>
|
||||
</div>
|
||||
</nav>
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Événements', path: events_path },
|
||||
{ name: @order.event.name, path: event_path(@order.event.slug, @order.event) },
|
||||
{ name: "Commande ##{@order.id}", path: nil }
|
||||
] %>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Order Summary -->
|
||||
@@ -77,8 +59,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Récapitulatif de votre commande</h3>
|
||||
<div class="space-y-4 mb-6 border-b border-gray-200 pb-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4 ">Récapitulatif de votre commande</h3>
|
||||
|
||||
<% @tickets.each do |ticket| %>
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
|
||||
@@ -99,12 +81,46 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Promotion Code Discount -->
|
||||
<% if @order.promotion_codes.any? %>
|
||||
<div class="space-y-2 mb-6 pb-6 border-b border-gray-200">
|
||||
<% @order.promotion_codes.each do |promo_code| %>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-green-600">
|
||||
<i data-lucide="tag" class="w-4 h-4 mr-1"></i>
|
||||
Code: <%= promo_code.code %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-green-600">-<%= promo_code.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Order Total -->
|
||||
<div class=" pt-12">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<!-- Subtotal -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Sous-total</span>
|
||||
<span class="text-sm font-medium text-gray-600"><%= @order.subtotal_amount_euros %>€</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<% if @order.discount_amount_cents > 0 %>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-green-600">Réduction</span>
|
||||
<span class="text-sm font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total</span>
|
||||
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
|
||||
<% if @order.total_amount_cents == 0 %>
|
||||
<span class="font-bold text-2xl text-green-600">GRATUIT</span>
|
||||
<% else %>
|
||||
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">TVA incluse</p>
|
||||
@@ -150,7 +166,11 @@
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
|
||||
Payer <%= @order.total_amount_euros %>€
|
||||
<% if @order.total_amount_cents == 0 %>
|
||||
Confirmer la commande
|
||||
<% else %>
|
||||
Payer <%= @order.total_amount_euros %>€
|
||||
<% end %>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -251,7 +271,11 @@ const stripeResult = await stripe.redirectToCheckout({
|
||||
button.innerHTML = `
|
||||
<div class="flex items-center justify-center">
|
||||
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
|
||||
Payer <%= @order.total_amount_euros %>€
|
||||
<% if @order.total_amount_cents == 0 %>
|
||||
Confirmer la commande
|
||||
<% else %>
|
||||
Payer <%= @order.total_amount_euros %>€
|
||||
<% end %>
|
||||
</div>
|
||||
`;
|
||||
alert('Erreur: ' + error.message);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -123,13 +123,58 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="mt-6">
|
||||
<!-- Promotion Codes Applied -->
|
||||
<% if @order.promotion_codes.any? %>
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
Codes promotionnels appliqués
|
||||
</h3>
|
||||
<% @order.promotion_codes.each do |promo_code| %>
|
||||
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-green-800">
|
||||
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<%= promo_code.code %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<!-- Subtotal -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<% if @order.discount_amount_cents > 0 %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-green-600">Réduction</span>
|
||||
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
|
||||
<span class="font-medium text-gray-900">Total payé</span>
|
||||
<span class="font-bold text-2xl text-green-600">
|
||||
<%= @order.total_amount_euros %>€
|
||||
<span class="font-bold text-xl text-green-600">
|
||||
<% if @order.total_amount_cents == 0 %>
|
||||
GRATUIT
|
||||
<% else %>
|
||||
<%= @order.total_amount_euros %>€
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,14 +94,57 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<!-- Promotion Codes Applied -->
|
||||
<% if @order.promotion_codes.any? %>
|
||||
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
|
||||
<i data-lucide="tag" class="w-5 h-5 mr-2"></i>
|
||||
Codes promotionnels appliqués
|
||||
</h3>
|
||||
<% @order.promotion_codes.each do |promo_code| %>
|
||||
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-green-800">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 mr-1 inline"></i>
|
||||
<%= promo_code.code %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="flex items-center justify-between text-lg pt-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
|
||||
<div class="space-y-2">
|
||||
<!-- Subtotal -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<% if @order.discount_amount_cents > 0 %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-green-600">Réduction</span>
|
||||
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
|
||||
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span>
|
||||
<span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
|
||||
<%= @order.total_amount_euros %>€
|
||||
<% if @order.total_amount_cents == 0 %>
|
||||
GRATUIT
|
||||
<% else %>
|
||||
<%= @order.total_amount_euros %>€
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Invoice -->
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
{ name: 'Tableau de bord', path: dashboard_path }
|
||||
] %>
|
||||
|
||||
<!-- Page Header -->
|
||||
<!-- Promoter Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord promoteur</h1>
|
||||
<p class="text-gray-600 mt-1">Gérez vos commandes et accédez à vos billets</p>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brouillons -->
|
||||
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200">
|
||||
<%= link_to promoter_events_path do %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-orange-600 text-sm font-medium">Brouillons</p>
|
||||
@@ -86,7 +88,9 @@
|
||||
<i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div><!-- /Brouillons -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Revenue Chart & Recent Events -->
|
||||
@@ -273,6 +277,16 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- User Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1>
|
||||
<p class="text-gray-600 mt-1">Accédez à vos billets et évenements</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User's Orders Section -->
|
||||
<div class="bg-white rounded-2xl shadow-lg mb-8">
|
||||
<div class="border-b border-gray-100 p-4 sm:p-6">
|
||||
|
||||
@@ -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.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 %>
|
||||
<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>
|
||||
|
||||
@@ -67,9 +67,41 @@
|
||||
</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" %>
|
||||
<div class="space-y-4">
|
||||
<!-- Current image preview -->
|
||||
<% 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="this.closest('div').querySelector('input[type=file]').click()" 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)
|
||||
<% if @event.image.attached? %>
|
||||
<br>Laissez vide pour conserver l'image actuelle
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview container -->
|
||||
<div id="image-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="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('image-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>
|
||||
|
||||
|
||||
@@ -60,9 +60,38 @@
|
||||
</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" %>
|
||||
<div class="space-y-4">
|
||||
<!-- Current image preview (for edit mode) -->
|
||||
<% 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="this.closest('div').previousElementSibling.querySelector('input[type=file]').click()" 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="image-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="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('image-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>
|
||||
|
||||
|
||||
@@ -174,9 +174,9 @@
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
||||
<!-- Event image -->
|
||||
<% if @event.image.present? %>
|
||||
<% if @event.image.attached? %>
|
||||
<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">
|
||||
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -209,6 +209,42 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Quick actions -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
|
||||
<div class="space-y-3">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
|
||||
Gérer les types de billets
|
||||
<% end %>
|
||||
|
||||
<%= link_to promoter_event_promotion_codes_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-green-600 text-white font-medium text-sm rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="tag" class="w-4 h-4 mr-2"></i>
|
||||
Gérer les codes de réduction
|
||||
<% end %>
|
||||
|
||||
<% if @event.sold_out? %>
|
||||
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
|
||||
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme disponible
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
|
||||
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme complet
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
<%= button_to promoter_event_path(@event), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
|
||||
class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
Supprimer l'événement
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event stats -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
|
||||
@@ -269,36 +305,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
|
||||
<div class="space-y-3">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
|
||||
Gérer les types de billets
|
||||
<% end %>
|
||||
|
||||
<% if @event.sold_out? %>
|
||||
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
|
||||
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme disponible
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
|
||||
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
|
||||
Marquer comme complet
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
<%= button_to promoter_event_path(@event), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
|
||||
class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
Supprimer l'événement
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
109
app/views/promoter/promotion_codes/edit.html.erb
Normal file
109
app/views/promoter/promotion_codes/edit.html.erb
Normal file
@@ -0,0 +1,109 @@
|
||||
<% content_for(:title, "Modifier le code de réduction - #{@event.name}") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements', path: promoter_events_path },
|
||||
{ name: @event.name, path: promoter_event_path(@event) },
|
||||
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
|
||||
{ name: "Modifier #{@promotion_code.code}" }
|
||||
] %>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier le code de réduction</h1>
|
||||
<p class="text-gray-600">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @promotion_code.code %></code> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_code_path(@event, @promotion_code), method: :patch, local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
|
||||
<% if @promotion_code.errors.any? %>
|
||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium text-red-800 mb-2">
|
||||
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-sm text-red-700">
|
||||
<% @promotion_code.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: SUMMER2024, BIENVENUE10, etc." %>
|
||||
<p class="text-sm text-gray-500 mt-2">Ce code sera visible par les clients lors du paiement</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
|
||||
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros (ex: 10, 5.50, 25)</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
|
||||
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
|
||||
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
|
||||
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Les clients peuvent utiliser ce code de réduction
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<i data-lucide="info" class="w-5 h-5 text-blue-400 mr-3 mt-0.5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-1">Statut actuel</h4>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p>Utilisations: <%= @promotion_code.uses_count %><%= " / #{@promotion_code.usage_limit}" if @promotion_code.usage_limit %></p>
|
||||
<p>Commandes associées: <%= @promotion_code.orders.count %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
|
||||
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
|
||||
Annuler
|
||||
<% end %>
|
||||
<%= form.submit "Mettre à jour le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
175
app/views/promoter/promotion_codes/index.html.erb
Normal file
175
app/views/promoter/promotion_codes/index.html.erb
Normal file
@@ -0,0 +1,175 @@
|
||||
<% content_for(:title, "Codes de réduction - #{@event.name}") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements', path: promoter_events_path },
|
||||
{ name: @event.name, path: promoter_event_path(@event) },
|
||||
{ name: 'Codes de réduction' }
|
||||
] %>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Codes de réduction</h1>
|
||||
<p class="text-gray-600">
|
||||
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
Nouveau code
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Event status info -->
|
||||
<% if @event.draft? %>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="info" class="w-5 h-5 text-gray-400 mr-3"></i>
|
||||
<p class="text-sm text-gray-600">
|
||||
Cet événement est en brouillon. Les codes de réduction ne seront actifs qu'une fois l'événement publié.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @promotion_codes.any? %>
|
||||
<div class="grid gap-6">
|
||||
<% @promotion_codes.each do |promotion_code| %>
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- Promotion code info -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
<%= promotion_code.code %>
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-3">Réduction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %></p>
|
||||
</div>
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="ml-4">
|
||||
<% if promotion_code.active? && (promotion_code.expires_at.nil? || promotion_code.expires_at > Time.current) %>
|
||||
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
|
||||
Actif
|
||||
</span>
|
||||
<% elsif promotion_code.expires_at && promotion_code.expires_at <= Time.current %>
|
||||
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
|
||||
Expiré
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
<i data-lucide="pause-circle" class="w-4 h-4 mr-1"></i>
|
||||
Inactif
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promotion code details grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
<%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Réduction</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-900">
|
||||
<% if promotion_code.usage_limit %>
|
||||
<%= promotion_code.usage_limit - promotion_code.uses_count %>
|
||||
<% else %>
|
||||
∞
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Restants</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-900">
|
||||
<%= promotion_code.uses_count %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Utilisés</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
<%= promotion_code.orders.count %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Commandes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional info -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<% if promotion_code.expires_at %>
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
|
||||
Expire le : <%= l(promotion_code.expires_at, format: :short) %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="infinity" class="w-4 h-4 mr-1"></i>
|
||||
Pas d'expiration
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="flex items-center">
|
||||
<i data-lucide="user" class="w-4 h-4 mr-1"></i>
|
||||
<% if promotion_code.user.first_name && promotion_code.user.last_name %>
|
||||
Créé par : <%= promotion_code.user.first_name %> <%= promotion_code.user.last_name %>
|
||||
<% else %>
|
||||
Créé par : <%= promotion_code.user.email %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= link_to edit_promoter_event_promotion_code_path(@event, promotion_code), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
|
||||
<i data-lucide="edit" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<% if promotion_code.orders.empty? %>
|
||||
<%= button_to promoter_event_promotion_code_path(@event, promotion_code), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce code de réduction ?" },
|
||||
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
Créé il y a <%= time_ago_in_words(promotion_code.created_at) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-12 text-center">
|
||||
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
|
||||
<i data-lucide="tag" class="w-12 h-12 text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun code de réduction</h3>
|
||||
<p class="text-gray-500 mb-6">Créez des codes de réduction pour offrir des remises spéciales à vos clients.</p>
|
||||
<%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
|
||||
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||
Créer mon premier code de réduction
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
96
app/views/promoter/promotion_codes/new.html.erb
Normal file
96
app/views/promoter/promotion_codes/new.html.erb
Normal file
@@ -0,0 +1,96 @@
|
||||
<% content_for(:title, "Nouveau code de réduction - #{@event.name}") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements', path: promoter_events_path },
|
||||
{ name: @event.name, path: promoter_event_path(@event) },
|
||||
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
|
||||
{ name: 'Nouveau code' }
|
||||
] %>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
<i data-lucide="arrow-left" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouveau code de réduction</h1>
|
||||
<p class="text-gray-600">
|
||||
Pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_codes_path(@event), local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
|
||||
<% if @promotion_code.errors.any? %>
|
||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium text-red-800 mb-2">
|
||||
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-sm text-red-700">
|
||||
<% @promotion_code.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: BIENVENUE10, VIP20" %>
|
||||
<p class="text-sm text-gray-500 mt-2">Ce code sera à appliquer par le client lors du paiement.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
|
||||
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
|
||||
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
|
||||
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
|
||||
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Les clients peuvent utiliser ce code de réduction
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
|
||||
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
|
||||
Annuler
|
||||
<% end %>
|
||||
<%= form.submit "Créer le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +91,16 @@ Rails.application.routes.draw do
|
||||
post :duplicate
|
||||
end
|
||||
end
|
||||
|
||||
# Nested promotion codes routes
|
||||
resources :promotion_codes, except: [ :show ]
|
||||
end
|
||||
end
|
||||
|
||||
# === Promotion Codes Routes ===
|
||||
resources :promotion_codes, only: [ :index ] do
|
||||
member do
|
||||
post :apply
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
class CreateOrders < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :orders do |t|
|
||||
t.references :user, null: false, foreign_key: false
|
||||
t.references :event, null: false, foreign_key: false
|
||||
t.string :status, null: false, default: "draft"
|
||||
t.integer :total_amount_cents, null: false, default: 0
|
||||
t.integer :payment_attempts, null: false, default: 0
|
||||
t.timestamp :expires_at
|
||||
t.timestamp :last_payment_attempt_at
|
||||
|
||||
t.references :user, null: false, foreign_key: false
|
||||
t.references :event, null: false, foreign_key: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
|
||||
@@ -7,8 +7,12 @@ class CreatePromotionCodes < ActiveRecord::Migration[8.0]
|
||||
t.boolean :active, default: true, null: false
|
||||
t.integer :usage_limit, default: nil
|
||||
t.integer :uses_count, default: 0, null: false
|
||||
t.datetime :created_at, null: false
|
||||
t.datetime :updated_at, null: false
|
||||
|
||||
# Reference user(promoter) who has created the promotion code
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :event, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Unique index for code
|
||||
|
||||
@@ -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
|
||||
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class AddImageToEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
end
|
||||
end
|
||||
42
db/schema.rb
generated
42
db/schema.rb
generated
@@ -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
|
||||
@@ -44,13 +72,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
||||
end
|
||||
|
||||
create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "event_id", null: false
|
||||
t.string "status", default: "draft", null: false
|
||||
t.integer "total_amount_cents", default: 0, null: false
|
||||
t.integer "payment_attempts", default: 0, null: false
|
||||
t.timestamp "expires_at"
|
||||
t.timestamp "last_payment_attempt_at"
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "event_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["event_id", "status"], name: "idx_orders_event_status"
|
||||
@@ -67,9 +95,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "usage_limit"
|
||||
t.integer "uses_count", default: 0, null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "event_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["code"], name: "index_promotion_codes_on_code", unique: true
|
||||
t.index ["event_id"], name: "index_promotion_codes_on_event_id"
|
||||
t.index ["user_id"], name: "index_promotion_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
@@ -126,6 +158,10 @@ 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_attachments", "active_storage_blobs", column: "blob_id"
|
||||
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"
|
||||
add_foreign_key "promotion_codes", "users"
|
||||
end
|
||||
|
||||
23
db/seeds.rb
23
db/seeds.rb
@@ -280,3 +280,26 @@ TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Entry 10
|
||||
end
|
||||
|
||||
puts "Created 3 additional events from Bizouk with ticket types"
|
||||
|
||||
# Create promotion codes for events
|
||||
# Promotion code for belle_epoque_event
|
||||
PromotionCode.find_or_create_by!(code: "BELLE10") do |pc|
|
||||
pc.discount_amount_cents = 1000 # 10€ discount
|
||||
pc.expires_at = belle_epoque_event.start_time + 1.day
|
||||
pc.active = true
|
||||
pc.usage_limit = 20
|
||||
pc.user = promoter
|
||||
pc.event = belle_epoque_october_event
|
||||
end
|
||||
|
||||
# Promotion code for belle_epoque_october_event
|
||||
PromotionCode.find_or_create_by!(code: "OCTOBRE5") do |pc|
|
||||
pc.discount_amount_cents = 500 # 5€ discount
|
||||
pc.expires_at = belle_epoque_october_event.start_time + 1.day
|
||||
pc.active = true
|
||||
pc.usage_limit = 30
|
||||
pc.user = promoter
|
||||
pc.event = belle_epoque_october_event
|
||||
end
|
||||
|
||||
puts "Created promotion codes for events"
|
||||
|
||||
74
debug_promotion_test.rb
Executable file
74
debug_promotion_test.rb
Executable 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
67
debug_test.rb
Executable 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}"
|
||||
@@ -1,4 +1,5 @@
|
||||
require "test_helper"
|
||||
require "securerandom"
|
||||
|
||||
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
include Devise::Test::IntegrationHelpers
|
||||
@@ -6,32 +7,68 @@ 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
|
||||
)
|
||||
|
||||
# Debug the ticket creation
|
||||
puts "Ticket saved: #{ticket.persisted?}"
|
||||
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
|
||||
puts "Order tickets count: #{@order.tickets.count}"
|
||||
|
||||
# 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 +77,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 +94,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
|
||||
|
||||
1
test/fixtures/users.yml
vendored
1
test/fixtures/users.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user