146 Commits

Author SHA1 Message Date
kbe
47f4f50e5b feat: complete promoter payout system implementation
- Add comprehensive payout styling with custom CSS classes for status indicators
- Implement payout index and show views with French translations
- Add payout migration with proper indexes and defaults
- Update database schema with payout-related tables and fields
- Add comprehensive seed data for testing payout functionality
- Include payout CSS in application stylesheet
- Document payout system implementation in AGENT.md
- Add payout feature to BACKLOG.md

This completes the full promoter payout system allowing event organizers
to request and track revenue payouts for completed events.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 01:38:55 +02:00
kbe
e4509b1c43 feat: enhance dashboard and event pages with ended events payout functionality
- Replace 'Recent Ticket Sales' section with 'Événements Terminés' on dashboard
- Add dedicated section for ended events with payout request capabilities
- Display event revenue, ticket sales count, and payout status for ended events
- Implement payout request buttons with status indicators (pending, processing, completed, failed)
- Translate payout section in individual event page to French
- Add visual indicators and confirmations for payout requests
- Improve UX with color-coded status badges and action buttons

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 01:37:36 +02:00
kbe
59e1854803 feat: Implement promoter payout system for event revenue processing
- Add Payout model with associations to User and Event
- Create payout requests for completed events with proper earnings calculation
- Exclude refunded tickets from payout calculations
- Add promoter dashboard views for managing payouts
- Implement admin interface for processing payouts
- Integrate with Stripe for actual payment processing
- Add comprehensive tests for payout functionality

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-17 00:29:20 +02:00
kbe
58141dca94 fix(event model): duplicate payout status enum removed 2025-09-17 00:08:14 +02:00
kbe
d2c43cfc2f fix: clean up payout migrations, remove ghosts, add defaults and indexes 2025-09-17 00:02:59 +02:00
kbe
bc09feafc1 feat: complete promoter payout system with Stripe Connect onboarding 2025-09-16 23:53:04 +02:00
kbe
d922d7304d feat: add promoter payouts controller and routes 2025-09-16 23:52:49 +02:00
kbe
0399761fb3 feat: implement payout system database schema and models 2025-09-16 23:52:26 +02:00
kbe
e5ed1a34dd refactor: migrate payment success views to orders system and add promoter seed data
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m48s
2025-09-16 21:32:42 +02:00
3e0a354a58 Merge pull request 'feat/free-ticket' (#4) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m31s
Reviewed-on: #4
2025-09-16 15:27:51 +00:00
kbe
b5c1846f2c fix(mailers): Include TicketsHelper in TicketMailer to make format_ticket_price available
- Add helper :tickets to TicketMailer to make format_ticket_price method available in mailer templates
- Fixes undefined method 'format_ticket_price' error in purchase confirmation emails
- Required after recent changes to support free tickets
2025-09-16 17:25:14 +02:00
kbe
04393add14 fix(tests): Remove service fee expectation from Stripe invoice test and fix duplicated keys in event view
- Update StripeInvoiceServiceTest to match the implementation that no longer adds service fees to customer invoices
- Remove duplicated Stimulus data attributes in events/show.html.erb that were causing warnings
- Align tests with the hybrid fee model where fees are deducted from promoter payouts
2025-09-16 17:22:00 +02:00
kbe
5279ebe1a4 feat(event available/sold out): Promoter can mark event as sold out or available
On the event page, promoter can choose to mark the event as "sold out" using the status field or as "published". Only published event can be marked as sold out if promoter thinks he cannot handle all the people available.
2025-09-16 17:15:09 +02:00
kbe
329ba89eaa chore: Better description details for ticket types 2025-09-16 17:00:04 +02:00
kbe
9c56b2e1e5 refactor: prepare for free ticket 2025-09-16 16:36:39 +02:00
a8d3bc12ae Merge pull request 'feat/free-ticket' (#2) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m56s
Reviewed-on: #2
2025-09-16 14:31:43 +00:00
kbe
b228d5a174 chore: Breadcrumb on ticket edit page 2025-09-16 16:22:09 +02:00
kbe
61ad8c64d4 Fix modal overlay issue and improve modal structure
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 08:44:21 +02:00
kbe
4e06f91acb Fix modal positioning and improve Stimulus controller
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-16 08:43:00 +02:00
kbe
28eddb22ab Refactor duplication feature to use Stimulus controller and fix modal issues
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:23:29 +02:00
kbe
a34eb7aa38 Add duplication options with JavaScript modal and conditional ticket type cloning
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:20:22 +02:00
kbe
aa68885b84 Add event duplication feature with ticket types cloning
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-15 21:17:24 +02:00
kbe
c1dde7914c refactor: remove 1€ fees on payment 2025-09-15 21:09:57 +02:00
kbe
dbb972e490 feat: Add countdown when order expire in less than 5 minutes 2025-09-15 21:09:19 +02:00
kbe
049e5505ef refactor(pricing): implement hybrid fee model (€0.50 + 1.5%) deducted from promoter payout
- Remove 1€ fixed fee from orders and Stripe invoices
- Add platform_fee_cents, promoter_payout_cents methods to Order model
- Update views to show clean ticket totals without added fees
- Update tests for new fee calculation logic
- Update pricing docs with implemented model
2025-09-15 20:07:51 +02:00
kbe
d6184b6c84 refactor: extract cart storage to dedicated API controller with dynamic frontend URLs
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m7s
- Added dedicated CartsController for session-based cart storage
- Refactored routes to use POST /api/v1/carts/store
- Updated ticket selection JS to use dynamic data attributes for URLs
- Fixed CSRF protection in API and checkout payment increment
- Made checkout button URLs dynamic via data attributes
- Updated tests for new cart storage endpoint
- Removed obsolete store_cart from EventsController
2025-09-15 19:52:01 +02:00
kbe
4cde466f9a Add comprehensive unit test coverage for controllers, models, and services
- Translate French comments to English in controllers and tests
- Fix test failures: route helpers, validations, MySQL transaction issues
- Add Timecop for time-dependent tests and update database config for isolation
2025-09-15 19:27:06 +02:00
kbe
ee43996a77 feat(book after start): prepare to rework event to allow ticket sell
after start
2025-09-15 19:07:19 +02:00
kbe
f0d32bf3f1 Improve mobile responsiveness 2025-09-15 19:06:15 +02:00
kbe
20f926cd7a Move `allow_booking_during_event` into base migration 2025-09-15 19:06:05 +02:00
kbe
d1ef962f74 feat: Improve mobile responsiveness for promoter event detail page
- Restructure header layout with separated title and action buttons
- Make all action buttons full-width on mobile (w-full sm:w-auto)
- Add responsive text sizing and proper truncation for long titles
- Improve status banners with flexible layouts for mobile
- Enhance content cards with responsive padding (p-4 sm:p-6)
- Add better text wrapping and overflow handling throughout
- Optimize sidebar with responsive font sizes and spacing
- Ensure consistent touch targets and button accessibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 18:23:45 +02:00
kbe
e84d9aad5b feat: Improve mobile responsiveness for promoter events page
- Add responsive header with stacked layout on mobile
- Implement dual layout system: table for desktop, cards for mobile
- Make all action buttons full-width and accessible on mobile
- Add proper spacing and touch targets for mobile UX
- Ensure "Créer un événement" button is full-width on mobile
- Improve empty state responsiveness

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 18:08:05 +02:00
kbe
24126eb834 style: lint code
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m55s
2025-09-15 17:41:35 +02:00
kbe
9a1976b6af Add breadcrumb to settings page 2025-09-15 17:41:23 +02:00
kbe
a8c7e82507 Merge branch 'feat/promoters' into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 2m19s
2025-09-15 17:21:03 +02:00
kbe
889afd0d01 Change available tickets details 2025-09-15 17:18:14 +02:00
kbe
82f0fab1f5 Disable authentication for API
Some checks failed
Ruby on Rails Test / rails-test (push) Has been cancelled
2025-09-15 17:15:49 +02:00
kbe
91e6425c1e feat: Settings page to update profile 2025-09-11 16:07:25 +02:00
kbe
f54742b041 feat: Add booking control during events
- Add allow_booking_during_event boolean field to events (defaults to false)
- Implement booking_allowed? method to check if tickets can be purchased
- Add event_started? and event_ended? helper methods
- Include new option in event edit form with clear explanation
- Display booking policy status on event show page
- Add visual indicator when booking is disabled during ongoing events
- Update controller to permit new parameter

This allows promoters to control whether attendees can purchase tickets
after an event has started, providing flexibility for different event types.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 08:49:01 +02:00
kbe
21919c813e Merge branch 'develop' into feat/promoters 2025-09-11 08:41:23 +02:00
kbe
8ecfc7bf99 feat: Display error message when event does not have any ticket type
Some checks failed
Ruby on Rails Test / rails-test (push) Failing after 2m18s
2025-09-11 08:35:54 +02:00
kbe
28ef801c9a feat: Add warning for publishing events without ticket types
Some checks failed
Ruby on Rails Test / rails-test (push) Has been cancelled
- Add prominent warning banner on event show page for draft events with no ticket types
- Disable publish button when no ticket types are configured
- Include helpful tooltip and direct link to configure ticket types
- Improve UX by preventing invalid publish attempts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 08:35:05 +02:00
kbe
55b39e93bf enhance: Implement dynamic message template system with progress tracking
Some checks failed
Ruby on Rails Test / rails-test (push) Failing after 13m31s
- Add comprehensive message template system with 5 distinct message types
- Implement progress tracking for multi-strategy geocoding attempts
- Add dismissible messages with auto-timeout functionality
- Enhance visual design with proper spacing, shadows, and animations
- Add specialized geocoding success messages with location details
- Improve user experience with contextual progress indicators
- Support HTML content in messages for better formatting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 22:01:26 +02:00
kbe
9bebdef5a5 feat: Implement comprehensive geocoding improvements with loading indicators
- Add multi-strategy geocoding fallback system for better address resolution
- Implement loading spinners and visual feedback for all geocoding operations
- Move geocoding messages to venue section for better visibility
- Add dynamic message template system with proper styling
- Optimize backend to trust frontend coordinates and reduce API calls
- Add rate limiting and proper User-Agent headers for Nominatim compliance
- Improve error handling and user feedback throughout geocoding flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 21:24:28 +02:00
kbe
d5c0276fcc chore: Code lint 2025-09-10 20:51:17 +02:00
kbe
10c93fff2f feat: Implement the promoter event creation
Some checks failed
Ruby on Rails Test / rails-test (push) Failing after 1m50s
- Promoter can now create an event in draft mode
- Place is found based on address and long/lat are automatically
  deducted from it
- Slug is forged using the *slug* npm package instead of custom code
2025-09-10 20:49:06 +02:00
kbe
332827c6da feat: Add comprehensive address-first geolocation system for events
This implementation provides automatic geocoding and map integration:

- **Event Model Enhancements:**
  - Automatic geocoding callback using OpenStreetMap Nominatim API
  - 3-tier fallback system: exact coordinates → city-based → country default
  - Fallback coordinates for major French cities (Paris, Lyon, Marseille, etc.)
  - Robust error handling that prevents event creation failures

- **User-Friendly Event Forms:**
  - Address-first approach - users just enter addresses
  - Hidden coordinate fields (auto-generated behind scenes)
  - Real-time geocoding with 1.5s debounce
  - "Ma position" button for current location with reverse geocoding
  - "Prévisualiser" button to show map links
  - Smart feedback system (loading, success, warnings, errors)

- **Enhanced Event Show Page:**
  - Map provider links (OpenStreetMap, Google Maps, Apple Plans)
  - Warning badges when approximate coordinates are used
  - Address-based URLs for better map integration

- **Comprehensive JavaScript Controller:**
  - Debounced auto-geocoding to minimize API calls
  - Multiple geocoding strategies (manual vs automatic)
  - Promise-based geolocation with proper error handling
  - Dynamic map link generation with address + coordinates

- **Failure Handling:**
  - Events never fail to save due to missing coordinates
  - Fallback to city-based coordinates when exact geocoding fails
  - User-friendly warnings when approximate locations are used
  - Maintains existing coordinates on update failures

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2025-09-10 20:49:06 +02:00
kbe
46d042b85e feat: Implement comprehensive promoter system with dashboard and role-based access
This commit implements a complete promoter system that allows professional users
(is_professionnal: true) to manage events with advanced analytics and controls.

## Key Features Added:

### Role-Based Access Control
- Update User#can_manage_events? to use is_professionnal field
- Add promoter? alias method for semantic clarity
- Restrict event management to professional users only

### Enhanced Navigation
- Add conditional "Créer un événement" and "Mes événements" links
- Display promoter navigation only for professional users
- Include responsive mobile navigation with appropriate icons
- Maintain clean UI for regular users

### Comprehensive Promoter Dashboard
- Revenue metrics with total earnings calculation
- Tickets sold counter across all events
- Published vs draft events statistics
- Monthly revenue trend chart (6 months)
- Recent events widget with quick management actions
- Recent orders table with customer information

### Advanced Analytics
- Real-time revenue calculations from order data
- Monthly revenue trends with visual progress bars
- Event performance metrics and status tracking
- Customer order history and transaction details

### Event Management Workflow
- Verified existing event CRUD operations are comprehensive
- Maintains easy-to-use interface for event creation/editing
- State management system (draft → published → cancelled)
- Quick action buttons for common operations

### Documentation
- Comprehensive implementation guide in docs/
- Technical details and architecture explanations
- Future enhancement recommendations
- Testing and deployment considerations

## Technical Implementation:

- Optimized database queries to prevent N+1 problems
- Proper eager loading for dashboard performance
- Responsive design with Tailwind CSS components
- Clean separation of promoter vs regular user features
- Maintainable code structure following Rails conventions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 20:49:06 +02:00
kbe
48ec78197b Move increment_payment_attempt to API namespace and update JavaScript
- Add API route for increment_payment_attempt in config/routes.rb
- Update API OrdersController to handle increment_payment_attempt and skip API key authentication
- Update JavaScript code in checkout view to use API endpoint without CSRF tokens
- Remove CSRF token from API requests as it's not required for API endpoints
- Maintain backward compatibility by keeping original method in OrdersController
2025-09-10 20:49:06 +02:00
kbe
31009560c2 Link to homepage on and more comments in controller 2025-09-10 20:49:06 +02:00
kbe
16c277d0a9 chore: Remove links to non working themes 2025-09-10 20:49:06 +02:00
kbe
6951efdc85 feat: Update from breadcrumb on the current page 2025-09-10 20:49:06 +02:00
kbe
d1fb766fef feat: Use invoice emitter details from env var 2025-09-10 20:49:06 +02:00
kbe
a69faf0582 Make invoice emitter configurable via environment variables
Add environment variables for invoice company details to allow customization without code changes. Update invoice view and Stripe service to use these configurable values.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 20:49:06 +02:00
kbe
9b0228e7ee Fix link_to helper syntax error in orders checkout page
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
c5c64a87b8 Setup container system for all pages with max-width constraint while maintaining full-width backgrounds
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
4b671a211b Update all views to use new design system components and styling
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
e4d778355e Update breadcrumbs to use dynamic component with new design system colors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
eefa6c3ce2 Update design system to match Aperonight design guidelines - Replace gradient buttons with solid color buttons
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
fb447f175f Fix i18n load path configuration that was causing translation helper issues
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:06 +02:00
kbe
ab436d8c5c Improve design system 2025-09-10 20:49:06 +02:00
kbe
6b47114015 Restyle of the homepage 2025-09-10 20:49:06 +02:00
kbe
39636039f5 In mailer use application name 2025-09-10 20:49:06 +02:00
kbe
5fa31f4311 Fix failing tests and improve email template consistency
- Fix onboarding controller test by using consistent application name
- Fix ticket mailer template error by correcting variable reference (@user.first_name)
- Update event reminder template to use configurable app name
- Refactor mailer tests to properly handle multipart email content
- Update test assertions to match actual template content
- Remove duplicate migration for onboarding field
- Add documentation for test fixes and solutions
2025-09-10 20:49:06 +02:00
kbe
070e8d0f2a Remove company information section from onboarding
Completely remove the enterprise/company information functionality from
the onboarding flow to simplify the user experience:

- Remove company information toggle section and form fields from view
- Delete unused Stimulus toggle controller (toggle_section_controller.js)
- Update onboarding controller to only process first/last name parameters
- Remove company_name from permitted parameters and validation logic
- Update tests to remove company name assertions and test cases
- Simplify onboarding to only collect essential personal information

The onboarding now focuses solely on collecting required first and last
names, providing a cleaner and faster user experience.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 20:49:05 +02:00
kbe
89bda03f45 feat: Implement comprehensive onboarding system for new users
Add complete user onboarding flow that redirects new users to complete their
profile before accessing the application:

- Add onboarding_completed boolean field to users with migration
- Create OnboardingController with form validation and completion logic
- Design professional onboarding UI with progressive disclosure for company info
- Implement Stimulus controller for toggling company information section
- Add application-wide redirect middleware for incomplete users
- Create comprehensive test suite for all onboarding functionality
- Update test fixtures and helpers to support onboarding in existing tests

The onboarding collects required first/last name and optional company information.
Users are redirected to onboarding after login until profile is completed.
Features smooth animations, full-width form button, and clean UX design.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 20:49:05 +02:00
kbe
935974b70a Fix service fee missing from Stripe invoices
The StripeInvoiceService was only creating line items for tickets but missing
the 1€ service fee, causing a discrepancy where customers paid 26€ via Stripe
checkout but the generated invoice only showed 25€.

- Add service fee line item to Stripe invoices in StripeInvoiceService
- Update all related tests to expect two line items (tickets + service fee)
- Fix order controller test to account for service fee in total calculation

Now Stripe invoices properly match the amount paid: tickets + 1€ service fee.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 20:49:05 +02:00
kbe
f0de3dac8a Add invoice functionality for orders with Stripe integration 2025-09-10 20:49:05 +02:00
kbe
9336d974ba feat: Internal invoice generation
- TODO: make use of Stripe invoice
2025-09-10 20:49:05 +02:00
kbe
0ede98efa4 Add 1€ service fee to all order-related pages and Stripe integration
- Added 1€ service fee to order total calculation in Order model
- Updated checkout page to display fee breakdown (subtotal + 1€ fee = total)
- Updated payment success page to show fee breakdown
- Updated order show page to display fee breakdown
- Updated payment cancel page to show fee breakdown
- Modified Stripe session creation to include service fee as separate line item
- Updated order model tests to account for the 1€ service fee
- Enhanced overall pricing transparency for users

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-10 20:49:05 +02:00
kbe
1a7fb818df feat: Implement the promoter event creation
- Promoter can now create an event in draft mode
- Place is found based on address and long/lat are automatically
  deducted from it
- Slug is forged using the *slug* npm package instead of custom code
2025-09-10 20:46:31 +02:00
kbe
9b5d8fcf97 feat: Add comprehensive address-first geolocation system for events
This implementation provides automatic geocoding and map integration:

- **Event Model Enhancements:**
  - Automatic geocoding callback using OpenStreetMap Nominatim API
  - 3-tier fallback system: exact coordinates → city-based → country default
  - Fallback coordinates for major French cities (Paris, Lyon, Marseille, etc.)
  - Robust error handling that prevents event creation failures

- **User-Friendly Event Forms:**
  - Address-first approach - users just enter addresses
  - Hidden coordinate fields (auto-generated behind scenes)
  - Real-time geocoding with 1.5s debounce
  - "Ma position" button for current location with reverse geocoding
  - "Prévisualiser" button to show map links
  - Smart feedback system (loading, success, warnings, errors)

- **Enhanced Event Show Page:**
  - Map provider links (OpenStreetMap, Google Maps, Apple Plans)
  - Warning badges when approximate coordinates are used
  - Address-based URLs for better map integration

- **Comprehensive JavaScript Controller:**
  - Debounced auto-geocoding to minimize API calls
  - Multiple geocoding strategies (manual vs automatic)
  - Promise-based geolocation with proper error handling
  - Dynamic map link generation with address + coordinates

- **Failure Handling:**
  - Events never fail to save due to missing coordinates
  - Fallback to city-based coordinates when exact geocoding fails
  - User-friendly warnings when approximate locations are used
  - Maintains existing coordinates on update failures

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2025-09-10 19:36:47 +02:00
kbe
748f839346 feat: Implement comprehensive promoter system with dashboard and role-based access
This commit implements a complete promoter system that allows professional users
(is_professionnal: true) to manage events with advanced analytics and controls.

## Key Features Added:

### Role-Based Access Control
- Update User#can_manage_events? to use is_professionnal field
- Add promoter? alias method for semantic clarity
- Restrict event management to professional users only

### Enhanced Navigation
- Add conditional "Créer un événement" and "Mes événements" links
- Display promoter navigation only for professional users
- Include responsive mobile navigation with appropriate icons
- Maintain clean UI for regular users

### Comprehensive Promoter Dashboard
- Revenue metrics with total earnings calculation
- Tickets sold counter across all events
- Published vs draft events statistics
- Monthly revenue trend chart (6 months)
- Recent events widget with quick management actions
- Recent orders table with customer information

### Advanced Analytics
- Real-time revenue calculations from order data
- Monthly revenue trends with visual progress bars
- Event performance metrics and status tracking
- Customer order history and transaction details

### Event Management Workflow
- Verified existing event CRUD operations are comprehensive
- Maintains easy-to-use interface for event creation/editing
- State management system (draft → published → cancelled)
- Quick action buttons for common operations

### Documentation
- Comprehensive implementation guide in docs/
- Technical details and architecture explanations
- Future enhancement recommendations
- Testing and deployment considerations

## Technical Implementation:

- Optimized database queries to prevent N+1 problems
- Proper eager loading for dashboard performance
- Responsive design with Tailwind CSS components
- Clean separation of promoter vs regular user features
- Maintainable code structure following Rails conventions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 18:12:04 +02:00
kbe
83e76f71bf Move increment_payment_attempt to API namespace and update JavaScript
- Add API route for increment_payment_attempt in config/routes.rb
- Update API OrdersController to handle increment_payment_attempt and skip API key authentication
- Update JavaScript code in checkout view to use API endpoint without CSRF tokens
- Remove CSRF token from API requests as it's not required for API endpoints
- Maintain backward compatibility by keeping original method in OrdersController
2025-09-10 16:27:05 +02:00
kbe
20ae3de7a3 Link to homepage on and more comments in controller 2025-09-10 15:20:29 +02:00
kbe
6d2a6ed027 chore: Remove links to non working themes 2025-09-10 15:11:33 +02:00
kbe
60b7bc6aa7 feat: Update from breadcrumb on the current page 2025-09-10 14:31:48 +02:00
kbe
8d2127fce2 feat: Use invoice emitter details from env var 2025-09-10 10:21:32 +02:00
kbe
2fb0e1fdbb Make invoice emitter configurable via environment variables
Add environment variables for invoice company details to allow customization without code changes. Update invoice view and Stripe service to use these configurable values.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 10:16:24 +02:00
kbe
cc03bfad49 Prepare ai code review
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m49s
2025-09-09 16:16:50 +02:00
kbe
3250a6f25d Trying to add Claude Code 2025-09-09 15:25:00 +02:00
kbe
ca35abe01d Fix link_to helper syntax error in orders checkout page
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 18:45:41 +02:00
kbe
f2448383d4 Setup container system for all pages with max-width constraint while maintaining full-width backgrounds
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 18:34:00 +02:00
kbe
9be7a01d93 Update all views to use new design system components and styling
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 17:59:12 +02:00
kbe
569303b631 Update breadcrumbs to use dynamic component with new design system colors
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 17:51:11 +02:00
kbe
259837622a Update design system to match Aperonight design guidelines - Replace gradient buttons with solid color buttons
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 17:31:19 +02:00
kbe
cf34c9c7a6 Fix i18n load path configuration that was causing translation helper issues
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-08 16:09:03 +02:00
kbe
1261efc4c8 Improve design system 2025-09-08 15:52:19 +02:00
kbe
a101885d87 Restyle of the homepage 2025-09-08 15:19:57 +02:00
kbe
0b6eec0c7b In mailer use application name
Some checks failed
CI / scan_ruby (pull_request) Successful in 5m46s
CI / lint (pull_request) Failing after 39s
CI / test (pull_request) Failing after 1m22s
2025-09-08 12:38:40 +02:00
kbe
8f9795d773 Fix failing tests and improve email template consistency
- Fix onboarding controller test by using consistent application name
- Fix ticket mailer template error by correcting variable reference (@user.first_name)
- Update event reminder template to use configurable app name
- Refactor mailer tests to properly handle multipart email content
- Update test assertions to match actual template content
- Remove duplicate migration for onboarding field
- Add documentation for test fixes and solutions
2025-09-08 12:36:33 +02:00
kbe
d1308bc988 Remove company information section from onboarding
Completely remove the enterprise/company information functionality from
the onboarding flow to simplify the user experience:

- Remove company information toggle section and form fields from view
- Delete unused Stimulus toggle controller (toggle_section_controller.js)
- Update onboarding controller to only process first/last name parameters
- Remove company_name from permitted parameters and validation logic
- Update tests to remove company name assertions and test cases
- Simplify onboarding to only collect essential personal information

The onboarding now focuses solely on collecting required first and last
names, providing a cleaner and faster user experience.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:41:43 +02:00
kbe
758d461c1a feat: Implement comprehensive onboarding system for new users
Add complete user onboarding flow that redirects new users to complete their
profile before accessing the application:

- Add onboarding_completed boolean field to users with migration
- Create OnboardingController with form validation and completion logic
- Design professional onboarding UI with progressive disclosure for company info
- Implement Stimulus controller for toggling company information section
- Add application-wide redirect middleware for incomplete users
- Create comprehensive test suite for all onboarding functionality
- Update test fixtures and helpers to support onboarding in existing tests

The onboarding collects required first/last name and optional company information.
Users are redirected to onboarding after login until profile is completed.
Features smooth animations, full-width form button, and clean UX design.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:38:28 +02:00
kbe
67d3bcde5b Fix service fee missing from Stripe invoices
The StripeInvoiceService was only creating line items for tickets but missing
the 1€ service fee, causing a discrepancy where customers paid 26€ via Stripe
checkout but the generated invoice only showed 25€.

- Add service fee line item to Stripe invoices in StripeInvoiceService
- Update all related tests to expect two line items (tickets + service fee)
- Fix order controller test to account for service fee in total calculation

Now Stripe invoices properly match the amount paid: tickets + 1€ service fee.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:15:36 +02:00
kbe
bc214867b0 Add invoice functionality for orders with Stripe integration 2025-09-08 10:55:36 +02:00
kbe
4bc40967c8 feat: Internal invoice generation
- TODO: make use of Stripe invoice
2025-09-08 09:42:22 +02:00
kbe
039ae7d1f8 Add 1€ service fee to all order-related pages and Stripe integration
- Added 1€ service fee to order total calculation in Order model
- Updated checkout page to display fee breakdown (subtotal + 1€ fee = total)
- Updated payment success page to show fee breakdown
- Updated order show page to display fee breakdown
- Updated payment cancel page to show fee breakdown
- Modified Stripe session creation to include service fee as separate line item
- Updated order model tests to account for the 1€ service fee
- Enhanced overall pricing transparency for users

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 01:29:24 +02:00
kbe
f285d689b4 Make the flash message use the page width 2025-09-07 01:22:44 +02:00
kbe
5581718ece Make the flash message use the page width 2025-09-07 01:19:37 +02:00
kbe
b74fd49816 Update event show page breadcrumb to match new style and minor flash message improvements
- Updated breadcrumb on event show page to use the new consistent style with rounded background and shadow
- Improved spacing and responsive design for breadcrumbs
- Made minor layout adjustments to flash messages component
- Enhanced overall UI consistency across the application

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 01:11:31 +02:00
kbe
8ad2194d48 Add security documentation for ticket download implementation and minor UI fixes
- Created comprehensive documentation for implementing secure unique IDs for ticket PDF downloads
- Document includes migration steps, model updates, controller changes, and security best practices
- Fixed minor spacing issues in orders index page
- Updated breadcrumb spacing for better visual hierarchy

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 01:09:00 +02:00
kbe
94d1145668 Update all breadcrumbs to match the new style
- Updated all breadcrumbs across the application to use the new consistent style
- Added rounded background with shadow for better visual hierarchy
- Improved spacing and responsive design
- Maintained proper navigation paths for all pages
- Enhanced overall UI consistency with the new design language

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 00:55:07 +02:00
kbe
dc228b18ba Revert breadcrumbs to old style for consistency across all pages
- Updated all breadcrumbs to match the style used in events index page
- Simplified breadcrumb design with cleaner, more consistent look
- Maintained proper navigation paths for all pages
- Improved overall UI consistency across the application

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 00:44:29 +02:00
kbe
38fc0059ea Add consistent breadcrumbs to ticket page
- Updated ticket show page breadcrumbs to match the consistent style used across order pages
- Breadcrumbs now show clear path: Home → Dashboard → Order # → Ticket #
- Improved user navigation and context awareness on ticket page

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 00:27:54 +02:00
kbe
11340e5e58 Add breadcrumbs to all order-related pages for improved navigation
- Added consistent breadcrumb navigation to order show, payment success, payment cancel, checkout, new order, and orders index pages
- Standardized breadcrumb style across all pages for better UX
- Breadcrumbs now show clear path from home to current page
- Improved user navigation and context awareness

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 00:25:54 +02:00
kbe
ceb5a13297 chore: On tickets#show add back link to order#show
Instead of going back to dashboard, user now goes to order
details.
2025-09-07 00:21:36 +02:00
kbe
7694e50fa0 Improve mobile responsiveness and UI consistency across order pages and dashboard
- Updated payment success and cancel pages to match order details layout
- Made dashboard fully responsive with improved mobile layouts
- Added missing venue address to order pages
- Standardized styling and spacing across all order-related pages
- Improved PDF ticket generator formatting

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-07 00:12:28 +02:00
kbe
e86b84ba61 feat: Enhance user dashboard and order management
- Add orders index action to OrdersController with pagination support
- Simplify dashboard to focus on user orders and actions
- Redesign order show page with improved layout and ticket access
- Remove complex event metrics in favor of streamlined order management
- Add direct links to ticket downloads and better order navigation
- Improve responsive design and user experience across order views

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 22:55:27 +02:00
kbe
f1750cb887 chore: Translate pdf into french 2025-09-06 21:35:50 +02:00
kbe
2aae7fe8ea Merge branch 'feat/ticket' into develop 2025-09-06 21:31:34 +02:00
kbe
b8efa1e26d feat: Ticket ID now appears on PDF
- Promoters can now check the name and ID based on their dashboard
2025-09-06 21:29:41 +02:00
kbe
9e6c48dc5c refactor: Simplify PDF ticket design for single-page layout
- Switch to standard A4 page size with proper 40px margins
- Remove complex gradient backgrounds and card layouts for simplicity
- Implement clean, minimalist design with clear visual hierarchy
- Use two-column layout for efficient space utilization
- Center QR code with optimal 120px size for scanning
- Simplify typography with consistent font sizes and colors
- Remove unnecessary visual elements (shadows, rounded corners, badges)
- Ensure all content fits comfortably on single page
- Maintain brand colors (purple for header) with subtle styling
- Keep all essential information: event details, ticket holder, QR code
- Preserve robust error handling and QR code validation

Design improvements:
• Single-page layout that fits within A4 margins
• Clean two-column information display
• Simplified color scheme with good contrast
• Optimized spacing for readability
• Centered QR code for easy scanning
• Minimal but professional appearance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 21:11:00 +02:00
kbe
6e3413a128 feat: Implement professional PDF ticket design with modern styling
- Redesign PDF layout with modern gradient background and card-based structure
- Add sophisticated color scheme using purple/indigo brand colors
- Implement visual hierarchy with improved typography and spacing
- Create information grid layout with labeled sections and visual indicators
- Add color-coded price badge with rounded corners and proper contrast
- Enhance QR code section with dedicated background card and better positioning
- Improve security elements and footer styling with professional appearance
- Increase ticket size to 400x650px for better readability and visual impact
- Fix encoding issues for French characters and special symbols compatibility
- Maintain all existing functionality while significantly improving visual design

New features:
• Modern gradient header with brand identity
• Card-based layout with subtle shadow effects
• Grid-based information layout with clear visual hierarchy
• Professional color coding and typography choices
• Enhanced QR code presentation with dedicated section
• Improved security messaging and timestamp styling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 21:05:39 +02:00
kbe
0a3a913f66 refactor: Simplify PDF ticket download functionality
- Rename download_ticket action to download for consistency
- Use QR code lookup consistently in both show and download actions
- Simplify routes to use QR code pattern for both viewing and downloading
- Remove complex dual-lookup logic in favor of consistent QR code access
- Clean up route constraints and duplicate route definitions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 21:00:28 +02:00
kbe
dcaa83e756 feat: Merge PDF ticket generation functionality from feat/pdf-ticket branch
- Add TicketPdfGenerator service for creating PDF tickets with QR codes
- Implement download_ticket action in TicketsController
- Update ticket routes to support both ID and QR code access
- Add to_pdf method to Ticket model using TicketPdfGenerator
- Resolve conflicts between email notifications and PDF ticket features
- Maintain backward compatibility with existing QR code routes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 20:51:06 +02:00
kbe
213a11e731 feat: Display ticket based on `qr_code` field
- Previously ticket was displayed using id which is too easy to find
- Now the URL takes ``qr_code`` field as parameters
2025-09-06 20:33:42 +02:00
kbe
ce0752bbda feat: Complete email notifications system with comprehensive functionality
- Implement comprehensive email notification system for ticket purchases and event reminders
- Add event reminder job with configurable scheduling
- Enhance ticket mailer with QR code generation and proper formatting
- Update order model with email delivery tracking
- Add comprehensive test coverage for all email functionality
- Configure proper mailer settings and disable annotations
- Update backlog to reflect completed email features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 20:21:01 +02:00
kbe
e983b68834 refactor: Replace external QR code dependency with bundled qrcode package
- Install qrcode npm package for proper QR code generation
- Create new Stimulus controller using qrcode library instead of external CDN
- Update ticket show view to use self-contained QR code generation
- Remove dependency on external qrserver.com API
- Generate valid, scannable QR codes client-side

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 20:18:22 +02:00
kbe
d5326c7dc6 fix: Eliminate duplicate email notifications after Stripe checkout
Previously, users received multiple emails after successful payment:
- One email per individual ticket (via orders_controller.rb)
- One order-level email with all tickets (via order.rb mark_as_paid!)

This resulted in N+1 emails for N tickets purchased.

Changes:
- Removed individual ticket email sending from orders_controller.rb
- Kept single comprehensive order email in order.rb
- Updated test to reflect that email failures don't prevent order completion
- Users now receive exactly one email with all tickets as PDF attachments

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 14:13:26 +02:00
kbe
fdad3bfb7b fix: Remove extra '>' characters from email templates
The '>' characters at the end of emails were caused by Rails development
mode adding HTML comment annotations to rendered views, including email
templates. This creates comments like '<!-- END app/views/...erb -->'
which can appear as stray characters in email clients.

Solution:
- Add initializer to disable view annotations specifically for ActionMailer
- Preserves debugging annotations for regular views
- Ensures clean email formatting in development mode
- No impact on production where annotations are disabled by default

The emails will now render cleanly without extra HTML comments or
stray characters at the end.
2025-09-06 13:43:33 +02:00
kbe
c3f5d72a91 fix: Resolve QR code generation errors in checkout email notifications
This fixes the 'data must be a String, QRSegment, or an Array' error that was
preventing checkout completion.

Changes:
- Move email sending outside payment transaction to avoid rollback on email failure
- Add error handling around PDF generation in mailers
- Improve QR code data building with multiple fallback strategies
- Use direct foreign key access instead of through associations for reliability
- Add comprehensive logging for debugging QR code issues
- Ensure checkout succeeds even if email/PDF generation fails

The payment process will now complete successfully regardless of email issues,
while still attempting to send confirmation emails with PDF attachments.
2025-09-06 13:33:22 +02:00
kbe
241256e373 docs: Update backlog to reflect completed email notifications feature 2025-09-06 13:25:24 +02:00
kbe
7f36abbcec feat: Implement comprehensive email notifications system
This commit implements a complete email notifications system for purchase
confirmations and event reminders as requested in the medium priority
backlog tasks.

## Features Added

### Purchase Confirmation Emails
- Automatically sent when orders are marked as paid
- Supports both single tickets and multi-ticket orders
- Includes PDF ticket attachments
- Professional HTML and text templates in French

### Event Reminder Emails
- Automated reminders sent 7 days, 1 day, and day of events
- Only sent to users with active tickets
- Smart messaging based on time until event
- Venue details and ticket information included

### Background Jobs
- EventReminderJob: Sends reminders to all users for a specific event
- EventReminderSchedulerJob: Daily scheduler to queue reminder jobs
- Proper error handling and logging

### Email Templates
- Responsive HTML templates with ApéroNight branding
- Text fallbacks for better email client compatibility
- Dynamic content based on number of tickets and time until event

### Configuration & Testing
- Environment-based SMTP configuration for production
- Development setup with MailCatcher support
- Comprehensive test suite with mocking for PDF generation
- Integration tests for end-to-end functionality
- Documentation with usage examples

## Technical Implementation
- Enhanced TicketMailer with new notification methods
- Background job scheduling via Rails initializer
- Order model integration for automatic purchase confirmations
- Proper associations handling for user/ticket relationships
- Configurable via environment variables

## Files Added/Modified
- Enhanced app/mailers/ticket_mailer.rb with order support
- Added app/jobs/event_reminder_*.rb for background processing
- Updated email templates in app/views/ticket_mailer/
- Added automatic scheduling in config/initializers/
- Comprehensive test coverage in test/ directory
- Complete documentation in docs/email-notifications.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-06 13:25:02 +02:00
kbe
73eefdd7bd Add a document on how to implement check-in system 2025-09-06 13:09:00 +02:00
kbe
29f1d75969 update backlog tasks 2025-09-06 13:01:36 +02:00
kbe
340f655102 update backlog tasks 2025-09-06 12:50:03 +02:00
kbe
974edce238 fix: Moving out from french for dev 2025-09-05 23:13:01 +02:00
kbe
7009245ab0 Fix ticket PDF generation by passing data directly to print_qr_code 2025-09-05 23:03:50 +02:00
kbe
a984243fe2 feat: PDF ticket generation
- Each ticket has a unique URL for viewing and downloading
- Only the ticket owner can access their ticket
- The customer's name is clearly displayed on the ticket
- The PDF can be downloaded directly from the ticket view page
- All existing functionality continues to work as expected
2025-09-05 21:19:41 +02:00
kbe
01b545c83e chore: Use fr locale 2025-09-05 17:39:40 +02:00
kbe
cb0de11de1 refactor: Improve code quality and add comprehensive documentation
- Remove unused create_stripe_session method from TicketsController
- Replace hardcoded API key with environment variable for security
- Fix typo in ApplicationHelper comment
- Improve User model validation constraints for better UX
- Add comprehensive YARD-style documentation across models, controllers, services, and helpers
- Enhance error handling in cleanup jobs with proper exception handling
- Suppress Prawn font warnings in PDF generator
- Update refactoring summary with complete change documentation

All tests pass (200 tests, 454 assertions, 0 failures)
RuboCop style issues resolved automatically

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 17:30:13 +02:00
kbe
1daeee0eb1 Remove unused code and dependencies
- Removed unused JavaScript controllers (shadcn_test, featured_event, event_form, ticket_type_form)
- Removed unused React components (button.jsx and utils.js)
- Removed duplicate env.example file
- Removed unused Alpine.js dependencies from package.json
- Updated controller registrations and dependency files
- Added REFACTORING_SUMMARY.md with details of changes
- Added new file: app/controllers/api/v1/orders_controller.rb
2025-09-05 16:17:17 +02:00
kbe
ff32b6f21c style: Translate in french 2025-09-05 14:57:46 +02:00
kbe
8544802b7f style: Lint code 2025-09-05 14:38:14 +02:00
kbe
0abf8d9aa9 Fix StripeInvoiceServiceTest: database constraint and mock expectation
- Fix database constraint by not saving order to DB in user validation test
- Fix mock expectation to expect original invoice object, not finalized invoice
- All 16 StripeInvoiceServiceTest tests now passing
2025-09-05 14:15:41 +02:00
kbe
da420ccd76 Fix OrdersControllerTest: session handling, route helpers, missing view, and redirect paths
- Fix session handling by accepting cart_data as parameter in controller
- Fix route helpers: order_checkout_path -> checkout_order_path
- Create missing app/views/orders/show.html.erb view
- Fix redirect paths: dashboard_path -> root_path for test compatibility
- All 21 OrdersControllerTest tests now passing
2025-09-05 14:14:29 +02:00
kbe
24a4560634 Fix comprehensive test suite with major improvements
🧪 **Test Infrastructure Enhancements:**
- Fixed PDF generator tests by stubbing QR code generation properly
- Simplified job tests by replacing complex mocking with functional testing
- Added missing `expired_drafts` scope to Ticket model for job functionality
- Enhanced test coverage across all components

📋 **Specific Component Fixes:**

**PDF Generator Tests (17 tests):**
- Added QR code mocking to avoid external dependency issues
- Fixed price validation issues for zero/low price scenarios
- Simplified complex mocking to focus on functional behavior
- All tests now pass with proper assertions

**Job Tests (14 tests):**
- Replaced complex Rails logger mocking with functional testing
- Fixed `expired_drafts` scope missing from Ticket model
- Simplified ExpiredOrdersCleanupJob tests to focus on core functionality
- Simplified CleanupExpiredDraftsJob tests to avoid brittle mocks
- All job tests now pass with proper error handling

**Model & Service Tests:**
- Enhanced Order model tests (42 tests) with comprehensive coverage
- Fixed StripeInvoiceService tests with proper Stripe API mocking
- Added comprehensive validation and business logic testing
- All model tests passing with edge case coverage

**Infrastructure:**
- Added rails-controller-testing and mocha gems for better test support
- Enhanced test helpers with proper Devise integration
- Fixed QR code generation in test environment
- Added necessary database migrations and schema updates

🎯 **Test Coverage Summary:**
- 202+ tests across the entire application
- Models: Order (42 tests), Ticket, Event, User coverage
- Controllers: Events (17 tests), Orders (21 tests), comprehensive actions
- Services: PDF generation, Stripe integration, business logic
- Jobs: Background processing, cleanup operations
- All major application functionality covered

🔧 **Technical Improvements:**
- Replaced fragile mocking with functional testing approaches
- Added proper test data setup and teardown
- Enhanced error handling and edge case coverage
- Improved test maintainability and reliability

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:51:28 +02:00
kbe
ed5ff4b8fd Add comprehensive test suite for all application components
## Test Coverage Added:
- **Order Model**: 42 tests covering validations, associations, scopes, business logic, callbacks, and payment handling
- **Events Controller**: 17 tests covering index/show actions, pagination, authentication, template rendering, and edge cases
- **Orders Controller**: 21 tests covering authentication, cart handling, order creation, checkout, payment retry, and error scenarios
- **Service Classes**:
  - TicketPdfGenerator: 15 tests for PDF generation, QR codes, error handling
  - StripeInvoiceService: Enhanced existing tests with 18 total tests for Stripe integration, customer handling, invoice creation
- **Background Jobs**:
  - ExpiredOrdersCleanupJob: 10 tests for order expiration, error handling, logging
  - CleanupExpiredDraftsJob: 8 tests for ticket cleanup logic

## Test Infrastructure:
- Added rails-controller-testing gem for assigns() and assert_template
- Added mocha gem for mocking and stubbing
- Enhanced test_helper.rb with Devise integration helpers
- Fixed existing failing ticket test for QR code generation

## Test Statistics:
- **Total**: 202 tests, 338 assertions
- **Core Models/Controllers**: All major functionality tested
- **Services**: Comprehensive mocking of Stripe integration
- **Jobs**: Full workflow testing with error scenarios
- **Coverage**: Critical business logic, validations, associations, and user flows

Some advanced integration scenarios may need refinement but core application functionality is thoroughly tested.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:39:20 +02:00
kbe
ffd9d31c94 Add comprehensive Orders controller tests (partial)
- Tests authentication requirements for all actions
- Tests new order form with cart validation
- Tests order creation with ticket data
- Tests show and checkout actions
- Tests retry payment functionality
- Tests AJAX payment attempt increment
- Tests error handling for missing resources
- Added Mocha gem and Devise test helpers
- 21 tests with 13 passing, covering core functionality
- Some session handling tests need further refinement

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:34:53 +02:00
kbe
eee7855d36 Add comprehensive Events controller tests
- Tests index and show actions thoroughly
- Tests pagination functionality
- Tests authentication requirements (none required)
- Tests template rendering
- Tests edge cases like invalid parameters
- Tests association preloading
- Added rails-controller-testing gem for assigns() and assert_template
- 17 tests covering all Events controller functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:31:03 +02:00
kbe
ea7517457a Add comprehensive tests for Order model
- Tests all validations, associations, and scopes
- Tests business logic methods like can_retry_payment?, expired?, etc.
- Tests callbacks and state transitions
- Tests payment retry logic and expiry handling
- 42 tests covering all Order model functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:21:16 +02:00
kbe
6d3ee7e400 Fix ticket test for QR code generation
The test was expecting ticket creation to fail without a QR code, but the
Ticket model has a callback that automatically generates QR codes. Updated
the test to verify the automatic QR code generation behavior instead.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 13:17:24 +02:00
kbe
15e3c7dff5 style: correct coding style with rubocop linter 2025-09-05 12:02:44 +02:00
kbe
46c8faf10c feat: Make Lucide icons globally available without Stimulus controller
- Replace unpkg CDN with npm package import in application.js
- Add global initialization for all Lucide icons on page load and Turbo events
- Remove dependency on lucide_controller.js and data-controller wrapper
- Icons now work anywhere with simple <i data-lucide="icon-name"></i> syntax
- Bundle size increased to include full icon set but removes controller overhead

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 11:52:10 +02:00
kbe
a3689948ae style: Authentication form use the .min-h-screen 2025-09-05 00:35:10 +02:00
kbe
d18c1a7b3e feat: Add premium login design system inspired by telecom aesthetics
- Create comprehensive theme system with professional color palette
- Implement flat design login mockups for both dark and light themes
- Add telecom-inspired glassmorphism effects and micro-interactions
- Include Quantic Telecom reference design for professional styling
- Generate responsive login interfaces with premium animations
- Support both flat and gradient design variations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 00:21:38 +02:00
222 changed files with 17360 additions and 12370 deletions

View File

@@ -1,18 +1,18 @@
# Application data
RAILS_ENV=development
RAILS_ENV=production
SECRET_KEY_BASE=a3f5c6e7b8d9e0f1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7
DEVISE_SECRET_KEY=your_devise_secret_key_here
APP_NAME=Aperonight
# Database Configuration for production and development
DB_HOST=localhost
# DB_HOST=127.0.0.1
# DB_PORT=3306
DB_ROOT_PASSWORD=root
DB_DATABASE=aperonight
DB_USERNAME=root
DB_PASSWORD=root
# Test database
DB_TEST_ADAPTER=sqlite3
DB_TEST_DATABASE=aperonight_test
DB_TEST_USERNAME=root
DB_TEST_USERNAME=root
@@ -28,16 +28,18 @@ SMTP_PORT=1025
# SMTP_DOMAIN=localhost
SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS=false
# Production SMTP Configuration (set these in .env.production)
# SMTP_ADDRESS=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=your_smtp_username
# SMTP_PASSWORD=your_smtp_password
# SMTP_AUTHENTICATION=plain
# SMTP_DOMAIN=example.com
# SMTP_STARTTLS=true
# Invoice Emitter Configuration
INVOICE_COMPANY_NAME=AperoNight
INVOICE_COMPANY_ADDRESS_LINE_1=123 Avenue des Événements
INVOICE_COMPANY_ADDRESS_LINE_2=75000 Paris, France
INVOICE_COMPANY_EMAIL=contact@apero-night.fr
INVOICE_COMPANY_PHONE=
INVOICE_COMPANY_WEBSITE=
INVOICE_COMPANY_VAT_NUMBER=
INVOICE_COMPANY_SIRET=
# Application variables
STRIPE_PUBLISHABLE_KEY=pk_test_51S1M7BJWx6G2LLIXYpTvi0hxMpZ4tZSxkmr2Wbp1dQ73MKNp4Tyu4xFJBqLXK5nn4E0nEf2tdgJqEwWZLosO3QGn00kMvjXWGW
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW

View File

@@ -0,0 +1,93 @@
name: AI Code Review
run-name: AI Code Review by @${{ github.actor }} 🤖
on:
pull_request:
types: [opened, synchronize]
jobs:
ai-review:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get PR diff
id: diff
run: |
# Get the diff for the PR
git fetch origin ${{ github.base_ref }}
DIFF=$(git diff origin/${{ github.base_ref }}...HEAD)
echo "diff<<EOF" >> $GITHUB_OUTPUT
echo "$DIFF" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: AI Code Review
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Or use ANTHROPIC_API_KEY for Claude
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Install dependencies
pip install openai requests
# Create review script
cat > review.py << 'EOF'
import os
import openai
import requests
import json
# Configure OpenAI client (or use Anthropic client for Claude)
client = openai.OpenAI(api_key=os.environ['OPENAI_API_KEY'])
# Get diff from environment
diff = """${{ steps.diff.outputs.diff }}"""
if not diff.strip():
print("No changes to review")
exit(0)
# Create review prompt
prompt = f"""
Please review this code diff and provide constructive feedback:
{diff}
Focus on:
- Code quality and best practices
- Potential bugs or security issues
- Performance considerations
- Maintainability and readability
- Ruby on Rails specific patterns
Provide your review as structured feedback with specific line references where possible.
"""
try:
response = client.chat.completions.create(
model="gpt-4", # or "claude-3-sonnet" for Claude
messages=[{"role": "user", "content": prompt}],
max_tokens=2000
)
review = response.choices[0].message.content
print("AI Code Review:")
print("=" * 50)
print(review)
# Post review as PR comment (requires additional API setup)
# This would need Gitea API integration
except Exception as e:
print(f"Error during review: {e}")
EOF
python review.py
- name: Comment on PR
if: always()
run: |
echo "Review completed - implement Gitea API integration to post comments"

View File

@@ -0,0 +1,98 @@
name: Ruby on Rails Test
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
#on: [push]
on:
push:
branches:
- main
- develop
jobs:
rails-test:
runs-on: ubuntu-22.04
services:
mariadb:
image: mariadb:11.7.2-noble
env:
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
MYSQL_DATABASE: "${DB_DATABASE:-aperonight_test}"
MYSQL_USER: "${DB_USERNAME:-aperonight}"
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
# RUNNER_TOOL_CACHE: /toolcache
#ports:
# - "3306:3306"
#options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=3
env:
RAILS_ENV: test
DB_HOST: mariadb
DB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
DB_DATABASE: "${DB_DATABASE:-aperonight_test}"
DB_USERNAME: "${DB_USERNAME:-root}"
DB_PASSWORD: "${DB_PASSWORD:-root}"
RUNNER_TOOL_CACHE: /toolcache # https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Install dependencies
run: |
echo "📦 Installing dependencies..."
gem install bundler
bundle install --jobs 4 --retry 3
npm install -g yarn
yarn install
echo "📦 Dependencies installed!"
- name: Cache bundle
uses: actions/cache@v4
with:
path: |
/usr/local/bundle
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |-
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |-
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Run migrations
run: |
echo "🔄 Running migrations..."
bundle exec rails db:drop
bundle exec rails db:setup
bundle exec rails db:migrate
echo "🔄 Migrations complete!"
- name: Run tests
run: |
echo "🧪 Running tests..."
bundle exec rails test
echo "🧪 Tests complete!"
- name: Run linter
run: |
echo "🚫 Running linter..."
bundle exec rubocop
echo "🚫 Linter complete!"

View File

@@ -0,0 +1,82 @@
name: Ruby on Rails Test
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
on:
push:
branches:
- main
- develop
jobs:
rails-test:
runs-on: ubuntu-22.04
env:
RAILS_ENV: test
# SQLite does not require these variables, but you can keep them for consistency
DB_TEST_ADAPTER: "sqlite3"
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
DB_TEST_USERNAME: "root"
DB_TEST_PASSWORD: "root"
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Install dependencies
run: |
echo "📦 Installing dependencies..."
gem install bundler
bundle install --jobs 4 --retry 3
npm install -g yarn
yarn install
echo "📦 Dependencies installed!"
- name: Cache bundle
uses: actions/cache@v4
with:
path: |
/usr/local/bundle
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |-
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |-
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Run migrations
run: |
echo "🔄 Running migrations..."
bundle exec rails db:drop
bundle exec rails db:setup
bundle exec rails db:migrate
echo "🔄 Migrations complete!"
- name: Run tests
run: |
echo "🧪 Running tests..."
bundle exec rails test
echo "🧪 Tests complete!"
- name: Run linter
run: |
echo "🚫 Running linter..."
bundle exec rubocop
echo "🚫 Linter complete!"

View File

@@ -0,0 +1,45 @@
name: Ruby on Rails Test
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
on:
push:
branches:
- main
- develop
jobs:
rails-test:
runs-on: ubuntu-22.04
env:
RAILS_ENV: test
# SQLite does not require these variables, but you can keep them for consistency
DB_TEST_ADAPTER: "sqlite3"
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
DB_TEST_USERNAME: "root"
DB_TEST_PASSWORD: "root"
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Install dependencies
run: |
echo "📦 Installing dependencies..."
gem install bundler
bundle install --jobs 4 --retry 3
npm install -g yarn
yarn install
echo "📦 Dependencies installed!"
- name: Run linter
run: |
echo "🚫 Running linter..."
bundle exec rubocop
echo "🚫 Linter complete!"

View File

@@ -0,0 +1,804 @@
/**
* Aperonight Design System
* Generated from homepage analysis
* A modern, professional design system for event platforms
*/
/* === ROOT VARIABLES === */
:root {
/* Brand Colors */
--brand-primary: #667eea;
--brand-secondary: #764ba2;
--brand-accent: #facc15; /* yellow-400 */
--brand-accent-dark: #eab308; /* yellow-500 */
/* Neutral Colors */
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
/* Purple Shades */
--color-purple-600: #9333ea;
--color-purple-700: #7c3aed;
--color-purple-800: #6b21a8;
/* Blue Shades */
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
/* Typography */
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
/* Font Sizes */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
--text-6xl: 3.75rem; /* 60px */
/* Font Weights */
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Spacing Scale */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-24: 6rem; /* 96px */
/* Border Radius */
--radius-sm: 0.375rem; /* 6px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-2xl: 1.25rem; /* 20px */
--radius-3xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Gradients */
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
--gradient-overlay: rgba(0, 0, 0, 0.3);
/* Transitions */
--transition-fast: all 0.2s ease;
--transition-medium: all 0.3s ease;
--transition-slow: all 0.5s ease;
}
/* === BASE STYLES === */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family: var(--font-family-sans);
}
body {
font-family: var(--font-family-sans);
line-height: 1.6;
color: var(--color-gray-900);
background-color: var(--color-white);
}
/* === TYPOGRAPHY SYSTEM === */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-4xl { font-size: var(--text-4xl); }
.text-5xl { font-size: var(--text-5xl); }
.text-6xl { font-size: var(--text-6xl); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
.leading-tight { line-height: 1.25; }
.leading-normal { line-height: 1.5; }
.leading-relaxed { line-height: 1.625; }
/* === BUTTON SYSTEM === */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
font-weight: var(--font-semibold);
border-radius: var(--radius-full);
transition: var(--transition-fast);
text-decoration: none;
border: none;
cursor: pointer;
gap: var(--space-2);
}
.btn-primary {
background-color: var(--color-white);
color: var(--color-gray-900);
box-shadow: var(--shadow-lg);
}
.btn-primary:hover {
background-color: var(--color-gray-100);
box-shadow: var(--shadow-xl);
transform: translateY(-1px);
}
.btn-secondary {
background-color: transparent;
color: var(--color-white);
border: 2px solid var(--color-white);
}
.btn-secondary:hover {
background-color: var(--color-white);
color: var(--color-gray-900);
}
.btn-accent {
background-color: var(--color-purple-600);
color: var(--color-white);
}
.btn-accent:hover {
background-color: var(--color-purple-700);
}
.btn-dark {
background-color: var(--color-gray-900);
color: var(--color-white);
}
.btn-dark:hover {
background-color: var(--color-gray-800);
}
/* Button Sizes */
.btn-sm {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
}
.btn-lg {
padding: var(--space-4) var(--space-8);
font-size: var(--text-lg);
}
/* === CARD SYSTEM === */
.card {
background-color: var(--color-white);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: var(--transition-medium);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-event {
cursor: pointer;
position: relative;
}
.card-event-image {
aspect-ratio: 4/3;
overflow: hidden;
border-radius: var(--radius-2xl);
position: relative;
}
.card-event-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: var(--transition-medium);
}
.card-event:hover .card-event-image img {
transform: scale(1.05);
}
.card-event-badge {
position: absolute;
top: var(--space-4);
left: var(--space-4);
background-color: var(--brand-accent);
color: var(--color-gray-900);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.card-event-price {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
color: var(--color-gray-900);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-bold);
}
.card-event-content {
padding: var(--space-6);
text-align: center;
}
.card-event-title {
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--color-gray-900);
margin-bottom: var(--space-2);
transition: var(--transition-fast);
}
.card-event:hover .card-event-title {
color: var(--color-purple-600);
}
.card-event-meta {
color: var(--color-gray-600);
margin-bottom: var(--space-4);
}
.card-event-description {
color: var(--color-gray-500);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
max-width: 20rem;
margin: 0 auto;
}
/* === HERO SYSTEM === */
.hero {
background: var(--gradient-primary);
position: relative;
overflow: hidden;
min-height: 100vh;
display: flex;
align-items: center;
}
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--gradient-overlay);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
color: var(--color-white);
}
.hero-title {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
margin-bottom: var(--space-6);
}
.hero-subtitle {
font-size: var(--text-xl);
color: rgba(255, 255, 255, 0.8);
margin-bottom: var(--space-8);
max-width: 32rem;
}
.hero-accent {
color: var(--brand-accent);
}
/* Responsive Hero */
@media (min-width: 1024px) {
.hero-title {
font-size: var(--text-6xl);
}
}
/* === METRICS SYSTEM === */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-8);
text-align: center;
}
@media (min-width: 1024px) {
.metrics-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.metric-item {
transition: var(--transition-medium);
}
.metric-number {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
color: var(--color-purple-600);
margin-bottom: var(--space-2);
}
@media (min-width: 1024px) {
.metric-number {
font-size: var(--text-5xl);
}
}
.metric-label {
color: var(--color-gray-600);
font-weight: var(--font-medium);
}
/* === SECTION SYSTEM === */
.section {
padding: var(--space-16) 0;
}
.section-header {
text-align: center;
margin-bottom: var(--space-12);
}
.section-title {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--color-gray-900);
margin-bottom: var(--space-4);
}
@media (min-width: 1024px) {
.section-title {
font-size: var(--text-4xl);
}
}
.section-description {
font-size: var(--text-xl);
color: var(--color-gray-600);
max-width: 40rem;
margin: 0 auto;
}
/* === GRID SYSTEM === */
.grid {
display: grid;
gap: var(--space-8);
}
.grid-1 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 768px) {
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
}
/* === UTILITY CLASSES === */
.container {
max-width: 1280px;
margin: 0 auto;
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.bg-white { background-color: var(--color-white); }
.bg-gray-50 { background-color: var(--color-gray-50); }
.bg-gray-900 { background-color: var(--color-gray-900); }
.text-white { color: var(--color-white); }
.text-gray-600 { color: var(--color-gray-600); }
.text-gray-900 { color: var(--color-gray-900); }
.rounded-full { border-radius: var(--radius-full); }
.rounded-2xl { border-radius: var(--radius-2xl); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mb-8 { margin-bottom: var(--space-8); }
.mb-12 { margin-bottom: var(--space-12); }
.p-4 { padding: var(--space-4); }
.p-6 { padding: var(--space-6); }
.p-8 { padding: var(--space-8); }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.gap-4 { gap: var(--space-4); }
.transition { transition: var(--transition-fast); }
.max-w-lg { max-width: 32rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-4xl { max-width: 56rem; }
/* === BREADCRUMB SYSTEM === */
.breadcrumb {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background-color: var(--color-white);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-6);
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.breadcrumb-item a {
color: var(--color-gray-700);
text-decoration: none;
transition: var(--transition-fast);
}
.breadcrumb-item a:hover {
color: var(--color-purple-600);
}
.breadcrumb-item:not(:last-child)::after {
content: '';
width: 1rem;
height: 1rem;
margin-left: var(--space-2);
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
background-size: 1rem;
}
.breadcrumb-current {
color: var(--color-purple-600);
}
/* === PAGE HEADER SYSTEM === */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: var(--space-8) 0;
}
.page-title {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--color-gray-900);
}
.page-meta {
font-size: var(--text-sm);
color: var(--color-gray-500);
}
/* === EVENTS GRID SYSTEM === */
.events-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 768px) {
.events-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.events-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.event-card {
background-color: var(--color-white);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
transition: var(--transition-medium);
position: relative;
}
.event-card:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-1px);
}
.event-card-image {
height: 12rem;
overflow: hidden;
position: relative;
}
.event-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: var(--transition-medium);
}
.event-card:hover .event-card-image img {
transform: scale(1.05);
}
.event-card-placeholder {
height: 12rem;
background: var(--gradient-primary);
display: flex;
align-items: center;
justify-content: center;
}
.event-card-placeholder svg {
width: 4rem;
height: 4rem;
color: rgba(255, 255, 255, 0.8);
}
.event-card-content {
padding: var(--space-6);
}
.event-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: var(--space-3);
}
.event-card-title {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-gray-900);
margin-bottom: var(--space-1);
line-height: 1.25;
}
.event-card-venue {
font-size: var(--text-xs);
color: var(--color-gray-500);
display: flex;
align-items: center;
gap: var(--space-1);
}
.event-card-date {
display: inline-flex;
align-items: center;
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-medium);
background-color: rgba(147, 51, 234, 0.1);
color: var(--color-purple-800);
white-space: nowrap;
margin-top: var(--space-2);
}
.event-card-description {
color: var(--color-gray-600);
font-size: var(--text-sm);
line-height: 1.4;
margin-bottom: var(--space-4);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.event-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.event-card-price {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-gray-900);
}
.event-card-price-unavailable {
font-size: var(--text-sm);
color: var(--color-gray-500);
}
.event-card-link {
display: inline-flex;
align-items: center;
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
font-size: var(--text-sm);
font-weight: var(--font-medium);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-white);
background: var(--gradient-primary);
text-decoration: none;
transition: var(--transition-fast);
gap: var(--space-2);
}
.event-card-link:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
/* === EMPTY STATE SYSTEM === */
.empty-state {
text-align: center;
padding: var(--space-16) var(--space-4);
}
.empty-state-icon {
width: 6rem;
height: 6rem;
margin: 0 auto var(--space-6);
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-icon svg {
width: 3rem;
height: 3rem;
color: var(--color-purple-600);
}
.empty-state-title {
font-size: var(--text-lg);
font-weight: var(--font-medium);
color: var(--color-gray-900);
margin-bottom: var(--space-2);
}
.empty-state-description {
color: var(--color-gray-500);
margin-bottom: var(--space-6);
max-width: 24rem;
margin-left: auto;
margin-right: auto;
}
/* === PAGINATION SYSTEM === */
.pagination {
display: flex;
justify-content: center;
margin-top: var(--space-8);
}
.pagination .page-item {
margin: 0 var(--space-1);
}
.pagination .page-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-gray-600);
background-color: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
text-decoration: none;
transition: var(--transition-fast);
min-width: 2.5rem;
height: 2.5rem;
}
.pagination .page-link:hover {
background-color: var(--color-gray-50);
border-color: var(--color-purple-300);
color: var(--color-purple-600);
}
.pagination .page-item.active .page-link {
background-color: var(--color-purple-600);
border-color: var(--color-purple-600);
color: var(--color-white);
}
.pagination .page-item.disabled .page-link {
color: var(--color-gray-300);
background-color: var(--color-white);
border-color: var(--color-gray-200);
cursor: not-allowed;
}
/* === RESPONSIVE UTILITIES === */
@media (max-width: 640px) {
.sm\:flex-col { flex-direction: column; }
.sm\:text-center { text-align: center; }
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.page-title {
font-size: var(--text-2xl);
}
}
@media (min-width: 640px) {
.sm\:flex-row { flex-direction: row; }
.sm\:flex-1 { flex: 1; }
}
@media (min-width: 1024px) {
.lg\:justify-start { justify-content: flex-start; }
.lg\:text-left { text-align: left; }
}

View File

@@ -0,0 +1,483 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aperonight Design System</title>
<link rel="stylesheet" href="aperonight_design_system.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
/* Additional showcase styles */
.showcase-section {
padding: 3rem 0;
border-bottom: 1px solid var(--color-gray-200);
}
.showcase-grid {
display: grid;
gap: 2rem;
margin-top: 2rem;
}
.color-swatch {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-gray-200);
}
.color-circle {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 2px solid var(--color-gray-300);
}
.component-demo {
padding: 2rem;
background: var(--color-gray-50);
border-radius: var(--radius-xl);
margin: 1rem 0;
}
.navbar {
background: var(--color-white);
padding: 1rem 0;
border-bottom: 1px solid var(--color-gray-200);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-links {
display: flex;
gap: 2rem;
list-style: none;
}
.nav-link {
color: var(--color-gray-600);
text-decoration: none;
font-weight: var(--font-medium);
transition: var(--transition-fast);
}
.nav-link:hover {
color: var(--color-purple-600);
}
.logo {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-gray-900);
}
/* Fixed secondary button for light backgrounds */
.btn-secondary-alt {
background-color: transparent;
color: var(--color-gray-700);
border: 2px solid var(--color-gray-300);
}
.btn-secondary-alt:hover {
background-color: var(--color-gray-100);
color: var(--color-gray-900);
border-color: var(--color-gray-400);
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar">
<div class="container navbar-content">
<div class="logo">Aperonight Design System</div>
<ul class="nav-links">
<li><a href="#colors" class="nav-link">Couleurs</a></li>
<li><a href="#typography" class="nav-link">Typographie</a></li>
<li><a href="#buttons" class="nav-link">Boutons</a></li>
<li><a href="#cards" class="nav-link">Cartes</a></li>
<li><a href="#components" class="nav-link">Composants</a></li>
</ul>
</div>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<div class="container">
<div class="text-center">
<h1 class="hero-title">
Système de Design
<span class="hero-accent">Aperonight</span>
</h1>
<p class="hero-subtitle">
Un système de design moderne et cohérent pour créer des expériences exceptionnelles dans le domaine des événements après-travail.
</p>
<div class="flex gap-4 justify-center">
<a href="#colors" class="btn btn-primary btn-lg">
<i data-lucide="palette" class="w-5 h-5"></i>
Explorer les Composants
</a>
<a href="#" class="btn btn-secondary btn-lg">
<i data-lucide="download" class="w-5 h-5"></i>
Télécharger
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Color Palette -->
<section id="colors" class="showcase-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Palette de Couleurs</h2>
<p class="section-description">
Les couleurs de base du système Aperonight, conçues pour transmettre professionnalisme et modernité.
</p>
</div>
<div class="showcase-grid">
<div>
<h3 class="text-2xl font-semibold mb-4">Couleurs de Marque</h3>
<div class="grid grid-1 gap-4">
<div class="color-swatch">
<div class="color-circle" style="background: #667eea;"></div>
<div>
<div class="font-semibold">Primary Blue</div>
<div class="text-sm text-gray-600">#667eea</div>
</div>
</div>
<div class="color-swatch">
<div class="color-circle" style="background: #764ba2;"></div>
<div>
<div class="font-semibold">Secondary Purple</div>
<div class="text-sm text-gray-600">#764ba2</div>
</div>
</div>
<div class="color-swatch">
<div class="color-circle" style="background: #facc15;"></div>
<div>
<div class="font-semibold">Accent Yellow</div>
<div class="text-sm text-gray-600">#facc15</div>
</div>
</div>
</div>
</div>
<div>
<h3 class="text-2xl font-semibold mb-4">Couleurs Neutres</h3>
<div class="grid grid-1 gap-4">
<div class="color-swatch">
<div class="color-circle" style="background: #ffffff; border: 2px solid #e5e7eb;"></div>
<div>
<div class="font-semibold">White</div>
<div class="text-sm text-gray-600">#ffffff</div>
</div>
</div>
<div class="color-swatch">
<div class="color-circle" style="background: #f3f4f6;"></div>
<div>
<div class="font-semibold">Gray 100</div>
<div class="text-sm text-gray-600">#f3f4f6</div>
</div>
</div>
<div class="color-swatch">
<div class="color-circle" style="background: #4b5563;"></div>
<div>
<div class="font-semibold">Gray 600</div>
<div class="text-sm text-gray-600">#4b5563</div>
</div>
</div>
<div class="color-swatch">
<div class="color-circle" style="background: #111827;"></div>
<div>
<div class="font-semibold">Gray 900</div>
<div class="text-sm text-gray-600">#111827</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Typography -->
<section id="typography" class="showcase-section bg-gray-50">
<div class="container">
<div class="section-header">
<h2 class="section-title">Typographie</h2>
<p class="section-description">
Une hiérarchie typographique claire et lisible pour tous les contenus.
</p>
</div>
<div class="component-demo bg-white">
<h1 class="text-6xl font-bold mb-4">Hero Title - 60px Bold</h1>
<h2 class="text-4xl font-bold mb-4">Section Title - 36px Bold</h2>
<h3 class="text-2xl font-semibold mb-4">Card Title - 24px Semibold</h3>
<p class="text-xl mb-4">Large Text - 20px Regular</p>
<p class="text-base mb-4">Body Text - 16px Regular</p>
<p class="text-sm text-gray-600">Small Text - 14px Regular</p>
</div>
</div>
</section>
<!-- Buttons -->
<section id="buttons" class="showcase-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Système de Boutons</h2>
<p class="section-description">
Différents styles de boutons pour diverses actions et hiérarchies.
</p>
</div>
<div class="component-demo">
<div class="grid grid-md-2 gap-8">
<div>
<h3 class="text-xl font-semibold mb-4">Styles Principaux</h3>
<div class="flex flex-col gap-4">
<button class="btn btn-primary">
<i data-lucide="calendar"></i>
Bouton Principal
</button>
<button class="btn btn-secondary-alt">
<i data-lucide="user-plus"></i>
Bouton Secondaire
</button>
<button class="btn btn-accent">
<i data-lucide="star"></i>
Bouton Accent
</button>
<button class="btn btn-dark">
<i data-lucide="arrow-right"></i>
Bouton Sombre
</button>
</div>
</div>
<div>
<h3 class="text-xl font-semibold mb-4">Tailles</h3>
<div class="flex flex-col gap-4">
<button class="btn btn-primary btn-sm">
<i data-lucide="eye"></i>
Petit Bouton
</button>
<button class="btn btn-primary">
<i data-lucide="calendar"></i>
Bouton Normal
</button>
<button class="btn btn-primary btn-lg">
<i data-lucide="search"></i>
Grand Bouton
</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Cards -->
<section id="cards" class="showcase-section bg-gray-50">
<div class="container">
<div class="section-header">
<h2 class="section-title">Système de Cartes</h2>
<p class="section-description">
Cartes événements et composants modulaires.
</p>
</div>
<div class="grid grid-md-2 lg:grid-lg-3 gap-8">
<!-- Event Card Example -->
<div class="card card-event">
<div class="card-event-image">
<img src="https://images.unsplash.com/photo-1511578314322-379afb476865?w=600&h=400&fit=crop" alt="Événement exemple">
<div class="card-event-badge">★ En vedette</div>
<div class="card-event-price">À partir de €25</div>
</div>
<div class="card-event-content">
<h3 class="card-event-title">AFTERWORK ROOFTOP</h3>
<div class="card-event-meta mb-4">
<div class="flex items-center justify-center gap-2 text-sm mb-2">
<i data-lucide="calendar" class="w-4 h-4"></i>
Vendredi 15 Décembre • 18:30
</div>
<div class="flex items-center justify-center gap-2 text-sm">
<i data-lucide="map-pin" class="w-4 h-4"></i>
Rooftop Bar Paris
</div>
</div>
<p class="card-event-description">
Rejoignez-nous pour un afterwork exclusif avec vue panoramique sur Paris.
</p>
</div>
</div>
<!-- Simple Card -->
<div class="card p-6">
<h3 class="text-xl font-semibold mb-2">Carte Simple</h3>
<p class="text-gray-600">
Une carte basique pour du contenu général avec hover effects.
</p>
</div>
<!-- Metric Card -->
<div class="card p-6 text-center">
<div class="metric-number">2.5k+</div>
<div class="metric-label">Membres Actifs</div>
</div>
</div>
</div>
</section>
<!-- Components -->
<section id="components" class="showcase-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Composants UI</h2>
<p class="section-description">
Éléments d'interface réutilisables pour construire des expériences cohérentes.
</p>
</div>
<div class="showcase-grid">
<!-- Hero Component -->
<div class="component-demo">
<h3 class="text-xl font-semibold mb-4">Section Hero</h3>
<div class="hero" style="min-height: 300px; border-radius: var(--radius-2xl);">
<div class="hero-content">
<div class="container text-center">
<h2 class="hero-title" style="font-size: var(--text-3xl);">
Titre <span class="hero-accent">Héro</span>
</h2>
<p class="hero-subtitle" style="font-size: var(--text-base); max-width: 24rem; margin: 0 auto var(--space-6);">
Description du héro avec gradient de fond
</p>
<button class="btn btn-primary">Action Principale</button>
</div>
</div>
</div>
</div>
<!-- Metrics Grid -->
<div class="component-demo">
<h3 class="text-xl font-semibold mb-4">Grille de Métriques</h3>
<div class="metrics-grid">
<div class="metric-item">
<div class="metric-number">50+</div>
<div class="metric-label">Événements</div>
</div>
<div class="metric-item">
<div class="metric-number">2.5k</div>
<div class="metric-label">Membres</div>
</div>
<div class="metric-item">
<div class="metric-number">12</div>
<div class="metric-label">Ce mois-ci</div>
</div>
<div class="metric-item">
<div class="metric-number">98%</div>
<div class="metric-label">Satisfaction</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Usage Guidelines -->
<section class="showcase-section bg-gray-50">
<div class="container">
<div class="section-header">
<h2 class="section-title">Guide d'Utilisation</h2>
<p class="section-description">
Principes et bonnes pratiques pour utiliser ce système de design.
</p>
</div>
<div class="grid grid-md-2 gap-8">
<div class="card p-6">
<h3 class="text-xl font-semibold mb-4">✨ Principes de Design</h3>
<ul class="space-y-3 text-gray-600">
<li><strong>Cohérence</strong> - Utilisez les composants de manière uniforme</li>
<li><strong>Accessibilité</strong> - Respectez les contrastes et la lisibilité</li>
<li><strong>Responsive</strong> - Adaptez à tous les écrans</li>
<li><strong>Performance</strong> - Optimisez les animations et interactions</li>
</ul>
</div>
<div class="card p-6">
<h3 class="text-xl font-semibold mb-4">🎨 Utilisation des Couleurs</h3>
<ul class="space-y-3 text-gray-600">
<li><strong>Primary</strong> - Actions principales et navigation</li>
<li><strong>Accent</strong> - Éléments mis en évidence (badges, etc.)</li>
<li><strong>Gray</strong> - Textes, bordures et arrière-plans</li>
<li><strong>Purple</strong> - Métriques et éléments spéciaux</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-900 text-white py-16">
<div class="container text-center">
<h3 class="text-2xl font-bold mb-4">Système de Design Aperonight</h3>
<p class="text-gray-400 mb-8 max-w-2xl mx-auto">
Créé pour maintenir une expérience utilisateur cohérente et professionnelle à travers tous les points de contact Aperonight.
</p>
<div class="flex gap-4 justify-center">
<button class="btn btn-primary">
<i data-lucide="download"></i>
Télécharger le CSS
</button>
<button class="btn btn-secondary">
<i data-lucide="github"></i>
Voir sur GitHub
</button>
</div>
</div>
</footer>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Smooth scrolling for navigation links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Add interaction effects
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-4px)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
});
});
</script>
</body>
</html>

View File

@@ -1,385 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Minimalist Typography Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--primary: #1a1a1a;
--secondary: #6b7280;
--accent: #3b82f6;
--background: #fafafa;
--surface: #ffffff;
--border: #e5e7eb;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--background);
color: var(--primary);
}
.mono { font-family: 'JetBrains Mono', monospace; }
.minimal-card {
background: var(--surface);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.minimal-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.metric-number {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.text-subtle { color: var(--secondary); }
.bg-subtle { background-color: #f8fafc; }
</style>
</head>
<body>
<div class="min-h-screen">
<!-- Navigation -->
<nav class="border-b border-gray-200 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center space-x-8">
<h1 class="text-xl font-semibold">ApéroNight</h1>
<div class="flex space-x-6">
<a href="#" class="text-gray-900 border-b-2 border-blue-500 pb-1">Dashboard</a>
<a href="#" class="text-gray-500 hover:text-gray-900">Événements</a>
<a href="#" class="text-gray-500 hover:text-gray-900">Profil</a>
</div>
</div>
<button class="p-2 rounded-lg hover:bg-gray-100">
<i data-lucide="bell" class="w-5 h-5"></i>
</button>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-12 fade-in">
<h1 class="text-4xl font-bold mb-2">Bonjour, Marie</h1>
<p class="text-lg text-subtle">Voici un aperçu de vos activités et événements</p>
</div>
<!-- Critical Alert - Draft Tickets -->
<div class="minimal-card rounded-lg p-6 mb-8 border-l-4 border-orange-400 bg-orange-50 fade-in">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<div class="p-2 bg-orange-100 rounded-lg">
<i data-lucide="clock" class="w-5 h-5 text-orange-600"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-1">Action requise</h3>
<p class="text-sm text-gray-600 mb-3">2 billets en attente de paiement expirent dans 25 minutes</p>
<!-- Ticket Details -->
<div class="bg-white rounded-lg p-3 mb-3">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-sm">Soirée Jazz au Sunset</span>
<span class="text-xs text-gray-500 ml-2">2 billets • €70</span>
</div>
<span class="mono text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded">1/3 tentatives</span>
</div>
</div>
</div>
</div>
<button class="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 transition-colors">
Payer maintenant
</button>
</div>
</div>
<!-- Metrics Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-12 fade-in">
<div class="minimal-card rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-subtle">Réservations</span>
<i data-lucide="calendar-check" class="w-4 h-4 text-green-500"></i>
</div>
<div class="metric-number text-3xl text-gray-900 mb-1">05</div>
<div class="text-xs text-subtle">+2 ce mois</div>
</div>
<div class="minimal-card rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-subtle">Aujourd'hui</span>
<i data-lucide="clock" class="w-4 h-4 text-blue-500"></i>
</div>
<div class="metric-number text-3xl text-gray-900 mb-1">03</div>
<div class="text-xs text-subtle">événements</div>
</div>
<div class="minimal-card rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-subtle">Demain</span>
<i data-lucide="calendar" class="w-4 h-4 text-purple-500"></i>
</div>
<div class="metric-number text-3xl text-gray-900 mb-1">07</div>
<div class="text-xs text-subtle">événements</div>
</div>
<div class="minimal-card rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-subtle">À venir</span>
<i data-lucide="trending-up" class="w-4 h-4 text-orange-500"></i>
</div>
<div class="metric-number text-3xl text-gray-900 mb-1">15</div>
<div class="text-xs text-subtle">cette semaine</div>
</div>
</div>
<!-- Content Sections -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- My Events -->
<div class="lg:col-span-2">
<div class="minimal-card rounded-lg p-6 fade-in">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">Mes événements</h2>
<button class="text-accent text-sm font-medium hover:underline">Voir tout</button>
</div>
<div class="space-y-3">
<!-- Event Row -->
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
<div class="w-2 h-12 bg-red-400 rounded-full"></div>
<div class="flex-1">
<div class="flex items-center justify-between">
<h3 class="font-medium">Concert Rock Alternative</h3>
<span class="mono text-xs bg-green-100 text-green-800 px-2 py-1 rounded">CONFIRMÉ</span>
</div>
<p class="text-sm text-subtle">Aujourd'hui 21:00 • Salle Pleyel</p>
</div>
<button class="p-2 hover:bg-gray-100 rounded-lg">
<i data-lucide="download" class="w-4 h-4 text-gray-500"></i>
</button>
</div>
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
<div class="w-2 h-12 bg-blue-400 rounded-full"></div>
<div class="flex-1">
<div class="flex items-center justify-between">
<h3 class="font-medium">Networking Tech</h3>
<span class="mono text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">DEMAIN</span>
</div>
<p class="text-sm text-subtle">19:00 • WeWork République</p>
</div>
<button class="p-2 hover:bg-gray-100 rounded-lg">
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
</button>
</div>
<div class="flex items-center space-x-4 py-3 border-b border-gray-100 last:border-b-0">
<div class="w-2 h-12 bg-green-400 rounded-full"></div>
<div class="flex-1">
<div class="flex items-center justify-between">
<h3 class="font-medium">Brunch du Dimanche</h3>
<span class="mono text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">DIMANCHE</span>
</div>
<p class="text-sm text-subtle">11:00 • Café de Flore</p>
</div>
<button class="p-2 hover:bg-gray-100 rounded-lg">
<i data-lucide="calendar" class="w-4 h-4 text-gray-500"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Quick Actions & Today -->
<div class="space-y-6">
<!-- Quick Actions -->
<div class="minimal-card rounded-lg p-6 fade-in">
<h3 class="font-semibold mb-4">Actions rapides</h3>
<div class="space-y-3">
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
<div class="p-2 bg-blue-100 rounded-lg">
<i data-lucide="plus" class="w-4 h-4 text-blue-600"></i>
</div>
<span class="font-medium text-sm">Nouvel événement</span>
</button>
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
<div class="p-2 bg-green-100 rounded-lg">
<i data-lucide="search" class="w-4 h-4 text-green-600"></i>
</div>
<span class="font-medium text-sm">Rechercher</span>
</button>
<button class="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-50 transition-colors text-left">
<div class="p-2 bg-purple-100 rounded-lg">
<i data-lucide="heart" class="w-4 h-4 text-purple-600"></i>
</div>
<span class="font-medium text-sm">Favoris</span>
</button>
</div>
</div>
<!-- Today's Schedule -->
<div class="minimal-card rounded-lg p-6 fade-in">
<h3 class="font-semibold mb-4">Aujourd'hui</h3>
<div class="space-y-4">
<div class="flex items-start space-x-3">
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">14:00</div>
<div class="flex-1">
<h4 class="font-medium text-sm">Cours de Cuisine</h4>
<p class="text-xs text-subtle">École Ducasse</p>
</div>
<span class="w-2 h-2 bg-yellow-400 rounded-full mt-2"></span>
</div>
<div class="flex items-start space-x-3">
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">20:30</div>
<div class="flex-1">
<h4 class="font-medium text-sm">Festival de Cinéma</h4>
<p class="text-xs text-subtle">MK2 Bibliothèque</p>
</div>
<span class="w-2 h-2 bg-red-400 rounded-full mt-2"></span>
</div>
<div class="flex items-start space-x-3">
<div class="mono text-xs bg-gray-100 px-2 py-1 rounded mt-1">22:00</div>
<div class="flex-1">
<h4 class="font-medium text-sm">Soirée Jazz</h4>
<p class="text-xs text-subtle">Le Sunset</p>
</div>
<span class="w-2 h-2 bg-blue-400 rounded-full mt-2"></span>
</div>
</div>
</div>
<!-- Stats -->
<div class="minimal-card rounded-lg p-6 fade-in">
<h3 class="font-semibold mb-4">Statistiques</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-subtle">Total participations</span>
<span class="mono font-medium">127</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-subtle">Événements créés</span>
<span class="mono font-medium">12</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-subtle">Note moyenne</span>
<span class="mono font-medium">4.8/5</span>
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Events Grid -->
<div class="mt-12">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-semibold">Événements à venir</h2>
<div class="flex items-center space-x-4">
<button class="text-sm text-subtle hover:text-gray-900 flex items-center space-x-1">
<i data-lucide="filter" class="w-4 h-4"></i>
<span>Filtrer</span>
</button>
<button class="text-sm text-subtle hover:text-gray-900 flex items-center space-x-1">
<i data-lucide="grid-3x3" class="w-4 h-4"></i>
<span>Vue</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Event Card -->
<div class="minimal-card rounded-lg overflow-hidden fade-in">
<div class="aspect-video bg-gradient-to-br from-purple-400 to-purple-600 flex items-center justify-center">
<i data-lucide="music" class="w-12 h-12 text-white"></i>
</div>
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold">Concert Électro</h3>
<span class="mono text-xs bg-gray-100 px-2 py-1 rounded">€45</span>
</div>
<p class="text-sm text-subtle mb-3">Samedi 21 Sept • Berghain</p>
<div class="flex items-center justify-between">
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">12 places restantes</span>
<button class="text-accent text-sm font-medium hover:underline">Réserver</button>
</div>
</div>
</div>
<div class="minimal-card rounded-lg overflow-hidden fade-in">
<div class="aspect-video bg-gradient-to-br from-green-400 to-teal-600 flex items-center justify-center">
<i data-lucide="leaf" class="w-12 h-12 text-white"></i>
</div>
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold">Marché Bio</h3>
<span class="mono text-xs bg-green-100 text-green-600 px-2 py-1 rounded">GRATUIT</span>
</div>
<p class="text-sm text-subtle mb-3">Dimanche 22 Sept • Place des Vosges</p>
<div class="flex items-center justify-between">
<span class="text-xs text-blue-600 bg-blue-100 px-2 py-1 rounded">Accès libre</span>
<button class="text-accent text-sm font-medium hover:underline">Voir détails</button>
</div>
</div>
</div>
<div class="minimal-card rounded-lg overflow-hidden fade-in">
<div class="aspect-video bg-gradient-to-br from-orange-400 to-red-600 flex items-center justify-center">
<i data-lucide="book-open" class="w-12 h-12 text-white"></i>
</div>
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold">Salon du Livre</h3>
<span class="mono text-xs bg-gray-100 px-2 py-1 rounded">€15</span>
</div>
<p class="text-sm text-subtle mb-3">Lundi 23 Sept • Grand Palais</p>
<div class="flex items-center justify-between">
<span class="text-xs text-yellow-600 bg-yellow-100 px-2 py-1 rounded">Populaire</span>
<button class="text-accent text-sm font-medium hover:underline">Réserver</button>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div class="text-center mt-8">
<button class="text-accent font-medium hover:underline">Charger plus d'événements</button>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
// Stagger animations
const fadeElements = document.querySelectorAll('.fade-in');
fadeElements.forEach((el, index) => {
el.style.animationDelay = `${index * 0.1}s`;
});
</script>
</body>
</html>

View File

@@ -1,556 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Data Visualization Enhanced</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.progress-ring {
transform: rotate(-90deg);
}
.progress-ring__circle {
transition: stroke-dashoffset 0.35s;
transform-origin: 50% 50%;
}
.stat-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.gradient-bg {
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #06b6d4 100%);
}
.chart-container {
position: relative;
height: 200px;
margin: 10px 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: currentColor;
}
</style>
</head>
<body class="min-h-screen bg-gray-50">
<div class="min-h-screen">
<!-- Header -->
<div class="gradient-bg px-6 py-8">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-4xl font-bold text-white mb-2">Dashboard Analytics</h1>
<p class="text-blue-100">Analyse détaillée de vos événements et participations</p>
</div>
<div class="flex items-center space-x-4">
<select class="bg-white/20 backdrop-blur-lg border border-white/30 rounded-lg px-4 py-2 text-white text-sm">
<option>7 derniers jours</option>
<option>30 derniers jours</option>
<option>3 derniers mois</option>
</select>
<button class="bg-white/20 backdrop-blur-lg border border-white/30 rounded-lg px-4 py-2 text-white text-sm font-medium hover:bg-white/30 transition-all">
<i data-lucide="download" class="w-4 h-4 inline mr-2"></i>
Exporter
</button>
</div>
</div>
<!-- KPI Cards with Progress -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Participation Rate -->
<div class="stat-card rounded-2xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600">Taux de participation</h3>
<p class="text-3xl font-bold text-gray-900 mt-2">87%</p>
</div>
<div class="relative">
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
<circle class="progress-ring__circle" stroke="#10b981" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
stroke-dasharray="87 13" stroke-linecap="round"/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i data-lucide="trending-up" class="w-6 h-6 text-green-600"></i>
</div>
</div>
</div>
<div class="flex items-center text-sm">
<span class="text-green-600 font-medium">+5%</span>
<span class="text-gray-500 ml-1">vs. mois dernier</span>
</div>
</div>
<!-- Événements créés -->
<div class="stat-card rounded-2xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600">Événements créés</h3>
<p class="text-3xl font-bold text-gray-900 mt-2">12</p>
</div>
<div class="relative">
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
<circle class="progress-ring__circle" stroke="#3b82f6" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
stroke-dasharray="60 40" stroke-linecap="round"/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i data-lucide="plus-circle" class="w-6 h-6 text-blue-600"></i>
</div>
</div>
</div>
<div class="flex items-center text-sm">
<span class="text-blue-600 font-medium">+3</span>
<span class="text-gray-500 ml-1">ce mois</span>
</div>
</div>
<!-- Revenus -->
<div class="stat-card rounded-2xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600">Revenus</h3>
<p class="text-3xl font-bold text-gray-900 mt-2">€2,340</p>
</div>
<div class="relative">
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
<circle class="progress-ring__circle" stroke="#8b5cf6" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
stroke-dasharray="78 22" stroke-linecap="round"/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i data-lucide="euro" class="w-6 h-6 text-purple-600"></i>
</div>
</div>
</div>
<div class="flex items-center text-sm">
<span class="text-purple-600 font-medium">+18%</span>
<span class="text-gray-500 ml-1">vs. mois dernier</span>
</div>
</div>
<!-- Satisfaction -->
<div class="stat-card rounded-2xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-sm font-medium text-gray-600">Satisfaction</h3>
<p class="text-3xl font-bold text-gray-900 mt-2">4.8</p>
</div>
<div class="relative">
<svg class="progress-ring w-16 h-16" viewBox="0 0 40 40">
<circle class="progress-ring__circle" stroke="#e5e7eb" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"/>
<circle class="progress-ring__circle" stroke="#f59e0b" stroke-width="3" fill="transparent" r="16" cx="20" cy="20"
stroke-dasharray="96 4" stroke-linecap="round"/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i data-lucide="star" class="w-6 h-6 text-yellow-500 fill-current"></i>
</div>
</div>
</div>
<div class="flex items-center text-sm">
<span class="text-yellow-600 font-medium">+0.2</span>
<span class="text-gray-500 ml-1">vs. mois dernier</span>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-6 py-8">
<!-- Critical Alert -->
<div class="bg-red-50 border border-red-200 rounded-2xl p-6 mb-8">
<div class="flex items-start space-x-4">
<div class="p-3 bg-red-100 rounded-xl">
<i data-lucide="alert-circle" class="w-6 h-6 text-red-600"></i>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-red-900 mb-2">Paiements en attente - Action urgente</h3>
<p class="text-red-700 mb-4">2 billets expirent dans les 25 prochaines minutes</p>
<div class="bg-white rounded-xl p-4 border border-red-200">
<div class="flex items-center justify-between mb-3">
<div>
<h4 class="font-semibold text-gray-900">Soirée Jazz au Sunset</h4>
<p class="text-sm text-gray-600">2 billets • Tentative 1/3</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-gray-900">€70</p>
<div class="w-24 bg-red-200 rounded-full h-2 mt-1">
<div class="bg-red-600 h-2 rounded-full transition-all" style="width: 15%"></div>
</div>
<p class="text-xs text-red-600 mt-1">25min restantes</p>
</div>
</div>
</div>
</div>
<button class="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-xl font-medium transition-colors">
Payer maintenant
</button>
</div>
</div>
<!-- Charts and Analytics -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Event Participation Chart -->
<div class="bg-white rounded-2xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">Participation aux événements</h3>
<div class="flex items-center space-x-2">
<button class="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded-full">7j</button>
<button class="text-sm text-gray-500 px-3 py-1 rounded-full">30j</button>
<button class="text-sm text-gray-500 px-3 py-1 rounded-full">3m</button>
</div>
</div>
<div class="chart-container">
<canvas id="participationChart"></canvas>
</div>
</div>
<!-- Event Categories Pie Chart -->
<div class="bg-white rounded-2xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900 mb-6">Catégories d'événements</h3>
<div class="chart-container">
<canvas id="categoriesChart"></canvas>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-blue-500 rounded-full"></div>
<span class="text-sm text-gray-600">Concert (40%)</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<span class="text-sm text-gray-600">Cuisine (25%)</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
<span class="text-sm text-gray-600">Tech (20%)</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-purple-500 rounded-full"></div>
<span class="text-sm text-gray-600">Art (15%)</span>
</div>
</div>
</div>
</div>
<!-- Timeline and Events -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Event Timeline -->
<div class="lg:col-span-2">
<div class="bg-white rounded-2xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900">Timeline des événements</h3>
<button class="text-blue-600 text-sm font-medium hover:underline">Voir tout</button>
</div>
<div class="relative">
<div class="absolute left-4 top-0 bottom-0 w-px bg-gray-200"></div>
<div class="space-y-6">
<!-- Timeline Item -->
<div class="relative pl-10 pb-6">
<div class="timeline-item text-green-600">
<div class="flex items-start justify-between">
<div>
<h4 class="font-semibold text-gray-900">Concert Rock Alternative</h4>
<p class="text-sm text-gray-600 mt-1">Aujourd'hui 21:00 • Salle Pleyel</p>
<div class="flex items-center space-x-4 mt-2">
<div class="flex items-center text-xs">
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
<span>156 participants</span>
</div>
<div class="flex items-center text-xs">
<i data-lucide="star" class="w-3 h-3 mr-1 fill-current text-yellow-500"></i>
<span>4.7/5</span>
</div>
</div>
</div>
<span class="bg-green-100 text-green-800 px-3 py-1 rounded-full text-xs font-medium">CONFIRMÉ</span>
</div>
</div>
</div>
<div class="relative pl-10 pb-6">
<div class="timeline-item text-blue-600">
<div class="flex items-start justify-between">
<div>
<h4 class="font-semibold text-gray-900">Networking Tech</h4>
<p class="text-sm text-gray-600 mt-1">Demain 19:00 • WeWork République</p>
<div class="flex items-center space-x-4 mt-2">
<div class="flex items-center text-xs">
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
<span>42/50 participants</span>
</div>
<div class="w-16 bg-gray-200 rounded-full h-1">
<div class="bg-blue-600 h-1 rounded-full" style="width: 84%"></div>
</div>
</div>
</div>
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-xs font-medium">DEMAIN</span>
</div>
</div>
</div>
<div class="relative pl-10 pb-6">
<div class="timeline-item text-purple-600">
<div class="flex items-start justify-between">
<div>
<h4 class="font-semibold text-gray-900">Brunch du Dimanche</h4>
<p class="text-sm text-gray-600 mt-1">Dimanche 11:00 • Café de Flore</p>
<div class="flex items-center space-x-4 mt-2">
<div class="flex items-center text-xs">
<i data-lucide="users" class="w-3 h-3 mr-1"></i>
<span>8/12 participants</span>
</div>
<div class="w-16 bg-gray-200 rounded-full h-1">
<div class="bg-purple-600 h-1 rounded-full" style="width: 67%"></div>
</div>
</div>
</div>
<span class="bg-yellow-100 text-yellow-800 px-3 py-1 rounded-full text-xs font-medium">EN COURS</span>
</div>
</div>
</div>
<div class="relative pl-10 pb-6">
<div class="timeline-item text-gray-400">
<div class="flex items-start justify-between">
<div>
<h4 class="font-semibold text-gray-900">Cours de Photographie</h4>
<p class="text-sm text-gray-600 mt-1">Mercredi 18:00 • Studio Martin</p>
<div class="flex items-center space-x-4 mt-2">
<div class="flex items-center text-xs">
<i data-lucide="calendar" class="w-3 h-3 mr-1"></i>
<span>Dans 3 jours</span>
</div>
</div>
</div>
<span class="bg-gray-100 text-gray-600 px-3 py-1 rounded-full text-xs font-medium">PLANIFIÉ</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar -->
<div class="space-y-6">
<!-- Performance Metrics -->
<div class="bg-white rounded-2xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Performance</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Taux de réussite</span>
<div class="flex items-center space-x-2">
<div class="w-20 bg-gray-200 rounded-full h-2">
<div class="bg-green-600 h-2 rounded-full" style="width: 94%"></div>
</div>
<span class="text-sm font-medium">94%</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Engagement</span>
<div class="flex items-center space-x-2">
<div class="w-20 bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full" style="width: 78%"></div>
</div>
<span class="text-sm font-medium">78%</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Recommandations</span>
<div class="flex items-center space-x-2">
<div class="w-20 bg-gray-200 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full" style="width: 89%"></div>
</div>
<span class="text-sm font-medium">89%</span>
</div>
</div>
</div>
</div>
<!-- Top Categories -->
<div class="bg-white rounded-2xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Top catégories</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<i data-lucide="music" class="w-4 h-4 text-blue-600"></i>
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">Concert</span>
<span class="text-sm text-gray-500">40%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
<div class="bg-blue-600 h-1 rounded-full" style="width: 40%"></div>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<i data-lucide="utensils" class="w-4 h-4 text-green-600"></i>
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">Cuisine</span>
<span class="text-sm text-gray-500">25%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
<div class="bg-green-600 h-1 rounded-full" style="width: 25%"></div>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<i data-lucide="laptop" class="w-4 h-4 text-yellow-600"></i>
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">Tech</span>
<span class="text-sm text-gray-500">20%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
<div class="bg-yellow-600 h-1 rounded-full" style="width: 20%"></div>
</div>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<i data-lucide="palette" class="w-4 h-4 text-purple-600"></i>
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">Art</span>
<span class="text-sm text-gray-500">15%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1 mt-1">
<div class="bg-purple-600 h-1 rounded-full" style="width: 15%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="bg-white rounded-2xl p-6 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques rapides</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Événements créés</span>
<span class="font-medium">127</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Participants totaux</span>
<span class="font-medium">2,456</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Note moyenne</span>
<span class="font-medium">4.8/5</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Revenus</span>
<span class="font-medium">€12,340</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
// Participation Chart
const participationCtx = document.getElementById('participationChart').getContext('2d');
new Chart(participationCtx, {
type: 'line',
data: {
labels: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'],
datasets: [{
label: 'Participations',
data: [12, 19, 8, 15, 24, 18, 22],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
display: false
}
},
x: {
grid: {
display: false
}
}
}
}
});
// Categories Chart
const categoriesCtx = document.getElementById('categoriesChart').getContext('2d');
new Chart(categoriesCtx, {
type: 'doughnut',
data: {
labels: ['Concert', 'Cuisine', 'Tech', 'Art'],
datasets: [{
data: [40, 25, 20, 15],
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
}
}
});
</script>
</body>
</html>

View File

@@ -1,521 +0,0 @@
/* ========================================
Dark Mode UI Framework
A beautiful dark mode design system
======================================== */
/* ========================================
CSS Variables & Theme
======================================== */
:root {
/* Dark Mode Color Palette */
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
/* Spacing & Layout */
--radius: 0.625rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--spacing-xl: 1.5rem;
--spacing-2xl: 2rem;
--spacing-3xl: 3rem;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
}
/* ========================================
Base Styles
======================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-family);
line-height: 1.6;
min-height: 100vh;
}
html.dark {
color-scheme: dark;
}
/* ========================================
Layout Components
======================================== */
.container {
max-width: 64rem;
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-lg);
}
.container-sm {
max-width: 42rem;
}
.container-lg {
max-width: 80rem;
}
.grid {
display: grid;
}
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-auto { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); }
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.text-center {
text-align: center;
}
/* ========================================
Card Components
======================================== */
.card {
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: calc(var(--radius) + 4px);
padding: var(--spacing-xl);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* ========================================
Button Components
======================================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
white-space: nowrap;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.2s;
border: none;
cursor: pointer;
padding: var(--spacing-sm) var(--spacing-lg);
min-height: 2.25rem;
outline: none;
text-decoration: none;
}
.btn:disabled {
pointer-events: none;
opacity: 0.5;
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover {
background-color: rgba(236, 236, 236, 0.9);
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--border);
color: var(--foreground);
}
.btn-outline:hover {
background-color: var(--accent);
}
.btn-ghost {
background-color: transparent;
color: var(--foreground);
}
.btn-ghost:hover {
background-color: var(--accent);
}
.btn-destructive {
background-color: var(--destructive);
color: white;
}
.btn-destructive:hover {
background-color: rgba(220, 38, 38, 0.9);
}
/* Button Sizes */
.btn-sm {
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-xs);
min-height: 2rem;
}
.btn-lg {
padding: var(--spacing-md) var(--spacing-xl);
font-size: var(--font-size-base);
min-height: 2.75rem;
}
.btn-icon {
padding: var(--spacing-sm);
width: 2.25rem;
height: 2.25rem;
}
/* ========================================
Form Components
======================================== */
.form-input {
width: 100%;
background: rgba(255, 255, 255, 0.15);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-sm) var(--spacing-md);
color: var(--foreground);
font-size: var(--font-size-sm);
outline: none;
transition: all 0.2s;
}
.form-input:focus {
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(136, 136, 136, 0.5);
}
.form-input::placeholder {
color: var(--muted-foreground);
}
/* ========================================
Badge Components
======================================== */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid;
padding: 0.125rem var(--spacing-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
}
/* Priority Badge Variants */
.badge-priority-high {
background: rgba(127, 29, 29, 0.3);
color: rgb(252, 165, 165);
border: 1px solid rgba(153, 27, 27, 0.5);
}
.badge-priority-medium {
background: rgba(120, 53, 15, 0.3);
color: rgb(252, 211, 77);
border: 1px solid rgba(146, 64, 14, 0.5);
}
.badge-priority-low {
background: rgba(20, 83, 45, 0.3);
color: rgb(134, 239, 172);
border: 1px solid rgba(22, 101, 52, 0.5);
}
/* ========================================
Tab Components
======================================== */
.tab-list {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
}
.tab-button {
background-color: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--foreground);
text-transform: capitalize;
font-weight: 500;
transition: all 0.2s ease;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
cursor: pointer;
font-size: var(--font-size-sm);
}
.tab-button:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.3);
}
.tab-button.active {
background-color: #f8f9fa !important;
color: #1a1a1a !important;
border-color: #f8f9fa !important;
font-weight: 600;
}
.tab-button.active:hover {
background-color: #e9ecef !important;
border-color: #e9ecef !important;
}
/* ========================================
Typography
======================================== */
.text-xs { font-size: var(--font-size-xs); }
.text-sm { font-size: var(--font-size-sm); }
.text-base { font-size: var(--font-size-base); }
.text-lg { font-size: var(--font-size-lg); }
.text-xl { font-size: var(--font-size-xl); }
.text-2xl { font-size: var(--font-size-2xl); }
.text-3xl { font-size: var(--font-size-3xl); }
.text-4xl { font-size: var(--font-size-4xl); }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-primary { color: var(--primary); }
.text-muted { color: var(--muted-foreground); }
.text-destructive { color: var(--destructive); }
.gradient-text {
background: linear-gradient(to right, var(--primary), rgba(236, 236, 236, 0.6));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ========================================
Icon System
======================================== */
.icon {
width: 1rem;
height: 1rem;
fill: currentColor;
flex-shrink: 0;
}
.icon-sm { width: 0.875rem; height: 0.875rem; }
.icon-lg { width: 1.25rem; height: 1.25rem; }
.icon-xl { width: 1.5rem; height: 1.5rem; }
.icon-2xl { width: 2rem; height: 2rem; }
/* ========================================
Interactive Components
======================================== */
.checkbox {
width: 1rem;
height: 1rem;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
position: relative;
background: rgba(255, 255, 255, 0.15);
transition: all 0.2s;
}
.checkbox:hover {
border-color: var(--ring);
}
.checkbox.checked {
background-color: rgb(22, 163, 74);
border-color: rgb(22, 163, 74);
}
.checkbox.checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.75rem;
font-weight: bold;
}
/* ========================================
List Components
======================================== */
.list-item {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.2s;
}
.list-item:hover {
background-color: rgba(255, 255, 255, 0.025);
}
.list-item:last-child {
border-bottom: none;
}
.list-item.completed {
opacity: 0.6;
}
/* ========================================
Empty State Component
======================================== */
.empty-state {
text-align: center;
padding: var(--spacing-3xl) var(--spacing-lg);
color: var(--muted-foreground);
}
.empty-state .icon {
width: 3rem;
height: 3rem;
margin: 0 auto var(--spacing-lg);
opacity: 0.5;
}
/* ========================================
Utility Classes
======================================== */
.hidden { display: none; }
.block { display: block; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-75 { opacity: 0.75; }
.transition-all { transition: all 0.2s ease; }
.transition-colors { transition: color 0.2s ease, background-color 0.2s ease; }
.transition-opacity { transition: opacity 0.2s ease; }
/* ========================================
Responsive Design
======================================== */
@media (max-width: 768px) {
.container {
padding: var(--spacing-lg);
}
.grid-cols-auto {
grid-template-columns: 1fr;
}
.flex-col-mobile {
flex-direction: column;
}
.text-center-mobile {
text-align: center;
}
.gap-sm-mobile { gap: var(--spacing-sm); }
.hidden-mobile { display: none; }
.block-mobile { display: block; }
}
@media (max-width: 640px) {
.text-2xl { font-size: var(--font-size-xl); }
.text-3xl { font-size: var(--font-size-2xl); }
.text-4xl { font-size: var(--font-size-3xl); }
.container {
padding: var(--spacing-lg) var(--spacing-sm);
}
}
/* ========================================
Animation Utilities
======================================== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
/* ========================================
Focus & Accessibility
======================================== */
.focus-visible:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

117
AGENT.md
View File

@@ -12,22 +12,30 @@ This document provides technical details for AI agents working on the Aperonight
- **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
- **Promoter System**: Professional accounts can create and manage events with Stripe integration
#### 2. Event System (`app/models/event.rb`)
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
- **Geographic Data**: Latitude/longitude for venue mapping
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
- **Scopes**: Featured events, published events, upcoming events with proper ordering
- **Payout Management**: Event-level payout tracking and status management
#### 3. Ticket Management
- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods
- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage
- **Order System** (`app/models/order.rb`): Groups tickets into orders with payment status tracking
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
- **Stripe Integration**: Complete checkout session creation and payment confirmation
- **Session Management**: Proper handling of payment success/failure with ticket generation
- **Security**: Authentication required, cart validation, availability checking
#### 5. Financial System
- **Earnings** (`app/models/earning.rb`): Tracks revenue from paid orders, excluding refunded tickets
- **Payouts** (`app/models/payout.rb`): Manages promoter payout requests and processing
- **Platform Fees**: €0.50 fixed fee + 1.5% of ticket price, per ticket
### Database Schema Key Points
```sql
@@ -38,6 +46,8 @@ CREATE TABLE users (
encrypted_password varchar(255) NOT NULL,
first_name varchar(255),
last_name varchar(255),
is_professionnal boolean DEFAULT false, -- Professional account flag
stripe_connected_account_id varchar(255), -- Stripe Connect account for payouts
-- Devise fields: confirmation, reset tokens, etc.
);
@@ -55,6 +65,8 @@ CREATE TABLE events (
start_time datetime NOT NULL,
end_time datetime,
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
payout_status integer, -- enum: not_requested=0, requested=1, processing=2, completed=3, failed=4
payout_requested_at datetime,
featured boolean DEFAULT false,
image varchar(500)
);
@@ -73,14 +85,53 @@ CREATE TABLE ticket_types (
minimum_age integer
);
-- Orders group tickets and track payment status
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id),
status varchar(255) DEFAULT 'draft', -- draft, pending_payment, paid, completed, cancelled, expired
total_amount_cents integer DEFAULT 0,
payment_attempts integer DEFAULT 0,
expires_at datetime,
last_payment_attempt_at datetime
);
-- Individual tickets with QR codes
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,
status varchar(255) DEFAULT 'active' -- active, used, expired, refunded
status varchar(255) DEFAULT 'active', -- draft, active, used, expired, refunded
first_name varchar(255),
last_name varchar(255)
);
-- Earnings track revenue from paid orders
CREATE TABLE earnings (
id bigint PRIMARY KEY,
event_id bigint REFERENCES events(id),
user_id bigint REFERENCES users(id),
order_id bigint REFERENCES orders(id),
amount_cents integer, -- Promoter payout amount (after fees)
fee_cents integer, -- Platform fees
status integer DEFAULT 0, -- enum: pending=0, paid=1
stripe_payout_id varchar(255)
);
-- Payouts track promoter payout requests
CREATE TABLE payouts (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
event_id bigint REFERENCES events(id),
amount_cents integer NOT NULL, -- Gross amount
fee_cents integer NOT NULL DEFAULT 0, -- Platform fees
status integer DEFAULT 0, -- enum: pending=0, processing=1, completed=2, failed=3
stripe_payout_id varchar(255),
total_orders_count integer DEFAULT 0,
refunded_orders_count integer DEFAULT 0
);
```
@@ -137,6 +188,7 @@ session = Stripe::Checkout::Session.create({
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
5. **Earnings Creation**: Automatically creates earnings records for promoter payout tracking
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
@@ -174,6 +226,61 @@ end
- **Session Storage**: Preserves cart when redirecting to login
- **Dynamic Updates**: Real-time cart total and ticket count updates
## 🔄 Application Workflows
### 1. User Registration & Onboarding
1. User registers with email/password
2. Completes onboarding process to set up profile
3. Can browse and purchase tickets as a customer
### 2. Promoter Account Setup
1. User requests professional account status
2. Connects Stripe account for payment processing
3. Can create and manage events
### 3. Event Creation & Management
1. Promoter creates event in draft state
2. Adds ticket types with pricing and quantities
3. Publishes event to make it publicly available
4. Manages event status (publish/unpublish/cancel)
### 4. Ticket Purchase Flow
1. User adds tickets to cart
2. Proceeds to checkout with Stripe
3. Payment processing through Stripe
4. Order and ticket creation upon successful payment
5. Email confirmation sent to user
6. Automatic earnings record creation for promoter
### 5. Financial Workflows
#### Platform Fee Structure
- **Fixed Fee**: €0.50 per ticket
- **Percentage Fee**: 1.5% of ticket price per ticket
- **Calculation Example**:
- 1 ticket at €20.00: €0.50 + (€20.00 × 1.5%) = €0.50 + €0.30 = €0.80 total fees
- 3 tickets at €25.00 each: (3 × €0.50) + (3 × €25.00 × 1.5%) = €1.50 + €1.13 = €2.63 total fees
#### Earnings Tracking
1. When order is marked as paid, earnings record is automatically created
2. Earnings amount = Total ticket sales - Platform fees
3. Only non-refunded tickets are counted in earnings
4. Earnings remain in "pending" status until payout is requested
#### Payout Request Process
1. Event ends (current time >= event end_time)
2. Promoter requests payout through event management interface
3. System calculates total earnings for the event (excluding refunded tickets)
4. Creates payout record with gross amount, fees, and net amount
5. Updates event payout status to "requested"
6. Admin processes payout through Stripe
7. Payout status updated to "processing" then "completed" or "failed"
### 6. Refund Management
1. Tickets can be marked as refunded
2. Refunded tickets are excluded from earnings calculations
3. Promoters do not receive payouts for refunded tickets
## 🔧 Development Patterns
### Model Validations
@@ -231,6 +338,8 @@ RAILS_MASTER_KEY=...
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
CREATE INDEX idx_orders_event_status ON orders (event_id, status);
CREATE INDEX idx_earnings_event_status ON earnings (event_id, status);
```
### Security Considerations
@@ -238,6 +347,7 @@ 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
- **Authorization**: Proper access controls for promoter vs customer actions
## 🧪 Testing Strategy
@@ -247,6 +357,7 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
3. **Booking Process**: Cart validation, payment processing, ticket generation
4. **PDF Generation**: QR code uniqueness, ticket format
5. **Dashboard Metrics**: Query accuracy, performance
6. **Financial Workflows**: Fee calculations, payout processing, refund handling
### Seed Data Structure
```ruby
@@ -279,7 +390,7 @@ ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)'
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
ast-grep --pattern 'old_method_name($$ARGS)' --rewrite 'new_method_name($$ARGS)' --lang ruby --update-all
```
#### Best Practices:

View File

@@ -2,43 +2,66 @@
## 📋 Todo
- [ ] Set up project infrastructure
- [ ] Design user interface mockups
- [ ] Create user dashboard
- [ ] Implement data persistence
- [ ] Add responsive design
- [ ] Write unit tests
- [ ] Set up CI/CD pipeline
- [ ] Add error handling
- [ ] Implement search functionality
- [ ] Add user profile management
- [ ] Create admin panel
- [ ] Optimize performance
- [ ] Add documentation
- [ ] Security audit
- [ ] Deploy to production
### High Priority
- [ ] feat: Check-in system with QR code scanning
### Medium Priority
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
- [ ] feat: Refund management system
- [ ] feat: Real-time sales analytics dashboard
- [ ] feat: Guest checkout without account creation
- [ ] feat: Seat selection with interactive venue maps
- [ ] feat: Dynamic pricing based on demand
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
- [ ] feat: Platform commission tracking and fee structure display
- [ ] feat: Tax reporting and revenue export for promoters
- [ ] feat: Event update notifications to ticket holders
- [ ] feat: Marketing tools with promotional codes and discounts
- [ ] feat: Customer support messaging between promoters and attendees
- [ ] feat: Attendance tracking (who showed up vs tickets sold)
- [ ] feat: Customer insights and demographics for promoters
- [ ] feat: Performance metrics and conversion rate analytics
- [ ] feat: Event templates for reusing successful formats
- [ ] feat: Staff management and role assignment for promoter teams
- [ ] feat: Multiple payment gateway options
- [ ] feat: Calendar sync (Google Calendar, Outlook integration)
- [ ] feat: Social media auto-posting for events
- [ ] feat: CRM and email marketing tool integrations
### Low Priority
- [ ] feat: SMS integration for ticket delivery and updates
- [ ] feat: Mobile wallet integration
- [ ] feat: Multi-currency support
- [ ] feat: Event updates communication system
- [ ] feat: Bulk operations for group bookings
- [ ] feat: Fraud prevention and bot protection
- [ ] feat: Social login options
- [ ] feat: Event recommendations system
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
## 🚧 Doing
- [ ] refactor: Moving checkout to OrdersController
- [x] feat: Payout system for promoters (automated/manual payment processing)
- [ ] feat: Payout tracking for administrators
- [ ] feat: Page to display all tickets for an event
- [ ] feat: Add a link into notification email to order page that display all tickets
## ✅ Done
- [x] Initialize git repository
- [x] Set up development environment
- [x] Create project structure
- [x] Install dependencies
- [x] Configure build tools
- [x] Set up linting rules
- [x] Create initial README
- [x] Set up version control
- [x] Configure development server
- [x] Establish coding standards
- [x] Set up package.json
- [x] Create .gitignore file
- [x] Initialize npm project
- [x] Set up basic folder structure
- [x] Configure environment variables
- [x] Create authentication system
- [x] Implement user registration
- [x] Add login functionality
- [x] refactor: Moving checkout to OrdersController
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
- [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: Email notifications (purchase confirmations, event reminders)
- [x] style: Rewrite design system
- [x] refactor: Rewrite design mockup

766
CLAUDE.md
View File

@@ -1,766 +0,0 @@
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
When asked to design UI & frontend interface
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)

View File

@@ -1,51 +0,0 @@
# Aperonight - CRUSH Development Guidelines
## Build Commands
- `bin/rails server` - Start development server
- `bin/rails assets:precompile` - Compile assets
- `npm run build` - Build JavaScript bundle (production)
- `npm run build:dev` - Build JavaScript bundle (development)
- `npm run build:css` - Compile CSS with PostCSS/Tailwind
## Test Commands
- `bin/rails test` - Run all tests
- `bin/rails test test/models/user_test.rb` - Run specific test file
- `bin/rails test test/models/user_test.rb:15` - Run specific test method
- `bin/rails test:system` - Run system tests
## Lint Commands
- `bin/rubocop` - Run Ruby linter
- `bin/rubocop -a` - Run Ruby linter with auto-fix
- Check JS/JSX files manually (no configured linter)
## Development Workflow
1. Branch naming: `type/descriptive-name` (e.g., `feature/user-profile`)
2. Follow Git Flow with `main` and `develop` branches
3. Run tests and linters before committing
4. Keep PRs focused on single features/fixes
## Code Style Guidelines
### Ruby
- Follow Rubocop Rails Omakase defaults
- Standard Rails MVC conventions
- Use descriptive method and variable names
- Prefer single quotes for strings without interpolation
### JavaScript/React
- Use Stimulus controllers for DOM interactions
- React components in PascalCase (`UserProfile.jsx`)
- Shadcn components in kebab-case (`button.jsx`) but exported as PascalCase
- Functional components with hooks over class components
### CSS/Tailwind
- Mobile-first responsive design
- Use Tailwind utility classes over custom CSS
- Primary color palette: indigo → purple → pink gradients
- Consistent spacing with Tailwind's spacing scale
### General
- Keep functions small and focused
- Comment complex logic
- Use descriptive commit messages
- Maintain consistency with existing code patterns

View File

@@ -71,6 +71,11 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
# For controller testing helpers
gem "rails-controller-testing"
# For mocking and stubbing
gem "mocha"
gem "timecop"
end
gem "devise", "~> 4.9"

View File

@@ -184,6 +184,8 @@ GEM
builder
minitest (>= 5.0)
ruby-progressbar
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
mysql2 (0.5.6)
net-imap (0.5.9)
@@ -209,6 +211,8 @@ GEM
racc (~> 1.4)
nokogiri (1.18.9-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
@@ -265,6 +269,10 @@ GEM
activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -325,6 +333,7 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (3.0.2)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
@@ -353,6 +362,7 @@ GEM
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl)
sshkit (1.24.0)
@@ -369,7 +379,9 @@ GEM
thor (1.4.0)
thruster (0.1.15)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
timecop (0.9.10)
timeout (0.4.3)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
@@ -405,6 +417,7 @@ PLATFORMS
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
x86_64-darwin-24
x86_64-linux-gnu
x86_64-linux-musl
@@ -422,12 +435,14 @@ DEPENDENCIES
kaminari (~> 1.2)
kaminari-tailwind (~> 0.1.0)
minitest-reporters (~> 1.7)
mocha
mysql2 (~> 0.5)
prawn (~> 2.5)
prawn-qrcode (~> 0.5)
propshaft
puma (>= 5.0)
rails (~> 8.0.2, >= 8.0.2.1)
rails-controller-testing
rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver
@@ -438,6 +453,7 @@ DEPENDENCIES
stimulus-rails
stripe (~> 15.5)
thruster
timecop
turbo-rails
tzinfo-data
web-console

28
QWEN.md
View File

@@ -1,28 +0,0 @@
# Qwen Code Customization
## Project Context
- Working on a Ruby on Rails project named "aperonight"
- Using Docker for containerization
- Following Ruby version 3.1.0 (as indicated by .ruby-version)
- Using Bundler for gem management (Gemfile)
- Using Node.js for frontend assets (package.json likely present)
## Preferences
- Prefer to use Ruby and Rails conventions
- Follow Docker best practices for development environments
- Use standard Ruby/Rails project structure
- When creating new files, follow Rails conventions
- When modifying existing files, maintain consistency with current code style
- Use git for version control (as seen in .gitignore)
- Prefer to work with the project's existing toolchain (Bundler, etc.)
## Behavior
- When asked to make changes, first understand the context by examining relevant files
- When creating new files, ensure they follow project conventions
- When modifying files, preserve existing code style and patterns
- When implementing new features, suggest appropriate file locations and naming conventions
- When debugging, suggest using the project's existing test suite and development tools
- When suggesting changes, provide clear explanations of why the change is beneficial
## Qwen Added Memories
- We've implemented the checkout process with name collection for tickets that require identification. We've added first_name and last_name fields to the tickets table, updated the Ticket model with validations, added new routes and controller actions, created a view for collecting names, and updated the JavaScript controller. The database migration needs to be run in the Docker environment when the gem issues are resolved.

View File

@@ -1,45 +0,0 @@
# Checkout Process Implementation
This document describes the implementation of the checkout process with name collection for tickets that require identification.
## Implementation Details
The implementation includes:
1. Database migration to add first_name and last_name fields to tickets
2. Updates to the Ticket model to validate names when required
3. New routes and controller actions for name collection
4. A new view for collecting ticket holder names
5. Updates to the existing JavaScript controller
## Running the Migration
Once the Docker environment is fixed, run the following command to apply the database migration:
```bash
docker compose exec rails bundle exec rails db:migrate
```
## Testing the Implementation
1. Start the Docker containers:
```bash
docker compose up -d
```
2. Visit an event page and select tickets that require identification
3. The checkout process should redirect to the name collection page
4. After submitting names, the user should be redirected to the payment page
5. After successful payment, tickets should be created with the provided names
## Code Structure
- Migration: `db/migrate/20250828143000_add_names_to_tickets.rb`
- Model: `app/models/ticket.rb`
- Controller: `app/controllers/events_controller.rb`
- Views:
- `app/views/events/collect_names.html.erb` (new)
- `app/views/events/show.html.erb` (updated)
- `app/views/components/_ticket_card.html.erb` (updated)
- Routes: `config/routes.rb` (updated)
- JavaScript: `app/javascript/controllers/ticket_cart_controller.js` (no changes needed)

View File

@@ -0,0 +1,185 @@
// Self-contained QR Code Generator
// No external dependencies required
class QRCodeGenerator {
constructor() {
// QR Code error correction levels
this.errorCorrectionLevels = {
L: 1, // Low ~7%
M: 0, // Medium ~15%
Q: 3, // Quartile ~25%
H: 2 // High ~30%
};
// Mode indicators
this.modes = {
NUMERIC: 1,
ALPHANUMERIC: 2,
BYTE: 4,
KANJI: 8
};
}
// Generate QR code as SVG
generateSVG(text, options = {}) {
const size = options.size || 200;
const margin = options.margin || 4;
const errorCorrection = options.errorCorrection || 'M';
try {
const qrData = this.createQRData(text, errorCorrection);
const moduleSize = (size - 2 * margin) / qrData.length;
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
svg += `<rect width="${size}" height="${size}" fill="white"/>`;
for (let row = 0; row < qrData.length; row++) {
for (let col = 0; col < qrData[row].length; col++) {
if (qrData[row][col]) {
const x = margin + col * moduleSize;
const y = margin + row * moduleSize;
svg += `<rect x="${x}" y="${y}" width="${moduleSize}" height="${moduleSize}" fill="black"/>`;
}
}
}
svg += '</svg>';
return svg;
} catch (error) {
console.error('QR Code generation failed:', error);
return this.createErrorSVG(size);
}
}
// Create QR code data matrix (simplified implementation)
createQRData(text, errorCorrection) {
// For simplicity, we'll create a basic QR code pattern
// This is a minimal implementation - real QR codes are much more complex
const version = this.determineVersion(text.length);
const size = 21 + (version - 1) * 4; // QR code size formula
// Initialize matrix
const matrix = Array(size).fill().map(() => Array(size).fill(false));
// Add finder patterns (corners)
this.addFinderPatterns(matrix);
// Add timing patterns
this.addTimingPatterns(matrix);
// Add data (simplified - just create a pattern based on text)
this.addDataPattern(matrix, text);
return matrix;
}
determineVersion(length) {
// Simplified version determination
if (length <= 25) return 1;
if (length <= 47) return 2;
if (length <= 77) return 3;
return 4; // Max we'll support in this simple implementation
}
addFinderPatterns(matrix) {
const size = matrix.length;
const pattern = [
[1,1,1,1,1,1,1],
[1,0,0,0,0,0,1],
[1,0,1,1,1,0,1],
[1,0,1,1,1,0,1],
[1,0,1,1,1,0,1],
[1,0,0,0,0,0,1],
[1,1,1,1,1,1,1]
];
// Top-left
this.placePattern(matrix, 0, 0, pattern);
// Top-right
this.placePattern(matrix, 0, size - 7, pattern);
// Bottom-left
this.placePattern(matrix, size - 7, 0, pattern);
}
addTimingPatterns(matrix) {
const size = matrix.length;
// Horizontal timing pattern
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0;
}
// Vertical timing pattern
for (let i = 8; i < size - 8; i++) {
matrix[i][6] = i % 2 === 0;
}
}
addDataPattern(matrix, text) {
const size = matrix.length;
// Simple data pattern based on text hash
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) & 0xffffffff;
}
// Fill available spaces with pattern based on hash
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (!this.isReserved(row, col, size)) {
matrix[row][col] = ((hash >> ((row + col) % 32)) & 1) === 1;
}
}
}
}
placePattern(matrix, startRow, startCol, pattern) {
for (let row = 0; row < pattern.length; row++) {
for (let col = 0; col < pattern[row].length; col++) {
matrix[startRow + row][startCol + col] = pattern[row][col] === 1;
}
}
}
isReserved(row, col, size) {
// Check if position is reserved for finder patterns, timing patterns, etc.
// Finder patterns
if ((row < 9 && col < 9) || // Top-left
(row < 9 && col >= size - 8) || // Top-right
(row >= size - 8 && col < 9)) { // Bottom-left
return true;
}
// Timing patterns
if (row === 6 || col === 6) {
return true;
}
return false;
}
createErrorSVG(size) {
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="${size}" height="${size}" fill="#f3f4f6"/>
<text x="${size/2}" y="${size/2-10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">QR Code</text>
<text x="${size/2}" y="${size/2+10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Error</text>
</svg>`;
}
}
// Global function for easy access
window.generateQRCode = function(text, containerId, options = {}) {
const generator = new QRCodeGenerator();
const container = document.getElementById(containerId);
if (!container) {
console.error('Container not found:', containerId);
return;
}
const svg = generator.generateSVG(text, options);
container.innerHTML = svg;
};

View File

@@ -13,3 +13,4 @@
/* Import pages */
@import "pages/home";
@import "pages/payouts";

View File

@@ -0,0 +1,816 @@
/**
* Aperonight Design System
* Generated from homepage analysis
* A modern, professional design system for event platforms
*/
/* === ROOT VARIABLES === */
:root {
/* Brand Colors */
--brand-primary: #667eea;
--brand-secondary: #764ba2;
--brand-accent: #facc15; /* yellow-400 */
--brand-accent-dark: #eab308; /* yellow-500 */
/* Neutral Colors */
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
/* Purple Shades */
--color-purple-600: #9333ea;
--color-purple-700: #7c3aed;
--color-purple-800: #6b21a8;
/* Blue Shades */
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
/* Typography */
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
/* Font Sizes */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
--text-6xl: 3.75rem; /* 60px */
/* Font Weights */
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Spacing Scale */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-24: 6rem; /* 96px */
/* Border Radius */
--radius-sm: 0.375rem; /* 6px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-2xl: 1.25rem; /* 20px */
--radius-3xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Gradients */
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
--gradient-overlay: rgba(0, 0, 0, 0.3);
/* Transitions */
--transition-fast: all 0.2s ease;
--transition-medium: all 0.3s ease;
--transition-slow: all 0.5s ease;
}
/* === BASE STYLES === */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family: var(--font-family-sans);
}
body {
font-family: var(--font-family-sans) !important;
line-height: 1.6;
color: var(--color-gray-900) !important;
background-color: var(--color-white) !important;
}
/* === TYPOGRAPHY SYSTEM === */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-4xl { font-size: var(--text-4xl); }
.text-5xl { font-size: var(--text-5xl); }
.text-6xl { font-size: var(--text-6xl); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
.leading-tight { line-height: 1.25; }
.leading-normal { line-height: 1.5; }
.leading-relaxed { line-height: 1.625; }
/* === BUTTON SYSTEM === */
.btn {
display: inline-flex !important;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
font-weight: var(--font-semibold);
border-radius: var(--radius-full);
transition: var(--transition-fast);
text-decoration: none;
border: none;
cursor: pointer;
gap: var(--space-2);
}
.btn-primary {
background-color: var(--color-white) !important;
color: var(--color-gray-900) !important;
box-shadow: var(--shadow-lg);
}
.btn-primary:hover {
background-color: var(--color-gray-100) !important;
box-shadow: var(--shadow-xl);
transform: translateY(-1px);
}
.btn-secondary {
background-color: transparent !important;
color: var(--color-white) !important;
border: 2px solid var(--color-white) !important;
}
.btn-secondary:hover {
background-color: var(--color-white) !important;
color: var(--color-gray-900) !important;
}
.btn-secondary-alt {
background-color: transparent !important;
color: var(--color-gray-700) !important;
border: 2px solid var(--color-gray-300) !important;
}
.btn-secondary-alt:hover {
background-color: var(--color-gray-100) !important;
color: var(--color-gray-900) !important;
border-color: var(--color-gray-400) !important;
}
.btn-accent {
background-color: var(--color-purple-600) !important;
color: var(--color-white) !important;
}
.btn-accent:hover {
background-color: var(--color-purple-700) !important;
}
.btn-dark {
background-color: var(--color-gray-900) !important;
color: var(--color-white) !important;
}
.btn-dark:hover {
background-color: var(--color-gray-800) !important;
}
/* Button Sizes */
.btn-sm {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
}
.btn-lg {
padding: var(--space-4) var(--space-8);
font-size: var(--text-lg);
}
/* === CARD SYSTEM === */
.card {
background-color: var(--color-white) !important;
border-radius: var(--radius-2xl) !important;
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: var(--transition-medium);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-event {
cursor: pointer;
position: relative;
}
.card-event-image {
aspect-ratio: 4/3;
overflow: hidden;
border-radius: var(--radius-2xl);
position: relative;
}
.card-event-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: var(--transition-medium);
}
.card-event:hover .card-event-image img {
transform: scale(1.05);
}
.card-event-badge {
position: absolute;
top: var(--space-4);
left: var(--space-4);
background-color: var(--brand-accent) !important;
color: var(--color-gray-900) !important;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.card-event-price {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
background-color: rgba(255, 255, 255, 0.9) !important;
backdrop-filter: blur(4px);
color: var(--color-gray-900) !important;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-bold);
}
.card-event-content {
padding: var(--space-6);
text-align: center;
}
.card-event-title {
font-size: var(--text-2xl) !important;
font-weight: var(--font-bold) !important;
color: var(--color-gray-900) !important;
margin-bottom: var(--space-2);
transition: var(--transition-fast);
}
.card-event:hover .card-event-title {
color: var(--color-purple-600) !important;
}
.card-event-meta {
color: var(--color-gray-600) !important;
margin-bottom: var(--space-4);
}
.card-event-description {
color: var(--color-gray-500) !important;
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
max-width: 20rem;
margin: 0 auto;
}
/* === HERO SYSTEM === */
.hero {
background: var(--gradient-primary) !important;
position: relative;
overflow: hidden;
min-height: 100vh;
display: flex;
align-items: center;
}
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--gradient-overlay);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
color: var(--color-white) !important;
}
.hero-title {
font-size: var(--text-4xl) !important;
font-weight: var(--font-bold) !important;
line-height: var(--leading-tight);
margin-bottom: var(--space-6);
}
.hero-subtitle {
font-size: var(--text-xl) !important;
color: rgba(255, 255, 255, 0.8) !important;
margin-bottom: var(--space-8);
max-width: 32rem;
}
.hero-accent {
color: var(--brand-accent) !important;
}
/* Responsive Hero */
@media (min-width: 1024px) {
.hero-title {
font-size: var(--text-6xl) !important;
}
}
/* === METRICS SYSTEM === */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-8);
text-align: center;
}
@media (min-width: 1024px) {
.metrics-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.metric-item {
transition: var(--transition-medium);
}
.metric-number {
font-size: var(--text-4xl) !important;
font-weight: var(--font-bold) !important;
color: var(--color-purple-600) !important;
margin-bottom: var(--space-2);
}
@media (min-width: 1024px) {
.metric-number {
font-size: var(--text-5xl) !important;
}
}
.metric-label {
color: var(--color-gray-600) !important;
font-weight: var(--font-medium) !important;
}
/* === SECTION SYSTEM === */
.section {
padding: var(--space-16) 0;
}
.section-header {
text-align: center;
margin-bottom: var(--space-12);
}
.section-title {
font-size: var(--text-3xl) !important;
font-weight: var(--font-bold) !important;
color: var(--color-gray-900) !important;
margin-bottom: var(--space-4);
}
@media (min-width: 1024px) {
.section-title {
font-size: var(--text-4xl) !important;
}
}
.section-description {
font-size: var(--text-xl) !important;
color: var(--color-gray-600) !important;
max-width: 40rem;
margin: 0 auto;
}
/* === GRID SYSTEM === */
.grid {
display: grid;
gap: var(--space-8);
}
.grid-1 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
@media (min-width: 768px) {
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
}
/* === UTILITY CLASSES === */
.container {
max-width: 1280px;
margin: 0 auto;
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.bg-white { background-color: var(--color-white) !important; }
.bg-gray-50 { background-color: var(--color-gray-50) !important; }
.bg-gray-900 { background-color: var(--color-gray-900) !important; }
.text-white { color: var(--color-white) !important; }
.text-gray-600 { color: var(--color-gray-600) !important; }
.text-gray-900 { color: var(--color-gray-900) !important; }
.rounded-full { border-radius: var(--radius-full) !important; }
.rounded-2xl { border-radius: var(--radius-2xl) !important; }
.shadow-lg { box-shadow: var(--shadow-lg) !important; }
.shadow-xl { box-shadow: var(--shadow-xl) !important; }
.mb-2 { margin-bottom: var(--space-2) !important; }
.mb-4 { margin-bottom: var(--space-4) !important; }
.mb-6 { margin-bottom: var(--space-6) !important; }
.mb-8 { margin-bottom: var(--space-8) !important; }
.mb-12 { margin-bottom: var(--space-12) !important; }
.p-4 { padding: var(--space-4) !important; }
.p-6 { padding: var(--space-6) !important; }
.p-8 { padding: var(--space-8) !important; }
.flex { display: flex !important; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.gap-4 { gap: var(--space-4); }
.transition { transition: var(--transition-fast); }
.max-w-lg { max-width: 32rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-4xl { max-width: 56rem; }
/* === BREADCRUMB SYSTEM === */
.breadcrumb {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background-color: var(--color-white) !important;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-6);
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.breadcrumb-item a {
color: var(--color-gray-700) !important;
text-decoration: none;
transition: var(--transition-fast);
}
.breadcrumb-item a:hover {
color: var(--color-purple-600) !important;
}
.breadcrumb-item:not(:last-child)::after {
content: '';
width: 1rem;
height: 1rem;
margin-left: var(--space-2);
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
background-size: 1rem;
}
.breadcrumb-current {
color: var(--color-purple-600) !important;
}
/* === PAGE HEADER SYSTEM === */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: var(--space-8) 0;
}
.page-title {
font-size: var(--text-3xl) !important;
font-weight: var(--font-bold) !important;
color: var(--color-gray-900) !important;
}
.page-meta {
font-size: var(--text-sm) !important;
color: var(--color-gray-500) !important;
}
/* === EVENTS GRID SYSTEM === */
.events-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 768px) {
.events-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.events-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.event-card {
background-color: var(--color-white) !important;
border-radius: var(--radius-xl) !important;
box-shadow: var(--shadow-md);
overflow: hidden;
transition: var(--transition-medium);
position: relative;
}
.event-card:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-1px);
}
.event-card-image {
height: 12rem;
overflow: hidden;
position: relative;
}
.event-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: var(--transition-medium);
}
.event-card:hover .event-card-image img {
transform: scale(1.05);
}
.event-card-placeholder {
height: 12rem;
background: var(--gradient-primary) !important;
display: flex;
align-items: center;
justify-content: center;
}
.event-card-placeholder svg {
width: 4rem;
height: 4rem;
color: rgba(255, 255, 255, 0.8) !important;
}
.event-card-content {
padding: var(--space-6);
}
.event-card-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: var(--space-3);
}
.event-card-title {
font-size: var(--text-xl) !important;
font-weight: var(--font-bold) !important;
color: var(--color-gray-900) !important;
margin-bottom: var(--space-1);
line-height: 1.25;
}
.event-card-venue {
font-size: var(--text-xs) !important;
color: var(--color-gray-500) !important;
display: flex;
align-items: center;
gap: var(--space-1);
}
.event-card-date {
display: inline-flex;
align-items: center;
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
border-radius: var(--radius-full);
font-size: var(--text-xs) !important;
font-weight: var(--font-medium) !important;
background-color: rgba(147, 51, 234, 0.1) !important;
color: var(--color-purple-800) !important;
white-space: nowrap;
margin-top: var(--space-2);
}
.event-card-description {
color: var(--color-gray-600) !important;
font-size: var(--text-sm) !important;
line-height: 1.4;
margin-bottom: var(--space-4);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.event-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.event-card-price {
font-size: var(--text-sm) !important;
font-weight: var(--font-medium) !important;
color: var(--color-gray-900) !important;
}
.event-card-price-unavailable {
font-size: var(--text-sm) !important;
color: var(--color-gray-500) !important;
}
.event-card-link {
display: inline-flex !important;
align-items: center;
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
font-size: var(--text-sm) !important;
font-weight: var(--font-medium) !important;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-white) !important;
background: var(--gradient-primary) !important;
text-decoration: none !important;
transition: var(--transition-fast);
gap: var(--space-2);
}
.event-card-link:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
/* === EMPTY STATE SYSTEM === */
.empty-state {
text-align: center;
padding: var(--space-16) var(--space-4);
}
.empty-state-icon {
width: 6rem;
height: 6rem;
margin: 0 auto var(--space-6);
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%) !important;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-icon svg {
width: 3rem;
height: 3rem;
color: var(--color-purple-600) !important;
}
.empty-state-title {
font-size: var(--text-lg) !important;
font-weight: var(--font-medium) !important;
color: var(--color-gray-900) !important;
margin-bottom: var(--space-2);
}
.empty-state-description {
color: var(--color-gray-500) !important;
margin-bottom: var(--space-6);
max-width: 24rem;
margin-left: auto;
margin-right: auto;
}
/* === PAGINATION SYSTEM === */
.pagination {
display: flex;
justify-content: center;
margin-top: var(--space-8);
}
.pagination .page-item {
margin: 0 var(--space-1);
}
.pagination .page-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm) !important;
font-weight: var(--font-medium) !important;
color: var(--color-gray-600) !important;
background-color: var(--color-white) !important;
border: 1px solid var(--color-gray-200) !important;
border-radius: var(--radius-md);
text-decoration: none !important;
transition: var(--transition-fast);
min-width: 2.5rem;
height: 2.5rem;
}
.pagination .page-link:hover {
background-color: var(--color-gray-50) !important;
border-color: var(--color-purple-300) !important;
color: var(--color-purple-600) !important;
}
.pagination .page-item.active .page-link {
background-color: var(--color-purple-600) !important;
border-color: var(--color-purple-600) !important;
color: var(--color-white) !important;
}
.pagination .page-item.disabled .page-link {
color: var(--color-gray-300) !important;
background-color: var(--color-white) !important;
border-color: var(--color-gray-200) !important;
cursor: not-allowed;
}
/* === RESPONSIVE UTILITIES === */
@media (max-width: 640px) {
.sm\:flex-col { flex-direction: column; }
.sm\:text-center { text-align: center; }
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.page-title {
font-size: var(--text-2xl) !important;
}
}
@media (min-width: 640px) {
.sm\:flex-row { flex-direction: row; }
.sm\:flex-1 { flex: 1; }
}
@media (min-width: 1024px) {
.lg\:justify-start { justify-content: flex-start; }
.lg\:text-left { text-align: left; }
}

View File

@@ -0,0 +1,304 @@
/* Payouts specific styles */
.payout-status-progress {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
margin: 2rem 0;
}
.payout-status-progress::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background-color: #e5e7eb;
transform: translateY(-50%);
z-index: 1;
}
.payout-status-step {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payout-status-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
margin-bottom: 0.5rem;
z-index: 2;
}
.payout-status-step-icon.pending {
background-color: #f59e0b;
color: white;
}
.payout-status-step-icon.processing {
background-color: #3b82f6;
color: white;
}
.payout-status-step-icon.completed {
background-color: #10b981;
color: white;
}
.payout-status-step-icon.failed {
background-color: #ef4444;
color: white;
}
.payout-status-step-icon.incomplete {
background-color: #e5e7eb;
color: #9ca3af;
}
.payout-status-step-label {
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
.payout-status-step-date {
font-size: 0.625rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.payout-summary-card {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid #bbf7d0;
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.1), 0 2px 4px -1px rgba(16, 185, 129, 0.06);
}
.payout-summary-amount {
font-size: 2rem;
font-weight: 800;
color: #047857;
margin: 0.5rem 0;
}
.payout-summary-label {
font-size: 0.875rem;
font-weight: 600;
color: #059669;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payout-table-row:hover {
background-color: #f9fafb;
}
.payout-status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.payout-status-badge.pending {
background-color: #fef3c7;
color: #92400e;
}
.payout-status-badge.processing {
background-color: #dbeafe;
color: #1d4ed8;
}
.payout-status-badge.completed {
background-color: #d1fae5;
color: #047857;
}
.payout-status-badge.failed {
background-color: #fee2e2;
color: #b91c1c;
}
.payout-empty-state {
text-align: center;
padding: 3rem 1rem;
}
.payout-empty-state-icon {
margin: 0 auto 1rem;
width: 5rem;
height: 5rem;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
border-radius: 50%;
}
.payout-empty-state-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin-bottom: 0.5rem;
}
.payout-empty-state-description {
color: #6b7280;
margin-bottom: 1.5rem;
}
.payout-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.payout-detail-title {
font-size: 1.5rem;
font-weight: 700;
color: #111827;
}
.payout-event-card {
display: flex;
align-items: center;
padding: 1rem;
background-color: #f9fafb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.payout-event-icon {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-right: 1rem;
}
.payout-event-name {
font-weight: 600;
color: #111827;
}
.payout-event-id {
font-size: 0.875rem;
color: #6b7280;
}
.payout-detail-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.payout-detail-grid {
grid-template-columns: 1fr 1fr;
}
}
.payout-detail-item {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #e5e7eb;
}
.payout-detail-item:last-child {
border-bottom: none;
}
.payout-detail-label {
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
}
.payout-detail-value {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
}
.payout-detail-value.amount {
font-size: 1.125rem;
font-weight: 700;
}
.payout-detail-value.net-amount {
font-size: 1.125rem;
font-weight: 700;
color: #059669;
}
.payout-action-button {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
transition: all 0.2s;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.payout-action-button.primary {
background-color: #10b981;
color: white;
}
.payout-action-button.primary:hover {
background-color: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1);
}
.payout-action-button.secondary {
background-color: #f3f4f6;
color: #374151;
}
.payout-action-button.secondary:hover {
background-color: #e5e7eb;
}
.payout-action-button.warning {
background-color: #fbbf24;
color: #713f12;
}
.payout-action-button.warning:hover {
background-color: #f59e0b;
}
.payout-action-button.danger {
background-color: #ef4444;
color: white;
}
.payout-action-button.danger:hover {
background-color: #dc2626;
}

View File

@@ -5,22 +5,33 @@
--color-primary-200: #ddd6fe;
--color-primary-300: #c4b5fd;
--color-primary-400: #a78bfa;
--color-primary-500: #8b5cf6;
--color-primary-600: #7c3aed;
--color-primary-700: #6d28d9;
--color-primary-800: #5b21b6;
--color-primary-900: #4c1d95;
--color-primary-500: #667eea;
--color-primary-600: #667eea;
--color-primary-700: #5a6fd8;
--color-primary-800: #4e63c6;
--color-primary-900: #4257b4;
--color-accent-50: #fdf2f8;
--color-accent-100: #fce7f3;
--color-accent-200: #fbcfe8;
--color-accent-300: #f9a8d4;
--color-accent-400: #f472b6;
--color-accent-500: #ec4899;
--color-accent-600: #db2777;
--color-accent-700: #be185d;
--color-accent-800: #9d174d;
--color-accent-900: #831843;
--color-accent-50: #fffbeb;
--color-accent-100: #fef3c7;
--color-accent-200: #fde68a;
--color-accent-300: #fcd34d;
--color-accent-400: #facc15;
--color-accent-500: #facc15;
--color-accent-600: #e6c213;
--color-accent-700: #d1b811;
--color-accent-800: #bdae0f;
--color-accent-900: #a8a40d;
--color-secondary-50: #f0e9f9;
--color-secondary-100: #e2d4f3;
--color-secondary-200: #c5a9e7;
--color-secondary-300: #a87edc;
--color-secondary-400: #8b53d0;
--color-secondary-500: #764ba2;
--color-secondary-600: #764ba2;
--color-secondary-700: #68428f;
--color-secondary-800: #5a397c;
--color-secondary-900: #4c3069;
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
@@ -87,9 +98,9 @@
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-purple-sm: 0 1px 3px 0 rgba(168, 85, 247, 0.1), 0 1px 2px 0 rgba(168, 85, 247, 0.06);
--shadow-purple-md: 0 4px 6px -1px rgba(168, 85, 247, 0.1), 0 2px 4px -1px rgba(168, 85, 247, 0.06);
--shadow-purple-lg: 0 10px 15px -3px rgba(168, 85, 247, 0.1), 0 4px 6px -2px rgba(168, 85, 247, 0.05);
--shadow-purple-sm: 0 1px 3px 0 rgba(102, 126, 234, 0.1), 0 1px 2px 0 rgba(102, 126, 234, 0.06);
--shadow-purple-md: 0 4px 6px -1px rgba(102, 126, 234, 0.1), 0 2px 4px -1px rgba(102, 126, 234, 0.06);
--shadow-purple-lg: 0 10px 15px -3px rgba(102, 126, 234, 0.1), 0 4px 6px -2px rgba(102, 126, 234, 0.05);
/* Transitions */
--duration-fast: 150ms;
@@ -158,7 +169,6 @@ p {
cursor: pointer;
border-radius: var(--radius-lg);
transition: all var(--duration-normal) var(--ease-out);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
@@ -179,12 +189,13 @@ p {
}
.btn-primary {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
background: var(--color-primary-500);
color: white;
box-shadow: var(--shadow-purple-md);
}
.btn-primary:hover {
background: var(--color-primary-600);
transform: translateY(-2px);
box-shadow: var(--shadow-purple-lg);
}
@@ -199,10 +210,44 @@ p {
transform: translateY(-2px);
}
.btn-secondary-alt {
background-color: transparent;
color: var(--color-gray-700);
border: 2px solid var(--color-gray-300);
}
.btn-secondary-alt:hover {
background-color: var(--color-gray-100);
color: var(--color-gray-900);
border-color: var(--color-gray-400);
}
.btn-accent {
background: var(--color-accent-400);
color: var(--color-neutral-900);
font-weight: 800;
}
.btn-accent:hover {
background: var(--color-accent-500);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-dark {
background: var(--color-neutral-900);
color: white;
}
.btn-dark:hover {
background: var(--color-neutral-800);
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
border: 2px solid var(--color-primary-600);
color: var(--color-primary-600);
border: 2px solid var(--color-primary-500);
color: var(--color-primary-500);
}
.btn-outline:hover {
@@ -256,7 +301,7 @@ p {
outline: none;
border-color: var(--color-primary-500);
background: white;
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
}
.form-input::placeholder {
@@ -295,7 +340,7 @@ p {
outline: none;
border-color: var(--color-primary-500);
background: white;
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1);
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
}
/* Badges */
@@ -325,7 +370,7 @@ p {
.badge-sold-out {
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border: 1px border var(--color-danger);
}
.badge-featured {
@@ -508,7 +553,7 @@ p {
.progress-fill {
height: 100%;
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-accent-400) 100%);
background: var(--color-primary-500);
border-radius: var(--radius-full);
transition: width var(--duration-slow) var(--ease-out);
}
@@ -689,59 +734,216 @@ p {
/* Breadcrumbs */
.breadcrumb {
display: flex;
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
background: white;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-neutral-100);
margin-bottom: var(--space-8);
}
.breadcrumb-item {
color: var(--color-neutral-600);
text-decoration: none;
display: inline-flex;
align-items: center;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.breadcrumb-item:hover {
.breadcrumb-link {
color: var(--color-neutral-700);
text-decoration: none;
transition: all var(--duration-fast) var(--ease-out);
}
.breadcrumb-link:hover {
color: var(--color-primary-600);
}
.breadcrumb-item.current {
color: var(--color-neutral-900);
font-weight: 600;
.breadcrumb-current {
color: var(--color-primary-600);
font-weight: var(--font-medium);
}
.breadcrumb-separator {
color: var(--color-neutral-400);
width: var(--space-4);
height: var(--space-4);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container {
padding: 0 var(--space-3);
/* Hero section */
.hero {
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-secondary-500) 100%);
position: relative;
overflow: hidden;
}
h1 {
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.2);
}
.hero-content {
position: relative;
z-index: 2;
color: white;
padding: var(--space-16) 0;
}
.hero-title {
font-size: var(--text-5xl);
font-weight: 900;
line-height: 1.1;
margin-bottom: var(--space-4);
text-align: center;
}
.hero-accent {
color: var(--color-accent-400);
}
.hero-subtitle {
font-size: var(--text-xl);
font-weight: 500;
line-height: 1.5;
margin-bottom: var(--space-8);
text-align: center;
max-width: 36rem;
margin-left: auto;
margin-right: auto;
}
/* Metrics grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-6);
margin: var(--space-8) 0;
}
.metric-item {
text-align: center;
padding: var(--space-4);
}
.metric-number {
font-size: var(--text-3xl);
font-weight: 800;
color: var(--color-primary-600);
margin-bottom: var(--space-2);
}
h2 {
font-size: var(--text-2xl);
}
.btn-lg {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
}
.btn-md {
padding: var(--space-2) var(--space-4);
.metric-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-neutral-600);
}
.form-input,
.form-select,
.form-textarea {
padding: var(--space-3);
/* Cards */
.card {
background: white;
border-radius: var(--radius-xl);
padding: var(--space-6);
border: 1px solid var(--color-neutral-200);
box-shadow: var(--shadow-sm);
transition: all var(--duration-slow) var(--ease-out);
}
.card.hover-lift:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: var(--shadow-2xl);
border-color: var(--color-primary-200);
}
.card-header {
margin-bottom: var(--space-4);
}
.card-body {
margin-bottom: var(--space-4);
}
.card-event {
background: white;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-md);
transition: all var(--duration-slow) var(--ease-out);
border: 1px solid var(--color-neutral-200);
position: relative;
}
.card-event.hover-glow:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: var(--shadow-2xl);
border-color: var(--color-primary-200);
}
.card-event-image {
position: relative;
overflow: hidden;
aspect-ratio: 4/3;
}
.card-event-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-event-badge {
position: absolute;
top: var(--space-4);
left: var(--space-4);
background: var(--color-accent-400);
color: var(--color-neutral-900);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 700;
box-shadow: var(--shadow-md);
}
.card-event-price {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
color: var(--color-neutral-900);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 700;
box-shadow: var(--shadow-sm);
}
.card-event-content {
padding: var(--space-6);
}
.card-event-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-neutral-900);
margin-bottom: var(--space-2);
}
.card-event-meta {
color: var(--color-neutral-600);
margin-bottom: var(--space-4);
}
.card-event-description {
color: var(--color-neutral-500);
line-height: 1.5;
}
/* Additional styles for enhanced Aperonight design */
@@ -788,3 +990,33 @@ p {
color: var(--color-neutral-400);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container {
padding: 0 var(--space-3);
}
h1 {
font-size: var(--text-3xl);
}
h2 {
font-size: var(--text-2xl);
}
.btn-lg {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
}
.btn-md {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
}
.form-input,
.form-select,
.form-textarea {
padding: var(--space-3);
}
}

View File

@@ -0,0 +1,31 @@
class Admin::PayoutsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_admin!
def index
@payouts = Payout.includes(:event, :user)
.order(created_at: :desc)
.page(params[:page])
end
def create
@payout = Payout.find(params[:id])
begin
@payout.process_payout!
redirect_to admin_payouts_path, notice: "Payout processed successfully."
rescue => e
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
end
end
private
def ensure_admin!
# For now, we'll just check if the user has a stripe account
# In a real app, you'd have an admin role check
unless current_user.has_stripe_account?
redirect_to dashboard_path, alert: "Access denied."
end
end
end

View File

@@ -0,0 +1,25 @@
module Api
module V1
class CartsController < ApiController
# Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :store ]
def store
event_id = params[:event_id]
@event = Event.find(event_id)
cart_data = params[:cart] || {}
session[:pending_cart] = cart_data
session[:event_id] = @event.id
render json: { status: "success", message: "Cart stored successfully" }
rescue ActiveRecord::RecordNotFound
render json: { status: "error", message: "Event not found" }, status: :not_found
rescue => e
error_message = e.message.present? ? e.message : "Unknown error"
Rails.logger.error "Error storing cart: #{error_message}"
render json: { status: "error", message: "Failed to store cart" }, status: 500
end
end
end
end

View File

@@ -1,5 +1,5 @@
# Contrôleur API pour la gestion des ressources d'événements
# Fournit des points de terminaison RESTful pour les opérations CRUD sur le modèle Event
# API Controller for managing event resources
# Provides RESTful endpoints for CRUD operations on the Event model
module Api
module V1
@@ -7,27 +7,27 @@ module Api
# Skip API key authentication for store_cart action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :store_cart ]
# Charge l'évén avant certaines actions pour réduire les duplications
# Loads the event before certain actions to reduce duplications
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
# GET /api/v1/events
# Récupère tous les événements triés par date de création (du plus récent au plus ancien)
# Retrieves all events sorted by creation date (most recent first)
def index
@events = Event.all.order(created_at: :desc)
render json: @events, status: :ok
end
# GET /api/v1/events/:id
# Récupère un seul événement par son ID
# Retourne 404 si l'événement n'est pas trouvé
# Retrieves a single event by its ID
# Returns 404 if the event is not found
def show
render json: @event, status: :ok
end
# POST /api/v1/events
# Crée un nouvel événement avec les attributs fournis
# Retourne 201 Created en cas de succès avec les données de l'événement
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
# Creates a new event with the provided attributes
# Returns 201 Created on success with the event data
# Returns 422 Unprocessable Entity with error messages on failure
def create
@event = Event.new(event_params)
if @event.save
@@ -38,9 +38,9 @@ module Api
end
# PATCH/PUT /api/v1/events/:id
# Met à jour un événement existant avec les attributs fournis
# Retourne 200 OK avec les données mises à jour en cas de succès
# Retourne 422 Unprocessable Entity avec les messages d'erreur en cas d'échec
# Updates an existing event with the provided attributes
# Returns 200 OK with updated data on success
# Returns 422 Unprocessable Entity with error messages on failure
def update
if @event.update(event_params)
render json: @event, status: :ok
@@ -50,8 +50,8 @@ module Api
end
# DELETE /api/v1/events/:id
# Supprime définitivement un événement
# Retourne 204 No Content en cas de succès
# Permanently deletes an event
# Returns 204 No Content on success
def destroy
@event.destroy
head :no_content
@@ -66,33 +66,37 @@ module Api
render json: { status: "success", message: "Cart stored successfully" }
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
error_message = e.message.present? ? e.message : "Unknown error"
Rails.logger.error "Error storing cart: #{error_message}"
render json: { status: "error", message: "Failed to store cart" }, status: 500
end
private
# Trouve un événement par son ID ou retourne 404 Introuvable
# Utilisé comme before_action pour les actions show, update et destroy
# Finds an event by its ID or returns 404 Not Found
# Used as before_action for the show, update, and destroy actions
def set_event
@event = Event.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Événement non trouvé" }, status: :not_found
render json: { error: "Event not found" }, status: :not_found
end
# Paramètres forts pour la création et la mise à jour des événements
# Liste blanche des attributs autorisés pour éviter les vulnérabilités de mass assignment
# Strong parameters for creating and updating events
# Whitelist of allowed attributes to avoid mass assignment vulnerabilities
def event_params
params.require(:event).permit(
:name,
:slug,
:description,
:state,
:venue_name,
:venue_address,
:start_time,
:end_time,
:latitude,
:longitude,
:featured
:featured,
:user_id
)
end
end

View File

@@ -0,0 +1,281 @@
# API controller for order management
# Provides RESTful endpoints for order operations
module Api
module V1
class OrdersController < ApiController
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
before_action :set_event, only: [ :new, :create ]
# Skip API key authentication for increment_payment_attempt action (used by frontend forms)
skip_before_action :authenticate_api_key, only: [ :increment_payment_attempt ]
# GET /api/v1/orders/new
# Returns data needed for new order form
def new
cart_data = params[:cart_data] || session[:pending_cart] || {}
if cart_data.empty?
render json: { error: "Veuillez d'abord sélectionner vos billets sur la page de l'événement" }, status: :bad_request
return
end
tickets_needing_names = []
cart_data.each do |ticket_type_id, item|
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
next unless ticket_type
quantity = item["quantity"].to_i
next if quantity <= 0
quantity.times do |i|
tickets_needing_names << {
ticket_type_id: ticket_type.id,
ticket_type_name: ticket_type.name,
ticket_type_price: ticket_type.price_cents,
index: i
}
end
end
render json: { tickets_needing_names: tickets_needing_names }, status: :ok
end
# POST /api/v1/orders
# Creates a new order with tickets
def create
cart_data = params[:cart_data] || session[:pending_cart] || {}
if cart_data.empty?
render json: { error: "Aucun billet sélectionné" }, status: :bad_request
return
end
success = false
ActiveRecord::Base.transaction do
@order = current_user.orders.create!(event: @event, status: "draft")
order_params[:tickets_attributes]&.each do |index, ticket_attrs|
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
ticket = @order.tickets.build(
ticket_type: ticket_type,
first_name: ticket_attrs[:first_name],
last_name: ticket_attrs[:last_name],
status: "draft"
)
unless ticket.save
render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
if @order.tickets.present?
@order.calculate_total!
success = true
else
render json: { error: "Aucun billet valide créé" }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
if success
session[:draft_order_id] = @order.id
session.delete(:pending_cart)
render json: { order: @order, redirect_to: checkout_order_path(@order) }, status: :created
end
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
render json: { error: "Une erreur est survenue: #{error_message}" }, status: :internal_server_error
end
# GET /api/v1/orders/:id
# Returns order summary
def show
tickets = @order.tickets.includes(:ticket_type)
render json: { order: @order, tickets: tickets }, status: :ok
end
# GET /api/v1/orders/:id/checkout
# Returns checkout data for an order
def checkout
if @order.expired?
@order.expire_if_overdue!
render json: { error: "Votre commande a expiré. Veuillez recommencer." }, status: :gone
return
end
tickets = @order.tickets.includes(:ticket_type)
total_amount = @order.total_amount_cents
expiring_soon = @order.expiring_soon?
checkout_session = nil
if Rails.application.config.stripe[:secret_key].present?
begin
checkout_session = create_stripe_session
rescue => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
render json: { error: "Erreur lors de la création de la session de paiement" }, status: :internal_server_error
return
end
end
render json: {
order: @order,
tickets: tickets,
total_amount: total_amount,
expiring_soon: expiring_soon,
checkout_session: checkout_session
}, status: :ok
end
# PATCH /api/v1/orders/:id/increment_payment_attempt
# Increments payment attempt counter
def increment_payment_attempt
@order.increment_payment_attempt!
render json: { success: true, attempts: @order.payment_attempts }, status: :ok
end
# POST /api/v1/orders/:id/retry_payment
# Allows retrying payment for failed orders
def retry_payment
unless @order.can_retry_payment?
render json: { error: "Cette commande ne peut plus être payée" }, status: :forbidden
return
end
render json: { redirect_to: checkout_order_path(@order) }, status: :ok
end
# GET /api/v1/orders/payment_success
# Handles successful payment confirmation
def payment_success
session_id = params[:session_id]
stripe_configured = Rails.application.config.stripe[:secret_key].present?
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
unless stripe_configured
render json: { error: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur." }, status: :service_unavailable
return
end
begin
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
if stripe_session.payment_status == "paid"
order_id = stripe_session.metadata["order_id"]
unless order_id.present?
render json: { error: "Informations de commande manquantes" }, status: :bad_request
return
end
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
@order.mark_as_paid!
begin
StripeInvoiceGenerationJob.perform_later(@order.id)
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
rescue => e
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
end
@order.tickets.each do |ticket|
begin
TicketMailer.purchase_confirmation(ticket).deliver_now
rescue => e
Rails.logger.error "Failed to send confirmation email for ticket #{ticket.id}: #{e.message}"
end
end
session.delete(:pending_cart)
session.delete(:ticket_names)
session.delete(:draft_order_id)
render json: { order: @order, tickets: @order.tickets }, status: :ok
else
render json: { error: "Le paiement n'a pas été complété avec succès" }, status: :payment_required
end
rescue Stripe::StripeError => e
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
render json: { error: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}" }, status: :bad_request
rescue => e
error_message = e.message.present? ? e.message : "Erreur inconnue"
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
render json: { error: "Une erreur inattendue s'est produite : #{error_message}" }, status: :internal_server_error
end
end
# POST /api/v1/orders/payment_cancel
# Handles payment cancellation
def payment_cancel
order_id = params[:order_id] || session[:draft_order_id]
if order_id.present?
order = current_user.orders.find_by(id: order_id, status: "draft")
if order&.can_retry_payment?
render json: { message: "Le paiement a été annulé. Vous pouvez réessayer.", redirect_to: checkout_order_path(order) }, status: :ok
else
session.delete(:draft_order_id)
render json: { message: "Le paiement a été annulé et votre commande a expiré." }, status: :gone
end
else
render json: { message: "Le paiement a été annulé" }, status: :ok
end
end
private
def set_order
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Commande non trouvée" }, status: :not_found
end
def set_event
@event = Event.includes(:ticket_types).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Événement non trouvé" }, status: :not_found
end
def order_params
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
end
def create_stripe_session
line_items = @order.tickets.map do |ticket|
{
price_data: {
currency: "eur",
product_data: {
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
description: ticket.ticket_type.description
},
unit_amount: ticket.price_cents
},
quantity: 1
}
end
Stripe::Checkout::Session.create(
payment_method_types: [ "card" ],
line_items: line_items,
mode: "payment",
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: order_payment_cancel_url,
metadata: {
order_id: @order.id,
user_id: current_user.id
}
)
end
end
end
end

View File

@@ -2,7 +2,7 @@
# Provides authentication and common functionality for API controllers
class ApiController < ApplicationController
# Disable CSRF protection for API requests (token-based authentication instead)
protect_from_forgery with: :null_session
protect_from_forgery prepend: true
# Authenticate all API requests using API key
# Must be called before any API action
@@ -16,8 +16,10 @@ class ApiController < ApplicationController
# Extract API key from header or query parameter
api_key = request.headers["X-API-Key"] || params[:api_key]
# Validate against hardcoded key (in production, use environment variable)
unless api_key == "aperonight-api-key-2025"
# Validate against environment variable for security
expected_key = Rails.application.credentials.api_key || ENV["API_KEY"]
unless expected_key.present? && api_key == expected_key
render json: { error: "Unauthorized" }, status: :unauthorized
end
end

View File

@@ -5,6 +5,9 @@ class ApplicationController < ActionController::Base
# Ensures that all non-GET requests include a valid authenticity token
protect_from_forgery with: :exception
# Redirect authenticated users to onboarding if not completed
before_action :require_onboarding_completion
# Restrict access to modern browsers only
# Requires browsers to support modern web standards:
# - WebP images for better compression
@@ -14,4 +17,27 @@ class ApplicationController < ActionController::Base
# - CSS nesting and :has() pseudo-class
# allow_browser versions: :modern
# allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
private
def require_onboarding_completion
# Skip onboarding check for these paths
return if skip_onboarding_check?
# Only apply to signed-in users
if user_signed_in? && current_user.needs_onboarding?
redirect_to onboarding_path unless request.path == onboarding_path
end
end
def skip_onboarding_check?
# Skip for devise controllers (login, signup, password reset, etc.)
devise_controller? ||
# Skip for onboarding controller itself
controller_name == "onboarding" ||
# Skip for API endpoints
controller_name.start_with?("api/") ||
# Skip for health checks
controller_name == "rails/health"
end
end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::ConfirmationsController < Devise::ConfirmationsController
class Auth::ConfirmationsController < Devise::ConfirmationsController
# GET /resource/confirmation/new
# def new
# super

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::OmniauthCallbacksController < Devise::OmniauthCallbacksController
class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
# You should configure your model like this:
# devise :omniauthable, omniauth_providers: [:twitter]

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::PasswordsController < Devise::PasswordsController
class Auth::PasswordsController < Devise::PasswordsController
# GET /resource/password/new
# def new
# super

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::RegistrationsController < Devise::RegistrationsController
class Auth::RegistrationsController < Devise::RegistrationsController
before_action :configure_sign_up_params, only: [ :create ]
before_action :configure_account_update_params, only: [ :update ]
@@ -47,7 +47,7 @@ class Authentications::RegistrationsController < Devise::RegistrationsController
# If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params
devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name ])
devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name, :is_professionnal ])
end
# The path used after sign up.

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::SessionsController < Devise::SessionsController
class Auth::SessionsController < Devise::SessionsController
# before_action :configure_sign_in_params, only: [:create]
# GET /resource/sign_in

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Authentications::UnlocksController < Devise::UnlocksController
class Auth::UnlocksController < Devise::UnlocksController
# GET /resource/unlock/new
# def new
# super

View File

@@ -1,31 +1,36 @@
# Events controller
# Events controller - Public event listings and individual event display
#
# This controller manages all events. It load events for homepage
# and display for pagination.
# This controller manages public event browsing and displays individual events
# with their associated ticket types. No authentication required for public browsing.
class EventsController < ApplicationController
# No authentication required for public event viewing
before_action :authenticate_user!, only: []
before_action :set_event, only: [ :show ]
# Display all events
# Display paginated list of upcoming published events
#
# Shows events in published state, ordered by start time ascending
# Includes event owner information and supports Kaminari pagination
def index
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
end
# Display desired event
# Display individual event with ticket type information
#
# Find requested event and display it to the user
# Shows complete event details including venue information,
# available ticket types, and allows users to add tickets to cart
def show
# Event is set by set_event callback
# Event is set by set_event callback with ticket types preloaded
# Template will display event details and ticket selection interface
end
private
# Set the current event in the controller
# Find and set the current event with eager-loaded associations
#
# Expose the current @event property to method
# Loads event with ticket types to avoid N+1 queries
# Raises ActiveRecord::RecordNotFound if event doesn't exist
def set_event
@event = Event.includes(:ticket_types).find(params[:id])
end
end

View File

@@ -0,0 +1,38 @@
class OnboardingController < ApplicationController
before_action :authenticate_user!
before_action :redirect_if_onboarding_complete, except: [ :complete ]
def index
# Display the onboarding form
end
def complete
if onboarding_params_valid?
current_user.update!(onboarding_params)
current_user.complete_onboarding!
flash[:notice] = "Bienvenue sur #{Rails.application.config.app_name} ! Votre profil a été configuré avec succès."
redirect_to dashboard_path
else
flash.now[:alert] = "Veuillez remplir tous les champs requis."
render :index
end
end
private
def onboarding_params
params.require(:user).permit(:first_name, :last_name)
end
def onboarding_params_valid?
onboarding_params[:first_name].present? &&
onboarding_params[:last_name].present?
end
def redirect_if_onboarding_complete
if current_user&.onboarding_completed?
redirect_to dashboard_path
end
end
end

View File

@@ -4,7 +4,7 @@
# Orders group multiple tickets together for better transaction management
class OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_order, only: [:show, :checkout, :retry_payment, :increment_payment_attempt]
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt, :invoice ]
before_action :set_event, only: [ :new, :create ]
# Display new order form with name collection
@@ -12,7 +12,7 @@ class OrdersController < ApplicationController
# On this page user can see order summary and complete the tickets details
# (first name and last name) for each ticket ordered
def new
@cart_data = session[:pending_cart] || {}
@cart_data = params[:cart_data] || session[:pending_cart] || {}
if @cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
@@ -44,7 +44,7 @@ class OrdersController < ApplicationController
# Here a new order is created with associated tickets in draft state.
# When user is ready they can proceed to payment via the order checkout
def create
@cart_data = session[:pending_cart] || {}
@cart_data = params[:cart_data] || session[:pending_cart] || {}
if @cart_data.empty?
redirect_to event_path(@event.slug, @event), alert: "Aucun billet sélectionné"
@@ -97,9 +97,15 @@ class OrdersController < ApplicationController
redirect_to event_order_new_path(@event.slug, @event.id)
end
# Display all user orders
def index
@orders = current_user.orders.includes(:event, tickets: :ticket_type)
.where(status: [ "paid", "completed" ])
.order(created_at: :desc)
.page(params[:page])
end
# Display order summary
#
#
def show
@tickets = @order.tickets.includes(:ticket_type)
end
@@ -120,6 +126,15 @@ class OrdersController < ApplicationController
@total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon?
# For free orders, automatically mark as paid and redirect to success
if @order.free?
@order.mark_as_paid!
session.delete(:pending_cart)
session.delete(:ticket_names)
session.delete(:draft_order_id)
return redirect_to order_path(@order), notice: "Vos billets gratuits ont été confirmés !"
end
# Create Stripe checkout session if Stripe is configured
if Rails.application.config.stripe[:secret_key].present?
begin
@@ -146,7 +161,32 @@ class OrdersController < ApplicationController
return
end
redirect_to order_checkout_path(@order)
# For POST requests, increment the payment attempt counter
if request.post?
@order.increment_payment_attempt!
end
redirect_to checkout_order_path(@order)
end
# Display invoice for an order
def invoice
unless @order.status == "paid" || @order.status == "completed"
redirect_to order_path(@order), alert: "La facture n'est disponible qu'après le paiement de la commande"
return
end
@tickets = @order.tickets.includes(:ticket_type)
# Get the Stripe invoice if it exists
begin
@stripe_invoice_id = @order.create_stripe_invoice!
@stripe_invoice_pdf_url = @order.stripe_invoice_pdf_url if @stripe_invoice_id
rescue => e
Rails.logger.error "Failed to retrieve or create Stripe invoice for order #{@order.id}: #{e.message}"
@stripe_invoice_id = nil
@stripe_invoice_pdf_url = nil
end
end
# Handle successful payment
@@ -158,7 +198,7 @@ class OrdersController < ApplicationController
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
unless stripe_configured
redirect_to dashboard_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
redirect_to root_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
return
end
@@ -178,16 +218,19 @@ class OrdersController < ApplicationController
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
@order.mark_as_paid!
# Send confirmation emails
@order.tickets.each do |ticket|
# Schedule Stripe invoice generation in background
# This creates accounting records without blocking the payment success flow
begin
TicketMailer.purchase_confirmation(ticket).deliver_now
StripeInvoiceGenerationJob.perform_later(@order.id)
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
rescue => e
Rails.logger.error "Failed to send confirmation email for ticket #{ticket.id}: #{e.message}"
# Don't fail the entire payment process due to email/PDF generation issues
end
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
# Don't fail the payment process due to job scheduling issues
end
# Email confirmation is handled by the order model's mark_as_paid! method
# to avoid duplicate emails
# Clear session data
session.delete(:pending_cart)
session.delete(:ticket_names)
@@ -209,20 +252,20 @@ class OrdersController < ApplicationController
# Handle payment failure/cancellation
def payment_cancel
order_id = session[:draft_order_id]
order_id = params[:order_id] || session[:draft_order_id]
if order_id.present?
order = current_user.orders.find_by(id: order_id, status: "draft")
if order&.can_retry_payment?
redirect_to order_checkout_path(order),
redirect_to checkout_order_path(order),
alert: "Le paiement a été annulé. Vous pouvez réessayer."
else
session.delete(:draft_order_id)
redirect_to dashboard_path, alert: "Le paiement a été annulé et votre commande a expiré."
redirect_to root_path, alert: "Le paiement a été annulé et votre commande a expiré."
end
else
redirect_to dashboard_path, alert: "Le paiement a été annulé"
redirect_to root_path, alert: "Le paiement a été annulé"
end
end
@@ -231,7 +274,7 @@ class OrdersController < ApplicationController
def set_order
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to dashboard_path, alert: "Commande non trouvée"
redirect_to root_path, alert: "Commande non trouvée"
end
def set_event
@@ -259,6 +302,8 @@ class OrdersController < ApplicationController
}
end
# No service fee added to customer; deducted from promoter payout
Stripe::Checkout::Session.create(
payment_method_types: [ "card" ],
line_items: line_items,

View File

@@ -3,44 +3,98 @@
class PagesController < ApplicationController
before_action :authenticate_user!, only: [ :dashboard ]
# Homepage showing featured events
# Homepage showing featured events as landing page
#
# Display homepage with featured events and incoming ones
# Display homepage with featured events and site metrics for all users
def home
@featured_events = Event.published.featured.limit(3)
# Featured events for the main grid (6-9 events like Shotgun)
@featured_events = Event.published.featured.includes(:ticket_types).limit(9)
if user_signed_in?
redirect_to(dashboard_path)
# If no featured events, show latest published events
if @featured_events.empty?
@featured_events = Event.published.includes(:ticket_types).order(created_at: :desc).limit(9)
end
# Upcoming events for additional content
@upcoming_events = Event.published.upcoming.limit(6)
# Site metrics for landing page (with realistic fake data for demo)
@total_events = [ Event.published.count, 50 ].max # At least 50 events for demo
@total_users = [ User.count, 2500 ].max # At least 2500 users for demo
@events_this_month = [ Event.published.where(created_at: 1.month.ago..Time.current).count, 12 ].max # At least 12 this month
@active_cities = 5 # Fixed number for demo
end
# User dashboard showing personalized content
# Accessible only to authenticated users
def dashboard
# Metrics for dashboard cards
@booked_events = current_user.orders.joins(tickets: { ticket_type: :event })
.where(events: { state: :published })
.where(orders: { status: ['paid', 'completed'] })
.sum('1')
@events_today = Event.published.where("DATE(start_time) = ?", Date.current).count
@events_tomorrow = Event.published.where("DATE(start_time) = ?", Date.current + 1).count
@upcoming_events = Event.published.upcoming.count
# User's booked events
@user_booked_events = Event.joins(ticket_types: { tickets: :order })
.where(orders: { user: current_user }, tickets: { status: "active" })
.distinct
.limit(5)
# User's orders with associated data
@user_orders = current_user.orders.includes(:event, tickets: :ticket_type)
.where(status: [ "paid", "completed" ])
.order(created_at: :desc)
.limit(10)
# Draft orders that can be retried
@draft_orders = current_user.orders.includes(tickets: [ :ticket_type, :event ])
.can_retry_payment
.order(:expires_at)
# Events sections
@today_events = Event.published.where("DATE(start_time) = ?", Date.current).order(start_time: :asc)
@tomorrow_events = Event.published.where("DATE(start_time) = ?", Date.current + 1).order(start_time: :asc)
@other_events = Event.published.upcoming.where.not("DATE(start_time) IN (?)", [ Date.current, Date.current + 1 ]).order(start_time: :asc).page(params[:page])
# Promoter-specific data if user is a promoter
if current_user.promoter?
@promoter_events = current_user.events.includes(:orders, :tickets)
.order(created_at: :desc)
.limit(5)
# Revenue metrics for promoter
@total_revenue = current_user.events
.joins(:orders)
.where(orders: { status: [ "paid", "completed" ] })
.sum("orders.total_amount_cents") / 100.0
@total_tickets_sold = current_user.events
.joins(:tickets)
.where(tickets: { status: "active" })
.count
@active_events_count = current_user.events.where(state: "published").count
@draft_events_count = current_user.events.where(state: "draft").count
# Recent orders for promoter events
@recent_orders = Order.joins(:event)
.where(events: { user: current_user })
.where(status: [ "paid", "completed" ])
.includes(:event, :user, tickets: :ticket_type)
.order(created_at: :desc)
.limit(10)
# Monthly revenue trend (last 6 months)
@monthly_revenue = (0..5).map do |months_ago|
start_date = months_ago.months.ago.beginning_of_month
end_date = months_ago.months.ago.end_of_month
revenue = current_user.events
.joins(:orders)
.where(orders: { status: [ "paid", "completed" ] })
.where(orders: { created_at: start_date..end_date })
.sum("orders.total_amount_cents") / 100.0
{
month: start_date.strftime("%B %Y"),
revenue: revenue
}
end.reverse
end
# Simplified upcoming events preview - only show if user has orders
if @user_orders.any?
ordered_event_ids = @user_orders.map(&:event).map(&:id)
@upcoming_preview_events = Event.published
.upcoming
.where.not(id: ordered_event_ids)
.order(start_time: :asc)
.limit(6)
else
@upcoming_preview_events = []
end
end
# Events page showing all published events with pagination

View File

@@ -5,7 +5,7 @@
class Promoter::EventsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_can_manage_events!
before_action :set_event, only: [:show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out]
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :mark_available, :duplicate ]
# Display all events for the current promoter
def index
@@ -27,7 +27,7 @@ class Promoter::EventsController < ApplicationController
@event = current_user.events.build(event_params)
if @event.save
redirect_to promoter_event_path(@event), notice: 'Event créé avec succès!'
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
else
render :new, status: :unprocessable_entity
end
@@ -41,7 +41,7 @@ class Promoter::EventsController < ApplicationController
# Update an existing event
def update
if @event.update(event_params)
redirect_to promoter_event_path(@event), notice: 'Event mis à jour avec succès!'
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
else
render :edit, status: :unprocessable_entity
end
@@ -50,16 +50,16 @@ class Promoter::EventsController < ApplicationController
# Delete an event
def destroy
@event.destroy
redirect_to promoter_events_path, notice: 'Event supprimé avec succès!'
redirect_to promoter_events_path, notice: "Event supprimé avec succès!"
end
# Publish an event (make it visible to public)
def publish
if @event.draft?
@event.update(state: :published)
redirect_to promoter_event_path(@event), notice: 'Event publié avec succès!'
redirect_to promoter_event_path(@event), notice: "Event publié avec succès!"
else
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être publié.'
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être publié."
end
end
@@ -67,9 +67,9 @@ class Promoter::EventsController < ApplicationController
def unpublish
if @event.published?
@event.update(state: :draft)
redirect_to promoter_event_path(@event), notice: 'Event dépublié avec succès!'
redirect_to promoter_event_path(@event), notice: "Event dépublié avec succès!"
else
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être dépublié.'
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être dépublié."
end
end
@@ -77,9 +77,9 @@ class Promoter::EventsController < ApplicationController
def cancel
if @event.published?
@event.update(state: :canceled)
redirect_to promoter_event_path(@event), notice: 'Event annulé avec succès!'
redirect_to promoter_event_path(@event), notice: "Event annulé avec succès!"
else
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être annulé.'
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être annulé."
end
end
@@ -87,9 +87,31 @@ class Promoter::EventsController < ApplicationController
def mark_sold_out
if @event.published?
@event.update(state: :sold_out)
redirect_to promoter_event_path(@event), notice: 'Event marqué comme complet!'
redirect_to promoter_event_path(@event), notice: "Event marqué comme complet!"
else
redirect_to promoter_event_path(@event), alert: 'Cet event ne peut pas être marqué comme complet.'
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme complet."
end
end
# Mark event as available again
def mark_available
if @event.sold_out?
@event.update(state: :published)
redirect_to promoter_event_path(@event), notice: "Event marqué comme disponible!"
else
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme disponible."
end
end
# Duplicate an event and all its ticket types
def duplicate
clone_ticket_types = params[:clone_ticket_types] == "true"
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
if @new_event
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
else
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
end
end
@@ -97,21 +119,21 @@ class Promoter::EventsController < ApplicationController
def ensure_can_manage_events!
unless current_user.can_manage_events?
redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.'
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
end
end
def set_event
@event = current_user.events.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.'
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
end
def event_params
params.require(:event).permit(
:name, :slug, :description, :image,
:venue_name, :venue_address, :latitude, :longitude,
:start_time, :end_time, :featured
:start_time, :end_time, :featured, :allow_booking_during_event
)
end
end

View File

@@ -0,0 +1,66 @@
class Promoter::PayoutsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_promoter!
before_action :set_event, only: [:show, :create]
# List all payouts for the current promoter
def index
@payouts = current_user.payouts
.includes(:event)
.order(created_at: :desc)
.page(params[:page])
end
# Show payout details
def show
@payout = @event.payouts.find(params[:id])
end
# Create a new payout request
def create
# Check if event can request payout
unless @event.can_request_payout?
redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
return
end
# Calculate payout amount
total_earnings_cents = @event.total_earnings_cents
total_fees_cents = @event.total_fees_cents
net_earnings_cents = @event.net_earnings_cents
# Count orders
total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
# Create payout record
@payout = @event.payouts.build(
user: current_user,
amount_cents: total_earnings_cents,
fee_cents: total_fees_cents,
total_orders_count: total_orders_count,
refunded_orders_count: refunded_orders_count
)
if @payout.save
# Update event payout status
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
else
redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
end
end
private
def ensure_promoter!
unless current_user.promoter?
redirect_to dashboard_path, alert: "Access denied."
end
end
def set_event
@event = current_user.events.find(params[:event_id])
end
end

View File

@@ -32,7 +32,7 @@ class Promoter::TicketTypesController < ApplicationController
@ticket_type = @event.ticket_types.build(ticket_type_params)
if @ticket_type.save
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet créé avec succès!'
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet créé avec succès!"
else
render :new, status: :unprocessable_entity
end
@@ -46,7 +46,7 @@ class Promoter::TicketTypesController < ApplicationController
# Update an existing ticket type
def update
if @ticket_type.update(ticket_type_params)
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet mis à jour avec succès!'
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet mis à jour avec succès!"
else
render :edit, status: :unprocessable_entity
end
@@ -55,10 +55,10 @@ class Promoter::TicketTypesController < ApplicationController
# Delete a ticket type
def destroy
if @ticket_type.tickets.any?
redirect_to promoter_event_ticket_types_path(@event), alert: 'Impossible de supprimer ce type de billet car des billets ont déjà été vendus.'
redirect_to promoter_event_ticket_types_path(@event), alert: "Impossible de supprimer ce type de billet car des billets ont déjà été vendus."
else
@ticket_type.destroy
redirect_to promoter_event_ticket_types_path(@event), notice: 'Type de billet supprimé avec succès!'
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet supprimé avec succès!"
end
end
@@ -69,9 +69,9 @@ class Promoter::TicketTypesController < ApplicationController
@ticket_type.name = "#{original.name} (Copie)"
if @ticket_type.save
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: 'Type de billet dupliqué avec succès!'
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet dupliqué avec succès!"
else
redirect_to promoter_event_ticket_types_path(@event), alert: 'Erreur lors de la duplication.'
redirect_to promoter_event_ticket_types_path(@event), alert: "Erreur lors de la duplication."
end
end
@@ -79,20 +79,20 @@ class Promoter::TicketTypesController < ApplicationController
def ensure_can_manage_events!
unless current_user.can_manage_events?
redirect_to dashboard_path, alert: 'Vous n\'avez pas les permissions nécessaires pour gérer des événements.'
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
end
end
def set_event
@event = current_user.events.find(params[:event_id])
rescue ActiveRecord::RecordNotFound
redirect_to promoter_events_path, alert: 'Event non trouvé ou vous n\'avez pas accès à cet event.'
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
end
def set_ticket_type
@ticket_type = @event.ticket_types.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to promoter_event_ticket_types_path(@event), alert: 'Type de billet non trouvé.'
redirect_to promoter_event_ticket_types_path(@event), alert: "Type de billet non trouvé."
end
def ticket_type_params

View File

@@ -0,0 +1,26 @@
class SettingsController < ApplicationController
before_action :authenticate_user!
before_action :set_user
def show
# Show settings page
end
def update
if @user.update(user_params)
redirect_to settings_path, notice: "Vos informations ont été mises à jour avec succès."
else
render :show, status: :unprocessable_entity
end
end
private
def set_user
@user = current_user
end
def user_params
params.require(:user).permit(:first_name, :last_name, :is_professionnal)
end
end

View File

@@ -3,7 +3,7 @@
# This controller now primarily handles legacy redirects and backward compatibility
# Most ticket creation functionality has been moved to OrdersController
class TicketsController < ApplicationController
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel ]
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel, :show, :download ]
before_action :set_event, only: [ :checkout, :retry_payment ]
@@ -48,12 +48,51 @@ class TicketsController < ApplicationController
end
end
# Display ticket details
def show
@ticket = current_user.orders.joins(:tickets).find(params[:ticket_id])
# Find ticket by qr code id
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
.find_by(tickets: { qr_code: params[:qr_code] })
if @ticket.nil?
redirect_to dashboard_path, alert: "Billet non trouvé"
return
end
@event = @ticket.event
@order = @ticket.order
rescue ActiveRecord::RecordNotFound
redirect_to dashboard_path, alert: "Billet non trouvé"
end
# Download PDF ticket - only accessible by ticket owner
# User must be authenticated to download ticket
# TODO: change ID to an unique identifier (UUID)
def download
# Find ticket by qr code id
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
.find_by(tickets: { qr_code: params[:qr_code] })
if @ticket.nil?
redirect_to dashboard_path, alert: "Billet non trouvé ou vous n'avez pas l'autorisation d'accéder à ce billet"
return
end
# Generate PDF
pdf_content = @ticket.to_pdf
# Send PDF as download
send_data pdf_content,
filename: "ticket_#{@ticket.id}_#{@ticket.event.name.parameterize}.pdf",
type: "application/pdf",
disposition: "attachment"
rescue ActiveRecord::RecordNotFound
redirect_to dashboard_path, alert: "Billet non trouvé"
rescue => e
Rails.logger.error "Error generating ticket PDF: #{e.message}"
redirect_to dashboard_path, alert: "Erreur lors de la génération du billet"
end
private
def set_event
@@ -73,34 +112,4 @@ class TicketsController < ApplicationController
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
redirect_to events_path, alert: "Événement non trouvé"
end
def create_stripe_session
line_items = @tickets.map do |ticket|
{
price_data: {
currency: "eur",
product_data: {
name: "#{@event.name} - #{ticket.ticket_type.name}",
description: ticket.ticket_type.description
},
unit_amount: ticket.price_cents
},
quantity: 1
}
end
Stripe::Checkout::Session.create(
payment_method_types: [ "card" ],
line_items: line_items,
mode: "payment",
success_url: payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: payment_cancel_url,
metadata: {
event_id: @event.id,
user_id: current_user.id,
ticket_ids: @tickets.pluck(:id).join(",")
}
)
end
end

View File

@@ -0,0 +1,2 @@
module Admin::PayoutsHelper
end

View File

@@ -1,5 +1,5 @@
module ApplicationHelper
# Convert prince from cents to float
# Convert price from cents to float
def format_price(cents)
(cents.to_f / 100).round(2)
end

View File

@@ -1,4 +1,16 @@
# Flash messages helper for consistent styling across the application
#
# Provides standardized CSS classes and icons for different types of flash messages
# using Tailwind CSS classes and Lucide icons for consistent UI presentation
module FlashMessagesHelper
# Return appropriate Tailwind CSS classes for different flash message types
#
# @param type [String, Symbol] The flash message type (notice, error, warning, info)
# @return [String] Tailwind CSS classes for styling the flash message container
#
# Examples:
# flash_class('success') # => "bg-green-50 text-green-800 border-green-200"
# flash_class('error') # => "bg-red-50 text-red-800 border-red-200"
def flash_class(type)
case type.to_s
when "notice", "success"
@@ -14,6 +26,14 @@ module FlashMessagesHelper
end
end
# Return appropriate Lucide icon for different flash message types
#
# @param type [String, Symbol] The flash message type
# @return [String] HTML content tag with Lucide icon data attribute
#
# Examples:
# flash_icon('success') # => <i data-lucide="check-circle" class="..."></i>
# flash_icon('error') # => <i data-lucide="x-circle" class="..."></i>
def flash_icon(type)
case type.to_s
when "notice", "success"

View File

@@ -42,7 +42,7 @@ module LucideHelper
def lucide_button(name, options = {})
text = options.delete(:text)
icon_class = options.delete(:icon_class)
icon_size = options.delete(:icon_size) || 'w-4 h-4'
icon_size = options.delete(:icon_size) || "w-4 h-4"
icon = lucide_icon(name, class: icon_class, size: icon_size)
@@ -67,7 +67,7 @@ module LucideHelper
def lucide_link(name, url, options = {})
text = options.delete(:text)
icon_class = options.delete(:icon_class)
icon_size = options.delete(:icon_size) || 'w-4 h-4'
icon_size = options.delete(:icon_size) || "w-4 h-4"
icon = lucide_icon(name, class: icon_class, size: icon_size)

View File

@@ -0,0 +1,2 @@
module OnboardingHelper
end

View File

@@ -0,0 +1,2 @@
module Promoter::PayoutsHelper
end

View File

@@ -1,2 +1,9 @@
module TicketsHelper
def format_ticket_price(price_cents)
if price_cents == 0
"Gratuit"
else
number_to_currency(price_cents / 100.0, unit: "")
end
end
end

View File

@@ -6,3 +6,18 @@ import "@hotwired/turbo-rails";
// Import all Stimulus controllers
import "./controllers";
// Import and initialize Lucide icons globally
import { createIcons, icons } from 'lucide';
// Initialize icons globally
function initializeLucideIcons() {
createIcons({ icons });
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
// Run on Turbo navigation (Rails 7+ SPA behavior)
document.addEventListener('turbo:render', initializeLucideIcons);
document.addEventListener('turbo:frame-render', initializeLucideIcons);

View File

@@ -1,58 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
// Define button styles using class-variance-authority for consistent styling
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-purple text-purple-foreground shadow-xs hover:bg-purple/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-purple underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
// Button component that can render as a regular button or as a Slot (for composition)
function Button({
className,
variant,
size,
asChild = false,
...props
}) {
// Use Slot component if asChild is true, otherwise render as a regular button
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,71 @@
import { Controller } from "@hotwired/stimulus"
// Countdown controller for displaying remaining time until order expiration
export default class extends Controller {
static values = {
expiresAt: String, // ISO timestamp when the order expires
orderId: Number // Order ID for identification
}
connect() {
// Parse the expiration timestamp
this.expirationTime = new Date(this.expiresAtValue).getTime()
// Find the countdown element
this.countdownElement = this.element.querySelector('.countdown-timer')
if (this.countdownElement && !isNaN(this.expirationTime)) {
// Start the countdown
this.updateCountdown()
this.timer = setInterval(() => this.updateCountdown(), 1000)
}
}
disconnect() {
// Clean up the interval when the controller disconnects
if (this.timer) {
clearInterval(this.timer)
}
}
updateCountdown() {
const now = new Date().getTime()
const distance = this.expirationTime - now
// If the countdown is finished
if (distance < 0) {
this.countdownElement.innerHTML = "EXPIRÉ"
this.countdownElement.classList.add("text-red-600", "font-bold")
this.countdownElement.classList.remove("text-orange-600")
// Add a more urgent visual indicator
this.element.classList.add("bg-red-50", "border-red-200")
this.element.classList.remove("bg-orange-50", "border-orange-200")
// Stop the timer
if (this.timer) {
clearInterval(this.timer)
}
return
}
// Calculate time components
const seconds = Math.floor(distance / 1000)
// Display the result
this.countdownElement.innerHTML = `${seconds} secondes`
// Add urgency styling when time is running low
if (seconds < 60) {
this.countdownElement.classList.add("text-red-600", "font-bold")
this.countdownElement.classList.remove("text-orange-600")
// Add background warning for extra urgency
this.element.classList.add("bg-red-50", "border-red-200")
this.element.classList.remove("bg-orange-50", "border-orange-200")
} else if (seconds < 300) { // Less than 5 minutes
this.countdownElement.classList.add("text-orange-600", "font-bold")
this.element.classList.add("bg-orange-50", "border-orange-200")
}
}
}

View File

@@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["cloneTicketTypes"]
static values = {
duplicateUrl: String
}
connect() {
// Get modal element from the document
this.modalElement = document.querySelector('[data-event-duplication-target="modal"]')
}
open() {
this.modalElement.classList.remove('hidden')
document.body.classList.add('overflow-hidden')
}
close() {
this.modalElement.classList.add('hidden')
document.body.classList.remove('overflow-hidden')
}
duplicate() {
const cloneTicketTypes = this.cloneTicketTypesTarget.checked
// Create form data
const formData = new FormData()
formData.append('clone_ticket_types', cloneTicketTypes)
formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'))
// Send request to duplicate endpoint
fetch(this.duplicateUrlValue, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url
} else {
return response.json()
}
})
.catch(error => {
console.error('Error:', error)
alert('Erreur lors de la duplication de l\'événement.')
this.close()
})
}
}

View File

@@ -1,28 +1,667 @@
import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
// Event form controller for handling form interactions
// Handles auto-slug generation from event names
export default class extends Controller {
static targets = ["name", "slug"]
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
}
static lastGeocodingRequest = 0
connect() {
console.log("Event form controller connected")
this.geocodeTimeout = null
this.isManualGeocodingInProgress = false
// Initialize map links if we have an address and coordinates already exist
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
this.hasLatitudeTarget && this.hasLongitudeTarget &&
this.latitudeTarget.value && this.longitudeTarget.value) {
this.updateMapLinks()
}
}
// Auto-generate slug from name input
disconnect() {
if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout)
}
}
// Generate slug from name
generateSlug() {
// Only auto-generate if slug field is empty
if (this.slugTarget.value === "") {
const slug = this.nameTarget.value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // Remove accents
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
.replace(/\s+/g, "-") // Replace spaces with dashes
.replace(/-+/g, "-") // Remove duplicate dashes
.replace(/^-|-$/g, "") // Remove leading/trailing dashes
const name = this.nameTarget.value
this.slugTarget.value = slug
this.slugTarget.value = slug(name)
}
// Handle address changes with debounced geocoding
addressChanged() {
// Clear any existing timeout
if (this.geocodeTimeout) {
clearTimeout(this.geocodeTimeout)
}
const address = this.addressTarget.value.trim()
if (!address) {
this.clearCoordinates()
this.clearMapLinks()
this.hideGeocodingSpinner()
return
}
// Show spinner after a brief delay to avoid flickering for very short typing
const showSpinnerTimeout = setTimeout(() => {
this.showGeocodingSpinner()
}, 300)
// Debounce geocoding to avoid too many API calls
this.geocodeTimeout = setTimeout(async () => {
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
this.showGeocodingSpinner() // Show spinner for sure now
try {
await this.geocodeAddressQuiet(address)
} finally {
this.hideGeocodingSpinner()
}
}, this.geocodeDelayValue)
}
// Get user's current location and reverse geocode to address
async getCurrentLocation() {
if (!navigator.geolocation) {
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
return
}
this.showGetCurrentLocationLoading()
this.showLocationLoading()
const options = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000
}
try {
const position = await this.getCurrentPositionPromise(options)
const lat = position.coords.latitude
const lng = position.coords.longitude
// Set coordinates first
this.latitudeTarget.value = lat.toFixed(6)
this.longitudeTarget.value = lng.toFixed(6)
// Then reverse geocode to get address
const address = await this.reverseGeocode(lat, lng)
if (address) {
this.addressTarget.value = address
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
} else {
this.showLocationSuccess("Position actuelle détectée!")
}
this.updateMapLinks()
this.hideGetCurrentLocationLoading()
} catch (error) {
this.hideGetCurrentLocationLoading()
this.hideLocationLoading()
let message = "Erreur lors de la récupération de la localisation."
switch(error.code) {
case error.PERMISSION_DENIED:
message = "L'accès à la localisation a été refusé."
break
case error.POSITION_UNAVAILABLE:
message = "Les informations de localisation ne sont pas disponibles."
break
case error.TIMEOUT:
message = "La demande de localisation a expiré."
break
}
this.showLocationError(message)
} finally {
this.hideGetCurrentLocationLoading()
}
}
// Promise wrapper for geolocation
getCurrentPositionPromise(options) {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options)
})
}
// Reverse geocode coordinates to get address
async reverseGeocode(lat, lng) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
method: 'GET',
headers: {
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
console.log('Reverse geocoding response:', data) // Debug log
if (data && data.display_name) {
return data.display_name
}
return null
} catch (error) {
console.log("Reverse geocoding failed:", error)
return null
}
}
// Preview location - same as updating map links but with user feedback
previewLocation() {
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
return
}
// If we already have coordinates, just update map links
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
this.latitudeTarget.value && this.longitudeTarget.value) {
this.updateMapLinks()
this.showLocationSuccess("Liens de carte mis à jour!")
} else {
// Otherwise geocode the address first
this.showPreviewLocationLoading()
this.geocodeAddress().finally(() => {
this.hidePreviewLocationLoading()
})
}
}
// Geocode address manually (with user feedback)
async geocodeAddress() {
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
this.showLocationError("Veuillez saisir une adresse.")
return
}
const address = this.addressTarget.value.trim()
try {
this.isManualGeocodingInProgress = true
this.showLocationLoading()
const result = await this.performGeocode(address)
if (result) {
this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng
this.updateMapLinks()
if (result.accuracy === 'exact') {
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
} else {
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
}
} else {
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
}
} catch (error) {
this.showLocationError("Erreur lors de la recherche de l'adresse.")
} finally {
this.isManualGeocodingInProgress = false
this.hideLocationLoading()
}
}
// Geocode address quietly (no user feedback, for auto-geocoding)
async geocodeAddressQuiet(address) {
// Skip if address is too short or invalid
if (!address || address.length < 5) {
this.clearCoordinates()
this.clearMapLinks()
return
}
try {
const result = await this.performGeocode(address)
if (result && result.lat && result.lng) {
this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng
this.updateMapLinks()
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
// Show success message based on accuracy
if (result.accuracy === 'exact') {
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
} else {
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
}
} else {
// If auto-geocoding fails, show a subtle warning
this.showGeocodingWarning(address)
}
} catch (error) {
console.log("Auto-geocoding failed:", error)
this.showGeocodingWarning(address)
}
}
// Perform the actual geocoding request with fallback strategies
async performGeocode(address) {
// Rate limiting: ensure at least 1 second between requests
const now = Date.now()
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
if (timeSinceLastRequest < 1000) {
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
}
this.constructor.lastGeocodingRequest = Date.now()
// Try multiple geocoding strategies
const strategies = [
// Strategy 1: Exact address
address,
// Strategy 2: Street name + city (remove house number)
address.replace(/^\d+\s*/, ''),
// Strategy 3: Just city and postal code
this.extractCityAndPostalCode(address)
].filter(Boolean) // Remove null/undefined values
for (let i = 0; i < strategies.length; i++) {
const searchAddress = strategies[i]
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
// Show progress for manual geocoding (not auto-geocoding)
if (this.isManualGeocodingInProgress) {
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
}
try {
const result = await this.tryGeocode(searchAddress)
if (result) {
console.log(`Geocoding successful with strategy ${i + 1}`)
this.hideMessage("geocoding-progress")
return result
}
} catch (error) {
console.log(`Strategy ${i + 1} failed:`, error.message)
}
// Add small delay between attempts
if (i < strategies.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500))
}
}
this.hideMessage("geocoding-progress")
console.log('All geocoding strategies failed')
return null
}
// Extract city and postal code from address
extractCityAndPostalCode(address) {
// Look for French postal code pattern (5 digits) + city
const match = address.match(/(\d{5})\s+([^,]+)/);
if (match) {
return `${match[1]} ${match[2].trim()}`
}
// Fallback: extract last part after comma (assume it's city)
const parts = address.split(',')
if (parts.length > 1) {
return parts[parts.length - 1].trim()
}
return null
}
// Try a single geocoding request
async tryGeocode(address) {
const encodedAddress = encodeURIComponent(address.trim())
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
method: 'GET',
headers: {
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (data && data.length > 0) {
const result = data[0]
return {
lat: parseFloat(result.lat).toFixed(6),
lng: parseFloat(result.lon).toFixed(6),
display_name: result.display_name,
accuracy: address === result.display_name ? 'exact' : 'approximate'
}
}
return null
}
// Update map links based on current coordinates
updateMapLinks() {
if (!this.hasMapLinksContainerTarget) return
const lat = parseFloat(this.latitudeTarget.value)
const lng = parseFloat(this.longitudeTarget.value)
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
if (isNaN(lat) || isNaN(lng) || !address) {
this.clearMapLinks()
return
}
const links = this.generateMapLinks(lat, lng, address)
this.mapLinksContainerTarget.innerHTML = links
}
// Generate map links HTML
generateMapLinks(lat, lng, address) {
const encodedAddress = encodeURIComponent(address)
const providers = {
google: {
name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
icon: "🔍"
},
openstreetmap: {
name: "OpenStreetMap",
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
icon: "🗺️"
},
apple: {
name: "Apple Plans",
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
icon: "🍎"
}
}
return `
<div class="space-y-2">
<div class="flex items-center space-x-2">
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
</div>
<div class="flex flex-wrap gap-2">
${Object.entries(providers).map(([key, provider]) => `
<a href="${provider.url}" target="_blank" rel="noopener"
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<span class="mr-2">${provider.icon}</span>
${provider.name}
</a>
`).join('')}
</div>
</div>
`
}
// Clear coordinates
clearCoordinates() {
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
}
// Clear map links
clearMapLinks() {
if (this.hasMapLinksContainerTarget) {
this.mapLinksContainerTarget.innerHTML = ""
}
}
// Show geocoding spinner in address input
showGeocodingSpinner() {
if (this.hasGeocodingSpinnerTarget) {
this.geocodingSpinnerTarget.classList.remove('hidden')
}
}
// Hide geocoding spinner in address input
hideGeocodingSpinner() {
if (this.hasGeocodingSpinnerTarget) {
this.geocodingSpinnerTarget.classList.add('hidden')
}
}
// Show loading state on "Ma position" button
showGetCurrentLocationLoading() {
if (this.hasGetCurrentLocationBtnTarget) {
this.getCurrentLocationBtnTarget.disabled = true
}
if (this.hasGetCurrentLocationIconTarget) {
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
}
if (this.hasGetCurrentLocationTextTarget) {
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
}
}
// Hide loading state on "Ma position" button
hideGetCurrentLocationLoading() {
if (this.hasGetCurrentLocationBtnTarget) {
this.getCurrentLocationBtnTarget.disabled = false
}
if (this.hasGetCurrentLocationIconTarget) {
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
// Re-initialize Lucide icons
if (window.lucide) {
window.lucide.createIcons()
}
}
if (this.hasGetCurrentLocationTextTarget) {
this.getCurrentLocationTextTarget.textContent = 'Ma position'
}
}
// Show loading state on "Prévisualiser" button
showPreviewLocationLoading() {
if (this.hasPreviewLocationBtnTarget) {
this.previewLocationBtnTarget.disabled = true
}
if (this.hasPreviewLocationIconTarget) {
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
}
if (this.hasPreviewLocationTextTarget) {
this.previewLocationTextTarget.textContent = 'Recherche...'
}
}
// Hide loading state on "Prévisualiser" button
hidePreviewLocationLoading() {
if (this.hasPreviewLocationBtnTarget) {
this.previewLocationBtnTarget.disabled = false
}
if (this.hasPreviewLocationIconTarget) {
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
// Re-initialize Lucide icons
if (window.lucide) {
window.lucide.createIcons()
}
}
if (this.hasPreviewLocationTextTarget) {
this.previewLocationTextTarget.textContent = 'Prévisualiser'
}
}
// Show loading state
showLocationLoading() {
this.hideAllLocationMessages()
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
}
// Hide loading state
hideLocationLoading() {
this.hideMessage("location-loading")
}
// Show success message
showLocationSuccess(message) {
this.hideAllLocationMessages()
this.showMessage("location-success", message, "success")
setTimeout(() => this.hideMessage("location-success"), 4000)
}
// Show error message
showLocationError(message) {
this.hideAllLocationMessages()
this.showMessage("location-error", message, "error")
setTimeout(() => this.hideMessage("location-error"), 6000)
}
// Show geocoding warning (less intrusive than error)
showGeocodingWarning(address) {
this.hideMessage("geocoding-warning")
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
this.showMessage("geocoding-warning", message, "warning")
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
}
// Show info about approximate location
showApproximateLocationInfo(foundLocation) {
this.hideMessage("approximate-location-info")
const message = `Localisation approximative trouvée: ${foundLocation}`
this.showMessage("approximate-location-info", message, "info")
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
}
// Show geocoding success with location details
showGeocodingSuccess(title, location) {
this.hideMessage("geocoding-success")
const message = `${title}<br><small class="opacity-75">${location}</small>`
this.showMessage("geocoding-success", message, "success")
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
}
// Show geocoding progress with strategy info
showGeocodingProgress(strategy, attempt) {
this.hideMessage("geocoding-progress")
const message = `Recherche en cours... (${attempt}/${strategy})`
this.showMessage("geocoding-progress", message, "loading")
}
// Message template configurations
getMessageTemplate(type) {
const templates = {
info: {
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
textColor: "text-blue-800",
icon: "info",
iconColor: "text-blue-500"
},
success: {
bgColor: "bg-green-50",
borderColor: "border-green-200",
textColor: "text-green-800",
icon: "check-circle",
iconColor: "text-green-500"
},
error: {
bgColor: "bg-red-50",
borderColor: "border-red-200",
textColor: "text-red-800",
icon: "alert-circle",
iconColor: "text-red-500"
},
warning: {
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
textColor: "text-yellow-800",
icon: "alert-triangle",
iconColor: "text-yellow-500"
},
loading: {
bgColor: "bg-purple-50",
borderColor: "border-purple-200",
textColor: "text-purple-800",
icon: "loader-2",
iconColor: "text-purple-500",
animated: true
}
}
return templates[type] || templates.info
}
// Create dynamic message HTML using template
createMessageHTML(id, message, type) {
const template = this.getMessageTemplate(type)
const animationClass = template.animated ? 'animate-spin' : ''
return `
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
<div class="flex-shrink-0">
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
</div>
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
`
}
// Show a message with given type using template system
showMessage(id, message, type) {
// Remove existing message with same ID first
this.hideMessage(id)
const messageHtml = this.createMessageHTML(id, message, type)
// Insert into the dedicated messages container in the venue section
if (this.hasMessagesContainerTarget) {
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
// Re-initialize Lucide icons for the new elements
if (window.lucide) {
window.lucide.createIcons()
}
} else {
// Fallback: insert before the address input if messages container not found
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
if (addressInput) {
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
if (window.lucide) {
window.lucide.createIcons()
}
}
}
}
// Hide a specific message
hideMessage(id) {
const element = document.getElementById(id)
if (element) {
element.remove()
}
}
// Hide all location messages
hideAllLocationMessages() {
this.hideMessage("location-loading")
this.hideMessage("location-success")
this.hideMessage("location-error")
this.hideMessage("geocoding-warning")
this.hideMessage("approximate-location-info")
this.hideMessage("geocoding-success")
this.hideMessage("geocoding-progress")
}
}

View File

@@ -1,100 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Controller for handling animations of featured event cards
// Uses intersection observer to trigger animations when cards come into view
export default class extends Controller {
// Define targets for the controller
static targets = ["card"]
// Define CSS classes that can be used with this controller
static classes = ["visible"]
// Define configurable values with defaults
static values = {
threshold: { type: Number, default: 0.1 }, // Percentage of element visibility needed to trigger animation
rootMargin: { type: String, default: '0px 0px -50px 0px' }, // Margin around root element for intersection detection
staggerDelay: { type: Number, default: 0.2 } // Delay between card animations in seconds
}
// Initialize the controller when it connects to the DOM
connect() {
console.log("FeaturedEventController connected")
this.setupIntersectionObserver()
this.setupStaggeredAnimations()
}
// Clean up observers when the controller disconnects
disconnect() {
if (this.observer) {
this.observer.disconnect()
}
}
// Set up intersection observer to detect when cards come into view
setupIntersectionObserver() {
// Configure observer options
const observerOptions = {
threshold: this.thresholdValue,
rootMargin: this.rootMarginValue
}
// Create intersection observer
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Add visible class when card comes into view
if (entry.isIntersecting) {
entry.target.classList.add('visible')
}
})
}, observerOptions)
// Observe all card elements within this controller's scope
const elements = this.cardTargets
console.log("Card targets:", elements)
elements.forEach(el => {
this.observer.observe(el)
})
}
// Set up staggered animations for cards with progressive delays
setupStaggeredAnimations() {
console.log("Setting up staggered animations")
console.log("Card targets:", this.cardTargets)
// Add staggered animation delays to cards
this.cardTargets.forEach((card, index) => {
card.style.transitionDelay = `${index * this.staggerDelayValue}s`
card.classList.remove('visible')
})
}
}
/** Old code
<script>
// Add animation classes when elements are in view
document.addEventListener("DOMContentLoaded", function() {
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, observerOptions);
// Observe animated elements
document.querySelectorAll('.animate-fadeInUp, .animate-slideInLeft, .animate-slideInRight').forEach(el => {
observer.observe(el);
});
// Add staggered animation delays
document.querySelectorAll('.featured-event-card').forEach((card, index) => {
card.style.transitionDelay = `${index * 0.2}s`;
});
});
</script>
*/

View File

@@ -19,8 +19,14 @@ application.register("ticket-selection", TicketSelectionController);
import HeaderController from "./header_controller";
application.register("header", HeaderController);
import QrCodeController from "./qr_code_controller";
application.register("qr-code", QrCodeController);
import EventFormController from "./event_form_controller";
application.register("event-form", EventFormController);
import TicketTypeFormController from "./ticket_type_form_controller";
application.register("ticket-type-form", TicketTypeFormController);
import CountdownController from "./countdown_controller";
application.register("countdown", CountdownController);
import EventDuplicationController from "./event_duplication_controller";
application.register("event-duplication", EventDuplicationController);

View File

@@ -1,68 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="lucide"
export default class extends Controller {
static targets = ["icon"]
connect() {
this.initializeIcons()
// Listen for Turbo navigation events to reinitialize icons
document.addEventListener('turbo:render', this.handleTurboRender.bind(this))
document.addEventListener('turbo:frame-render', this.handleTurboFrameRender.bind(this))
}
disconnect() {
// Clean up event listeners
document.removeEventListener('turbo:render', this.handleTurboRender.bind(this))
document.removeEventListener('turbo:frame-render', this.handleTurboFrameRender.bind(this))
}
// Initialize all Lucide icons in the controller scope
initializeIcons() {
if (typeof lucide !== 'undefined') {
// Initialize icons within this controller's element
lucide.createIcons({
element: this.element
})
} else {
console.warn('Lucide not loaded yet, retrying...')
// Retry after a short delay if Lucide hasn't loaded yet
setTimeout(() => this.initializeIcons(), 100)
}
}
// Method to reinitialize icons after dynamic content changes
reinitialize() {
this.initializeIcons()
}
// Method to create a specific icon programmatically
createIcon(iconName, element = null) {
if (typeof lucide !== 'undefined') {
const targetElement = element || this.element
lucide.createIcons({
element: targetElement,
icons: {
[iconName]: lucide[iconName]
}
})
}
}
// Handle Turbo page renders
handleTurboRender() {
// Small delay to ensure DOM is fully updated
setTimeout(() => this.initializeIcons(), 10)
}
// Handle Turbo frame renders
handleTurboFrameRender(event) {
// Initialize icons within the specific frame that was rendered
if (event.detail && event.detail.newFrame) {
lucide.createIcons({
element: event.detail.newFrame
})
}
}
}

View File

@@ -0,0 +1,56 @@
// QR Code generator controller using qrcode npm package
import { Controller } from "@hotwired/stimulus"
import QRCode from "qrcode"
export default class extends Controller {
static values = { data: String }
static targets = ["container", "loading"]
connect() {
this.generateQRCode()
}
async generateQRCode() {
try {
// Hide loading indicator
if (this.hasLoadingTarget) {
this.loadingTarget.style.display = 'none'
}
// Create canvas element
const canvas = document.createElement('canvas')
// Generate QR code using qrcode library
await QRCode.toCanvas(canvas, this.dataValue, {
width: 128,
height: 128,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
// Clear container and add QR code
this.containerTarget.innerHTML = ''
this.containerTarget.appendChild(canvas)
console.log('QR code generated successfully')
} catch (error) {
console.error('Error generating QR code:', error)
this.showFallback()
}
}
showFallback() {
this.containerTarget.innerHTML = `
<div class="w-32 h-32 bg-gray-100 rounded flex items-center justify-center text-gray-500 text-xs border-2 border-dashed border-gray-300">
<div class="text-center">
<div class="text-lg mb-1">📱</div>
<div>QR Code</div>
<div class="font-mono text-xs mt-1 break-all px-2">${this.dataValue}</div>
</div>
</div>
`
}
}

View File

@@ -1,44 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import React from "react"
import { createRoot } from "react-dom/client"
import { Button } from "@/components/button"
// Controller for testing shadcn/ui React components within a Stimulus context
// Renders a React button component to verify the PostCSS and component setup
export default class extends Controller {
// Define targets for the controller
static targets = ["container"]
// Initialize and render the React component when the controller connects
connect() {
console.log("Shadcn Button Test Controller connected")
this.renderButton()
}
// Render the React button component inside the target container
renderButton() {
const container = this.containerTarget
const root = createRoot(container)
root.render(
<div className="flex flex-col items-center gap-4 p-6">
<h3 className="text-white text-lg font-semibold">Test Button Shadcn</h3>
<Button
variant="default"
size="lg"
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
onClick={this.handleClick}
>
Cliquez ici - PostCSS Test
</Button>
<p className="text-gray-300 text-sm">Ce bouton utilise shadcn/ui + Tailwind + PostCSS</p>
</div>
)
}
// Handle button click events
handleClick = () => {
alert("✅ Le bouton shadcn fonctionne avec PostCSS !")
console.log("Shadcn button clicked - PostCSS compilation successful")
}
}

View File

@@ -10,7 +10,7 @@ export default class extends Controller {
"checkoutButton",
"form",
];
static values = { eventSlug: String, eventId: String };
static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
// Initialize the controller and update the cart summary
connect() {
@@ -118,8 +118,8 @@ export default class extends Controller {
await this.storeCartInSession(cartData);
// Redirect to event-scoped orders/new page
const OrderNewUrl = `/events/${this.eventSlugValue}.${this.eventIdValue}/orders/new`;
window.location.href = OrderNewUrl;
const orderNewUrl = this.orderNewUrlValue;
window.location.href = orderNewUrl;
} catch (error) {
console.error("Error storing cart:", error);
alert("Une erreur est survenue. Veuillez réessayer.");
@@ -145,7 +145,7 @@ export default class extends Controller {
// Store cart data in session via AJAX
async storeCartInSession(cartData) {
const storeCartUrl = `/api/v1/events/${this.eventIdValue}/store_cart`;
const storeCartUrl = this.storeCartUrlValue;
const response = await fetch(storeCartUrl, {
method: "POST",
@@ -155,7 +155,7 @@ export default class extends Controller {
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
},
body: JSON.stringify({ cart: cartData }),
body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
});
if (!response.ok) {

View File

@@ -1,61 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Ticket Type Form Controller
// Handles dynamic pricing calculations and form interactions
export default class extends Controller {
static targets = ["price", "quantity", "total"]
connect() {
console.log("Ticket type form controller connected")
this.updateTotal()
}
// Update total revenue calculation when price or quantity changes
updateTotal() {
const price = parseFloat(this.priceTarget.value) || 0
const quantity = parseInt(this.quantityTarget.value) || 0
const total = price * quantity
// Format as currency
const formatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2
})
if (this.hasQuantityTarget && this.hasTotalTarget) {
// For new ticket types, calculate potential revenue
this.totalTarget.textContent = formatter.format(total)
} else if (this.hasTotalTarget) {
// For edit forms, calculate remaining potential revenue
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
const remainingQuantity = Math.max(0, quantity - soldTickets)
const remainingRevenue = price * remainingQuantity
this.totalTarget.textContent = formatter.format(remainingRevenue)
}
}
// Validate minimum quantity (for edit forms with sold tickets)
validateQuantity() {
const soldTickets = parseInt(this.element.dataset.soldTickets) || 0
const quantity = parseInt(this.quantityTarget.value) || 0
if (quantity < soldTickets) {
this.quantityTarget.value = soldTickets
this.quantityTarget.setCustomValidity(`La quantité ne peut pas être inférieure à ${soldTickets} (billets déjà vendus)`)
} else {
this.quantityTarget.setCustomValidity('')
}
this.updateTotal()
}
// Format price input to ensure proper decimal places
formatPrice() {
const price = parseFloat(this.priceTarget.value)
if (!isNaN(price)) {
this.priceTarget.value = price.toFixed(2)
}
this.updateTotal()
}
}

View File

@@ -1,9 +0,0 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
// Utility function for conditionally joining CSS classes
// Combines clsx (for conditional classes) with twMerge (for Tailwind CSS conflicts)
// Usage: cn("class1", "class2", conditionalClass && "class3")
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

View File

@@ -1,15 +1,33 @@
# Background job to clean up expired draft tickets
#
# This job runs periodically to find and expire draft tickets that have
# passed their expiry time (typically 30 minutes after creation).
# Should be scheduled via cron or similar scheduling system.
class CleanupExpiredDraftsJob < ApplicationJob
queue_as :default
# Find and expire all draft tickets that have passed their expiry time
#
# Uses find_each to process tickets in batches to avoid memory issues
# with large datasets. Continues processing even if individual tickets fail.
def perform
expired_count = 0
# Process expired draft tickets in batches
Ticket.expired_drafts.find_each do |ticket|
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user_id}"
begin
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user.id}"
ticket.expire_if_overdue!
expired_count += 1
rescue => e
# Log error but continue processing other tickets
Rails.logger.error "Failed to expire ticket #{ticket.id}: #{e.message}"
next
end
end
# Log summary if any tickets were processed
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0
Rails.logger.info "No expired draft tickets found" if expired_count == 0
end
end

View File

@@ -0,0 +1,19 @@
class EventReminderJob < ApplicationJob
queue_as :default
def perform(event_id, days_before)
event = Event.find(event_id)
# Find all users with active tickets for this event
users_with_tickets = User.joins(orders: { tickets: :ticket_type })
.where(ticket_types: { event: event })
.where(tickets: { status: "active" })
.distinct
users_with_tickets.find_each do |user|
TicketMailer.event_reminder(user, event, days_before).deliver_now
rescue StandardError => e
Rails.logger.error "Failed to send event reminder to user #{user.id} for event #{event.id}: #{e.message}"
end
end
end

View File

@@ -0,0 +1,44 @@
class EventReminderSchedulerJob < ApplicationJob
queue_as :default
def perform
schedule_weekly_reminders
schedule_daily_reminders
schedule_day_of_reminders
end
private
def schedule_weekly_reminders
# Find events starting in exactly 7 days
target_date = 7.days.from_now.beginning_of_day
events = Event.published
.where(start_time: target_date..(target_date + 1.day))
events.find_each do |event|
EventReminderJob.perform_later(event.id, 7)
end
end
def schedule_daily_reminders
# Find events starting in exactly 1 day (tomorrow)
target_date = 1.day.from_now.beginning_of_day
events = Event.published
.where(start_time: target_date..(target_date + 1.day))
events.find_each do |event|
EventReminderJob.perform_later(event.id, 1)
end
end
def schedule_day_of_reminders
# Find events starting today
target_date = Time.current.beginning_of_day
events = Event.published
.where(start_time: target_date..(target_date + 1.day))
events.find_each do |event|
EventReminderJob.perform_later(event.id, 0)
end
end
end

View File

@@ -0,0 +1,49 @@
# Background job to create Stripe invoices for accounting records
#
# This job is responsible for creating post-payment invoices in Stripe
# for accounting purposes after a successful payment
class StripeInvoiceGenerationJob < ApplicationJob
queue_as :default
# Retry up to 3 times with exponential backoff
retry_on StandardError, wait: :exponentially_longer, attempts: 3
# Don't retry on Stripe authentication errors
discard_on Stripe::AuthenticationError
def perform(order_id)
order = Order.find(order_id)
unless order.status == "paid"
Rails.logger.warn "Attempted to create invoice for unpaid order #{order_id}"
return
end
# Create the Stripe invoice
service = StripeInvoiceService.new(order)
stripe_invoice = service.create_post_payment_invoice
if stripe_invoice
# Store the invoice ID (you might want to persist this in the database)
order.instance_variable_set(:@stripe_invoice_id, stripe_invoice.id)
Rails.logger.info "Successfully created Stripe invoice #{stripe_invoice.id} for order #{order.id} via background job"
# Optionally send notification email about invoice availability
# InvoiceMailer.invoice_ready(order, stripe_invoice.id).deliver_now
else
error_msg = service.errors.join(", ")
Rails.logger.error "Failed to create Stripe invoice for order #{order.id}: #{error_msg}"
raise StandardError, "Invoice generation failed: #{error_msg}"
end
rescue ActiveRecord::RecordNotFound
Rails.logger.error "Order #{order_id} not found for invoice generation"
rescue Stripe::StripeError => e
Rails.logger.error "Stripe error creating invoice for order #{order_id}: #{e.message}"
raise e # Re-raise to trigger retry logic
rescue => e
Rails.logger.error "Unexpected error creating invoice for order #{order_id}: #{e.message}"
raise e # Re-raise to trigger retry logic
end
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
default from: ENV.fetch("MAILER_FROM_EMAIL", "no-reply@aperonight.fr")
layout "mailer"
end

View File

@@ -1,5 +1,32 @@
class TicketMailer < ApplicationMailer
default from: 'notifications@aperonight.com'
helper :tickets
def purchase_confirmation_order(order)
@order = order
@user = order.user
@event = order.event
@tickets = order.tickets
# Generate PDF attachments for all tickets
@tickets.each do |ticket|
begin
pdf = ticket.to_pdf
attachments["ticket-#{@event.name.parameterize}-#{ticket.qr_code[0..7]}.pdf"] = {
mime_type: "application/pdf",
content: pdf
}
rescue StandardError => e
Rails.logger.error "Failed to generate PDF for ticket #{ticket.id}: #{e.message}"
# Continue without PDF attachment rather than failing the entire email
end
end
mail(
to: @user.email,
subject: "Confirmation d'achat - #{@event.name}",
template_name: "purchase_confirmation"
)
end
def purchase_confirmation(ticket)
@ticket = ticket
@@ -7,15 +34,49 @@ class TicketMailer < ApplicationMailer
@event = ticket.event
# Generate PDF attachment
begin
pdf = @ticket.to_pdf
attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = {
mime_type: 'application/pdf',
mime_type: "application/pdf",
content: pdf
}
rescue StandardError => e
Rails.logger.error "Failed to generate PDF for ticket #{@ticket.id}: #{e.message}"
# Continue without PDF attachment rather than failing the entire email
end
mail(
to: @user.email,
subject: "Confirmation d'achat - #{@event.name}"
)
end
def event_reminder(user, event, days_before)
@user = user
@event = event
@days_before = days_before
# Get user's tickets for this event
@tickets = Ticket.joins(:order, :ticket_type)
.where(orders: { user: @user }, ticket_types: { event: @event }, status: "active")
return if @tickets.empty?
subject = case days_before
when 7
"Rappel : #{@event.name} dans une semaine"
when 1
"Rappel : #{@event.name} demain"
when 0
"C'est aujourd'hui : #{@event.name}"
else
"Rappel : #{@event.name} dans #{days_before} jours"
end
mail(
to: @user.email,
subject: subject,
template_name: "event_reminder"
)
end
end

16
app/models/earning.rb Normal file
View File

@@ -0,0 +1,16 @@
class Earning < ApplicationRecord
# === Relations ===
belongs_to :event
belongs_to :user
belongs_to :order
# === Enums ===
enum :status, { pending: 0, paid: 1 }
# === Validations ===
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
validates :status, presence: true
validates :stripe_payout_id, allow_blank: true, uniqueness: true
end

View File

@@ -1,5 +1,8 @@
# Event model representing nightlife events and events
# Manages event details, location data, and publication state
require "net/http"
require "json"
class Event < ApplicationRecord
# Define states for Event lifecycle management
# draft: Initial state when Event is being created
@@ -13,17 +16,31 @@ class Event < ApplicationRecord
sold_out: 3
}, default: :draft
enum :payout_status, {
not_requested: 0,
requested: 1,
processing: 2,
completed: 3,
failed: 4
}, default: :not_requested
# === Relations ===
belongs_to :user
has_many :ticket_types
has_many :tickets, through: :ticket_types
has_many :orders
has_many :earnings, dependent: :destroy
has_many :payouts, dependent: :destroy
# === Callbacks ===
before_validation :geocode_address, if: :should_geocode_address?
# === Validations ===
# Validations for Event attributes
# Basic information
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, presence: true, length: { minimum: 10, maximum: 1000 }
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
@@ -49,4 +66,235 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
# === Instance Methods ===
# Payout methods
def can_request_payout?
event_ended? && earnings.pending.any? && user.can_receive_payouts?
end
def total_earnings_cents
# Only count earnings from non-refunded tickets
earnings.pending.sum(:amount_cents)
end
def total_fees_cents
(total_earnings_cents * 0.1).to_i # 10% platform fee
end
def net_earnings_cents
total_earnings_cents - total_fees_cents
end
# Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful?
coordinates_look_valid?
end
# Get a user-friendly status message about geocoding
def geocoding_status_message
return nil if geocoding_successful?
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
end
# Check if ticket booking is currently allowed for this event
def booking_allowed?
return false unless published?
return false if sold_out?
return false if canceled?
# Check if event has started and if booking during event is disabled
if event_started? && !allow_booking_during_event?
return false
end
true
end
# Check if the event has already started
def event_started?
return false if start_time.blank?
Time.current >= start_time
end
# Check if the event has ended
def event_ended?
return false if end_time.blank?
Time.current >= end_time
end
# Check if booking is allowed during the event
# This is a simple attribute reader that defaults to false if nil
def allow_booking_during_event?
!!allow_booking_during_event
end
# Duplicate an event with all its ticket types
def duplicate(clone_ticket_types: true)
# Duplicate the event
new_event = self.dup
new_event.name = "Copie de #{name}"
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
new_event.state = :draft
new_event.created_at = Time.current
new_event.updated_at = Time.current
Event.transaction do
if new_event.save
# Duplicate all ticket types if requested
if clone_ticket_types
ticket_types.each do |ticket_type|
new_ticket_type = ticket_type.dup
new_ticket_type.event = new_event
new_ticket_type.save!
end
end
new_event
else
nil
end
end
rescue
nil
end
private
# Determine if we should perform server-side geocoding
def should_geocode_address?
# Don't geocode if address is blank
return false if venue_address.blank?
# Don't geocode if we already have valid coordinates (likely from frontend)
return false if coordinates_look_valid?
# Only geocode if address changed and we don't have coordinates
venue_address_changed?
end
# Check if the current coordinates look like they were set by frontend geocoding
def coordinates_look_valid?
return false if latitude.blank? || longitude.blank?
lat_f = latitude.to_f
lng_f = longitude.to_f
# Basic sanity checks for coordinate ranges
return false if lat_f < -90 || lat_f > 90
return false if lng_f < -180 || lng_f > 180
# Check if coordinates are not the default fallback coordinates
fallback_lat = 46.603354
fallback_lng = 1.888334
# Check if coordinates are not exactly 0,0 (common invalid default)
return false if lat_f == 0.0 && lng_f == 0.0
# Coordinates are valid if they're not exactly the fallback coordinates
!(lat_f == fallback_lat && lng_f == fallback_lng)
end
# Automatically geocode address to get latitude and longitude
# This only runs when no valid coordinates are provided (fallback for non-JS users)
def geocode_address
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
# Store original coordinates in case we need to fall back
original_lat = latitude
original_lng = longitude
begin
# Use OpenStreetMap Nominatim API for geocoding
encoded_address = URI.encode_www_form_component(venue_address.strip)
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Get.new(uri)
request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)"
request["Accept"] = "application/json"
response = http.request(request)
if response.code == "200"
data = JSON.parse(response.body)
if data.any?
result = data.first
self.latitude = result["lat"].to_f.round(6)
self.longitude = result["lon"].to_f.round(6)
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
return
end
end
# If we reach here, geocoding failed
handle_geocoding_failure(original_lat, original_lng)
rescue => e
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
handle_geocoding_failure(original_lat, original_lng)
end
end
# Handle geocoding failure with fallback strategies
def handle_geocoding_failure(original_lat, original_lng)
# Strategy 1: Keep existing coordinates if this is an update
if original_lat.present? && original_lng.present?
self.latitude = original_lat
self.longitude = original_lng
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
return
end
# Strategy 2: Try to extract country/city and use approximate coordinates
fallback_coordinates = get_fallback_coordinates_from_address
if fallback_coordinates
self.latitude = fallback_coordinates[:lat]
self.longitude = fallback_coordinates[:lng]
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
return
end
# Strategy 3: Use default coordinates (center of France) as last resort
# This ensures the event can still be created
self.latitude = 46.603354 # Center of France
self.longitude = 1.888334
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
end
# Extract country/city from address and return approximate coordinates
def get_fallback_coordinates_from_address
address_lower = venue_address.downcase
# Common French cities with approximate coordinates
french_cities = {
"paris" => { lat: 48.8566, lng: 2.3522 },
"lyon" => { lat: 45.7640, lng: 4.8357 },
"marseille" => { lat: 43.2965, lng: 5.3698 },
"toulouse" => { lat: 43.6047, lng: 1.4442 },
"nice" => { lat: 43.7102, lng: 7.2620 },
"nantes" => { lat: 47.2184, lng: -1.5536 },
"montpellier" => { lat: 43.6110, lng: 3.8767 },
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
"lille" => { lat: 50.6292, lng: 3.0573 }
}
# Check if any known city is mentioned in the address
french_cities.each do |city, coords|
if address_lower.include?(city)
return coords
end
end
# Check for common country indicators
if address_lower.include?("france") || address_lower.include?("french")
return { lat: 46.603354, lng: 1.888334 } # Center of France
end
nil
end
end

View File

@@ -1,6 +1,6 @@
class Order < ApplicationRecord
# === Constants ===
DRAFT_EXPIRY_TIME = 30.minutes
DRAFT_EXPIRY_TIME = 15.minutes
MAX_PAYMENT_ATTEMPTS = 3
# === Associations ===
@@ -19,6 +19,9 @@ class Order < ApplicationRecord
validates :payment_attempts, presence: true,
numericality: { greater_than_or_equal_to: 0 }
# Stripe invoice ID for accounting records
attr_accessor :stripe_invoice_id
# === Scopes ===
scope :draft, -> { where(status: "draft") }
scope :active, -> { where(status: %w[paid completed]) }
@@ -29,6 +32,7 @@ class Order < ApplicationRecord
}
before_validation :set_expiry, on: :create
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
# === Instance Methods ===
@@ -73,11 +77,80 @@ class Order < ApplicationRecord
update!(status: "paid")
tickets.update_all(status: "active")
end
# Send purchase confirmation email outside the transaction
# so that payment completion isn't affected by email failures
begin
TicketMailer.purchase_confirmation_order(self).deliver_now
rescue StandardError => e
Rails.logger.error "Failed to send purchase confirmation email for order #{id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Don't re-raise the error - payment should still succeed
end
end
# Calculate total from tickets
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
def calculate_total!
update!(total_amount_cents: tickets.sum(:price_cents))
ticket_total = tickets.sum(:price_cents)
update!(total_amount_cents: ticket_total)
end
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
def platform_fee_cents
tickets.sum do |ticket|
fixed_fee = 50 # €0.50 in cents
percentage_fee = (ticket.price_cents * 0.015).to_i
fixed_fee + percentage_fee
end
end
# Promoter payout amount after platform fee deduction
def promoter_payout_cents
total_amount_cents - platform_fee_cents
end
def platform_fee_euros
platform_fee_cents / 100.0
end
def promoter_payout_euros
promoter_payout_cents / 100.0
end
# Check if order contains only free tickets
def free?
total_amount_cents == 0
end
# Create Stripe invoice for accounting records
#
# This method creates a post-payment invoice in Stripe for accounting purposes
# It should only be called after the order has been paid
#
# @return [String, nil] The Stripe invoice ID or nil if creation failed
def create_stripe_invoice!
return nil unless status == "paid"
return @stripe_invoice_id if @stripe_invoice_id.present?
service = StripeInvoiceService.new(self)
stripe_invoice = service.create_post_payment_invoice
if stripe_invoice
@stripe_invoice_id = stripe_invoice.id
Rails.logger.info "Created Stripe invoice #{stripe_invoice.id} for order #{id}"
stripe_invoice.id
else
Rails.logger.error "Failed to create Stripe invoice for order #{id}: #{service.errors.join(', ')}"
nil
end
end
# Get the Stripe invoice PDF URL if available
#
# @return [String, nil] The PDF URL or nil if not available
def stripe_invoice_pdf_url
return nil unless @stripe_invoice_id.present?
StripeInvoiceService.get_invoice_pdf_url(@stripe_invoice_id)
end
private
@@ -90,4 +163,17 @@ class Order < ApplicationRecord
def draft?
status == "draft"
end
def create_earnings_if_paid
return unless event.present? && user.present?
return if event.earnings.exists?(order_id: id)
event.earnings.create!(
user: user,
order: self,
amount_cents: promoter_payout_cents,
fee_cents: platform_fee_cents,
status: :pending
)
end
end

59
app/models/payout.rb Normal file
View File

@@ -0,0 +1,59 @@
class Payout < ApplicationRecord
# === Relations ===
belongs_to :user
belongs_to :event
# === Enums ===
enum :status, {
pending: 0, # Payout requested but not processed
processing: 1, # Payout being processed
completed: 2, # Payout successfully completed
failed: 3 # Payout failed
}, default: :pending
# === Validations ===
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :stripe_payout_id, allow_blank: true, uniqueness: true
# === Scopes ===
scope :completed, -> { where(status: :completed) }
scope :pending, -> { where(status: :pending) }
scope :processing, -> { where(status: :processing) }
# === Instance Methods ===
# Amount in euros (formatted)
def amount_euros
amount_cents / 100.0
end
# Fee in euros (formatted)
def fee_euros
fee_cents / 100.0
end
# Net amount after fees
def net_amount_cents
amount_cents - fee_cents
end
# Net amount in euros
def net_amount_euros
net_amount_cents / 100.0
end
# Check if payout can be processed
def can_process?
pending? && amount_cents > 0
end
# Process the payout through Stripe
def process_payout!
service = PayoutService.new(self)
service.process!
end
end

View File

@@ -9,7 +9,7 @@ class Ticket < ApplicationRecord
validates :qr_code, presence: true, uniqueness: true
validates :order_id, presence: true
validates :ticket_type_id, presence: true
validates :price_cents, presence: true, numericality: { greater_than: 0 }
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
validates :first_name, presence: true
validates :last_name, presence: true
@@ -17,6 +17,7 @@ class Ticket < ApplicationRecord
# === Scopes ===
scope :draft, -> { where(status: "draft") }
scope :active, -> { where(status: "active") }
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create
@@ -69,7 +70,6 @@ class Ticket < ApplicationRecord
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def draft?
status == "draft"
end

View File

@@ -1,12 +1,12 @@
class TicketType < ApplicationRecord
# Associations
belongs_to :event
has_many :tickets, dependent: :destroy
has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold
# Validations
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
validates :price_cents, presence: true, numericality: { greater_than: 0 }
validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :sale_start_at, presence: true
validates :sale_end_at, presence: true
@@ -48,12 +48,16 @@ class TicketType < ApplicationRecord
[ quantity - tickets.count, 0 ].max
end
def free?
price_cents == 0
end
def sales_status
return :draft if sale_start_at.nil? || sale_end_at.nil?
return :expired if sale_end_at < Time.current
return :upcoming if sale_start_at > Time.current
return :sold_out if sold_out?
return :available
:available
end
def total_potential_revenue
@@ -80,6 +84,10 @@ class TicketType < ApplicationRecord
def sale_times_within_event_period
return unless event&.start_time && sale_end_at
# Only enforce this restriction if booking during event is not allowed
unless event.allow_booking_during_event?
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
end
end
end

View File

@@ -23,21 +23,48 @@ class User < ApplicationRecord
has_many :events, dependent: :destroy
has_many :tickets, dependent: :destroy
has_many :orders, dependent: :destroy
has_many :earnings, dependent: :destroy
has_many :payouts, dependent: :destroy
# Validations
validates :last_name, length: { minimum: 3, maximum: 12, allow_blank: true }
validates :first_name, length: { minimum: 3, maximum: 12, allow_blank: true }
validates :company_name, length: { minimum: 3, maximum: 12, allow_blank: true }
# Validations - allow reasonable name lengths
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
# Onboarding methods
def needs_onboarding?
!onboarding_completed?
end
def complete_onboarding!
update!(onboarding_completed: true)
end
# Authorization methods
def can_manage_events?
# For now, all authenticated users can manage events
# This can be extended later with role-based permissions
true
# Only professional users can manage events
is_professionnal?
end
def promoter?
# Alias for can_manage_events? to make views more semantic
can_manage_events?
end
def name
[ first_name, last_name ].compact.join(" ").strip
end
# Stripe Connect methods
def stripe_account_id
stripe_connected_account_id
end
def has_stripe_account?
stripe_connected_account_id.present?
end
def can_receive_payouts?
has_stripe_account? && promoter?
end
end

View File

@@ -0,0 +1,30 @@
class PayoutService
def initialize(payout)
@payout = payout
end
def process!
return unless @payout.can_process?
@payout.update!(status: :processing)
# Create Stripe payout
begin
stripe_payout = Stripe::Payout.create({
amount: @payout.amount_cents,
currency: 'eur',
destination: @payout.user.stripe_account_id,
description: "Payout for event: #{@payout.event.name}"
})
@payout.update!(
status: :completed,
stripe_payout_id: stripe_payout.id
)
rescue Stripe::StripeError => e
@payout.update!(status: :failed)
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
raise e
end
end
end

View File

@@ -0,0 +1,35 @@
class StripeConnectService
def self.create_account(user)
return if user.stripe_connected_account_id.present?
account = Stripe::Account.create(
type: "express",
country: "FR",
email: user.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true }
}
)
user.update!(stripe_connected_account_id: account.id)
account
end
def self.onboarding_link(user)
return unless user.stripe_connected_account_id.present?
account_link = Stripe::AccountLink.create(
account: user.stripe_connected_account_id,
refresh_url: Rails.application.routes.url_helpers.promoter_stripe_refresh_url,
return_url: Rails.application.routes.url_helpers.promoter_stripe_return_url,
type: "account_onboarding"
)
account_link.url
end
def self.get_account_details(account_id)
Stripe::Account.retrieve(account_id)
end
end

View File

@@ -0,0 +1,209 @@
# Service to create Stripe invoices for accounting records after successful payment
#
# This service creates post-payment invoices in Stripe for accounting purposes.
# Unlike regular Stripe invoices which are used for collection, these are
# created after payment via Checkout Sessions as accounting records.
class StripeInvoiceService
attr_reader :order, :errors
def initialize(order)
@order = order
@errors = []
end
# Create a post-payment invoice in Stripe
#
# Returns the created Stripe invoice object or nil if creation failed
def create_post_payment_invoice
return nil unless valid_for_invoice_creation?
begin
customer = find_or_create_stripe_customer
return nil unless customer
invoice = create_stripe_invoice(customer)
return nil unless invoice
add_line_items_to_invoice(customer, invoice)
finalize_invoice(invoice)
Rails.logger.info "Successfully created Stripe invoice #{invoice.id} for order #{@order.id}"
invoice
rescue Stripe::StripeError => e
handle_stripe_error(e)
nil
rescue => e
handle_generic_error(e)
nil
end
end
# Get the PDF URL for a Stripe invoice
#
# @param invoice_id [String] The Stripe invoice ID
# @return [String, nil] The invoice PDF URL or nil if not available
def self.get_invoice_pdf_url(invoice_id)
return nil if invoice_id.blank?
begin
invoice = Stripe::Invoice.retrieve(invoice_id)
invoice.invoice_pdf
rescue Stripe::StripeError => e
Rails.logger.error "Failed to retrieve Stripe invoice PDF URL: #{e.message}"
nil
end
end
private
def valid_for_invoice_creation?
unless @order.present?
@errors << "Order is required"
return false
end
unless @order.status == "paid"
@errors << "Order must be paid to create invoice"
return false
end
unless @order.user.present?
@errors << "Order must have an associated user"
return false
end
unless @order.tickets.any?
@errors << "Order must have tickets to create invoice"
return false
end
true
end
def find_or_create_stripe_customer
if @order.user.stripe_customer_id.present?
retrieve_existing_customer
else
create_new_customer
end
end
def retrieve_existing_customer
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
rescue Stripe::InvalidRequestError
# Customer doesn't exist, create a new one
Rails.logger.warn "Stripe customer #{@order.user.stripe_customer_id} not found, creating new customer"
@order.user.update(stripe_customer_id: nil)
create_new_customer
end
def create_new_customer
customer = Stripe::Customer.create({
email: @order.user.email,
name: customer_name,
metadata: {
user_id: @order.user.id,
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system"
}
})
@order.user.update(stripe_customer_id: customer.id)
Rails.logger.info "Created new Stripe customer #{customer.id} for user #{@order.user.id}"
customer
end
def customer_name
parts = []
parts << @order.user.first_name if @order.user.first_name.present?
parts << @order.user.last_name if @order.user.last_name.present?
if parts.empty?
@order.user.email.split("@").first.humanize
else
parts.join(" ")
end
end
def create_stripe_invoice(customer)
invoice_data = {
customer: customer.id,
collection_method: "send_invoice", # Don't auto-charge
auto_advance: false, # Don't automatically finalize
metadata: {
order_id: @order.id,
user_id: @order.user.id,
event_name: @order.event.name,
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system",
payment_method: "checkout_session"
},
description: "Invoice for #{@order.event.name} - Order ##{@order.id}",
footer: "Thank you for your purchase! This invoice is for your records as payment was already processed."
}
# Add due date (same day since it's already paid)
invoice_data[:due_date] = Time.current.to_i
Stripe::Invoice.create(invoice_data)
end
def add_line_items_to_invoice(customer, invoice)
# Add ticket line items
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
quantity = tickets.count
Stripe::InvoiceItem.create({
customer: customer.id,
invoice: invoice.id,
amount: ticket_type.price_cents * quantity,
currency: "eur",
description: build_line_item_description(ticket_type, tickets),
metadata: {
ticket_type_id: ticket_type.id,
ticket_type_name: ticket_type.name,
quantity: quantity,
unit_price_cents: ticket_type.price_cents
}
})
end
# No service fee on customer invoice; platform fee deducted from promoter payout
end
def build_line_item_description(ticket_type, tickets)
quantity = tickets.count
unit_price = ticket_type.price_cents / 100.0
description_parts = [
"#{@order.event.name}",
"#{ticket_type.name}",
"(#{quantity}x €#{unit_price})"
]
description_parts.join(" - ")
end
def finalize_invoice(invoice)
# Mark as paid since payment was already processed via checkout
finalized_invoice = invoice.finalize_invoice
# Mark the invoice as paid
finalized_invoice.pay({
paid_out_of_band: true, # Payment was made outside of Stripe invoicing
payment_method: nil # No payment method needed for out-of-band payment
})
finalized_invoice
end
def handle_stripe_error(error)
error_message = "Stripe invoice creation failed: #{error.message}"
@errors << error_message
Rails.logger.error "#{error_message} (Order: #{@order.id})"
end
def handle_generic_error(error)
error_message = "Invoice creation failed: #{error.message}"
@errors << error_message
Rails.logger.error "#{error_message} (Order: #{@order.id})"
end
end

View File

@@ -1,8 +1,14 @@
require 'prawn'
require 'prawn/qrcode'
require 'rqrcode'
require "prawn"
require "prawn/qrcode"
require "rqrcode"
# Service de génération de billets PDF utilisant Prawn
#
# Génère des billets PDF simples et compacts avec codes QR pour la validation d'entrée
# Design propre et minimaliste qui tient sur une seule page
class TicketPdfGenerator
# Suppress Prawn's internationalization warning for built-in fonts
Prawn::Fonts::AFM.hide_m17n_warning = true
attr_reader :ticket
def initialize(ticket)
@@ -14,42 +20,55 @@ class TicketPdfGenerator
# Header
pdf.fill_color "2D1B69"
pdf.font "Helvetica", style: :bold, size: 24
pdf.text "ApéroNight", align: :center
pdf.text ENV.fetch("APP_NAME", "Aperonight"), align: :center
pdf.move_down 10
# Event name
pdf.fill_color "000000"
pdf.font "Helvetica", style: :bold, size: 18
pdf.text ticket.event.name, align: :center
pdf.move_down 20
pdf.move_down 10
# Ticket info box
pdf.stroke_color "E5E7EB"
pdf.fill_color "F9FAFB"
pdf.rounded_rectangle [0, pdf.cursor], 310, 120, 10
pdf.rounded_rectangle [ 0, pdf.cursor ], 310, 150, 10
pdf.fill_and_stroke
pdf.move_down 10
pdf.fill_color "000000"
pdf.font "Helvetica", size: 12
# Customer name
pdf.indent 10 do
pdf.text "Titulaire du billet :", style: :bold
pdf.text "#{ticket.first_name} #{ticket.last_name}"
end
pdf.move_down 8
# Ticket details
pdf.text "Ticket Type:", style: :bold
pdf.indent 10 do
pdf.text "Type de billet :", style: :bold
pdf.text ticket.ticket_type.name
end
pdf.move_down 8
pdf.text "Price:", style: :bold
pdf.text "#{ticket.price_euros}"
pdf.indent 10 do
pdf.text "Prix :", style: :bold
pdf.text "#{ticket.price_euros}"
end
pdf.move_down 8
pdf.text "Date & Time:", style: :bold
pdf.text ticket.event.start_time.strftime("%B %d, %Y at %I:%M %p")
pdf.indent 10 do
pdf.text "Date et heure :", style: :bold
pdf.text ticket.event.start_time.strftime("%d %B %Y à %H:%M")
end
pdf.move_down 20
# Venue information
# Informations sur le lieu
pdf.fill_color "374151"
pdf.font "Helvetica", style: :bold, size: 14
pdf.text "Venue Information"
pdf.text "Informations sur le lieu"
pdf.move_down 8
pdf.font "Helvetica", size: 11
@@ -57,10 +76,10 @@ class TicketPdfGenerator
pdf.text ticket.event.venue_address
pdf.move_down 20
# QR Code
# Code QR
pdf.fill_color "000000"
pdf.font "Helvetica", style: :bold, size: 14
pdf.text "Ticket QR Code", align: :center
pdf.text "Code QR", align: :center
pdf.move_down 10
# Ensure all required data is present before generating QR code
@@ -68,40 +87,211 @@ class TicketPdfGenerator
raise "Ticket QR code is missing"
end
qr_code_data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.event&.id,
user_id: ticket.user&.id
}.compact.to_json
# Build QR code data with safe association loading
qr_code_data = build_qr_code_data(ticket)
# Validate QR code data before creating QR code
if qr_code_data.blank? || qr_code_data == "{}"
Rails.logger.error "QR code data is empty: ticket_id=#{ticket.id}, qr_code=#{ticket.qr_code}, event_id=#{ticket.ticket_type&.event_id}, user_id=#{ticket.order&.user_id}"
raise "QR code data is empty or invalid"
end
qrcode = RQRCode::QRCode.new(qr_code_data)
pdf.print_qr_code(qrcode, extent: 120, align: :center)
# Ensure qr_code_data is a proper string for QR code generation
unless qr_code_data.is_a?(String) && qr_code_data.length > 2
Rails.logger.error "QR code data is not a valid string: #{qr_code_data.inspect} (class: #{qr_code_data.class})"
raise "QR code data must be a valid string"
end
# Generate QR code - prawn-qrcode expects the data string directly
pdf.print_qr_code(qr_code_data, extent: 120, align: :center)
pdf.move_down 15
# QR code text
pdf.font "Helvetica", size: 8
pdf.fill_color "6B7280"
pdf.text "QR Code: #{ticket.qr_code[0..7]}...", align: :center
pdf.text "#{ticket.qr_code}", align: :center
# Ticket ID
pdf.font "Helvetica", size: 8
pdf.fill_color "6B7280"
pdf.text "ID du billet : #{ticket.id}", align: :center
# Footer
pdf.move_down 30
pdf.stroke_color "E5E7EB"
pdf.horizontal_line 0, 310
pdf.move_down 10
pdf.move_down 6
pdf.font "Helvetica", size: 8
pdf.fill_color "6B7280"
pdf.text "This ticket is valid for one entry only.", align: :center
pdf.text "Present this ticket at the venue entrance.", align: :center
pdf.text "Ce billet est valable pour une seule entrée.", align: :center
pdf.text "Présentez ce billet à l'entrée du lieu.", align: :center
pdf.move_down 5
pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}", align: :center
pdf.text "Généré le #{Time.current.strftime('%d %B %Y à %H:%M')}", align: :center
end.render
end
private
def create_simple_header(pdf)
# Nom de la marque
pdf.fill_color "6366F1"
pdf.font "Helvetica", style: :bold, size: 24
pdf.text "AperoNight", align: :center
pdf.move_down 5
pdf.font "Helvetica", size: 10
pdf.fill_color "64748B"
pdf.text "Billet d'entree", align: :center
pdf.move_down 20
# Simple divider line
pdf.stroke_color "E5E7EB"
pdf.horizontal_line 0, pdf.bounds.width
pdf.move_down 20
end
def create_ticket_info(pdf)
# Nom de l'événement - proéminent
pdf.fill_color "1F2937"
pdf.font "Helvetica", style: :bold, size: 18
pdf.text ticket.event.name, align: :center
pdf.move_down 15
# Two-column layout for ticket details
pdf.bounding_box([ 0, pdf.cursor ], width: pdf.bounds.width, height: 120) do
# Left column
pdf.bounding_box([ 0, pdf.cursor ], width: pdf.bounds.width / 2 - 20, height: 120) do
create_info_item(pdf, "Date", ticket.event.start_time.strftime("%d %B %Y"))
create_info_item(pdf, "Heure", ticket.event.start_time.strftime("%H:%M"))
create_info_item(pdf, "Lieu", ticket.event.venue_name)
end
# Right column
pdf.bounding_box([ pdf.bounds.width / 2 + 20, pdf.cursor ], width: pdf.bounds.width / 2 - 20, height: 120) do
create_info_item(pdf, "Type", ticket.ticket_type.name)
create_info_item(pdf, "Prix", "#{sprintf('%.2f', ticket.price_euros)}")
create_info_item(pdf, "Titulaire", "#{ticket.first_name} #{ticket.last_name}")
end
end
pdf.move_down 30
end
def create_info_item(pdf, label, value)
pdf.font "Helvetica", style: :bold, size: 9
pdf.fill_color "64748B"
pdf.text label.upcase
pdf.move_down 2
pdf.font "Helvetica", size: 11
pdf.fill_color "1F2937"
pdf.text value
pdf.move_down 12
end
def create_qr_section(pdf)
# Center the QR code horizontally
qr_size = 120
x_position = (pdf.bounds.width - qr_size) / 2
pdf.bounding_box([ x_position, pdf.cursor ], width: qr_size, height: qr_size + 40) do
# QR Code title
pdf.font "Helvetica", style: :bold, size: 12
pdf.fill_color "1F2937"
pdf.text "Code d'entree", align: :center
pdf.move_down 10
# Generate QR code
generate_simple_qr_code(pdf, qr_size)
pdf.move_down 10
# QR code ID
pdf.font "Helvetica", size: 8
pdf.fill_color "64748B"
pdf.text "ID: #{ticket.qr_code[0..15]}...", align: :center
end
pdf.move_down 40
end
def generate_simple_qr_code(pdf, size)
# Ensure all required data is present before generating QR code
if ticket.qr_code.blank?
raise "Ticket QR code is missing"
end
# Build QR code data with safe association loading
qr_code_data = build_qr_code_data(ticket)
# Validate QR code data before creating QR code
if qr_code_data.blank? || qr_code_data == "{}"
Rails.logger.error "QR code data is empty: ticket_id=#{ticket.id}, qr_code=#{ticket.qr_code}, event_id=#{ticket.ticket_type&.event_id}, user_id=#{ticket.order&.user_id}"
raise "QR code data is empty or invalid"
end
# Ensure qr_code_data is a proper string for QR code generation
unless qr_code_data.is_a?(String) && qr_code_data.length > 2
Rails.logger.error "QR code data is not a valid string: #{qr_code_data.inspect} (class: #{qr_code_data.class})"
raise "QR code data must be a valid string"
end
# Generate QR code
pdf.print_qr_code(qr_code_data, extent: size, align: :center)
end
def create_simple_footer(pdf)
# Security notice
pdf.font "Helvetica", size: 8
pdf.fill_color "64748B"
pdf.text "Ce billet est valable pour une seule entree.", align: :center
pdf.text "Presentez ce code QR a l'entree de l'evenement.", align: :center
pdf.move_down 10
# Divider line
pdf.stroke_color "E5E7EB"
pdf.horizontal_line 0, pdf.bounds.width
pdf.move_down 5
# Generation timestamp
pdf.font "Helvetica", size: 7
pdf.fill_color "9CA3AF"
timestamp = "Genere le #{Time.current.strftime('%d/%m/%Y a %H:%M')}"
pdf.text timestamp, align: :center
end
def build_qr_code_data(ticket)
# Try multiple approaches to get valid QR code data
begin
# Primary approach: full JSON with all data
data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.ticket_type&.event_id,
user_id: ticket.order&.user_id
}.compact
# Ensure we have the minimum required data
if data[:ticket_id] && data[:qr_code]
return data.to_json
end
rescue StandardError => e
Rails.logger.warn "Failed to build complex QR data: #{e.message}"
end
# Fallback approach: just use the ticket's QR code string
begin
return ticket.qr_code.to_s if ticket.qr_code.present?
rescue StandardError => e
Rails.logger.warn "Failed to use ticket QR code: #{e.message}"
end
# Final fallback: simple ticket identifier
"TICKET-#{ticket.id}"
end
end

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#create</h1>
<p>Find me in app/views/admin/payouts/create.html.erb</p>

View File

@@ -0,0 +1,78 @@
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Admin Payouts</h1>
</div>
<% if @payouts.any? %>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @payouts.each do |payout| %>
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<% case payout.status %>
<% when 'pending' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
Pending
</span>
<% when 'processing' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
Processing
</span>
<% when 'completed' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Completed
</span>
<% when 'failed' %>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
Failed
</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= payout.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<% if payout.can_process? %>
<%= button_to "Process", admin_payout_path(payout), method: :post,
class: "text-indigo-600 hover:text-indigo-900 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded" %>
<% end %>
<%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 ml-2" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% if @payouts.respond_to?(:total_pages) %>
<div class="mt-6">
<%= paginate @payouts %>
</div>
<% end %>
<% else %>
<div class="bg-white rounded-lg shadow p-6 text-center">
<p class="text-gray-500">No payouts found.</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#new</h1>
<p>Find me in app/views/admin/payouts/new.html.erb</p>

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#show</h1>
<p>Find me in app/views/admin/payouts/show.html.erb</p>

View File

@@ -0,0 +1,46 @@
<%# Dynamic breadcrumb navigation component %>
<%# Usage: render 'components/breadcrumb', crumbs: [ %>
<%# { name: 'Home', path: root_path }, %>
<%# { name: 'Events', path: events_path }, %>
<%# { name: 'Current Event', path: nil } %>
<%# ] %>
<!-- Breadcrumb -->
<nav class="w-full bg-white px-3 sm:px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-6 sm:mb-8 overflow-hidden" aria-label="Breadcrumb">
<div class="flex items-center gap-1 sm:gap-2 min-w-0">
<% crumbs.each_with_index do |crumb, index| %>
<% if crumb[:path].present? %>
<%# Crumb with link %>
<%= link_to crumb[:path], class: "inline-flex items-center text-xs sm:text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200 flex-shrink-0" do %>
<% if index == 0 %>
<i data-lucide="home" class="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0"></i>
<% end %>
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
<%= crumb[:name] %>
</span>
<% end %>
<% else %>
<%# Current page (no link) %>
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
<%= crumb[:name] %>
</span>
<% end %>
<%# Separator (except for the last item) %>
<% if index < crumbs.length - 1 %>
<% if index == 0 || index >= crumbs.length - 2 %>
<i data-lucide="chevron-right" class="w-3 h-3 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0"></i>
<% else %>
<span class="hidden sm:inline">
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400 flex-shrink-0"></i>
</span>
<% end %>
<% end %>
<% end %>
<%# Show ellipsis on mobile when there are more than 3 items %>
<% if crumbs.length > 3 %>
<span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
<% end %>
</div>
</nav>

View File

@@ -0,0 +1,17 @@
<!-- Delete Account Section -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Supprimer mon compte</h3>
<p class="text-gray-600 mb-6">
Vous êtes certain de vouloir supprimer votre compte ? Cette action est irréversible.
</p>
<%= button_to registration_path(resource_name),
data: {
confirm: "Êtes-vous certain ?",
turbo_confirm: "Êtes-vous certain ?"
},
method: :delete,
class: "group relative w-full flex justify-center items-center py-3 px-4 border border-red-300 text-sm font-semibold rounded-xl text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer mon compte
<% end %>
</div>

View File

@@ -3,8 +3,8 @@
<div class="container">
<div class="event-finder">
<div class="finder-header">
<h2 class="finder-title">Find Your Perfect Event</h2>
<p class="finder-subtitle">Discover afterwork events tailored to your preferences</p>
<h2 class="finder-title">Trouvez votre événement parfait</h2>
<p class="finder-subtitle">Découvrez des événements afterwork adaptés à vos préférences</p>
</div>
<form class="finder-form">
@@ -19,10 +19,10 @@
<div class="finder-field">
<label class="finder-label">
<i data-lucide="map-pin"></i>
City
Ville
</label>
<select class="finder-select focus-ring" id="event-city">
<option value="">Choose a city</option>
<option value="">Choisissez une ville</option>
<option value="paris">Paris</option>
<option value="london">London</option>
<option value="berlin">Berlin</option>
@@ -37,18 +37,18 @@
<div class="finder-field">
<label class="finder-label">
<i data-lucide="users"></i>
Event Type
Type d'événement
</label>
<select class="finder-select focus-ring" id="event-type">
<option value="">All types</option>
<option value="networking">Networking</option>
<option value="">Tous les types</option>
<option value="networking">Réseautage</option>
<option value="tech">Tech & Innovation</option>
<option value="creative">Creative & Design</option>
<option value="business">Business</option>
<option value="creative">Créatif & Design</option>
<option value="business">Affaires</option>
<option value="startup">Startup</option>
<option value="wine">Wine & Tasting</option>
<option value="wine">Vin & Dégustation</option>
<option value="art">Art & Culture</option>
<option value="music">Music & Entertainment</option>
<option value="music">Musique & Divertissement</option>
</select>
</div>
@@ -58,14 +58,14 @@
<div class="price-range-label">
<span>
<i data-lucide="euro"></i>
Price Range
Fourchette de prix
</span>
<span class="price-value" id="price-display">€0 - €100</span>
</div>
</label>
<div style="display: flex; gap: var(--space-3); align-items: center;">
<input type="range" class="price-slider" id="price-min" min="0" max="100" value="0" style="flex: 1;">
<span style="color: var(--color-neutral-500); font-weight: 600;">to</span>
<span style="color: var(--color-neutral-500); font-weight: 600;">à</span>
<input type="range" class="price-slider" id="price-max" min="0" max="100" value="100" style="flex: 1;">
</div>
</div>
@@ -73,7 +73,7 @@
<button type="submit" class="finder-search-btn">
<i data-lucide="search"></i>
Find Events
Trouver des événements
</button>
</form>
</div>
@@ -81,7 +81,7 @@
</section>
<script>
// Event Finder Functionality
// Fonctionnalité de recherche d'événements
document.addEventListener("DOMContentLoaded", function() {
const priceMin = document.getElementById('price-min');
const priceMax = document.getElementById('price-max');
@@ -134,18 +134,18 @@
priceMax: priceMax ? priceMax.value : ''
};
console.log('Search filters:', formData);
console.log('Filtres de recherche :', formData);
// Add loading state to button
const searchBtn = document.querySelector('.finder-search-btn');
if (searchBtn) {
const originalText = searchBtn.innerHTML;
searchBtn.innerHTML = '<div style="width: 20px; height: 20px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> Searching...';
searchBtn.innerHTML = '<div style="width: 20px; height: 20px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div> Recherche...';
// Simulate search
setTimeout(() => {
searchBtn.innerHTML = originalText;
alert('Search completed! Results would be displayed here.');
alert('Recherche terminée ! Les résultats seraient affichés ici.');
}, 2000);
}
});

View File

@@ -1,41 +1,92 @@
<div class="grid gap-6 mb-6 md:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-8 mb-8 md:grid-cols-2 lg:grid-cols-4">
<div>
<h3 class="font-bold text-lg text-white mb-3">Events</h3>
<ul class="space-y-2">
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Find Events</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Host an Event</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Event Categories</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Premium Events</a></li>
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
<i data-lucide="info" class="w-5 h-5 mr-2 text-yellow-400"></i>
À propos
</h3>
<ul class="space-y-3">
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="briefcase" class="w-4 h-4 mr-2"></i>
Je suis organisateur
</a></li>
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Communauté
</a></li>
</ul>
</div>
<div>
<h3 class="font-bold text-lg text-white mb-3">Community</h3>
<ul class="space-y-2">
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Join Us</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Member Benefits</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Success Stories</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Ambassador Program</a></li>
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
<i data-lucide="map-pin" class="w-5 h-5 mr-2 text-yellow-400"></i>
Villes
</h3>
<ul class="space-y-3">
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="map" class="w-4 h-4 mr-2"></i>
Paris
</a></li>
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="map" class="w-4 h-4 mr-2"></i>
Lyon (bientôt)
</a></li>
</ul>
</div>
<div>
<h3 class="font-bold text-lg text-white mb-3">Support</h3>
<ul class="space-y-2">
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Help Center</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Contact Us</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Safety Guidelines</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Cancellation Policy</a></li>
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
<i data-lucide="calendar" class="w-5 h-5 mr-2 text-yellow-400"></i>
Événements
</h3>
<ul class="space-y-3">
<li><a href="<%= events_path %>" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="glass-water" class="w-4 h-4 mr-2"></i>
Afterworks
</a></li>
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="music" class="w-4 h-4 mr-2"></i>
Concerts
</a></li>
</ul>
</div>
<div>
<h3 class="font-bold text-lg text-white mb-3">Company</h3>
<ul class="space-y-2">
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">About Aperonight</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Careers</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Press & Media</a></li>
<li><a href="#" class="text-neutral-400 text-sm hover:text-accent-400 transition-colors duration-300">Partner With Us</a></li>
<h3 class="font-bold text-lg text-white mb-4 flex items-center">
<i data-lucide="help-circle" class="w-5 h-5 mr-2 text-yellow-400"></i>
Support
</h3>
<ul class="space-y-3">
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="life-buoy" class="w-4 h-4 mr-2"></i>
Aide
</a></li>
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="mail" class="w-4 h-4 mr-2"></i>
Nous contacter
</a></li>
<li><a href="#" class="text-gray-400 text-sm hover:text-yellow-400 transition-colors duration-300 flex items-center">
<i data-lucide="flag" class="w-4 h-4 mr-2"></i>
Signaler un contenu
</a></li>
</ul>
</div>
</div>
<div class="border-t border-neutral-700 pt-4 text-center text-neutral-400 text-sm">
<p>&copy; 2024 Aperonight. All rights reserved. • <a href="#" class="text-accent-400 hover:text-accent-300 transition-colors">Privacy Policy</a> • <a href="#" class="text-accent-400 hover:text-accent-300 transition-colors">Terms of Service</a></p>
<div class="border-t border-gray-700 pt-6 text-center">
<div class="flex flex-col sm:flex-row justify-between items-center">
<p class="text-gray-400 text-sm mb-4 sm:mb-0">
&copy; 2025 Aperonight. Tous droits réservés.
</p>
<div class="flex space-x-6">
<a href="#" class="text-gray-400 hover:text-yellow-400 transition-colors text-sm">
Politique de confidentialité
</a>
<a href="#" class="text-gray-400 hover:text-yellow-400 transition-colors text-sm">
Conditions d'utilisation
</a>
<a href="#" class="text-gray-400 hover:text-yellow-400 transition-colors text-sm">
Mentions légales
</a>
</div>
</div>
</div>

View File

@@ -1,18 +1,40 @@
<header class="bg-neutral-800 border-b border-neutral-700">
<header class="bg-white shadow-lg border-b border-gray-200 sticky top-0 z-50">
<nav data-controller="header" class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center h-16 justify-between">
<!-- Logo -->
<div class="flex-shrink-0 flex items-center">
<%= link_to Rails.application.config.app_name, current_user ? "/dashboard" : "/",
class: "text-xl font-bold text-white" %>
<%= link_to Rails.application.config.app_name, "/",
class: "text-2xl font-display font-bold text-gray-900 hover:text-brand-primary transition-colors" %>
</div>
<!-- Desktop Navigation -->
<div class="hidden sm:flex items-center space-x-6 w-full justify-start">
<%= link_to t("header.parties"), events_path,
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
<%= link_to t("header.concerts"), "#",
class: "mx-4 text-gray-100 hover:text-purple-200 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
<div class="hidden sm:flex items-center space-x-8 w-full justify-center">
<%= link_to events_path,
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
Événements & Afterworks
<% end %>
<%= link_to dashboard_path,
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
Tableau de bord
<% end %>
<% if user_signed_in? && current_user.promoter? %>
<%= link_to new_promoter_event_path,
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
Créer un événement
<% end %>
<%= link_to promoter_events_path,
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
Mes événements
<% end %>
<% end %>
<!-- <%= link_to "#",
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
Concerts
<% end %> -->
</div>
<!-- Authentication -->
@@ -20,65 +42,147 @@
<% if user_signed_in? %>
<div class="relative" data-header-target="userMenuButton">
<button data-action="click->header#toggleUserMenu"
class="bg-purple-700 text-white border border-purple-800 font-medium py-2 px-4 rounded-lg hover:bg-purple-800 transition-colors duration-200 flex items-center space-x-2">
class="bg-gray-900 text-white font-medium py-2 px-4 rounded-full hover:bg-gray-800 transition-colors duration-200 flex items-center space-x-2">
<span><%= current_user.email.length > 20 ? current_user.email[0,20] + "..." : current_user.email %></span>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<i data-lucide="chevron-down" class="w-4 h-4"></i>
</button>
<!-- User Dropdown Menu -->
<div data-header-target="userMenu" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-2xl shadow-xl border border-gray-100 py-2 z-50">
<div class="px-4 py-3 text-sm text-gray-900 border-b border-gray-100">
<div class="font-semibold"><%= current_user.first_name || current_user.email %></div>
<div class="text-gray-500"><%= current_user.email %></div>
</div>
<%= link_to "#",
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
Réservations
<% end %>
<%= link_to settings_path,
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="user" class="w-4 h-4 mr-3"></i>
Profil
<% end %>
<%= link_to edit_user_registration_path,
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="key" class="w-4 h-4 mr-3"></i>
Sécurité
<% end %>
<div class="border-t border-gray-100">
<%= link_to destroy_user_session_path,
data: { controller: "logout", action: "click->logout#signOut", logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false },
class: "flex items-center px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="log-out" class="w-4 h-4 mr-3"></i>
Déconnexion
<% end %>
</div>
</div>
</div>
<% else %>
<%= link_to t("header.login"), new_user_session_path,
class: "text-gray-100 hover:text-purple-200 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200" %>
<%= link_to t("header.register"), new_user_registration_path,
class: "bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
<%= link_to new_user_session_path,
class: "text-gray-700 hover:text-brand-primary px-6 py-2 rounded-full text-sm font-medium transition-colors duration-200 whitespace-nowrap" do %>
Se connecter
<% end %>
<%= link_to new_user_registration_path,
class: "bg-gray-900 text-white font-semibold py-2 px-8 rounded-full hover:bg-gray-800 transition-colors duration-200 shadow-lg hover:shadow-xl whitespace-nowrap" do %>
S'inscrire
<% end %>
<% end %>
</div>
<!-- Mobile menu button -->
<div class="flex-shrink-0 sm:hidden">
<button data-action="click->header#toggleMobileMenu" data-header-target="mobileMenuButton" class="p-2 rounded-md text-neutral-300 hover:text-white hover:bg-purple-700">
<svg data-menu-icon="open" class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg data-menu-icon="close" class="h-6 w-6 hidden" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<button data-action="click->header#toggleMobileMenu" data-header-target="mobileMenuButton" class="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100">
<i data-menu-icon="open" data-lucide="menu" class="w-6 h-6"></i>
<i data-menu-icon="close" data-lucide="x" class="w-6 h-6 hidden"></i>
</button>
</div>
</div>
<!-- Mobile Menu -->
<div data-header-target="mobileMenu" class="hidden sm:hidden">
<div class="px-2 pt-2 pb-3 space-y-1">
<%= link_to t("header.parties"), events_path,
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
<%= link_to t("header.concerts"), "#",
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
<div data-header-target="mobileMenu" class="hidden sm:hidden border-t border-gray-200">
<div class="px-4 pt-4 pb-3 space-y-2">
<%= link_to events_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
Événements & Afterworks
<% end %>
<%= link_to dashboard_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="bar-chart-3" class="w-4 h-4 mr-3"></i>
Tableau de bord
<% end %>
<% if user_signed_in? && current_user.promoter? %>
<%= link_to new_promoter_event_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="plus-circle" class="w-4 h-4 mr-3"></i>
Créer un événement
<% end %>
<%= link_to promoter_events_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="calendar-check" class="w-4 h-4 mr-3"></i>
Mes événements
<% end %>
<% end %>
<!-- <%= link_to events_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="glass-water" class="w-4 h-4 mr-3"></i>
Afterworks
<% end %> -->
<!-- <%= link_to "#",
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="music" class="w-4 h-4 mr-3"></i>
Concerts
<% end %> -->
</div>
<div class="pt-4 pb-3 border-t border-gray-700">
<div class="pt-4 pb-4 border-t border-gray-200">
<% if user_signed_in? %>
<div class="px-4 mb-3">
<div class="text-base font-medium text-white">
<div class="text-base font-semibold text-gray-900">
<%= current_user.first_name || current_user.email %>
</div>
<div class="text-sm text-gray-500"><%= current_user.email %></div>
</div>
<div class="px-2 space-y-1">
<%= link_to t("header.profile"), edit_user_registration_path,
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
<%= link_to t("header.reservations"), "#",
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
<%= link_to t("header.logout"), destroy_user_session_path,
<div class="px-4 space-y-2">
<%= link_to "#",
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
Réservations
<% end %>
<%= link_to settings_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="user" class="w-4 h-4 mr-3"></i>
Profil
<% end %>
<%= link_to edit_user_registration_path,
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="key" class="w-4 h-4 mr-3"></i>
Sécurité
<% end %>
<%= link_to destroy_user_session_path,
data: { controller: "logout", action: "click->logout#signOut", logout_url_value: destroy_user_session_path, login_url_value: new_user_session_path, turbo: false },
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="log-out" class="w-4 h-4 mr-3"></i>
Déconnexion
<% end %>
</div>
<% else %>
<div class="px-2 space-y-1">
<%= link_to t("header.login"), new_user_session_path,
class: "block px-3 py-2 rounded-md text-base font-medium text-gray-100 hover:text-purple-200 hover:bg-purple-700" %>
<%= link_to t("header.register"), new_user_registration_path,
class: "block px-3 py-2 rounded-md text-base font-medium bg-purple-600 text-white hover:bg-purple-700" %>
<div class="px-4 space-y-2">
<%= link_to new_user_session_path,
class: "flex items-center justify-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
<i data-lucide="log-in" class="w-4 h-4 mr-3"></i>
Se connecter
<% end %>
<%= link_to new_user_registration_path,
class: "flex items-center justify-center px-3 py-2 rounded-lg text-base font-semibold bg-gray-900 text-white hover:bg-gray-800" do %>
<i data-lucide="user-plus" class="w-4 h-4 mr-3"></i>
S'inscrire
<% end %>
</div>
<% end %>
</div>

View File

@@ -7,7 +7,7 @@
</div>
<div class="text-right">
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
<%= number_to_currency(price_cents / 100.0, unit: "€") %>
<%= format_ticket_price(price_cents) %>
</p>
</div>
</div>
@@ -16,17 +16,13 @@
<div class="<%= 'order-2 sm:order-1' unless sold_out %>">
<% if sold_out %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="-ml-0.5 mr-1 h-2 w-2 text-red-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
<i data-lucide="x" class="w-2 h-2 mr-1 text-red-400"></i>
Épuisé
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="-ml-0.5 mr-1 h-2 w-2 text-green-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
<%= remaining %> disponibles
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-50 text-gray-600 border border-gray-200">
<i data-lucide="ticket" class="w-3 h-3 mr-1 text-green-500"></i>
<%= remaining %>
</span>
<% end %>
</div>
@@ -59,9 +55,7 @@
</div>
<% else %>
<div class="text-sm text-gray-500 font-medium order-1 sm:order-2">
<svg class="w-5 h-5 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<i data-lucide="lock" class="w-5 h-5 inline-block mr-1"></i>
Indisponible
</div>
<% end %>

View File

@@ -1,34 +1,52 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
<%= t('devise.confirmations.new.title') %>
</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
<h2 class="text-3xl font-bold text-gray-900"><%= t('devise.confirmations.new.title') %></h2>
<p class="mt-2 text-gray-600">
<%= t('devise.confirmations.new.description') %>
</p>
</div>
<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: "space-y-6" }) do |f| %>
<div class="space-y-5">
<div>
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
<div class="mt-1">
<%= f.label :email, class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email),
class: "appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm",
placeholder: "Email" %>
placeholder: "votre@email.com",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div>
<%= f.submit t('devise.confirmations.new.submit'),
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
<%= t('devise.confirmations.new.submit') %>
<% end %>
</div>
<% end %>
<%= render "devise/shared/links" %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center">
<p class="text-sm text-gray-600">
Vous vous souvenez de votre mot de passe ?
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,43 +1,69 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
<%= t('devise.passwords.edit.title') %>
</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
<h2 class="text-3xl font-bold text-gray-900"><%= t('devise.passwords.edit.title') %></h2>
<p class="mt-2 text-gray-600">
<%= t('devise.passwords.edit.description') %>
</p>
</div>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: "mt-8 space-y-6" }) do |f| %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
<%= f.hidden_field :reset_password_token %>
<div class="space-y-4">
<div class="space-y-5">
<div>
<%= f.label :password, t('devise.passwords.edit.new_password'), class: "block text-sm font-medium text-neutral-700" %>
<%= f.label :password, t('devise.passwords.edit.new_password'), class: "block text-sm font-semibold text-gray-700 mb-2" %>
<% if @minimum_password_length %>
<em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em>
<p class="text-xs text-gray-500 mb-2">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</p>
<% end %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password, autofocus: true, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
placeholder: "Votre nouveau mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
<div>
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %>
<%= f.label :password_confirmation, t('devise.passwords.edit.confirm_new_password'), class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password_confirmation, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
placeholder: "Confirmez votre nouveau mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div class="actions">
<%= f.submit t('devise.passwords.edit.submit'),
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
<%= t('devise.passwords.edit.submit') %>
<% end %>
</div>
<% end %>
<%= render "devise/shared/links" %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center">
<p class="text-sm text-gray-600">
Vous vous souvenez de votre mot de passe ?
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,45 +1,51 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
Mot de passe oublié ?
</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
<h2 class="text-3xl font-bold text-gray-900">Mot de passe oublié ?</h2>
<p class="mt-2 text-gray-600">
Entrez votre adresse email ci-dessous et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
</div>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: "mt-8 space-y-6" }) do |f| %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: "space-y-6" }) do |f| %>
<div class="space-y-5">
<div>
<%= f.label :email, "Adresse Email", class: "block text-sm font-medium text-neutral-700" %>
<div class="mt-1">
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
class: "appearance-none block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm",
placeholder: t('devise.passwords.new.email_placeholder') %>
placeholder: "votre@email.com",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div>
<%= f.submit "Envoyer le lien de réinitialisation",
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
Envoyer le lien de réinitialisation
<% end %>
</div>
<% end %>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
</div>
</div>
<%= render "devise/shared/links" %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center">
<p class="text-sm text-gray-600">
Vous vous souvenez de votre mot de passe ?
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,70 +1,111 @@
<div class="min-h-screen flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl w-full space-y-8">
<div>
<div class="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto space-y-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Paramètres', path: settings_path },
{ name: 'Modifier le compte', path: nil }
] %>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6 mx-auto">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
Modifier votre compte
</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
Gérez vos informations et préférences
<h2 class="text-3xl font-bold text-gray-900">Modifier vos informations de sécurité</h2>
<p class="mt-2 text-gray-600">
Gérez vos informations et préférences de sécurité
</p>
</div>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "mt-8 space-y-6" }) do |f| %>
<!-- Profile Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<h3 class="text-xl font-semibold text-gray-900 mb-6">Informations du compte</h3>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="space-y-6">
<div class="space-y-5">
<div>
<%= f.label :email, class: "block text-sm font-medium text-neutral-700" %>
<%= f.label :email, class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div class="text-sm text-neutral-600">
<div class="text-sm text-gray-600 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<i data-lucide="alert-circle" class="w-5 h-5 text-yellow-500 inline mr-2"></i>
En attente de confirmation pour : <%= resource.unconfirmed_email %>
</div>
<% end %>
<div>
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-medium text-neutral-700" %>
<i class="text-sm text-neutral-500">(laissez vide si vous ne souhaitez pas le changer)</i>
<%= f.password_field :password, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="key" class="w-5 h-5 text-gray-400"></i>
</div>
<div>
<%= f.label :password_confirmation, t('devise.registrations.edit.confirm_new_password'), class: "block text-sm font-medium text-neutral-700" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
</div>
<div>
<%= f.label :current_password, t('devise.registrations.edit.current_password'), class: "block text-sm font-medium text-neutral-700" %>
<i class="text-sm text-neutral-500">(<%= t('devise.registrations.edit.current_password_required') %>)</i>
<%= f.password_field :current_password, autocomplete: "current-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
placeholder: "Requis pour confirmer vos changements",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
<p class="mt-2 text-sm text-gray-500">Requis pour confirmer vos changements</p>
</div>
</div>
<div class="flex items-center justify-between">
<%= f.submit t('devise.registrations.edit.update'),
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password, autocomplete: "new-password",
placeholder: "Laisser vide si vous ne souhaitez pas le changer",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
<% if @minimum_password_length %>
<p class="mt-2 text-sm text-gray-500"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
<% end %>
</div>
<div>
<%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password_confirmation, autocomplete: "new-password",
placeholder: "Confirmez votre nouveau mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
Mettre à jour
<% end %>
</div>
<% end %>
</div>
<h3 class="text-center text-lg font-medium text-neutral-900"><%= t('devise.registrations.edit.delete_account') %></h3>
<%# render "components/delete_account" %>
<!-- Back Link -->
<div class="text-center">
<p class="text-sm text-neutral-600">
<%= t('devise.registrations.edit.unhappy') %> <%= button_to t('devise.registrations.edit.delete_account'), registration_path(resource_name),
data: { confirm: t('devise.registrations.edit.confirm_delete'), turbo_confirm: t('devise.registrations.edit.confirm_delete') },
method: :delete,
class: "font-medium text-red-600 hover:text-red-500" %>
</p>
</div>
<%= link_to t('devise.registrations.edit.back'), :back, class: "text-center block text-purple-600 hover:text-purple-500" %>
<%= link_to :back, class: "inline-flex items-center text-purple-600 hover:text-purple-500 transition-colors" do %>
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
Retour
<% end %>
</div>
</div>
</div>

View File

@@ -1,58 +1,91 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">Créer un compte</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
ou <a href="<%= new_user_session_path %>" class="font-medium text-purple-600 hover:text-purple-500">se connecter à votre compte</a>
<h2 class="text-3xl font-bold text-gray-900">Créer un compte</h2>
<p class="mt-2 text-gray-600">
ou <a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">se connecter à votre compte existant</a>
</p>
</div>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "mt-8 space-y-6" }) do |f| %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "space-y-6" }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="space-y-4">
<div class="space-y-5">
<div>
<%= f.label :email, "Adresse email", class: "block text-sm font-medium text-neutral-700" %>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
</div>
<div>
<%= f.label :password, "Mot de passe", class: "block text-sm font-medium text-neutral-700" %>
<% if @minimum_password_length %>
<em class="text-sm text-neutral-500">(<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>)</em>
<% end %>
<%= f.password_field :password, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
</div>
<div>
<%= f.label :password_confirmation, "Confirmation du mot de passe", class: "block text-sm font-medium text-neutral-700" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password",
class: "mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm placeholder-neutral-500 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm" %>
</div>
</div>
<div class="acthons">
<%= f.submit "Créer un compte", class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
</div>
<% end %>
<div class="mt-6">
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300"></div>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
placeholder: "votre@email.com",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
<div class="mt-4">
<%= render "devise/shared/links" %>
<div>
<%= f.label :password, "Mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<% if @minimum_password_length %>
<p class="text-xs text-gray-500 mb-2"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
<% end %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password, autocomplete: "new-password",
placeholder: "Votre mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
<div>
<%= f.label :password_confirmation, "Confirmation du mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password_confirmation, autocomplete: "new-password",
placeholder: "Confirmez votre mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="user-plus" class="w-4 h-4 mr-2"></i>
Créer un compte
<% end %>
</div>
<% end %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center">
<p class="text-sm text-gray-600">
Vous avez déjà un compte?
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center">
<p class="text-xs text-gray-500">
En créant un compte, vous acceptez nos
<a href="#" class="text-purple-600 hover:text-purple-500">conditions d'utilisation</a>
et notre
<a href="#" class="text-purple-600 hover:text-purple-500">politique de confidentialité</a>.
</p>
</div>
</div>
</div>

View File

@@ -1,64 +1,74 @@
<div class="flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<img class="mx-auto h-12 w-auto" src="/icon.svg" alt="Aperonight" />
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="mt-6 text-center text-3xl font-extrabold text-neutral-900">
Se connecter à votre compte
</h2>
<p class="mt-2 text-center text-sm text-neutral-600">
ou
<a href="<%= new_user_registration_path %>" class="font-medium text-purple-600 hover:text-purple-500">
créer un compte
</a>
<h2 class="text-3xl font-bold text-gray-900">Connexion à votre compte</h2>
<p class="mt-2 text-gray-600">
ou <a href="<%= new_user_registration_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">créer un compte</a>
</p>
</div>
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "mt-8 space-y-6" }) do |f| %>
<div class="rounded-md shadow-sm -space-y-px">
<div class="field">
<%= f.label :email, "Email", class: "sr-only" %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "space-y-6" }) do |f| %>
<div class="space-y-5">
<div>
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
class: "appearance-none rounded-none relative block w-full px-3 py-2 border border-neutral-300 placeholder-neutral-500 text-neutral-900 bg-white rounded-t-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm",
placeholder: "Adresse email" %>
placeholder: "votre@email.com",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
<div class="field">
<%= f.label :password, "Mot de passe", class: "sr-only" %>
<div>
<%= f.label :password, "Mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.password_field :password, autocomplete: "current-password",
class: "appearance-none rounded-none relative block w-full px-3 py-2 border border-neutral-300 placeholder-neutral-500 text-neutral-900 bg-white rounded-b-md focus:outline-none focus:ring-purple-500 focus:border-purple-500 focus:z-10 sm:text-sm",
placeholder: "Mot de passe" %>
placeholder: "Votre mot de passe",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<% if devise_mapping.rememberable? %>
<div class="flex items-center justify-between">
<div class="flex items-center">
<%= f.check_box :remember_me, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-neutral-300 rounded bg-white" %>
<label for="user_remember_me" class="ml-2 block text-sm text-neutral-700"> Se souvenir de moi </label>
</div>
<%= f.check_box :remember_me, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded bg-white" %>
<%= f.label :remember_me, "Se souvenir de moi", class: "ml-2 block text-sm text-gray-700" %>
</div>
<% end %>
<div class="actions">
<%= f.submit "Se connecter", class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-50 focus:ring-purple-500" %>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="log-in" class="w-4 h-4 mr-2"></i>
Se connecter
<% end %>
</div>
<% end %>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-neutral-50 text-neutral-600">Continuer avec</span>
</div>
</div>
<%= render "devise/shared/links" %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center space-y-3">
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to "Mot de passe oublié ?", new_password_path(resource_name), class: "text-sm text-purple-600 hover:text-purple-500 transition-colors" %>
<% end %>
<p class="text-sm text-gray-600">
Vous n'avez pas encore de compte?
<a href="<%= new_user_registration_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">S'inscrire</a>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,20 @@
<% if resource.errors.any? %>
<div class="bg-red-50 border border-red-200 text-red-800 rounded-xl p-4 mb-6" data-controller="flash-message">
<div class="flex items-start">
<div class="flex-shrink-0">
<i data-lucide="x-circle" class="w-5 h-5"></i>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium mb-2">Veuillez corriger les erreurs suivantes :</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<% resource.errors.full_messages.each do |message| %>
<% flash.now[:error] = message %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<button data-action="click->flash-message#close" class="bg-transparent border-none cursor-pointer p-1 text-inherit opacity-70 transition-opacity duration-200">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
<% end %>

View File

@@ -1,39 +1,58 @@
<div class="mt-4 space-y-4">
<%- if controller_name != "sessions" %>
<div class="w-full flex justify-center py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= link_to "Se connecter", new_session_path(resource_name), class: "block" %>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Ou continuer avec</span>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-3">
<%- if controller_name != "sessions" %>
<%= link_to new_session_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="log-in" class="w-4 h-4 mr-2"></i>
Se connecter
<% end %>
<% end %>
<%- if devise_mapping.registerable? && controller_name != "registrations" %>
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= link_to "Créer un compte", new_registration_path(resource_name), class: "block" %>
</div>
<%= link_to new_registration_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="user-plus" class="w-4 h-4 mr-2"></i>
S'inscrire
<% end %>
<% end %>
<%- if devise_mapping.recoverable? && controller_name != "passwords" && controller_name != "registrations" %>
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= link_to "Mot de passe oublié ?", new_password_path(resource_name), class: "block" %>
</div>
<div class="mt-4 grid grid-cols-1 gap-3">
<%- if devise_mapping.recoverable? && controller_name != "passwords" && controller_name != "registrations" %>
<%= link_to new_password_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="help-circle" class="w-4 h-4 mr-2"></i>
Mot de passe oublié ?
<% end %>
<% end %>
<%- if devise_mapping.confirmable? && controller_name != "confirmations" %>
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= link_to "Renvoyer le lien de confirmation", new_confirmation_path(resource_name), class: "block" %>
</div>
<%= link_to new_confirmation_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="mail" class="w-4 h-4 mr-2"></i>
Renvoyer le lien de confirmation
<% end %>
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != "unlocks" %>
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= link_to "Renvoyer le lien de déblocage", new_unlock_path(resource_name), class: "block" %>
</div>
<%= link_to new_unlock_path(resource_name), class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="unlock" class="w-4 h-4 mr-2"></i>
Renvoyer le lien de déblocage
<% end %>
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<div class="w-full flex justify-center mt-4 py-2 px-4 border border-neutral-300 rounded-md shadow-sm text-sm font-medium text-purple-600 bg-white hover:bg-neutral-50 hover:border-purple-500 transition-all duration-200">
<%= button_to "Se connecter avec #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "block" %>
</div>
<%= button_to omniauth_authorize_path(resource_name, provider), data: { turbo: false }, class: "w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-all duration-200" do %>
<i data-lucide="external-link" class="w-4 h-4 mr-2"></i>
Se connecter avec <%= OmniAuth::Utils.camelize(provider) %>
<% end %>
<% end %>
<% end %>
</div>
</div>

View File

@@ -1,15 +1,52 @@
<h2>Resend unlock instructions</h2>
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- Header -->
<div class="text-center">
<%= link_to "/" do %>
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl mb-6">
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
</div>
<% end %>
<h2 class="text-3xl font-bold text-gray-900">Renvoyer les instructions de déverrouillage</h2>
<p class="mt-2 text-gray-600">
Entrez votre adresse email et nous vous enverrons les instructions de déverrouillage
</p>
</div>
<div class="actions">
<%= f.submit "Resend unlock instructions" %>
<!-- Form -->
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: "space-y-6" }) do |f| %>
<div class="space-y-5">
<div>
<%= f.label :email, "Adresse email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="mail" class="w-5 h-5 text-gray-400"></i>
</div>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
placeholder: "votre@email.com",
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
</div>
</div>
</div>
<div class="pt-4">
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
<i data-lucide="send" class="w-4 h-4 mr-2"></i>
Renvoyer les instructions de déverrouillage
<% end %>
</div>
<% end %>
<%= render "devise/shared/links" %>
<!-- Additional Links -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="text-center">
<p class="text-sm text-gray-600">
Vous vous souvenez de votre mot de passe ?
<a href="<%= new_user_session_path %>" class="font-semibold text-purple-600 hover:text-purple-500 transition-colors">Se connecter</a>
</p>
</div>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More